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 }); + }; }); -