diff --git a/DESIGN.md b/DESIGN.md index a1dd7b8..fcee117 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -647,3 +647,14 @@ and no new endpoint. 5. Fenced code with `lexLine` highlighting, size caps, info-string allowlist. 6. Images (relative via blob, remote placeholder), size gate with "Render anyway", palette command, README and SECURITY notes, fuzz test. + +## 15. Copy as Image + +The code view keeps its selection as a model range (line/column), so Select All, copy and image export cover lines +that are not in the DOM and never include decorations (gutter, "more characters"). **Copy as Image** (right-click +menu with a selection, palette command `selection.copyImage`, Cmd/Ctrl+Shift+C; not in the terminal) lays the range +out as canvas draw ops (`imagelayout.ts`, pure) in the theme colours and writes a PNG; a Markdown preview selection +is serialised into an SVG `foreignObject` snapshot (`mdsnap.ts`). The ClipboardItem is created synchronously inside +the gesture with a promise of the blob. Fallbacks: macOS app posts `copyimage` to the native bridge, no image +clipboard downloads the PNG, a render failure copies plain text; selections over 20,000 rows or 4,000 columns, or whose canvas would exceed 16M pixels even at scale 1, are refused (the scale drops from 2 to fit). Toasts reuse the +app toast. diff --git a/README.md b/README.md index c09b71b..e72b818 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,18 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/ | ⌘W · ⌘⇧[ ⌘⇧] | Close tab · switch tabs | | ⌘= ⌘- ⌘0 | Font size | | ⌘⇧G · ⌥F5 / ⌥⇧F5 | Changed files · next / previous change in file | +| ⌘⇧C / Ctrl+Shift+C | Copy the selection as an image (PNG) | | ⌘-click in terminal | Open `path:line` or URL | | Left ⌥ | Meta in the terminal (right ⌥ still types special characters) | +## Copy as Image + +Select code (or text in a Markdown preview) and choose **Copy as Image** from the right-click menu, the command +palette (*Copy Selection as Image*), or press ⌘⇧C / Ctrl+Shift+C. Code is drawn with the current theme's syntax +colours (no line numbers); a preview selection is rendered as it looks on screen. If the clipboard does not accept +images the PNG is downloaded instead; if rendering fails the plain text is copied and a message says so. Very large +selections are refused with "Selection too large for an image". + ## Markdown `.md` and `.markdown` files open as a rendered document; the **View source** button (or the command diff --git a/packaging/macos/main.swift b/packaging/macos/main.swift index 4257b85..f740c35 100644 --- a/packaging/macos/main.swift +++ b/packaging/macos/main.swift @@ -96,6 +96,7 @@ final class AppWebView: WKWebView { } final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { + private var cancelledSnaps = Set() let window: NSWindow let webView: AppWebView let server: Server @@ -240,6 +241,9 @@ 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: "snapshotrect", id, x, y, w, h} snapshot a viewport rect (CSS px) to the pasteboard; replies window.__cvSnap(id, ok) + // {t: "snapshotcancel", id} timed out: a late snapshot for id is dropped // {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 +255,37 @@ 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 == "snapshotcancel" { + if let id = (body["id"] as? NSNumber)?.intValue { cancelledSnaps.insert(id) } + return + } + if t == "snapshotrect" { + guard let id = (body["id"] as? NSNumber)?.intValue else { return } + func num(_ k: String) -> Double? { (body[k] as? NSNumber)?.doubleValue } + func reply(_ ok: Bool) { webView.evaluateJavaScript("window.__cvSnap && window.__cvSnap(\(id), \(ok))", completionHandler: nil) } + guard let x = num("x"), let y = num("y"), let w = num("w"), let h = num("h"), + x.isFinite, y.isFinite, w.isFinite, h.isFinite, w > 0, h > 0 else { return reply(false) } + let cfg = WKSnapshotConfiguration() + cfg.rect = CGRect(x: x, y: y, width: w, height: h) + webView.takeSnapshot(with: cfg) { [weak self] image, _ in + // A cancelled (timed out) request must not touch the pasteboard or reply. + if self?.cancelledSnaps.remove(id) != nil { return } + guard let image = image, let tiff = image.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) else { return reply(false) } + let pb = NSPasteboard.general + pb.clearContents() + reply(pb.setData(png, forType: .png)) + } + return + } if t == "dblclick" { switch UserDefaults.standard.string(forKey: "AppleActionOnDoubleClick") { case "Minimize": window.performMiniaturize(nil) diff --git a/src/client/app.ts b/src/client/app.ts index 971c1c6..d8b9867 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -10,9 +10,15 @@ import { icons } from "./icons.ts"; import { GitState, relativeTime } from "./git.ts"; import { Updates } from "./updates.ts"; import { debounce, dragSplitter, esc, formatBytes, h, IS_MAC, MOD, store } from "./util.ts"; -import { isContextMenuOpen } from "./menu.ts"; +import { isContextMenuOpen, showContextMenu } from "./menu.ts"; +import { bindEditorSelectionMenu, readSelection, snapshotText, copyImageCommand, selectionSnapshot, type SelectionHost } from "./copyentry.ts"; +import { copyCodeImage, copyPngOrSnapshot, MSG_DOWNLOADED, MSG_FAILED, MSG_NO_SELECTION, MSG_OK } from "./copyimage.ts"; +import { browserDeps, fragmentFromRange, markdownSnapDeps, snapshotDeps } from "./imagedom.ts"; +import { snapshotMarkdown } from "./mdsnap.ts"; +import type { ImageTheme } from "./imagelayout.ts"; +import type { SelRange } from "./selection.ts"; import { SearchView } from "./search.ts"; -import { normSideView, searchKey, type SideView } from "./keys.ts"; +import { copyImageKey, normSideView, searchKey, type SideView } from "./keys.ts"; type ThemeName = "dark" | "light" | "system"; @@ -222,6 +228,7 @@ class App { if (t) this.trackRecent(t.path); }; this.editor.onStatus = (s) => this.renderStatus(s); + bindEditorSelectionMenu(this.editor, this.selectionHost, showContextMenu); this.editor.onOpenLink = async (p, hash) => { const type = await api.stat(p); if (type === "file") { @@ -313,6 +320,12 @@ class App { this.palette.onOpenFile = (p, line) => void this.openFile(p, { pin: true, line }); this.palette.onGoToLine = (n) => this.editor.goToLine(n); this.palette.commands = this.commands(); + // Opening the palette moves focus into its input; remember what was selected so its command still has it. + const openPalette = this.palette.open.bind(this.palette); + this.palette.open = (prefix) => { + this.paletteSel.take(); + openPalette(prefix); + }; window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => this.applyTheme()); this.applyLayout(); @@ -338,6 +351,7 @@ class App { c("search.next", "Next Search Result", () => this.search.step(1), "F4"), c("search.prev", "Previous Search Result", () => this.search.step(-1), "⇧F4"), c("view.explorer", "Show Explorer", () => this.setSideView("explorer")), + copyImageCommand({ copyImage: () => this.copyImage(this.paletteSel.value()) }, IS_MAC ? "⌘⇧C" : "Ctrl+Shift+C"), markdownToggleCommand({ isMarkdownTab: () => this.editor.isMarkdownTab(), toggleMarkdown: () => this.editor.toggleMarkdown() }), c("file.find", "Find in File", () => this.editor.openFind(), `${MOD}F`), c("file.line", "Go to Line…", () => this.palette.open(":"), IS_MAC ? "⌃G" : "Ctrl+G"), @@ -510,6 +524,74 @@ class App { private saveLayout = debounce(() => store.set("layout", { ...this.L, v: LAYOUT_VERSION }), 200); + // ───────────────────────────── copy as image ───────────────────────────── + + private readSel = () => readSelection(this.editor.active); + + private paletteSel = selectionSnapshot<{ code?: SelRange; md?: Range }>(this.readSel); + + private selectionHost: SelectionHost = { + capture: () => this.readSel(), + hasSelection: (snap) => { + if (snap !== undefined) return snap !== null; + const t = this.editor.active; + return t?.md ? !!t.md.selectionRange() : !!t?.code?.selection; + }, + copy: (snap) => { + const s = (snap ?? this.readSel()) as { code?: SelRange; md?: Range } | null; + const t = snapshotText(this.editor.active, s); + if (t) void navigator.clipboard?.writeText(t).catch(() => {}); + }, + copyImage: (snap) => this.copyImage((snap as { code?: SelRange; md?: Range } | null | undefined) ?? null), + }; + + private imageTheme(): ImageTheme { + const tokenColors: Record = {}; + for (const t of "kscnftpovmhaxl") tokenColors[t] = cssVar(`--sx-${t}`); + const font = "JetBrains Mono"; + return { + bg: cssVar("--bg-editor"), + fg: cssVar("--fg"), + tokenColors, + tokenStyles: { c: { italic: true }, h: { bold: true }, b: { bold: true }, i: { italic: true } }, + font, + size: this.L.codeSize, + }; + } + + /** Must run synchronously from the click/keydown handler: nothing is awaited before the clipboard write. */ + private copyImage(snap: { code?: SelRange; md?: Range } | null = null) { + const tab = this.editor.active; + this.paletteSel.clear(); + const notify = (m: string) => this.notify(m); + const theme = this.imageTheme(); + const deps = browserDeps({ font: theme.font, size: theme.size, bridge: nativeBridge(), notify }); + if (tab?.md) { + const range = snap?.md ?? tab.md.selectionRange(); + if (!range) return notify(MSG_NO_SELECTION); + const text = range.toString(); + const png = fragmentFromRange(range, tab.md.el, cssVar("--bg-editor")) + .then((f) => snapshotMarkdown(markdownSnapDeps, f)) + .then((r) => { + if (!(r instanceof Blob)) throw new Error("snapshot failed"); + return r; + }); + const rects = Array.from(range.getClientRects()); + const vp = { width: window.innerWidth, height: window.innerHeight }; + void copyPngOrSnapshot({ ...deps, ...snapshotDeps(nativeBridge()) }, png, rects, vp).then(async (res) => { + if (res === "failed") { + await Promise.resolve(deps.copyText(text)).catch(() => {}); + return notify(MSG_FAILED); + } + notify(res === "downloaded" ? MSG_DOWNLOADED : MSG_OK); + }); + return; + } + const input = tab?.code?.imageInput(snap?.code); + if (!input) return notify(MSG_NO_SELECTION); + void copyCodeImage(deps, { ...input, theme, dpr: window.devicePixelRatio || 1 }); + } + private notify(msg: string) { this.toast.textContent = msg; this.toast.hidden = false; @@ -597,6 +679,13 @@ class App { const k = e.key.toLowerCase(); const inTerm = (e.target as HTMLElement).classList?.contains("term-input"); let handled = true; + if (copyImageKey(e, { isMac: IS_MAC, inTerm: !!inTerm })) { + // Runs synchronously inside the keydown so the clipboard write keeps the user gesture. + this.copyImage(); + e.preventDefault(); + e.stopPropagation(); + return; + } const sk = searchKey(e, { isMac: IS_MAC, inTerm: !!inTerm }); if (sk === "search") this.showSearch(); else if (sk === "next" || sk === "prev") this.search.step(sk === "next" ? 1 : -1); diff --git a/src/client/codeview.ts b/src/client/codeview.ts index 6e39ff8..3fda44a 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -1,6 +1,7 @@ -import { Highlighter, type Tok } from "./highlight.ts"; +import { Highlighter, type Span, 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"]; @@ -144,6 +145,9 @@ export class CodeView { const ln = (e.target as HTMLElement).closest(".ln"); if (!ln) return; const n = Number(ln.dataset.n); + if (e.button || e.ctrlKey) return this.setActiveLine(n); // right/middle press or Ctrl+click (opens the menu) keeps the range + this.sel = null; + this.applied = null; if ((e.target as HTMLElement).classList.contains("g") && this.gitMarks[n]) { e.preventDefault(); this.togglePeek(n, ln); @@ -152,6 +156,122 @@ 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()); + // A primary press outside the lines (not on the context menu) makes the model range stale. + document.addEventListener("mousedown", (e) => { + const t = e.target as HTMLElement | null; + if (e.button || e.ctrlKey || !this.sel || this.el.contains(t) || this.linesEl.contains(t)) return; + if (t?.closest?.(".ctx-menu")) return; + this.sel = null; + this.applied = null; + }); + } + + // ───────────────────────────── 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; + /** 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 { + 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.applied = null; + this.schedule(); + } + + /** Selects the whole file as a model range. */ + selectAll() { + this.setRange(selectAllRange(this.lines)); + } + + /** Inputs for copying the selection as an image, or null without a selection. */ + imageInput(range: SelRange | null = this.sel): { lines: string[]; tokens: Span[][]; range: SelRange } | null { + if (!range) return null; + const tokens: Span[][] = this.lines.map(() => []); + // Beyond the row cap the layout reports "too large" anyway; skip lexing then. + if (range.end.line - range.start.line < 20000) { + for (let i = range.start.line; i <= range.end.line; i++) tokens[i] = this.hl.line(i); + } + return { lines: this.lines, tokens, range }; + } + + /** Copies the model selection as plain text (context-menu "Copy"). */ + copySelection() { + const t = this.selectedText(); + if (t) void navigator.clipboard?.writeText(t).catch(() => {}); + } + + /** Text of the model selection ("" when none), never including decorations. */ + selectedText(range: SelRange | null = this.sel): string { + return range ? textOf(this.lines, range) : ""; + } + + 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) - 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.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); + } + + /** 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) { + if (this.el.contains(ds.anchorNode) || this.el.contains(ds.focusNode)) return; // still inside the view (e.g. rows re-rendered) + this.sel = null; // a real selection elsewhere replaces the stale range + this.applied = null; + return; + } + 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() { @@ -366,6 +486,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 +668,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/copyentry.ts b/src/client/copyentry.ts new file mode 100644 index 0000000..2e8b8dc --- /dev/null +++ b/src/client/copyentry.ts @@ -0,0 +1,85 @@ +// Entry points for "Copy as Image": context-menu items, the palette command and the context-menu binding. +import type { Command } from "./palette.ts"; +import type { MenuItem } from "./menu.ts"; + +export interface SelectionHost { + hasSelection(snap?: unknown): boolean; + copy(snap?: unknown): void; + copyImage(snap?: unknown): void; + /** Called on contextmenu: the selection to keep for the menu items (the live one may collapse before a click). */ + capture?(): unknown; +} + +/** ["Copy", "Copy as Image"] when something is selected, otherwise no items. */ +export function selectionMenuItems(host: SelectionHost, snap?: unknown): MenuItem[] { + if (!host.hasSelection(snap)) return []; + return [ + { label: "Copy", run: () => host.copy(snap) }, + { label: "Copy as Image", run: () => host.copyImage(snap) }, + ]; +} + +/** The "Copy Selection as Image" palette command. */ +export function copyImageCommand(host: { copyImage(): void }, keys: string): Command { + return { id: "selection.copyImage", label: "Copy Selection as Image", keys, run: () => host.copyImage() }; +} + +/** Shows the selection menu on contextmenu; with no selection the default menu is left alone. */ +export function bindSelectionMenu( + el: { addEventListener(t: string, f: (e: MouseEvent) => void): void }, + host: SelectionHost, + show: (e: MouseEvent, items: MenuItem[]) => void, +): void { + el.addEventListener("contextmenu", (e) => { + const snap = host.capture?.(); + const items = selectionMenuItems(host, snap); + if (items.length) show(e, items); + }); +} + +/** Remembers the selection that existed when the palette opened (opening it moves focus and may drop the live one). */ +export function selectionSnapshot(read: () => T | null): { take(): void; value(): T | null; clear(): void } { + let snap: T | null = null; + return { + take() { + snap = read(); + }, + value: () => snap, + clear() { + snap = null; + }, + }; +} + +/** The one place that decides which element and which host the selection context menu is bound to. */ +export function bindEditorSelectionMenu( + editor: { el: { addEventListener(t: string, f: (e: MouseEvent) => void): void } }, + host: SelectionHost, + show: (e: MouseEvent, items: MenuItem[]) => void, +): void { + bindSelectionMenu(editor.el, host, show); +} + +/** A tab as far as selection capture is concerned. */ +export interface SelTab { + md?: { selectionRange(): R | null }; + code?: { selection: C | null; selectedText(range?: C): string }; +} +export type Captured = { code?: C; md?: R }; + +/** The selection to remember for a menu or palette: markdown ranges are cloned, so a later collapse of the live one cannot change them. */ +export function readSelection(tab: SelTab | undefined): Captured | null { + if (tab?.md) { + const r = tab.md.selectionRange(); + return r ? { md: r.cloneRange() } : null; + } + const c = tab?.code?.selection; + return c ? { code: c } : null; +} + +/** Plain text of a captured selection (what the menu's Copy writes); "" when there is none. */ +export function snapshotText(tab: SelTab | undefined, snap: Captured | null | undefined): string { + if (!snap) return ""; + if (snap.md) return snap.md.toString(); + return snap.code && tab?.code ? tab.code.selectedText(snap.code) : ""; +} diff --git a/src/client/copyimage.ts b/src/client/copyimage.ts new file mode 100644 index 0000000..86444a7 --- /dev/null +++ b/src/client/copyimage.ts @@ -0,0 +1,207 @@ +// 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; + png.catch(() => {}); // failures are handled below; this only keeps an early rejection from reading as unhandled + // 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 { + 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 { + /* fall through to download */ + } + } + let blob: Blob; + try { + blob = await png; + } catch { + return "failed"; + } + 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); +} + +export interface RectLike { + left: number; + top: number; + width: number; + height: number; +} +export interface SnapRect { + x: number; + y: number; + w: number; + h: number; +} +export interface Viewport { + width: number; + height: number; +} + +/** Union of the selection's client rects, clamped to the visible viewport (CSS px); null when nothing visible remains. */ +export function clampSnapRect(rects: RectLike[], vp: Viewport): SnapRect | null { + let l = Infinity; + let t = Infinity; + let r = -Infinity; + let b = -Infinity; + for (const q of rects) { + if (!(q.width > 0 && q.height > 0)) continue; + l = Math.min(l, q.left); + t = Math.min(t, q.top); + r = Math.max(r, q.left + q.width); + b = Math.max(b, q.top + q.height); + } + l = Math.max(l, 0); + t = Math.max(t, 0); + r = Math.min(r, vp.width); + b = Math.min(b, vp.height); + if (!(r > l && b > t)) return null; + return { x: l, y: t, w: r - l, h: b - t }; +} + +export interface SnapshotDeps { + /** Native WKWebView snapshot of a viewport rect; resolves true when the PNG reached the pasteboard. */ + snapshot?: (r: SnapRect, id?: number) => Promise; + /** Starts a request: returns its id (supersedes any pending one) and makes it the owner of the highlight hide. */ + begin?: () => number; + /** Hides (true) / restores (false) the selection highlight; a restore by a request that no longer owns the hide is ignored. */ + hideSelection(on: boolean, id?: number): void; +} + +/** Tries the native snapshot of the selection rects; true only when it succeeded. Always restores the highlight. */ +export async function copyViaSnapshot(deps: SnapshotDeps, rects: RectLike[], vp: Viewport): Promise { + const rect = deps.snapshot ? clampSnapRect(rects, vp) : null; + if (!deps.snapshot || !rect) return false; + const id = deps.begin?.(); + deps.hideSelection(true, id); + try { + return (await deps.snapshot(rect, id)) === true; + } catch { + return false; + } finally { + deps.hideSelection(false, id); + } +} + +/** copyPng, then (only when the PNG could not be produced) the native snapshot fallback. */ +export async function copyPngOrSnapshot( + deps: CopyImageDeps & SnapshotDeps, + png: Promise, + rects: RectLike[], + vp: Viewport, +): Promise { + const res = await copyPng(deps, png); + if (res === "failed" && (await copyViaSnapshot(deps, rects, vp))) return "snapshot"; + return res; +} + +export interface SnapTracker { + /** Starts a request: returns a fresh id and makes it the only pending one. */ + begin(): number; + /** True exactly once for the pending id; stale, unknown, repeated or non-number ids are ignored. */ + accept(id: unknown): boolean; + /** Cancels the pending request (or only `id`, when given and still pending): returns its id so the native side can be told, undefined otherwise. */ + cancel(id?: number): number | undefined; + /** Makes `id` the owner of the selection hide (a newer claim takes it over). */ + claimHide(id: number): void; + /** True only when `id` still owns the hide (then ownership is dropped): a superseded request must not un-hide. */ + releaseHide(id: number): boolean; +} +export function snapTracker(): SnapTracker { + let next = 1; + let pending: number | undefined; + let hideOwner: number | undefined; + return { + begin: () => (pending = next++), + accept(id) { + if (typeof id !== "number" || id !== pending) return false; + pending = undefined; + return true; + }, + cancel(id) { + if (id !== undefined && id !== pending) return undefined; + const cur = pending; + pending = undefined; + return cur; + }, + claimHide(id) { + hideOwner = id; + }, + releaseHide(id) { + if (id !== hideOwner) return false; + hideOwner = undefined; + return true; + }, + }; +} diff --git a/src/client/imagedom.ts b/src/client/imagedom.ts new file mode 100644 index 0000000..4157e0b --- /dev/null +++ b/src/client/imagedom.ts @@ -0,0 +1,229 @@ +// Thin browser glue for image copy (canvas, clipboard, download, native bridge). Verified manually. +import { snapTracker, type CopyImageDeps, type SnapRect, type SnapshotDeps } from "./copyimage.ts"; +import { isInlinableImage, pruneToRange, snapFaces, snapSize, type PNode, type PrunedNode, type SnapDeps, type SnapFragment } from "./mdsnap.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 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) throw 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, + }; +} + +const SNAP_TIMEOUT = 3000; +const tracker = snapTracker(); +let snapDone: ((ok: boolean) => void) | undefined; +if (typeof window !== "undefined") { + (window as unknown as { __cvSnap?: (id: unknown, ok: unknown) => void }).__cvSnap = (id, ok) => { + if (tracker.accept(id)) snapDone?.(ok === true); + }; +} + +/** Snapshot fallback deps: native takeSnapshot via the bridge (reply through window.__cvSnap) and a root class hiding ::selection. */ +export function snapshotDeps(bridge: Bridge): SnapshotDeps { + const root = document.documentElement; + const deps: SnapshotDeps = { + begin() { + // A new request supersedes a pending one: its late PNG must never reach the pasteboard. + const old = tracker.cancel(); + if (old !== undefined) { + bridge?.postMessage({ t: "snapshotcancel", id: old }); + snapDone?.(false); + } + const id = tracker.begin(); + tracker.claimHide(id); + return id; + }, + // Only the request that owns the hide may restore the highlight. + hideSelection: (on, id) => { + if (!on && id !== undefined && !tracker.releaseHide(id)) return; + root.classList.toggle("snap-hide-sel", on); + }, + snapshot: bridge + ? (r: SnapRect, id?: number) => + new Promise((res) => { + if (id === undefined) id = deps.begin!(); + const rid = id; + const finish = (ok: boolean) => { + clearTimeout(timer); + snapDone = undefined; + res(ok); + }; + const timer = setTimeout(() => { + if (tracker.cancel(rid) !== undefined) bridge.postMessage({ t: "snapshotcancel", id: rid }); + finish(false); + }, SNAP_TIMEOUT); + snapDone = finish; + // Give the highlight removal one frame to paint before the native side captures. + requestAnimationFrame(() => requestAnimationFrame(() => bridge.postMessage({ t: "snapshotrect", id: rid, ...r }))); + }) + : undefined, + }; + return deps; +} + +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")); + }, +}; + +const PAD = 12; + +type LiveNode = PNode & { node: Node; children: LiveNode[] }; +const liveTree = (n: Node, map: Map): LiveNode => { + const t: LiveNode = { node: n, children: [], ...(n.nodeType === 3 ? { text: n.nodeValue ?? "" } : {}) }; + map.set(n, t); + if (n.nodeType === 1) for (const c of Array.from(n.childNodes)) if (c.nodeType === 1 || c.nodeType === 3) t.children.push(liveTree(c, map)); + return t; +}; + +async function inlineImage(img: HTMLImageElement, live: HTMLImageElement) { + try { + const src = live.currentSrc || live.src; + if (!isInlinableImage(src, location.href)) throw new Error("cross-origin image"); + img.setAttribute("src", await blobToDataUri(await (await fetch(src)).blob())); + img.removeAttribute("srcset"); + } catch { + img.removeAttribute("src"); + img.removeAttribute("srcset"); + } +} + +/** Builds a pruned copy of the live subtree under the range's common ancestor as a fragment for snapshotMarkdown. */ +export async function fragmentFromRange(range: Range, host: HTMLElement, bg: string): Promise { + let root: Node = range.commonAncestorContainer; + if (root.nodeType !== 1) root = root.parentNode ?? host; + const map = new Map(); + const tree = liveTree(root, map); + const pt = (node: Node, offset: number) => ({ node: map.get(node) ?? tree, offset }); + const pruned = pruneToRange(tree, pt(range.startContainer, range.startOffset), pt(range.endContainer, range.endOffset)); + const liveOf = new Map(); + const build = (p: PrunedNode): Node => { + if (p.text !== undefined) return document.createTextNode(p.text); + const c = p.src.node.cloneNode(false); + liveOf.set(c, p.src.node); + for (const k of p.children) c.appendChild(build(k)); + return c; + }; + const cloneRoot = build(pruned) as Element; + const wrap = document.createElement("div"); + wrap.appendChild(cloneRoot); + const kept = [wrap, ...Array.from(wrap.querySelectorAll("*"))]; + const elements: Element[] = [host]; + const imgs: Promise[] = []; + kept.slice(1).forEach((c, n) => { + const live = liveOf.get(c) as Element | undefined; + elements.push(live ?? host); + c.setAttribute("data-snap", String(n + 1)); + if (live && c.tagName === "IMG") imgs.push(inlineImage(c as HTMLImageElement, live as HTMLImageElement)); + }); + const hs = getComputedStyle(host); + wrap.setAttribute("style", `padding:${PAD}px;box-sizing:border-box;font:${hs.font};color:${hs.color};line-height:${hs.lineHeight}`); + await Promise.all(imgs); + const size = snapSize(Array.from(range.getClientRects()), PAD); + const fontFaces = snapFaces(`${getComputedStyle(host).fontFamily},JetBrains Mono,Inter`); + return { + html: new XMLSerializer().serializeToString(wrap), + elements, + fontUrls: {}, + faces: fontFaces, + width: Math.ceil(Math.max(size.width, 1)), + height: Math.ceil(Math.max(size.height, 1)), + bg, + text: range.toString(), + }; +} diff --git a/src/client/imagelayout.ts b/src/client/imagelayout.ts new file mode 100644 index 0000000..cf322b4 --- /dev/null +++ b/src/client/imagelayout.ts @@ -0,0 +1,99 @@ +// 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 = 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 - 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++) { + 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 - base); + } + 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/src/client/keys.ts b/src/client/keys.ts index 03530bb..78880ee 100644 --- a/src/client/keys.ts +++ b/src/client/keys.ts @@ -9,6 +9,7 @@ export interface KeyLike { ctrlKey?: boolean; altKey?: boolean; shiftKey?: boolean; + target?: unknown; } export type SearchKeyAction = "search" | "find" | "next" | "prev"; @@ -30,3 +31,11 @@ export function searchKey(e: KeyLike, o: { isMac: boolean; inTerm: boolean }): S export function normSideView(v: unknown): SideView { return v === "search" ? "search" : "explorer"; } + +/** Cmd/Ctrl+Shift+C copies the selection as an image (not from the terminal, where Ctrl+Shift+C is its own copy). */ +export function copyImageKey(e: KeyLike, o: { isMac: boolean; inTerm: boolean }): boolean { + const mod = o.isMac ? !!e.metaKey : !!e.ctrlKey; + const t = e.target as { tagName?: string; isContentEditable?: boolean } | null | undefined; + const field = t?.tagName === "INPUT" || t?.tagName === "TEXTAREA" || !!t?.isContentEditable; + return mod && !!e.shiftKey && !e.altKey && !o.inTerm && !field && e.key.toLowerCase() === "c"; +} diff --git a/src/client/mdsnap.ts b/src/client/mdsnap.ts new file mode 100644 index 0000000..69782b6 --- /dev/null +++ b/src/client/mdsnap.ts @@ -0,0 +1,219 @@ +// Markdown selection -> PNG via an SVG foreignObject. Pure logic; browser APIs are injected. + +export interface FontFace { + family: string; + uri: string; + weight?: string; + style?: 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; + /** Extra faces of the same families (weight/style variants), embedded alongside `fontUrls`. */ + faces?: { family: string; url: string; weight?: string; style?: string }[]; + 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)}")${f.weight ? `;font-weight:${esc(f.weight)}` : ""}${f.style ? `;font-style:${esc(f.style)}` : ""}}`).join(""); + const rules = Object.entries(i.styles) + .map(([k, css]) => `${/^\d+$/.test(k) ? `[data-snap="${k}"]` : esc(k)}{${esc(cssSafe(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 */ + } + }), + ); + await Promise.all( + (f.faces ?? []).map(async (face) => { + try { + fonts.push({ family: face.family, uri: await deps.loadFont(face.url), weight: face.weight, style: face.style }); + } catch { + /* render with a fallback face */ + } + }), + ); + 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 }; + } +} + +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} +export interface TreeNode { + children: TreeNode[]; +} + +/** Maps a computed font-family list to the embeddable {family,url} faces found in the table. */ +export function fontFaceUrls(familyList: string, table: Record): { family: string; url: string }[] { + const byLower = new Map(Object.keys(table).map((k) => [k.toLowerCase(), k])); + const out: { family: string; url: string }[] = []; + const seen = new Set(); + for (const raw of familyList.split(",")) { + const key = byLower.get(raw.trim().replace(/^["']|["']$/g, "").toLowerCase()); + if (!key || seen.has(key)) continue; + seen.add(key); + for (const url of table[key]!) out.push({ family: key, url }); + } + return out; +} + +/** Union of the rects plus padding on every side; zero size when there are no rects. */ +export function snapSize(rects: Box[], pad: number): { width: number; height: number } { + if (rects.length === 0) return { width: 0, height: 0 }; + const l = Math.min(...rects.map((r) => r.left)); + const t = Math.min(...rects.map((r) => r.top)); + const r = Math.max(...rects.map((x) => x.right)); + const b = Math.max(...rects.map((x) => x.bottom)); + return { width: r - l + pad * 2, height: b - t + pad * 2 }; +} + +/** Pairs two structurally parallel trees in pre-order (roots included); stops pairing children where counts differ. */ +export function pairTrees(live: T, clone: U): [T, U][] { + const out: [T, U][] = [[live, clone]]; + const n = Math.min(live.children.length, clone.children.length); + for (let i = 0; i < n; i++) out.push(...pairTrees(live.children[i] as T, clone.children[i] as U)); + return out; +} + +/** Makes CSS text safe to place in a rule body: no braces, no markup openers. */ +export function cssSafe(css: string): string { + return css.replace(/[{}<]/g, ""); +} + +/** Abstract node: a text node has `text`, an element has `children` (empty for leaves such as img/br). */ +export interface PNode { + text?: string; + children: PNode[]; +} +export interface RangePoint { + node: N; + offset: number; +} +export interface PrunedNode { + src: N; + /** Trimmed text, for text nodes only. */ + text?: string; + children: PrunedNode[]; +} + +/** + * Pure: computes which nodes of the tree under `root` lie inside the range, from the original (unmutated) tree. + * Text at the boundaries is trimmed; elements with no kept descendants are dropped; the root is always kept. + */ +export function pruneToRange(root: N, start: RangePoint, end: RangePoint): PrunedNode { + // pre-order index of every node, plus the index just past each subtree + const index = new Map(); + const after = new Map(); + let counter = 0; + const walk = (n: PNode) => { + index.set(n, counter++); + for (const c of n.children) walk(c); + after.set(n, counter); + }; + walk(root); + // a boundary is (node index, offset inside that node); element offsets are child indexes + const point = (p: RangePoint): [number, number] => { + if (p.node.text !== undefined) return [index.get(p.node) ?? 0, p.offset]; + const child = p.node.children[p.offset]; + return [child ? index.get(child)! : after.get(p.node)!, 0]; + }; + const [si, so] = point(start); + const [ei, eo] = point(end); + const build = (n: N, isRoot: boolean): PrunedNode | null => { + const i = index.get(n)!; + if (n.text !== undefined) { + const from = i < si ? Infinity : i === si ? so : 0; + const to = i > ei ? -Infinity : i === ei ? eo : n.text.length; + const text = n.text.slice(Math.max(from, 0), Math.max(to, 0)); + return from < to && text ? { src: n, text, children: [] } : null; + } + if (n.children.length === 0) { + const inside = (i > si || (i === si && so === 0)) && (i < ei || (i === ei && eo > 0)); + return inside || isRoot ? { src: n, children: [] } : null; + } + const kids = n.children.map((c) => build(c as N, false)).filter((k): k is PrunedNode => k !== null); + return kids.length > 0 || isRoot ? { src: n, children: kids } : null; + }; + return build(root, true)!; +} + +/** True when `src` resolves (against `base`) to the page origin or is an inline data: URI; anything else is not fetched. */ +export function isInlinableImage(src: string, base: string): boolean { + try { + const u = new URL(src, base); + if (u.protocol === "data:") return true; + if (u.protocol === "blob:") return new URL(u.pathname).origin === new URL(base).origin; + return (u.protocol === "http:" || u.protocol === "https:") && u.origin === new URL(base).origin; + } catch { + return false; + } +} + +/** Weight/style of each same-origin font file, matching styles.css @font-face. */ +export const FONT_FACES: Record = { + "/fonts/JetBrainsMono-Regular.woff2": { weight: "400", style: "normal" }, + "/fonts/JetBrainsMono-Bold.woff2": { weight: "700", style: "normal" }, + "/fonts/JetBrainsMono-Italic.woff2": { weight: "400", style: "italic" }, + "/fonts/JetBrainsMono-BoldItalic.woff2": { weight: "700", style: "italic" }, + "/fonts/InterVariable.woff2": { weight: "100 900", style: "normal" }, +}; + +/** Family to font files for fontFaceUrls. */ +export const FONT_TABLE: Record = { + "JetBrains Mono": Object.keys(FONT_FACES).filter((u) => u.includes("JetBrains")), + Inter: ["/fonts/InterVariable.woff2"], +}; + +/** Embeddable faces (family, url, weight, style) for a computed font-family list. */ +export function snapFaces(familyList: string): { family: string; url: string; weight: string; style: string }[] { + return fontFaceUrls(familyList, FONT_TABLE).map((f) => ({ ...f, ...FONT_FACES[f.url]! })); +} 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/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/src/client/styles.css b/src/client/styles.css index 827790d..6033127 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; } @@ -2080,3 +2081,5 @@ li[data-state="done"] .upd-mark { .sv-stale { display: flex; align-items: center; gap: 8px; padding: 6px 10px; color: var(--fg-muted); } .sv-stale[hidden] { display: none; } .sv-refresh { cursor: pointer; } + +html.snap-hide-sel ::selection { background: transparent !important; } diff --git a/test/client.codeview.ctxclick.test.ts b/test/client.codeview.ctxclick.test.ts new file mode 100644 index 0000000..ce1e1ac --- /dev/null +++ b/test/client.codeview.ctxclick.test.ts @@ -0,0 +1,121 @@ +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("R-07 a mousedown that opens the context menu keeps the model range", () => { + const press = (init: Record) => { + setWinSel({ setBaseAndExtent() {} }); + const { view, linesEl, rows } = mount(src); + view.setRange(rng(0, 0, 2, 3)); + const g = rows()[1]!.querySelector("g")!; + linesEl.dispatchEvent(new FakeEvent("mousedown", { target: g, button: 0, ...init })); + return view; + }; + test("macOS Ctrl+click (button 0, ctrlKey) keeps the selection", () => { + expect(press({ ctrlKey: true }).selection).toEqual(rng(0, 0, 2, 3)); + }); + test("plain primary click still clears it", () => { + expect(press({}).selection).toBeNull(); + }); +}); diff --git a/test/client.codeview.hygiene.test.ts b/test/client.codeview.hygiene.test.ts new file mode 100644 index 0000000..51353e8 --- /dev/null +++ b/test/client.codeview.hygiene.test.ts @@ -0,0 +1,199 @@ +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"); + +import { bindSelectionMenu } from "../src/client/copyentry.ts"; + +const outside = () => el("sidebar", text("elsewhere")); +const fireSel = () => dom.document.dispatchEvent(new FakeEvent("selectionchange")); +const domSel = (anchor: N, focus: N, collapsed: boolean) => + setWinSel({ setBaseAndExtent() {}, rangeCount: 1, isCollapsed: collapsed, anchorNode: anchor, anchorOffset: 0, focusNode: focus, focusOffset: 1 }); +const down = (target: unknown, init: Record = {}) => + dom.document.dispatchEvent(new FakeEvent("mousedown", { target, button: 0, ...init })); + +describe("AC-10 a stale model range is dropped when the user acts outside the lines", () => { + const ranged = () => { + setWinSel({ setBaseAndExtent() {} }); + const m = mount(src); + m.view.setRange(rng(0, 0, 2, 3)); + return m; + }; + test("a non-collapsed selection outside the code view's lines clears the range", () => { + const { view } = ranged(); + const o = outside(); + domSel(o, o, false); + fireSel(); + expect(view.selection).toBeNull(); + }); + test("a collapsed selectionchange outside the lines (virtualized re-render) keeps the range", () => { + const { view } = ranged(); + const o = outside(); + domSel(o, o, true); + fireSel(); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("a primary press outside the lines clears the range", () => { + const { view } = ranged(); + down(outside()); + expect(view.selection).toBeNull(); + }); + test("a Ctrl+press or secondary press outside the lines keeps the range", () => { + const { view } = ranged(); + down(outside(), { ctrlKey: true }); + down(outside(), { button: 2 }); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("a primary press on the context menu (Copy item) keeps the range", () => { + const { view } = ranged(); + const item = el("ctx-item", text("Copy")); + el("ctx-menu", item); + down(item); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("a press inside the lines is left to the row handler (the document hook does not clear)", () => { + const { view, rows } = ranged(); + down(rows()[1]!.querySelector("g")!); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); +}); + +describe("AC-11 right/Ctrl-click on a line moves the active line and keeps the range", () => { + const press = (init: Record, marked = false) => { + setWinSel({ setBaseAndExtent() {} }); + const { view, linesEl, rows } = mount(src); + view.setRange(rng(0, 0, 2, 3)); + if (marked) view.gitMarks = new Uint8Array([0, 1, 0]); + let peeks = 0; + view.togglePeek = () => void peeks++; + const g = rows()[1]!.querySelector("g")!; + const ev = new FakeEvent("mousedown", { target: g, button: 0, ...init }); + linesEl.dispatchEvent(ev); + return { view, peeks, ev }; + }; + test("secondary press moves the active line and keeps the selection", () => { + const { view } = press({ button: 2 }); + expect(view.activeLine).toBe(1); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("Ctrl+click moves the active line and keeps the selection", () => { + const { view } = press({ ctrlKey: true }); + expect(view.activeLine).toBe(1); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("right-click on a change bar does not open the git peek", () => { + const { view, peeks } = press({ button: 2 }, true); + expect(peeks).toBe(0); + expect(view.activeLine).toBe(1); + }); + test("Ctrl+click on a change bar does not open the git peek", () => { + const { peeks } = press({ ctrlKey: true }, true); + expect(peeks).toBe(0); + }); + test("primary click on a change bar still opens the peek and clears the selection", () => { + const { view, peeks, ev } = press({}, true); + expect(peeks).toBe(1); + expect(ev.defaultPrevented).toBe(true); + expect(view.selection).toBeNull(); + }); +}); diff --git a/test/client.codeview.hygiene2.test.ts b/test/client.codeview.hygiene2.test.ts new file mode 100644 index 0000000..687ebaa --- /dev/null +++ b/test/client.codeview.hygiene2.test.ts @@ -0,0 +1,164 @@ +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"); + + +const fireSel = () => dom.document.dispatchEvent(new FakeEvent("selectionchange")); +const domSel = (anchor: N, focus: N, collapsed: boolean) => + setWinSel({ setBaseAndExtent() {}, rangeCount: 1, isCollapsed: collapsed, anchorNode: anchor, anchorOffset: 0, focusNode: focus, focusOffset: 1 }); +const down = (target: unknown, init: Record = {}) => + dom.document.dispatchEvent(new FakeEvent("mousedown", { target, button: 0, ...init })); + +// "Inside" = anywhere in the code view element (find bar, scroller/scrollbar, gutter, lines container), not only the rows. +function mountWithChrome() { + setWinSel({ setBaseAndExtent() {} }); + const m = mount(src); + const chrome = el("code-chrome", el("find-input"), el("code-scroller"), el("code-lines")); + const inner = m.view.el as FakeElement & Record; + const rowsContains = inner.contains?.bind(inner); + inner.contains = (n: N) => chrome.contains(n) || m.linesEl.contains(n) || !!rowsContains?.(n); + m.view.setRange(rng(0, 0, 2, 3)); + return { ...m, chrome }; +} + +describe("R-01 a non-collapsed selection inside the code view element keeps the range", () => { + test("both endpoints on the lines container (rows removed by a re-render) keep the range", () => { + const { view, chrome } = mountWithChrome(); + const container = chrome.querySelector("code-lines")!; + domSel(container, container, false); + fireSel(); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("endpoints in the find bar / scroller (inside the element, outside the rows) keep the range", () => { + const { view, chrome } = mountWithChrome(); + domSel(chrome.querySelector("find-input")!, chrome.querySelector("code-scroller")!, false); + fireSel(); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("endpoints outside the whole element still clear the range", () => { + const { view } = mountWithChrome(); + const o = el("sidebar", text("elsewhere")); + domSel(o, o, false); + fireSel(); + expect(view.selection).toBeNull(); + }); +}); + +describe("R-02 a primary press inside the code view element keeps the range", () => { + test("press on the find bar keeps it", () => { + const { view, chrome } = mountWithChrome(); + down(chrome.querySelector("find-input")!); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("press on the scroller / scrollbar keeps it", () => { + const { view, chrome } = mountWithChrome(); + down(chrome.querySelector("code-scroller")!); + expect(view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("press outside the element clears it", () => { + const { view } = mountWithChrome(); + down(el("sidebar", text("elsewhere"))); + expect(view.selection).toBeNull(); + }); +}); diff --git a/test/client.codeview.selection.test.ts b/test/client.codeview.selection.test.ts new file mode 100644 index 0000000..7ebb6bb --- /dev/null +++ b/test/client.codeview.selection.test.ts @@ -0,0 +1,194 @@ +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"); + // Keep only the text outside tags; rendered text is HTML-escaped, so "<" always opens markup. + let text = ""; + let inTag = false; + for (const ch of m[3]!) { + if (ch === "<") inTag = true; + else if (ch === ">") inTag = false; + else if (!inTag) text += ch; + } + tx.append(text); + 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/); + }); +}); 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; + }); +}); diff --git a/test/client.copyimage.entry.test.ts b/test/client.copyimage.entry.test.ts new file mode 100644 index 0000000..7b05b77 --- /dev/null +++ b/test/client.copyimage.entry.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { bindSelectionMenu, copyImageCommand, selectionMenuItems, type SelectionHost } from "../src/client/copyentry.ts"; +import type { MenuItem } from "../src/client/menu.ts"; + +const host = (sel: boolean) => { + const h = { copies: 0, images: 0, hasSelection: () => sel, copy: () => void h.copies++, copyImage: () => void h.images++ }; + return h satisfies SelectionHost & object; +}; + +describe("AC-6 selection menu items", () => { + test("with a selection: Copy then Copy as Image, each wired to its action", () => { + const h = host(true); + const items = selectionMenuItems(h); + expect(items.map((i) => i.label)).toEqual(["Copy", "Copy as Image"]); + items[0]!.run(); + expect(h.copies).toBe(1); + expect(h.images).toBe(0); + items[1]!.run(); + expect(h.images).toBe(1); + }); + test("without a selection: no items", () => { + expect(selectionMenuItems(host(false))).toEqual([]); + }); +}); + +describe("AC-6 bindSelectionMenu", () => { + const setup = (sel: boolean) => { + let fn: ((e: MouseEvent) => void) | undefined; + let type = ""; + const shown: MenuItem[][] = []; + bindSelectionMenu({ addEventListener: (t, f) => ((type = t), (fn = f)) }, host(sel), (_e, items) => void shown.push(items)); + return { type, fire: () => fn!({ preventDefault() {} } as unknown as MouseEvent), shown }; + }; + test("contextmenu with a selection shows the two items", () => { + const s = setup(true); + expect(s.type).toBe("contextmenu"); + s.fire(); + expect(s.shown.length).toBe(1); + expect(s.shown[0]!.map((i) => i.label)).toEqual(["Copy", "Copy as Image"]); + }); + test("contextmenu without a selection leaves the default menu alone", () => { + const s = setup(false); + expect(s.type).toBe("contextmenu"); + s.fire(); + expect(s.shown.length).toBe(0); + }); +}); + +describe("AC-6 palette command", () => { + test("id, label, keys and action", () => { + const h = host(true); + const c = copyImageCommand(h, "⌘⇧C"); + expect(c.id).toBe("selection.copyImage"); + expect(c.label).toBe("Copy Selection as Image"); + expect(c.keys).toBe("⌘⇧C"); + c.run(); + expect(h.images).toBe(1); + }); +}); + +describe("AC-9 docs", () => { + for (const f of ["README.md", "DESIGN.md"]) { + test(`${f} mentions Copy as Image and the shortcut`, () => { + const t = readFileSync(new URL(`../${f}`, import.meta.url), "utf8"); + expect(t).toContain("Copy as Image"); + expect(/Shift\+C|⌘⇧C/.test(t)).toBe(true); + }); + } +}); diff --git a/test/client.copyimage.entry2.test.ts b/test/client.copyimage.entry2.test.ts new file mode 100644 index 0000000..81360c0 --- /dev/null +++ b/test/client.copyimage.entry2.test.ts @@ -0,0 +1,206 @@ +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"); +import { copyPng, type CopyImageDeps } from "../src/client/copyimage.ts"; +import { copyImageKey } from "../src/client/keys.ts"; +import { bindSelectionMenu, selectionSnapshot } from "../src/client/copyentry.ts"; + +describe("R-01 right-click inside a selection keeps the model range", () => { + const press = (button: number) => { + setWinSel({ setBaseAndExtent() {} }); + const { view, linesEl, rows } = mount(src); + view.setRange(rng(0, 0, 2, 3)); + const g = rows()[1]!.querySelector("g")!; + linesEl.dispatchEvent(new FakeEvent("mousedown", { target: g, button })); + return view; + }; + test("secondary button (2) keeps the selection", () => { + expect(press(2).selection).toEqual(rng(0, 0, 2, 3)); + }); + test("middle button (1) keeps the selection", () => { + expect(press(1).selection).toEqual(rng(0, 0, 2, 3)); + }); + test("primary button (0) still clears it", () => { + expect(press(0).selection).toBeNull(); + }); + test("no dead context-menu hook remains on the code view (AC-12)", () => { + const { view } = mount(src); + expect("onContextMenu" in view).toBe(false); + expect((view.scroller as FakeElement).listenerCount("contextmenu")).toBe(0); + }); + test("the menu is wired through bindSelectionMenu: contextmenu with a selection shows Copy / Copy as Image", () => { + setWinSel({ setBaseAndExtent() {} }); + const { view } = mount(src); + view.setRange(rng(0, 0, 2, 3)); + const shown: string[][] = []; + bindSelectionMenu(view.el as FakeElement as never, { hasSelection: () => view.selection !== null, copy() {}, copyImage() {} }, (_e, items) => shown.push(items.map((i) => i.label))); + (view.el as FakeElement).dispatchEvent(new FakeEvent("contextmenu")); + expect(shown).toEqual([["Copy", "Copy as Image"]]); + }); +}); + +describe("R-02 selection snapshot taken when the palette opens", () => { + test("value() returns the selection captured by take() even after the live one is gone", () => { + let live: string | null = "range-A"; + const s = selectionSnapshot(() => live); + s.take(); + live = null; + expect(s.value()).toBe("range-A"); + }); + test("take() replaces the snapshot; clear() drops it", () => { + let live: string | null = "A"; + const s = selectionSnapshot(() => live); + s.take(); + live = "B"; + s.take(); + expect(s.value()).toBe("B"); + s.clear(); + expect(s.value()).toBeNull(); + }); + test("take() with no live selection leaves value() null", () => { + const s = selectionSnapshot(() => null); + s.take(); + expect(s.value()).toBeNull(); + }); +}); + +describe("R-03 copyImageKey ignores text fields", () => { + const e = (target: unknown) => ({ key: "C", metaKey: true, shiftKey: true, target }); + const o = { isMac: true, inTerm: false }; + test("input, textarea and contenteditable targets are not intercepted", () => { + expect(copyImageKey(e({ tagName: "INPUT" }), o)).toBe(false); + expect(copyImageKey(e({ tagName: "TEXTAREA" }), o)).toBe(false); + expect(copyImageKey(e({ tagName: "DIV", isContentEditable: true }), o)).toBe(false); + }); + test("a plain element or no target still fires", () => { + expect(copyImageKey(e({ tagName: "DIV" }), o)).toBe(true); + expect(copyImageKey(e(undefined), o)).toBe(true); + }); +}); + +describe("R-06 copyPng does not leave the png promise unhandled", () => { + test("a png that rejects while write() is still pending raises no unhandledRejection", async () => { + const seen: unknown[] = []; + const on = (r: unknown) => void seen.push(r); + process.on("unhandledRejection", on); + try { + class Item { + constructor(public d: Record>) {} + } + const deps: CopyImageDeps = { + renderPng: async () => new Blob(), + clipboard: { ClipboardItem: Item, write: () => new Promise((_, rej) => setTimeout(() => rej(new Error("w")), 30)) }, + download: () => {}, + copyText: () => {}, + notify: () => {}, + }; + const res = await copyPng(deps, Promise.reject(new Error("render"))); + await new Promise((r) => setTimeout(r, 20)); + expect(res).toBe("failed"); + expect(seen).toEqual([]); + } finally { + process.off("unhandledRejection", on); + } + }); +}); 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 new file mode 100644 index 0000000..fb4c574 --- /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 and calls write synchronously, before any await", () => { + const { deps, log } = mk(); + void copyPng(deps, Promise.resolve(blob)); + expect(log).toEqual(["item", "write:1"]); + }); + 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", "write:1"]); + }); + 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"); + }); +}); 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); + }); +}); 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 }); + }); +}); diff --git a/test/client.keys.test.ts b/test/client.keys.test.ts new file mode 100644 index 0000000..ff82070 --- /dev/null +++ b/test/client.keys.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { copyImageKey } from "../src/client/keys.ts"; + +const ev = (o: object) => ({ key: "C", ...o }); + +describe("AC-6 copyImageKey", () => { + test("mac: Cmd+Shift+C outside the terminal", () => { + expect(copyImageKey(ev({ metaKey: true, shiftKey: true }), { isMac: true, inTerm: false })).toBe(true); + expect(copyImageKey(ev({ key: "c", metaKey: true, shiftKey: true }), { isMac: true, inTerm: false })).toBe(true); + }); + test("non-mac: Ctrl+Shift+C outside the terminal", () => { + expect(copyImageKey(ev({ ctrlKey: true, shiftKey: true }), { isMac: false, inTerm: false })).toBe(true); + }); + test("false in the terminal", () => { + expect(copyImageKey(ev({ metaKey: true, shiftKey: true }), { isMac: true, inTerm: true })).toBe(false); + expect(copyImageKey(ev({ ctrlKey: true, shiftKey: true }), { isMac: false, inTerm: true })).toBe(false); + }); + test("false without shift, without the right modifier, with alt, or for another key", () => { + expect(copyImageKey(ev({ metaKey: true }), { isMac: true, inTerm: false })).toBe(false); + expect(copyImageKey(ev({ ctrlKey: true, shiftKey: true }), { isMac: true, inTerm: false })).toBe(false); + expect(copyImageKey(ev({ metaKey: true, shiftKey: true }), { isMac: false, inTerm: false })).toBe(false); + expect(copyImageKey(ev({ metaKey: true, shiftKey: true, altKey: true }), { isMac: true, inTerm: false })).toBe(false); + expect(copyImageKey(ev({ key: "V", metaKey: true, shiftKey: true }), { isMac: true, inTerm: false })).toBe(false); + }); +}); diff --git a/test/client.mdrobust.test.ts b/test/client.mdrobust.test.ts new file mode 100644 index 0000000..51d9a39 --- /dev/null +++ b/test/client.mdrobust.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { bindEditorSelectionMenu, bindSelectionMenu, selectionMenuItems, type SelectionHost } from "../src/client/copyentry.ts"; +import { FONT_FACES, FONT_TABLE, isInlinableImage, snapFaces } from "../src/client/mdsnap.ts"; +import type { MenuItem } from "../src/client/menu.ts"; + +type Snap = { id: number }; + +describe("AC-13 selection captured at contextmenu", () => { + const setup = () => { + let live: Snap | null = { id: 1 }; + const used: { op: string; snap: unknown }[] = []; + const host: SelectionHost = { + capture: () => live, + hasSelection: (s) => (s === undefined ? live !== null : s !== null), + copy: (s) => void used.push({ op: "copy", snap: s }), + copyImage: (s) => void used.push({ op: "image", snap: s }), + }; + let fn: ((e: MouseEvent) => void) | undefined; + const shown: MenuItem[][] = []; + bindSelectionMenu({ addEventListener: (_t, f) => (fn = f) }, host, (_e, items) => void shown.push(items)); + return { fire: () => fn!({} as MouseEvent), shown, used, collapse: () => (live = null) }; + }; + test("items act on the selection present at contextmenu even after it collapsed", () => { + const s = setup(); + const captured = { id: 1 }; + s.fire(); + s.collapse(); + s.shown[0]![0]!.run(); + s.shown[0]![1]!.run(); + expect(s.used).toEqual([ + { op: "copy", snap: captured }, + { op: "image", snap: captured }, + ]); + }); + test("a selection that is gone at contextmenu shows no menu", () => { + const s = setup(); + s.collapse(); + s.fire(); + expect(s.shown.length).toBe(0); + }); + test("selectionMenuItems passes an explicit snapshot to the actions", () => { + const got: unknown[] = []; + const items = selectionMenuItems({ hasSelection: () => true, copy: (s) => void got.push(s), copyImage: (s) => void got.push(s) }, "SNAP"); + items.forEach((i) => i.run()); + expect(got).toEqual(["SNAP", "SNAP"]); + }); + test("a host without capture still works", () => { + let fn: ((e: MouseEvent) => void) | undefined; + const shown: MenuItem[][] = []; + bindSelectionMenu({ addEventListener: (_t, f) => (fn = f) }, { hasSelection: () => true, copy() {}, copyImage() {} }, (_e, i) => void shown.push(i)); + fn!({} as MouseEvent); + expect(shown[0]!.length).toBe(2); + }); +}); + +describe("SL07 R-03 real wiring", () => { + test("bindEditorSelectionMenu binds contextmenu on the editor element with the host", () => { + const events: string[] = []; + let fn: ((e: MouseEvent) => void) | undefined; + const shown: string[][] = []; + bindEditorSelectionMenu( + { el: { addEventListener: (t, f) => (events.push(t), (fn = f)) } }, + { hasSelection: () => true, copy() {}, copyImage() {} }, + (_e, items) => void shown.push(items.map((i) => i.label)), + ); + expect(events).toEqual(["contextmenu"]); + fn!({} as MouseEvent); + expect(shown).toEqual([["Copy", "Copy as Image"]]); + }); + test("app.ts calls it in one line and no longer binds directly", () => { + const app = readFileSync("src/client/app.ts", "utf8"); + expect(app).toContain("bindEditorSelectionMenu(this.editor, this.selectionHost, showContextMenu)"); + expect(app).not.toContain("bindSelectionMenu("); + }); + test("app.ts host captures at contextmenu", () => { + const app = readFileSync("src/client/app.ts", "utf8"); + expect(app).toContain("capture:"); + }); +}); + +describe("AC-14 font face table", () => { + const css = readFileSync("src/client/styles.css", "utf8"); + const blocks = [...css.matchAll(/@font-face\s*{([^}]*)}/g)].map((m) => m[1]!); + const decl = (b: string, k: string) => new RegExp(`${k}:\\s*([^;]+);`).exec(b)?.[1]!.trim().replace(/^"|"$/g, ""); + test("matches every @font-face in styles.css", () => { + expect(blocks.length).toBe(5); + expect(Object.keys(FONT_FACES).length).toBe(blocks.length); + for (const b of blocks) { + const url = /url\("([^"]+)"\)/.exec(b)![1]!; + expect(FONT_FACES[url]).toEqual({ weight: decl(b, "font-weight")!, style: decl(b, "font-style")! }); + const fam = decl(b, "font-family")!; + expect(FONT_TABLE[fam]).toContain(url); + } + }); + test("snapFaces resolves a computed family list to faces with weight and style", () => { + const f = snapFaces('"JetBrains Mono", monospace'); + expect(f.length).toBe(4); + expect(f.every((x) => x.family === "JetBrains Mono")).toBe(true); + expect(f.find((x) => x.url === "/fonts/JetBrainsMono-BoldItalic.woff2")).toEqual({ + family: "JetBrains Mono", + url: "/fonts/JetBrainsMono-BoldItalic.woff2", + weight: "700", + style: "italic", + }); + expect(snapFaces("Inter")).toEqual([{ family: "Inter", url: "/fonts/InterVariable.woff2", weight: "100 900", style: "normal" }]); + expect(snapFaces("serif")).toEqual([]); + }); +}); + +describe("AC-14 isInlinableImage", () => { + const base = "http://127.0.0.1:4000/app/"; + test.each([ + ["/img/a.png", true], + ["a.png", true], + ["http://127.0.0.1:4000/x.png", true], + ["data:image/png;base64,AAAA", true], + ["http://evil.test/x.png", false], + ["http://127.0.0.1:4001/x.png", false], + ["//evil.test/x.png", false], + ["javascript:alert(1)", false], + ["file:///etc/passwd", false], + ["https://127.0.0.1:4000/x.png", false], + ])("%s -> %p", (src, ok) => { + expect(isInlinableImage(src, base)).toBe(ok); + }); + test("blob: is accepted only for the same origin", () => { + expect(isInlinableImage("blob:http://127.0.0.1:4000/uuid", base)).toBe(true); + expect(isInlinableImage("blob:http://evil.test/uuid", base)).toBe(false); + }); + test("an unparsable base rejects", () => { + expect(isInlinableImage("a.png", "not a url")).toBe(false); + }); +}); diff --git a/test/client.mdrobust2.test.ts b/test/client.mdrobust2.test.ts new file mode 100644 index 0000000..f5de406 --- /dev/null +++ b/test/client.mdrobust2.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { readSelection, snapshotText } from "../src/client/copyentry.ts"; + +class FakeRange { + constructor(public text: string) {} + clones = 0; + cloneRange() { + this.clones++; + return new FakeRange(this.text); + } + toString() { + return this.text; + } +} +type C = { id: string }; + +describe("R-02 readSelection clones the markdown Range", () => { + test("returns a clone, not the live Range, and the clone survives the live one changing", () => { + const live = new FakeRange("hello"); + const snap = readSelection({ md: { selectionRange: () => live } }); + expect(snap?.md).toBeDefined(); + expect(snap!.md).not.toBe(live); + expect(live.clones).toBe(1); + live.text = ""; + expect(snap!.md!.toString()).toBe("hello"); + }); + test("no markdown selection, or no tab: null", () => { + expect(readSelection({ md: { selectionRange: () => null } })).toBeNull(); + expect(readSelection(undefined)).toBeNull(); + }); + test("code tab: the model range is captured", () => { + const sel = { id: "a" }; + expect(readSelection({ code: { selection: sel, selectedText: () => "" } })).toEqual({ code: sel }); + expect(readSelection({ code: { selection: null, selectedText: () => "" } })).toBeNull(); + }); +}); + +describe("R-01 snapshotText uses the captured selection", () => { + const codeTab = (live: C | null) => ({ code: { selection: live, selectedText: (r?: C) => `text:${(r ?? live)?.id ?? ""}` } }); + test("code: text of the captured range even after the live selection moved", () => { + expect(snapshotText(codeTab({ id: "live" }), { code: { id: "captured" } })).toBe("text:captured"); + }); + test("markdown: text of the captured Range", () => { + expect(snapshotText({ md: { selectionRange: () => null } }, { md: new FakeRange("captured") })).toBe("captured"); + }); + test("no snapshot: empty string", () => { + expect(snapshotText(codeTab({ id: "live" }), null)).toBe(""); + expect(snapshotText(undefined, undefined)).toBe(""); + }); +}); diff --git a/test/client.mdsnap.fix.test.ts b/test/client.mdsnap.fix.test.ts new file mode 100644 index 0000000..2d8e2ef --- /dev/null +++ b/test/client.mdsnap.fix.test.ts @@ -0,0 +1,109 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { buildSvg, cssSafe, fontFaceUrls, pairTrees, snapSize, type TreeNode } from "../src/client/mdsnap.ts"; +import { installDom, uninstallDom } from "./helpers/fakedom.ts"; + +const table = { + "JetBrains Mono": ["/fonts/JetBrainsMono-Regular.woff2", "/fonts/JetBrainsMono-Bold.woff2"], + Inter: ["/fonts/InterVariable.woff2"], +}; + +describe("R-01 fontFaceUrls", () => { + test("maps the families in a computed font-family list to every url in the table", () => { + const r = fontFaceUrls('"JetBrains Mono", Inter, monospace', table); + expect(r).toEqual([ + { family: "JetBrains Mono", url: "/fonts/JetBrainsMono-Regular.woff2" }, + { family: "JetBrains Mono", url: "/fonts/JetBrainsMono-Bold.woff2" }, + { family: "Inter", url: "/fonts/InterVariable.woff2" }, + ]); + }); + test("strips quotes, ignores case and unknown families", () => { + expect(fontFaceUrls("'inter', Arial", table)).toEqual([{ family: "Inter", url: "/fonts/InterVariable.woff2" }]); + expect(fontFaceUrls("Arial, serif", table)).toEqual([]); + }); +}); + +describe("R-02 snapSize", () => { + test("is the union of the rects plus padding on all sides", () => { + const r = snapSize( + [ + { left: 10, top: 20, right: 110, bottom: 40 }, + { left: 5, top: 40, right: 60, bottom: 90 }, + ], + 8, + ); + expect(r).toEqual({ width: 105 + 16, height: 70 + 16 }); + }); + test("no rects gives zero size", () => { + expect(snapSize([], 8)).toEqual({ width: 0, height: 0 }); + }); +}); + +describe("R-04 pairTrees", () => { + const n = (...children: TreeNode[]): TreeNode => ({ children }); + test("pairs parallel trees in pre-order, roots included", () => { + const a = n(n(n()), n()); + const b = n(n(n()), n()); + const pairs = pairTrees(a, b); + expect(pairs.length).toBe(4); + expect(pairs[0]).toEqual([a, b]); + expect(pairs[1]![0]).toBe(a.children[0]!); + expect(pairs[1]![1]).toBe(b.children[0]!); + expect(pairs[2]![0]).toBe(a.children[0]!.children[0]!); + expect(pairs[3]![1]).toBe(b.children[1]!); + }); + test("a child-count mismatch pairs only the common children, never misaligning", () => { + const a = n(n(), n(), n()); + const b = n(n(), n()); + const pairs = pairTrees(a, b); + expect(pairs.length).toBe(3); + for (const [l, c] of pairs) expect(l === a ? c === b : a.children.indexOf(l) === b.children.indexOf(c)).toBe(true); + }); +}); + +describe("R-06 cssSafe", () => { + test("removes braces and markup openers from css text", () => { + const out = cssSafe("color:red}body{background:url(x)"); + expect(out).not.toContain("}"); + expect(out).not.toContain("{"); + expect(out).not.toContain("<"); + expect(out).toContain("color:red"); + }); + test("leaves ordinary declarations alone", () => { + expect(cssSafe("color:red;margin:0 1px")).toBe("color:red;margin:0 1px"); + }); + test("buildSvg never lets a style value close its rule", () => { + const svg = buildSvg({ html: "", styles: { p: "color:red}body{x:y" }, fonts: [], width: 1, height: 1, bg: "#000" }); + expect(svg).toContain("p{color:red"); + expect((svg.match(/}/g) ?? []).length).toBe(1); + }); +}); + +describe("R-05 MarkdownView.selectionRange", () => { + let V: typeof import("../src/client/mdview.ts"); + beforeAll(async () => { + installDom(); + V = await import("../src/client/mdview.ts"); + }); + afterAll(() => uninstallDom()); + const setSel = (sel: unknown) => { + (globalThis as unknown as { window: { getSelection: () => unknown } }).window.getSelection = () => sel; + }; + test("returns the range when it lies inside the view", () => { + const v = new V.MarkdownView("hello"); + const range = { commonAncestorContainer: v.el }; + setSel({ rangeCount: 1, isCollapsed: false, getRangeAt: () => range }); + expect(v.selectionRange()).toBe(range as unknown as Range); + }); + test("null when collapsed, empty, outside the view or unsupported", () => { + const v = new V.MarkdownView("hello"); + const outside = { commonAncestorContainer: document.createElement("div") }; + setSel({ rangeCount: 1, isCollapsed: true, getRangeAt: () => ({ commonAncestorContainer: v.el }) }); + expect(v.selectionRange()).toBeNull(); + setSel({ rangeCount: 0, isCollapsed: false, getRangeAt: () => outside }); + expect(v.selectionRange()).toBeNull(); + setSel({ rangeCount: 1, isCollapsed: false, getRangeAt: () => outside }); + expect(v.selectionRange()).toBeNull(); + setSel(null); + expect(v.selectionRange()).toBeNull(); + }); +}); diff --git a/test/client.mdsnap.prune.test.ts b/test/client.mdsnap.prune.test.ts new file mode 100644 index 0000000..9592e93 --- /dev/null +++ b/test/client.mdsnap.prune.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { pruneToRange, type PNode, type PrunedNode } from "../src/client/mdsnap.ts"; + +interface N extends PNode { + tag?: string; + children: N[]; +} +const t = (text: string): N => ({ text, children: [] }); +const e = (tag: string, ...children: N[]): N => ({ tag, children }); +const show = (p: PrunedNode): string => + p.src.text !== undefined ? JSON.stringify(p.text) : `${p.src.tag}[${p.children.map(show).join(",")}]`; + +describe("pruneToRange (R-07)", () => { + test("keeps selected text that follows a removed inline element", () => { + const hello = t("Hello "), world = t(" world"); + const p = e("p", hello, e("em", t("x")), world); + const r = pruneToRange(p, { node: world, offset: 1 }, { node: p, offset: 3 }); + expect(show(r)).toBe('p["world"]'); + }); + test("whole range keeps everything untouched", () => { + const a = t("ab"), b = t("cd"); + const p = e("p", a, e("b", b)); + const r = pruneToRange(p, { node: a, offset: 0 }, { node: b, offset: 2 }); + expect(show(r)).toBe('p["ab",b["cd"]]'); + }); + test("trims both boundary text nodes in the same node", () => { + const a = t("abcdef"); + const p = e("p", a); + expect(show(pruneToRange(p, { node: a, offset: 2 }, { node: a, offset: 4 }))).toBe('p["cd"]'); + }); + test("trims start and end across siblings and drops outside elements", () => { + const a = t("one "), c = t(" three"); + const b = e("b", t("two")); + const before = e("i", t("zzz")); + const root = e("div", before, e("p", a, b, c), e("p", t("after"))); + const r = pruneToRange(root, { node: a, offset: 1 }, { node: c, offset: 3 }); + expect(show(r)).toBe('div[p["ne ",b["two"]," th"]]'); + }); + test("element-container boundaries select whole children", () => { + const x = t("x"), y = t("y"), z = t("z"); + const p = e("p", x, e("em", y), z); + expect(show(pruneToRange(p, { node: p, offset: 1 }, { node: p, offset: 2 }))).toBe('p[em["y"]]'); + }); + test("keeps selected leaf elements such as img and drops those outside", () => { + const img1 = e("img"), img2 = e("img"), img3 = e("img"); + const a = t("a"), b = t("b"); + const p = e("p", img1, a, img2, b, img3); + const r = pruneToRange(p, { node: p, offset: 1 }, { node: p, offset: 4 }); + expect(show(r)).toBe('p["a",img[],"b"]'); + }); + test("nested elements: text after a removed deep element survives", () => { + const tail = t("tail"); + const li = e("li", t("gone"), e("span", e("b", t("deep"))), tail); + const ul = e("ul", li); + expect(show(pruneToRange(ul, { node: tail, offset: 0 }, { node: tail, offset: 4 }))).toBe('ul[li["tail"]]'); + }); + test("root is kept even when nothing is selected; empty text slices are dropped", () => { + const a = t("abc"); + const p = e("p", a); + expect(show(pruneToRange(p, { node: a, offset: 3 }, { node: a, offset: 3 }))).toBe("p[]"); + }); + test("pruned nodes reference their source nodes", () => { + const a = t("abc"); + const p = e("p", a); + const r = pruneToRange(p, { node: a, offset: 0 }, { node: a, offset: 3 }); + expect(r.src).toBe(p); + expect(r.children[0]!.src).toBe(a); + }); +}); 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("