From bcdd069534dda6ca2bd4bc541c159afb6e552ceb Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Tue, 20 Oct 2026 06:33:05 +0300 Subject: [PATCH] web: finer wheel zoom, and drag the canvas with space or the middle button A mouse wheel's notch with ctrl or cmd zoomed by a factor of e (deltaY 100 through exp(-deltaY * 0.01)); a notch is now one 10% step, told apart from a pinch's few pixels by size (50 px or more, or any line-mode delta). Holding space, unless a text field has focus, or the middle button drags the canvas from anywhere, over windows too: the pointer is taken in the capture phase before Svelte Flow or the window sees it, so nothing drags, selects or autoscrolls. Svelte Flow's own space key, which only worked on empty canvas, is off. Signed-off-by: dawn <90008@klbr.net> --- robot-docs/web.md | 2 +- web/src/lib/Canvas.svelte | 82 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/robot-docs/web.md b/robot-docs/web.md index 2a91eb0c..4659e8f8 100644 --- a/robot-docs/web.md +++ b/robot-docs/web.md @@ -14,7 +14,7 @@ In development, `npm run dev` in `web/` proxies `/api` the same way from `$ALBED Words: a **board** is a named canvas of panels, and the open boards are the tabs along the top (`boards.svelte.ts`). A board holds sessions from any folders. **Workspace** keeps its albedo meaning, a session's folder, and the sidebar shows each session's folder under its name. `+` makes a new board; double-click or F2 renames one; closing a tab keeps the board, which the palette reopens (`Open board: `); `Delete board` removes it. The last tab cannot be closed, so there is always a canvas. -A board is an open canvas. **Windows** sit anywhere on it; the view pans freely and zooms out to an overview. Svelte Flow draws, drags and resizes the windows; `board.svelte.ts` places new ones (right of the selected window, top-aligned, stepping past overlaps) and owns the viewport. The wheel is handled in `Canvas.svelte` rather than by Svelte Flow, because windows and canvas share the trackpad: a pinch (ctrl-wheel) zooms around the pointer anywhere, an up-down scroll over a window that can scroll scrolls it while the zoom is readable (60% and up), and every other scroll pans. + and − zoom in and out by 5 points (the buttons too), ⇧1 fits all windows, ⇧0 returns to 100%, M shows an overview map, ⌥←/⌥→ moves between windows. The view controls (zoom, fit, map) fade in while the view moves and fade out 4 s after it stops; hovering or focusing them keeps them, and once faded they let clicks through to the windows beneath. What a window shows is a **view**, each framed by `nodes/WindowFrame.svelte`: a session's chat, or a review of one turn's changes; files, terminals and agent-made pages are meant to follow. A view opened from a window names it as its `parent`, opens beside it, and the canvas draws a faint dashed line between them (the board derives Svelte Flow edges from those parents; every window carries invisible handles for them). +A board is an open canvas. **Windows** sit anywhere on it; the view pans freely and zooms out to an overview. Svelte Flow draws, drags and resizes the windows; `board.svelte.ts` places new ones (right of the selected window, top-aligned, stepping past overlaps) and owns the viewport. The wheel is handled in `Canvas.svelte` rather than by Svelte Flow, because windows and canvas share the trackpad: a pinch (ctrl-wheel) zooms around the pointer anywhere, a mouse wheel's notch with ctrl or ⌘ by one 10% step (a notch is a jump of 50 px or more, or any line-mode delta, where a pinch moves a few pixels at a time), an up-down scroll over a window that can scroll scrolls it while the zoom is readable (60% and up), and every other scroll pans. Dragging empty canvas pans; holding Space (unless a text field has focus) or the middle button drags the canvas from anywhere, over windows too, taking the pointer before Svelte Flow or the window sees it. + and − zoom in and out by 5 points (the buttons too), ⇧1 fits all windows, ⇧0 returns to 100%, M shows an overview map, ⌥←/⌥→ moves between windows. The view controls (zoom, fit, map) fade in while the view moves and fade out 4 s after it stops; hovering or focusing them keeps them, and once faded they let clicks through to the windows beneath. What a window shows is a **view**, each framed by `nodes/WindowFrame.svelte`: a session's chat, or a review of one turn's changes; files, terminals and agent-made pages are meant to follow. A view opened from a window names it as its `parent`, opens beside it, and the canvas draws a faint dashed line between them (the board derives Svelte Flow edges from those parents; every window carries invisible handles for them). The review (`nodes/ReviewNode.svelte`, `review.ts`) opens from "review N files +a −r" on a turn's sign-off, or from the +a −r on any work line, which also scrolls it to the first file that stretch of work changed (`data.jump`). It needs no daemon support beyond history: every tool call's trace records each file it changed, with the unified diff (`changes[].diff`, or `reason` when the file was binary, unreadable or too large). Writes the harness makes for itself stay out of it (`albedo_trace.unobserved()`): the browser plugin's profile lock and marker and its auto-named `.browser-artifacts` screenshots among them; a screenshot saved to a path the cell chose is still the cell's change. The window keeps the session, the turn and the turn's first history position, reads that turn's entries (`after=`, until the next turn starts), hydrates their traces, and shows the files with their counts, then each file's diffs in order. Diffs are drawn by `@pierre/diffs` (Shiki highlighting, `pierre-dark`/`pierre-light` tokens over the canvas's own ground and diff colours; `views/DiffView.svelte`), loaded with the first review so the rest of the client stays small. The margin button beside a line (drag for several) leaves a note on the canvas beside the window, tied to those lines; "send review" sends every unsent note tied into the review as one message, after an optional overall line. diff --git a/web/src/lib/Canvas.svelte b/web/src/lib/Canvas.svelte index abde858b..c0b10b60 100644 --- a/web/src/lib/Canvas.svelte +++ b/web/src/lib/Canvas.svelte @@ -15,7 +15,8 @@ // The wheel is handled here rather than by Svelte Flow, because a window's // contents and the canvas share the trackpad: a pinch zooms the canvas // anywhere, an up-down scroll over a readable window scrolls that window, - // and every other scroll pans the canvas freely. + // and every other scroll pans the canvas freely. Holding space, or the + // middle button, drags the canvas from anywhere, over windows too. let { board, onpalette, minimap }: { board: Board; onpalette: () => void; minimap: boolean } = $props(); const nodeTypes: NodeTypes = { session: SessionNode, review: ReviewNode, note: NoteNode }; @@ -36,13 +37,59 @@ return scroller.scrollHeight > scroller.clientHeight || deltaY === 0; } + // A pinch moves a few pixels at a time, a mouse wheel's notch a hundred or + // so (or a few lines), so a notch is one fixed step rather than a jump. + const NOTCH = 1.1; + function zoomFactor(event: WheelEvent) { + const pixels = event.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? event.deltaY : event.deltaY * 40; + return Math.abs(pixels) >= 50 ? NOTCH ** -Math.sign(pixels) : Math.exp(-pixels * 0.01); + } + + let hand = $state(false); + let panning = $state<{ x: number; y: number } | null>(null); + + function typing(target: EventTarget | null) { + return !!(target as HTMLElement | null)?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'); + } + + function keydown(event: KeyboardEvent) { + if (event.key !== ' ' || typing(event.target) || event.metaKey || event.ctrlKey || event.altKey) return; + // Held, not typed: no scrolling the page or pressing the focused button. + event.preventDefault(); + hand = true; + } + + function keyup(event: KeyboardEvent) { + if (event.key === ' ') hand = false; + } + + function grab(event: PointerEvent) { + if (!(event.button === 1 || (hand && event.button === 0))) return; + // Taken before Svelte Flow or a window sees it, so nothing drags, selects or autoscrolls. + event.preventDefault(); + event.stopPropagation(); + element!.setPointerCapture(event.pointerId); + panning = { x: event.clientX, y: event.clientY }; + } + + function drag(event: PointerEvent) { + if (!panning) return; + const { x, y, zoom } = board.viewport; + board.setView({ x: x + event.clientX - panning.x, y: y + event.clientY - panning.y, zoom }); + panning = { x: event.clientX, y: event.clientY }; + } + + function release() { + panning = null; + } + $effect(() => { if (!element) return; const wheel = (event: WheelEvent) => { const rect = element!.getBoundingClientRect(); if (event.ctrlKey || event.metaKey) { event.preventDefault(); - board.zoomAt(Math.exp(-event.deltaY * 0.01), { x: event.clientX - rect.left, y: event.clientY - rect.top }); + board.zoomAt(zoomFactor(event), { x: event.clientX - rect.left, y: event.clientY - rect.top }); return; } const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX); @@ -52,11 +99,28 @@ board.setView({ x: x - event.deltaX, y: y - event.deltaY, zoom }); }; element.addEventListener('wheel', wheel, { passive: false, capture: true }); - return () => element?.removeEventListener('wheel', wheel, { capture: true }); + element.addEventListener('pointerdown', grab, { capture: true }); + return () => { + element?.removeEventListener('wheel', wheel, { capture: true }); + element?.removeEventListener('pointerdown', grab, { capture: true }); + }; }); -