Skip to content

Commit 27257e4

Browse files
authored
Merge pull request #90 from markrai/enhancement/infinite-wall-pan-zoom
Enhancement/infinite wall pan zoom
2 parents 53210c1 + 7527f7a commit 27257e4

24 files changed

Lines changed: 1603 additions & 123 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,29 @@
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.4] - 2026-06-02
6+
7+
### Added
8+
9+
- **Wall pan and zoom** - The sticky-note wall is now an infinite canvas (Mural-style): scroll to pan, Ctrl/Cmd+scroll (or trackpad pinch) to zoom, middle-drag or Space+drag to pan. A **fit view** control (⊡ button or **F**) recenters on all notes. Pan/zoom is remembered per board in the browser (`localStorage`); no server or data migration changes—existing note positions are unchanged at the default view.
10+
11+
### Improvements
12+
13+
- **Wall coordinates** - Notes can be placed at negative canvas coordinates (matching the server’s ±100000 bound). Drag, resize, marquee select, edge preview, and create-at-pointer all use a shared screen-to-canvas transform so gestures stay correct at any zoom.
14+
- **Wall keyboard pan** - **Arrow keys** pan the canvas (hold **Shift** for larger steps), complementing scroll-wheel, middle-drag, and Space+drag for users without horizontal scroll or a middle button. Suppressed while editing a note or when focus is in an input/button.
15+
16+
### Fixed
17+
18+
- **Wall fit view** - Fit-to-notes can zoom out below the manual zoom floor (`FIT_ZOOM_MIN`) so widely spread notes actually fit on screen; manual zoom still bottoms out at 0.2× for legibility.
19+
- **Wall viewport persistence** - Saved and loaded pan/zoom clamp pan against the stored zoom (not a stale module zoom), so reload after low-zoom sessions restores a consistent view.
20+
- **Wall pan while closing** - Middle-drag and Space+drag document listeners are torn down if the wall closes mid-gesture, preventing ghost panning or surprise viewport state on the next open.
21+
- **Wall wheel pan on Firefox** - Scroll-wheel deltas are normalized for line/page `deltaMode` so pan and zoom speed match pixel-mode wheels in Chromium.
22+
23+
### Documentation
24+
25+
- **`docs/wall.md`** - Pan, zoom, and fit-view controls.
26+
- **`docs/wall-viewport-manual-checklist.md`** - Manual browser sign-off checklist for pan/zoom (real-browser verification; Vitest/jsdom alone is not sufficient for layout transforms).
27+
528
## [3.17.3] - 2026-06-01
629

730
### Changed

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.3-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.17.4-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Wall pan/zoom — manual browser verification
2+
3+
Run each scenario in a real browser (Chromium/Firefox) at **zoom ≠ 1** and with at least one note at **negative canvas coordinates** (e.g. x: -200, y: -150).
4+
5+
| # | Scenario | Pass criteria |
6+
|---|----------|---------------|
7+
| 1 | Create note (right-click canvas) | Note appears under cursor |
8+
| 2 | Drag single note | Resting position matches pointer; PATCH persists |
9+
| 3 | Multi-select drag | All selected notes move together |
10+
| 4 | Resize (corner handle) | Size changes correctly at non-1 zoom |
11+
| 5 | Marquee select | Box selects geometrically correct notes |
12+
| 6 | Edge create (Shift+drag) | Connects intended notes |
13+
| 7 | Edge preview | Preview line follows cursor |
14+
| 8 | Trash delete | Drag-to-trash hit-test works when panned/zoomed |
15+
| 9 | Fit view (⊡ or **F**) | All notes visible; recovers from off-screen pan |
16+
| 10 | Reload | Pan/zoom restored from localStorage |
17+
| 11 | Corrupt storage | Clear key or set invalid JSON → wall opens at origin without error |
18+
| 12 | Negative coords | Note at negative x/y reachable and editable |
19+
| 13 | Edit suppression | Wheel / Space+drag do nothing while editing note text |
20+
| 14 | Input suppression | Wheel does nothing when focus in textarea/button |
21+
| 15 | Teardown | Close wall → Space pan not stuck; page scroll normal |
22+
| 16 | Arrow-key pan | Arrow keys pan the canvas; Shift = larger steps; page behind modal does not scroll |
23+
| 17 | Arrow suppression | Arrow keys move the caret (no pan) while editing note text or with focus in an input/button |
24+
25+
Navigation reference: wheel = pan, Ctrl/Cmd+wheel = zoom, middle-drag / Space+drag / arrow keys = pan.

docs/wall.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ 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+
- **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).
25+
- **Zoom** - **Ctrl**+scroll (Windows/Linux) or ****+scroll (Mac). Pinch-to-zoom on trackpads uses the same modifier.
26+
- **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.
2427

2528
## Disabling the wall
2629

internal/httpapi/web/dist/dialogs/wall-drag-controller.js

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { clampDim, updateEdgesForNote, MAX_NOTE_HEIGHT, MAX_NOTE_WIDTH, MIN_NOTE
1919
import { DRAG_TRANSIENT_COALESCE_MS, TRANSIENT_COALESCE_MS } from "./wall-postbaby-constants.js";
2020
import { postTransient } from "./wall-api.js";
2121
import { getMounted, setDragActive } from "./wall-state.js";
22+
import { MAX_CANVAS_COORD, canvasDelta, clampCanvasCoord, getViewportState, getWallContent, } from "./wall-viewport.js";
2223
// Phase 0 debug counters. Lifetime-accumulating across all drags in the
2324
// current session; reset via __resetDragCounters() in tests. Per-drag deltas
2425
// are also emitted via console.debug when window.__scrumboyWallDebug is true.
@@ -103,6 +104,7 @@ export function updateTrashHoverAny(participants) {
103104
export function isOverTrash(noteEl) {
104105
if (!wallTrash)
105106
return false;
107+
// Screen-space OK: trash is a fixed viewport overlay; compare screen rects.
106108
const a = noteEl.getBoundingClientRect();
107109
const b = wallTrash.getBoundingClientRect();
108110
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
@@ -119,10 +121,8 @@ export function beginDrag(opts) {
119121
return;
120122
const surfaceRect = surface.getBoundingClientRect();
121123
const noteRect = noteEl.getBoundingClientRect();
122-
// shiftX/shiftY: offset from pointer-down position to the primary note's
123-
// top-left, measured within the surface. Using the original downX/downY
124-
// (rather than current pointer position) preserves the visual feel of
125-
// Postbaby's `shiftX = clientX - rect.left` so the note doesn't jump.
124+
const { panX, panY, zoom: z } = getViewportState();
125+
// shiftX/shiftY: screen-space offset from pointer to note top-left at pointerdown.
126126
const shiftX = downX - noteRect.left;
127127
const shiftY = downY - noteRect.top;
128128
const isGroup = state.selected.has(noteId) && state.selected.size > 1;
@@ -204,25 +204,32 @@ export function beginDrag(opts) {
204204
return;
205205
animationFrameId = requestAnimationFrame(() => {
206206
animationFrameId = null;
207-
const rawX = lastClientX - surfaceRect.left - shiftX;
208-
const rawY = lastClientY - surfaceRect.top - shiftY;
207+
const screenNoteLeft = lastClientX - shiftX;
208+
const screenNoteTop = lastClientY - shiftY;
209+
const rawX = (screenNoteLeft - surfaceRect.left - panX) / z;
210+
const rawY = (screenNoteTop - surfaceRect.top - panY) / z;
209211
let minDeltaX = rawX - primary.x;
210212
let minDeltaY = rawY - primary.y;
211213
for (const p of participants) {
212-
if (p.startX + minDeltaX < 0)
213-
minDeltaX = -p.startX;
214-
if (p.startY + minDeltaY < 0)
215-
minDeltaY = -p.startY;
214+
if (p.startX + minDeltaX < -MAX_CANVAS_COORD)
215+
minDeltaX = -MAX_CANVAS_COORD - p.startX;
216+
if (p.startY + minDeltaY < -MAX_CANVAS_COORD)
217+
minDeltaY = -MAX_CANVAS_COORD - p.startY;
218+
if (p.startX + minDeltaX > MAX_CANVAS_COORD)
219+
minDeltaX = MAX_CANVAS_COORD - p.startX;
220+
if (p.startY + minDeltaY > MAX_CANVAS_COORD)
221+
minDeltaY = MAX_CANVAS_COORD - p.startY;
216222
}
217223
let edgeCallsThisTick = 0;
218224
for (const p of participants) {
219-
const nx = p.startX + minDeltaX;
220-
const ny = p.startY + minDeltaY;
225+
const nx = clampCanvasCoord(p.startX + minDeltaX);
226+
const ny = clampCanvasCoord(p.startY + minDeltaY);
221227
p.el.style.left = `${Math.round(nx)}px`;
222228
p.el.style.top = `${Math.round(ny)}px`;
223229
storeTransientPosition(p.id, nx, ny);
224-
if (wallSurface) {
225-
updateEdgesForNote(wallSurface, p.id, nx + p.el.offsetWidth / 2, ny + p.el.offsetHeight / 2);
230+
const edgeRoot = getWallContent() ?? wallSurface;
231+
if (edgeRoot) {
232+
updateEdgesForNote(edgeRoot, p.id, nx + p.el.offsetWidth / 2, ny + p.el.offsetHeight / 2);
226233
edgeCallsThisTick += 1;
227234
}
228235
}
@@ -281,8 +288,9 @@ export function beginDrag(opts) {
281288
for (const p of participants) {
282289
p.el.style.left = `${Math.round(p.startX)}px`;
283290
p.el.style.top = `${Math.round(p.startY)}px`;
284-
if (wallSurface) {
285-
updateEdgesForNote(wallSurface, p.id, p.startX + p.el.offsetWidth / 2, p.startY + p.el.offsetHeight / 2);
291+
const edgeRoot = getWallContent() ?? wallSurface;
292+
if (edgeRoot) {
293+
updateEdgesForNote(edgeRoot, p.id, p.startX + p.el.offsetWidth / 2, p.startY + p.el.offsetHeight / 2);
286294
}
287295
}
288296
opts.onDropOnTrash(participants.map((p) => p.id), isGroup);
@@ -321,8 +329,8 @@ export function startResize(opts) {
321329
const origH = note.height;
322330
const onMove = (mv) => {
323331
mv.preventDefault();
324-
const dw = mv.clientX - startX;
325-
const dh = mv.clientY - startY;
332+
const dw = canvasDelta(mv.clientX - startX);
333+
const dh = canvasDelta(mv.clientY - startY);
326334
const w = clampDim(origW + dw, MIN_NOTE_WIDTH, MAX_NOTE_WIDTH);
327335
const h = clampDim(origH + dh, MIN_NOTE_HEIGHT, MAX_NOTE_HEIGHT);
328336
noteEl.style.width = `${Math.round(w)}px`;

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
// - `startRealtime({ slug, onApplyDoc, onApplyTransient })` returns a
1818
// `stop()` handle that unsubscribes from the event bus.
1919
import { wallDialog, wallSurface } from "../dom/elements.js";
20+
import { getWallContent } from "./wall-viewport.js";
2021
import { on, off } from "../events.js";
2122
import { showToast } from "../utils.js";
2223
import { updateEdgesForNote } from "./wall-rendering.js";
@@ -116,7 +117,10 @@ export function applyTransient(payload, noteElementById) {
116117
return;
117118
el.style.left = `${Math.round(x)}px`;
118119
el.style.top = `${Math.round(y)}px`;
119-
updateEdgesForNote(wallSurface, noteId, x + el.offsetWidth / 2, y + el.offsetHeight / 2);
120+
const edgeRoot = getWallContent() ?? wallSurface;
121+
if (edgeRoot) {
122+
updateEdgesForNote(edgeRoot, noteId, x + el.offsetWidth / 2, y + el.offsetHeight / 2);
123+
}
120124
}
121125
let currentDebounceHandle = null;
122126
export function startRealtime(opts) {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// orchestration, event wiring, and teardown.
1616
import { HEX_COLOR_RE, sanitizeHexColor } from "../utils.js";
1717
import { colorIndexFromHex } from "./wall-postbaby-constants.js";
18+
import { screenToCanvas } from "./wall-viewport.js";
1819
const DEFAULT_NOTE_COLOR = "#ffd966";
1920
// Clamp to the same limits the backend enforces. Keep in sync with
2021
// internal/store/wall.go (clampNoteDim / validateWallColor).
@@ -279,7 +280,9 @@ export function getNoteCenterFromElement(surface, noteEl) {
279280
cy: noteEl.offsetTop + noteEl.offsetHeight / 2,
280281
};
281282
}
283+
// Screen-space fallback: convert note center through the active viewport
284+
// transform (offsetLeft path above is canvas-local when parent is .wall-content).
282285
const a = noteEl.getBoundingClientRect();
283-
const s = surface.getBoundingClientRect();
284-
return { cx: a.left - s.left + a.width / 2, cy: a.top - s.top + a.height / 2 };
286+
const c = screenToCanvas(a.left + a.width / 2, a.top + a.height / 2);
287+
return { cx: c.x, cy: c.y };
285288
}

internal/httpapi/web/dist/dialogs/wall-test-harness.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Shared test helpers for the wall feature's interaction tests.
2+
import { ensureWallContent, initWallViewport, teardownWallViewport } from "./wall-viewport.js";
23
//
34
// These helpers are **non-hoisted** by design: any `vi.hoisted` / `vi.mock`
45
// call must stay in the test file itself so that vitest wires the module
@@ -18,14 +19,17 @@ export function installDialogPolyfill() {
1819
},
1920
});
2021
}
21-
export function setupWallDom(refs) {
22+
export function setupWallDom(refs, slug = "test-wall") {
23+
teardownWallViewport();
2224
document.body.innerHTML = "";
2325
refs.wallDialogEl.innerHTML = "";
2426
refs.wallSurfaceEl.innerHTML = "";
2527
refs.wallDialogEl.appendChild(refs.wallSurfaceEl);
2628
document.body.appendChild(refs.wallDialogEl);
2729
document.body.appendChild(refs.closeWallBtnEl);
2830
document.body.appendChild(refs.wallTrashEl);
31+
const content = ensureWallContent(refs.wallSurfaceEl);
32+
initWallViewport(refs.wallSurfaceEl, content, slug);
2933
}
3034
export function makeNote(overrides = {}) {
3135
return {

0 commit comments

Comments
 (0)