import { getViewportForBounds, Position, type Edge, type Node, type Viewport, type useSvelteFlow } from '@xyflow/svelte'; import { uuidv7 } from './api'; import { growInto } from './agentTree'; import { deleteTerminal } from './terminals.svelte'; import { addTab, canvas as emptyCanvas, canvasAbove, canvasOf, childrenOf, find, frameOf, GUTTER, leaf, LEAST, leastAlong, parentOf, pathTo, paneTrail, place, raised, rearrange, remove as removeWindow, shape, split, tileOut, trail, splitBeside, swap as swapWindows, update, valid, windows as windowsIn, type Axis, type Canvas, type Leaf, type Placed, type Rect, squeezed, type Side, type Squeezed, type Tile, VIEW, } from './tiles/tree'; import { command } from './keys'; import { hosts } from './tiles/hosts'; import { windowTitle } from './tiles/title'; // A board is a set of windows and a layout that arranges them: tiles side by // side, tabs, and canvases where windows sit anywhere and the view pans and // zooms (tiles/tree.ts). On a canvas Svelte Flow draws, drags and sizes the // windows; the board places new ones and owns each canvas's 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; a canvas draws a // faint line between them, so a board reads as conversations with the // papers each one spread around it. type Of = { id: string; type: T; data: D }; export type SessionWindow = Of<'session', { sessionId: string; parent?: string; zoom?: number }>; /** * 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, and * `at` makes each ask new. */ export type ReviewWindow = Of<'review', { sessionId: string; turnId: string; from: number; parent?: string; jump?: { path: string; at: number }; zoom?: number }>; /** A background job's output, following the job; `job` is its id in the session's kernel. */ export type TerminalWindow = Of<'terminal', { sessionId: string; job: string; parent?: string; zoom?: number }>; /** An interactive pseudo-terminal window running a live shell session. */ export type PtyWindow = Of<'pty', { terminalId: string; sessionId?: string; cwd?: string; title?: string; parent?: string; zoom?: number }>; /** * 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 = Of<'trace', { sessionId: string; from: number; earlier?: number; parent?: string; zoom?: number }>; /** The agents of the family whose root is `sessionId`, as a tree. */ export type AgentsWindow = Of<'agents', { sessionId: string; parent?: string; fit?: { width: number; height: number }; zoom?: number }>; /** * 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 = Of<'artifact', { artifact: string; sessionId: string; version: number | null; latest: number; parent?: string; zoom?: number }>; export type ChipKind = 'model' | 'artifacts' | 'skills' | '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 = Of<'chip', { sessionId: string; chip: ChipKind; parent: string; zoom?: number }>; /** * 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: 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 = Of<'note', { text: string; target?: Target; sent?: boolean; alone?: boolean; zoom?: number }>; export type Window = SessionWindow | ReviewWindow | TerminalWindow | TraceWindow | AgentsWindow | ArtifactWindow | ChipWindow | NoteWindow | PtyWindow; export type WindowType = Window['type']; type DataOf = Extract['data']; export type Place = 'canvas' | 'float' | 'tab' | 'group' | 'tile'; /** What a window's component is mounted with (tiles/WindowSlot.svelte). */ export type WindowProps = { id: string; data: W['data']; selected: boolean }; /** A canvas's child as Svelte Flow sees it. */ export type SlotNode = Node<{ tile: Tile; board: Board }, 'slot'>; /** Which title-bar chip lists and opens a given window type, when undocked. */ export function chipForWindow(window: Window): ChipKind | null { if (window.type === 'terminal') return 'jobs'; if (window.type === 'artifact') return 'artifacts'; if (window.type === 'agents' || (window.type === 'session' && window.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; type Flow = ReturnType; export const SIZE = { width: 560, height: 720, 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; /** Where a window carried onto open canvas is held: its corner this far up and left of the pointer, in canvas units. */ export const GRAB = { x: 40, y: 16 }; /** What a group adds around its windows on a canvas: its header above, its edge around (nodes/SlotNode.svelte). */ const GROUP_EDGE = { width: 12, height: 30, top: 24 }; /** A new canvas made on a canvas. */ const CANVAS = { width: 720, height: 520 }; /** How small a window of a type may be resized on a canvas. */ export function limits(type: WindowType | undefined): { minWidth: number; minHeight: number } { if (type === 'chip') return { minWidth: CHIP.minWidth, minHeight: CHIP.minHeight }; if (type === 'pty') return { minWidth: 420, minHeight: 260 }; return { minWidth: SIZE.minWidth, minHeight: SIZE.minHeight }; } /** About how big a window of a type stands when nothing has sized it. */ function sizeOf(type: WindowType | undefined): { width: number; height: number } { return type === 'note' ? NOTE : type === 'chip' ? CHIP : SIZE; } /** A note or chip window takes the height of what it holds. */ function sizesItself(type: WindowType | undefined) { return type === 'note' || type === 'chip'; } /** The id of a canvas child's flow node: its window's for a lone window, else its tile's. */ export function nodeIdOf(placed: Placed): string { return placed.tile.kind === 'leaf' ? placed.tile.window : placed.tile.id; } /** Every canvas in a tree. */ function canvases(tile: Tile): Canvas[] { return [...(tile.kind === 'canvas' ? [tile] : []), ...childrenOf(tile).flatMap(canvases)]; } /** `value` kept within `range`. */ export function clamp(value: number, range: { min: number; max: number }): number { return Math.min(range.max, Math.max(range.min, value)); } /** `percent` stepped by `step` points up or down, landing on a multiple of `step`. */ function stepped(percent: number, by: 1 | -1, step: number): number { return by > 0 ? Math.floor(percent / step) * step + step : Math.ceil(percent / step) * step - step; } function clampWindowZoom(zoom: unknown): number | undefined { if (typeof zoom !== 'number' || !Number.isFinite(zoom)) return undefined; const clamped = Math.round(clamp(zoom, WINDOW_ZOOM) * 100) / 100; return clamped === 1 ? undefined : clamped; } function withWindowZoom(data: T): T { const zoom = clampWindowZoom(data.zoom); if (zoom === undefined) { const { zoom: _, ...rest } = data; return rest as T; } return { ...data, zoom }; } /** 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 cleaned(window: Window): Window { const data = window.type === 'note' ? aloneOnce(window.data) : window.data; return { id: window.id, type: window.type, data: withWindowZoom(data) } as Window; } /** A window floating over the board, at a point in the board's own pixels. */ export type Float = Omit & { tile: Leaf }; /** A step of the layout; `windows` are the windows it brings back (or, as null, closes again) on the way. */ type Layout = { root: Tile; floats: Float[]; windows?: Record }; type Left = { canvas: Canvas; tiled: Tile }; /** Where a window sat, to put it back: its spot on a canvas, or beside the first of `beside` still there (a tile next to it, or its tabs and then a tab beside it). */ type Spot = { canvas: string; at: Omit } | { beside: string[]; where: Side | 'tab'; index?: number }; /** A piece of a board's layout, with every window in it and its canvases at their views. */ export type Pane = { tile: Tile; windows: Record }; /** A pane popped out into a page of its own (popouts.svelte.ts), as it left, with where it sat; off a canvas, also the layout it left. */ export type Away = Pane & { spot: Spot | null; root?: Tile }; type Saved = { windows: Record; root: Tile; floats: Float[]; left: Left | null; away: Record }; /** This page is a popped-out pane's own (popouts.svelte.ts): its board holds that pane and is never saved. */ export const popped = new URLSearchParams(location.search).has('pop'); const all = new Map(); /** The board with `id`, while it exists. */ export function boardOf(id: string): Board | undefined { return all.get(id); } /** Every board, for the windows they have away. */ export function everyBoard(): Board[] { return [...all.values()]; } function read(key: string): any { try { return JSON.parse(localStorage.getItem(key) ?? ''); } catch { return null; } } /** A board saved before layouts: one canvas, its windows where they sat. */ function fromCanvas(saved: any): Saved { const nodes: (Window & { position?: { x: number; y: number }; width?: number; height?: number })[] = Array.isArray(saved?.nodes) ? saved.nodes : []; const windows: Record = {}; const children: Placed[] = []; nodes.forEach((node, at) => { if (!node?.id || windows[node.id]) return; windows[node.id] = cleaned(node); const width = node.width ?? sizeOf(node.type).width; const height = sizesItself(node.type) ? undefined : (node.height ?? SIZE.height); children.push({ tile: leaf(node.id), x: node.position?.x ?? 0, y: node.position?.y ?? 0, width, height, z: at + 1 }); }); return { windows, root: emptyCanvas(children, saved?.viewport?.zoom ? saved.viewport : VIEW), floats: [], left: null, away: {} }; } /** A tree saved before tiles had names: a group's name was on its place on the canvas, and tabs held windows and named the shown one. */ function named(tile: any): Tile { if (tile?.kind === 'canvas' && Array.isArray(tile.children)) { for (const placed of tile.children) { if (placed?.name !== undefined && placed.tile) placed.tile.name ??= placed.name; delete placed?.name; named(placed?.tile); } } else if ((tile?.kind === 'split' || tile?.kind === 'tabs') && Array.isArray(tile.children)) { tile.children.forEach(named); const shown = tile.kind === 'tabs' && tile.children.find((child: any) => child?.kind === 'leaf' && child.window === tile.active); if (shown) tile.active = shown.id; } return tile; } function load(id: string): Saved { const saved = read(`albedo.board.v3:${id}`); if (!saved?.root?.kind || typeof saved.windows !== 'object') return fromCanvas(read(`albedo.board.v2:${id}`)); const windows: Record = {}; for (const window of Object.values(saved.windows as Record)) if (window?.id) windows[window.id] = cleaned(window); let root: Tile = named(saved.root); for (const missing of windowsIn(root).filter((window) => !windows[window])) root = removeWindow(root, missing); if (!valid(root)) root = emptyCanvas(Object.keys(windows).map((window, at) => ({ tile: leaf(window), x: at * 40, y: at * 40, width: SIZE.width, height: SIZE.height, z: at + 1 }))); const floats: Float[] = (Array.isArray(saved.floats) ? saved.floats : []).filter( (float: Float) => float?.tile?.kind === 'leaf' && windows[float.tile.window] && !pathTo(root, float.tile.window), ); // A note never sits in a tile: one tiled before notes floated floats now. for (const note of windowsIn(root).filter((window) => windows[window].type === 'note' && !canvasOf(root, window))) { root = removeWindow(root, note); floats.push({ tile: leaf(note), x: 40 + floats.length * 24, y: 40 + floats.length * 24, width: NOTE.width, z: floats.length + 1 }); } const shown = new Set([...windowsIn(root), ...floats.map((float) => float.tile.window)]); for (const window of Object.keys(windows)) if (!shown.has(window)) delete windows[window]; const left = saved.left?.canvas?.kind === 'canvas' && saved.left.tiled?.kind ? { canvas: named(saved.left.canvas) as Canvas, tiled: named(saved.left.tiled) } : null; const away: Record = {}; for (const [id, gone] of Object.entries((saved.away ?? {}) as Record)) { const root = gone?.root?.kind ? gone.root : undefined; // Saved when only a window popped out, as `window`. const pane = gone?.window?.id === id ? { tile: (root && pathTo(root, id)?.at(-1)) || leaf(id), windows: { [id]: gone.window } } : gone; if (!pane?.tile?.kind || typeof pane.windows !== 'object') continue; away[id] = { tile: pane.tile, windows: Object.fromEntries(Object.entries(pane.windows).map(([window, record]) => [window, cleaned(record)])), spot: gone.spot ?? null, root }; } return { windows, root, floats, left, away }; } /** The element a window is shown in, wherever it sits, unless it is held unseen behind one filling the board. */ export function slotOf(window: string): HTMLElement | null { const slot = hosts.slot(window); return slot && getComputedStyle(slot).visibility !== 'hidden' ? slot : null; } /** The element a tile of the layout is drawn in, under `root`. */ export function tileElement(id: string, root: ParentNode = document): HTMLElement | null { return root.querySelector(`[data-tile="${CSS.escape(id)}"]`); } /** Pulses a window's outline once, to show where it is (WindowSlot draws it). */ export function flash(window: string) { slotOf(window)?.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 900, easing: 'ease-out', pseudoElement: '::before' }); } /** * Facing sides for a line between two slots on a canvas, from how far apart * their centres are against their sizes: a separated axis wins over an * overlapping one, so the curve stays in the gap. */ function tetherSides(source: Rect, target: Rect) { const dx = target.x + target.width / 2 - source.x - source.width / 2; const dy = target.y + target.height / 2 - source.y - source.height / 2; if (Math.abs(dx) / (source.width + target.width) >= Math.abs(dy) / (source.height + target.height)) { return dx >= 0 ? { sourceHandle: Position.Right, targetHandle: Position.Left } : { sourceHandle: Position.Left, targetHandle: Position.Right }; } return dy >= 0 ? { sourceHandle: Position.Bottom, targetHandle: Position.Top } : { sourceHandle: Position.Top, targetHandle: Position.Bottom }; } function paneRect(canvas: string | undefined) { return canvas ? (document.querySelector(`.canvas[data-canvas="${CSS.escape(canvas)}"]`)?.getBoundingClientRect() ?? null) : null; } /** What one board gives another (`Board.give`): a piece of its layout, its windows, and a name for the board it becomes. */ type Given = { name: string; root: Tile; windows: Record }; /** One board's windows and layout. Only the active board is on screen, so each canvas's flow is known only while it is. */ export class Board { readonly id: string; root = $state.raw(emptyCanvas()); /** Windows floating over the board rather than placed in it: notes with no canvas to sit on. Positions are the board's own pixels. */ floats = $state.raw([]); windows = $state.raw>({}); /** Panes popped out into pages of their own, by the window's or tile's id; each comes back when its page closes. */ away = $state.raw>({}); /** On a popped-out pane's page, where the windows opened from it go instead: the board it came from. */ openElsewhere: ((window: Exclude) => void) | null = null; /** The pane keys act on: a window, or a section, group or canvas by its tile's id. */ selected = $state(null); /** A window carried out of the layout while it is dragged: its place closes until it lands or goes back. */ lifted = $state(null); /** Whether a slot's edges are being dragged: no page's frame may take the pointer meanwhile (views/ArtifactView.svelte). */ resizing = $state(false); /** Where the next window opened or moved goes: on `side` of `pane`. */ presel = $state<{ pane: string; side: Side } | null>(null); /** What is picked to make a section of, in the order it was picked: windows, and in the tiles a canvas or a section by its id. */ picked = $state.raw>(new Set()); /** The tile whose name field is open, in whichever view shows its name. */ naming = $state(null); #expanded = $state(null); /** A window, section or canvas filling the board for now, over the rest: never saved and never a step. */ expanded = $derived(this.#expanded && this.expandable(this.#expanded) ? this.#expanded : null); /** Whether the expanded one is a window or a canvas carried off its canvas to fill the board, rather than a tile lifted out of the tiles. */ expandedOver = $derived(!!this.expanded && (this.windows[this.expanded] ? !!canvasOf(this.root, this.expanded) : !!canvasAbove(this.root, this.expanded))); /** A window just closed, for the board view to say where it was (`at`, when it showed); a new object each time. */ told = $state.raw<{ text: string; at?: DOMRect } | null>(null); #before: Tile | null = null; /** The canvas root left for another layout, and the layout it became, to come back to while nothing moved. */ #left = $state.raw(null); #past: Layout[] = []; #future: Layout[] = []; list = $derived(Object.values(this.windows)); /** * Each canvas's own view as it moves; written into the tree when the board * is saved. A canvas on a canvas is drawn at its own view inside the one * around it, so its windows show at both zooms (`shownOf`). */ views = $state.raw>({}); /** The innermost canvas under the pointer, while it is over one. */ pointed = $state(null); /** The canvas the view controls and keys work on: the one under the pointer, else the selected canvas or the selected pane's, else the first. */ canvas = $derived(this.#canvasAt(this.pointed) ?? this.#canvasAt(this.selected) ?? (this.selected ? canvasOf(this.root, this.selected) : null) ?? canvases(this.root)[0] ?? null); /** Notes made since the page loaded and not yet written in, which take the focus once. */ fresh = new Set(); #flows = new Map(); #whenShown = new Map void>(); #save: ReturnType | undefined; /** Artifact windows the reader closed, which a new version does not open again. */ #dismissed = new Set(); /** Agents windows that have grown to their tree once, and been brought into view for it. */ #grown = new Set(); #stop: () => void; constructor(id: string) { this.id = id; all.set(id, this); this.#stop = () => {}; if (popped) return; const saved = load(id); this.windows = saved.windows; // The overview map was one switch for the whole app before each canvas had its own. const root = saved.root; this.root = root.kind === 'canvas' && root.map === undefined && localStorage.getItem('albedo.minimap') === 'on' ? { ...root, map: true } : root; this.floats = saved.floats; this.#left = saved.left; this.away = saved.away; const key = `albedo.board.v3:${id}`; this.#stop = $effect.root(() => { $effect(() => { const { windows, views, floats, away, root: shown } = this; const left = this.#left; // Read either way, so the save follows the root once a lift ends. const root = this.#before ?? shown; clearTimeout(this.#save); this.#save = setTimeout(() => localStorage.setItem(key, JSON.stringify({ windows, root: withViews(root, views), floats, left, away })), 250); }); }); } /** Drops the saved layout, for a deleted board. */ forget() { clearTimeout(this.#save); this.#stop(); all.delete(this.id); localStorage.removeItem(`albedo.board.v3:${this.id}`); localStorage.removeItem(`albedo.board.v2:${this.id}`); } /** A canvas calls this when it comes on screen; anything asked of it while hidden runs then. */ attach(canvas: string, flow: Flow) { this.#flows.set(canvas, flow); const pending = this.#whenShown.get(canvas); this.#whenShown.delete(canvas); if (pending) requestAnimationFrame(() => pending(flow)); } detach(canvas: string, flow: Flow) { if (this.#flows.get(canvas) === flow) this.#flows.delete(canvas); } /** The current canvas's view as it is drawn. */ get viewport(): Viewport { return this.canvas ? this.shownOf(this.canvas.id) : VIEW; } #canvasAt(id: string | null): Canvas | null { const tile = id ? find(this.root, id) : null; return tile?.kind === 'canvas' ? tile : null; } viewOf(canvas: string): Viewport { const tile = this.views[canvas] ? null : find(this.root, canvas); return this.views[canvas] ?? (tile?.kind === 'canvas' ? tile.viewport : VIEW); } /** * How much the canvas around `canvas` scales it on screen: the zoom it is * shown at there, 1 for one in the tiles or filling the board. Its pane is * drawn this much bigger and scaled back down (Canvas.svelte), so the flow * inside it works in screen pixels as a canvas in the tiles does. */ scaleOf(canvas: string): number { const outer = this.expanded === canvas ? null : canvasAbove(this.root, canvas); return outer ? this.shownOf(outer.id).zoom : 1; } /** A canvas's view as its flow has it, in the screen pixels of its pane: its own, scaled by the canvas around it. */ shownOf(canvas: string): Viewport { const scale = this.scaleOf(canvas); const own = this.viewOf(canvas); return scale === 1 ? own : { x: own.x * scale, y: own.y * scale, zoom: own.zoom * scale }; } /** A view as a canvas's flow has it, as that canvas's own. */ #own(canvas: string, { x, y, zoom }: Viewport): Viewport { const scale = this.scaleOf(canvas); return { x: x / scale, y: y / scale, zoom: zoom / scale }; } /** The zoom a window is drawn at on screen: its canvas's, with every canvas round that, or 1 in a tile. */ zoomOf(window: string): number { const canvas = canvasOf(this.root, window); return canvas ? this.shownOf(canvas.id).zoom : 1; } /** The flow moved a canvas's view itself (a drag on empty canvas, the minimap); `viewport` is as the flow has it. */ moved(canvas: string, viewport: Viewport) { const own = this.#own(canvas, viewport); const was = this.viewOf(canvas); if (Math.abs(own.x - was.x) + Math.abs(own.y - was.y) + Math.abs(own.zoom - was.zoom) < 1e-6) return; this.views = { ...this.views, [canvas]: own }; this.#follow(canvas); } /** The canvases on `canvas` keep their own views as it moves: each flow is told the view it now draws. */ #follow(canvas: string) { const tile = find(this.root, canvas); for (const inner of tile ? canvases(tile) : []) if (inner !== tile) this.#flows.get(inner.id)?.setViewport(this.shownOf(inner.id)); } /** Moves a canvas's view now, without animation (wheel and pinch); `viewport` is as its flow has it (`shownOf`). */ setView(viewport: Viewport, canvas = this.canvas?.id) { if (!canvas) return; const scale = this.scaleOf(canvas); const own = this.#own(canvas, viewport); const zoom = clamp(own.zoom, ZOOM); this.views = { ...this.views, [canvas]: { ...own, zoom } }; this.#flows.get(canvas)?.setViewport({ ...viewport, zoom: zoom * scale }); this.#follow(canvas); } #glide(canvas: string, viewport: Viewport, duration: number) { this.views = { ...this.views, [canvas]: this.#own(canvas, viewport) }; this.#flows.get(canvas)?.setViewport(viewport, { duration }); this.#follow(canvas); } /** Zooms by a factor around a point on a canvas's pane, which stays under the pointer. */ zoomAt(factor: number, point: { x: number; y: number }, canvas = this.canvas?.id) { if (!canvas) return; const { x, y, zoom } = this.shownOf(canvas); const scale = this.scaleOf(canvas); const next = clamp((zoom / scale) * factor, ZOOM) * scale; const by = next / zoom; this.setView({ x: point.x - (point.x - x) * by, y: point.y - (point.y - y) * by, zoom: next }, canvas); } /** Glides a canvas's view to show every window on it, no closer than 100%. */ fit(canvas = this.canvas?.id) { const flow = canvas ? this.#flows.get(canvas) : undefined; const pane = paneRect(canvas); const nodes = flow?.getNodes() ?? []; if (!canvas || !flow || !pane || !nodes.length) return; const scale = this.scaleOf(canvas); // Not flow.fitView: that stays queued until its glide ends, and one cut short fits again on every change after. this.#glide(canvas, getViewportForBounds(flow.getNodesBounds(nodes), pane.width, pane.height, ZOOM.min * scale, scale, 0.12), 260); } zoomTo(zoom: number, canvas = this.canvas?.id) { const pane = paneRect(canvas); if (pane) this.zoomAt(zoom / this.viewOf(canvas!).zoom, { x: pane.width / 2, y: pane.height / 2 }, canvas); } /** Zooms in or out by 5 points, landing on a multiple of 5. */ stepZoom(by: 1 | -1, canvas = this.canvas?.id) { if (!canvas) return; this.zoomTo(stepped(Math.round(this.viewOf(canvas).zoom * 100), by, 5) / 100, canvas); } toggleMap(canvas = this.canvas?.id) { if (canvas) this.root = update(this.root, canvas, (tile) => (tile.kind === 'canvas' ? { ...tile, map: !tile.map } : tile)); } /** One window's own zoom, 1 when at 100%. */ windowZoom(id: string): number { return this.windows[id]?.data.zoom ?? 1; } /** Sets one window's own zoom, clamped to WINDOW_ZOOM. */ zoomWindow(id: string, zoom: number) { const next = clampWindowZoom(zoom); this.#patch(id, (window) => ({ ...window, data: withWindowZoom({ ...window.data, zoom: next }) }) as Window); } /** 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) { this.zoomWindow(id, stepped(Math.round(this.windowZoom(id) * 100), by, 10) / 100); } #patch(id: string, change: (window: Window) => Window) { const window = this.windows[id]; if (window) this.windows = { ...this.windows, [id]: change(window) }; } /** Changes the data of window `id` when it is a `type` window. */ #patchData(id: string, type: T, change: (data: DataOf) => Partial>) { this.#patch(id, (window) => (window.type === type ? ({ ...window, data: { ...window.data, ...change(window.data as DataOf) } } as Window) : window)); } /** * 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.windows[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. const placed = this.placedOf(id); if (fresh && placed) this.#patchData(id, 'agents', () => ({ fit: { width: placed.width, height: placed.height ?? 0 } })); } /** * Sizes an agents window on a canvas 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. In a tile the tile sizes it. */ fitAgents(id: string, width: number, height: number) { const window = this.windows[id]; const at = this.#holder(id); if (window?.type !== 'agents' || !at || at.placed.tile.kind !== 'leaf') return; const { canvas, placed } = at; const others = canvas.children.filter((other) => other !== placed && !this.#isNote(other)).map((other) => this.#box(other)); // No bigger than the pane shows at this zoom, as a new window is sized. const pane = paneRect(canvas.id); const zoom = this.shownOf(canvas.id).zoom; const shown = pane ? { width: pane.width / zoom - 48, height: pane.height / zoom - 140 } : { width, height }; const fitted = growInto(this.#box(placed), 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 === placed.width && fitted.height === placed.height && window.data.fit?.width === fitted.width && window.data.fit?.height === fitted.height) return; this.#setPlaced(canvas.id, id, (old) => ({ ...old, width: fitted.width, height: fitted.height })); this.#patchData(id, 'agents', () => ({ fit: fitted })); // The first growth of the window being worked in brings it whole into view; later ones leave the view where it is. if (this.selected === id && !this.#grown.has(id) && (fitted.width > placed.width || fitted.height > (placed.height ?? 0))) { this.#grown.add(id); this.#withFlow(canvas.id, () => 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.#patchData(id, 'review', () => ({ jump })); } /** 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 into a window of its own. */ hasChip(parent: string, chip: ChipKind): boolean { return !!this.windows[`chip:${parent}:${chip}`]; } /** * Tears a chat's chip off into a window of its own, docked into `target` as * `move` docks a window: a step ⌘Z takes back to the chip. */ undockChip(sessionId: string, chip: ChipKind, parent: string, target: string, where: Side | 'tab', options: { at?: { x: number; y: number }; index?: number } = {}): Squeezed | undefined { const id = `chip:${parent}:${chip}`; if (this.windows[id]) return; this.windows = { ...this.windows, [id]: { id, type: 'chip', data: { sessionId, chip, parent } } }; const refused = this.#move(id, target, where, options, { [id]: null }); if (!pathTo(this.root, id)) this.#close([id]); return refused; } /** 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.#patchData(id, 'trace', () => ({ earlier })); } /** 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.windows[id]) return this.#heard(id, version); if (!this.windows[parent] || this.#dismissed.has(id)) return; this.#add({ id, type: 'artifact', data: { artifact, sessionId, version: null, latest: version, parent } }, ARTIFACT.width); if (this.selected === parent) 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.windows[parent]; this.#withFlow(canvasOf(this.root, id)?.id, () => (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.#patchData(id, 'artifact', () => ({ version })); } #heard(id: string, version: number) { this.#patchData(id, 'artifact', (data) => (data.latest < version ? { latest: version } : {})); } /** * Puts a note at a point in a canvas's coordinates: the canvas of what it is * tied to, or the current one for a loose note. Tied to a window in a tile, * it goes beside it, as a window opened from it would. * Returns its id. */ addNote(position: { x: number; y: number }, target?: Target, text = '', canvas?: string): string { const id = `note:${uuidv7()}`; this.windows = { ...this.windows, [id]: { id, type: 'note', data: { text, target } } }; const home = canvas ?? (target ? canvasOf(this.root, target.window)?.id : this.canvas?.id); if (home) this.root = place(this.root, home, leaf(id), { ...this.#clearOfNotes(home, position), width: NOTE.width }); else this.floats = [...this.floats, { tile: leaf(id), ...this.#floatSpot(target?.window), width: NOTE.width, z: this.#topFloat() + 1 }]; this.select(id); this.fresh.add(id); return id; } /** The free spot nearest `spot` on a canvas for a new note, clear of the notes there by a small gap. */ #clearOfNotes(canvas: string, spot: { x: number; y: number }): { x: number; y: number } { const tile = find(this.root, canvas); const notes = tile?.kind === 'canvas' ? tile.children.filter((placed) => this.#isNote(placed)) : []; const floating = [...document.querySelectorAll('.board-view .window-slot[data-place="float"]')].flatMap((slot) => this.#onCanvas(canvas, slot.getBoundingClientRect()) ?? []); return nearestFree(spot, [...notes.map((placed) => ({ ...this.#box(placed), height: slotOf((placed.tile as Leaf).window)?.offsetHeight || NOTE.height })), ...floating]); } updateNote(id: string, data: Partial) { this.#patchData(id, 'note', () => data); } /** * Glides a canvas's 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, canvas = this.canvas?.id) { const pane = paneRect(canvas); const flow = canvas ? this.#flows.get(canvas) : undefined; if (!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 = flow.screenToFlowPosition(screen); flow.setCenter(point.x, point.y, { zoom: zoom * this.scaleOf(canvas!), duration: 300 + 200 * far }); } /** Puts a loose note at a point on screen, or the middle of a canvas's view; with no canvas, beside the selected window. */ noteAt(screen?: { x: number; y: number }, canvas = this.canvas?.id): string | null { if (!canvas) return this.addNote({ x: 0, y: 0 }); const pane = paneRect(canvas); const flow = this.#flows.get(canvas); if (!flow || !pane) return null; const point = 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 }, undefined, '', canvas); } /** * A spot on a window's canvas for a note beside it, at a height on screen: * right of it unless another window sits there, then left. */ besideWindow(window: string, screenY: number): { x: number; y: number; canvas: string } | null { const at = this.#holder(window); const flow = at && this.#flows.get(at.canvas.id); if (!at || !flow) return null; const { canvas, placed } = at; const y = flow.screenToFlowPosition({ x: 0, y: screenY }).y; const right = { x: placed.x + placed.width + 40, y }; const blocked = canvas.children.some((other) => other !== placed && !this.#isNote(other) && overlaps(this.#box(other), right, NOTE.width, 160)); return { x: blocked ? placed.x - NOTE.width - 40 : right.x, y, canvas: canvas.id }; } /** * 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 { if (!this.windows[target.window]) return null; const spot = this.besideWindow(target.window, screenY); if (!spot) return this.addNote({ x: 0, y: 0 }, target); const id = this.addNote({ x: spot.x, y: spot.y }, target, '', spot.canvas); this.#withFlow(spot.canvas, () => this.reveal(id)); return id; } /** Notes tied to anything in one window. */ notesOn(window: string): NoteWindow[] { return this.list.filter((note): note is NoteWindow => note.type === 'note' && note.data.target?.window === window); } /** The session a window belongs to, for sending what is tied to it. */ sessionOf(window: string): string | null { const found = this.windows[window]; return found && found.type !== 'note' ? (found.data.sessionId ?? null) : null; } /** * Where a window sits, for whatever looks or acts differently by it: * alone on a canvas, floating over the board, a tab, in a group of windows * on a canvas, or a tile. A new kind of container belongs here first. */ placeOf(window: string): Place { if (this.floats.some((float) => float.tile.window === window)) return 'float'; if (this.placedOf(window)) return 'canvas'; if (parentOf(this.root, window)?.kind === 'tabs') return 'tab'; return canvasOf(this.root, window) ? 'group' : 'tile'; } /** Where a window sits on its canvas, when it sits on one by itself. */ placedOf(window: string): Placed | null { const at = this.#holder(window); return at && at.placed.tile.kind === 'leaf' ? at.placed : null; } #open(window: Exclude, width = SIZE.width) { this.#add(window, width); this.focus(window.id); } #add(window: Exclude, width: number) { if (this.windows[window.id]) return; if (this.openElsewhere) return this.openElsewhere(window); const kind = chipForWindow(window); const chip = kind && window.data.parent ? `chip:${window.data.parent}:${kind}` : null; const anchor = chip && this.windows[chip] ? chip : window.data.parent && this.windows[window.data.parent]?.type !== 'note' ? window.data.parent : undefined; this.windows = { ...this.windows, [window.id]: window }; this.root = this.#placeNew(this.root, window.id, width, undefined, anchor); } /** * Where a new window goes: into the container of the window it came from * (or the selected one, or the last). On a canvas, at a free spot beside * it; in tabs, as a tab. In the tiles it splits the preselected tile, or * the tile of the window it came from, or else the biggest, along its * longer side, where both halves keep LEAST; with no such room anywhere it * joins the selected window (or the biggest tile) as a tab. `add` is what * goes there, window `id`'s leaf unless a pane coming back brings its own. */ #placeNew(root: Tile, id: string, width: number, height: number | undefined, near?: string, add: Tile = leaf(id)): Tile { const shown = shownFrames(root); const rectOf = (tile: Tile) => shown.find((frame) => frame.tile === tile)?.rect; const chosen = this.presel; this.presel = null; const beside = chosen && chosen.pane !== id ? frameOf(root, chosen.pane) : null; if (chosen && beside && halving(beside, rectOf(beside), chosen.side)) return splitBeside(root, beside.id, add, chosen.side); const anchor = [near, this.selected].find((candidate) => candidate && candidate !== id && paneTrail(root, candidate)) ?? windowsIn(root).filter((window) => window !== id).at(-1); const path = anchor ? paneTrail(root, anchor) : null; // A selected canvas takes the new window itself. const container = path?.at(-1)?.kind === 'canvas' ? path.at(-1) : path && path.length > 1 ? path[path.length - 2] : path ? null : root; if (container?.kind === 'canvas') return place(root, container.id, add, this.#freeSpot(container, id, width, height, anchor)); if (container?.kind === 'tabs') return addTab(root, container.id, add); if (!path) return update(root, root.id, () => add); const own = anchor === near || !shown.length ? path[path.length - 1] : null; const ownSide = own && halving(own, rectOf(own)); if (own && ownSide) return splitBeside(root, own.id, add, ownSide); const biggest = [...shown].sort((a, b) => b.rect.width * b.rect.height - a.rect.width * a.rect.height); const roomy = biggest.find((frame) => halving(frame.tile, frame.rect)); if (roomy) return splitBeside(root, roomy.tile.id, add, halving(roomy.tile, roomy.rect)!); const selected = this.selected && !canvasOf(root, this.selected) ? frameOf(root, this.selected) : null; return addTab(root, (selected ?? biggest[0].tile).id, add); } #isNote(placed: Placed) { return placed.tile.kind === 'leaf' && this.windows[placed.tile.window]?.type === 'note'; } #box(placed: Placed): Rect { const type = placed.tile.kind === 'leaf' ? this.windows[placed.tile.window]?.type : undefined; return { x: placed.x, y: placed.y, width: placed.width, height: placed.height ?? sizeOf(type).height }; } /** * The first window on a canvas starts at the top left of its view. After * that one goes right of the window it came from (or the selected one, or * the newest), top-aligned, stepping right past anything in the way, so * windows never land on each other. */ #freeSpot(canvas: Canvas, id: string, width: number, height: number | undefined, near?: string): Omit { const type = this.windows[id]?.type; const pane = paneRect(canvas.id); const { x, y, zoom } = this.shownOf(canvas.id); const tall = height ?? Math.round(Math.min(860, Math.max(480, (pane?.height ?? 800) / zoom - 140))); const size = { width, height: sizesItself(type) ? undefined : tall }; const boxes = canvas.children.filter((placed) => !this.#isNote(placed)); const holding = (pane: string | null | undefined) => (pane ? boxes.find((placed) => windowsIn(placed.tile).includes(pane) || find(placed.tile, pane)) : undefined); const anchor = holding(near) ?? holding(this.selected) ?? boxes.at(-1); if (!anchor) return { x: Math.round((48 - x) / zoom), y: Math.round((88 - y) / zoom), ...size }; const spot = { x: anchor.x + anchor.width + GAP, y: anchor.y }; let blocker: Placed | undefined; while ((blocker = boxes.find((placed) => overlaps(this.#box(placed), spot, width, tall)))) spot.x = blocker.x + blocker.width + GAP; return { ...spot, ...size }; } /** The canvas a window is placed freely on, and the child of it that holds the window. */ #holder(window: string, root = this.root): { canvas: Canvas; placed: Placed } | null { const canvas = canvasOf(root, window); const placed = canvas?.children.find((child) => windowsIn(child.tile).includes(window)); return canvas && placed ? { canvas, placed } : null; } #setPlaced(canvas: string, node: string, change: (placed: Placed) => Placed) { this.root = update(this.root, canvas, (tile) => (tile.kind === 'canvas' ? { ...tile, children: tile.children.map((placed) => (nodeIdOf(placed) === node ? change(placed) : placed)) } : tile)); } /** Writes where Svelte Flow has dragged or sized a canvas's children. */ arrange(canvas: string, nodes: { id: string; position: { x: number; y: number }; width?: number; height?: number }[]) { const tile = find(this.root, canvas); if (tile?.kind !== 'canvas') return; const byId = new Map(nodes.map((node) => [node.id, node])); let changed = false; const children = tile.children.map((placed) => { const node = byId.get(nodeIdOf(placed)); if (!node) return placed; const width = node.width ?? placed.width; const height = placed.tile.kind === 'leaf' && sizesItself(this.windows[placed.tile.window]?.type) ? undefined : (node.height ?? placed.height); if (node.position.x === placed.x && node.position.y === placed.y && width === placed.width && height === placed.height) return placed; changed = true; return { ...placed, x: node.position.x, y: node.position.y, width, height }; }); if (changed) this.root = update(this.root, canvas, () => ({ ...tile, children })); } /** Puts a canvas's child (a window, or a tile of windows) at a point in its coordinates, on top, as dragging it does. */ place(node: string, position: { x: number; y: number }) { const canvas = this.#placedNode(node)?.canvas; if (!canvas) return; const children = raised( canvas.children.map((placed) => (nodeIdOf(placed) === node ? { ...placed, ...position } : placed)), (placed) => nodeIdOf(placed) === node, ); this.root = update(this.root, canvas.id, () => ({ ...canvas, children })); if (this.windows[node]) this.selected = node; } /** * Selects a pane (a window, or a section, group or canvas in the layout) * and brings it to the front of its canvas, and that to the front of every * canvas round it, without moving the camera. */ select(id: string) { const path = paneTrail(this.root, id); if (!this.windows[id] && !(path && path.length > 1)) return; this.selected = id; this.floats = raised(this.floats, (float) => float.tile.window === id); let root = this.root; path?.forEach((tile, at) => { if (tile.kind !== 'canvas' || at === path.length - 1) return; const lifted = path[at + 1].id; root = update(root, tile.id, (canvas) => { if (canvas.kind !== 'canvas') return canvas; const children = raised(canvas.children, (child) => child.tile.id === lifted); return children === canvas.children ? canvas : { ...canvas, children }; }); }); this.root = root; } focus(id: string) { this.select(id); this.#withFlow(canvasOf(this.root, id)?.id, () => this.reveal(id)); } /** * Closes a window wherever it is, with any chip torn off it, as a step ⌘Z * takes back (an empty note goes for good). A shell closed with its window * comes back as a new one. */ remove(id: string, { undoable = true } = {}) { const window = this.windows[id]; if (!window) return; const gone = [id, ...this.list.filter((other) => other.type === 'chip' && other.data.parent === id).map((chip) => chip.id)]; if (undoable && !(window.type === 'note' && !window.data.text.trim())) { this.#remember(Object.fromEntries(gone.map((closing) => [closing, this.windows[closing]]))); const at = slotOf(id)?.getBoundingClientRect(); this.told = { text: window.type === 'note' ? 'deleted note' : `closed ${windowTitle(window)}`, at: at?.width ? at : undefined }; } this.#close(gone); } /** `ending`: the windows are closed for good, rather than popped out to come back. */ #close(gone: string[], ending = true) { for (const id of ending ? gone : []) { const window = this.windows[id]; if (window?.type === 'artifact') this.#dismissed.add(id); // A shell lives on its own until its window is closed, not while its window is merely out of sight. if (window?.type === 'pty') deleteTerminal(window.data.terminalId); } const windows = { ...this.windows }; let root = this.root; for (const closing of gone) { delete windows[closing]; root = removeWindow(root, closing); } this.windows = windows; this.root = root; this.floats = this.floats.filter((float) => !gone.includes(float.tile.window)); if (this.selected && gone.includes(this.selected)) this.selected = null; if (this.#expanded && gone.includes(this.#expanded)) this.#expanded = null; } /** * Takes a pane off the board into a page of its own, as a close would, * remembering where it sat; not a step, and the shells in it live on. A * window may be the whole board; a section or canvas may not. */ popOut(id: string) { if (this.lifted) this.unlift(); const pane = this.pane(id); if (!pane || this.windows[id]?.type === 'note' || (!this.windows[id] && pane.tile === this.root)) return; const spot = this.#spotOf(id); this.away = { ...this.away, [id]: { ...pane, spot, root: spot && 'canvas' in spot ? undefined : this.root } }; this.#close(windowsIn(pane.tile), false); if (find(this.root, pane.tile.id)) this.root = update(this.root, pane.tile.id, () => null); if (this.selected === id) this.selected = null; } /** * Puts a popped-out pane back where it sat, in the shape `pane` says its * page had when it left: there when the spot is still there and has room, * else as new. Its windows opened again on the board meanwhile stay as they are. */ bringBack(id: string, pane?: Pane) { const gone = this.away[id]; if (!gone) return; const { [id]: _, ...away } = this.away; this.away = away; const windows = { ...gone.windows, ...pane?.windows }; let tile = pane?.tile ?? gone.tile; for (const window of windowsIn(tile)) if (this.windows[window] || !windows[window]) tile = removeWindow(tile, window); if (!windowsIn(tile).length && tile.kind !== 'canvas') return; // Its page showed it without its name, as the whole of its board. if (tile.kind !== 'leaf' && gone.tile.kind !== 'leaf' && gone.tile.name) tile = { ...tile, name: gone.tile.name }; this.windows = { ...this.windows, ...Object.fromEntries(windowsIn(tile).map((window) => [window, cleaned(windows[window])])) }; const inside = new Set(canvases(tile).map((canvas) => canvas.id)); this.views = Object.fromEntries(Object.entries(this.views).filter(([canvas]) => !inside.has(canvas))); this.root = this.#settle(this.#putBack(tile, gone)); this.focus(tile.kind === 'leaf' ? tile.window : tile.id); } /** Opens a window asked for from a popped-out window's page, as the board would open it. */ adopt(window: Exclude) { const widths: Partial> = { artifact: ARTIFACT.width, trace: TRACE.width, agents: AGENTS.width, review: 640, terminal: 640, pty: 640 }; this.#open(window, widths[window.type] ?? sizeOf(window.type).width); } /** Window or tile `id` with every window in it, its canvases at their views now. */ pane(id: string): Pane | null { const tile = this.windows[id] ? pathTo(this.root, id)?.at(-1) : find(this.root, id); return tile ? { tile: withViews(tile, this.views), windows: Object.fromEntries(windowsIn(tile).map((window) => [window, this.windows[window]])) } : null; } /** * Takes window or tile `id` off this board, with every window in it, for a * board of its own (`receive`); not a step, and its shells live on. */ give(id: string): Given | null { if (this.lifted) this.unlift(); const pane = this.pane(id); if (!pane || pane.tile === this.root) return null; const { tile, windows } = pane; const name = tile.kind === 'leaf' ? windowTitle(windows[tile.window]) : (tile.name ?? ''); this.#close(windowsIn(tile), false); if (find(this.root, tile.id)) this.root = update(this.root, tile.id, () => null); return { name, root: tile.kind === 'leaf' ? tile : { ...tile, name: undefined }, windows }; } /** Shows what another board gave as this one's whole layout. */ receive(given: Given) { this.windows = given.windows; this.root = given.root; this.selected = windowsIn(given.root)[0] ?? null; } /** Makes this board the pane it shows on a popped-out pane's page, without the name its frame showed. */ showPane(id: string, { tile, windows }: Pane) { this.windows = windows; this.root = tile.kind === 'leaf' ? tile : { ...tile, name: undefined }; this.selected = windows[id] ? id : (windowsIn(tile)[0] ?? null); } #spotOf(id: string): Spot | null { const path = paneTrail(this.root, id); const own = path?.at(-1); const holder = path?.at(-2); if (!own || !holder) return null; if (holder.kind === 'canvas') { const { tile: _, z: __, ...at } = holder.children.find((placed) => placed.tile === own)!; return { canvas: holder.id, at }; } const siblings = childrenOf(holder); const index = siblings.indexOf(own); const before = siblings[index - 1]; const after = siblings[index + 1]; const near = before ?? after; if (holder.kind === 'tabs') return { beside: near ? [holder.id, near.id] : [holder.id], where: 'tab', index }; if (!near) return null; const row = holder.kind === 'split' && holder.axis === 'row'; return { beside: [near.id], where: before ? (row ? 'right' : 'bottom') : row ? 'left' : 'top' }; } /** Where a pane coming back goes: into the layout it left while nothing else has moved since, else its spot, else as new. */ #putBack(tile: Tile, gone: Away): Tile { const { spot, root } = gone; const type = tile.kind === 'leaf' ? this.windows[tile.window]?.type : undefined; if (root && shape(update(root, gone.tile.id, () => null)) === shape(this.root)) return update(root, gone.tile.id, () => tile); if (spot && 'canvas' in spot) { const canvas = find(this.root, spot.canvas); const { at } = spot; const height = at.height ?? sizeOf(type).height; const clear = (box: Rect) => at.x >= box.x + box.width || at.x + at.width <= box.x || at.y >= box.y + box.height || at.y + height <= box.y; if (canvas?.kind === 'canvas' && canvas.children.every((placed) => this.#isNote(placed) || clear(this.#box(placed)))) return place(this.root, canvas.id, tile, at); } else if (spot) { const target = spot.beside.find((beside) => find(this.root, beside)); const next = target && (spot.where === 'tab' ? addTab(this.root, target, tile, spot.index) : splitBeside(this.root, target, tile, spot.where)); if (next && valid(next) && find(next, tile.id) && !this.squeeze(next)) return next; } if (tile.kind === 'leaf') return this.#placeNew(this.root, tile.window, spot && 'canvas' in spot ? spot.at.width : sizeOf(type).width, undefined); const size = spot && 'canvas' in spot ? spot.at : CANVAS; return this.#placeNew(this.root, tile.id, size.width, size.height, undefined, tile); } /** Puts the same windows under a root of another kind, tiled by where they were shown, or spread out as they were. */ setRoot(kind: 'canvas' | 'split' | 'tabs') { this.#remember(); const rects = new Map(); for (const window of windowsIn(this.root)) { const rect = slotOf(window)?.getBoundingClientRect(); if (rect?.width) rects.set(window, { x: rect.left, y: rect.top, width: rect.width, height: rect.height }); } const left = Math.min(...[...rects.values()].map((rect) => rect.x)); const top = Math.min(...[...rects.values()].map((rect) => rect.y)); for (const [window, rect] of rects) rects.set(window, { ...rect, x: Math.round(rect.x - left), y: Math.round(rect.y - top) }); const leaving = this.root.kind === 'canvas' && kind !== 'canvas' ? (withViews(this.root, this.views) as Canvas) : null; const back = kind === 'canvas' ? this.#cameBack() : null; let root = back?.canvas ?? rearrange(this.root, kind, rects); // A note never becomes a tile: off a canvas it floats where it was on screen. for (const note of windowsIn(root).filter((window) => this.windows[window]?.type === 'note' && !canvasOf(root, window))) { this.#floatWhereShown(note); root = removeWindow(root, note); } // On a canvas root a floating note joins the canvas where it floated, so it pans with what it is about. if (root.kind === 'canvas') { const { x, y, zoom } = this.viewOf(root.id); for (const float of this.floats) if (!pathTo(root, float.tile.window)) root = place(root, root.id, float.tile, { x: (float.x - x) / zoom, y: (float.y - y) / zoom, width: float.width }); this.floats = []; } this.root = this.#settle(root); this.#left = leaving && { canvas: leaving, tiled: this.root }; const opened = back?.opened.at(-1); if (opened) this.#withFlow(this.root.id, () => this.reveal(opened)); } /** * The canvas this board left for the layout it has now, as it was, when * nothing has moved since but windows opened or closed: closed ones are left * out, opened ones placed as new. Null when there is none to go back to. */ #cameBack(): { canvas: Canvas; opened: string[] } | null { const left = this.#left; if (!left) return null; const now = new Set([...windowsIn(this.root), ...this.floats.map((float) => float.tile.window)]); const was = new Set(windowsIn(left.tiled)); const without = (root: Tile, keep: Set) => windowsIn(root).reduce((tile, window) => (keep.has(window) ? tile : removeWindow(tile, window)), root); if (shape(without(left.tiled, now)) !== shape(without(this.root, was))) return null; let root: Tile = without(left.canvas, now); const opened = [...now].filter((window) => !pathTo(root, window) && this.windows[window]?.type !== 'note'); for (const window of opened) root = this.#placeNew(root, window, SIZE.width, undefined); return root.kind === 'canvas' ? { canvas: root, opened } : null; } /** * Names a tile, as an undo step. An empty name clears a tab's own name; a * section keeps its old one, since losing it would ungroup (ungroup does that). */ rename(id: string, name: string) { const tile = find(this.root, id); const next = name.trim() || undefined; if (!tile || next === tile.name || (!next && tile.kind !== 'leaf')) return; this.#remember(); this.#name(id, next); } #name(id: string, name: string | undefined) { this.root = update(this.root, id, (tile) => ({ ...tile, name })); } /** * Lets a group go, one level. On a canvas each of its parts (a window, a * tabs block, a split) stays where it is on screen; in the tiles a section * loses its name and its frame, and its tiles stay where they are. */ ungroup(id: string) { if (this.selected === id) this.selected = null; const at = this.#placedNode(id); if (!at) { this.#remember(); return this.#name(id, undefined); } if (!paneRect(at.canvas.id)) return; const parts = childrenOf(at.placed.tile).map((part, index): Placed => { const shown = (part.kind === 'leaf' ? slotOf(part.window) : tileElement(part.id))?.getBoundingClientRect(); const box = (shown && this.#onCanvas(at.canvas.id, shown)) ?? { x: at.placed.x + index * 40, y: at.placed.y + index * 40, width: SIZE.width, height: at.placed.height }; const sized = part.kind === 'leaf' && sizesItself(this.windows[part.window]?.type); return { tile: part, ...box, height: sized ? undefined : box.height, z: 0 }; }); const children = at.canvas.children.flatMap((placed) => (placed === at.placed ? parts : [placed])); this.#remember(); this.root = update(this.root, at.canvas.id, () => ({ ...at.canvas, children: raised(children, (placed) => parts.includes(placed)) })); } #placedNode(node: string): { canvas: Canvas; placed: Placed } | null { for (const canvas of canvases(this.root)) { const placed = canvas.children.find((child) => nodeIdOf(child) === node); if (placed) return { canvas, placed }; } return null; } /** * Picks a window, or a canvas or section in the tiles, to make a section * of, or lets it go. Notes are never tiles, so never picked, and a group * on a canvas is picked by its windows. */ togglePick(id: string) { if (!this.#pickable(id)) return; const picked = new Set(this.picked); if (!picked.delete(id)) picked.add(id); this.picked = picked; } #pickable(id: string): boolean { if (this.windows[id]) return !!pathTo(this.root, id) && this.windows[id].type !== 'note'; const tile = trail(this.root, id).at(-1); return !!tile && tile.id !== this.root.id && (tile.kind === 'canvas' || !!tile.name); } unpick() { if (this.picked.size) this.picked = new Set(); } /** * Makes what is picked one section and opens its name field. When it is * all a split or tabs holds, that is named and nothing moves. Otherwise * in the tiles they are gathered into a split where the first picked one * was, across when that one is wider than tall, and on a canvas they become * a group laid out by where they sat. Returns why not, when it can't. */ makeSection(): string | null { const paths = [...this.picked].map((id) => (this.windows[id] ? (pathTo(this.root, id) ?? []) : trail(this.root, id))).filter((path) => path.length); // What lies inside another pick goes with it. const tiles = paths.map((path) => path.at(-1)!).filter((tile, _, all) => !all.some((other) => other !== tile && trail(other, tile.id).length)); if (tiles.length < 2 && paths.length > 1) return 'one of those holds the other: pick a section or what is in it, not both'; if (tiles.length < 2) return `pick two or more: ${command}-click title bars, section headers or a canvas`; const homes = new Set(tiles.map((tile) => (tile.kind === 'leaf' ? (canvasOf(this.root, tile.window)?.id ?? null) : null))); if (homes.size > 1) return homes.has(null) ? 'some of those are on a canvas and some in the tiles' : 'those are on different canvases'; let shared: Tile | null = null; for (let at = 0; paths.every((path) => path[at] && path[at] === paths[0][at]); at++) shared = paths[0][at]; const covered = (tile: Tile): boolean => tiles.includes(tile) || ((tile.kind === 'split' || tile.kind === 'tabs') && childrenOf(tile).every(covered)); const [canvas] = homes; let section: string | null; if (shared && (shared.kind === 'split' || shared.kind === 'tabs') && covered(shared)) { section = shared.id; this.#remember(); this.root = update(this.root, section, (tile) => ({ ...tile, name: tile.name ?? 'section' })); } else section = canvas ? this.#groupOn(canvas, tiles.map((tile) => (tile as Leaf).window)) : this.#gather(tiles); if (!section) return 'show that canvas first'; this.picked = new Set(); this.naming = section; return null; } /** Gathers tiles into a named split where the first of them is, in the order they are laid out. */ #gather(tiles: Tile[]): string { const [first, ...rest] = tiles; const shown = document.querySelector(`[data-tile="${CSS.escape(first.id)}"], [data-section="${CSS.escape(first.id)}"]`)?.getBoundingClientRect(); const order = (tile: Tile): Tile[] => (tiles.includes(tile) ? [tile] : childrenOf(tile).flatMap(order)); const section = { ...split(shown && shown.height > shown.width ? 'column' : 'row', order(this.root)), name: 'section' }; let root = this.root; for (const tile of rest) root = update(root, tile.id, () => null); this.#remember(); this.root = this.#settle(update(root, first.id, () => section)); return section.id; } /** Makes windows on a canvas a named group laid out by where they are on screen, placed where they are; null when the canvas is not on screen. */ #groupOn(canvas: string, picked: string[]): string | null { const parts = picked.flatMap((window, z): Placed[] => { const shown = slotOf(window)?.getBoundingClientRect(); const box = shown && this.#onCanvas(canvas, shown); return box ? [{ tile: pathTo(this.root, window)!.at(-1)!, ...box, z }] : []; }); if (parts.length < picked.length) return null; const section = { ...tileOut(parts)!, name: 'section' }; // As big as the windows are, side by side or stacked as they are tiled, not as far apart as they sat. const size = (tile: Tile): { width: number; height: number } => { const part = parts.find((part) => part.tile === tile); if (part) return { width: part.width, height: part.height! }; const sizes = childrenOf(tile).map(size); const sum = (key: 'width' | 'height') => sizes.reduce((total, size) => total + size[key], 0); const most = (key: 'width' | 'height') => Math.max(...sizes.map((size) => size[key])); return tile.kind === 'split' && tile.axis === 'row' ? { width: sum('width'), height: most('height') } : { width: most('width'), height: sum('height') }; }; const { width, height } = size(section); let root = this.root; for (const window of picked) root = removeWindow(root, window); this.#remember(); this.root = place(root, canvas, section, { x: Math.min(...parts.map((part) => part.x)) - GROUP_EDGE.width / 2, y: Math.min(...parts.map((part) => part.y)) - GROUP_EDGE.top, width: width + GROUP_EDGE.width, height: height + GROUP_EDGE.height, }); return section.id; } /** Closes a section, a group or a canvas inside the layout with everything in it, as one step ⌘Z takes back. */ closeTile(id: string) { const tile = find(this.root, id); if (!tile || tile.kind === 'leaf' || this.root.id === id) return; const inside = windowsIn(tile); const gone = [...inside, ...this.list.filter((other) => other.type === 'chip' && inside.includes(other.data.parent)).map((chip) => chip.id)]; this.#remember(Object.fromEntries(gone.map((closing) => [closing, this.windows[closing]]))); const at = tileElement(id)?.getBoundingClientRect(); const count = inside.length ? ` · ${inside.length} window${inside.length === 1 ? '' : 's'}` : ''; const kind = tile.kind === 'canvas' ? 'canvas' : trail(this.root, id).at(-2)?.kind === 'canvas' ? 'group' : 'section'; this.told = { text: `closed ${tile.name || kind}${count}`, at: at?.width ? at : undefined }; this.#close(gone); this.root = update(this.root, id, () => null); if (this.selected === id) this.selected = null; } /** * Lets a canvas inside the layout go and keeps what is on it: its windows * are tiled where it was, by where they sat, and its notes float. One on a * canvas lets them go onto the canvas round it, each where it shows, as one step. */ ungroupCanvas(id: string) { const tile = find(this.root, id); if (tile?.kind !== 'canvas' || this.root.id === id) return; if (this.selected === id) this.selected = null; const outer = canvasAbove(this.root, id); if (outer) return this.#spill(tile, outer); const notes = tile.children.filter((placed) => placed.tile.kind === 'leaf' && this.windows[placed.tile.window]?.type === 'note'); for (const note of notes) this.#floatWhereShown((note.tile as Leaf).window); this.#remember(); this.root = update(this.root, id, () => tileOut(tile.children.filter((placed) => !notes.includes(placed)))); } /** Takes `inner` off `outer`, its children kept their size and put where they show; off screen, where its corner was. */ #spill(inner: Canvas, outer: Canvas) { const corner = outer.children.find((placed) => placed.tile === inner) ?? { x: 0, y: 0 }; const view = this.viewOf(inner.id); const pane = paneRect(inner.id); const shown = this.shownOf(inner.id); const children = inner.children.map((placed): Placed => { const at = pane && this.#onCanvas(outer.id, { left: pane.left + shown.x + placed.x * shown.zoom, top: pane.top + shown.y + placed.y * shown.zoom }); return { ...placed, ...(at ? { x: at.x, y: at.y } : { x: corner.x + view.x + placed.x * view.zoom, y: corner.y + view.y + placed.y * view.zoom }) }; }); this.#remember(); this.root = update(this.root, outer.id, (tile) => tile.kind === 'canvas' ? { ...tile, children: raised([...tile.children.filter((placed) => placed.tile !== inner), ...children], (placed) => children.includes(placed)) } : tile, ); } /** Takes a note off its canvas to float where it is on screen, as dropping it outside the canvas does. */ floatOut(note: string) { if (!canvasOf(this.root, note) || this.root.kind === 'canvas') return; this.#floatWhereShown(note); this.#remember(); this.root = removeWindow(this.root, note); } /** Puts a floating note on a canvas where it is on screen, as dropping it onto the canvas does. */ dockFloat(window: string, canvas: string) { const float = this.floats.find((candidate) => candidate.tile.window === window); const shown = slotOf(window)?.getBoundingClientRect(); const box = shown && this.#onCanvas(canvas, shown); if (!float || !box) return; this.#remember(); this.floats = this.floats.filter((other) => other !== float); this.root = place(this.root, canvas, float.tile, { x: box.x, y: box.y, width: float.width }); this.select(window); } /** A box on screen in canvas `canvas`'s own units; null while that canvas is not on screen. */ #onCanvas(canvas: string, rect: { left: number; top: number; width?: number; height?: number }): Rect | null { const pane = paneRect(canvas); if (!pane) return null; const { x, y, zoom } = this.shownOf(canvas); return { x: (rect.left - pane.left - x) / zoom, y: (rect.top - pane.top - y) / zoom, width: (rect.width ?? 0) / zoom, height: (rect.height ?? 0) / zoom }; } /** Floats a note at the spot on the board where it shows now, else at a free one. */ #floatWhereShown(note: string) { const shown = slotOf(note)?.getBoundingClientRect(); const view = document.querySelector('.board-view')?.getBoundingClientRect(); const at = shown && view ? { x: Math.round(shown.left - view.left), y: Math.round(shown.top - view.top) } : this.#floatSpot(); this.floats = [...this.floats, { tile: leaf(note), ...at, width: NOTE.width, z: this.#topFloat() + 1 }]; } /** Moves a floating window to a point in the board's own pixels and lifts it above the others. */ moveFloat(window: string, at: { x: number; y: number }) { const moved = this.floats.map((float) => (float.tile.window === window ? { ...float, ...at } : float)); this.floats = raised(moved, (float) => float.tile.window === window); } #topFloat() { return Math.max(0, ...this.floats.map((float) => float.z)); } /** * Where a new floating note goes: beside the window it is about, right of * it where the board has room, else left of it, else just inside its * corner; loose, in the middle of the board. Moves to the nearest spot * clear of the notes on screen, so they never stack. */ #floatSpot(near?: string): { x: number; y: number } { const view = document.querySelector('.board-view')?.getBoundingClientRect(); if (!view) return { x: 40, y: 40 }; const rect = near ? slotOf(near)?.getBoundingClientRect() : undefined; const gap = 12; const spot = !rect ? { x: view.width / 2 - NOTE.width / 2, y: view.height / 3 } : { x: rect.right + gap + NOTE.width <= view.right ? rect.right + gap : rect.left - gap - NOTE.width >= view.left ? rect.left - gap - NOTE.width : rect.right - NOTE.width - gap, y: rect.top + 48, }; if (rect) { spot.x -= view.left; spot.y -= view.top; } const notes = [...document.querySelectorAll('.board-view .window-slot[data-type="note"]')].map((slot) => { const rect = slot.getBoundingClientRect(); return { x: rect.left - view.left, y: rect.top - view.top, width: rect.width, height: rect.height }; }); const free = nearestFree({ x: spot.x, y: Math.min(spot.y, view.height - NOTE.height) }, notes, { x: 0, y: 0, width: view.width, height: view.height }); return { x: Math.round(free.x), y: Math.round(free.y) }; } /** Notes and chips on a canvas take their own height; a tile of windows placed on one needs one. */ #settle(root: Tile): Tile { let next = root; for (const canvas of canvases(root)) { const children = canvas.children.map((placed) => { const own = placed.tile.kind === 'leaf' && sizesItself(this.windows[placed.tile.window]?.type); return { ...placed, height: own ? undefined : placed.height || SIZE.height }; }); next = update(next, canvas.id, (tile) => ({ ...tile, children }) as Canvas); } return next; } /** Sets a split's sizes, which sum to 1. */ resize(split: string, sizes: number[]) { this.#remember(); this.root = update(this.root, split, (tile) => (tile.kind === 'split' && sizes.length === tile.children.length ? { ...tile, sizes } : tile)); } /** Shows one child of a tabs tile. */ activate(tabs: string, child: string) { this.root = update(this.root, tabs, (tile) => (tile.kind === 'tabs' && tile.children.some((tab) => tab.id === child) ? { ...tile, active: child } : tile)); } /** * A whole canvas child split to take a window beside it grows by that * window rather than halving: the two keep their sizes, the one dropped on * stays where it was, and a window becoming a group makes room for the * group's header and edge. A window moved within its own group (`home`, * its windows before the move) has its room there already. */ #makeRoom(root: Tile, onto: Tile, window: string, side: Side, from: Placed | null, shown: DOMRect | undefined, home: string[]): Tile { const { canvas, placed } = this.#holder(window, root) ?? {}; if (!canvas || !placed || placed.tile.kind === 'leaf' || !childrenOf(placed.tile).includes(onto)) return root; if (windowsIn(onto).every((other) => home.includes(other))) return root; const zoom = this.shownOf(canvas.id).zoom; const width = from?.width ?? (shown ? shown.width / zoom : sizeOf(this.windows[window]?.type).width); const height = from?.height ?? (shown ? shown.height / zoom : 0); const held = onto.kind === 'leaf' ? (slotOf(onto.window)?.getBoundingClientRect().height ?? 0) / zoom : 0; const fresh = onto.kind === 'leaf' ? GROUP_EDGE : { width: 0, height: 0, top: 0 }; const across = side === 'left' || side === 'right'; const grown = { ...placed, x: placed.x - (side === 'left' ? width : 0) - fresh.width / 2, y: placed.y - (side === 'top' ? height : 0) - fresh.top, width: placed.width + (across ? width : 0) + fresh.width, height: (placed.height ?? held) + (across ? 0 : height) + fresh.height, }; return update(root, canvas.id, () => ({ ...canvas, children: canvas.children.map((child) => (child === placed ? grown : child)) })); } /** * What `next` would squeeze under LEAST, laid out where the board is now, * against `before`; null when it has room. */ squeeze(next: Tile, before = this.root): Squeezed | null { const box = tileElement(this.root.id)?.getBoundingClientRect(); return box ? squeezed(before, next, box.width, box.height) : null; } /** * Docks a window into tile `target`: beside it on `where`, or as a tab of * it; onto a canvas, at a free spot there. A window docked onto one sitting * on a canvas makes the two a tile of their own where that one sat. `at`, * a point on screen, is where on a canvas a window let go of over it lands. * Returns what it would have squeezed, when it did not move for that. */ move(window: string, target: string, where: Side | 'tab', options: { at?: { x: number; y: number }; index?: number } = {}): Squeezed | undefined { return this.#move(window, target, where, options); } /** `move`, remembering `windows` with the step as they were before it. */ #move(window: string, target: string, where: Side | 'tab', { at, index }: { at?: { x: number; y: number }; index?: number }, windows?: Layout['windows']): Squeezed | undefined { if (!this.windows[window]) return; if (this.lifted === window) this.unlift(); const own = pathTo(this.root, window)?.at(-1); if (own?.id === target) return; const from = this.placedOf(window); const home = windowsIn(this.#holder(window)?.placed.tile ?? leaf(window)); const shown = slotOf(window)?.getBoundingClientRect(); let next = removeWindow(this.root, window); const onto = find(next, target); if (!onto) return; if (onto.kind === 'canvas' && where === 'tab') { const width = Math.round(from?.width ?? shown?.width ?? sizeOf(this.windows[window].type).width); const height = from?.height ?? (shown ? Math.round(shown.height) : undefined); const point = at && this.#onCanvas(onto.id, { left: at.x, top: at.y }); // Held by its title bar, a little in from its corner. const spot = point ? { x: point.x - GRAB.x, y: point.y - GRAB.y, width, height } : this.#freeSpot(onto, window, width, height); next = place(next, onto.id, leaf(window), spot); } else if (where === 'tab') next = addTab(next, onto.id, leaf(window), index); else next = this.#makeRoom(splitBeside(next, onto.id, leaf(window), where), onto, window, where, from, shown, home); if (!valid(next) || !pathTo(next, window)) return; const refused = where === 'tab' ? null : this.squeeze(next); if (refused) return refused; this.#remember(windows); this.root = this.#settle(next); this.floats = this.floats.filter((float) => float.tile.window !== window); this.select(window); } /** Trades the places of two windows; returns what it would have squeezed, when it did not for that. */ swap(a: string, b: string): Squeezed | undefined { if (a === b || !pathTo(this.root, a) || !pathTo(this.root, b)) return; const next = swapWindows(this.root, a, b); const refused = this.squeeze(next); if (refused) return refused; this.#remember(); this.root = next; this.select(a); } /** * Moves a whole tile, such as a canvas or a section, to `where` of `target`: * a side, or into it as a tab at `index`. Returns what it would have * squeezed, when it did not move for that. */ moveTile(id: string, target: string, where: Side | 'tab', index?: number): Squeezed | undefined { if (this.lifted === id) this.unlift(); const tile = find(this.root, id); if (!tile || find(tile, target)) return; const rest = update(this.root, id, () => null); const next = where === 'tab' ? addTab(rest, target, tile, index) : splitBeside(rest, target, tile, where); if (!valid(next) || windowsIn(tile).some((window) => !pathTo(next, window))) return; const refused = where === 'tab' ? null : this.squeeze(next); if (refused) return refused; this.#remember(); this.root = this.#settle(next); } /** The tile `id` names, a window's leaf for a window. */ #tileOf(root: Tile, id: string): Tile | null { return find(root, id) ?? pathTo(root, id)?.at(-1) ?? null; } /** * What can fill the board: a window in the layout but a note, when there * is more than it on the board, a canvas, a group on a canvas, or a * section in the tiles. */ expandable(id: string): boolean { const window = this.windows[id]; if (window) return window.type !== 'note' && !!pathTo(this.root, id) && this.root.kind !== 'leaf'; const path = trail(this.root, id); const tile = path.at(-1); return !!tile && tile.id !== this.root.id && (tile.kind === 'canvas' || path.at(-2)?.kind === 'canvas' || (!!tile.name && path.slice(0, -1).every((above) => above.kind !== 'canvas'))); } toggleExpanded(id: string) { this.#expanded = this.expanded === id || !this.expandable(id) ? null : id; if (this.#expanded) this.select(id); } collapse() { this.#expanded = null; } /** Carries a window or a tile out of the layout for a drag: its place closes, and its windows stay mounted until it lands. */ lift(id: string) { const tile = this.#tileOf(this.root, id); if (this.lifted || !tile || tile === this.root) return; for (const window of windowsIn(tile)) hosts.hold(this.id, window); this.#before = this.root; this.lifted = id; this.root = update(this.root, tile.id, () => null); } /** Puts what was lifted back where it was. */ unlift() { const id = this.lifted; if (!id || !this.#before) return; const tile = this.#tileOf(this.#before, id); this.root = this.#before; this.#before = null; this.lifted = null; for (const window of tile ? windowsIn(tile) : []) hosts.free(this.id, window); } undo() { this.#step(this.#past, this.#future); } redo() { this.#step(this.#future, this.#past); } /** * Layout changes step back and forth one at a time, and so does closing a * window, whose step brings it back (or closes it again). Opening one is * not a step, so a step keeps today's windows: one closed since is left * out, and one opened since is placed again. */ #step(from: Layout[], to: Layout[]) { const layout = from.pop(); if (!layout) return; this.unlift(); this.#expanded = null; const back = layout.windows ?? {}; to.push({ root: this.root, floats: this.floats, windows: layout.windows && Object.fromEntries(Object.keys(back).map((id) => [id, this.windows[id] ?? null])) }); const closing = Object.keys(back).filter((id) => !back[id] && this.windows[id]); if (closing.length) this.#close(closing); for (const window of Object.values(back)) if (window) this.#dismissed.delete(window.id); this.windows = { ...this.windows, ...Object.fromEntries(Object.values(back).flatMap((window) => (window ? [[window.id, window]] : []))) }; let root = layout.root; for (const window of windowsIn(root)) if (!this.windows[window]) root = removeWindow(root, window); const floats = layout.floats.filter((float) => this.windows[float.tile.window]); const shown = new Set([...windowsIn(root), ...floats.map((float) => float.tile.window)]); for (const window of Object.values(this.windows)) { if (shown.has(window.id)) continue; if (window.type === 'note') floats.push({ tile: leaf(window.id), ...this.#floatSpot(), width: NOTE.width, z: floats.length + 1 }); else root = this.#placeNew(root, window.id, SIZE.width, undefined); } this.root = this.#settle(root); this.floats = floats; const opened = Object.values(back).find((window) => window && pathTo(this.root, window.id)); if (opened) this.focus(opened.id); } #remember(windows?: Layout['windows']) { this.#past.push({ root: this.#before ?? this.root, floats: this.floats, windows }); if (this.#past.length > 50) this.#past.shift(); this.#future = []; } /** Splits a new empty canvas off beside a tile; beside a window on a canvas, it goes on that canvas. */ addCanvas(beside: string, side: Side) { const home = canvasAbove(this.root, beside); if (home) return this.addCanvasOn(home.id); const next = splitBeside(this.root, beside, emptyCanvas(), side); if (!valid(next)) return; this.#remember(); this.root = next; } /** Puts a new empty canvas on a canvas, beside the selected window as a new window goes, and opens its name field. */ addCanvasOn(canvas = this.canvas?.id) { const home = canvas ? find(this.root, canvas) : null; if (home?.kind !== 'canvas') return; const made = emptyCanvas(); this.#remember(); this.root = place(this.root, home.id, made, this.#freeSpot(home, made.id, CANVAS.width, CANVAS.height)); this.naming = made.id; const at = this.#placedNode(made.id); if (at) this.#withFlow(home.id, () => this.#revealOn(at.canvas, at.placed)); } /** The lines a canvas draws between windows on it and the views they opened. */ edgesOf(canvas: Canvas): Edge[] { const nodeOf = new Map(); for (const placed of canvas.children) for (const window of windowsIn(placed.tile)) nodeOf.set(window, placed); return this.list.flatMap((window): Edge[] => { if (window.type === 'note' || !window.data.parent) return []; const source = nodeOf.get(window.data.parent); const target = nodeOf.get(window.id); if (!source || !target || source === target) return []; const kind = chipForWindow(window); if (kind && this.hasChip(window.data.parent, kind)) return []; return [{ id: `${window.data.parent}->${window.id}`, source: nodeIdOf(source), target: nodeIdOf(target), ...tetherSides(this.#box(source), this.#box(target)), type: 'default', selectable: false, focusable: false, class: 'tether' }]; }); } /** * Shows a window: every tab it is in, and on a canvas, pans just enough to * show it whole, keeping the zoom the reader chose; on a canvas on a canvas, * the outer one shows the inner one too. */ reveal(id: string) { const path = paneTrail(this.root, id) ?? []; path.slice(0, -1).forEach((tile, at) => tile.kind === 'tabs' && tile.active !== path[at + 1].id && this.activate(tile.id, path[at + 1].id)); for (let at = path.length - 2; at >= 0; at--) { const canvas = path[at]; const placed = canvas.kind === 'canvas' ? canvas.children.find((child) => child.tile === path[at + 1]) : undefined; if (placed) this.#revealOn(canvas as Canvas, placed); } } #revealOn(canvas: Canvas, placed: Placed) { const pane = paneRect(canvas.id); if (!pane || !this.#flows.has(canvas.id)) return; const box = this.#box(placed); const { x, y, zoom } = this.shownOf(canvas.id); const margin = 24; const left = box.x * zoom + x; const top = box.y * zoom + y; const right = left + box.width * zoom; const bottom = top + box.height * 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.#glide(canvas.id, { x: x + dx, y: y + dy, zoom }, 240); } /** * Shows several windows on one canvas whole: by panning when they fit at * this zoom, else by zooming out as far as still reads; past that, or when * they are not all on that canvas, the last one is shown. */ revealTogether(ids: string[]) { const held = ids.map((id) => this.#holder(id)); const canvas = held.at(-1)?.canvas; const pane = paneRect(canvas?.id); if (!canvas || !pane || !this.#flows.has(canvas.id) || held.some((at) => at?.canvas !== canvas)) return this.reveal(ids.at(-1)!); const boxes = held.map((at) => this.#box(at!.placed)); const margin = 24; const top = margin + 48; const left = Math.min(...boxes.map((box) => box.x)); const right = Math.max(...boxes.map((box) => box.x + box.width)); const upper = Math.min(...boxes.map((box) => box.y)); const lower = Math.max(...boxes.map((box) => box.y + box.height)); const view = this.shownOf(canvas.id); const fits = Math.min((pane.width - 2 * margin) / (right - left), (pane.height - top - margin) / (lower - upper)); const zoom = Math.min(view.zoom, fits); if (zoom < READABLE) return this.reveal(ids.at(-1)!); const moved = zoom !== view.zoom; let { x, y } = view; 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 === view.x && y === view.y) return; this.#glide(canvas.id, { x, y, zoom }, 300); } /** Selects the next or previous window in the layout and shows it. */ step(by: 1 | -1) { const ordered = windowsIn(this.root); const index = this.selected ? ordered.indexOf(this.selected) : -1; const next = ordered[index < 0 ? 0 : Math.min(ordered.length - 1, Math.max(0, index + by))]; if (next) this.focus(next); } /** Runs now when the canvas is on screen, else when it comes on screen; a window in no canvas needs none. */ #withFlow(canvas: string | undefined, run: (flow: Flow | null) => void) { const flow = canvas ? this.#flows.get(canvas) : undefined; if (flow || !canvas) run(flow ?? null); else this.#whenShown.set(canvas, run); } } /** The tree with each canvas's view as it now stands. */ function withViews(root: Tile, views: Record): Tile { let next = root; for (const canvas of canvases(root)) if (views[canvas.id]) next = update(next, canvas.id, (tile) => ({ ...tile, viewport: views[canvas.id] }) as Canvas); return next; } /** The steps of a 24 px grid 40 steps out each way, nearest first. */ const AROUND = Array.from({ length: 81 * 81 }, (_, at) => ({ dx: (Math.floor(at / 81) - 40) * 24, dy: ((at % 81) - 40) * 24 })).sort((a, b) => Math.hypot(a.dx, a.dy) - Math.hypot(b.dx, b.dy)); /** The spot nearest `spot`, on a grid around it and inside `within` if given, where a new note keeps a small gap from every box. */ function nearestFree(spot: { x: number; y: number }, boxes: Rect[], within?: Rect): { x: number; y: number } { const gap = 12; const inside = (at: { x: number; y: number }) => !within || (at.x >= within.x && at.y >= within.y && at.x + NOTE.width <= within.x + within.width && at.y + NOTE.height <= within.y + within.height); const clear = (at: { x: number; y: number }) => inside(at) && boxes.every((box) => at.x >= box.x + box.width + gap || at.x + NOTE.width + gap <= box.x || at.y >= box.y + box.height + gap || at.y + NOTE.height + gap <= box.y); for (const { dx, dy } of AROUND) { const at = { x: spot.x + dx, y: spot.y + dy }; if (clear(at)) return at; } return spot; } /** The tiles on screen a new window could split, outside canvases: each window's, and each canvas in the tiles, with its box. */ function shownFrames(root: Tile): { tile: Tile; rect: DOMRect }[] { const tiles = new Set(canvases(root).filter((canvas) => canvas !== root)); for (const window of windowsIn(root)) if (!canvasOf(root, window)) tiles.add(frameOf(root, window)!); return [...tiles].flatMap((tile) => { const rect = tileElement(tile.id)?.getBoundingClientRect(); return rect?.width ? [{ tile, rect }] : []; }); } /** * The side a split of a tile shown at `rect` puts a new window on, `side` or * along its longer side, when both halves keep every tile at LEAST; null when * they would not. A tile not on screen has room. */ function halving(tile: Tile, rect: DOMRect | undefined, side?: Side): Side | null { const along = side ?? (rect && rect.height > rect.width ? 'bottom' : 'right'); if (!rect) return along; const axis: Axis = along === 'left' || along === 'right' ? 'row' : 'column'; const half = ((axis === 'row' ? rect.width : rect.height) - GUTTER) / 2; return half >= Math.max(LEAST, leastAlong(tile, axis)) ? along : null; } function overlaps(box: Rect, spot: { x: number; y: number }, width: number, height: number) { return spot.x < box.x + box.width + GAP && spot.x + width + GAP > box.x && spot.y < box.y + box.height && spot.y + height > box.y; }