Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465<script lang="ts"> import { MiniMap, SvelteFlow, SvelteFlowProvider, type NodeTypes } from '@xyflow/svelte'; import { untrack } from 'svelte'; import { clamp, nodeIdOf, ZOOM, type Board, type SlotNode as Slot } from './board.svelte'; import { prefs } from './prefs.svelte'; import Dots from './Dots.svelte'; import FlowBinder from './FlowBinder.svelte'; import SlotNode from './nodes/SlotNode.svelte'; import { canvasAbove, type Canvas, type Tile } from './tiles/tree'; import { pixels, zoomFactor } from './wheel'; import CanvasControls from './tiles/CanvasControls.svelte';
// One canvas of a board: the whole board, a tile of it, or one sitting on // another canvas, which takes what the pointer does over it. Mounted again // on every tab switch (keyed on the board), so each canvas opens on its own // saved view. // // 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 but over a readable timeline that zooms and pans itself (a trace), an // up-down scroll over a readable window scrolls that window, a sideways one // over something that scrolls sideways (a diff's code, a wide table) // scrolls that, a menu floating over the canvas, or a view that pans and // zooms itself, keeps every scroll over it, and every other scroll pans the // canvas freely. Holding space drags the canvas from anywhere, over windows // too; the middle button drags it from the empty canvas, and over a card it // stays the browser's middle click; holding alt (option) drags a window by any // part of it, not only its title bar. let { board, canvas }: { board: Board; canvas: Canvas } = $props();
// The view bar shows while the view moves and lingers a few seconds after, // the overview map with it, and stays while the pointer is on either. A // canvas tile inside the layout shows it while the pointer is anywhere over // the tile too, since its grip and close are only there. const LINGER_MS = 4000; let shown = $state(false); let mapWidth = $state(188); let linger: ReturnType<typeof setTimeout> | undefined; let over = false; const nested = $derived(board.root.id !== canvas.id); function show() { shown = true; clearTimeout(linger); linger = setTimeout(() => (shown = over), LINGER_MS); } function hold() { shown = true; clearTimeout(linger); } // One on a canvas shows its bar only while the pointer is over it: out of it, the outer canvas's bar is the one moving. $effect(() => { void view; if (!framed || over) show(); }); $effect(() => () => clearTimeout(linger)); const onMap = (target: EventTarget | null) => !!(target as Element | null)?.closest?.('.svelte-flow__minimap');
/** A note let go of outside this canvas goes onto the canvas it was let go over, or over the rest of the layout floats there. */ function dropped(event: MouseEvent | TouchEvent, id: string) { if (board.windows[id]?.type !== 'note' || !element) return; const at = 'changedTouches' in event ? event.changedTouches[0] : event; const node = element.querySelector(`.svelte-flow__node[data-id="${CSS.escape(id)}"]`); const under = document.elementsFromPoint(at.clientX, at.clientY).find((found) => !node?.contains(found)); const onto = under?.closest<HTMLElement>('.canvas[data-canvas]'); if (onto === element) return; if (onto?.dataset.canvas) board.move(id, onto.dataset.canvas, 'tab', { at: { x: at.clientX, y: at.clientY } }); else board.floatOut(id); }
const nodeTypes: NodeTypes = { slot: SlotNode }; const view = $derived(board.viewOf(canvas.id)); /** On a canvas, the pane is drawn `scale` times bigger and scaled back down, so the flow works in screen pixels (board.scaleOf). */ const scale = $derived(board.scaleOf(canvas.id)); const drawn = $derived(board.shownOf(canvas.id)); /** * Sitting on a canvas: a window carried off it is on its way out, not * asking this one to pan after it, and the node it sits in on the outer * flow (marked nopan there) is not one of this flow's. */ const framed = $derived(board.expanded !== canvas.id && !!canvasAbove(board.root, canvas.id));
// The flow keeps the nodes it was given, with what it measured, and hands // back each drag and resize, which goes to the board. A child the board // left as it was keeps its node, so nothing is measured again. let nodes = $state.raw<Slot[]>([]); const same = (a: Tile, b: Tile) => a === b || (a.kind !== 'leaf' && JSON.stringify(a) === JSON.stringify(b)); $effect.pre(() => { const previous = new Map(untrack(() => nodes).map((node) => [node.id, node])); let changed = previous.size !== canvas.children.length; const next = canvas.children.map((placed): Slot => { const id = nodeIdOf(placed); const type = placed.tile.kind === 'leaf' ? board.windows[placed.tile.window]?.type : undefined; const old = previous.get(id); if (old && old.position.x === placed.x && old.position.y === placed.y && old.width === placed.width && old.height === placed.height && old.zIndex === placed.z && same(old.data.tile, placed.tile)) return old; changed = true; return { ...old, id, type: 'slot', position: { x: placed.x, y: placed.y }, width: placed.width, height: placed.height, zIndex: placed.z, dragHandle: placed.tile.kind === 'canvas' ? '.canvas-handle' : placed.tile.kind !== 'leaf' ? '.group-handle' : type === 'note' ? '.note-handle' : '.window-handle', data: old && same(old.data.tile, placed.tile) ? old.data : { tile: placed.tile, board }, }; }); if (changed) nodes = next; }); const edges = $derived(board.edgesOf(canvas));
// A double-click on empty canvas leaves a note there. Holding space, a // double-click anywhere, over windows too, takes the view there instead. const JUMP_ZOOM = 0.9; function dblclick(event: MouseEvent) { if (!here(event.target)) return; const at = { x: event.clientX, y: event.clientY }; if (hand) { event.stopPropagation(); board.centreOn(at, JUMP_ZOOM, canvas.id); } else if ((event.target as HTMLElement).classList.contains('svelte-flow__pane')) board.noteAt(at, canvas.id); } /** * How tall, in physical pixels, a window's text must be on this screen for the * window to keep the wheel; smaller, a scroll over it pans the canvas. Measured * rather than a fixed zoom: a Retina screen or the browser's own zoom * (devicePixelRatio counts both) leaves text readable far further out. */ const LEGIBLE_PX = 8; let element = $state<HTMLElement>();
function readableAt(under: Element | null): boolean { const id = under?.closest<HTMLElement>('.window-slot')?.dataset.id; const text = prefs.text.own ? prefs.text.canvas : prefs.text.ui; return text * drawn.zoom * (id ? board.windowZoom(id) : 1) * devicePixelRatio >= LEGIBLE_PX; }
/** What an up-down scroll over `under` moves: the innermost box with room that way, else the window's own scroller. */ function scrollerAt(under: HTMLElement | null, dy: number): HTMLElement | null { if (!readableAt(under)) return null; let fallback: HTMLElement | null = null; for (let box = under; box && !box.classList.contains('window-slot'); box = box.parentElement) { if (box.scrollHeight <= box.clientHeight + 1) continue; const overflow = getComputedStyle(box).overflowY; if (overflow !== 'auto' && overflow !== 'scroll') continue; if (dy < 0 ? box.scrollTop > 0 : box.scrollTop + box.clientHeight < box.scrollHeight - 1) return box; if (box.classList.contains('nowheel')) fallback ??= box; } return fallback; }
// The path crosses into shadow roots, so Pierre's code element counts. function scrollsSideways(event: WheelEvent, under: Element | null) { if (!readableAt(under)) return false; for (const target of event.composedPath()) { if (!(target instanceof HTMLElement) || target.classList.contains('window-slot')) break; const overflow = getComputedStyle(target).overflowX; if ((overflow === 'auto' || overflow === 'scroll') && target.scrollWidth > target.clientWidth + 1) return true; } return false; }
let hand = $state(false); let panning = $state<{ x: number; y: number } | null>(null); let carrying = $state<{ id: string; pointer: { x: number; y: number }; from: { x: number; y: number } } | null>(null); // Flow caches edge layers by edge/node identity. Refresh the edges with the // flag too: ending an Alt-drag changes no node, but must lower its links. const connectors = $derived({ edges: edges.map((edge) => ({ ...edge })), elevateEdgesOnSelect: !!carrying || nodes.some((node) => node.dragging), }); // The click that ends a carry is swallowed, so letting go over a link or a // button presses nothing (option-click would download a link). let swallow = false;
/** Whether `target` is in this canvas rather than one on it, which takes what happens over it. */ function here(target: EventTarget | null) { return (target as Element | null)?.closest?.('.canvas') === element; }
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 (!here(event.target)) return; const hit = event.button === 0 ? (event.target as Element | null)?.closest<HTMLElement>('.svelte-flow__node') : null; const frame = hit && event.altKey && !hand ? hit : null; const node = frame && nodes.find((candidate) => candidate.id === frame.dataset.id); // The middle button over a card belongs to the card (its links, the // browser's autoscroll), so only the empty canvas pans from it. if (event.button === 1 && (event.target as Element).closest('.svelte-flow__node')) return; if (!(event.button === 1 || (hand && event.button === 0) || node)) return; // Taken before Svelte Flow or a window sees it, so nothing drags, selects or autoscrolls. event.preventDefault(); event.stopPropagation(); element!.setPointerCapture(event.pointerId); const pointer = { x: event.clientX, y: event.clientY }; if (node) carrying = { id: node.id, pointer, from: node.position }; else panning = pointer; }
function drag(event: PointerEvent) { if (carrying) { const { pointer, from } = carrying; const zoom = drawn.zoom; board.place(carrying.id, { x: from.x + (event.clientX - pointer.x) / zoom, y: from.y + (event.clientY - pointer.y) / zoom }); return; } if (!panning) return; const { x, y, zoom } = drawn; board.setView({ x: x + event.clientX - panning.x, y: y + event.clientY - panning.y, zoom }, canvas.id); panning = { x: event.clientX, y: event.clientY }; }
function release() { if (carrying) { swallow = true; setTimeout(() => (swallow = false)); } panning = null; carrying = null; }
function click(event: MouseEvent) { if (!swallow) return; event.preventDefault(); event.stopPropagation(); }
$effect(() => { if (!element) return; let gesture: 'scroller' | 'stuck' | 'canvas' | null = null; let lastWheel = 0; const wheel = (event: WheelEvent) => { if (!here(event.target)) return; const rect = element!.getBoundingClientRect(); const under = (document.elementFromPoint(event.clientX, event.clientY) ?? event.target) as HTMLElement | null; if (event.altKey) { const slot = under?.closest<HTMLElement>('.window-slot'); if (slot?.dataset.id) { event.preventDefault(); board.zoomWindowBy(slot.dataset.id, zoomFactor(event)); return; } } const readable = readableAt(under); if (event.ctrlKey || event.metaKey) { // A readable window that zooms itself (a trace's timeline) takes the pinch. if (readable && under?.closest('.zooms-itself')) return; event.preventDefault(); board.zoomAt(zoomFactor(event), { x: event.clientX - rect.left, y: event.clientY - rect.top }, canvas.id); return; } // A menu floating over the canvas, or a view that pans and zooms itself (the agents tree), keeps // every scroll over it, even with nothing left to scroll. if (readable && under?.closest('.holds-wheel')) return; if (event.timeStamp - lastWheel > 140) gesture = null; lastWheel = event.timeStamp; const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX); if (!vertical && readable && under?.closest('.zooms-itself')) return; if (!vertical && gesture !== 'canvas' && scrollsSideways(event, under)) return; const scroller = gesture === 'canvas' ? null : scrollerAt(under, event.deltaY); // A two-finger scroll over a window often starts with a 1-2 px sideways wobble as // both fingers land; calling preventDefault on that first event cancels native // scrolling of the window for the whole gesture. if (scroller && (gesture === 'scroller' || gesture === 'stuck' || vertical || Math.abs(event.deltaX) <= 6)) { const scale = scroller.clientHeight ? scroller.getBoundingClientRect().height / scroller.clientHeight : 1; const by = pixels(event, event.deltaY) / scale; if (gesture === 'stuck') { event.preventDefault(); scroller.scrollTop += by; return; } if (Math.abs(event.deltaY) > 0) gesture = 'scroller'; // The browser scrolls the window. After a pinch over empty canvas, or a pan under // a still pointer, its scroll can stay latched on the canvas and the window never // moves; when a frame passes with room to scroll and no movement, the rest of that // gesture is moved here, by the fingers' own deltas. const from = scroller.scrollTop; const room = by < 0 ? from > 0 : from + scroller.clientHeight < scroller.scrollHeight - 1; if (room && by) requestAnimationFrame(() => requestAnimationFrame(() => { if (gesture !== 'scroller' || scroller.scrollTop !== from) return; gesture = 'stuck'; scroller.scrollTop += by; }), ); return; } gesture = 'canvas'; event.preventDefault(); const { x, y, zoom } = board.shownOf(canvas.id); board.setView({ x: x - event.deltaX, y: y - event.deltaY, zoom }, canvas.id); }; // Two fingers on a touch screen, over the canvas or a window: the spot between them // stays under them while their spread zooms the view and their travel pans it. One // finger is left alone (it scrolls a window, or pans empty canvas through Svelte Flow). // Until every finger lifts, moves go no further, so the finger left behind when the // other lifts does not start a drag. Lifts still do: Svelte Flow saw the first finger // land, and must see it go. let pinch: { distance: number; mid: { x: number; y: number }; view: { x: number; y: number; zoom: number } } | null = null; const spread = (touches: TouchList) => { const [a, b] = [touches[0], touches[1]]; const rect = element!.getBoundingClientRect(); return { distance: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, mid: { x: (a.clientX + b.clientX) / 2 - rect.left, y: (a.clientY + b.clientY) / 2 - rect.top }, }; }; const touch = (event: TouchEvent) => { if (!pinch && !here(event.target)) return; if (event.type === 'touchstart' && event.touches.length === 2) pinch = { ...spread(event.touches), view: { ...board.shownOf(canvas.id) } }; if (!pinch) return; if (event.type === 'touchstart' || event.type === 'touchmove') { if (event.cancelable) event.preventDefault(); event.stopPropagation(); } if (event.type === 'touchmove' && event.touches.length >= 2) { const now = spread(event.touches); const { view } = pinch; const zoom = clamp((view.zoom * now.distance) / pinch.distance, { min: ZOOM.min * scale, max: ZOOM.max * scale }); const at = { x: (pinch.mid.x - view.x) / view.zoom, y: (pinch.mid.y - view.y) / view.zoom }; board.setView({ x: now.mid.x - at.x * zoom, y: now.mid.y - at.y * zoom, zoom }, canvas.id); } if (event.touches.length === 0) pinch = null; }; // Safari's own pinch would zoom the whole page instead, from the top bar or the sidebar too. const noPageZoom = (event: Event) => event.preventDefault(); const touchTypes = ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const; for (const type of touchTypes) element.addEventListener(type, touch, { passive: false, capture: true }); document.addEventListener('gesturestart', noPageZoom); document.addEventListener('gesturechange', noPageZoom); element.addEventListener('wheel', wheel, { passive: false, capture: true }); element.addEventListener('pointerdown', grab, { capture: true }); return () => { for (const type of touchTypes) element?.removeEventListener(type, touch, { capture: true }); document.removeEventListener('gesturestart', noPageZoom); document.removeEventListener('gesturechange', noPageZoom); element?.removeEventListener('wheel', wheel, { capture: true }); element?.removeEventListener('pointerdown', grab, { capture: true }); }; });</script>
<svelte:window onkeydown={keydown} onkeyup={keyup} onblur={() => (hand = false)} />
<div class="canvas" class:hand class:panning={panning !== null} class:carrying={carrying !== null} class:resizing={board.resizing} class:quiet={!shown} bind:this={element} data-canvas={canvas.id} style:width={scale === 1 ? undefined : `${scale * 100}%`} style:height={scale === 1 ? undefined : `${scale * 100}%`} style:transform={scale === 1 ? undefined : `scale(${1 / scale})`} ondblclickcapture={dblclick} onclickcapture={click} onpointermove={drag} onpointerup={release} onpointercancel={release} onpointerenter={() => nested && ((over = true), hold())} onpointerleave={() => { if (board.pointed === canvas.id) board.pointed = null; if (over) (over = false), show(); }} onpointerover={(event) => { if (here(event.target)) board.pointed = canvas.id; if (onMap(event.target)) hold(); }} onpointerout={(event) => onMap(event.target) && !onMap(event.relatedTarget) && show()} role="presentation"> <!-- Its own provider: a flow on a canvas would otherwise find the outer flow's, and fail as it goes. --> <SvelteFlowProvider> <SvelteFlow bind:nodes={() => nodes, (next) => ((nodes = next), board.arrange(canvas.id, next))} edges={connectors.edges} {nodeTypes} colorMode={prefs.resolved} initialViewport={untrack(() => drawn)} minZoom={ZOOM.min * scale} maxZoom={ZOOM.max * scale} panOnDrag autoPanOnNodeDrag={!framed} noPanClass={framed ? `nopan-${canvas.id}` : undefined} panOnScroll={false} zoomOnScroll={false} zoomOnPinch={false} zoomOnDoubleClick={false} deleteKey={null} panActivationKey={null} nodesConnectable={false} elementsSelectable={false} elevateEdgesOnSelect={connectors.elevateEdgesOnSelect} proOptions={{ hideAttribution: true }} onmove={(_, viewport) => board.moved(canvas.id, viewport)} onnodedragstop={({ event, targetNode }) => targetNode && dropped(event, targetNode.id)} > {#if prefs.dots} <Dots gap={36} size={3} /> {/if} {#if canvas.map} <MiniMap position="bottom-right" pannable zoomable={false} width={mapWidth} height={Math.round((mapWidth * 104) / 168)} nodeBorderRadius={6} bgColor="var(--surface)" nodeColor="var(--minimap-window)" maskColor="var(--minimap-mask)" /> {/if} <FlowBinder {board} canvas={canvas.id} /> </SvelteFlow> </SvelteFlowProvider> <CanvasControls {board} {canvas} {shown} bind:width={mapWidth} onenter={hold} onleave={show} /></div>
<style> /* Fingers scroll windows natively; a pinch is the canvas's, never the page's. */ .canvas { position: absolute; inset: 0; touch-action: pan-x pan-y; /* The view bar fits itself to the canvas's width (tiles/CanvasControls.svelte). */ container: canvas / size; transform-origin: 0 0; } .hand, .hand :global(*) { cursor: grab !important; user-select: none; } .panning, .panning :global(*) { cursor: grabbing !important; } .carrying, .carrying :global(*) { cursor: move !important; user-select: none; } .canvas :global(.svelte-flow__minimap) { transition: opacity 160ms ease, visibility 0s; } .quiet :global(.svelte-flow__minimap) { visibility: hidden; opacity: 0; transition: opacity 600ms ease, visibility 0s linear 600ms; } @media (prefers-reduced-motion: reduce) { .canvas :global(.svelte-flow__minimap) { transition: none !important; } }</style>