diff --git a/DESIGN.md b/DESIGN.md index fcee117..a1dd7b8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -647,14 +647,3 @@ 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 e72b818..5ac383f 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,12 @@ 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 +## Selection and copy -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". +Select code in the viewer as you would in any editor, dragging in either direction. Copy (Cmd/Ctrl+C or the context menu) puts the exact text on the clipboard, including parts scrolled out of view, and never includes line numbers, the blame label or the truncation label on very long lines. Cmd/Ctrl+A selects the whole file while focus is in the code view. The blame label stays out of the way while you drag or have text selected. ## Markdown diff --git a/packaging/macos/main.swift b/packaging/macos/main.swift index f740c35..4257b85 100644 --- a/packaging/macos/main.swift +++ b/packaging/macos/main.swift @@ -96,7 +96,6 @@ 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 @@ -241,9 +240,6 @@ 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 } @@ -255,37 +251,6 @@ 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 d8b9867..971c1c6 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -10,15 +10,9 @@ 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, 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 { isContextMenuOpen } from "./menu.ts"; import { SearchView } from "./search.ts"; -import { copyImageKey, normSideView, searchKey, type SideView } from "./keys.ts"; +import { normSideView, searchKey, type SideView } from "./keys.ts"; type ThemeName = "dark" | "light" | "system"; @@ -228,7 +222,6 @@ 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") { @@ -320,12 +313,6 @@ 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(); @@ -351,7 +338,6 @@ 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"), @@ -524,74 +510,6 @@ 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; @@ -679,13 +597,6 @@ 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 3fda44a..b581a21 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -1,7 +1,7 @@ -import { Highlighter, type Span, type Tok } from "./highlight.ts"; +import { Highlighter, type Tok } from "./highlight.ts"; import { dismissable, esc, h } from "./util.ts"; import { icons } from "./icons.ts"; -import { normalize, restorePlan, selectAll as selectAllRange, textOf, type SelRange } from "./selection.ts"; +import { allRange, normalize, restorePlan, selectionText, 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"]; @@ -53,6 +53,9 @@ export interface CodeViewState { * Virtualized, read-only code view: only visible lines exist in the DOM. * Highlighting is computed lazily per visible line by the incremental Highlighter. */ +/** The view the user last pressed in; document-level Cmd+A / copy only act for it. */ +let current: CodeView | null = null; + export class CodeView { readonly el: HTMLElement; private scroller: HTMLElement; @@ -67,6 +70,8 @@ export class CodeView { private charW = 7.8; private gutterW = 48; private frame = 0; + private visible = true; + private off: (() => void)[] = []; private cache = new Map(); private changed: Uint8Array = new Uint8Array(0); private flash: { from: number; to: number; at: number } | null = null; @@ -96,9 +101,10 @@ export class CodeView { private lang: string, fontSize: number, ) { + current = null; // a new file starts without a pointer interaction this.linesEl = h("div", { class: "code-lines" }); this.sizer = h("div", { class: "code-sizer" }, this.linesEl); - this.scroller = h("div", { class: "code-scroll" }, this.sizer); + this.scroller = h("div", { class: "code-scroll", tabindex: "-1" }, this.sizer); this.findInput = h("input", { class: "find-input", placeholder: "Find", spellcheck: "false", "aria-label": "Find in file" }); this.findCount = h("span", { class: "find-count" }); const toggle = (label: string, key: keyof CodeView["opts"], title: string) => { @@ -142,12 +148,15 @@ export class CodeView { } }); this.linesEl.addEventListener("mousedown", (e) => { + this.held = !e.button && !e.ctrlKey; // a new press supersedes a hold whose mouseup was lost 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 + current = this; + if (e.button || e.ctrlKey) return this.setActiveLine(n); // right press / Ctrl+click opens the menu: keep the range this.sel = null; this.applied = null; + current = this; if ((e.target as HTMLElement).classList.contains("g") && this.gitMarks[n]) { e.preventDefault(); this.togglePeek(n, ln); @@ -155,24 +164,65 @@ export class CodeView { } this.setActiveLine(n); }); + // A press in the scroller's blank area (beside short lines, below the text) also starts a drag. + this.scroller.addEventListener("mousedown", (e) => { + if (!e.button && !e.ctrlKey) this.held = true; + }); this.scroller.addEventListener("scroll", () => this.closePeek(), { passive: true }); + // Focus does not reliably sit on the scroller (a press on code text may leave it on ), so the + // key and copy handling also listens on the document and checks that this view is the one in use. + const selectAllKey = (e: KeyboardEvent) => (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "a"; this.scroller.addEventListener("keydown", (e) => { - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "a") { - e.preventDefault(); - this.selectAll(); - } + if (!selectAllKey(e)) return; + e.preventDefault(); + this.selectAll(); }); + const on = (t: T, type: string, fn: (e: any) => void) => { + t.addEventListener(type, fn); + this.off.push(() => t.removeEventListener(type, fn)); + }; + on(document, "keydown", (e: KeyboardEvent) => { + if (e.defaultPrevented || !this.active || !selectAllKey(e) || isEditable(e.target)) return; + e.preventDefault(); + this.selectAll(); + }); + // Also fires for the WebView's native Edit / context-menu Copy. + const onCopy = (e: ClipboardEvent) => { + if (e.defaultPrevented || !this.sel || isEditable(e.target)) return; + const ds = window.getSelection?.(); + const live = !!ds && ds.rangeCount > 0 && !ds.isCollapsed; + const inside = live && (this.el.contains(ds!.anchorNode) || this.el.contains(ds!.focusNode)); + if (live ? !inside : !this.active) return; + e.preventDefault(); + e.clipboardData?.setData("text/plain", this.selectedText()); + }; 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; + on(document, "copy", onCopy); + on(document, "selectionchange", () => { + this.readDomSelection(); + this.syncBlame(); // a selection hides the label only while it exists + }); + const release = () => { + if (!this.held) return; + this.held = false; + this.syncBlame(); + }; + on(document, "mouseup", release); + on(window, "blur", release); + on(document, "mousemove", (e: MouseEvent) => { + if (this.held && e.buttons === 0) release(); // the button was released outside the page + }); + // A primary press outside the whole view makes the model range stale. + on(document, "mousedown", (e: MouseEvent) => { + const t = e.target as Node | null; + const inside = this.el.contains(t) || this.linesEl.contains(t); + if (inside) current = this; + else if (current === this) current = null; + if (e.button || e.ctrlKey || !this.sel || inside) return; this.sel = null; this.applied = null; }); @@ -183,7 +233,18 @@ export class CodeView { /** 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; + /** True when the user's anchor is the end of the normalised range (bottom-to-top selection). */ + private rev = false; + /** This view was last used and keyboard focus is still in it (or nowhere in particular). */ + private get active() { + if (current !== this || !this.visible || !this.el.isConnected) return false; + const f = document.activeElement; + return !f || f === document.body || this.el.contains(f); + } + /** The primary button went down in the rows and has not been released. */ + private held = false; /** Endpoints of the last DOM selection we applied; its selectionchange echo must not shrink the model. */ private applied: SelRange | null = null; @@ -195,35 +256,19 @@ export class CodeView { /** Sets the model range; the DOM selection is restored on the next render. */ setRange(r: SelRange | null) { this.sel = r ? normalize(r) : null; + this.rev = false; 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(() => {}); + this.setRange(allRange(this.lines)); } /** Text of the model selection ("" when none), never including decorations. */ - selectedText(range: SelRange | null = this.sel): string { - return range ? textOf(this.lines, range) : ""; + selectedText(): string { + return this.sel ? selectionText(this.lines, this.sel) : ""; } private rowTx(n: number): HTMLElement | null { @@ -236,22 +281,36 @@ export class CodeView { /** Re-applies the model range to the freshly rendered rows. */ private restoreSelection() { if (!this.sel) return; + if (this.held) { + // Never fight the browser mid-drag unless the render destroyed the anchor (the selection then + // collapses onto a container such as .code-lines, which is not inside any row's text). + const an = window.getSelection?.()?.anchorNode; + const el = an ? (an.nodeType === 3 ? an.parentElement : (an as HTMLElement)) : null; + const tx = el?.closest?.(".tx"); + if (tx && this.linesEl.contains(tx)) 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 } }; + const [sn, so] = locate(a, plan.anchor.offset); + const [en, eo] = locate(f, plan.focus.offset); + // anchor/focus keep the user's direction; `applied` is stored in the same anchor -> focus order + const [an, ao, fn, fo] = this.rev ? [en, eo, sn, so] : [sn, so, en, eo]; + const [ap, fp] = this.rev ? [plan.focus, plan.anchor] : [plan.anchor, plan.focus]; + this.applied = { start: { line: ap.line, col: ap.offset }, end: { line: fp.line, col: fp.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; + if (!ds || ds.rangeCount === 0 || !ds.anchorNode || !ds.focusNode) return; + // A collapsed DOM selection is not an intent to clear: scrolling the range out of the rendered rows makes + // the browser collapse it. Only a plain click, setText or a selection made elsewhere clears the model range. + if (ds.isCollapsed) return; const pos = (node: Node, off: number) => { const el = node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; const row = el?.closest(".ln"); @@ -261,19 +320,34 @@ export class CodeView { }; 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; + if (!a || !f) { + // An endpoint outside the whole view (not just outside the rows) means a selection made elsewhere. + if ((!a && !this.el.contains(ds.anchorNode)) || (!f && !this.el.contains(ds.focusNode))) { + this.sel = null; + 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.rev = f.line < a.line || (f.line === a.line && f.col < a.col); this.sel = normalize({ start: a, end: f }); } + /** Removes every document/window listener, drops global ownership and cancels a pending frame. */ + dispose() { + for (const f of this.off.splice(0)) f(); + if (current === this) current = null; + if (this.frame) cancelAnimationFrame(this.frame); + this.frame = 0; + } + + /** Set by the editor when it shows or hides this view (tab switch, Markdown preview toggle). */ + setVisible(visible: boolean) { + this.visible = visible; + } + get lineCount() { return this.lines.length; } @@ -370,7 +444,30 @@ export class CodeView { /** Inline blame annotation shown at the end of the active line. */ setBlame(line: number, html: string | null) { this.blame = html === null ? null : { line, html }; - this.schedule(); + this.syncBlame(); + } + + private blameVisible(i: number): boolean { + return !!this.blame && this.blame.line === i && !this.held && !this.sel && window.getSelection?.()?.isCollapsed !== false; + } + + private rowEl(i: number): HTMLElement | null { + return this.linesEl.querySelector?.(`.ln[data-n="${i}"]`) ?? null; + } + + /** Line whose row currently carries the .blame span (-1 = none). */ + private shownBlame = -1; + + /** Patches the single .blame span in place; rebuilding rows would break a drag-selection. */ + private syncBlame() { + const want = this.blame && this.blameVisible(this.blame.line) ? this.blame.line : -1; + if (this.shownBlame >= 0) this.rowEl(this.shownBlame)?.querySelector?.(".blame")?.remove(); + this.shownBlame = -1; + if (want < 0) return; + const row = this.rowEl(want); + if (!row) return; + row.insertAdjacentHTML("beforeend", `${this.blame!.html}`); + this.shownBlame = want; } private hunkAt(line: number): Hunk | undefined { @@ -481,11 +578,13 @@ export class CodeView { cls += " flash"; style = ` style="animation-delay:${-(now - this.flash.at) | 0}ms"`; } - const blame = this.blame && this.blame.line === i ? `${this.blame.html}` : ""; + const blame = this.blameVisible(i) ? `${this.blame!.html}` : ""; html += `
${i + 1}${this.lineHtml(i)}${blame}
`; } this.linesEl.style.transform = `translateY(${first * this.lh}px)`; this.linesEl.innerHTML = html; + const bl = this.blame?.line ?? -1; + this.shownBlame = bl >= first && bl < last && this.blameVisible(bl) ? bl : -1; this.restoreSelection(); } @@ -539,9 +638,13 @@ export class CodeView { } setActiveLine(n: number) { + const old = this.activeLine; this.activeLine = n; this.onActiveLine(n); - this.schedule(); + if (old !== n) { + if (old >= 0) this.rowEl(old)?.classList?.remove?.("active"); + this.rowEl(n)?.classList?.add?.("active"); + } } /** Scrolls so that 0-based `line` is visible (centered when far away). */ @@ -668,9 +771,15 @@ export class CodeView { } } -/** Text node and offset for a text offset inside a row's text element. */ +/** True for targets that own Cmd+A / Cmd+C themselves (inputs, textareas such as the terminal's, editable hosts). */ +function isEditable(t: EventTarget | null): boolean { + const e = t as HTMLElement | null; + const tag = e?.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || !!e?.isContentEditable; +} + +/** Text node and offset for a text offset inside a row's text element (the truncation label is skipped). */ 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 => { diff --git a/src/client/copyentry.ts b/src/client/copyentry.ts deleted file mode 100644 index 2e8b8dc..0000000 --- a/src/client/copyentry.ts +++ /dev/null @@ -1,85 +0,0 @@ -// 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 deleted file mode 100644 index 86444a7..0000000 --- a/src/client/copyimage.ts +++ /dev/null @@ -1,207 +0,0 @@ -// 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/editor.ts b/src/client/editor.ts index e979336..ff66160 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -162,7 +162,7 @@ export class Editor { tab.md?.dispose(); tab.md = undefined; } - if (!plan.code) tab.code = undefined; + if (!plan.code) this.dropCode(tab); if (f.kind === "text") { tab.eol = /\r\n/.test(f.text.slice(0, 10000)) ? "CRLF" : "LF"; if (plan.view === "md") { @@ -175,6 +175,7 @@ export class Editor { } else if (plan.reuse) tab.code!.setText(f.text); else { tab.el.replaceChildren(); + tab.code?.dispose(); tab.code = new CodeView(f.text, tab.lang, this.fontSize); const code = tab.code; const blame = debounce((line: number) => void this.loadBlame(tab, code, line), 180); @@ -191,7 +192,7 @@ export class Editor { tab.el.prepend( tooLargeNote(() => { tab.forcePreview = true; - tab.code = undefined; + this.dropCode(tab); void this.load(tab); }), ); @@ -202,16 +203,16 @@ export class Editor { } else if (f.kind === "image") { if (tab.image && reload) tab.image.reload(tab.mtime); else { - tab.code = undefined; + this.dropCode(tab); tab.image = new ImageView(tab.path, tab.mtime, () => this.status()); tab.el.replaceChildren(tab.image.el); if (tab.path.endsWith(".svg")) tab.el.prepend(this.viewToggle(tab, "View source", icons.code)); } } else if (f.kind === "binary") { - tab.code = undefined; + this.dropCode(tab); tab.el.replaceChildren(await binaryView(tab.path, f.size)); } else { - tab.code = undefined; + this.dropCode(tab); tab.el.replaceChildren(h("div", { class: "notice" }, "This is a directory.")); } tab.kind = f.kind; @@ -222,7 +223,7 @@ export class Editor { return; } tab.kind = "error"; - tab.code = undefined; + this.dropCode(tab); tab.md?.dispose(); tab.md = undefined; tab.el.replaceChildren(h("div", { class: "notice error" }, h("strong", null, "Can't open file"), h("span", null, (e as Error).message))); @@ -249,7 +250,7 @@ export class Editor { tab.asSource = next.asSource; tab.forcePreview = next.forcePreview; // an explicit request overrides the size gate } else tab.asSource = !tab.asSource; // SVG: image or source - tab.code = undefined; + this.dropCode(tab); tab.image = undefined; tab.md?.dispose(); tab.md = undefined; @@ -273,8 +274,10 @@ export class Editor { activate(tab: Tab) { if (this.active === tab) return; + this.active?.code?.setVisible(false); this.active = tab; this.body.replaceChildren(tab.el); + tab.code?.setVisible(true); tab.code?.schedule(); if (tab.gitStale) void this.refreshGit(tab); this.render(); @@ -314,7 +317,13 @@ export class Editor { await this.load(tab); } + private dropCode(tab: Tab) { + tab.code?.dispose(); + tab.code = undefined; + } + private disposeTab(tab: Tab) { + tab.code?.dispose(); tab.md?.dispose(); tab.el.remove(); tab.tabEl.remove(); diff --git a/src/client/imagedom.ts b/src/client/imagedom.ts deleted file mode 100644 index 4157e0b..0000000 --- a/src/client/imagedom.ts +++ /dev/null @@ -1,229 +0,0 @@ -// 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 deleted file mode 100644 index cf322b4..0000000 --- a/src/client/imagelayout.ts +++ /dev/null @@ -1,99 +0,0 @@ -// 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 78880ee..03530bb 100644 --- a/src/client/keys.ts +++ b/src/client/keys.ts @@ -9,7 +9,6 @@ export interface KeyLike { ctrlKey?: boolean; altKey?: boolean; shiftKey?: boolean; - target?: unknown; } export type SearchKeyAction = "search" | "find" | "next" | "prev"; @@ -31,11 +30,3 @@ 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 deleted file mode 100644 index 69782b6..0000000 --- a/src/client/mdsnap.ts +++ /dev/null @@ -1,219 +0,0 @@ -// 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 37bee5c..37a7166 100644 --- a/src/client/mdview.ts +++ b/src/client/mdview.ts @@ -132,14 +132,6 @@ 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 index 82ca9a5..4d3e2d4 100644 --- a/src/client/selection.ts +++ b/src/client/selection.ts @@ -1,4 +1,4 @@ -// Pure selection model for the code view (0-based line/col). No DOM access. +// Pure selection model for the code view (0-based line/col in UTF-16 code units). No DOM access. export interface Pos { line: number; @@ -19,13 +19,13 @@ export interface RestorePlan { const before = (a: Pos, b: Pos) => a.line < b.line || (a.line === b.line && a.col < b.col); -/** Returns the range with start <= end. */ +/** Returns a copy of 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 { +export function selectionText(lines: readonly string[], r: SelRange): string { const { start, end } = normalize(r); const out: string[] = []; for (let i = start.line; i <= end.line && i < lines.length; i++) { @@ -36,14 +36,14 @@ export function textOf(lines: string[], r: SelRange): string { } /** Range covering the whole file. */ -export function selectAll(lines: string[]): SelRange { +export function allRange(lines: readonly 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, + * 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 { diff --git a/src/client/styles.css b/src/client/styles.css index 6033127..619674e 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -288,11 +288,13 @@ svg { .resizing-x, .resizing-x * { cursor: col-resize !important; + -webkit-user-select: none !important; user-select: none !important; } .resizing-y, .resizing-y * { cursor: row-resize !important; + -webkit-user-select: none !important; user-select: none !important; } @@ -554,6 +556,7 @@ svg { cursor: pointer; white-space: nowrap; color: var(--fg); + -webkit-user-select: none; user-select: none; } .row::before { @@ -713,6 +716,7 @@ svg { color: var(--fg-muted); border-right: 1px solid var(--border); cursor: pointer; + -webkit-user-select: none; user-select: none; transition: color 0.12s, background 0.12s; } @@ -1064,6 +1068,7 @@ svg { text-align: right; color: var(--fg-faint); background: var(--bg-editor); + -webkit-user-select: none; user-select: none; font-variant-numeric: tabular-nums; } @@ -1134,6 +1139,7 @@ svg { font-size: 0.88em; font-style: italic; white-space: nowrap; + -webkit-user-select: none; user-select: none; pointer-events: none; } @@ -1189,6 +1195,7 @@ svg { padding-right: 12px; text-align: right; color: var(--fg-faint); + -webkit-user-select: none; user-select: none; } .peek-t { @@ -1207,6 +1214,7 @@ svg { } } .trunc { + -webkit-user-select: none; user-select: none; color: var(--fg-faint); font-style: italic; @@ -1517,6 +1525,7 @@ mark.cur { border-radius: 6px; color: var(--fg-muted); cursor: pointer; + -webkit-user-select: none; user-select: none; font-size: 12px; } @@ -2082,4 +2091,9 @@ li[data-state="done"] .upd-mark { .sv-stale[hidden] { display: none; } .sv-refresh { cursor: pointer; } -html.snap-hide-sel ::selection { background: transparent !important; } +.ln .g::selection, +.ln .g *::selection, +.blame::selection, +.blame *::selection, +.trunc::selection, +.trunc *::selection { background: transparent; } diff --git a/test/client.codeview.blame.test.ts b/test/client.codeview.blame.test.ts new file mode 100644 index 0000000..eb90334 --- /dev/null +++ b/test/client.codeview.blame.test.ts @@ -0,0 +1,256 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { FakeEvent, uninstallDom } from "./helpers/fakedom.ts"; +import { fireSelectionChange, loadCodeView, mount, type Env, type Mounted, type N } from "./helpers/selfake.ts"; + +let env: Env; +beforeAll(async () => { + env = await loadCodeView(); +}); +afterAll(() => uninstallDom()); + +const body = Array.from({ length: 300 }, (_, i) => `line ${i} text`).join("\n"); +const css = readFileSync(new URL("../src/client/styles.css", import.meta.url), "utf8"); +const editorSrc = readFileSync(new URL("../src/client/editor.ts", import.meta.url), "utf8"); + +const blameOf = (r: N) => r.querySelector("blame"); +const shown = (m: Mounted) => m.rows().filter((r) => blameOf(r)).length; +const tx = (m: Mounted, n: number) => m.row(n).querySelector("tx")!; +const press = (m: Mounted, n: number, init: Record = {}) => + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: m.row(n).childNodes[0], button: 0, ...init })); +const release = () => { + env.dom.document.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); + env.dom.window.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); +}; +/** DOM Node.contains is inclusive: contains(self) is true. */ +function inclusive(m: Mounted) { + const inner = m.linesEl.contains; + m.linesEl.contains = (n: unknown) => n === m.linesEl || inner(n); +} + +describe("AC-8 active line and blame are patched in place", () => { + test("setActiveLine keeps every row object and moves the active class", () => { + const m = mount(env, body); + const before = m.rows().slice(); + m.view.setActiveLine(3); + env.flush(); + expect(m.rows().length).toBe(before.length); + expect(m.rows().every((r, i) => r === before[i])).toBe(true); + m.view.setActiveLine(5); + env.flush(); + expect(m.rows().every((r, i) => r === before[i])).toBe(true); + expect(m.row(3).classList.contains("active")).toBe(false); + expect(m.row(5).classList.contains("active")).toBe(true); + }); + + test("setBlame keeps every row object and patches one blame span", () => { + const m = mount(env, body); + const before = m.rows().slice(); + m.view.setActiveLine(2); + m.view.setBlame(2, "Ann, today"); + env.flush(); + expect(m.rows().every((r, i) => r === before[i])).toBe(true); + expect(shown(m)).toBe(1); + expect(blameOf(m.row(2))!.textContent).toBe("Ann, today"); + m.view.setBlame(4, "Bob"); + env.flush(); + expect(m.rows().every((r, i) => r === before[i])).toBe(true); + expect(blameOf(m.row(2))).toBeNull(); + expect(blameOf(m.row(4))!.textContent).toBe("Bob"); + m.view.setBlame(4, null); + env.flush(); + expect(m.rows().every((r, i) => r === before[i])).toBe(true); + expect(shown(m)).toBe(0); + }); + + test("a later render still draws the label on its line", () => { + const m = mount(env, body); + m.view.setBlame(2, "Ann"); + env.flush(); + m.view.setText(body.replace("line 9 ", "line 9x ")); + env.flush(); + expect(blameOf(m.row(2))).toBeNull(); // setText resets blame + m.view.setBlame(2, "Ann"); + m.view.schedule(); + env.flush(); + expect(blameOf(m.row(2))!.textContent).toBe("Ann"); + }); +}); + +describe("AC-9 the label waits for the button and the selection", () => { + test("no label while the primary button is held; it appears after mouseup", () => { + const m = mount(env, body); + press(m, 1); + m.view.setBlame(1, "Ann"); + env.flush(); + expect(shown(m)).toBe(0); + release(); + env.flush(); + expect(blameOf(m.row(1))!.textContent).toBe("Ann"); + }); + + test("a non-collapsed live selection hides the label while it exists", () => { + const m = mount(env, body); + m.view.setActiveLine(1); + m.view.setBlame(1, "Ann"); + env.flush(); + expect(shown(m)).toBe(1); + m.sel.set(tx(m, 1).childNodes[0]!, 0, tx(m, 2).childNodes[0]!, 1); + fireSelectionChange(env); + env.flush(); + expect(shown(m)).toBe(0); + m.view.setBlame(1, "Kay"); + env.flush(); + expect(shown(m)).toBe(0); + press(m, 1); // a plain click clears the range + m.sel.set(tx(m, 1).childNodes[0]!, 0, tx(m, 1).childNodes[0]!, 0, true); + release(); + fireSelectionChange(env); + env.flush(); + expect(blameOf(m.row(1))!.textContent).toBe("Kay"); + }); + + test("a keyboard active-line change after the selection collapsed shows its blame", () => { + const m = mount(env, body); + press(m, 1); + m.sel.set(tx(m, 1).childNodes[0]!, 0, tx(m, 2).childNodes[0]!, 1); + release(); + m.sel.isCollapsed = true; + fireSelectionChange(env); + m.view.setActiveLine(3); + m.view.setBlame(3, "Bob"); + env.flush(); + expect(blameOf(m.row(3))!.textContent).toBe("Bob"); + }); + + test("select-all (model range) hides the label", () => { + const m = mount(env, body); + m.view.setBlame(1, "Ann"); + env.flush(); + m.view.selectAll(); + env.flush(); + m.view.setBlame(1, "Ann"); + env.flush(); + expect(shown(m)).toBe(0); + }); + + test("the held state never sticks: blur, buttonless mousemove, next mousedown", () => { + for (const clear of [ + () => env.dom.window.dispatchEvent(new FakeEvent("blur")), + () => env.dom.document.dispatchEvent(new FakeEvent("mousemove", { buttons: 0 })), + (m: Mounted) => press(m, 2, { button: 2 }), + ]) { + const m = mount(env, body); + press(m, 1); + clear(m); + m.view.setBlame(1, "Ann"); + env.flush(); + expect(shown(m)).toBe(1); + } + }); + + test("a mousemove with the primary button down keeps the hold", () => { + const m = mount(env, body); + press(m, 1); + env.dom.document.dispatchEvent(new FakeEvent("mousemove", { buttons: 1 })); + m.view.setBlame(1, "Ann"); + env.flush(); + expect(shown(m)).toBe(0); + }); +}); + +describe("AC-10 editor wiring", () => { + test("changing the active line clears the blame through setBlame and reloads it after a debounce", () => { + const i = editorSrc.indexOf("code.onActiveLine"); + expect(i).toBeGreaterThan(0); + const wiring = editorSrc.slice(i, i + 200); + expect(wiring).toContain("code.setBlame(line, null)"); + expect(wiring).toContain("blame(line)"); + expect(editorSrc).toContain("debounce((line: number) => void this.loadBlame("); + }); + + test("setBlame(line, null) removes the label without rebuilding rows", () => { + const m = mount(env, body); + m.view.setBlame(1, "Ann"); + env.flush(); + const r = m.row(1); + m.view.setBlame(1, null); + env.flush(); + expect(m.row(1)).toBe(r); + expect(blameOf(r)).toBeNull(); + }); +}); + +describe("AC-13 a bottom-to-top drag keeps its anchor", () => { + test("active line and blame arrive mid-drag; anchor nodes and selection survive; range normalises", () => { + const m = mount(env, body); + inclusive(m); + press(m, 5); + const anchorRow = m.row(5); + const anchorText = tx(m, 5).childNodes[0]!; + m.sel.set(anchorText, 2, tx(m, 2).childNodes[0]!, 1); + fireSelectionChange(env); + m.view.setActiveLine(2); + m.view.setBlame(2, "Ann"); + env.flush(); + expect(m.row(5)).toBe(anchorRow); + expect(tx(m, 5).childNodes[0]).toBe(anchorText); + expect(m.linesEl.contains(anchorText)).toBe(true); + expect(m.sel.anchorNode).toBe(anchorText); + expect(m.sel.anchorOffset).toBe(2); + expect(shown(m)).toBe(0); + release(); + fireSelectionChange(env); + env.flush(); + const r = m.view.selection!; + expect(r.start.line).toBe(2); + expect(r.start.col).toBe(1); + expect(r.end.line).toBe(5); + expect(r.end.col).toBe(2); + expect(m.sel.anchorNode).toBe(anchorText); + }); +}); + +describe("AC-14 the label never shifts the text", () => { + test("setBlame while held inserts nothing and leaves every .tx text node untouched", () => { + const m = mount(env, body); + press(m, 3); + const nodes = m.rows().map((r) => r.querySelector("tx")!.childNodes[0]!); + const data = nodes.map((n) => n.data); + m.view.setBlame(3, "Ann"); + m.view.setBlame(4, "Bob"); + env.flush(); + expect(shown(m)).toBe(0); + m.rows().forEach((r, i) => { + expect(r.querySelector("tx")!.childNodes[0]).toBe(nodes[i]!); + expect(nodes[i]!.data).toBe(data[i]!); + }); + }); + + test("after release the label is added without touching the text node", () => { + const m = mount(env, body); + const t = tx(m, 3).childNodes[0]!; + const d = t.data; + m.view.setBlame(3, "Ann"); + env.flush(); + expect(tx(m, 3).childNodes[0]).toBe(t); + expect(t.data).toBe(d); + expect(blameOf(m.row(3))!.parent).toBe(m.row(3)); + }); + + test("styles.css: .blame is out of the text flow (absolute or sticky)", () => { + const m = /(^|\n)\.blame\s*\{([^}]*)\}/.exec(css); + expect(m).not.toBeNull(); + expect(m![2]).toMatch(/position:\s*(absolute|sticky)/); + }); +}); + +describe("SL02 minor: no duplicate declaration in the macOS titlebar rule", () => { + test("-webkit-user-select appears once", () => { + const head = css.indexOf(':root[data-native="macos"] .titlebar {'); + expect(head).toBeGreaterThan(0); + const block = css.slice(head, css.indexOf("}", head)); + expect(block.split("-webkit-user-select: none").length - 1).toBe(1); + expect(block).toContain("user-select: none"); + }); +}); diff --git a/test/client.codeview.blame2.test.ts b/test/client.codeview.blame2.test.ts new file mode 100644 index 0000000..6bcff99 --- /dev/null +++ b/test/client.codeview.blame2.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, uninstallDom } from "./helpers/fakedom.ts"; +import { loadCodeView, mount, rng, type Env, type Mounted, type N } from "./helpers/selfake.ts"; + +let env: Env; +beforeAll(async () => { + env = await loadCodeView(); +}); +afterAll(() => uninstallDom()); + +const body = Array.from({ length: 300 }, (_, i) => `line ${i} text`).join("\n"); +const blameOf = (r: N) => r.querySelector("blame"); +const shown = (m: Mounted) => m.rows().filter((r) => blameOf(r)).length; +const release = () => { + env.dom.document.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); + env.dom.window.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); +}; + +describe("R-01 a primary press anywhere in the text area holds the label", () => { + test("press on the lines container (blank space beside or below rows)", () => { + const m = mount(env, body); + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: m.linesEl, button: 0 })); + m.view.setBlame(6, "Bob"); + env.flush(); + expect(shown(m)).toBe(0); + release(); + env.flush(); + expect(blameOf(m.row(6))!.textContent).toBe("Bob"); + }); + + test("press on the scroller's blank area", () => { + const m = mount(env, body); + m.scroller.dispatchEvent(new FakeEvent("mousedown", { target: m.scroller, button: 0 })); + m.view.setBlame(6, "Bob"); + env.flush(); + expect(shown(m)).toBe(0); + release(); + env.flush(); + expect(blameOf(m.row(6))!.textContent).toBe("Bob"); + }); + + test("a hold started in blank space does not stick (blur clears it)", () => { + const m = mount(env, body); + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: m.linesEl, button: 0 })); + env.dom.window.dispatchEvent(new FakeEvent("blur")); + m.view.setBlame(6, "Bob"); + env.flush(); + expect(shown(m)).toBe(1); + }); + + test("a right press on blank space does not hold", () => { + const m = mount(env, body); + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: m.linesEl, button: 2 })); + m.view.setBlame(6, "Bob"); + env.flush(); + expect(shown(m)).toBe(1); + }); +}); + +describe("R-02 a non-collapsed model range hides the label", () => { + test("model range present while the DOM selection reads collapsed", () => { + const m = mount(env, body); + m.view.setRange(rng(0, 1, 2, 2)); + env.flush(); + m.sel.isCollapsed = true; + m.view.setBlame(1, "Ann"); + env.flush(); + expect(shown(m)).toBe(0); + }); + + test("the label returns once the model range is dropped", () => { + const m = mount(env, body); + m.view.setRange(rng(0, 1, 2, 2)); + env.flush(); + m.sel.isCollapsed = true; + m.view.setBlame(1, "Ann"); + m.view.setRange(null); + env.flush(); + expect(blameOf(m.row(1))!.textContent).toBe("Ann"); + }); +}); diff --git a/test/client.codeview.blame3.test.ts b/test/client.codeview.blame3.test.ts new file mode 100644 index 0000000..584dca1 --- /dev/null +++ b/test/client.codeview.blame3.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, uninstallDom } from "./helpers/fakedom.ts"; +import { fireSelectionChange, loadCodeView, mount, rng, scrollTo, type Env, type Mounted, type N } from "./helpers/selfake.ts"; + +let env: Env; +beforeAll(async () => { + env = await loadCodeView(); +}); +afterAll(() => uninstallDom()); + +const body = Array.from({ length: 1000 }, (_, i) => `line ${i} text`).join("\n"); +const tx = (m: Mounted, n: number) => m.row(n).querySelector("tx")!; +const blameOf = (r: N) => r.querySelector("blame"); +const release = () => { + env.dom.document.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); + env.dom.window.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); +}; +function ranged() { + const m = mount(env, body); + m.view.setRange(rng(10, 0, 12, 3)); + m.sel.set(tx(m, 10).childNodes[0]!, 0, tx(m, 12).childNodes[0]!, 3); + fireSelectionChange(env); + env.flush(); + return m; +} +const collapseOntoLines = (m: Mounted) => { + m.sel.setBaseAndExtent(m.linesEl as never, 0, m.linesEl as never, 0); + fireSelectionChange(env); + env.flush(); +}; + +describe("R-05 a collapsed DOM selection is not an intent to clear the range", () => { + for (const incl of [false, true]) { + const prep = (m: Mounted) => { + if (!incl) return; + const inner = m.linesEl.contains; + m.linesEl.contains = (n: unknown) => n === m.linesEl || inner(n); + }; + test(`scrolling out of the window (browser collapses onto the lines container) keeps the range (inclusive=${incl})`, () => { + const m = ranged(); + prep(m); + scrollTo(env, m, 2400); + collapseOntoLines(m); + expect(m.view.selection).toEqual(rng(10, 0, 12, 3)); + }); + + test(`scrolling back restores the DOM selection from the kept range (inclusive=${incl})`, () => { + const m = ranged(); + prep(m); + scrollTo(env, m, 2400); + collapseOntoLines(m); + m.calls.length = 0; + scrollTo(env, m, 0); + expect(m.view.selection).toEqual(rng(10, 0, 12, 3)); + const last = m.calls[m.calls.length - 1]!; + expect([last.a, last.f].map((n) => Number(n.closest("ln")!.dataset.n))).toEqual([10, 12]); + expect(last.ao).toBe(0); + expect(last.f).toBe(tx(m, 12).childNodes[1]!.childNodes[0]!); // col 3 sits in the t-k text node + expect(last.fo).toBe(1); + }); + } + + test("a collapsed selection inside a row with no render pending keeps the range", () => { + const m = ranged(); + m.sel.setBaseAndExtent(tx(m, 11).childNodes[0]!, 2, tx(m, 11).childNodes[0]!, 2); + fireSelectionChange(env); + expect(m.view.selection).toEqual(rng(10, 0, 12, 3)); + }); + + test("a plain click on a row clears the range and the blame label returns after mouseup", () => { + const m = ranged(); + m.view.setBlame(30, "Ann"); + env.flush(); + expect(blameOf(m.row(30))).toBeNull(); // hidden while the range exists + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: tx(m, 30).parent, button: 0 })); + expect(m.view.selection).toBeNull(); + m.sel.setBaseAndExtent(tx(m, 30).childNodes[0]!, 1, tx(m, 30).childNodes[0]!, 1); + fireSelectionChange(env); + env.flush(); + expect(m.view.selection).toBeNull(); + expect(blameOf(m.row(30))).toBeNull(); // still held + release(); + env.flush(); + expect(blameOf(m.row(30))!.textContent).toBe("Ann"); + }); +}); diff --git a/test/client.codeview.ctxclick.test.ts b/test/client.codeview.ctxclick.test.ts deleted file mode 100644 index ce1e1ac..0000000 --- a/test/client.codeview.ctxclick.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -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.dispose.test.ts b/test/client.codeview.dispose.test.ts new file mode 100644 index 0000000..eaf5477 --- /dev/null +++ b/test/client.codeview.dispose.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, uninstallDom } from "./helpers/fakedom.ts"; +import { copyEvent, fireSelectionChange, loadCodeView, mount, pressDoc, rng, type Env, type Mounted } from "./helpers/selfake.ts"; + +let env: Env; +beforeAll(async () => { + env = await loadCodeView(); +}); +afterAll(() => uninstallDom()); + +const body = Array.from({ length: 300 }, (_, i) => `line ${i} text`).join("\n"); +const tx = (m: Mounted, n: number) => m.row(n).querySelector("tx")!; +const DOC_EVENTS = ["keydown", "copy", "selectionchange", "mouseup", "mousemove", "mousedown"]; +type Counting = { listenerCount(t: string): number }; +const doc = () => env.dom.document as unknown as Counting; +const win = () => env.dom.window as unknown as Counting; +const counts = () => [...DOC_EVENTS.map((t) => doc().listenerCount(t)), win().listenerCount("blur")]; +const selectAllKey = () => new FakeEvent("keydown", { key: "a", metaKey: true, target: env.dom.document.body }); +/** Gives the view ownership of the shortcuts: a primary press inside its rows. */ +const own = (m: Mounted) => { + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: tx(m, 1).parent, button: 0 })); + env.dom.document.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); +}; + +describe("F-1 dispose releases every global hook", () => { + test("dispose removes all document and window listeners the view added", () => { + const before = counts(); + const m = mount(env, body); + const during = counts(); + expect(during.every((c, i) => c > before[i]!)).toBe(true); + m.view.dispose(); + expect(counts()).toEqual(before); + }); + + test("a disposed view no longer reacts to selectionchange", () => { + const m = mount(env, body); + m.view.setRange(rng(10, 0, 12, 3)); + m.view.dispose(); + m.sel.set(tx(m, 1).childNodes[0]!, 0, tx(m, 2).childNodes[0]!, 1); + fireSelectionChange(env); + expect(m.view.selection).toEqual(rng(10, 0, 12, 3)); + }); + + test("a disposed view no longer takes Cmd+A or copy", () => { + const m = mount(env, body); + own(m); + m.view.dispose(); + const k = selectAllKey(); + env.dom.document.dispatchEvent(k); + expect(k.defaultPrevented).toBe(false); + expect(m.view.selection).toBeNull(); + m.view.setRange(rng(0, 0, 2, 3)); + const { e, set } = copyEvent(); + env.dom.document.dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + }); + + test("dispose cancels a pending frame", () => { + const m = mount(env, body); + m.view.setText(body.replace("line 1 ", "LINE 1 "), false); + const html = m.html(); + m.view.schedule(); + m.view.dispose(); + env.flush(); + expect(m.html()).toBe(html); + expect(m.view.frame).toBe(0); + }); + + test("dispose twice is harmless", () => { + const m = mount(env, body); + m.view.dispose(); + expect(() => m.view.dispose()).not.toThrow(); + }); +}); + +describe("F-2 a detached or hidden view does not own Cmd+A and copy", () => { + test("a visible, connected view owns Cmd+A (control)", () => { + const m = mount(env, body); + own(m); + const k = selectAllKey(); + env.dom.document.dispatchEvent(k); + expect(k.defaultPrevented).toBe(true); + expect(m.view.selection).not.toBeNull(); + m.view.dispose(); + }); + + test("a detached view does not preventDefault Cmd+A", () => { + const m = mount(env, body); + own(m); + (m.view.el as { parent: { children: unknown[] } | null; remove?: () => void }).parent!.children.splice(0); + (m.view.el as { parent: unknown }).parent = null; + expect(m.view.el.isConnected).toBe(false); + const k = selectAllKey(); + env.dom.document.dispatchEvent(k); + expect(k.defaultPrevented).toBe(false); + expect(m.view.selection).toBeNull(); + m.view.dispose(); + }); + + test("a hidden view does not preventDefault Cmd+A", () => { + const m = mount(env, body); + own(m); + m.view.setVisible(false); + const k = selectAllKey(); + env.dom.document.dispatchEvent(k); + expect(k.defaultPrevented).toBe(false); + expect(m.view.selection).toBeNull(); + m.view.dispose(); + }); + + test("a hidden view does not intercept copy when there is no live selection", () => { + const m = mount(env, body); + own(m); + m.view.setRange(rng(0, 0, 2, 3)); + m.view.setVisible(false); + const { e, set } = copyEvent(); + env.dom.document.dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + m.view.dispose(); + }); + + test("showing the view again restores ownership", () => { + const m = mount(env, body); + own(m); + m.view.setVisible(false); + m.view.setVisible(true); + const k = selectAllKey(); + env.dom.document.dispatchEvent(k); + expect(k.defaultPrevented).toBe(true); + m.view.dispose(); + }); + + test("a press elsewhere then Cmd+A stays with the other target (hidden view released current)", () => { + const m = mount(env, body); + own(m); + m.view.setVisible(false); + pressDoc(env, env.dom.document.body); + const k = selectAllKey(); + env.dom.document.dispatchEvent(k); + expect(k.defaultPrevented).toBe(false); + m.view.dispose(); + }); +}); diff --git a/test/client.codeview.hygiene.test.ts b/test/client.codeview.hygiene.test.ts deleted file mode 100644 index 51353e8..0000000 --- a/test/client.codeview.hygiene.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index 687ebaa..0000000 --- a/test/client.codeview.hygiene2.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -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 index 7ebb6bb..216a126 100644 --- a/test/client.codeview.selection.test.ts +++ b/test/client.codeview.selection.test.ts @@ -1,194 +1,295 @@ 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)[] = []; +import { FakeEvent, uninstallDom } from "./helpers/fakedom.ts"; +import { copyEvent, el, fireSelectionChange, loadCodeView, mount, pressDoc, rng, rowOf, scrollTo, text, type Env } from "./helpers/selfake.ts"; +let env: Env; 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 {} + env = await loadCodeView(); }); afterAll(() => uninstallDom()); - beforeEach(() => { - expect(typeof C?.CodeView).toBe("function"); + expect(typeof env.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 small = ["abcdefgh", "ijklmnop", "qrstuvwx"].join("\n"); const keyA = (init: Record) => new FakeEvent("keydown", { key: "a", ...init }); +const outside = () => el("sidebar", text("elsewhere")); -describe("CodeView model range survives re-render (AC-1)", () => { +describe("model copy (AC-2, AC-3)", () => { 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)); + const m = mount(env, small); + m.view.setRange(rng(2, 3, 0, 1)); + expect(m.view.selection).toEqual(rng(0, 1, 2, 3)); + }); + + test("copy writes the full model text of a range reaching far outside the window, and is prevented", () => { + const m = mount(env, body); + m.view.setRange(rng(0, 0, 900, 4)); + const want = body.split("\n").slice(0, 901); + want[900] = want[900]!.slice(0, 4); + const { e, set } = copyEvent(); + m.scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(set).toEqual([["text/plain", want.join("\n")]]); + const t = set[0]![1]; + expect(t).not.toContain("more characters"); + expect(t.split("\n")[5]).toBe("line 5 text"); // no gutter number prefix + }); + + test("a partial range copies tabs and newlines exactly", () => { + const m = mount(env, "a\n\tb\nc"); + m.view.setRange(rng(0, 1, 2, 1)); + const { e, set } = copyEvent(); + m.scroller.dispatchEvent(e); + expect(set).toEqual([["text/plain", "\n\tb\nc"]]); + }); + + test("the blame label text never reaches the copy", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 8)); + m.view.setBlame(1, "BLAMEMARK Someone"); + env.flush(); + const { e, set } = copyEvent(); + m.scroller.dispatchEvent(e); + expect(set).toEqual([["text/plain", small]]); + }); + + test("a truncated long line copies in full and the label is created as .trunc", () => { + const long = "x".repeat(5000); + const src = `short\n${long}\nend`; + const m = mount(env, src); + m.view.setRange(rng(0, 2, 2, 2)); + env.flush(); + const { e, set } = copyEvent(); + m.scroller.dispatchEvent(e); + expect(set).toEqual([["text/plain", `ort\n${long}\nen`]]); + expect(set[0]![1]).not.toContain("more characters"); + expect(m.html()).toContain(''); }); + test("selectedText mirrors the model text; empty without a range", () => { + const m = mount(env, small); + expect(m.view.selectedText()).toBe(""); + m.view.setRange(rng(0, 2, 1, 3)); + expect(m.view.selectedText()).toBe("cdefgh\nijk"); + }); + + test("copy without a range is left to the browser", () => { + const m = mount(env, small); + const { e, set } = copyEvent(); + m.scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + }); +}); + +describe("range survives re-renders (AC-4)", () => { 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); + const m = mount(env, body); + m.view.setRange(rng(10, 2, 300, 4)); + env.flush(); + expect(m.calls.length).toBeGreaterThan(0); + scrollTo(env, m, 4800); // window ~260..390: line 10 is out, 300 is in + expect(m.view.selection).toEqual(rng(10, 2, 300, 4)); + let last = m.calls[m.calls.length - 1]!; + expect(rowOf(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(rowOf(last.f)).toBe(300); + expect(last.fo).toBe(2); + scrollTo(env, m, 0); + expect(m.view.selection).toEqual(rng(10, 2, 300, 4)); + last = m.calls[m.calls.length - 1]!; + expect(rowOf(last.a)).toBe(10); expect(last.ao).toBe(2); - expect(rowN(last.f)).toBeLessThan(300); // 300 is now past the window + expect(rowOf(last.f)).toBeLessThan(300); // 300 is now past the window; only the visible part is applied }); 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)); + const m = mount(env, body); + m.view.setRange(rng(500, 0, 510, 3)); + env.flush(); + const n = m.calls.length; + scrollTo(env, m, 0); + expect(m.calls.length).toBe(n); + expect(m.view.selection).toEqual(rng(500, 0, 510, 3)); + }); + + test("full re-renders re-apply the same DOM positions and leave the model unchanged", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 2, 2, 5)); + env.flush(); + const before = m.calls.length; + m.view.schedule(); // a full re-render of a stale window + env.flush(); + expect(m.calls.length).toBeGreaterThan(before); + const last = m.calls[m.calls.length - 1]!; + expect([rowOf(last.a), last.ao, rowOf(last.f), last.fo]).toEqual([0, 2, 2, 3]); + expect(m.view.selection).toEqual(rng(0, 2, 2, 5)); + }); + + test("a find change re-applies the selection and keeps the model", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 1, 2, 4)); + env.flush(); + const before = m.calls.length; + m.view.findInput.value = "ijk"; + m.view.findInput.dispatchEvent(new FakeEvent("input")); + env.flush(); + expect(m.calls.length).toBeGreaterThan(before); + expect(m.view.selection).toEqual(rng(0, 1, 2, 4)); + }); + + test("the restore's own selectionchange echo does not shrink the full range", () => { + const m = mount(env, body); + m.view.setRange(rng(10, 2, 300, 4)); + scrollTo(env, m, 4800); // start is clamped to the first rendered row + fireSelectionChange(env); // the browser echoes the clamped selection back + expect(m.view.selection).toEqual(rng(10, 2, 300, 4)); + scrollTo(env, m, 0); + fireSelectionChange(env); + expect(m.view.selection).toEqual(rng(10, 2, 300, 4)); + }); + + test("a user DOM selection inside the rows becomes the model range, bottom-to-top included", () => { + const m = mount(env, small); + const tx = (n: number) => m.row(n).querySelector("tx")!; + // anchor on row 2 at col 6 (offset 1 in the plain tail text node), focus on row 0 at col 1 + m.sel.set(tx(2).childNodes[2]!, 1, tx(0).childNodes[0]!, 1); + fireSelectionChange(env); + expect(m.view.selection).toEqual(rng(0, 1, 2, 6)); + expect(m.view.selectedText()).toBe("bcdefgh\nijklmnop\nqrstuv"); + }); + + test("a selection whose endpoints sit on the lines container (rows replaced) keeps the range", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + const lc = m.chrome.querySelector("code-lines")!; + m.sel.set(lc, 0, lc, 0); + fireSelectionChange(env); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); + }); +}); + +describe("stale ranges are dropped (AC-5)", () => { + const ranged = () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + return m; + }; + + test("a non-collapsed selection outside the whole view drops the range and copy is not intercepted", () => { + const m = ranged(); + const o = outside(); + m.sel.set(o, 0, o, 1); + fireSelectionChange(env); + expect(m.view.selection).toBeNull(); + const { e, set } = copyEvent(); + m.scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + }); + + test("endpoints in the find bar or scroller (inside the element, outside the rows) keep the range", () => { + const m = ranged(); + m.sel.set(m.chrome.querySelector("find-input")!, 0, m.chrome.querySelector("code-scroller")!, 0); + fireSelectionChange(env); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); + }); + + test("a collapsed click on a row (primary press inside the lines) drops the range", () => { + const m = ranged(); + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: m.row(1).querySelector("tx")!, button: 0 })); + expect(m.view.selection).toBeNull(); + }); + + test("a primary press outside the whole view drops the range", () => { + const m = ranged(); + pressDoc(env, outside()); + expect(m.view.selection).toBeNull(); + }); + + test("a primary press on the find bar, scroller or scrollbar keeps the range", () => { + const m = ranged(); + pressDoc(env, m.chrome.querySelector("find-input")!); + pressDoc(env, m.chrome.querySelector("code-scroller")!); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); + }); + + test("a press inside the rows is left to the row handler, not the document hook", () => { + const m = ranged(); + pressDoc(env, m.row(1).querySelector("g")!); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); }); }); -describe("Cmd/Ctrl+A selects the whole file (AC-2)", () => { +describe("Cmd/Ctrl+A selects the whole file (AC-6)", () => { for (const mod of ["metaKey", "ctrlKey"]) { - test(`${mod}+A selects everything and is prevented`, () => { - const { view, scroller } = mount("alpha\n\tbeta\ngamma"); + test(`${mod}+A is prevented, selects everything and copy yields the whole file`, () => { + const m = mount(env, "alpha\n\tbeta\ngamma"); const e = keyA({ [mod]: true }); - scroller.dispatchEvent(e); + m.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"); + expect(m.view.selection).toEqual(rng(0, 0, 2, 5)); + const c = copyEvent(); + m.scroller.dispatchEvent(c.e); + expect(c.set).toEqual([["text/plain", "alpha\n\tbeta\ngamma"]]); }); } - test("plain A is left alone", () => { - const { view, scroller } = mount("alpha\nbeta"); - const e = keyA({}); - scroller.dispatchEvent(e); + test("a 1000-line file is selected whole although only a window is rendered", () => { + const m = mount(env, body); + m.scroller.dispatchEvent(keyA({ metaKey: true })); + expect(m.view.selection).toEqual(rng(0, 0, 999, "line 999 text".length)); + expect(m.view.selectedText()).toBe(body); + }); + test("plain A, Shift+Ctrl+A and Alt+Ctrl+A are left alone", () => { + const m = mount(env, "alpha\nbeta"); + for (const init of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }]) { + const e = keyA(init); + m.scroller.dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + } + expect(m.view.selection).toBeNull(); + }); + test("Cmd+A inside the find input stays native", () => { + const m = mount(env, "alpha\nbeta"); + const e = keyA({ metaKey: true }); + m.view.findInput.dispatchEvent(e); expect(e.defaultPrevented).toBe(false); - expect(view.selection).toBeNull(); + expect(m.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(""); + const m = mount(env, "a\nbb\nccc"); + m.view.selectAll(); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); + expect(m.view.selectedText()).toBe("a\nbb\nccc"); }); }); -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"); +describe("right / Ctrl+click keep the selection (AC-7)", () => { + const press = (init: Record) => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: m.row(1).querySelector("tx")!, button: 0, ...init })); + return m; + }; + test("secondary press inside the view keeps the range", () => { + expect(press({ button: 2 }).view.selection).toEqual(rng(0, 0, 2, 3)); }); - - 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("Ctrl+click inside the view keeps the range", () => { + expect(press({ ctrlKey: true }).view.selection).toEqual(rng(0, 0, 2, 3)); }); - - 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("primary click inside clears it", () => { + expect(press({}).view.selection).toBeNull(); }); - - 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/); + test("secondary or Ctrl press elsewhere on the page keeps the range too", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + pressDoc(env, outside(), { ctrlKey: true }); + pressDoc(env, outside(), { button: 2 }); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); + }); + test("right-click on a line still makes it the active line", () => { + expect(press({ button: 2 }).view.activeLine).toBe(1); }); }); diff --git a/test/client.codeview.selection2.test.ts b/test/client.codeview.selection2.test.ts index ca85e46..5651177 100644 --- a/test/client.codeview.selection2.test.ts +++ b/test/client.codeview.selection2.test.ts @@ -1,197 +1,187 @@ 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); +import { FakeEvent, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; +import { copyEvent, el, fireSelectionChange, loadCodeView, mount, rng, rowOf, text, type Env, type Mounted } from "./helpers/selfake.ts"; -let C: typeof import("../src/client/codeview.ts"); -let dom: Dom; -let raf: (() => void)[] = []; +let env: Env; 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"); + env = await loadCodeView(); }); 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 body = Array.from({ length: 1000 }, (_, i) => `line ${i} text`).join("\n"); +const small = ["abcdefgh", "ijklmnop", "qrstuvwx"].join("\n"); +const tx = (m: Mounted, n: number) => m.row(n).querySelector("tx")!; +const outside = () => el("sidebar", text("elsewhere")); +const doc = () => env.dom.document; +const input = () => env.dom.document.createElement("input") as FakeElement; +const keyA = (init: Record = {}) => new FakeEvent("keydown", { key: "a", metaKey: true, ...init }); +const pressRow = (m: Mounted, n: number, init: Record = {}) => + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: tx(m, n), button: 0, ...init })); + +/** Bottom-to-top drag: anchor row 50 col 6, focus row 10 col 1. */ +function dragUp(m: Mounted) { + m.sel.set(tx(m, 50).childNodes[2]!, 1, tx(m, 10).childNodes[0]!, 1); + fireSelectionChange(env); } -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("R-02 the selection keeps its direction across a re-render", () => { + test("a bottom-to-top selection is restored with the anchor on the lower row", () => { + const m = mount(env, body); + dragUp(m); + expect(m.view.selection).toEqual(rng(10, 1, 50, 6)); + const before = m.calls.length; + m.view.schedule(); + env.flush(); + expect(m.calls.length).toBeGreaterThan(before); + const last = m.calls[m.calls.length - 1]!; + expect([rowOf(last.a), last.ao, rowOf(last.f), last.fo]).toEqual([50, 1, 10, 1]); + }); + + test("a top-to-bottom selection keeps its anchor on the upper row", () => { + const m = mount(env, body); + m.sel.set(tx(m, 10).childNodes[0]!, 1, tx(m, 50).childNodes[2]!, 1); + fireSelectionChange(env); + m.view.schedule(); + env.flush(); + const last = m.calls[m.calls.length - 1]!; + expect([rowOf(last.a), rowOf(last.f)]).toEqual([10, 50]); + }); + + test("during a drag (primary held) a re-render that destroys the rows still restores anchor and focus as given", () => { + const m = mount(env, body); + pressRow(m, 50); + env.flush(); + dragUp(m); + m.view.schedule(); // a full re-render of a stale window + env.flush(); + const last = m.calls[m.calls.length - 1]!; + expect([rowOf(last.a), last.ao, rowOf(last.f), last.fo]).toEqual([50, 1, 10, 1]); + }); + + test("while the primary button is held and the anchor row survives, the DOM selection is not touched", () => { + const m = mount(env, body); + pressRow(m, 50); + env.flush(); + dragUp(m); + // a render that patches in place: the rows (and the anchor) stay attached + Object.defineProperty(m.linesEl, "innerHTML", { configurable: true, get: () => "", set: () => {} }); + const before = m.calls.length; + m.view.schedule(); + env.flush(); + expect(m.calls.length).toBe(before); + expect(m.view.selection).toEqual(rng(10, 1, 50, 6)); }); }); -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(); +describe("R-01 copy and select-all work without the scroller holding focus", () => { + test("the scroller can take focus (tabindex -1)", () => { + const m = mount(env, small); + expect(m.scroller.getAttribute("tabindex")).toBe("-1"); + }); + + test("a copy event on the document is answered with the model text", () => { + const m = mount(env, body); + m.view.setRange(rng(0, 0, 900, 4)); + env.flush(); + const { e, set } = copyEvent(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(set.length).toBe(1); + expect(set[0]![0]).toBe("text/plain"); + expect(set[0]![1].split("\n").length).toBe(901); + }); + + test("a copy aimed at an input (find bar, palette) is left alone even with a model range", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + const { e, set } = copyEvent(); + e.target = input(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + }); + + test("a copy while the DOM selection is elsewhere (outside the view) is left alone", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + const o = outside(); + m.sel.set(o, 0, o, 1); + const { e, set } = copyEvent(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + }); + + test("a copy without any range or selection is left alone", () => { + mount(env, small); + const { e, set } = copyEvent(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(set).toEqual([]); + }); + + test("Cmd+A on the body after interacting with the view selects the whole file", () => { + const m = mount(env, "alpha\n\tbeta\ngamma"); + pressRow(m, 1); + const e = keyA(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(m.view.selection).toEqual(rng(0, 0, 2, 5)); + expect(m.view.selectedText()).toBe("alpha\n\tbeta\ngamma"); + }); + + test("Ctrl+A works the same", () => { + const m = mount(env, small); + pressRow(m, 0); + const e = keyA({ metaKey: false, ctrlKey: true }); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(m.view.selection).toEqual(rng(0, 0, 2, 8)); + }); + + test("Cmd+A before any interaction with the view is left alone", () => { + const m = mount(env, small); + const e = keyA(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(m.view.selection).toBeNull(); + }); + + test("Cmd+A after the last press landed outside the view is left alone", () => { + const m = mount(env, small); + pressRow(m, 1); + doc().dispatchEvent(new FakeEvent("mousedown", { target: outside(), button: 0 })); + const e = keyA(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(m.view.selection).toBeNull(); + }); + + test("Cmd+A typed into an input stays native even right after clicking the view", () => { + const m = mount(env, small); + pressRow(m, 1); + const e = keyA(); + e.target = input(); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(m.view.selection).toBeNull(); + }); +}); + +describe("R-03 one endpoint outside the rows follows the same outside-the-view rule", () => { + test("anchor in the rows and focus outside the whole view drops the range", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + m.sel.set(tx(m, 1).childNodes[0]!, 1, outside(), 0); + fireSelectionChange(env); + expect(m.view.selection).toBeNull(); + }); + + test("anchor in the rows and focus on the lines container (still inside the view) keeps the range", () => { + const m = mount(env, small); + m.view.setRange(rng(0, 0, 2, 3)); + m.sel.set(tx(m, 1).childNodes[0]!, 1, m.chrome.querySelector("code-lines")!, 0); + fireSelectionChange(env); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); }); }); diff --git a/test/client.codeview.selection3.test.ts b/test/client.codeview.selection3.test.ts index 7b215a0..d6d696b 100644 --- a/test/client.codeview.selection3.test.ts +++ b/test/client.codeview.selection3.test.ts @@ -1,155 +1,173 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { FakeEvent, installDom, uninstallDom, type Dom, type FakeElement } from "./helpers/fakedom.ts"; +import { FakeEvent, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; +import { copyEvent, el, fireSelectionChange, loadCodeView, mount, pressDoc, rng, rowOf, scrollTo, text, type Env, type Mounted } from "./helpers/selfake.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)[] = []; +let env: Env; 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"); + env = await loadCodeView(); }); 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")); +const doc = () => env.dom.document; +const tx = (m: Mounted, n: number) => m.row(n).querySelector("tx")!; +const keyA = (init: Record = {}) => new FakeEvent("keydown", { key: "a", metaKey: true, ...init }); -/** 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; +/** Primary press in a row, like the browser: the mousedown also focuses the scroller. */ +function pressIn(m: Mounted, n: number) { + m.linesEl.dispatchEvent(new FakeEvent("mousedown", { target: tx(m, n).parent, button: 0 })); + pressDoc(env, tx(m, n).parent, { button: 0 }); + m.scroller.focus(); +} +/** Moves keyboard focus to an element outside the view (explorer tree, palette, terminal) without a press. */ +function focusElsewhere(tag = "div") { + const e = doc().createElement(tag) as FakeElement; + doc().body.append(e); + e.focus(); + return e; } -const scrollTo = (scroller: FakeElement, top: number) => { - scroller.scrollTop = top; - scroller.dispatchEvent(new FakeEvent("scroll")); - flush(); -}; +/** DOM Node.contains is inclusive: contains(self) is true. */ +function inclusive(m: Mounted) { + const inner = m.linesEl.contains; + m.linesEl.contains = (n: unknown) => n === m.linesEl || inner(n); +} + +describe("R-04 a held drag whose anchor row is destroyed is restored", () => { + test("selection collapsed onto the lines container mid-drag is restored with anchor and focus", () => { + const m = mount(env, body); + inclusive(m); + scrollTo(env, m, 2000); + pressIn(m, 150); + m.sel.set(tx(m, 150).childNodes[0]!, 2, tx(m, 100).childNodes[0]!, 3); + fireSelectionChange(env); + const before = m.calls.length; + m.sel.setBaseAndExtent(m.linesEl as never, 0, m.linesEl as never, 0); + m.view.schedule(); // a full re-render of a stale window + env.flush(); + expect(m.calls.length).toBeGreaterThan(before); + const last = m.calls[m.calls.length - 1]!; + expect(rowOf(last.a)).toBe(150); + expect(rowOf(last.f)).toBeLessThan(150); + }); + + test("anchor still inside a rendered row keeps the browser in charge (no restore)", () => { + const m = mount(env, body); + inclusive(m); + pressIn(m, 50); + m.sel.set(tx(m, 50).childNodes[2]!, 1, tx(m, 10).childNodes[0]!, 1); + fireSelectionChange(env); + Object.defineProperty(m.linesEl, "innerHTML", { configurable: true, get: () => "", set: () => {} }); + const before = m.calls.length; + m.view.schedule(); + env.flush(); + expect(m.calls.length).toBe(before); + }); + + test("anchor on the lines container with the primary button held is restored even when the window did not move", () => { + const m = mount(env, body); + inclusive(m); + pressIn(m, 20); + m.sel.set(tx(m, 20).childNodes[0]!, 2, tx(m, 5).childNodes[0]!, 1); + fireSelectionChange(env); + m.sel.setBaseAndExtent(m.linesEl as never, 0, m.linesEl as never, 0); + const before = m.calls.length; + m.view.schedule(); + env.flush(); + expect(m.calls.length).toBeGreaterThan(before); + const last = m.calls[m.calls.length - 1]!; + expect([rowOf(last.a), rowOf(last.f)]).toEqual([20, 5]); + }); +}); + +describe("R-05 the view only owns the keyboard while focus is inside it", () => { + test("Cmd+A with focus moved to the explorer is left native", () => { + const m = mount(env, body); + pressIn(m, 3); + m.view.setRange(rng(0, 0, 2, 3)); + const tree = focusElsewhere(); + const e = keyA({ target: tree }); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + expect(m.view.selection).toEqual(rng(0, 0, 2, 3)); + }); + + test("copy with focus moved to the explorer is left native", () => { + const m = mount(env, body); + pressIn(m, 3); + m.view.setRange(rng(0, 0, 2, 3)); + const tree = focusElsewhere(); + const c = copyEvent(); + c.e.target = tree; + doc().dispatchEvent(c.e); + expect(c.e.defaultPrevented).toBe(false); + expect(c.set).toEqual([]); + }); + + test("Cmd+A and copy with focus in a terminal textarea or palette input stay native", () => { + const m = mount(env, body); + pressIn(m, 3); + m.view.setRange(rng(0, 0, 2, 3)); + for (const tag of ["textarea", "input"]) { + const t = focusElsewhere(tag); + const e = keyA({ target: t }); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); + const c = copyEvent(); + c.e.target = t; + doc().dispatchEvent(c.e); + expect(c.e.defaultPrevented).toBe(false); + } + }); + + test("Cmd+A with focus inside the view (scroller) selects the whole file", () => { + const m = mount(env, "alpha\nbeta\ngamma"); + pressIn(m, 1); + const e = keyA({ target: m.scroller }); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(m.view.selection).toEqual(rng(0, 0, 2, 5)); + }); + + test("focus returning to the view after a detour makes it own the keyboard again", () => { + const m = mount(env, "alpha\nbeta\ngamma"); + pressIn(m, 1); + focusElsewhere(); + m.scroller.focus(); + const e = keyA({ target: m.scroller }); + doc().dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + }); + + test("copy with focus inside the view and a model range is answered with the model text", () => { + const m = mount(env, body); + pressIn(m, 3); + m.view.setRange(rng(0, 0, 2, 3)); + const c = copyEvent(); + c.e.target = m.scroller; + doc().dispatchEvent(c.e); + expect(c.e.defaultPrevented).toBe(true); + expect(c.set[0]![1].split("\n").length).toBe(3); + }); -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("copy with the DOM selection inside the view is answered even when focus is elsewhere", () => { + const m = mount(env, body); + m.view.setRange(rng(0, 0, 2, 3)); + m.sel.set(tx(m, 0).childNodes[0]!, 0, tx(m, 2).childNodes[0]!, 1); + focusElsewhere(); + const c = copyEvent(); + doc().dispatchEvent(c.e); + expect(c.e.defaultPrevented).toBe(true); + expect(c.set[0]![0]).toBe("text/plain"); }); - 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; + test("copy with a DOM selection elsewhere and focus outside the view is left native", () => { + const m = mount(env, body); + m.view.setRange(rng(0, 0, 2, 3)); + const o = el("sidebar", text("elsewhere")); + m.sel.set(o, 0, o, 1); + focusElsewhere(); + const c = copyEvent(); + doc().dispatchEvent(c.e); + expect(c.e.defaultPrevented).toBe(false); }); }); diff --git a/test/client.copyimage.entry.test.ts b/test/client.copyimage.entry.test.ts deleted file mode 100644 index 7b05b77..0000000 --- a/test/client.copyimage.entry.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 81360c0..0000000 --- a/test/client.copyimage.entry2.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -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 deleted file mode 100644 index 4ade2a6..0000000 --- a/test/client.copyimage.gesture.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index fb4c574..0000000 --- a/test/client.copyimage.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index 97a946c..0000000 --- a/test/client.imagelayout.clamp.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 82f1c03..0000000 --- a/test/client.imagelayout.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 deleted file mode 100644 index ff82070..0000000 --- a/test/client.keys.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 51d9a39..0000000 --- a/test/client.mdrobust.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index f5de406..0000000 --- a/test/client.mdrobust2.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 2d8e2ef..0000000 --- a/test/client.mdsnap.fix.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index 9592e93..0000000 --- a/test/client.mdsnap.prune.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index dd86cdb..0000000 --- a/test/client.mdsnap.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -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("