import { pageHosts, type PageHost } from './artifacts.svelte'; import type { Target } from './board.svelte'; import { locate, quoteAt, tidy } from './quote'; import { parseDiff } from './review'; import { selection } from './tie.svelte'; // Where a note's target is on screen, and what is under a point when a note // is dropped. Targets are found in the DOM by what they show, so a tie // survives the window redrawing: a window by its node id, a chat message or // step by `data-entry`, a passage of text by its words inside the entry it // was in, diff lines by the diff's `data-diff` key and the line's number // inside Pierre's shadow root. An artifact's page is another document, so it // is asked instead: what is under a point, and where the parts and passages // notes are tied to are, through its bridge. /** Diffs on screen by key, so a dropped note can quote the lines it lands on. */ export const diffs = new Map(); export function diffKey(window: string, path: string, change: number) { return `${window}#${path}#${change}`; } type TextTarget = Extract; type PassageTarget = Extract; function windowOf(element: Element | null): string | null { const node = element?.closest('.svelte-flow__node'); return node && !node.classList.contains('svelte-flow__node-note') ? (node.dataset.id ?? null) : null; } function windowNode(window: string): Element | null { return document.querySelector(`.svelte-flow__node[data-id="${CSS.escape(window)}"]`); } /** The blocks of a message a dropped note picks out: what reads as one paragraph, item, code block or row. */ const BLOCKS = 'p, li, pre, h1, h2, h3, h4, h5, h6, tr, blockquote, dt, dd'; function union(rects: DOMRect[]): DOMRect | null { if (!rects.length) return null; const left = Math.min(...rects.map((rect) => rect.left)); const top = Math.min(...rects.map((rect) => rect.top)); return new DOMRect(left, top, Math.max(...rects.map((rect) => rect.right)) - left, Math.max(...rects.map((rect) => rect.bottom)) - top); } function intersect(rect: DOMRect, frame: DOMRect): DOMRect | null { const top = Math.max(rect.top, frame.top); const bottom = Math.min(rect.bottom, frame.bottom); const left = Math.max(rect.left, frame.left); const right = Math.min(rect.right, frame.right); return bottom > top && right > left ? new DOMRect(left, top, right - left, bottom - top) : null; } /** What a target's on-screen box is held inside: whatever scrolls it, else its window. */ function frameOf(element: Element): DOMRect | undefined { const root = element.getRootNode(); const light = root instanceof ShadowRoot ? root.host : element; return (light.closest('.nowheel') ?? light.closest('.svelte-flow__node'))?.getBoundingClientRect(); } /** Where a passage was found: the entry it was in, else its whole window. */ function scopeOf(target: TextTarget): Element | null { const node = windowNode(target.window); return (target.entry ? node?.querySelector(`[data-entry="${CSS.escape(target.entry)}"]`) : null) ?? node; } /** A range over a stretch of a scope's text, by offsets into its `textContent`. */ function rangeAt(scope: Node, start: number, end: number): Range | null { const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); const range = document.createRange(); let seen = 0; let begun = false; for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { const next = seen + node.data.length; if (!begun && start < next) { range.setStart(node, start - seen); begun = true; } if (begun && end <= next) { range.setEnd(node, end - seen); return range; } seen = next; } return null; } /** How far into a scope's text a point in it is. */ function offsetIn(scope: Node, container: Node, offset: number): number { const before = document.createRange(); before.setStart(scope, 0); before.setEnd(container, offset); return before.toString().length; } /** Ranges already found, kept while their text is still there. */ const found = new Map(); /** The range a passage covers now, found again by its words when the window has redrawn. */ export function textRange(target: TextTarget): Range | null { const key = JSON.stringify([target.window, target.entry, target.prefix, target.quote, target.suffix]); const known = found.get(key); if (known?.startContainer.isConnected && known.endContainer.isConnected && known.toString() === target.quote) return known; found.delete(key); const scope = scopeOf(target); const at = scope && locate(scope.textContent ?? '', target); const range = at ? rangeAt(scope!, at.start, at.end) : null; if (range) found.set(key, range); return range; } /** A passage of a window's own text as a note's target: a selection, or a block of a message. */ export function textTarget(range: Range): Target | undefined { const element = (node: Node) => (node instanceof Element ? node : node.parentElement); const start = element(range.startContainer); const window = windowOf(start); if (!window || windowOf(element(range.endContainer)) !== window || start?.closest('textarea, input, [contenteditable]')) return undefined; const entry = element(range.commonAncestorContainer)?.closest('[data-entry]'); const scope = entry ?? windowNode(window); if (!scope) return undefined; const from = offsetIn(scope, range.startContainer, range.startOffset); const passage = quoteAt(scope.textContent ?? '', from, from + range.toString().length); return passage.quote.trim() ? { kind: 'text', window, entry: entry?.dataset.entry, ...passage } : undefined; } /** Where the selected text is on screen, line by line. */ export function selectionLines(): DOMRect[] { const current = selection.current; if (!current) return []; if (current.range) return rangeLines(current.range); const frame = current.page.frameBox()?.rect; return frame ? current.lines.flatMap((line) => intersect(current.page.onScreen(line) ?? frame, frame) ?? []) : []; } const QUOTE_CHARS = 600; function excerpt(text: string) { const trimmed = text.replace(/\s+\n/g, '\n').trim(); return trimmed.length > QUOTE_CHARS ? `${trimmed.slice(0, QUOTE_CHARS)}…` : trimmed; } /** The lines of a diff a note covers, as diff text with their signs. */ export function quoteLines(diff: string, side: 'additions' | 'deletions', start: number, end: number): string { return parseDiff(diff) .flatMap((line) => { if (line.kind === 'hunk') return []; const number = side === 'additions' ? line.new : line.old; if (number === null || number < start || number > end) return []; return [(line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' ') + line.text]; }) .join('\n'); } /** What a note dropped at a point on screen would be about, and where that is on screen. */ export function pick(x: number, y: number): { target: Target; rect: DOMRect } | undefined { // Text the person selected is what they mean, wherever it is. const chosen = selection.current; const lines = chosen ? selectionLines() : []; if (chosen && lines.some((line) => x >= line.left - 4 && x <= line.right + 4 && y >= line.top - 2 && y <= line.bottom + 2)) return { target: chosen.target, rect: union(lines)! }; const hit = document.elementsFromPoint(x, y).find((element) => !element.closest('.svelte-flow__node-note')); if (!hit) return undefined; const window = windowOf(hit); if (!window) return undefined; const page = pageHosts.get(window); const part = page && pagePart(window, page, x, y); if (part) return part; if (hit.tagName.toLowerCase() === 'diffs-container' && hit.shadowRoot) { const row = (hit.shadowRoot.elementsFromPoint(x, y) as Element[]).map((element) => element.closest('[data-line]')).find(Boolean); const known = diffs.get(hit.closest('[data-diff]')?.dataset.diff ?? ''); if (row && known) { const number = Number(row.dataset.line); const side = row.dataset.lineType?.includes('deletion') ? 'deletions' : 'additions'; return { target: { kind: 'lines', window, path: known.path, change: known.change, side, start: number, end: number, quote: quoteLines(known.diff, side, number, number) }, rect: row.getBoundingClientRect() }; } } const entry = hit.closest('[data-entry]'); if (entry) { const step = entry.classList.contains('tool') || entry.classList.contains('thought'); // A reply is several blocks: the one under the pointer, unless it is on // the speaker's name, which stands for the whole message. const block = step || hit.closest('.plate') ? null : hit.closest(BLOCKS); if (block && entry.contains(block)) { const range = document.createRange(); range.selectNodeContents(block); const target = textTarget(range); if (target) return { target, rect: block.getBoundingClientRect() }; } return { target: { kind: 'entry', window, entry: entry.dataset.entry!, step, quote: excerpt(entry.innerText) }, rect: entry.getBoundingClientRect() }; } return { target: { kind: 'window', window }, rect: hit.closest('.svelte-flow__node')!.getBoundingClientRect() }; } /** How far, in the page's pixels, the page's last answer may be from the pointer and still count. */ const NEAR = 24; /** * The part of an artifact's page under a point, as the page last said. The * page answers a moment later, so a drag reads the answer to its last move. */ function pagePart(window: string, page: PageHost, x: number, y: number): { target: Target; rect: DOMRect } | undefined { const { rect, scale } = page.frameBox() ?? {}; if (!rect || !scale || x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return undefined; page.point(x, y); const answer = page.pointed; if (!answer?.selector || Math.hypot(answer.x - (x - rect.left) / scale, answer.y - (y - rect.top) / scale) > NEAR) return undefined; const shown = page.onScreen(answer.rect); if (!shown) return undefined; const at = { window, selector: answer.selector, version: page.version }; const target: Target = answer.passage ? { kind: 'passage', ...at, ...answer.passage } : { kind: 'element', ...at, quote: answer.quote }; return { target, rect: shown }; } /** Where a part of an artifact's page is on screen, held inside the page, or nothing while it is out of sight. */ function pageBox(target: Extract): DOMRect | null { const page = pageHosts.get(target.window); if (!page) return null; page.watch(target.selector); const part = page.rects.get(target.selector); const frame = page.frameBox()?.rect; const rect = part && page.onScreen(part); return rect && frame ? intersect(rect, frame) : null; } /** The lines of a passage of an artifact's page on screen, held inside the page. */ function pageLines(target: PassageTarget): DOMRect[] { const page = pageHosts.get(target.window); const frame = page?.frameBox()?.rect; if (!page || !frame) return []; const spans = page.passages.get(page.watchPassage(target)) ?? []; return spans.flatMap((span) => intersect(page.onScreen(span) ?? frame, frame) ?? []); } /** * The lines a passage covers on screen, held inside the window and whatever * scrolls it, for drawing its tint; null for targets that are not passages. */ export function targetLines(target: Target): DOMRect[] | null { if (target.kind === 'passage') return pageLines(target); if (target.kind !== 'text') return null; const range = textRange(target); return range ? rangeLines(range) : []; } /** The lines a range of the client's text covers on screen, held inside whatever scrolls it. */ function rangeLines(range: Range): DOMRect[] { const container = range.commonAncestorContainer; const frame = frameOf(container instanceof Element ? container : container.parentElement!); if (!frame) return []; // A range also lists the boxes of elements it holds whole; only the lines of text count. const all = [...range.getClientRects()].filter((line) => line.width > 0).slice(0, 200); const lines = all.filter((rect) => !all.some((other) => other !== rect && other.top >= rect.top && other.bottom <= rect.bottom && other.left >= rect.left && other.right <= rect.right && (other.width < rect.width || other.height < rect.height))); return lines.flatMap((line) => intersect(line, frame) ?? []); } export function targetAt(x: number, y: number): Target | undefined { return pick(x, y)?.target; } /** The element a target points at now, when it is on screen. */ export function anchorOf(target: Target): Element | null { const window = windowNode(target.window); if (!window || target.kind === 'window' || target.kind === 'element' || target.kind === 'passage') return window; if (target.kind === 'entry') return window.querySelector(`[data-entry="${CSS.escape(target.entry)}"]`) ?? window; if (target.kind === 'text') return scopeOf(target); const host = window.querySelector(`[data-diff="${CSS.escape(diffKey(target.window, target.path, target.change))}"] diffs-container`); const rows = [...(host?.shadowRoot?.querySelectorAll(`[data-line="${target.end}"]`) ?? [])]; const deletion = target.side === 'deletions'; return rows.find((row) => (row.dataset.lineType?.includes('deletion') ?? false) === deletion) ?? rows[0] ?? host ?? window; } /** * Where a target sits on screen, held inside the window and whatever scrolls * it, or nothing while it is scrolled out of sight or is the whole window. A * passage that cannot be found any more falls back to the entry it was in. */ export function targetBox(target: Target): DOMRect | null { if (target.kind === 'window') return null; if (target.kind === 'element') return pageBox(target); if (target.kind === 'passage') return union(pageLines(target)); const first = anchorOf(target); if (!first) return null; let rect = first.getBoundingClientRect(); if (target.kind === 'text') { const range = textRange(target); if (range) rect = range.getBoundingClientRect(); else if (first.classList.contains('svelte-flow__node')) return null; } if (target.kind === 'lines' && target.start !== target.end) { const top = anchorOf({ ...target, end: target.start })?.getBoundingClientRect(); if (top) rect = new DOMRect(rect.left, Math.min(top.top, rect.top), rect.width, Math.max(top.bottom, rect.bottom) - Math.min(top.top, rect.top)); } const frame = frameOf(first); return frame ? intersect(rect, frame) : rect; } /** * The point on screen a tie should end at: the target's left or right edge, * whichever faces the note, held inside the window and whatever scrolls it. */ export function anchorPoint(target: Target, from: { x: number; y: number }): { x: number; y: number } | null { const anchor = anchorOf(target); if (!anchor) return null; if (target.kind === 'element' || target.kind === 'passage') { const node = anchor.getBoundingClientRect(); const part = targetBox(target); return { x: from.x > node.left + node.width / 2 ? node.right : node.left, y: part ? part.top + Math.min(part.height / 2, 12) : node.top + 22 }; } const root = anchor.getRootNode(); const light = root instanceof ShadowRoot ? root.host : anchor; const node = light.closest('.svelte-flow__node')?.getBoundingClientRect(); const frame = frameOf(anchor); const rect = (target.kind === 'text' && textRange(target)?.getBoundingClientRect()) || anchor.getBoundingClientRect(); let y = target.kind === 'window' ? rect.top + 22 : rect.top + Math.min(rect.height / 2, 12); if (frame) y = Math.min(frame.bottom - 6, Math.max(frame.top + 6, y)); const box = node ?? rect; const x = from.x > box.left + box.width / 2 ? box.right : box.left; return { x, y }; } /** A passage or part as a few words, for a label. */ function words(quote: string): string { const line = tidy(quote).split('\n')[0]; return line.length > 40 ? `${line.slice(0, 40)}…` : line; } /** How a note names its target, short. */ export function describe(target: Target | undefined, title: (window: string) => string): string { if (!target) return 'a loose note'; if (target.kind === 'window') return `on ${title(target.window)}`; if (target.kind === 'entry') return `on ${target.step ? 'a step' : 'a message'} in ${title(target.window)}`; if (target.kind === 'element' || target.kind === 'text' || target.kind === 'passage') return `on “${words(target.quote)}” in ${title(target.window)}`; return `on ${linesName(target)}`; } /** What a note is on, as few words as a chip holds: the passage, the lines, or the window. */ export function chip(target: Target, title: (window: string) => string): string { if (target.kind === 'window') return title(target.window); if (target.kind === 'entry') return target.step ? 'a step' : `“${words(target.quote)}”`; if (target.kind === 'lines') return linesName(target); return `“${words(target.quote)}”`; } function linesName(target: Extract): string { const name = target.path.split('/').pop(); return `${name}:${target.start === target.end ? target.start : `${target.start}–${target.end}`}${target.side === 'deletions' ? ' (removed)' : ''}`; }