Skip to content

Commit a21695a

Browse files
committed
fixed regression bug which was inhibiting edge creation on wall
Signed-off-by: Mark Rai <markraidc@gmail.com>
1 parent 044e493 commit a21695a

15 files changed

Lines changed: 296 additions & 28 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** / **3.11.x** / **3.12.x** / **3.13.x** / **3.14.x** / **3.15.x** / **3.16.x** / **3.17.x** unless noted below.
44
5+
## [3.17.6] - 2026-06-02
6+
7+
### Fixed
8+
9+
- **Wall edge lines in Chrome/Edge** - Shift-drag connections were created but invisible in Chromium: the edge SVG overlay lived inside the 0×0 `.wall-content` transform anchor with `width/height: 100%` (== 0), so lines never painted. The overlay now uses a non-zero box with `overflow: visible`.
10+
11+
### Improvements
12+
13+
- **Wall zoom modifier** - **Shift**+scroll is now the primary zoom control on the wall (Ctrl/Cmd+scroll and trackpad pinch still zoom).
14+
- **Wall canvas mode preference** - The Select/Pan toggle is remembered globally in the browser and applies to every project's wall.
15+
16+
### Documentation
17+
18+
- **`docs/wall.md`** - Shift+scroll zoom, global canvas-mode preference.
19+
520
## [3.17.5] - 2026-06-02
621

722
### Improvements

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.17.5-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.17.6-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>

docs/wall.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ Sticky-note board for durable projects. Open it from the board topbar (desktop o
2121
- **Exit multi-select on canvas** - **Click** empty space (no drag).
2222
- **Move a group** - With multiple notes selected, drag one of them; all selected notes move together. Selection clears when you release the drag.
2323
- **Delete several at once** - Drag the group over the trash; one confirmation lists how many notes will be deleted.
24-
- **Canvas mode toggle** - The button left of **Fit view** switches between **Select** mode (dashed-square icon) and **Pan** mode (hand icon). The wall always opens in Select mode.
24+
- **Canvas mode toggle** - The button left of **Fit view** switches between **Select** mode (dashed-square icon) and **Pan** mode (hand icon). Your choice is remembered globally in the browser and applies to every project's wall (defaults to Select until you change it).
2525
- **Select mode** - Empty-canvas drag draws the marquee box to select notes.
2626
- **Pan mode** - Empty-canvas mouse drag or touch swipe pans the wall. Two-finger touch pinch zooms the wall.
2727
- **Pan the canvas** - Scroll wheel, **middle-mouse drag**, hold **Space** and drag on empty canvas, or use the **arrow keys** (hold **Shift** for larger steps).
28-
- **Zoom** - **Ctrl**+scroll (Windows/Linux) or ****+scroll (Mac). Pinch-to-zoom on trackpads uses the same modifier.
28+
- **Zoom** - **Shift**+scroll. **Ctrl**+scroll (Windows/Linux) or ****+scroll (Mac) also zoom, and pinch-to-zoom on trackpads uses that modifier.
2929
- **Fit view** - Click the **** button (top-right, beside close) or press **F** while the wall is open. Recenters on all notes (or origin when empty). Your pan/zoom per board is remembered in the browser.
3030

3131
## Disabling the wall
Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,53 @@
1+
// Global (not per-project) canvas-mode preference. The wall is per-project but
2+
// the Select/Pan toggle is remembered across every board's wall via a single
3+
// localStorage key. Mirrors the resilient try/catch storage pattern used by
4+
// wall-viewport.ts.
5+
const CANVAS_MODE_STORAGE_KEY = "scrumboy.wall.canvasMode";
16
let wallCanvasMode = "select";
27
export function getWallCanvasMode() {
38
return wallCanvasMode;
49
}
10+
/** Coerce arbitrary input to a valid mode; anything but "pan" falls to "select". */
11+
export function normalizeWallCanvasMode(raw) {
12+
return raw === "pan" ? "pan" : "select";
13+
}
14+
function saveWallCanvasMode(mode) {
15+
try {
16+
localStorage.setItem(CANVAS_MODE_STORAGE_KEY, mode);
17+
}
18+
catch {
19+
// private mode / quota / disabled — ignore
20+
}
21+
}
522
export function setWallCanvasMode(mode) {
6-
wallCanvasMode = mode === "pan" ? "pan" : "select";
23+
wallCanvasMode = normalizeWallCanvasMode(mode);
24+
saveWallCanvasMode(wallCanvasMode);
725
}
826
export function toggleWallCanvasMode() {
927
wallCanvasMode = wallCanvasMode === "select" ? "pan" : "select";
28+
saveWallCanvasMode(wallCanvasMode);
1029
return wallCanvasMode;
1130
}
1231
export function isWallPanMode() {
1332
return wallCanvasMode === "pan";
1433
}
34+
/** Load the persisted global preference into memory and return it. */
35+
export function loadWallCanvasMode() {
36+
try {
37+
wallCanvasMode = normalizeWallCanvasMode(localStorage.getItem(CANVAS_MODE_STORAGE_KEY));
38+
}
39+
catch {
40+
wallCanvasMode = "select";
41+
}
42+
return wallCanvasMode;
43+
}
44+
/** Test-only: reset in-memory mode and clear the persisted preference. */
1545
export function resetWallCanvasMode() {
1646
wallCanvasMode = "select";
47+
try {
48+
localStorage.removeItem(CANVAS_MODE_STORAGE_KEY);
49+
}
50+
catch {
51+
// ignore
52+
}
1753
}

internal/httpapi/web/dist/dialogs/wall-rendering.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,21 @@ export function ensureEdgeOverlay(surface) {
144144
svg = document.createElementNS(SVG_NS, "svg");
145145
svg.setAttribute("id", EDGE_OVERLAY_ID);
146146
svg.setAttribute("class", "wall-edge-overlay");
147-
// Cover the entire surface; SVG itself ignores pointer events, only
148-
// hit-line children opt back in.
147+
// The overlay lives inside `.wall-content`, which is intentionally 0x0 (it
148+
// is only a transform anchor; notes are absolutely positioned). Edges are
149+
// drawn in canvas coordinates and rely on `overflow: visible` to paint
150+
// outside that box. Chromium will NOT paint SVG geometry that lies outside a
151+
// *zero-sized* outer <svg> viewport (Firefox/WebKit tolerate it), so a
152+
// `width/height: 100%` (== 0 here) overlay renders every connection line
153+
// invisibly in Chrome/Edge. Give the SVG a non-zero box so painting is
154+
// enabled; `overflow: visible` then lets lines extend to any canvas coord
155+
// (including negative) in all engines. See wall-rendering.test.ts.
149156
svg.style.position = "absolute";
150157
svg.style.left = "0";
151158
svg.style.top = "0";
152-
svg.style.width = "100%";
153-
svg.style.height = "100%";
159+
svg.style.width = "1px";
160+
svg.style.height = "1px";
161+
svg.style.overflow = "visible";
154162
svg.style.pointerEvents = "none";
155163
// Postbaby parity: lines paint *under* notes (.wall-note is z-index: 2 in
156164
// styles.css). This overlay uses z-index: 0 via .wall-edge-overlay; a

internal/httpapi/web/dist/dialogs/wall-viewport-nav.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,17 @@ function onWheel(ev) {
152152
return;
153153
ev.preventDefault();
154154
const { dx, dy } = wheelPixels(ev);
155-
if (ev.ctrlKey || ev.metaKey) {
156-
const factor = dy < 0 ? WHEEL_ZOOM_FACTOR : 1 / WHEEL_ZOOM_FACTOR;
155+
// Shift is the primary zoom modifier; Ctrl/Cmd also zoom because browsers
156+
// deliver trackpad pinch-to-zoom as synthetic Ctrl+wheel events.
157+
if (ev.shiftKey || ev.ctrlKey || ev.metaKey) {
158+
// On Windows, Shift+wheel is remapped to horizontal scroll, so the delta
159+
// arrives in dx (dy ~ 0). Fall back to dx so zoom direction stays correct.
160+
const zoomDelta = dy !== 0 ? dy : dx;
161+
// A zero-delta wheel event (some inertia/end events, synthetic devices)
162+
// carries no direction; do nothing rather than zooming out by default.
163+
if (zoomDelta === 0)
164+
return;
165+
const factor = zoomDelta < 0 ? WHEEL_ZOOM_FACTOR : 1 / WHEEL_ZOOM_FACTOR;
157166
zoomAround(ev.clientX, ev.clientY, factor);
158167
return;
159168
}
@@ -429,6 +438,10 @@ export function isSpacePanArmed() {
429438
export function __onArrowKeyDownForTest(ev, isOpen = () => true) {
430439
onArrowKeyDown(ev, isOpen);
431440
}
441+
/** For tests: drive the wheel pan/zoom handler directly. */
442+
export function __onWheelForTest(ev) {
443+
onWheel(ev);
444+
}
432445
/** For tests: whether space-to-pan is armed. */
433446
export function __isSpaceHeldForTest() {
434447
return spaceHeld;

internal/httpapi/web/dist/dialogs/wall.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { beginEdit as beginEditController } from "./wall-edit-controller.js";
3737
import { openWallNoteContextMenu } from "./wall-note-context-menu.js";
3838
import { clampCanvasCoord, ensureWallContent, fitToNotes, getWallContent, initWallViewport, screenToCanvas, teardownWallViewport, } from "./wall-viewport.js";
3939
import { bindWallNavigation, cancelWallNavigationGestures, isSpacePanArmed, } from "./wall-viewport-nav.js";
40-
import { getWallCanvasMode, isWallPanMode, resetWallCanvasMode, toggleWallCanvasMode, } from "./wall-canvas-mode.js";
40+
import { getWallCanvasMode, isWallPanMode, loadWallCanvasMode, toggleWallCanvasMode, } from "./wall-canvas-mode.js";
4141
const TEARDOWN_MARKER = Symbol("wallMounted");
4242
const SELECT_MODE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dashed-icon lucide-square-dashed"><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/></svg>`;
4343
const PAN_MODE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hand-icon lucide-hand"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>`;
@@ -106,7 +106,7 @@ export async function openWallDialog(opts) {
106106
};
107107
setMounted(state);
108108
dialog[TEARDOWN_MARKER] = true;
109-
resetWallCanvasMode();
109+
loadWallCanvasMode();
110110
wallSurface.classList.toggle("wall-surface--readonly", !canEdit);
111111
const content = ensureWallContent(wallSurface);
112112
initWallViewport(wallSurface, content, opts.slug);
@@ -176,7 +176,6 @@ function teardown() {
176176
state.transient.clear();
177177
state.selected.clear();
178178
cancelWallNavigationGestures();
179-
resetWallCanvasMode();
180179
syncWallCanvasModeUi();
181180
state.abort.abort();
182181
teardownWallViewport();

internal/httpapi/web/modules/dialogs/wall-canvas-mode.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,20 +200,30 @@ describe("wall canvas mode toggle", () => {
200200
expect(wallSurfaceEl.classList.contains("wall-surface--pan-mode")).toBe(false);
201201
});
202202

203-
it("resets to Select mode after close and reopen", async () => {
203+
it("persists Pan mode globally after close and reopen", async () => {
204204
await openWall();
205205
const btn = getModeBtn();
206206
btn.click();
207207
await flushPromises();
208208
expect(btn.getAttribute("aria-pressed")).toBe("true");
209+
expect(localStorage.getItem("scrumboy.wall.canvasMode")).toBe("pan");
209210

210211
wallDialogEl.close();
211212
await flushPromises();
212213

214+
// Reopen a different project's wall: the global preference still applies.
213215
await openWall();
214216
const reopened = getModeBtn();
215-
expect(reopened.getAttribute("aria-pressed")).toBe("false");
216-
expect(reopened.innerHTML).toContain("lucide-square-dashed");
217+
expect(reopened.getAttribute("aria-pressed")).toBe("true");
218+
expect(reopened.innerHTML).toContain("lucide-hand");
219+
expect(wallSurfaceEl.classList.contains("wall-surface--pan-mode")).toBe(true);
220+
});
221+
222+
it("opens in Select mode when no saved preference exists", async () => {
223+
await openWall();
224+
const btn = getModeBtn();
225+
expect(btn.getAttribute("aria-pressed")).toBe("false");
226+
expect(btn.innerHTML).toContain("lucide-square-dashed");
217227
expect(wallSurfaceEl.classList.contains("wall-surface--pan-mode")).toBe(false);
218228
});
219229

@@ -415,3 +425,46 @@ describe("wall canvas mode toggle", () => {
415425
);
416426
});
417427
});
428+
429+
describe("wall-canvas-mode persistence", () => {
430+
beforeEach(() => {
431+
vi.resetModules();
432+
localStorage.clear();
433+
});
434+
435+
afterEach(() => {
436+
localStorage.clear();
437+
});
438+
439+
it("normalizeWallCanvasMode rejects garbage and defaults to select", async () => {
440+
const { normalizeWallCanvasMode } = await import("./wall-canvas-mode.js");
441+
expect(normalizeWallCanvasMode("pan")).toBe("pan");
442+
expect(normalizeWallCanvasMode("select")).toBe("select");
443+
expect(normalizeWallCanvasMode("nonsense")).toBe("select");
444+
expect(normalizeWallCanvasMode(null)).toBe("select");
445+
expect(normalizeWallCanvasMode(undefined)).toBe("select");
446+
expect(normalizeWallCanvasMode(42)).toBe("select");
447+
});
448+
449+
it("loadWallCanvasMode reads a saved pan preference", async () => {
450+
localStorage.setItem("scrumboy.wall.canvasMode", "pan");
451+
const { loadWallCanvasMode, getWallCanvasMode } = await import("./wall-canvas-mode.js");
452+
expect(loadWallCanvasMode()).toBe("pan");
453+
expect(getWallCanvasMode()).toBe("pan");
454+
});
455+
456+
it("loadWallCanvasMode defaults to select when unset or invalid", async () => {
457+
const { loadWallCanvasMode } = await import("./wall-canvas-mode.js");
458+
expect(loadWallCanvasMode()).toBe("select");
459+
localStorage.setItem("scrumboy.wall.canvasMode", "bogus");
460+
expect(loadWallCanvasMode()).toBe("select");
461+
});
462+
463+
it("toggleWallCanvasMode and setWallCanvasMode write to localStorage", async () => {
464+
const { toggleWallCanvasMode, setWallCanvasMode } = await import("./wall-canvas-mode.js");
465+
expect(toggleWallCanvasMode()).toBe("pan");
466+
expect(localStorage.getItem("scrumboy.wall.canvasMode")).toBe("pan");
467+
setWallCanvasMode("select");
468+
expect(localStorage.getItem("scrumboy.wall.canvasMode")).toBe("select");
469+
});
470+
});
Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,61 @@
11
export type WallCanvasMode = "select" | "pan";
22

3+
// Global (not per-project) canvas-mode preference. The wall is per-project but
4+
// the Select/Pan toggle is remembered across every board's wall via a single
5+
// localStorage key. Mirrors the resilient try/catch storage pattern used by
6+
// wall-viewport.ts.
7+
const CANVAS_MODE_STORAGE_KEY = "scrumboy.wall.canvasMode";
8+
39
let wallCanvasMode: WallCanvasMode = "select";
410

511
export function getWallCanvasMode(): WallCanvasMode {
612
return wallCanvasMode;
713
}
814

15+
/** Coerce arbitrary input to a valid mode; anything but "pan" falls to "select". */
16+
export function normalizeWallCanvasMode(raw: unknown): WallCanvasMode {
17+
return raw === "pan" ? "pan" : "select";
18+
}
19+
20+
function saveWallCanvasMode(mode: WallCanvasMode): void {
21+
try {
22+
localStorage.setItem(CANVAS_MODE_STORAGE_KEY, mode);
23+
} catch {
24+
// private mode / quota / disabled — ignore
25+
}
26+
}
27+
928
export function setWallCanvasMode(mode: WallCanvasMode): void {
10-
wallCanvasMode = mode === "pan" ? "pan" : "select";
29+
wallCanvasMode = normalizeWallCanvasMode(mode);
30+
saveWallCanvasMode(wallCanvasMode);
1131
}
1232

1333
export function toggleWallCanvasMode(): WallCanvasMode {
1434
wallCanvasMode = wallCanvasMode === "select" ? "pan" : "select";
35+
saveWallCanvasMode(wallCanvasMode);
1536
return wallCanvasMode;
1637
}
1738

1839
export function isWallPanMode(): boolean {
1940
return wallCanvasMode === "pan";
2041
}
2142

43+
/** Load the persisted global preference into memory and return it. */
44+
export function loadWallCanvasMode(): WallCanvasMode {
45+
try {
46+
wallCanvasMode = normalizeWallCanvasMode(localStorage.getItem(CANVAS_MODE_STORAGE_KEY));
47+
} catch {
48+
wallCanvasMode = "select";
49+
}
50+
return wallCanvasMode;
51+
}
52+
53+
/** Test-only: reset in-memory mode and clear the persisted preference. */
2254
export function resetWallCanvasMode(): void {
2355
wallCanvasMode = "select";
56+
try {
57+
localStorage.removeItem(CANVAS_MODE_STORAGE_KEY);
58+
} catch {
59+
// ignore
60+
}
2461
}

internal/httpapi/web/modules/dialogs/wall-rendering.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,22 @@ describe('wall edge overlay', () => {
154154
expect(surface.querySelectorAll('svg').length).toBe(1);
155155
});
156156

157+
it('ensureEdgeOverlay is non-zero sized with overflow:visible (Chromium paints out-of-box edges)', () => {
158+
// Regression guard: the overlay lives inside the 0x0 `.wall-content`
159+
// transform anchor. If the SVG itself is sized 100% (== 0 here) Chromium
160+
// refuses to paint any connection line that lies outside the zero-sized
161+
// viewport, so Shift-drag edges become invisible in Chrome/Edge while the
162+
// edge data is still created. A non-zero box + overflow:visible fixes it.
163+
const surface = document.createElement('div');
164+
document.body.appendChild(surface);
165+
const svg = ensureEdgeOverlay(surface);
166+
expect(svg.style.width).not.toBe('100%');
167+
expect(svg.style.height).not.toBe('100%');
168+
expect(parseFloat(svg.style.width)).toBeGreaterThan(0);
169+
expect(parseFloat(svg.style.height)).toBeGreaterThan(0);
170+
expect(svg.style.overflow).toBe('visible');
171+
});
172+
157173
it('renderEdges draws hit + visible lines between note centers', () => {
158174
const { surface } = mountSurfaceWithNotes();
159175
renderEdges(surface, [{ id: 'e1', from: 'na', to: 'nb' }]);

0 commit comments

Comments
 (0)