From a819947ec0b8835438726fd99e5e61382479d3f6 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 12:38:19 +0100 Subject: [PATCH 01/24] feat(selection): pure model range, text extraction and restore plan --- src/client/selection.ts | 55 +++++++++++++++++++++++ test/client.selection.test.ts | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/client/selection.ts create mode 100644 test/client.selection.test.ts diff --git a/src/client/selection.ts b/src/client/selection.ts new file mode 100644 index 0000000..82ca9a5 --- /dev/null +++ b/src/client/selection.ts @@ -0,0 +1,55 @@ +// Pure selection model for the code view (0-based line/col). No DOM access. + +export interface Pos { + line: number; + col: number; +} +export interface SelRange { + start: Pos; + end: Pos; +} +export interface PlanPoint { + line: number; + offset: number; +} +export interface RestorePlan { + anchor: PlanPoint; + focus: PlanPoint; +} + +const before = (a: Pos, b: Pos) => a.line < b.line || (a.line === b.line && a.col < b.col); + +/** Returns the range with start <= end. */ +export function normalize(r: SelRange): SelRange { + return before(r.end, r.start) ? { start: { ...r.end }, end: { ...r.start } } : { start: { ...r.start }, end: { ...r.end } }; +} + +/** Exact text covered by the range, lines joined with "\n". */ +export function textOf(lines: string[], r: SelRange): string { + const { start, end } = normalize(r); + const out: string[] = []; + for (let i = start.line; i <= end.line && i < lines.length; i++) { + const l = lines[i] ?? ""; + out.push(l.slice(i === start.line ? start.col : 0, i === end.line ? end.col : l.length)); + } + return out.join("\n"); +} + +/** Range covering the whole file. */ +export function selectAll(lines: string[]): SelRange { + const last = Math.max(0, lines.length - 1); + return { start: { line: 0, col: 0 }, end: { line: last, col: (lines[last] ?? "").length } }; +} + +/** + * Maps a model range onto the rendered window [first,last] (line indexes, inclusive). + * Endpoints outside the window clamp to the window edge (start of first / end of last, + * `lenOf` gives a line's length). Returns null when the range misses the window. + */ +export function restorePlan(r: SelRange, first: number, last: number, lenOf: (line: number) => number): RestorePlan | null { + const { start, end } = normalize(r); + if (end.line < first || start.line > last) return null; + const anchor = start.line < first ? { line: first, offset: 0 } : { line: start.line, offset: start.col }; + const focus = end.line > last ? { line: last, offset: lenOf(last) } : { line: end.line, offset: end.col }; + return { anchor, focus }; +} diff --git a/test/client.selection.test.ts b/test/client.selection.test.ts new file mode 100644 index 0000000..e8ef0d7 --- /dev/null +++ b/test/client.selection.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { normalize, restorePlan, selectAll, textOf, type SelRange } from "../src/client/selection.ts"; + +const r = (sl: number, sc: number, el: number, ec: number): SelRange => ({ start: { line: sl, col: sc }, end: { line: el, col: ec } }); +const lines = ["alpha", "\tbeta gamma", "", "delta"]; + +describe("normalize (AC-1)", () => { + test("reversed endpoints are swapped", () => { + expect(normalize(r(3, 2, 1, 4))).toEqual(r(1, 4, 3, 2)); + }); + test("same line reversed columns are swapped", () => { + expect(normalize(r(2, 5, 2, 1))).toEqual(r(2, 1, 2, 5)); + }); + test("already ordered ranges are unchanged", () => { + expect(normalize(r(0, 1, 2, 0))).toEqual(r(0, 1, 2, 0)); + }); +}); + +describe("textOf (AC-1)", () => { + test("single line substring", () => { + expect(textOf(lines, r(0, 1, 0, 4))).toBe("lph"); + }); + test("partial first and last lines joined with newline, tabs preserved", () => { + expect(textOf(lines, r(0, 2, 3, 3))).toBe("pha\n\tbeta gamma\n\ndel"); + }); + test("empty lines inside the range", () => { + expect(textOf(lines, r(1, 6, 3, 0))).toBe("gamma\n\n"); + }); + test("reversed range yields the same text", () => { + expect(textOf(lines, r(3, 3, 0, 2))).toBe("pha\n\tbeta gamma\n\ndel"); + }); + test("columns past the line end are clamped", () => { + expect(textOf(lines, r(0, 3, 0, 99))).toBe("ha"); + }); + test("collapsed range is empty", () => { + expect(textOf(lines, r(1, 2, 1, 2))).toBe(""); + }); +}); + +describe("selectAll (AC-2)", () => { + test("covers first char to end of last line", () => { + expect(selectAll(lines)).toEqual(r(0, 0, 3, 5)); + }); + test("text of select-all equals the full text", () => { + expect(textOf(lines, selectAll(lines))).toBe(lines.join("\n")); + }); + test("single empty line", () => { + expect(selectAll([""])).toEqual(r(0, 0, 0, 0)); + }); +}); + +describe("restorePlan (AC-1)", () => { + const len = (n: number) => 10 + n; // line n has length 10+n + test("range fully inside the window maps to its own coordinates", () => { + expect(restorePlan(r(12, 3, 15, 7), 10, 20, len)).toEqual({ anchor: { line: 12, offset: 3 }, focus: { line: 15, offset: 7 } }); + }); + test("start above the window clamps to start of first row", () => { + expect(restorePlan(r(2, 4, 15, 7), 10, 20, len)).toEqual({ anchor: { line: 10, offset: 0 }, focus: { line: 15, offset: 7 } }); + }); + test("end below the window clamps to end of last row", () => { + expect(restorePlan(r(12, 3, 99, 1), 10, 20, len)).toEqual({ anchor: { line: 12, offset: 3 }, focus: { line: 20, offset: 30 } }); + }); + test("range spanning the whole window clamps both ends", () => { + expect(restorePlan(r(0, 0, 300, 5), 10, 20, len)).toEqual({ anchor: { line: 10, offset: 0 }, focus: { line: 20, offset: 30 } }); + }); + test("range entirely above or below the window is null", () => { + expect(restorePlan(r(0, 0, 9, 5), 10, 20, len)).toBeNull(); + expect(restorePlan(r(21, 0, 30, 5), 10, 20, len)).toBeNull(); + }); + test("reversed range is normalised first", () => { + expect(restorePlan(r(15, 7, 12, 3), 10, 20, len)).toEqual({ anchor: { line: 12, offset: 3 }, focus: { line: 15, offset: 7 } }); + }); + test("window edge lines are inclusive", () => { + expect(restorePlan(r(10, 1, 20, 2), 10, 20, len)).toEqual({ anchor: { line: 10, offset: 1 }, focus: { line: 20, offset: 2 } }); + }); +}); + +describe("truncated long lines (AC-3)", () => { + test("textOf returns the full model text, never a truncation label", () => { + const long = "x".repeat(5000); + const t = textOf(["a", long, "b"], r(0, 0, 2, 1)); + expect(t).toBe("a\n" + long + "\nb"); + expect(t).not.toContain("more characters"); + }); +}); From 07c27e3c314b35e6b741ff211e636f1ab0daccf0 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 12:43:13 +0100 Subject: [PATCH 02/24] feat(codeview): keep selection across re-renders, file-wide select-all and model copy --- src/client/codeview.ts | 125 +++++++++++++++++ src/client/styles.css | 1 + test/client.codeview.selection.test.ts | 186 +++++++++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 test/client.codeview.selection.test.ts diff --git a/src/client/codeview.ts b/src/client/codeview.ts index 6e39ff8..383172b 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -1,6 +1,7 @@ import { Highlighter, type Tok } from "./highlight.ts"; import { dismissable, esc, h } from "./util.ts"; import { icons } from "./icons.ts"; +import { normalize, restorePlan, selectAll as selectAllRange, textOf, type SelRange } from "./selection.ts"; import { diffLines, gutterMarks, ADDED, MODIFIED, DELETED_BELOW, DELETED_ABOVE, type Hunk } from "./diff.ts"; const TOKS: Tok[] = ["k", "s", "c", "n", "f", "t", "p", "o", "v", "m", "h", "a", "x", "b", "i", "l"]; @@ -143,6 +144,7 @@ export class CodeView { this.linesEl.addEventListener("mousedown", (e) => { const ln = (e.target as HTMLElement).closest(".ln"); if (!ln) return; + this.sel = null; const n = Number(ln.dataset.n); if ((e.target as HTMLElement).classList.contains("g") && this.gitMarks[n]) { e.preventDefault(); @@ -152,6 +154,83 @@ export class CodeView { this.setActiveLine(n); }); this.scroller.addEventListener("scroll", () => this.closePeek(), { passive: true }); + this.scroller.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "a") { + e.preventDefault(); + this.selectAll(); + } + }); + this.scroller.addEventListener("copy", (e) => { + if (!this.sel) return; + e.preventDefault(); + e.clipboardData?.setData("text/plain", this.selectedText()); + }); + document.addEventListener("selectionchange", () => this.readDomSelection()); + } + + // ───────────────────────────── selection (model range) ───────────────────────────── + + /** Seam that applies a DOM selection; tests inject a recorder. */ + setSelection: (anchorEl: Node, anchorOffset: number, focusEl: Node, focusOffset: number) => void = (a, ao, f, fo) => + window.getSelection()?.setBaseAndExtent(a, ao, f, fo); + private sel: SelRange | null = null; + + /** The selection as a normalised model range, or null. */ + get selection(): SelRange | null { + return this.sel; + } + + /** Sets the model range; the DOM selection is restored on the next render. */ + setRange(r: SelRange | null) { + this.sel = r ? normalize(r) : null; + this.schedule(); + } + + /** Selects the whole file as a model range. */ + selectAll() { + this.setRange(selectAllRange(this.lines)); + } + + /** Text of the model selection ("" when none), never including decorations. */ + selectedText(): string { + return this.sel ? textOf(this.lines, this.sel) : ""; + } + + private rowTx(n: number): HTMLElement | null { + for (const row of this.linesEl.querySelectorAll(".ln")) { + if (Number(row.dataset.n) === n) return row.querySelector(".tx"); + } + return null; + } + + /** Re-applies the model range to the freshly rendered rows. */ + private restoreSelection() { + if (!this.sel) return; + const last = Math.min(this.window.last, this.lines.length) - (this.window.last === Infinity ? 0 : 1); + const plan = restorePlan(this.sel, this.window.first, last, (l) => Math.min(this.lines[l]?.length ?? 0, MAX_RENDER_COLS)); + if (!plan) return; + const a = this.rowTx(plan.anchor.line); + const f = this.rowTx(plan.focus.line); + if (!a || !f) return; + const [an, ao] = locate(a, plan.anchor.offset); + const [fn, fo] = locate(f, plan.focus.offset); + this.setSelection(an, ao, fn, fo); + } + + /** Mirrors a user (mouse/keyboard) DOM selection inside the rows into the model range. */ + private readDomSelection() { + const ds = window.getSelection?.(); + if (!ds || ds.rangeCount === 0 || ds.isCollapsed || !ds.anchorNode || !ds.focusNode) return; + const pos = (node: Node, off: number) => { + const el = node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; + const row = el?.closest(".ln"); + const tx = row?.querySelector(".tx"); + if (!row || !tx || !this.linesEl.contains(row)) return null; + return { line: Number(row.dataset.n), col: Math.min(colOf(tx, node, off), this.lines[Number(row.dataset.n)]?.length ?? 0) }; + }; + const a = pos(ds.anchorNode, ds.anchorOffset); + const f = pos(ds.focusNode, ds.focusOffset); + if (a && f) this.sel = normalize({ start: a, end: f }); } get lineCount() { @@ -366,6 +445,7 @@ export class CodeView { } this.linesEl.style.transform = `translateY(${first * this.lh}px)`; this.linesEl.innerHTML = html; + this.restoreSelection(); } private lineHtml(i: number): string { @@ -547,6 +627,51 @@ export class CodeView { } } +/** Text node and offset for a text offset inside a row's text element. */ +function locate(tx: Node, off: number): [Node, number] { + if (!tx.childNodes) return [tx, off]; + let left = off; + let lastText: Text | null = null; + const walk = (n: Node): [Node, number] | null => { + for (const c of Array.from(n.childNodes ?? [])) { + if (c.nodeType === 3) { + lastText = c as Text; + const len = (c as Text).data.length; + if (left <= len) return [c, left]; + left -= len; + } else if (c.nodeType === 1 && !(c as HTMLElement).classList.contains("trunc")) { + const r = walk(c); + if (r) return r; + } + } + return null; + }; + return walk(tx) ?? (lastText ? [lastText, (lastText as Text).data.length] : [tx, 0]); +} + +/** Text offset of (node, off) within a row's text element. */ +function colOf(tx: Node, node: Node, off: number): number { + let n = 0; + let done = false; + const walk = (p: Node) => { + for (const c of Array.from(p.childNodes)) { + if (done) return; + if (c === node) { + if (c.nodeType === 3) n += off; + else for (let i = 0; i < off; i++) n += c.childNodes[i]?.textContent?.length ?? 0; + done = true; + return; + } + if (c.nodeType === 3) n += (c as Text).data.length; + else if (c.contains(node)) walk(c); + else n += c.textContent?.length ?? 0; + } + }; + if (node === tx) for (let i = 0; i < off; i++) n += tx.childNodes[i]?.textContent?.length ?? 0; + else walk(tx); + return n; +} + function countTabs(s: string): number { let n = 0; for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 9) n++; diff --git a/src/client/styles.css b/src/client/styles.css index 827790d..a4314dc 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1207,6 +1207,7 @@ svg { } } .trunc { + user-select: none; color: var(--fg-faint); font-style: italic; } diff --git a/test/client.codeview.selection.test.ts b/test/client.codeview.selection.test.ts new file mode 100644 index 0000000..4e78b22 --- /dev/null +++ b/test/client.codeview.selection.test.ts @@ -0,0 +1,186 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { FakeElement, FakeEvent, installDom, uninstallDom, type Dom } from "./helpers/fakedom.ts"; + +let C: typeof import("../src/client/codeview.ts"); +let dom: Dom; +let rafQueue: (() => void)[] = []; + +beforeAll(async () => { + dom = installDom(); + const g = globalThis as Record; + g.ResizeObserver = class { + observe() {} + disconnect() {} + }; + g.requestAnimationFrame = (cb: () => void) => { + rafQueue.push(cb); + return rafQueue.length; + }; + g.getComputedStyle = () => ({ getPropertyValue: () => "monospace" }); + const make = dom.document.createElement.bind(dom.document); + dom.document.createElement = (tag: string) => { + const el = make(tag); + (el.style as Record).setProperty = (k: string, v: string) => (el.style[k] = v); + if (tag === "canvas") (el as unknown as Record).getContext = () => ({ font: "", measureText: () => ({ width: 80 }) }); + return el; + }; + try { + C = await import("../src/client/codeview.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +beforeEach(() => { + expect(typeof C?.CodeView).toBe("function"); +}); + +const flush = () => { + const q = rafQueue; + rafQueue = []; + for (const f of q) f(); +}; + +/** The fake DOM keeps innerHTML as a string; turn the rendered rows into real fake elements. */ +function parseRows(linesEl: FakeElement, html: string) { + linesEl.children = []; + const re = /
]*>.*?(.*?)<\/span>(?:.*?<\/span>)?<\/div>/g; + for (let m; (m = re.exec(html)); ) { + const row = dom.document.createElement("div"); + row.setAttribute("class", m[1]!); + row.setAttribute("data-n", m[2]!); + const tx = dom.document.createElement("span"); + tx.setAttribute("class", "tx"); + tx.append(m[3]!.replace(/<[^>]*>/g, "")); + row.append(tx); + linesEl.append(row); + } +} + +type Rec = { a: Node; ao: number; f: Node; fo: number }; +function mount(text: string) { + const view = new C.CodeView(text, "text", 10) as unknown as Record; + const linesEl = view.linesEl as FakeElement; + Object.defineProperty(linesEl, "innerHTML", { configurable: true, get: () => "", set: (v: string) => parseRows(linesEl, v) }); + const calls: Rec[] = []; + view.setSelection = (a: Node, ao: number, f: Node, fo: number) => calls.push({ a, ao, f, fo }); + dom.document.body.append(view.el as FakeElement); + flush(); + return { view, calls, scroller: view.scroller as FakeElement }; +} +const rowN = (n: Node) => Number((n as unknown as FakeElement).closest(".ln")!.dataset.n); +const scrollTo = (v: Record, scroller: FakeElement, top: number) => { + scroller.scrollTop = top; + scroller.dispatchEvent(new FakeEvent("scroll")); + flush(); +}; +const body = Array.from({ length: 1000 }, (_, i) => `line ${i} text`).join("\n"); +const rng = (sl: number, sc: number, el: number, ec: number) => ({ start: { line: sl, col: sc }, end: { line: el, col: ec } }); +const keyA = (init: Record) => new FakeEvent("keydown", { key: "a", ...init }); + +describe("CodeView model range survives re-render (AC-1)", () => { + test("setRange stores a normalised range", () => { + const { view } = mount("alpha\nbeta\ngamma"); + view.setRange(rng(2, 3, 0, 1)); + expect(view.selection).toEqual(rng(0, 1, 2, 3)); + }); + + test("scrolling the range out of and back into the window keeps it and restores the DOM selection", () => { + const { view, calls, scroller } = mount(body); + view.setRange(rng(10, 2, 300, 4)); + flush(); + expect(calls.length).toBeGreaterThan(0); + scrollTo(view, scroller, 4800); // window ~260..390: line 10 is out, 300 is in + expect(view.selection).toEqual(rng(10, 2, 300, 4)); + let last = calls[calls.length - 1]!; + expect(rowN(last.a)).toBeGreaterThanOrEqual(260); + expect(last.ao).toBe(0); + expect(rowN(last.f)).toBe(300); + expect(last.fo).toBe(4); + scrollTo(view, scroller, 0); + expect(view.selection).toEqual(rng(10, 2, 300, 4)); + last = calls[calls.length - 1]!; + expect(rowN(last.a)).toBe(10); + expect(last.ao).toBe(2); + expect(rowN(last.f)).toBeLessThan(300); // 300 is now past the window + }); + + test("a range wholly outside the window triggers no DOM restore but is kept", () => { + const { view, calls, scroller } = mount(body); + view.setRange(rng(500, 0, 510, 3)); + flush(); + const n = calls.length; + scrollTo(view, scroller, 0); + expect(calls.length).toBe(n); + expect(view.selection).toEqual(rng(500, 0, 510, 3)); + }); +}); + +describe("Cmd/Ctrl+A selects the whole file (AC-2)", () => { + for (const mod of ["metaKey", "ctrlKey"]) { + test(`${mod}+A selects everything and is prevented`, () => { + const { view, scroller } = mount("alpha\n\tbeta\ngamma"); + const e = keyA({ [mod]: true }); + scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(view.selection).toEqual(rng(0, 0, 2, 5)); + expect(view.selectedText()).toBe("alpha\n\tbeta\ngamma"); + }); + } + test("plain A is left alone", () => { + const { view, scroller } = mount("alpha\nbeta"); + const e = keyA({}); + scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(view.selection).toBeNull(); + }); + test("selectAll() API matches", () => { + const { view } = mount("a\nbb\nccc"); + view.selectAll(); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + expect(view.selectedText()).toBe("a\nbb\nccc"); + }); + test("no selection gives empty selectedText", () => { + const { view } = mount("a\nbb"); + expect(view.selectedText()).toBe(""); + }); +}); + +describe("copy uses the model, not decorations (AC-3)", () => { + const long = "x".repeat(5000); + const text = `short\n${long}\nend`; + + test("selectedText holds the full long line and no label", () => { + const { view } = mount(text); + view.setRange(rng(0, 2, 2, 2)); + const t = view.selectedText(); + expect(t).toBe(`ort\n${long}\nen`); + expect(t).not.toContain("more characters"); + }); + + test("the copy event writes model text and is prevented", () => { + const { view, scroller } = mount(text); + view.selectAll(); + const set: [string, string][] = []; + const e = new FakeEvent("copy", { clipboardData: { setData: (t: string, d: string) => set.push([t, d]) } }); + scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(set).toEqual([["text/plain", text]]); + }); + + test("copy with no selection is left to the browser", () => { + const { scroller } = mount(text); + const set: unknown[] = []; + const e = new FakeEvent("copy", { clipboardData: { setData: (...a: unknown[]) => set.push(a) } }); + scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + }); + + test(".trunc is user-select:none in styles.css", () => { + const css = readFileSync(new URL("../src/client/styles.css", import.meta.url), "utf8"); + const m = /\.trunc\s*\{([^}]*)\}/.exec(css); + expect(m).not.toBeNull(); + expect(m![1]).toMatch(/user-select:\s*none/); + }); +}); From 915827b1a4b177a8f565f36a1da9191fb4a157db Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 12:55:03 +0100 Subject: [PATCH 03/24] fix(codeview): keep the full range when the restore's own selectionchange echoes back --- src/client/codeview.ts | 13 +- test/client.codeview.selection2.test.ts | 197 ++++++++++++++++++++++++ test/client.codeview.selection3.test.ts | 155 +++++++++++++++++++ 3 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 test/client.codeview.selection2.test.ts create mode 100644 test/client.codeview.selection3.test.ts diff --git a/src/client/codeview.ts b/src/client/codeview.ts index 383172b..989ab67 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -145,6 +145,7 @@ export class CodeView { const ln = (e.target as HTMLElement).closest(".ln"); if (!ln) return; this.sel = null; + this.applied = null; const n = Number(ln.dataset.n); if ((e.target as HTMLElement).classList.contains("g") && this.gitMarks[n]) { e.preventDefault(); @@ -174,6 +175,8 @@ export class CodeView { setSelection: (anchorEl: Node, anchorOffset: number, focusEl: Node, focusOffset: number) => void = (a, ao, f, fo) => window.getSelection()?.setBaseAndExtent(a, ao, f, fo); private sel: SelRange | null = null; + /** Endpoints of the last DOM selection we applied; its selectionchange echo must not shrink the model. */ + private applied: SelRange | null = null; /** The selection as a normalised model range, or null. */ get selection(): SelRange | null { @@ -183,6 +186,7 @@ export class CodeView { /** Sets the model range; the DOM selection is restored on the next render. */ setRange(r: SelRange | null) { this.sel = r ? normalize(r) : null; + this.applied = null; this.schedule(); } @@ -206,7 +210,7 @@ export class CodeView { /** Re-applies the model range to the freshly rendered rows. */ private restoreSelection() { if (!this.sel) return; - const last = Math.min(this.window.last, this.lines.length) - (this.window.last === Infinity ? 0 : 1); + const last = Math.min(this.window.last, this.lines.length) - 1; const plan = restorePlan(this.sel, this.window.first, last, (l) => Math.min(this.lines[l]?.length ?? 0, MAX_RENDER_COLS)); if (!plan) return; const a = this.rowTx(plan.anchor.line); @@ -214,6 +218,7 @@ export class CodeView { if (!a || !f) return; const [an, ao] = locate(a, plan.anchor.offset); const [fn, fo] = locate(f, plan.focus.offset); + this.applied = { start: { line: plan.anchor.line, col: plan.anchor.offset }, end: { line: plan.focus.line, col: plan.focus.offset } }; this.setSelection(an, ao, fn, fo); } @@ -230,7 +235,11 @@ export class CodeView { }; const a = pos(ds.anchorNode, ds.anchorOffset); const f = pos(ds.focusNode, ds.focusOffset); - if (a && f) this.sel = normalize({ start: a, end: f }); + if (!a || !f) return; + const ap = this.applied; + if (ap && a.line === ap.start.line && a.col === ap.start.col && f.line === ap.end.line && f.col === ap.end.col) return; + this.applied = null; + this.sel = normalize({ start: a, end: f }); } get lineCount() { diff --git a/test/client.codeview.selection2.test.ts b/test/client.codeview.selection2.test.ts new file mode 100644 index 0000000..ca85e46 --- /dev/null +++ b/test/client.codeview.selection2.test.ts @@ -0,0 +1,197 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, uninstallDom, type Dom, type FakeElement } from "./helpers/fakedom.ts"; + +// A small real-DOM-like node tree (childNodes, nodeType) so text-offset <-> node mapping runs for real. +class N { + parent: N | null = null; + childNodes: N[] = []; + data = ""; + dataset: Record = {}; + constructor(public nodeType: 1 | 3, public cls = "") {} + get parentElement() { + return this.parent; + } + get textContent(): string { + return this.nodeType === 3 ? this.data : this.childNodes.map((c) => c.textContent).join(""); + } + get classList() { + return { contains: (c: string) => this.cls.split(" ").includes(c) }; + } + add(...cs: N[]) { + for (const c of cs) { + c.parent = this; + this.childNodes.push(c); + } + return this; + } + contains(n: N | null): boolean { + for (let x = n; x; x = x.parent) if (x === this) return true; + return false; + } + closest(c: string): N | null { + c = c.replace(/^\./, ""); + for (let x: N | null = this; x; x = x.parent) if (x.nodeType === 1 && x.classList.contains(c)) return x; + return null; + } + querySelector(c: string): N | null { + c = c.replace(/^\./, ""); + for (const k of this.childNodes) { + if (k.nodeType !== 1) continue; + if (k.classList.contains(c)) return k; + const r = k.querySelector(c); + if (r) return r; + } + return null; + } +} +const text = (s: string) => Object.assign(new N(3), { data: s }); +const el = (cls: string, ...cs: N[]) => new N(1, cls).add(...cs); + +let C: typeof import("../src/client/codeview.ts"); +let dom: Dom; +let raf: (() => void)[] = []; +beforeAll(async () => { + dom = installDom(); + const g = globalThis as Record; + g.ResizeObserver = class { + observe() {} + }; + g.requestAnimationFrame = (cb: () => void) => (raf.push(cb), raf.length); + g.getComputedStyle = () => ({ getPropertyValue: () => "monospace" }); + const make = dom.document.createElement.bind(dom.document); + dom.document.createElement = (tag: string) => { + const e = make(tag); + (e.style as Record).setProperty = (k: string, v: string) => (e.style[k] = v); + if (tag === "canvas") (e as unknown as Record).getContext = () => ({ measureText: () => ({ width: 80 }) }); + return e; + }; + C = await import("../src/client/codeview.ts"); +}); +afterAll(() => uninstallDom()); +const flush = () => { + const q = raf; + raf = []; + for (const f of q) f(); +}; + +function mount(src: string, rowsOverride?: () => N[]) { + const view = new C.CodeView(src, "text", 10) as unknown as Record; + const linesEl = view.linesEl as FakeElement & Record; + let rows: N[] = []; + Object.defineProperty(linesEl, "innerHTML", { + configurable: true, + get: () => "", + set: () => { + rows = []; + const lines = view.lines as string[]; + for (let i = view.window.first; i < Math.min(lines.length, 5); i++) { + const l = lines[i]!; + const tx = el("tx", text(l.slice(0, 2)), el("t-k", text(l.slice(2, 5))), text(l.slice(5)), ...(i === 1 ? [el("trunc", text(" … more characters"))] : [])); + const row = el("ln", el("g", text(String(i + 1))), tx); + row.dataset.n = String(i); + rows.push(row); + } + }, + }); + linesEl.querySelectorAll = () => (rowsOverride ? rowsOverride() : rows); + linesEl.contains = (n: N) => rows.some((r) => r.contains(n)); + dom.document.body.append(view.el as FakeElement); + flush(); + return { view, linesEl, rows: () => rows }; +} +const rng = (sl: number, sc: number, el_: number, ec: number) => ({ start: { line: sl, col: sc }, end: { line: el_, col: ec } }); +const setWinSel = (s: unknown) => ((dom.window as unknown as Record).getSelection = () => s); +const src = ["abcdefgh", "ijklmnop", "qrstuvwx"].join("\n"); + +describe("restoring the DOM selection maps text offsets to text nodes", () => { + test("default seam calls getSelection().setBaseAndExtent with resolved text nodes", () => { + const calls: unknown[][] = []; + setWinSel({ setBaseAndExtent: (...a: unknown[]) => calls.push(a) }); + const { view } = mount(src); + view.setRange(rng(0, 1, 2, 6)); // line 0 col 1 ("a|b"), line 2 col 6 inside the trailing text node + flush(); + expect(calls.length).toBe(1); + const [an, ao, fn, fo] = calls[0] as [N, number, N, number]; + expect(an.data).toBe("ab"); + expect(ao).toBe(1); + expect(fn.data).toBe("vwx"); // "qr" + "stu" + "vwx": offset 6 is 1 into the last node + expect(fo).toBe(1); + }); + + test("offset spanning nested spans lands in the right node; past the end lands at the end of the last text", () => { + const calls: { fn: N; fo: number }[] = []; + const { view } = mount(src); + view.setSelection = (_a: unknown, _ao: number, f: N, fo: number) => calls.push({ fn: f, fo }); + view.setRange(rng(0, 0, 0, 3)); + flush(); + expect(calls.at(-1)!.fn.data).toBe("cde"); + expect(calls.at(-1)!.fo).toBe(1); + view.setRange(rng(0, 0, 1, 99)); + flush(); + expect(calls.at(-1)!.fn.data).toBe("nop"); // the trunc label is skipped; clamps to the end of real text + expect(calls.at(-1)!.fo).toBe(3); + }); + + test("a row without text nodes anchors on its element", () => { + const calls: { fn: unknown; fo: number }[] = []; + const { view } = mount("\nx"); + view.setSelection = (_a: unknown, _ao: number, f: unknown, fo: number) => calls.push({ fn: f, fo }); + view.setRange(rng(0, 0, 0, 0)); + flush(); + expect(calls.at(-1)!.fo).toBe(0); + }); + + test("no rendered row for an endpoint means no DOM selection call", () => { + const calls: unknown[] = []; + const { view } = mount(src, () => []); + view.setSelection = () => calls.push(1); + view.setRange(rng(0, 0, 1, 2)); + flush(); + expect(calls).toEqual([]); + }); +}); + +describe("selectionchange mirrors the DOM selection into the model range", () => { + const fire = () => dom.document.dispatchEvent(new FakeEvent("selectionchange")); + const sel = (an: N, ao: number, fn: N, fo: number, extra = {}) => ({ rangeCount: 1, isCollapsed: false, anchorNode: an, anchorOffset: ao, focusNode: fn, focusOffset: fo, ...extra }); + + test("text and element endpoints, in either direction, become a normalised range", () => { + const { view, rows } = mount(src); + const r = rows(); + const tx0 = r[0]!.querySelector("tx")!; + const tx2 = r[2]!.querySelector("tx")!; + // anchor: second span of line 2 (after "qr"), offset 2 -> col 4; focus: line 0 tx element, child index 1 -> col 2 + setWinSel(sel(tx2.childNodes[1]!.childNodes[0]!, 2, tx0, 1)); + fire(); + expect(view.selection).toEqual(rng(0, 2, 2, 4)); + // text node in first and trailing text nodes + setWinSel(sel(tx0.childNodes[0]!, 1, tx0.childNodes[2]!, 2)); + fire(); + expect(view.selection).toEqual(rng(0, 1, 0, 7)); + // an element node that is a span child of the row text, counting preceding children + setWinSel(sel(tx0.childNodes[1]!, 1, r[1]!.querySelector("tx")!, 3)); + fire(); + expect(view.selection).toEqual(rng(0, 5, 1, 8)); // span element offset 1 = "ab" + "cde" + }); + + test("collapsed, empty or foreign selections leave the model untouched", () => { + const { view, rows } = mount(src); + const tx0 = rows()[0]!.querySelector("tx")!; + view.setRange(rng(1, 0, 1, 2)); + for (const s of [null, sel(tx0, 0, tx0, 0, { isCollapsed: true }), sel(tx0, 0, tx0, 1, { rangeCount: 0 }), sel(tx0, 0, tx0, 1, { anchorNode: null }), sel(text("x"), 0, tx0, 1)]) { + setWinSel(s); + fire(); + expect(view.selection).toEqual(rng(1, 0, 1, 2)); + } + }); + + test("a plain mousedown on a row clears the model range", () => { + setWinSel({ setBaseAndExtent() {} }); + const { view, linesEl, rows } = mount(src); + view.setRange(rng(0, 0, 2, 3)); + const row = rows()[1]!; + const g = row.querySelector("g")!; + linesEl.dispatchEvent(new FakeEvent("mousedown", { target: g })); + expect(view.selection).toBeNull(); + }); +}); diff --git a/test/client.codeview.selection3.test.ts b/test/client.codeview.selection3.test.ts new file mode 100644 index 0000000..7b215a0 --- /dev/null +++ b/test/client.codeview.selection3.test.ts @@ -0,0 +1,155 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, uninstallDom, type Dom, type FakeElement } from "./helpers/fakedom.ts"; + +// A small real-DOM-like node tree (childNodes, nodeType) so text-offset <-> node mapping runs for real. +class N { + parent: N | null = null; + childNodes: N[] = []; + data = ""; + dataset: Record = {}; + constructor(public nodeType: 1 | 3, public cls = "") {} + get parentElement() { + return this.parent; + } + get textContent(): string { + return this.nodeType === 3 ? this.data : this.childNodes.map((c) => c.textContent).join(""); + } + get classList() { + return { contains: (c: string) => this.cls.split(" ").includes(c) }; + } + add(...cs: N[]) { + for (const c of cs) { + c.parent = this; + this.childNodes.push(c); + } + return this; + } + contains(n: N | null): boolean { + for (let x = n; x; x = x.parent) if (x === this) return true; + return false; + } + closest(c: string): N | null { + c = c.replace(/^\./, ""); + for (let x: N | null = this; x; x = x.parent) if (x.nodeType === 1 && x.classList.contains(c)) return x; + return null; + } + querySelector(c: string): N | null { + c = c.replace(/^\./, ""); + for (const k of this.childNodes) { + if (k.nodeType !== 1) continue; + if (k.classList.contains(c)) return k; + const r = k.querySelector(c); + if (r) return r; + } + return null; + } +} +const text = (s: string) => Object.assign(new N(3), { data: s }); +const el = (cls: string, ...cs: N[]) => new N(1, cls).add(...cs); + +let C: typeof import("../src/client/codeview.ts"); +let dom: Dom; +let raf: (() => void)[] = []; +beforeAll(async () => { + dom = installDom(); + const g = globalThis as Record; + g.ResizeObserver = class { + observe() {} + }; + g.requestAnimationFrame = (cb: () => void) => (raf.push(cb), raf.length); + g.getComputedStyle = () => ({ getPropertyValue: () => "monospace" }); + const make = dom.document.createElement.bind(dom.document); + dom.document.createElement = (tag: string) => { + const e = make(tag); + (e.style as Record).setProperty = (k: string, v: string) => (e.style[k] = v); + if (tag === "canvas") (e as unknown as Record).getContext = () => ({ measureText: () => ({ width: 80 }) }); + return e; + }; + C = await import("../src/client/codeview.ts"); +}); +afterAll(() => uninstallDom()); +const flush = () => { + const q = raf; + raf = []; + for (const f of q) f(); +}; + +function mount(src: string) { + const view = new C.CodeView(src, "text", 10) as unknown as Record; + const linesEl = view.linesEl as FakeElement & Record; + let rows: N[] = []; + Object.defineProperty(linesEl, "innerHTML", { + configurable: true, + get: () => "", + set: () => { + rows = []; + const lines = view.lines as string[]; + for (let i = view.window.first; i < Math.min(lines.length, view.window.last); i++) { + const row = el("ln", el("g", text(String(i + 1))), el("tx", text(lines[i]!))); + row.dataset.n = String(i); + rows.push(row); + } + }, + }); + linesEl.querySelectorAll = () => rows; + linesEl.contains = (n: N) => rows.some((r) => r.contains(n)); + dom.document.body.append(view.el as FakeElement); + flush(); + return { view, scroller: view.scroller as FakeElement }; +} +const rng = (sl: number, sc: number, el_: number, ec: number) => ({ start: { line: sl, col: sc }, end: { line: el_, col: ec } }); +const body = Array.from({ length: 1000 }, (_, i) => `line ${i} text`).join("\n"); +const fire = () => dom.document.dispatchEvent(new FakeEvent("selectionchange")); + +/** A DOM selection that, like a browser, records what setBaseAndExtent set. */ +function liveSelection() { + const sel: Record = { + rangeCount: 0, + isCollapsed: true, + anchorNode: null, + anchorOffset: 0, + focusNode: null, + focusOffset: 0, + setBaseAndExtent(a: N, ao: number, f: N, fo: number) { + Object.assign(sel, { rangeCount: 1, isCollapsed: false, anchorNode: a, anchorOffset: ao, focusNode: f, focusOffset: fo }); + }, + }; + (dom.window as unknown as Record).getSelection = () => sel; + return sel; +} +const scrollTo = (scroller: FakeElement, top: number) => { + scroller.scrollTop = top; + scroller.dispatchEvent(new FakeEvent("scroll")); + flush(); +}; + +describe("the DOM selection set by a restore must not overwrite the model (R-01)", () => { + test("a range partly outside the window survives restore + the browser's selectionchange", () => { + liveSelection(); + const { view, scroller } = mount(body); + view.setRange(rng(10, 2, 300, 4)); + flush(); + fire(); + expect(view.selection).toEqual(rng(10, 2, 300, 4)); // end clamped to the window by the restore + scrollTo(scroller, 4800); // line 10 leaves the window; the restore clamps the anchor to the window start + fire(); + expect(view.selection).toEqual(rng(10, 2, 300, 4)); + scrollTo(scroller, 0); + fire(); + expect(view.selection).toEqual(rng(10, 2, 300, 4)); + expect(view.selectedText().startsWith("ne 10 text")).toBe(true); + }); + + test("a genuine user selection made afterwards still replaces the model range", () => { + const sel = liveSelection(); + const { view, scroller } = mount(body); + view.setRange(rng(10, 2, 300, 4)); + flush(); + fire(); + const tx = (n: number) => (view.linesEl.querySelectorAll() as N[]).find((r) => r.dataset.n === String(n))!.querySelector("tx")!; + Object.assign(sel, { rangeCount: 1, isCollapsed: false, anchorNode: tx(5).childNodes[0], anchorOffset: 1, focusNode: tx(7).childNodes[0], focusOffset: 3 }); + fire(); + expect(view.selection).toEqual(rng(5, 1, 7, 3)); + void scroller; + }); +}); From 6f2aabe686f766d39c8a7e010cc1045211ec4d73 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 12:59:16 +0100 Subject: [PATCH 04/24] feat(image): pure code-image layout with theme colours, tabs and size cap --- src/client/imagelayout.ts | 97 ++++++++++++++++++ test/client.imagelayout.test.ts | 172 ++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/client/imagelayout.ts create mode 100644 test/client.imagelayout.test.ts diff --git a/src/client/imagelayout.ts b/src/client/imagelayout.ts new file mode 100644 index 0000000..bd2ea85 --- /dev/null +++ b/src/client/imagelayout.ts @@ -0,0 +1,97 @@ +// Pure layout of a code selection into canvas draw ops. No DOM access. +import type { Span } from "./highlight.ts"; +import { normalize, type SelRange } from "./selection.ts"; + +export interface TokenStyle { + bold?: boolean; + italic?: boolean; +} +export interface ImageTheme { + bg: string; + fg: string; + tokenColors: Record; + tokenStyles?: Record; + font: string; + size: number; +} +export interface LayoutOptions { + tabSize?: number; + padding?: number; + radius?: number; + charW?: number; + lineH?: number; +} +export type DrawOp = + | { t: "rect"; x: number; y: number; w: number; h: number; r: number; fill: string } + | { t: "text"; x: number; y: number; text: string; fill: string; bold: boolean; italic: boolean }; +export interface ImageLayout { + width: number; + height: number; + cols: number; + rows: number; + ops: DrawOp[]; +} +export type LayoutResult = ImageLayout | { tooLarge: true }; +export type ScaleResult = { scale: number } | { tooLarge: true }; + +export const MAX_PIXELS = 16_000_000; +export const MAX_ROWS = 20000; +export const MAX_COLS = 4000; + +/** Scale = min(dpr, 2), reduced until w*h*scale^2 fits MAX_PIXELS; tooLarge when even scale 1 does not fit. */ +export function fitScale(w: number, h: number, dpr: number): ScaleResult { + const area = w * h; + if (area > MAX_PIXELS) return { tooLarge: true }; + const s = Math.min(dpr, 2); + if (area * s * s <= MAX_PIXELS) return { scale: s }; + return { scale: Math.floor(Math.sqrt(MAX_PIXELS / area) * 1000) / 1000 }; +} + +/** Lays out the range of `lines` (with per-line token spans) as draw ops. Deterministic. */ +export function layoutImage(lines: string[], tokens: Span[][], theme: ImageTheme, range: SelRange, opts: LayoutOptions = {}): LayoutResult { + const { start, end } = normalize(range); + const rows = end.line - start.line + 1; + if (rows > MAX_ROWS) return { tooLarge: true }; + const tabSize = opts.tabSize ?? 4; + const padding = opts.padding ?? 16; + const radius = opts.radius ?? 8; + const charW = opts.charW ?? theme.size * 0.6; + const lineH = opts.lineH ?? Math.round(theme.size * 1.5); + const text: DrawOp[] = []; + let cols = 0; + for (let row = 0; row < rows; row++) { + const n = start.line + row; + const line = lines[n] ?? ""; + const toks: (string | undefined)[] = []; + for (const [a, b, t] of tokens[n] ?? []) for (let k = a; k < b; k++) toks[k] = t; + const c0 = row === 0 ? start.col : 0; + const c1 = row === rows - 1 ? end.col : line.length; + const y = padding + row * lineH; + let col = 0; + let run = ""; + let runCol = 0; + let runTok: string | undefined; + const flush = () => { + if (!run) return; + const st = (runTok && theme.tokenStyles?.[runTok]) || {}; + text.push({ t: "text", x: padding + runCol * charW, y, text: run, fill: (runTok && theme.tokenColors[runTok]) || theme.fg, bold: !!st.bold, italic: !!st.italic }); + run = ""; + }; + for (let i = c0; i < c1; i++) { + if (run && toks[i] !== runTok) flush(); + if (!run) { + runCol = col; + runTok = toks[i]; + } + const w = line[i] === "\t" ? tabSize - (col % tabSize) : 1; + run += " ".repeat(line[i] === "\t" ? w : 0) + (line[i] === "\t" ? "" : line[i]); + col += w; + } + flush(); + cols = Math.max(cols, col); + } + if (cols > MAX_COLS) return { tooLarge: true }; + const width = 2 * padding + cols * charW; + const height = 2 * padding + rows * lineH; + return { width, height, cols, rows, ops: [{ t: "rect", x: 0, y: 0, w: width, h: height, r: radius, fill: theme.bg }, ...text] }; +} diff --git a/test/client.imagelayout.test.ts b/test/client.imagelayout.test.ts new file mode 100644 index 0000000..82f1c03 --- /dev/null +++ b/test/client.imagelayout.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import type { Span } from "../src/client/highlight.ts"; +import { fitScale, layoutImage, type DrawOp, type ImageLayout, type ImageTheme } from "../src/client/imagelayout.ts"; +import type { SelRange } from "../src/client/selection.ts"; + +const r = (sl: number, sc: number, el: number, ec: number): SelRange => ({ start: { line: sl, col: sc }, end: { line: el, col: ec } }); +const theme: ImageTheme = { + bg: "#101010", + fg: "#eeeeee", + tokenColors: { k: "#ff0000", s: "#00ff00", c: "#888888" }, + tokenStyles: { k: { bold: true }, c: { italic: true } }, + font: "Menlo, monospace", + size: 10, +}; +const opts = { charW: 6, lineH: 15, padding: 10, radius: 8, tabSize: 4 }; +const lay = (lines: string[], tokens: Span[][], range: SelRange, o = opts): ImageLayout => { + const res = layoutImage(lines, tokens, theme, range, o); + if ("tooLarge" in res) throw new Error("unexpected tooLarge"); + return res; +}; +const texts = (l: ImageLayout) => l.ops.filter((o): o is Extract => o.t === "text"); +const rowText = (l: ImageLayout, row: number) => + texts(l) + .filter((o) => o.y === opts.padding + row * opts.lineH) + .map((o) => o.text) + .join(""); + +describe("layoutImage (AC-4)", () => { + const lines = ["const a = 'x'; // hi", "\treturn a", "tail"]; + const tokens: Span[][] = [ + [ + [0, 5, "k"], + [10, 13, "s"], + [15, 20, "c"], + ], + [[1, 7, "k"]], + [], + ]; + + test("first op is a rounded background rect covering the canvas in theme bg", () => { + const l = lay(lines, tokens, r(0, 0, 2, 4)); + expect(l.ops[0]).toEqual({ t: "rect", x: 0, y: 0, w: l.width, h: l.height, r: 8, fill: "#101010" }); + }); + + test("canvas size is padding + cols*charW by padding + rows*lineH", () => { + const l = lay(["abcd", "abcdefghij"], [[], []], r(0, 0, 1, 10)); + expect(l.cols).toBe(10); + expect(l.rows).toBe(2); + expect(l.width).toBe(2 * 10 + 10 * 6); + expect(l.height).toBe(2 * 10 + 2 * 15); + }); + + test("token runs use the theme colour, untokenized text uses fg", () => { + const l = lay(lines, tokens, r(0, 0, 0, 20)); + const kw = texts(l).find((o) => o.text === "const")!; + expect(kw.fill).toBe("#ff0000"); + expect(kw.x).toBe(10); + expect(kw.y).toBe(10); + const str = texts(l).find((o) => o.text === "'x'")!; + expect(str.fill).toBe("#00ff00"); + expect(str.x).toBe(10 + 10 * 6); + const gap = texts(l).find((o) => o.text.includes("a ="))!; + expect(gap.fill).toBe("#eeeeee"); + expect(rowText(l, 0)).toBe("const a = 'x'; // hi"); + }); + + test("bold and italic come from the token style", () => { + const l = lay(lines, tokens, r(0, 0, 0, 20)); + const kw = texts(l).find((o) => o.text === "const")!; + expect(kw.bold).toBe(true); + expect(kw.italic).toBe(false); + const cm = texts(l).find((o) => o.text === "// hi")!; + expect(cm.italic).toBe(true); + expect(cm.bold).toBe(false); + const gap = texts(l).find((o) => o.text.includes("a ="))!; + expect(gap.bold).toBe(false); + expect(gap.italic).toBe(false); + }); + + test("tabs expand to tab stops and no tab character is drawn", () => { + const l = lay(lines, tokens, r(1, 0, 1, 9)); + expect(texts(l).some((o) => o.text.includes("\t"))).toBe(false); + const ret = texts(l).find((o) => o.text === "return")!; + expect(ret.x).toBe(10 + 4 * 6); + expect(l.cols).toBe(4 + 8); + const l2 = lay(["ab\tx"], [[]], r(0, 0, 0, 4)); + expect(l2.cols).toBe(5); + expect(rowText(l2, 0)).toBe("ab x"); + }); + + test("first and last lines are clipped to the range columns", () => { + const l = lay(lines, tokens, r(0, 6, 2, 2)); + expect(l.rows).toBe(3); + expect(rowText(l, 0)).toBe("a = 'x'; // hi"); + expect(rowText(l, 2)).toBe("ta"); + const str = texts(l).find((o) => o.text === "'x'")!; + expect(str.x).toBe(10 + 4 * 6); + }); + + test("a clipped token keeps its colour", () => { + const l = lay(lines, tokens, r(0, 2, 0, 4)); + const part = texts(l).find((o) => o.text === "ns")!; + expect(part.fill).toBe("#ff0000"); + }); + + test("reversed range gives the same layout as the normalised one", () => { + expect(lay(lines, tokens, r(2, 2, 0, 6))).toEqual(lay(lines, tokens, r(0, 6, 2, 2))); + }); + + test("no line numbers by default: only the rect and the line text are drawn", () => { + const l = lay(["ab", "cd"], [[], []], r(0, 0, 1, 2)); + expect(rowText(l, 0)).toBe("ab"); + expect(rowText(l, 1)).toBe("cd"); + expect(texts(l).every((o) => o.x >= 10)).toBe(true); + expect(l.ops.filter((o) => o.t === "rect")).toHaveLength(1); + }); + + test("identical input yields identical ops", () => { + expect(lay(lines, tokens, r(0, 0, 2, 4))).toEqual(lay(lines, tokens, r(0, 0, 2, 4))); + }); + + test("defaults derive charW/lineH from the font size", () => { + const res = layoutImage(["abcd"], [[]], theme, r(0, 0, 0, 4)); + if ("tooLarge" in res) throw new Error("unexpected"); + expect(res.width).toBeGreaterThan(0); + expect(res.height).toBeGreaterThan(res.rows * theme.size - 1); + }); + + test("empty lines inside the range occupy a row", () => { + const l = lay(["a", "", "b"], [[], [], []], r(0, 0, 2, 1)); + expect(l.rows).toBe(3); + expect(rowText(l, 2)).toBe("b"); + }); +}); + +describe("size caps (AC-7)", () => { + test("more than 20000 rows is tooLarge", () => { + const lines = Array.from({ length: 20001 }, () => "x"); + expect(layoutImage(lines, lines.map(() => []), theme, r(0, 0, 20000, 1), opts)).toEqual({ tooLarge: true }); + }); + test("exactly 20000 rows is allowed", () => { + const lines = Array.from({ length: 20000 }, () => "x"); + expect("tooLarge" in layoutImage(lines, lines.map(() => []), theme, r(0, 0, 19999, 1), opts)).toBe(false); + }); + test("more than 4000 columns is tooLarge", () => { + const lines = ["x".repeat(4001)]; + expect(layoutImage(lines, [[]], theme, r(0, 0, 0, 4001), opts)).toEqual({ tooLarge: true }); + }); +}); + +describe("fitScale (AC-7)", () => { + test("uses dpr when small", () => { + expect(fitScale(100, 100, 1)).toEqual({ scale: 1 }); + expect(fitScale(100, 100, 1.5)).toEqual({ scale: 1.5 }); + }); + test("caps dpr at 2", () => { + expect(fitScale(100, 100, 3)).toEqual({ scale: 2 }); + }); + test("reduces the scale so the area fits 16M pixels", () => { + const res = fitScale(3000, 3000, 2); + if (!("scale" in res)) throw new Error("expected scale"); + expect(res.scale).toBeLessThan(2); + expect(res.scale).toBeGreaterThanOrEqual(1); + expect(3000 * 3000 * res.scale * res.scale).toBeLessThanOrEqual(16_000_000); + }); + test("a size that cannot fit at scale 1 is tooLarge", () => { + expect(fitScale(5000, 4000, 1)).toEqual({ tooLarge: true }); + }); + test("exactly 16M pixels fits at scale 1", () => { + expect(fitScale(4000, 4000, 2)).toEqual({ scale: 1 }); + }); +}); From dda8f24ca46e12e7f49df57c0b034bc8940ee79c Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 13:03:20 +0100 Subject: [PATCH 05/24] fix(image): clamp range columns and keep tab stops on partial lines --- src/client/imagelayout.ts | 10 +++-- test/client.imagelayout.clamp.test.ts | 57 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 test/client.imagelayout.clamp.test.ts diff --git a/src/client/imagelayout.ts b/src/client/imagelayout.ts index bd2ea85..cf322b4 100644 --- a/src/client/imagelayout.ts +++ b/src/client/imagelayout.ts @@ -64,17 +64,19 @@ export function layoutImage(lines: string[], tokens: Span[][], theme: ImageTheme const line = lines[n] ?? ""; const toks: (string | undefined)[] = []; for (const [a, b, t] of tokens[n] ?? []) for (let k = a; k < b; k++) toks[k] = t; - const c0 = row === 0 ? start.col : 0; - const c1 = row === rows - 1 ? end.col : line.length; + const c0 = Math.min(row === 0 ? start.col : 0, line.length); + const c1 = Math.min(row === rows - 1 ? end.col : line.length, line.length); const y = padding + row * lineH; let col = 0; + for (let i = 0; i < c0; i++) col += line[i] === "\t" ? tabSize - (col % tabSize) : 1; + const base = col; let run = ""; let runCol = 0; let runTok: string | undefined; const flush = () => { if (!run) return; const st = (runTok && theme.tokenStyles?.[runTok]) || {}; - text.push({ t: "text", x: padding + runCol * charW, y, text: run, fill: (runTok && theme.tokenColors[runTok]) || theme.fg, bold: !!st.bold, italic: !!st.italic }); + text.push({ t: "text", x: padding + (runCol - base) * charW, y, text: run, fill: (runTok && theme.tokenColors[runTok]) || theme.fg, bold: !!st.bold, italic: !!st.italic }); run = ""; }; for (let i = c0; i < c1; i++) { @@ -88,7 +90,7 @@ export function layoutImage(lines: string[], tokens: Span[][], theme: ImageTheme col += w; } flush(); - cols = Math.max(cols, col); + cols = Math.max(cols, col - base); } if (cols > MAX_COLS) return { tooLarge: true }; const width = 2 * padding + cols * charW; diff --git a/test/client.imagelayout.clamp.test.ts b/test/client.imagelayout.clamp.test.ts new file mode 100644 index 0000000..97a946c --- /dev/null +++ b/test/client.imagelayout.clamp.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import type { Span } from "../src/client/highlight.ts"; +import { layoutImage, type DrawOp, type ImageLayout, type ImageTheme } from "../src/client/imagelayout.ts"; +import type { SelRange } from "../src/client/selection.ts"; + +const r = (sl: number, sc: number, el: number, ec: number): SelRange => ({ start: { line: sl, col: sc }, end: { line: el, col: ec } }); +const theme: ImageTheme = { bg: "#000", fg: "#fff", tokenColors: {}, font: "monospace", size: 10 }; +const opts = { charW: 6, lineH: 15, padding: 10, radius: 8, tabSize: 4 }; +const lay = (lines: string[], range: SelRange, tokens: Span[][] = lines.map(() => [])): ImageLayout => { + const res = layoutImage(lines, tokens, theme, range, opts); + if ("tooLarge" in res) throw new Error("unexpected tooLarge"); + return res; +}; +const texts = (l: ImageLayout) => l.ops.filter((o): o is Extract => o.t === "text"); +const rowText = (l: ImageLayout, row: number) => + texts(l) + .filter((o) => o.y === opts.padding + row * opts.lineH) + .map((o) => o.text) + .join(""); + +describe("column clamping (R-01)", () => { + test("end column past the line length draws no 'undefined' and does not inflate cols", () => { + const l = lay(["abc"], r(0, 0, 0, 50)); + expect(rowText(l, 0)).toBe("abc"); + expect(l.cols).toBe(3); + expect(l.width).toBe(2 * 10 + 3 * 6); + }); + test("start column past the line length yields an empty row", () => { + const l = lay(["abc", "def"], r(0, 9, 1, 2)); + expect(rowText(l, 0)).toBe(""); + expect(rowText(l, 1)).toBe("de"); + expect(l.cols).toBe(2); + }); + test("range extending past the last line draws empty rows without undefined", () => { + const l = lay(["abc"], r(0, 0, 2, 4)); + expect(l.rows).toBe(3); + expect(rowText(l, 0)).toBe("abc"); + expect(texts(l).some((o) => o.text.includes("undefined"))).toBe(false); + expect(l.cols).toBe(3); + }); +}); + +describe("tab stops use the real column 0 (R-02)", () => { + test("a partial first line keeps the editor's tab alignment", () => { + // "ab\tx": tab after col 2 advances to col 4; selecting from col 1 keeps 'b' + 2 spaces + 'x' + const l = lay(["ab\tx"], r(0, 1, 0, 4)); + expect(rowText(l, 0)).toBe("b x"); + expect(texts(l).find((o) => o.text.includes("x"))).toBeDefined(); + expect(l.cols).toBe(4); + }); + test("a tab at the start of the clipped range expands to the real stop, not a full tab", () => { + // "abc\tx": tab at col 3 only advances 1 column + const l = lay(["abc\tx"], r(0, 3, 0, 5)); + expect(rowText(l, 0)).toBe(" x"); + expect(l.cols).toBe(2); + }); +}); From 39f61311cd7966dd9d9dae6af7312d8142adbc55 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 13:07:42 +0100 Subject: [PATCH 06/24] feat(image): copy code selection as PNG with clipboard, native and download fallbacks --- packaging/macos/main.swift | 9 ++ src/client/copyimage.ts | 91 +++++++++++++++++++ src/client/imagedom.ts | 68 ++++++++++++++ test/client.copyimage.test.ts | 161 ++++++++++++++++++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 src/client/copyimage.ts create mode 100644 src/client/imagedom.ts create mode 100644 test/client.copyimage.test.ts diff --git a/packaging/macos/main.swift b/packaging/macos/main.swift index 4257b85..c1af9c0 100644 --- a/packaging/macos/main.swift +++ b/packaging/macos/main.swift @@ -240,6 +240,7 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI // Messages from the page: // {t: "theme", dark, bg} sync the window appearance // {t: "drag"} mouse-down on empty title bar: move the window + // {t: "copyimage", png} PNG (base64) to put on the general pasteboard // {t: "dblclick"} double-click on the title bar: system zoom/minimize action func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [String: Any], let t = body["t"] as? String else { return } @@ -251,6 +252,14 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI (NSApp.delegate as? AppDelegate)?.relaunch() return } + if t == "copyimage" { + if let b64 = body["png"] as? String, let data = Data(base64Encoded: b64) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setData(data, forType: .png) + } + return + } if t == "dblclick" { switch UserDefaults.standard.string(forKey: "AppleActionOnDoubleClick") { case "Minimize": window.performMiniaturize(nil) diff --git a/src/client/copyimage.ts b/src/client/copyimage.ts new file mode 100644 index 0000000..18fe501 --- /dev/null +++ b/src/client/copyimage.ts @@ -0,0 +1,91 @@ +// Image copy orchestration: layout -> render -> clipboard / native / download. Deps are injected. +import type { Span } from "./highlight.ts"; +import { fitScale, layoutImage, type ImageLayout, type ImageTheme, type LayoutOptions } from "./imagelayout.ts"; +import { textOf, type SelRange } from "./selection.ts"; + +export const MSG_OK = "Copied as image"; +export const MSG_TOO_LARGE = "Selection too large for an image"; +export const MSG_DOWNLOADED = "Clipboard unavailable: image downloaded"; +export const MSG_FAILED = "Could not create image; copied text instead"; +export const MSG_NO_SELECTION = "Nothing selected"; + +export type CopyResult = "native" | "clipboard" | "downloaded" | "failed"; + +export interface CopyImageDeps { + renderPng(layout: ImageLayout, scale: number): Promise; + clipboard?: { write?(items: unknown[]): Promise; ClipboardItem?: new (data: Record>) => unknown }; + download(blob: Blob, name: string): void; + native?: (png: Blob) => Promise; + copyText(text: string): Promise | void; + notify(msg: string): void; +} +export interface CopyImageInput { + lines: string[]; + tokens: Span[][]; + theme: ImageTheme; + range: SelRange; + dpr: number; + opts?: LayoutOptions; +} + +const FILE_NAME = "selection.png"; + +/** Writes a PNG (as a promise, so the ClipboardItem can be built inside the user gesture). */ +export async function copyPng(deps: CopyImageDeps, png: Promise): Promise { + const { clipboard } = deps; + // Built before any await: the browser only accepts it while the user gesture is live. + let item: unknown; + if (!deps.native && clipboard?.ClipboardItem && clipboard.write) { + try { + item = new clipboard.ClipboardItem({ "image/png": png }); + } catch { + item = undefined; + } + } + let blob: Blob; + try { + blob = await png; + } catch { + return "failed"; + } + if (item) { + try { + await clipboard!.write!([item]); + return "clipboard"; + } catch { + /* fall through to download */ + } + } + if (deps.native) { + try { + await deps.native(blob); + return "native"; + } catch { + /* fall through to download */ + } + } + deps.download(blob, FILE_NAME); + return "downloaded"; +} + +/** Copies the range as an image; returns (and notifies) the user-facing message. */ +export async function copyCodeImage(deps: CopyImageDeps, input: CopyImageInput): Promise { + const done = (msg: string) => { + deps.notify(msg); + return msg; + }; + const layout = layoutImage(input.lines, input.tokens, input.theme, input.range, input.opts); + if ("tooLarge" in layout) return done(MSG_TOO_LARGE); + const fit = fitScale(layout.width, layout.height, input.dpr); + if ("tooLarge" in fit) return done(MSG_TOO_LARGE); + const result = await copyPng(deps, deps.renderPng(layout, fit.scale)); + if (result === "failed") { + try { + await deps.copyText(textOf(input.lines, input.range)); + } catch { + /* nothing more to try */ + } + return done(MSG_FAILED); + } + return done(result === "downloaded" ? MSG_DOWNLOADED : MSG_OK); +} diff --git a/src/client/imagedom.ts b/src/client/imagedom.ts new file mode 100644 index 0000000..3e8f53f --- /dev/null +++ b/src/client/imagedom.ts @@ -0,0 +1,68 @@ +// Thin browser glue for image copy (canvas, clipboard, download, native bridge). Verified manually. +import type { CopyImageDeps } from "./copyimage.ts"; +import type { ImageLayout } from "./imagelayout.ts"; + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +export function renderLayoutPng(layout: ImageLayout, scale: number, font: string, size: number): Promise { + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(layout.width * scale); + canvas.height = Math.ceil(layout.height * scale); + const ctx = canvas.getContext("2d"); + if (!ctx) return Promise.reject(new Error("no canvas")); + ctx.scale(scale, scale); + ctx.textBaseline = "top"; + for (const op of layout.ops) { + if (op.t === "rect") { + ctx.fillStyle = op.fill; + roundRect(ctx, op.x, op.y, op.w, op.h, op.r); + ctx.fill(); + } else { + ctx.font = `${op.italic ? "italic " : ""}${op.bold ? "bold " : ""}${size}px ${font}`; + ctx.fillStyle = op.fill; + ctx.fillText(op.text, op.x, op.y); + } + } + return new Promise((res, rej) => canvas.toBlob((b) => (b ? res(b) : rej(new Error("toBlob failed"))), "image/png")); +} + +export function downloadBlob(blob: Blob, name: string): void { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} + +function toBase64(blob: Blob): Promise { + return new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(String(fr.result).split(",")[1] ?? ""); + fr.onerror = () => rej(fr.error); + fr.readAsDataURL(blob); + }); +} + +type Bridge = { postMessage(m: unknown): void } | undefined; + +export function browserDeps(opts: { font: string; size: number; bridge?: Bridge; notify(msg: string): void }): CopyImageDeps { + const w = window as unknown as { ClipboardItem?: new (d: Record>) => unknown }; + const cb = navigator.clipboard as { write?(items: unknown[]): Promise } | undefined; + const bridge = opts.bridge; + return { + renderPng: (layout, scale) => renderLayoutPng(layout, scale, opts.font, opts.size), + clipboard: cb?.write && w.ClipboardItem ? { write: (items) => cb.write!(items), ClipboardItem: w.ClipboardItem } : undefined, + download: downloadBlob, + native: bridge ? async (png) => bridge.postMessage({ t: "copyimage", png: await toBase64(png) }) : undefined, + copyText: (t) => navigator.clipboard.writeText(t), + notify: opts.notify, + }; +} diff --git a/test/client.copyimage.test.ts b/test/client.copyimage.test.ts new file mode 100644 index 0000000..b2553dd --- /dev/null +++ b/test/client.copyimage.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { copyCodeImage, copyPng, MSG_DOWNLOADED, MSG_FAILED, MSG_NO_SELECTION, MSG_OK, MSG_TOO_LARGE, type CopyImageDeps, type CopyImageInput } from "../src/client/copyimage.ts"; +import type { ImageTheme } from "../src/client/imagelayout.ts"; + +const theme: ImageTheme = { bg: "#000", fg: "#fff", tokenColors: {}, font: "monospace", size: 10 }; +const range = (sl: number, sc: number, el: number, ec: number) => ({ start: { line: sl, col: sc }, end: { line: el, col: ec } }); +const blob = new Blob(["png"], { type: "image/png" }); + +function mk(over: Partial = {}, log: string[] = []) { + class Item { + constructor(public data: Record>) { + log.push("item"); + } + } + const deps: CopyImageDeps = { + renderPng: async () => { + log.push("render"); + return blob; + }, + clipboard: { + ClipboardItem: Item, + write: async (items) => { + log.push(`write:${items.length}`); + }, + }, + download: (_b, name) => void log.push(`download:${name}`), + copyText: (t) => void log.push(`text:${t}`), + notify: (m) => void log.push(`notify:${m}`), + ...over, + }; + return { deps, log }; +} +const input = (over: Partial = {}): CopyImageInput => ({ lines: ["ab", "cd"], tokens: [[], []], theme, range: range(0, 0, 1, 2), dpr: 2, ...over }); + +describe("messages (AC-7b)", () => { + test("exact strings", () => { + expect(MSG_OK).toBe("Copied as image"); + expect(MSG_TOO_LARGE).toBe("Selection too large for an image"); + expect(MSG_DOWNLOADED).toBe("Clipboard unavailable: image downloaded"); + expect(MSG_FAILED).toBe("Could not create image; copied text instead"); + expect(MSG_NO_SELECTION).toBe("Nothing selected"); + }); +}); + +describe("copyPng (AC-8)", () => { + test("creates the ClipboardItem synchronously, before any await", () => { + const { deps, log } = mk(); + void copyPng(deps, Promise.resolve(blob)); + expect(log).toEqual(["item"]); + }); + test("writes one item and reports clipboard", async () => { + const { deps, log } = mk(); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("clipboard"); + expect(log).toEqual(["item", "write:1"]); + }); + test("missing ClipboardItem downloads", async () => { + const { deps, log } = mk({ clipboard: { write: async () => {} } }); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); + expect(log.filter((l) => l.startsWith("download"))).toHaveLength(1); + }); + test("missing clipboard or write downloads", async () => { + const a = mk({ clipboard: undefined }); + expect(await copyPng(a.deps, Promise.resolve(blob))).toBe("downloaded"); + const b = mk({ clipboard: { ClipboardItem: class {} as never } }); + expect(await copyPng(b.deps, Promise.resolve(blob))).toBe("downloaded"); + }); + test("write rejection falls back to download", async () => { + const { deps, log } = mk({ + clipboard: { + ClipboardItem: class {} as never, + write: async () => { + throw new Error("denied"); + }, + }, + }); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); + expect(log).toHaveLength(1); + expect(log[0]).toStartWith("download:"); + }); + test("native handler is preferred over the clipboard", async () => { + const got: Blob[] = []; + const { deps, log } = mk({ native: async (b) => void got.push(b) }); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("native"); + expect(got).toEqual([blob]); + expect(log.some((l) => l.startsWith("write"))).toBe(false); + }); + test("native failure falls back to download", async () => { + const { deps, log } = mk({ + native: async () => { + throw new Error("x"); + }, + }); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); + expect(log.filter((l) => l.startsWith("download"))).toHaveLength(1); + }); + test("a rejected render promise reports failed without download", async () => { + const { deps, log } = mk(); + const p = Promise.reject(new Error("render")); + expect(await copyPng(deps, p)).toBe("failed"); + expect(log.some((l) => l.startsWith("download"))).toBe(false); + }); +}); + +describe("copyCodeImage (AC-4, AC-7)", () => { + test("success renders at min(dpr,2) and reports MSG_OK", async () => { + const scales: number[] = []; + const { deps, log } = mk({ + renderPng: async (_l, s) => { + scales.push(s); + return blob; + }, + }); + expect(await copyCodeImage(deps, input({ dpr: 3 }))).toBe("Copied as image"); + expect(scales).toEqual([2]); + expect(log).toContain("notify:Copied as image"); + }); + test("renders synchronously inside the gesture (ClipboardItem built before await)", () => { + const { deps, log } = mk(); + void copyCodeImage(deps, input()); + expect(log).toEqual(["render", "item"]); + }); + test("layout receives the laid out size", async () => { + let w = 0; + const { deps } = mk({ + renderPng: async (l) => { + w = l.width; + return blob; + }, + }); + await copyCodeImage(deps, input({ opts: { charW: 6, padding: 10, lineH: 15 } })); + expect(w).toBe(2 * 10 + 2 * 6); + }); + test("too many rows reports too large and never touches the clipboard", async () => { + const lines = Array.from({ length: 20001 }, () => "x"); + const { deps, log } = mk(); + const msg = await copyCodeImage(deps, input({ lines, tokens: [], range: range(0, 0, 20000, 1) })); + expect(msg).toBe("Selection too large for an image"); + expect(log).toEqual(["notify:Selection too large for an image"]); + }); + test("area beyond the pixel cap reports too large", async () => { + const lines = Array.from({ length: 5000 }, () => "x".repeat(3000)); + const { deps, log } = mk(); + const msg = await copyCodeImage(deps, input({ lines, tokens: [], range: range(0, 0, 4999, 3000) })); + expect(msg).toBe("Selection too large for an image"); + expect(log.some((l) => l === "item" || l.startsWith("write") || l === "render")).toBe(false); + }); + test("clipboard unavailable reports the download message", async () => { + const { deps } = mk({ clipboard: undefined }); + expect(await copyCodeImage(deps, input())).toBe("Clipboard unavailable: image downloaded"); + }); + test("render failure copies plain text and reports MSG_FAILED", async () => { + const { deps, log } = mk({ + renderPng: async () => { + throw new Error("canvas"); + }, + }); + expect(await copyCodeImage(deps, input({ range: range(0, 1, 1, 1) }))).toBe("Could not create image; copied text instead"); + expect(log).toContain("text:b\nc"); + expect(log).toContain("notify:Could not create image; copied text instead"); + }); +}); From dcdda363e7827d9156ff3d832b3f73d9e34db4ea Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 13:12:39 +0100 Subject: [PATCH 07/24] fix(image): call clipboard.write inside the gesture and preload the font --- src/client/copyimage.ts | 25 ++++---- src/client/imagedom.ts | 9 ++- test/client.copyimage.gesture.test.ts | 88 +++++++++++++++++++++++++++ test/client.copyimage.test.ts | 6 +- 4 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 test/client.copyimage.gesture.test.ts diff --git a/src/client/copyimage.ts b/src/client/copyimage.ts index 18fe501..ffb314e 100644 --- a/src/client/copyimage.ts +++ b/src/client/copyimage.ts @@ -33,13 +33,22 @@ const FILE_NAME = "selection.png"; /** Writes a PNG (as a promise, so the ClipboardItem can be built inside the user gesture). */ export async function copyPng(deps: CopyImageDeps, png: Promise): Promise { const { clipboard } = deps; - // Built before any await: the browser only accepts it while the user gesture is live. - let item: unknown; + // ClipboardItem and write() are both called before any await: browsers only accept them while the user gesture is live. + let write: Promise | undefined; if (!deps.native && clipboard?.ClipboardItem && clipboard.write) { try { - item = new clipboard.ClipboardItem({ "image/png": png }); + write = clipboard.write([new clipboard.ClipboardItem({ "image/png": png })]); + } catch (e) { + write = Promise.reject(e); + } + } + if (write) { + try { + await write; + await png; // a render failure must not read as success even if write() ignored it + return "clipboard"; } catch { - item = undefined; + /* fall through to download */ } } let blob: Blob; @@ -48,14 +57,6 @@ export async function copyPng(deps: CopyImageDeps, png: Promise): Promise< } catch { return "failed"; } - if (item) { - try { - await clipboard!.write!([item]); - return "clipboard"; - } catch { - /* fall through to download */ - } - } if (deps.native) { try { await deps.native(blob); diff --git a/src/client/imagedom.ts b/src/client/imagedom.ts index 3e8f53f..f56df2e 100644 --- a/src/client/imagedom.ts +++ b/src/client/imagedom.ts @@ -12,12 +12,17 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe ctx.closePath(); } -export function renderLayoutPng(layout: ImageLayout, scale: number, font: string, size: number): Promise { +export async function renderLayoutPng(layout: ImageLayout, scale: number, font: string, size: number): Promise { + try { + await document.fonts.load(`${size}px ${font}`); + } catch { + /* draw with whatever font is available */ + } const canvas = document.createElement("canvas"); canvas.width = Math.ceil(layout.width * scale); canvas.height = Math.ceil(layout.height * scale); const ctx = canvas.getContext("2d"); - if (!ctx) return Promise.reject(new Error("no canvas")); + if (!ctx) throw new Error("no canvas"); ctx.scale(scale, scale); ctx.textBaseline = "top"; for (const op of layout.ops) { diff --git a/test/client.copyimage.gesture.test.ts b/test/client.copyimage.gesture.test.ts new file mode 100644 index 0000000..4ade2a6 --- /dev/null +++ b/test/client.copyimage.gesture.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { copyCodeImage, copyPng, type CopyImageDeps } from "../src/client/copyimage.ts"; +import type { ImageTheme } from "../src/client/imagelayout.ts"; + +const theme: ImageTheme = { bg: "#000", fg: "#fff", tokenColors: {}, font: "monospace", size: 10 }; +const blob = new Blob(["png"], { type: "image/png" }); +const range = { start: { line: 0, col: 0 }, end: { line: 1, col: 2 } }; + +// Realistic clipboard: write() settles with the ClipboardItem's blob promise. +function mk(log: string[], over: Partial = {}): CopyImageDeps { + class Item { + constructor(public data: Record>) {} + } + return { + renderPng: async () => blob, + clipboard: { + ClipboardItem: Item, + write: async (items) => { + log.push("write"); + await (items[0] as Item).data["image/png"]; + }, + }, + download: () => void log.push("download"), + copyText: (t) => void log.push(`text:${t}`), + notify: (m) => void log.push(`notify:${m}`), + ...over, + }; +} + +describe("clipboard.write is called inside the gesture (R-01)", () => { + test("copyPng calls write before the png promise resolves", () => { + const log: string[] = []; + let release!: (b: Blob) => void; + const png = new Promise((r) => (release = r)); + void copyPng(mk(log), png); + expect(log).toEqual(["write"]); + release(blob); + }); + test("copyCodeImage calls render then write synchronously", () => { + const log: string[] = []; + const deps = mk(log, { + renderPng: () => { + log.push("render"); + return new Promise(() => {}); + }, + }); + void copyCodeImage(deps, { lines: ["ab", "cd"], tokens: [[], []], theme, range, dpr: 1 }); + expect(log).toEqual(["render", "write"]); + }); + test("a rejected png makes write reject and reports failed without download", async () => { + const log: string[] = []; + expect(await copyPng(mk(log), Promise.reject(new Error("render")))).toBe("failed"); + expect(log).toEqual(["write"]); + }); + test("write rejecting while the png is fine falls back to download", async () => { + const log: string[] = []; + const deps = mk(log, { + clipboard: { + ClipboardItem: class {} as never, + write: async () => { + log.push("write"); + throw new Error("denied"); + }, + }, + }); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); + expect(log).toEqual(["write", "download"]); + }); + test("render failure copies plain text, reports the failed message, never downloads", async () => { + const log: string[] = []; + const deps = mk(log, { + renderPng: async () => { + throw new Error("canvas"); + }, + }); + const msg = await copyCodeImage(deps, { lines: ["ab", "cd"], tokens: [[], []], theme, range, dpr: 1 }); + expect(msg).toBe("Could not create image; copied text instead"); + expect(log).toContain("text:ab\ncd"); + expect(log).not.toContain("download"); + }); + test("too large is decided before write is called", async () => { + const log: string[] = []; + const lines = Array.from({ length: 20001 }, () => "x"); + const msg = await copyCodeImage(mk(log), { lines, tokens: [], theme, range: { start: { line: 0, col: 0 }, end: { line: 20000, col: 1 } }, dpr: 1 }); + expect(msg).toBe("Selection too large for an image"); + expect(log).toEqual(["notify:Selection too large for an image"]); + }); +}); diff --git a/test/client.copyimage.test.ts b/test/client.copyimage.test.ts index b2553dd..fb4c574 100644 --- a/test/client.copyimage.test.ts +++ b/test/client.copyimage.test.ts @@ -43,10 +43,10 @@ describe("messages (AC-7b)", () => { }); describe("copyPng (AC-8)", () => { - test("creates the ClipboardItem synchronously, before any await", () => { + test("creates the ClipboardItem and calls write synchronously, before any await", () => { const { deps, log } = mk(); void copyPng(deps, Promise.resolve(blob)); - expect(log).toEqual(["item"]); + expect(log).toEqual(["item", "write:1"]); }); test("writes one item and reports clipboard", async () => { const { deps, log } = mk(); @@ -117,7 +117,7 @@ describe("copyCodeImage (AC-4, AC-7)", () => { test("renders synchronously inside the gesture (ClipboardItem built before await)", () => { const { deps, log } = mk(); void copyCodeImage(deps, input()); - expect(log).toEqual(["render", "item"]); + expect(log).toEqual(["render", "item", "write:1"]); }); test("layout receives the laid out size", async () => { let w = 0; From 9d7e1418acb1210ecf000f98a69ad2073444d511 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 13:16:55 +0100 Subject: [PATCH 08/24] feat(markdown): copy rendered markdown selection as PNG via SVG snapshot --- src/client/imagedom.ts | 59 ++++++++++++++++++++++ src/client/mdsnap.ts | 70 ++++++++++++++++++++++++++ src/client/mdview.ts | 8 +++ test/client.mdsnap.test.ts | 100 +++++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 src/client/mdsnap.ts create mode 100644 test/client.mdsnap.test.ts diff --git a/src/client/imagedom.ts b/src/client/imagedom.ts index f56df2e..3fbfc01 100644 --- a/src/client/imagedom.ts +++ b/src/client/imagedom.ts @@ -1,5 +1,6 @@ // Thin browser glue for image copy (canvas, clipboard, download, native bridge). Verified manually. import type { CopyImageDeps } from "./copyimage.ts"; +import type { SnapDeps, SnapFragment } from "./mdsnap.ts"; import type { ImageLayout } from "./imagelayout.ts"; function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { @@ -71,3 +72,61 @@ export function browserDeps(opts: { font: string; size: number; bridge?: Bridge; notify: opts.notify, }; } + +const blobToDataUri = (b: Blob) => + new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(String(fr.result)); + fr.onerror = () => rej(fr.error); + fr.readAsDataURL(b); + }); + +export const markdownSnapDeps: SnapDeps = { + computedStyle(el) { + const cs = getComputedStyle(el as Element); + let out = ""; + for (let i = 0; i < cs.length; i++) out += `${cs[i]}:${cs.getPropertyValue(cs[i]!)};`; + return out; + }, + loadFont: async (url) => blobToDataUri(await (await fetch(url)).blob()), + loadImage: (svgUrl) => + new Promise((res, rej) => { + const img = new Image(); + img.onload = () => res(img); + img.onerror = () => rej(new Error("svg image failed")); + img.src = svgUrl; + }), + async toBlob(bitmap, width, height) { + const scale = Math.min(window.devicePixelRatio || 1, 2); + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(width * scale); + canvas.height = Math.ceil(height * scale); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("no canvas"); + ctx.scale(scale, scale); + ctx.drawImage(bitmap as CanvasImageSource, 0, 0, width, height); + return new Promise((res, rej) => canvas.toBlob((b) => (b ? res(b) : rej(new Error("toBlob failed"))), "image/png")); + }, +}; + +/** Clones the range into an index-tagged fragment (element 0 is the wrapper) ready for snapshotMarkdown. */ +export function fragmentFromRange(range: Range, host: HTMLElement, bg: string): SnapFragment { + const wrap = document.createElement("div"); + wrap.appendChild(range.cloneContents()); + const live = Array.from(host.querySelectorAll("*")).filter((e) => range.intersectsNode(e)); + const clones = [wrap, ...Array.from(wrap.querySelectorAll("*"))]; + clones.forEach((c, n) => c.setAttribute("data-snap", String(n))); + const rect = host.getBoundingClientRect(); + // clone styles come from the live elements of the same tag order; the wrapper takes the host's style + const elements: Element[] = [host, ...live.slice(0, clones.length - 1)]; + while (elements.length < clones.length) elements.push(host); + return { + html: new XMLSerializer().serializeToString(wrap), + elements, + fontUrls: {}, + width: Math.ceil(rect.width), + height: Math.ceil(Math.min(rect.height, host.scrollHeight)), + bg, + text: range.toString(), + }; +} diff --git a/src/client/mdsnap.ts b/src/client/mdsnap.ts new file mode 100644 index 0000000..84c39d8 --- /dev/null +++ b/src/client/mdsnap.ts @@ -0,0 +1,70 @@ +// Markdown selection -> PNG via an SVG foreignObject. Pure logic; browser APIs are injected. + +export interface FontFace { + family: string; + uri: string; +} +export interface SvgInput { + html: string; + styles: Record; + fonts: FontFace[]; + width: number; + height: number; + bg: string; +} +export interface SnapDeps { + computedStyle(el: unknown): string; + loadFont(url: string): Promise; + loadImage(svgUrl: string): Promise; + toBlob(bitmap: unknown, width: number, height: number): Promise; +} +export interface SnapFragment { + html: string; + elements: unknown[]; + fontUrls: Record; + width: number; + height: number; + bg: string; + text: string; +} + +const esc = (v: string) => v.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +export function buildSvg(i: SvgInput): string { + const faces = i.fonts.map((f) => `@font-face{font-family:"${esc(f.family)}";src:url("${esc(f.uri)}")}`).join(""); + const rules = Object.entries(i.styles) + .map(([k, css]) => `${/^\d+$/.test(k) ? `[data-snap="${k}"]` : esc(k)}{${esc(css)}}`) + .join(""); + const w = Math.round(i.width); + const h = Math.round(i.height); + return ( + `` + + `` + + `
` + + `${i.html}
` + ); +} + +export async function snapshotMarkdown(deps: SnapDeps, f: SnapFragment): Promise { + try { + const styles: Record = {}; + f.elements.forEach((el, n) => { + styles[String(n)] = deps.computedStyle(el); + }); + const fonts: FontFace[] = []; + await Promise.all( + Object.entries(f.fontUrls).map(async ([family, url]) => { + try { + fonts.push({ family, uri: await deps.loadFont(url) }); + } catch { + /* render with a fallback font */ + } + }), + ); + const svg = buildSvg({ html: f.html, styles, fonts, width: f.width, height: f.height, bg: f.bg }); + const bitmap = await deps.loadImage("data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg)); + return await deps.toBlob(bitmap, f.width, f.height); + } catch { + return { fallbackText: f.text }; + } +} diff --git a/src/client/mdview.ts b/src/client/mdview.ts index 37a7166..37bee5c 100644 --- a/src/client/mdview.ts +++ b/src/client/mdview.ts @@ -132,6 +132,14 @@ export class MarkdownView { return true; } + /** The live selection range when it lies inside this view, else null. */ + selectionRange(): Range | null { + const sel = window.getSelection?.(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null; + const r = sel.getRangeAt(0); + return this.el.contains(r.commonAncestorContainer) ? r : null; + } + setFontSize(px: number) { this.el.style.fontSize = `${px}px`; } diff --git a/test/client.mdsnap.test.ts b/test/client.mdsnap.test.ts new file mode 100644 index 0000000..dd86cdb --- /dev/null +++ b/test/client.mdsnap.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { buildSvg, snapshotMarkdown, type SnapDeps, type SnapFragment } from "../src/client/mdsnap.ts"; + +const input = { + html: '

a & b

', + styles: { p: "color:red;margin:0", "0": "font-weight:bold" } as Record, + fonts: [{ family: "Mono", uri: "data:font/woff2;base64,AAAA" }], + width: 320, + height: 120, + bg: "#101010", +}; + +describe("buildSvg", () => { + test("is a well-formed svg with a foreignObject of the given size", () => { + const svg = buildSvg(input); + expect(svg.startsWith("]*width="320"[^>]*height="120"/); + expect(svg).toMatch(/]*width="320"[^>]*height="120"/); + expect(svg).toContain('xmlns="http://www.w3.org/1999/xhtml"'); + expect(svg.trimEnd().endsWith("")).toBe(true); + }); + test("inlines styles, font-face data URIs, content and theme background", () => { + const svg = buildSvg(input); + expect(svg).toContain("p{color:red;margin:0}"); + expect(svg).toContain('[data-snap="0"]{font-weight:bold}'); + expect(svg).toContain("@font-face"); + expect(svg).toContain("Mono"); + expect(svg).toContain("data:font/woff2;base64,AAAA"); + expect(svg).toContain("#101010"); + expect(svg).toContain("a & b"); + }); + test("escapes hostile style and font values so the markup stays well-formed", () => { + const svg = buildSvg({ ...input, styles: { "p" }, bg: 'x">' }); + expect(svg).not.toContain("