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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336<script lang="ts"> import { anchorPoint, pick, selectionLines, targetBox, targetLines, textRange, textTarget } from './anchors'; import { chipForWindow, type Board, type NoteWindow } from './board.svelte'; import { selection, tying } from './tie.svelte';
// The lines from notes to what they are about. Targets live inside windows // that scroll and redraw on their own, so the lines are measured in screen // space every frame while any note is tied, and drawn over the canvas. let { board }: { board: Board } = $props(); let element = $state<SVGSVGElement>(); // Each tie is a line and, where its target is a passage, a tint over it, // so a note and what it is about read as one even with several on screen. // The note in hand (selected or under the pointer) draws strongest. type Box = { x: number; y: number; width: number; height: number }; let paths = $state<{ id: string; d: string; sent: boolean; lit: boolean; box: Box | null; lines: Box[] | null }[]>([]); let chipPaths = $state<{ id: string; d: string }[]>([]); let preview = $state<string | null>(null); // While a tie is drawn, what it would land on. let landing = $state<{ x: number; y: number; width: number; height: number; window: boolean } | null>(null);
// Which note the pointer is over, read from the canvas rather than asked of each note. let hovered = $state<string | null>(null); $effect(() => { const over = (event: PointerEvent) => { hovered = (event.target as Element | null)?.closest<HTMLElement>('.svelte-flow__node-note')?.dataset.id ?? null; }; document.addEventListener('pointerover', over); return () => document.removeEventListener('pointerover', over); });
const tied = $derived(board.nodes.filter((node): node is NoteWindow => node.type === 'note' && !!node.data.target)); const chipTied = $derived( board.nodes.flatMap((node) => { if (node.type === 'note' || !node.data.parent) return []; const kind = chipForWindow(node); if (!kind || !board.hasChip(node.data.parent, kind)) return []; return [{ id: node.id, chipNodeId: `chip:${node.data.parent}:${kind}` }]; }), );
// A passage of the client's own text is tinted under its words with the // CSS Custom Highlight API where there is one; a page's passages, and // every passage without it, by their lines over the canvas. const highlights = typeof CSS !== 'undefined' && 'highlights' in CSS ? CSS.highlights : null; const painted: Record<'normal' | 'lit' | 'sent', Range[]> = { normal: [], lit: [], sent: [] }; function paint(next: typeof painted) { for (const name of ['normal', 'lit', 'sent'] as const) { if (next[name].length === painted[name].length && next[name].every((range, index) => range === painted[name][index])) continue; painted[name] = next[name]; highlights!.set(`albedo-note-${name}`, new Highlight(...next[name])); } } $effect(() => () => { for (const name of ['normal', 'lit', 'sent']) highlights?.delete(`albedo-note-${name}`); });
// Text selected in a window gets a small button by it that makes a note // about exactly that. The page an artifact shows reports its own // selection through its bridge (artifacts.svelte.ts). let pressed = false; let offer = $state<{ x: number; y: number } | null>(null); // When the current selection first appeared: the note button waits for it // to settle, so it is never under the pointer the moment a drag ends. let offerSince: number | null = null; $effect(() => { let frame = 0; const read = () => { const chosen = document.getSelection(); const range = chosen?.rangeCount && !chosen.isCollapsed ? chosen.getRangeAt(0) : null; const typing = document.activeElement?.matches('textarea, input, [contenteditable]:not([contenteditable="false"])'); const target = range && !typing ? textTarget(range) : undefined; if (target) selection.current = { target, range: range!.cloneRange() }; else if (selection.current?.range) selection.current = null; }; const changed = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(read); }; const down = (event: PointerEvent) => { pressed = true; // A page's selection outlives a click outside it: the click lets go of // it, and offers again whatever is still selected in the client's text. const on = event.target as Element | null; if (selection.current?.page && !on?.closest('.grip, .selection-note')) { selection.current = null; changed(); } }; const up = () => (pressed = false); document.addEventListener('selectionchange', changed); document.addEventListener('pointerdown', down, true); document.addEventListener('pointerup', up, true); return () => { cancelAnimationFrame(frame); document.removeEventListener('selectionchange', changed); document.removeEventListener('pointerdown', down, true); document.removeEventListener('pointerup', up, true); }; });
function noteSelection(event: PointerEvent) { event.preventDefault(); const chosen = selection.current; const top = selectionLines()[0]?.top; if (!chosen || top === undefined) return; board.noteBeside(chosen.target, top - 16); selection.current = null; if (chosen.range) document.getSelection()?.removeAllRanges(); }
function curve(a: { x: number; y: number }, b: { x: number; y: number }) { const bend = Math.max(24, Math.abs(b.x - a.x) / 2); const direction = b.x >= a.x ? 1 : -1; return `M${a.x} ${a.y} C${a.x + bend * direction} ${a.y} ${b.x - bend * direction} ${b.y} ${b.x} ${b.y}`; }
function noteEnd(id: string, toward: number) { const rect = document.querySelector(`.svelte-flow__node[data-id="${CSS.escape(id)}"]`)?.getBoundingClientRect(); if (!rect) return null; return { x: toward < rect.left + rect.width / 2 ? rect.left : rect.right, y: rect.top + 16 }; }
$effect(() => { if (!element || (!tied.length && !chipTied.length && !tying.note && !selection.current)) { paths = []; chipPaths = []; preview = null; landing = null; offer = null; if (highlights) paint({ normal: [], lit: [], sent: [] }); return; } let frame = 0; const draw = () => { const origin = element!.getBoundingClientRect(); const local = (point: { x: number; y: number }) => ({ x: point.x - origin.left, y: point.y - origin.top }); const shift = (rect: DOMRect) => ({ x: rect.left - origin.left, y: rect.top - origin.top, width: rect.width, height: rect.height }); const next: typeof paths = []; const nextChips: typeof chipPaths = []; const ranges: typeof painted = { normal: [], lit: [], sent: [] }; for (const item of chipTied) { const chipEl = document.querySelector<HTMLElement>(`.svelte-flow__node[data-id="${CSS.escape(item.chipNodeId)}"]`); const targetEl = document.querySelector<HTMLElement>(`.svelte-flow__node[data-id="${CSS.escape(item.id)}"]`); if (!chipEl || !targetEl) continue; const rowEl = chipEl.querySelector<HTMLElement>(`[data-chip-target="${CSS.escape(item.id)}"]`); const chipRect = chipEl.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); const listEl = chipEl.querySelector<HTMLElement>('.list.inline'); const listRect = listEl?.getBoundingClientRect() ?? chipRect; const rowRect = rowEl?.getBoundingClientRect(); const rawY = rowRect ? rowRect.top + rowRect.height / 2 : chipRect.top + chipRect.height / 2; const startY = Math.max(listRect.top + 6, Math.min(listRect.bottom - 6, rawY)); const endY = targetRect.top + Math.min(21, targetRect.height / 2); const xOverlap = targetRect.left < chipRect.right - 16 && targetRect.right > chipRect.left + 16; const stacked = xOverlap && (targetRect.top > chipRect.bottom - 8 || targetRect.bottom < chipRect.top + 8); let d: string; if (stacked) { const useLeft = Math.abs(targetRect.left - chipRect.left) <= Math.abs(targetRect.right - chipRect.right); const sx = (useLeft ? chipRect.left : chipRect.right) - origin.left; const sy = startY - origin.top; const ex = (useLeft ? targetRect.left : targetRect.right) - origin.left; const ey = endY - origin.top; const outX = useLeft ? Math.min(sx, ex) - 28 : Math.max(sx, ex) + 28; d = `M${sx} ${sy} C${outX} ${sy} ${outX} ${ey} ${ex} ${ey}`; } else { const toRight = targetRect.left + targetRect.width / 2 >= chipRect.left + chipRect.width / 2; const start = { x: toRight ? chipRect.right : chipRect.left, y: startY }; const end = { x: toRight ? targetRect.left : targetRect.right, y: endY }; d = curve(local(start), local(end)); } nextChips.push({ id: `${item.chipNodeId}->${item.id}`, d }); } chipPaths = nextChips; for (const note of tied) { const rect = document.querySelector(`.svelte-flow__node[data-id="${CSS.escape(note.id)}"]`)?.getBoundingClientRect(); if (!rect) continue; const centre = { x: rect.left + rect.width / 2, y: rect.top + 16 }; const box = targetBox(note.data.target!); // A visible passage takes the line right to its own edge. const point = anchorPoint(note.data.target!, centre); const end = point && box ? { x: centre.x > box.left + box.width / 2 ? box.right : box.left, y: Math.min(box.bottom - 2, Math.max(box.top + 2, point.y)) } : point; const start = end && noteEnd(note.id, end.x); const lit = !!note.selected || hovered === note.id; const target = note.data.target!; const range = highlights && target.kind === 'text' ? textRange(target) : null; if (range) ranges[note.data.sent ? 'sent' : lit ? 'lit' : 'normal'].push(range); // A passage that cannot be found any more is tinted as the block it was in. const lines = range ? null : targetLines(target); const words = lines?.length ? lines.map(shift) : null; if (end && start) next.push({ id: note.id, d: curve(local(start), local(end)), sent: !!note.data.sent, lit, box: box && !range && !words ? shift(box) : null, lines: words }); } paths = next; if (highlights) paint(ranges); // Above the start of the selection, and only once it has settled for a // beat, so a press meant to start or grow a selection never lands on // the button; kept on the canvas, so it shows wherever the window sits. const chosen = !tying.note && !pressed && selectionLines(); const first = chosen && chosen[0]; if (chosen && offerSince === null) offerSince = performance.now(); if (!chosen) offerSince = null; const settled = offerSince !== null && performance.now() - offerSince > 400; const below = first ? first.top - origin.top < 38 : false; offer = first && settled ? { x: Math.max(8, Math.min(first.left - origin.left, origin.width - 72)), y: Math.max(8, below ? first.bottom - origin.top + 6 : first.top - origin.top - 30), } : null; if (tying.note && !board.nodes.some((node) => node.id === tying.note)) tying.note = null; if (tying.note) { const start = noteEnd(tying.note, tying.x); preview = start ? curve(local(start), local({ x: tying.x, y: tying.y })) : null; const found = pick(tying.x, tying.y); const rect = found?.rect; landing = rect ? { x: rect.left - origin.left, y: rect.top - origin.top, width: rect.width, height: rect.height, window: found!.target.kind === 'window' } : null; } else [preview, landing] = [null, null]; frame = requestAnimationFrame(draw); }; draw(); return () => cancelAnimationFrame(frame); });</script>
<svg class="tethers" bind:this={element} aria-hidden="true"> {#each chipPaths as path (path.id)} <path d={path.d} class="chip-tether" /> {/each} {#each paths as path (path.id)} {#if path.box}<rect class="passage" class:lit={path.lit} class:sent={path.sent} x={path.box.x} y={path.box.y} width={path.box.width} height={path.box.height} rx="3" />{/if} {#each path.lines ?? [] as line, index (index)}<rect class="passage words" class:lit={path.lit} class:sent={path.sent} x={line.x} y={line.y} width={line.width} height={line.height} rx="2" />{/each} <path d={path.d} class:sent={path.sent} class:lit={path.lit} /> <circle cx={path.d.split(' ').at(-2)} cy={path.d.split(' ').at(-1)} r={path.lit ? 3.5 : 2.5} class:sent={path.sent} /> {/each} {#if landing}<rect class="landing" class:whole={landing.window} x={landing.x - 2} y={landing.y - 1} width={landing.width + 4} height={landing.height + 2} rx="6" />{/if} {#if preview}<path d={preview} class="preview" />{/if}</svg>{#if offer} <button class="selection-note" style:left="{offer.x}px" style:top="{offer.y}px" title="leave a note on the selected text" onpointerdown={noteSelection}> <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path d="M3 3.5h10v7H7l-3 2.5v-2.5H3z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /></svg> note </button>{/if}
<style> .tethers { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 4; } path { fill: none; stroke: color-mix(in oklab, var(--accent) 55%, transparent); stroke-width: 1.5; } path.chip-tether { stroke: color-mix(in oklab, var(--accent) 40%, transparent); stroke-width: 1.5; stroke-dasharray: 4 6; } path.lit { stroke: var(--accent); stroke-width: 2.25; } .passage { fill: color-mix(in oklab, var(--accent) 10%, transparent); stroke: color-mix(in oklab, var(--accent) 35%, transparent); stroke-width: 1; } .passage.lit { fill: color-mix(in oklab, var(--accent) 18%, transparent); stroke: var(--accent); } circle { fill: var(--accent); } .sent { opacity: 0.35; } .landing { fill: color-mix(in oklab, var(--accent) 12%, transparent); stroke: color-mix(in oklab, var(--accent) 70%, transparent); stroke-width: 1.5; } .landing.whole { fill: none; rx: 12px; } /* A passage's own words, tinted line by line, without an outline. */ .passage.words { stroke: none; } .passage.words.lit { fill: color-mix(in oklab, var(--accent) 24%, transparent); } :global(::highlight(albedo-note-normal)) { background-color: color-mix(in oklab, var(--accent) 16%, transparent); } :global(::highlight(albedo-note-lit)) { background-color: color-mix(in oklab, var(--accent) 30%, transparent); } :global(::highlight(albedo-note-sent)) { background-color: color-mix(in oklab, var(--accent) 7%, transparent); } /* Under the end of the selection; the note itself goes beside the window. */ .selection-note { position: absolute; z-index: 5; display: flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px 0 7px; color: var(--surface); font: inherit; font-size: calc(12px * var(--ui-scale)); font-weight: 560; background: var(--accent); border: 0; border-radius: 999px; box-shadow: var(--shadow-raised); cursor: pointer; } .selection-note path { stroke: currentColor; stroke-width: 1.4; } .preview { stroke: var(--accent); stroke-dasharray: none; }</style>