Skip to content

Commit 7527f7a

Browse files
committed
added keyboard navigation to wall pan
Signed-off-by: Mark Rai <markraidc@gmail.com>
1 parent 4c8cdc6 commit 7527f7a

8 files changed

Lines changed: 182 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Improvements
1212

1313
- **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.
1415

1516
### Fixed
1617

docs/wall-viewport-manual-checklist.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ Run each scenario in a real browser (Chromium/Firefox) at **zoom ≠ 1** and wit
1919
| 13 | Edit suppression | Wheel / Space+drag do nothing while editing note text |
2020
| 14 | Input suppression | Wheel does nothing when focus in textarea/button |
2121
| 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 |
2224

23-
Navigation reference: wheel = pan, Ctrl/Cmd+wheel = zoom, middle-drag / Space+drag = pan.
25+
Navigation reference: wheel = pan, Ctrl/Cmd+wheel = zoom, middle-drag / Space+drag / arrow keys = pan.

docs/wall.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ 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**, or hold **Space** and drag on empty canvas.
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).
2525
- **Zoom** - **Ctrl**+scroll (Windows/Linux) or ****+scroll (Mac). Pinch-to-zoom on trackpads uses the same modifier.
2626
- **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.
2727

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const WHEEL_ZOOM_FACTOR = 1.08;
55
// Approximate pixel sizes for line/page wheel modes (Firefox, some mice).
66
const WHEEL_LINE_PX = 16;
77
const WHEEL_PAGE_PX = 800;
8+
// Arrow-key pan step (screen px). Shift pans in coarse steps.
9+
const ARROW_PAN_STEP_PX = 64;
10+
const ARROW_PAN_STEP_COARSE_PX = ARROW_PAN_STEP_PX * 4;
811
/** Normalize wheel deltas to pixels regardless of deltaMode. */
912
function wheelPixels(ev) {
1013
const unit = ev.deltaMode === WheelEvent.DOM_DELTA_LINE
@@ -45,6 +48,37 @@ function onKeyDown(ev) {
4548
ev.preventDefault();
4649
spaceHeld = true;
4750
}
51+
// Arrow keys pan the canvas (additive to wheel / Space+drag / middle-drag).
52+
// Direction = the way the viewport moves toward content, matching scroll-to-pan.
53+
function onArrowKeyDown(ev, isOpen) {
54+
if (ev.ctrlKey || ev.metaKey || ev.altKey)
55+
return;
56+
let dx = 0;
57+
let dy = 0;
58+
const step = ev.shiftKey ? ARROW_PAN_STEP_COARSE_PX : ARROW_PAN_STEP_PX;
59+
switch (ev.key) {
60+
case "ArrowRight":
61+
dx = -step;
62+
break;
63+
case "ArrowLeft":
64+
dx = step;
65+
break;
66+
case "ArrowDown":
67+
dy = -step;
68+
break;
69+
case "ArrowUp":
70+
dy = step;
71+
break;
72+
default:
73+
return;
74+
}
75+
if (!isOpen())
76+
return;
77+
if (isNavigationSuppressed(ev.target))
78+
return;
79+
ev.preventDefault();
80+
panBy(dx, dy);
81+
}
4882
function onKeyUp(ev) {
4983
if (ev.code !== "Space")
5084
return;
@@ -106,11 +140,12 @@ function bindPanPointer(surface, signal, shouldStart, onActiveChange) {
106140
* Bind wheel / keyboard / space+drag / middle-drag pan on the wall surface.
107141
* All listeners abort when `signal` fires (wall teardown).
108142
*/
109-
export function bindWallNavigation(surface, signal) {
143+
export function bindWallNavigation(surface, signal, isOpen = () => true) {
110144
const opts = { signal, passive: false };
111145
surface.addEventListener("wheel", onWheel, opts);
112146
window.addEventListener("keydown", onKeyDown, { signal });
113147
window.addEventListener("keyup", onKeyUp, { signal });
148+
window.addEventListener("keydown", (ev) => onArrowKeyDown(ev, isOpen), { signal });
114149
surface.addEventListener("blur", onBlur, { signal, capture: true });
115150
window.addEventListener("blur", onBlur, { signal });
116151
bindPanPointer(surface, signal, (ev) => ev.button === 1, (active) => {
@@ -124,6 +159,10 @@ export function bindWallNavigation(surface, signal) {
124159
export function isSpacePanArmed() {
125160
return spaceHeld;
126161
}
162+
/** For tests: drive the arrow-key pan handler directly. */
163+
export function __onArrowKeyDownForTest(ev, isOpen = () => true) {
164+
onArrowKeyDown(ev, isOpen);
165+
}
127166
/** For tests: whether space-to-pan is armed. */
128167
export function __isSpaceHeldForTest() {
129168
return spaceHeld;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function openWallDialog(opts) {
7373
wallSurface.classList.toggle("wall-surface--readonly", !canEdit);
7474
const content = ensureWallContent(wallSurface);
7575
initWallViewport(wallSurface, content, opts.slug);
76-
bindWallNavigation(wallSurface, state.abort.signal);
76+
bindWallNavigation(wallSurface, state.abort.signal, () => dialog.open);
7777
renderSurface();
7878
if (closeWallBtn) {
7979
closeWallBtn.addEventListener("click", () => dialog.close(), { signal: state.abort.signal });
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// @vitest-environment happy-dom
2+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
3+
import {
4+
ensureWallContent,
5+
getViewportState,
6+
initWallViewport,
7+
teardownWallViewport,
8+
} from "./wall-viewport.js";
9+
import { __onArrowKeyDownForTest } from "./wall-viewport-nav.js";
10+
import { rect } from "./wall-test-harness.js";
11+
12+
const STEP = 64;
13+
const COARSE = STEP * 4;
14+
15+
function arrow(key: string, extra: Partial<KeyboardEventInit> = {}): KeyboardEvent {
16+
return new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...extra });
17+
}
18+
19+
describe("wall-viewport-nav arrow pan", () => {
20+
let surface: HTMLElement;
21+
22+
beforeEach(() => {
23+
document.body.innerHTML = "";
24+
surface = document.createElement("div");
25+
surface.id = "wallSurface";
26+
document.body.appendChild(surface);
27+
surface.getBoundingClientRect = () => rect(0, 0, 800, 600);
28+
initWallViewport(surface, ensureWallContent(surface), "nav-proj");
29+
});
30+
31+
afterEach(() => {
32+
teardownWallViewport();
33+
localStorage.clear();
34+
});
35+
36+
it("ArrowRight pans content left (negative panX) by one step", () => {
37+
__onArrowKeyDownForTest(arrow("ArrowRight"));
38+
expect(getViewportState().panX).toBe(-STEP);
39+
});
40+
41+
it("ArrowLeft pans content right (positive panX)", () => {
42+
__onArrowKeyDownForTest(arrow("ArrowLeft"));
43+
expect(getViewportState().panX).toBe(STEP);
44+
});
45+
46+
it("ArrowDown / ArrowUp pan vertically", () => {
47+
__onArrowKeyDownForTest(arrow("ArrowDown"));
48+
expect(getViewportState().panY).toBe(-STEP);
49+
__onArrowKeyDownForTest(arrow("ArrowUp"));
50+
expect(getViewportState().panY).toBe(0);
51+
});
52+
53+
it("Shift pans in coarse steps", () => {
54+
__onArrowKeyDownForTest(arrow("ArrowRight", { shiftKey: true }));
55+
expect(getViewportState().panX).toBe(-COARSE);
56+
});
57+
58+
it("calls preventDefault when it pans", () => {
59+
const ev = arrow("ArrowRight");
60+
__onArrowKeyDownForTest(ev);
61+
expect(ev.defaultPrevented).toBe(true);
62+
});
63+
64+
it("does nothing when the wall is closed", () => {
65+
__onArrowKeyDownForTest(arrow("ArrowRight"), () => false);
66+
expect(getViewportState().panX).toBe(0);
67+
});
68+
69+
it("is suppressed while editing a note", () => {
70+
const note = document.createElement("div");
71+
note.className = "wall-note wall-note--editing";
72+
const ta = document.createElement("textarea");
73+
note.appendChild(ta);
74+
surface.appendChild(note);
75+
const ev = arrow("ArrowRight");
76+
Object.defineProperty(ev, "target", { value: ta });
77+
__onArrowKeyDownForTest(ev);
78+
expect(getViewportState().panX).toBe(0);
79+
});
80+
81+
it("is ignored with ctrl/meta/alt modifiers", () => {
82+
__onArrowKeyDownForTest(arrow("ArrowRight", { ctrlKey: true }));
83+
__onArrowKeyDownForTest(arrow("ArrowRight", { metaKey: true }));
84+
__onArrowKeyDownForTest(arrow("ArrowRight", { altKey: true }));
85+
expect(getViewportState().panX).toBe(0);
86+
});
87+
88+
it("ignores non-arrow keys", () => {
89+
__onArrowKeyDownForTest(arrow("a"));
90+
expect(getViewportState()).toEqual({ panX: 0, panY: 0, zoom: 1 });
91+
});
92+
});

internal/httpapi/web/modules/dialogs/wall-viewport-nav.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const WHEEL_ZOOM_FACTOR = 1.08;
1515
// Approximate pixel sizes for line/page wheel modes (Firefox, some mice).
1616
const WHEEL_LINE_PX = 16;
1717
const WHEEL_PAGE_PX = 800;
18+
// Arrow-key pan step (screen px). Shift pans in coarse steps.
19+
const ARROW_PAN_STEP_PX = 64;
20+
const ARROW_PAN_STEP_COARSE_PX = ARROW_PAN_STEP_PX * 4;
1821

1922
/** Normalize wheel deltas to pixels regardless of deltaMode. */
2023
function wheelPixels(ev: WheelEvent): { dx: number; dy: number } {
@@ -59,6 +62,35 @@ function onKeyDown(ev: KeyboardEvent): void {
5962
spaceHeld = true;
6063
}
6164

65+
// Arrow keys pan the canvas (additive to wheel / Space+drag / middle-drag).
66+
// Direction = the way the viewport moves toward content, matching scroll-to-pan.
67+
function onArrowKeyDown(ev: KeyboardEvent, isOpen: () => boolean): void {
68+
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
69+
let dx = 0;
70+
let dy = 0;
71+
const step = ev.shiftKey ? ARROW_PAN_STEP_COARSE_PX : ARROW_PAN_STEP_PX;
72+
switch (ev.key) {
73+
case "ArrowRight":
74+
dx = -step;
75+
break;
76+
case "ArrowLeft":
77+
dx = step;
78+
break;
79+
case "ArrowDown":
80+
dy = -step;
81+
break;
82+
case "ArrowUp":
83+
dy = step;
84+
break;
85+
default:
86+
return;
87+
}
88+
if (!isOpen()) return;
89+
if (isNavigationSuppressed(ev.target)) return;
90+
ev.preventDefault();
91+
panBy(dx, dy);
92+
}
93+
6294
function onKeyUp(ev: KeyboardEvent): void {
6395
if (ev.code !== "Space") return;
6496
spaceHeld = false;
@@ -139,12 +171,17 @@ function bindPanPointer(
139171
* Bind wheel / keyboard / space+drag / middle-drag pan on the wall surface.
140172
* All listeners abort when `signal` fires (wall teardown).
141173
*/
142-
export function bindWallNavigation(surface: HTMLElement, signal: AbortSignal): void {
174+
export function bindWallNavigation(
175+
surface: HTMLElement,
176+
signal: AbortSignal,
177+
isOpen: () => boolean = () => true,
178+
): void {
143179
const opts = { signal, passive: false as const };
144180

145181
surface.addEventListener("wheel", onWheel, opts);
146182
window.addEventListener("keydown", onKeyDown, { signal });
147183
window.addEventListener("keyup", onKeyUp, { signal });
184+
window.addEventListener("keydown", (ev) => onArrowKeyDown(ev, isOpen), { signal });
148185
surface.addEventListener("blur", onBlur, { signal, capture: true });
149186
window.addEventListener("blur", onBlur, { signal });
150187

@@ -172,6 +209,11 @@ export function isSpacePanArmed(): boolean {
172209
return spaceHeld;
173210
}
174211

212+
/** For tests: drive the arrow-key pan handler directly. */
213+
export function __onArrowKeyDownForTest(ev: KeyboardEvent, isOpen: () => boolean = () => true): void {
214+
onArrowKeyDown(ev, isOpen);
215+
}
216+
175217
/** For tests: whether space-to-pan is armed. */
176218
export function __isSpaceHeldForTest(): boolean {
177219
return spaceHeld;

internal/httpapi/web/modules/dialogs/wall.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export async function openWallDialog(opts: OpenWallDialogOptions): Promise<void>
140140
wallSurface.classList.toggle("wall-surface--readonly", !canEdit);
141141
const content = ensureWallContent(wallSurface);
142142
initWallViewport(wallSurface, content, opts.slug);
143-
bindWallNavigation(wallSurface, state.abort.signal);
143+
bindWallNavigation(wallSurface, state.abort.signal, () => dialog.open);
144144
renderSurface();
145145

146146
if (closeWallBtn) {

0 commit comments

Comments
 (0)