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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575import type { Edge, Node, Viewport, useSvelteFlow } from '@xyflow/svelte';import { uuidv7 } from './api';import { growInto } from './agentTree';
// A board is an open canvas: windows sit anywhere, the view pans freely and// zooms out to an overview. Svelte Flow draws, drags and sizes the windows;// the board places new ones and owns the viewport.//// A window's data names what it shows and the window looks the live record// up, so the saved board stays small and never holds stale daemon state.//// A view opened from a window names it as its parent; the canvas draws a// faint line between them, so a board reads as conversations with the// papers each one spread around it.export type SessionWindow = Node<{ sessionId: string; parent?: string; zoom?: number }, 'session'>;/** The changes one turn made, walked file by file. `from` is the turn's first history position. *//** `jump` asks the window to bring one file into view; `at` makes each ask new. */export type ReviewWindow = Node<{ sessionId: string; turnId: string; from: number; parent?: string; jump?: { path: string; at: number }; zoom?: number }, 'review'>;/** A background job's output, following the job; `job` is its id in the session's kernel. */export type TerminalWindow = Node<{ sessionId: string; job: string; parent?: string; zoom?: number }, 'terminal'>;/** An interactive pseudo-terminal window running a live shell session. */export type PtyWindow = Node<{ terminalId: string; sessionId?: string; cwd?: string; title?: string; parent?: string; zoom?: number }, 'pty'>;/** * A task's trace: the turn at `from` and the turns after it until the person * types again, with `earlier` of the turns before it. */export type TraceWindow = Node<{ sessionId: string; from: number; earlier?: number; parent?: string; zoom?: number }, 'trace'>;/** The agents of the family whose root is `sessionId`, as a tree. */export type AgentsWindow = Node<{ sessionId: string; parent?: string; fit?: { width: number; height: number }; zoom?: number }, 'agents'>;/** * A page the agent made, beside the chat it came from. `version` pins one * version, or is null to follow the newest; `latest` is the newest version * this board has heard of, so a window learns of the next one without asking. */export type ArtifactWindow = Node<{ artifact: string; sessionId: string; version: number | null; latest: number; parent?: string; zoom?: number }, 'artifact'>;export type ChipKind = 'model' | 'artifacts' | 'agents' | 'jobs';/** A title-bar chip dragged out of a chat window onto the canvas, tied to that window; closing it docks it back into the title bar. */export type ChipWindow = Node<{ sessionId: string; chip: ChipKind; parent: string; zoom?: number }, 'chip'>;/** * What a note is about. The quote is taken when the note is tied, so a sent * note says what it pointed at even if that has since scrolled or closed. */export type Target = | { kind: 'window'; window: string } | { kind: 'entry'; window: string; entry: string; step?: boolean; quote: string } | { kind: 'lines'; window: string; path: string; change: number; side: 'additions' | 'deletions'; start: number; end: number; quote: string } | { kind: 'element'; window: string; selector: string; version: number; quote: string } /** A passage of text in a window, inside the entry it was found in when there is one (quote.ts). */ | { kind: 'text'; window: string; entry?: string; quote: string; prefix: string; suffix: string } /** A passage of an artifact's page, inside the part `selector` names. */ | { kind: 'passage'; window: string; selector: string; version: number; quote: string; prefix: string; suffix: string };/** * A note on the canvas: free, or tied to a window, a message or step in a * chat, a passage of text, lines in a diff, or a part of an artifact's page. * An unsent note goes with its session's next message unless it is `alone`, * sent only by its own button. */export type NoteWindow = Node<{ text: string; target?: Target; sent?: boolean; alone?: boolean; zoom?: number }, 'note'>;export type Window = SessionWindow | ReviewWindow | TerminalWindow | TraceWindow | AgentsWindow | ArtifactWindow | ChipWindow | NoteWindow | PtyWindow;
/** Which title-bar chip lists and opens a given window type, when undocked. */export function chipForWindow(node: Window): ChipKind | null { if (node.type === 'terminal') return 'jobs'; if (node.type === 'artifact') return 'artifacts'; if (node.type === 'agents' || (node.type === 'session' && node.data.parent)) return 'agents'; return null;}
/** A note's width, and about how tall one just made stands. */export const NOTE = { width: 260, height: 110 };/** An undocked title-bar chip window's default and minimum size, and about how tall one stands. */export const CHIP = { width: 296, minWidth: 240, minHeight: 84, height: 132 };const ARTIFACT = { width: 720 };const TRACE = { width: 940 };const AGENTS = { width: 760 };/** Below this zoom a window's text is too small to read, so the view does not zoom out past it on its own. */const READABLE = 0.6;const HANDLES = { note: '.note-handle' };
type Flow = ReturnType<typeof useSvelteFlow>;
export const SIZE = { width: 560, minWidth: 360, minHeight: 320 };export const ZOOM = { min: 0.1, max: 1.5 };export const WINDOW_ZOOM = { min: 0.5, max: 2 };const GAP = 32;
function clampWindowZoom(zoom: unknown): number | undefined { if (typeof zoom !== 'number' || !Number.isFinite(zoom)) return undefined; const clamped = Math.round(Math.min(WINDOW_ZOOM.max, Math.max(WINDOW_ZOOM.min, zoom)) * 100) / 100; return clamped === 1 ? undefined : clamped;}
function withWindowZoom<T extends { zoom?: number }>(data: T): T { const zoom = clampWindowZoom(data.zoom); if (zoom === undefined) { const { zoom: _, ...rest } = data; return rest as T; } return { ...data, zoom };}
interface Saved { nodes: Window[]; viewport: Viewport | null;}
/** A note saved when `alone` was called `held`. */function aloneOnce({ held, ...data }: NoteWindow['data'] & { held?: boolean }): NoteWindow['data'] { return held && data.alone === undefined ? { ...data, alone: true } : data;}
function load(key: string): Saved { try { const saved = JSON.parse(localStorage.getItem(key) ?? ''); if (Array.isArray(saved?.nodes)) return { nodes: saved.nodes, viewport: saved.viewport?.zoom ? saved.viewport : null }; } catch {} return { nodes: [], viewport: null };}
/** One board's windows and view. Only the active board is on screen, so `flow` is set only while it is. */export class Board { nodes = $state.raw<Window[]>([]); edges = $derived( this.nodes.flatMap((node): Edge[] => { if (node.type === 'note' || !node.data.parent || !this.nodes.some((other) => other.id === node.data.parent)) return []; const kind = chipForWindow(node); if (kind && this.hasChip(node.data.parent, kind)) return []; return [{ id: `${node.data.parent}->${node.id}`, source: node.data.parent, target: node.id, type: 'default', selectable: false, focusable: false, class: 'tether' }]; }), ); viewport = $state.raw<Viewport>({ x: 0, y: 0, zoom: 1 }); flow: Flow | null = null; /** Notes made since the page loaded and not yet written in, which take the focus once. */ fresh = new Set<string>(); #key: string; #save: ReturnType<typeof setTimeout> | undefined; #whenShown: ((flow: Flow) => void) | null = null; /** Artifact windows the reader closed, which a new version does not open again. */ #dismissed = new Set<string>(); /** Agents windows that have grown to their tree once, and been brought into view for it. */ #grown = new Set<string>(); #topZ = 1; #stop: () => void;
constructor(id: string) { const key = (this.#key = `albedo.board.v2:${id}`); const saved = load(key); // A note or chip window takes the height of what it holds; other windows keep the height they were given. this.nodes = saved.nodes.map((node): Window => node.type === 'note' ? { ...node, height: undefined, data: withWindowZoom(aloneOnce(node.data)), dragHandle: HANDLES.note, selected: false } : node.type === 'chip' ? { ...node, width: node.width ?? CHIP.width, height: undefined, data: withWindowZoom(node.data), dragHandle: '.window-handle', selected: false } : ({ ...node, height: node.height ?? 720, data: withWindowZoom(node.data), dragHandle: '.window-handle', selected: false } as Window), ); this.viewport = saved.viewport ?? { x: 40, y: 72, zoom: 1 }; this.#stop = $effect.root(() => { $effect(() => { const snapshot = { nodes: this.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, width, height: type === 'note' || type === 'chip' ? undefined : height, data })), viewport: this.viewport }; clearTimeout(this.#save); this.#save = setTimeout(() => localStorage.setItem(key, JSON.stringify(snapshot)), 250); }); }); }
/** Drops the saved layout, for a deleted board. */ forget() { clearTimeout(this.#save); this.#stop(); localStorage.removeItem(this.#key); }
/** The canvas calls this when the board comes on screen; anything asked of it while hidden runs then. */ attach(flow: Flow) { this.flow = flow; const pending = this.#whenShown; this.#whenShown = null; if (pending) requestAnimationFrame(() => pending(flow)); }
detach() { this.flow = null; }
/** Moves the view to a viewport now, without animation (wheel and pinch). */ setView(viewport: Viewport) { const zoom = Math.min(ZOOM.max, Math.max(ZOOM.min, viewport.zoom)); this.viewport = { ...viewport, zoom }; this.flow?.setViewport(this.viewport); }
/** Zooms by a factor around a point on screen, which stays under the pointer. */ zoomAt(factor: number, point: { x: number; y: number }) { const { x, y, zoom } = this.viewport; const next = Math.min(ZOOM.max, Math.max(ZOOM.min, zoom * factor)); const scale = next / zoom; this.setView({ x: point.x - (point.x - x) * scale, y: point.y - (point.y - y) * scale, zoom: next }); }
fit() { this.flow?.fitView({ padding: 0.12, maxZoom: 1, duration: 260 }); }
zoomTo(zoom: number) { const pane = paneRect(); if (pane) this.zoomAt(zoom / this.viewport.zoom, { x: pane.width / 2, y: pane.height / 2 }); }
/** One window's own zoom, 1 when at 100%. */ windowZoom(id: string): number { return this.nodes.find((node) => node.id === id)?.data.zoom ?? 1; }
/** Sets one window's own zoom, clamped to WINDOW_ZOOM. */ zoomWindow(id: string, zoom: number) { const next = clampWindowZoom(zoom); this.nodes = this.nodes.map((node) => node.id === id ? ({ ...node, data: withWindowZoom({ ...node.data, zoom: next }) } as Window) : node, ); }
/** Multiplies one window's zoom by `factor` (trackpad pinch or Alt+scroll). */ zoomWindowBy(id: string, factor: number) { this.zoomWindow(id, this.windowZoom(id) * factor); }
/** Steps one window's zoom in or out by 10 points, landing on a multiple of 10. */ stepWindowZoom(id: string, by: 1 | -1) { const percent = Math.round(this.windowZoom(id) * 100); const next = by > 0 ? Math.floor(percent / 10) * 10 + 10 : Math.ceil(percent / 10) * 10 - 10; this.zoomWindow(id, next / 100); }
/** * Brings a session's window into view, placing it beside the selected (or * newest) window first, or beside `parent` when an agent is opened from it. */ openSession(sessionId: string, parent?: string) { this.#open({ id: `session:${sessionId}`, type: 'session', data: parent ? { sessionId, parent } : { sessionId } }); }
/** Opens the tree of a family's agents beside the window it was asked from. */ openAgents(rootId: string, parent?: string) { const id = `agents:${rootId}`; const fresh = !this.nodes.some((node) => node.id === id); this.#open({ id, type: 'agents', data: { sessionId: rootId, parent } }, AGENTS.width); // A new one is as big as it opens, which is a size it may still be fitted from. if (fresh) this.nodes = this.nodes.map((node) => (node.id === id && node.type === 'agents' ? { ...node, data: { ...node.data, fit: { width: node.width ?? AGENTS.width, height: node.height ?? 0 } } } : node)); }
/** * Sizes an agents window to its tree, as far as there is room, and keeps that * size with it, so after a reload it can be told from the person's own. */ fitAgents(id: string, width: number, height: number) { const node = this.nodes.find((candidate) => candidate.id === id); if (!node || node.type !== 'agents') return; const box = (window: Window) => ({ x: window.position.x, y: window.position.y, width: window.width ?? SIZE.width, height: window.height ?? 720 }); const others = this.nodes.filter((other) => other.id !== id && other.type !== 'note').map(box); // No bigger than the pane shows at this zoom, as #add sizes a new window. const pane = paneRect(); const shown = pane ? { width: pane.width / this.viewport.zoom - 48, height: pane.height / this.viewport.zoom - 140 } : { width, height }; const fitted = growInto(box(node), others, { width: Math.max(SIZE.minWidth, Math.min(width, shown.width)), height: Math.max(SIZE.minHeight, Math.min(height, shown.height)) }, GAP); if (fitted.width === node.width && fitted.height === node.height && node.data.fit?.width === fitted.width && node.data.fit?.height === fitted.height) return; this.nodes = this.nodes.map((candidate) => (candidate.id === id && candidate.type === 'agents' ? { ...candidate, ...fitted, data: { ...candidate.data, fit: fitted } } : candidate)); // The first growth of the window being worked in brings it whole into view; later ones leave the view where it is. if (node.selected && !this.#grown.has(id) && (fitted.width > (node.width ?? 0) || fitted.height > (node.height ?? 0))) { this.#grown.add(id); this.#withFlow(() => this.reveal(id)); } }
/** Opens the review of one turn's changes beside the window it was asked from. */ openReview(sessionId: string, turnId: string, from: number, parent?: string, path?: string) { const id = `review:${sessionId}:${turnId}`; const jump = path ? { path, at: Date.now() } : undefined; this.#open({ id, type: 'review', data: { sessionId, turnId, from, parent, jump } }, 640); if (jump) this.nodes = this.nodes.map((node) => (node.id === id && node.type === 'review' ? { ...node, data: { ...node.data, jump } } : node)); }
/** Opens a background job's output beside the window of the session that started it. */ openTerminal(sessionId: string, job: string, parent?: string) { this.#open({ id: `terminal:${sessionId}:${job}`, type: 'terminal', data: { sessionId, job, parent } }, 640); }
/** Opens an interactive terminal window with a live shell. */ openPty(options: { terminalId?: string; sessionId?: string; cwd?: string; title?: string; parent?: string } = {}) { const terminalId = options.terminalId ?? uuidv7(); const id = `pty:${terminalId}`; this.#open({ id, type: 'pty', data: { terminalId, sessionId: options.sessionId, cwd: options.cwd, title: options.title, parent: options.parent } }, 640); }
/** Whether a chat window's title-bar chip has been torn off onto the canvas. */ hasChip(parent: string, chip: ChipKind): boolean { return this.nodes.some((node) => node.type === 'chip' && node.data.parent === parent && node.data.chip === chip); }
/** Tears a title-bar chip out of its chat window onto the canvas at the drop position on screen. */ undockChip(sessionId: string, chip: ChipKind, parent: string, screen?: { x: number; y: number }): string { const id = `chip:${parent}:${chip}`; const position = screen && this.flow ? (() => { const point = this.flow.screenToFlowPosition(screen); return { x: Math.round(point.x - CHIP.width / 2), y: Math.round(point.y - 20) }; })() : this.#freeSpot(CHIP.height, CHIP.width, this.nodes.find((node) => node.id === parent)); const existing = this.nodes.some((node) => node.id === id); if (existing) { this.place(id, position); } else { const node: ChipWindow = { id, type: 'chip', position, width: CHIP.width, zIndex: ++this.#topZ, dragHandle: '.window-handle', selected: true, data: { sessionId, chip, parent } }; this.nodes = [...this.nodes.map((other) => (other.selected ? { ...other, selected: false } : other)), node]; } return id; }
/** Opens the trace of the task starting at `from`, beside the chat it came from. */ openTrace(sessionId: string, from: number, parent?: string) { this.#open({ id: `trace:${sessionId}:${from}`, type: 'trace', data: { sessionId, from, parent } }, TRACE.width); }
/** Shows `earlier` turns before the one a trace was opened on. */ traceEarlier(id: string, earlier: number) { this.nodes = this.nodes.map((node) => (node.id === id && node.type === 'trace' ? { ...node, data: { ...node.data, earlier } } : node)); }
/** Opens an artifact, beside the window it was asked from when there is one. */ openArtifact(artifact: string, sessionId: string, latest: number, parent?: string) { const id = `artifact:${artifact}`; this.#dismissed.delete(id); this.#heard(id, latest); this.#add({ id, type: 'artifact', data: { artifact, sessionId, version: null, latest, parent } }, ARTIFACT.width); this.#show(id, parent); }
/** * The agent made or revised an artifact in a chat on this board. An open * window moves to the new version in place, one the reader closed stays * closed, and otherwise one opens beside the chat. The view moves only when * that chat is the window being worked in, and then shows both. */ artifactMade(artifact: string, sessionId: string, version: number, parent: string) { const id = `artifact:${artifact}`; if (this.nodes.some((node) => node.id === id)) return this.#heard(id, version); const chat = this.nodes.find((node) => node.id === parent); if (!chat || this.#dismissed.has(id)) return; this.#add({ id, type: 'artifact', data: { artifact, sessionId, version: null, latest: version, parent } }, ARTIFACT.width); if (chat.selected) this.#show(id, parent); }
/** Selects a window and shows it, whole, beside the one it came from when that is on the board. */ #show(id: string, parent?: string) { this.select(id); const beside = parent && this.nodes.some((node) => node.id === parent); this.#withFlow(() => (beside ? this.revealTogether([parent, id]) : this.reveal(id))); }
/** Pins an artifact window to one version, or with null follows the newest. */ pinArtifact(id: string, version: number | null) { this.nodes = this.nodes.map((node) => (node.id === id && node.type === 'artifact' ? { ...node, data: { ...node.data, version } } : node)); }
#heard(id: string, version: number) { this.nodes = this.nodes.map((node) => node.id === id && node.type === 'artifact' && node.data.latest < version ? { ...node, data: { ...node.data, latest: version } } : node, ); }
/** Puts a note on the canvas at a point in canvas coordinates; returns its id. */ addNote(position: { x: number; y: number }, target?: Target, text = ''): string { const id = `note:${uuidv7()}`; const note: NoteWindow = { id, type: 'note', position, width: NOTE.width, zIndex: ++this.#topZ, dragHandle: HANDLES.note, data: { text, target } }; this.nodes = [...this.nodes.map((node) => (node.selected ? { ...node, selected: false } : node)), { ...note, selected: true }]; this.fresh.add(id); return id; }
updateNote(id: string, data: Partial<NoteWindow['data']>) { this.nodes = this.nodes.map((node) => (node.id === id && node.type === 'note' ? { ...node, data: { ...node.data, ...data } } : node)); }
/** * Glides the view to centre a point on screen, at a zoom: 300 ms from the * middle of the pane, up to 500 ms from a corner. */ centreOn(screen: { x: number; y: number }, zoom: number) { const pane = paneRect(); if (!this.flow || !pane) return; const away = Math.hypot(screen.x - (pane.left + pane.width / 2), screen.y - (pane.top + pane.height / 2)); const far = Math.min(1, away / (Math.hypot(pane.width, pane.height) / 2)); const point = this.flow.screenToFlowPosition(screen); this.flow.setCenter(point.x, point.y, { zoom, duration: 300 + 200 * far }); }
/** Puts a loose note at a point on screen, or the middle of the view. */ noteAt(screen?: { x: number; y: number }): string | null { const pane = paneRect(); if (!this.flow || !pane) return null; const point = this.flow.screenToFlowPosition(screen ?? { x: pane.left + pane.width / 2, y: pane.top + pane.height / 2 }); return this.addNote({ x: point.x - NOTE.width / 2, y: point.y - 20 }); }
/** * A spot for a note beside a window, at a height on screen: right of it * unless another window sits there, then left. */ besideWindow(window: string, screenY: number): { x: number; y: number } | null { const node = this.nodes.find((candidate) => candidate.id === window); if (!node || !this.flow) return null; const y = this.flow.screenToFlowPosition({ x: 0, y: screenY }).y; const right = { x: node.position.x + (node.width ?? SIZE.width) + 40, y }; const blocked = this.nodes.some((other) => other.type !== 'note' && other.id !== window && overlaps(other, right, NOTE.width, 160)); return blocked ? { x: node.position.x - NOTE.width - 40, y } : right; }
/** * Puts a note tied to something beside its window, level with a height on * screen, stepping down past notes already there so they never stack. */ noteBeside(target: Target, screenY: number): string | null { const spot = this.besideWindow(target.window, screenY); if (!spot) return null; for (const other of this.notesOn(target.window)) if (Math.abs(other.position.x - spot.x) < NOTE.width && Math.abs(other.position.y - spot.y) < 60) spot.y = other.position.y + 72; const id = this.addNote(spot, target); this.#withFlow(() => this.reveal(id)); return id; }
/** Notes tied to anything in one window. */ notesOn(window: string): NoteWindow[] { return this.nodes.filter((node): node is NoteWindow => node.type === 'note' && node.data.target?.window === window); }
/** The session a window belongs to, for sending what is tied to it. */ sessionOf(window: string): string | null { const node = this.nodes.find((candidate) => candidate.id === window); return node && node.type !== 'note' ? (node.data.sessionId ?? null) : null; }
#open(window: Pick<Exclude<Window, NoteWindow>, 'id' | 'type' | 'data'>, width = SIZE.width) { this.#add(window, width); this.focus(window.id); }
#add(window: Pick<Exclude<Window, NoteWindow>, 'id' | 'type' | 'data'>, width: number) { if (this.nodes.some((node) => node.id === window.id)) return; const height = Math.round(Math.min(860, Math.max(480, (paneRect()?.height ?? 800) / this.viewport.zoom - 140))); const kind = chipForWindow(window as Window); const chipId = kind && window.data.parent ? `chip:${window.data.parent}:${kind}` : null; const anchor = (chipId ? this.nodes.find((node) => node.id === chipId) : undefined) ?? this.nodes.find((node) => node.id === window.data.parent && node.type !== 'note'); const placed = { ...window, position: this.#freeSpot(height, width, anchor), width, height, dragHandle: '.window-handle' } as Window; this.nodes = [...this.nodes, placed]; }
/** Puts a window at a point in canvas coordinates, selected, as dragging it does. */ place(id: string, position: { x: number; y: number }) { const zIndex = ++this.#topZ; this.nodes = this.nodes.map((node) => (node.id === id ? { ...node, position, zIndex, selected: true } : node.selected ? { ...node, selected: false } : node)); }
/** Brings a window to the front without moving the camera. */ select(id: string) { const current = this.nodes.find((node) => node.id === id); if (!current || (current.selected && current.zIndex === this.#topZ && this.nodes.every((node) => node.id === id || !node.selected))) return; const zIndex = ++this.#topZ; this.nodes = this.nodes.map((node) => (node.id === id ? { ...node, zIndex, selected: true } : node.selected ? { ...node, selected: false } : node)); }
focus(id: string) { this.select(id); this.#withFlow(() => this.reveal(id)); }
remove(id: string) { if (id.startsWith('artifact:')) this.#dismissed.add(id); this.nodes = this.nodes.filter((node) => node.id !== id && !(node.type === 'chip' && node.data.parent === id)); }
/** Pans just enough to show a window whole, keeping the zoom the reader chose. */ reveal(id: string) { const node = this.nodes.find((candidate) => candidate.id === id); const pane = paneRect(); if (!node || !pane || !this.flow) return; const { x, y, zoom } = this.viewport; const margin = 24; const left = node.position.x * zoom + x; const top = node.position.y * zoom + y; const right = left + (node.width ?? SIZE.width) * zoom; const bottom = top + heightOf(node) * zoom; let dx = 0; let dy = 0; if (right > pane.width - margin) dx = pane.width - margin - right; if (left + dx < margin) dx = margin - left; if (bottom > pane.height - margin) dy = pane.height - margin - bottom; if (top + dy < margin + 48) dy = margin + 48 - top; if (!dx && !dy) return; this.viewport = { x: x + dx, y: y + dy, zoom }; this.flow.setViewport(this.viewport, { duration: 240 }); }
/** * Shows several windows whole: by panning when they fit at this zoom, else * by zooming out as far as still reads; past that, the last one is shown. */ revealTogether(ids: string[]) { const nodes = this.nodes.filter((node) => ids.includes(node.id)); const pane = paneRect(); if (!nodes.length || !pane || !this.flow) return; const margin = 24; const top = margin + 48; const left = Math.min(...nodes.map((node) => node.position.x)); const right = Math.max(...nodes.map((node) => node.position.x + (node.width ?? SIZE.width))); const upper = Math.min(...nodes.map((node) => node.position.y)); const lower = Math.max(...nodes.map((node) => node.position.y + heightOf(node))); const fits = Math.min((pane.width - 2 * margin) / (right - left), (pane.height - top - margin) / (lower - upper)); const zoom = Math.min(this.viewport.zoom, fits); if (zoom < READABLE) return this.reveal(ids.at(-1)!); const moved = zoom !== this.viewport.zoom; let { x, y } = this.viewport; if (moved || left * zoom + x < margin || right * zoom + x > pane.width - margin) x = (pane.width - (right - left) * zoom) / 2 - left * zoom; if (moved || upper * zoom + y < top || lower * zoom + y > pane.height - margin) y = top - upper * zoom; if (!moved && x === this.viewport.x && y === this.viewport.y) return; this.viewport = { x, y, zoom }; this.flow.setViewport(this.viewport, { duration: 300 }); }
/** Selects the window nearest in a direction from the selected one and shows it. */ step(by: 1 | -1) { const ordered = [...this.nodes].sort((a, b) => a.position.x - b.position.x || a.position.y - b.position.y); const index = ordered.findIndex((node) => node.selected); const next = ordered[index < 0 ? 0 : Math.min(ordered.length - 1, Math.max(0, index + by))]; if (next) this.focus(next.id); }
#withFlow(run: (flow: Flow) => void) { if (this.flow) run(this.flow); else this.#whenShown = run; }
/** * The first window starts at the top left of the view. After that one goes * right of the selected window (or the newest), top-aligned, stepping right * past anything in the way, so windows never land on each other. */ #freeSpot(height: number, width: number, near?: Window) { const windows = this.nodes.filter((node) => node.type !== 'note'); const anchor = near ?? windows.find((node) => node.selected) ?? windows.at(-1); if (!anchor) { const { x, y, zoom } = this.viewport; return { x: Math.round((48 - x) / zoom), y: Math.round((88 - y) / zoom) }; } const spot = { x: anchor.position.x + (anchor.width ?? SIZE.width) + GAP, y: anchor.position.y }; let blocker: Window | undefined; while ((blocker = windows.find((node) => overlaps(node, spot, width, height)))) spot.x = blocker.position.x + (blocker.width ?? SIZE.width) + GAP; return spot; }}
function paneRect() { return document.querySelector('.svelte-flow')?.getBoundingClientRect() ?? null;}
function heightOf(node: Window): number { return node.height ?? (node.type === 'note' ? NOTE.height : node.type === 'chip' ? CHIP.height : 720);}
function overlaps(node: Window, spot: { x: number; y: number }, width: number, height: number) { const wide = node.width ?? SIZE.width; const tall = heightOf(node); return spot.x < node.position.x + wide + GAP && spot.x + width + GAP > node.position.x && spot.y < node.position.y + tall && spot.y + height > node.position.y;}