diff --git a/dom/directives.ts b/dom/directives.ts index 7baf716..0b472cd 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -2238,6 +2238,571 @@ export function teardownRegionSelectForRoot(rootElement: Element): void { } } +// ── text-select ──────────────────────────────────────────────────────────── +// lvt-fx:text-select="" on a container captures a NATIVE text selection +// (window.getSelection) over line-bearing children and dispatches a CHARACTER +// range to the server, so a reviewer can comment on a word / phrase / multi-line +// span — not just whole lines. Unlike area/region-select (draw a box, hit-test +// rects), it reads the browser's own selection, so it works with mouse, touch, +// AND keyboard from one code path. +// +// Contract (kept generic — no host-app class names): +// - each line is an element carrying `data-line` (1-based source line) and, +// optionally, `data-side="old"` (a diff's deleted side; anything else is the +// "new" side) — the SAME markers region-select's data-surface="code" reads. +// - within a line, the selectable text lives in a descendant marked +// `data-line-text` (so a gutter / line-number prefix is excluded from the +// column count); if absent, the line element itself is the text container. +// +// Offsets are RUNE counts (Unicode code points) into the text container's +// textContent, which the host must keep equal to the raw source line so the +// server can re-locate the span by splitting the raw line at [fromCol, toCol). +// +// Trigger: a single debounced document `selectionchange` — uniform across +// mouse / touch / keyboard (pointerup/keyup miss touch-handle drags and +// keyboard selection). Non-collapsed + settled selection inside the host → +// show a floating "Comment" button at the selection; collapsed → hide it. +interface TextSelectEntry { + action: string; + cleanup: () => void; + updateSend: (send: AreaSelectSendFn) => void; + // sync re-draws the block caret after each render (morphdom wipes the overlay + // and the is-cursor line may have moved). + sync: () => void; +} + +const textSelectArmed = new Map(); + +// A character range within (or across) source lines. side undefined == "new". +type TextRange = { + fromLine: number; + fromCol: number; + toLine: number; + toCol: number; + side?: string; + text: string; +}; + +const TEXT_SELECT_BUTTON_ATTR = "data-lvt-text-select-button"; +const TEXT_SELECT_DEBOUNCE_MS = 150; + +// runeLen counts Unicode code points, not UTF-16 code units, so it matches +// Go's []rune length: a surrogate-pair char (e.g. an emoji) is one column on +// both the client and the server. +function runeLen(s: string): number { + return [...s].length; +} + +// lineTextContainer walks up from a selection-boundary node to its +// `[data-line]` row and returns the row plus its text container (the +// `[data-line-text]` descendant, or the row itself). Returns null when the +// boundary is outside the host, outside any line, or inside a line but outside +// its text container (e.g. a drag that started in the gutter) — the caller then +// bails rather than compute a column against the wrong element. +function lineTextContainer( + node: Node | null, + host: Element +): { line: Element; content: Element } | null { + const start: Element | null = + node && node.nodeType === Node.TEXT_NODE + ? node.parentElement + : (node as Element | null); + if (!start || !host.contains(start)) return null; + const line = start.closest("[data-line]"); + if (!line || !host.contains(line)) return null; + const content = line.querySelector("[data-line-text]") ?? line; + // `contains` is reflexive (an element contains itself), so this also holds + // when the boundary sits directly on the content element. + if (!content.contains(start)) return null; + return { line, content }; +} + +// colWithin returns the rune offset of (node, offset) from the start of +// content, or -1 if the boundary is not inside content. +function colWithin(content: Element, node: Node, offset: number): number { + const r = document.createRange(); + r.selectNodeContents(content); + try { + r.setEnd(node, offset); + } catch { + return -1; + } + return runeLen(r.toString()); +} + +// textOffsetsFromSelection resolves a native selection to a source character +// range, or null when it can't be represented (collapsed, outside the host, +// or crossing the diff old/new boundary). Exported so unit tests exercise the +// real resolver rather than a copy. +export function textOffsetsFromSelection( + sel: Selection | null, + host: Element +): TextRange | null { + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null; + const range = sel.getRangeAt(0); // normalized: start precedes end in doc order + const startCtx = lineTextContainer(range.startContainer, host); + const endCtx = lineTextContainer(range.endContainer, host); + if (!startCtx || !endCtx) return null; + const startRow = readCodeRange(startCtx.line); + const endRow = readCodeRange(endCtx.line); + if (!startRow || !endRow) return null; + // A diff's old and new sides are separate content streams; a single + // character range can't span them. (ctx + add lines are both "new", so only + // a del/new boundary crosses — a rare drag, safe to drop.) + if ((startRow.side ?? "new") !== (endRow.side ?? "new")) return null; + const fromCol = colWithin(startCtx.content, range.startContainer, range.startOffset); + const toCol = colWithin(endCtx.content, range.endContainer, range.endOffset); + if (fromCol < 0 || toCol < 0) return null; + const text = range.toString(); + if (text.length === 0) return null; + return { + fromLine: startRow.from, + fromCol, + toLine: endRow.from, + toCol, + side: startRow.side, + text, + }; +} + +// selectionInsideHost reports whether the current selection's common ancestor +// is within host (so a selection that starts in the diff but ends in a comment +// card, or lives elsewhere on the page, is ignored). +function selectionInsideHost(sel: Selection | null, host: Element): boolean { + if (!sel || sel.rangeCount === 0) return false; + return host.contains(sel.getRangeAt(0).commonAncestorContainer); +} + +function attachTextSelect( + el: HTMLElement, + action: string, + initialSend: AreaSelectSendFn +): TextSelectEntry { + let send = initialSend; + let button: HTMLButtonElement | null = null; + let debounce: ReturnType | null = null; + // Keyboard block caret — a client-only visual overlay over one character. + // Its LINE follows the server's is-cursor line (Up/Down move it via the + // existing CursorUp/Down); its COLUMN is client-owned (Left/Right). Selection + // (Shift+Arrow) extends from the caret via getSelection().modify() on the + // non-editable text — no contenteditable needed. + let caretEl: HTMLElement | null = null; + let caretCol = 0; // current rune column of the caret + let goalCol = 0; // remembered column for vertical (line) moves + let selecting = false; // a Shift-selection is in progress (caret hidden) + let lastLine: Element | null = null; // detect line changes to restore goalCol + + const hideButton = () => { + if (button) { + button.remove(); + button = null; + } + }; + + // ── caret geometry helpers ── + const runesOf = (s: string): string[] => [...s]; + const lineContentOf = (line: Element): HTMLElement => + line.querySelector("[data-line-text]") ?? (line as HTMLElement); + const lineRuneLen = (content: Element): number => + runesOf(content.textContent ?? "").length; + + // activeCursorLine is the line the caret sits on: the server is-cursor line + // when present, else the first line — so a caret is visible from page load. + const activeCursorLine = (): Element | null => + el.querySelector("[data-line].is-cursor") ?? el.querySelector("[data-line]"); + + // nodePointAtRune maps a rune column within content to a DOM (node, offset) + // point (offset is UTF-16, as the DOM requires), clamped to the content end. + const nodePointAtRune = ( + content: Element, + col: number + ): { node: Node; offset: number } => { + const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); + let acc = 0; + let last: Text | null = null; + let n: Node | null; + while ((n = walker.nextNode())) { + const t = n as Text; + last = t; + const runes = runesOf(t.data); + if (acc + runes.length >= col) { + return { node: t, offset: runes.slice(0, col - acc).join("").length }; + } + acc += runes.length; + } + return last ? { node: last, offset: last.length } : { node: content, offset: 0 }; + }; + + // caretCharRect is the viewport rect of the character cell at col (the block + // the caret paints over), or the whole (empty) content box for a blank line. + const caretCharRect = (content: Element, col: number, len: number): DOMRect | null => { + const r = document.createRange(); + if (len === 0) { + // Empty line: no character to sit on — a collapsed range rect can be + // zero-height, so use the content box and paint a thin bar at its start. + const cr = content.getBoundingClientRect(); + return new DOMRect(cr.left, cr.top, 2, cr.height || 16); + } + const a = nodePointAtRune(content, col); + const b = nodePointAtRune(content, col + 1); + try { + r.setStart(a.node, a.offset); + r.setEnd(b.node, b.offset); + } catch { + return null; + } + const rects = r.getClientRects(); + return rects.length ? rects[0] : r.getBoundingClientRect(); + }; + + const removeCaret = () => { + if (caretEl) { + caretEl.remove(); + caretEl = null; + } + }; + + // renderCaret (re)draws the block caret over the character at (active line, + // caretCol). Called on every render (sync) and after each horizontal move. + // Hidden while a selection is in progress — the native highlight shows then. + const renderCaret = () => { + const line = activeCursorLine(); + if (!line || selecting) { + removeCaret(); + lastLine = line; + return; + } + const content = lineContentOf(line); + const len = lineRuneLen(content); + if (line !== lastLine) { + // Arrived on a new line (Up/Down): restore the remembered goal column. + caretCol = Math.min(goalCol, Math.max(len - 1, 0)); + } + lastLine = line; + caretCol = Math.max(0, Math.min(caretCol, Math.max(len - 1, 0))); + const rect = caretCharRect(content, caretCol, len); + if (!rect) { + removeCaret(); + return; + } + if (!caretEl) { + caretEl = document.createElement("div"); + caretEl.setAttribute("data-lvt-text-caret", ""); + caretEl.setAttribute("aria-hidden", "true"); + } + if (caretEl.parentElement !== el) el.appendChild(caretEl); + const host = el.getBoundingClientRect(); + caretEl.style.cssText = + "position:absolute;pointer-events:none;z-index:var(--lvt-text-caret-z-index,4);" + + "border-radius:2px;background:var(--lvt-text-caret-bg,rgba(31,111,235,0.4));" + + "box-shadow:var(--lvt-text-caret-shadow,0 0 0 1px var(--lvt-text-caret-border,#1f6feb));" + + `left:${rect.left - host.left + el.scrollLeft}px;` + + `top:${rect.top - host.top + el.scrollTop}px;` + + `width:${Math.max(rect.width, 2)}px;height:${rect.height}px;`; + }; + + // collapseSelectionAtCaret puts a collapsed selection at the caret so + // modify("extend", …) starts the selection FROM the caret (not line start). + const collapseSelectionAtCaret = () => { + const line = activeCursorLine(); + if (!line) return; + const p = nodePointAtRune(lineContentOf(line), caretCol); + const sel = window.getSelection(); + if (!sel) return; + const r = document.createRange(); + r.setStart(p.node, p.offset); + r.collapse(true); + sel.removeAllRanges(); + sel.addRange(r); + }; + + // colOfCaret reads a collapsed selection's column back as a rune offset (used + // after a word move, where the browser's engine found the boundary). + const colOfCaret = (content: Element): number => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return -1; + const r = document.createRange(); + r.selectNodeContents(content); + try { + r.setEnd(sel.getRangeAt(0).startContainer, sel.getRangeAt(0).startOffset); + } catch { + return -1; + } + return runesOf(r.toString()).length; + }; + + const isEditableFocus = (): boolean => { + const a = document.activeElement as HTMLElement | null; + if (!a) return false; + const tag = a.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || a.isContentEditable; + }; + + // moveCaret shifts the caret one character/word and re-renders it; any active + // selection collapses. + const moveCaret = (dir: "forward" | "backward", granularity: "character" | "word") => { + selecting = false; + hideButton(); + const line = activeCursorLine(); + if (!line) return; + const content = lineContentOf(line); + const len = lineRuneLen(content); + if (granularity === "word") { + collapseSelectionAtCaret(); + const sel = window.getSelection(); + if (sel && typeof sel.modify === "function") { + sel.modify("move", dir, "word"); + const c = colOfCaret(content); + if (c >= 0) caretCol = c; + } + window.getSelection()?.removeAllRanges(); + } else { + caretCol += dir === "forward" ? 1 : -1; + } + caretCol = Math.max(0, Math.min(caretCol, Math.max(len - 1, 0))); + goalCol = caretCol; + renderCaret(); + }; + + // extendSelection grows a native selection from the caret with modify() — the + // browser's text engine handles word boundaries — over the NON-editable text. + const extendSelection = ( + dir: "forward" | "backward", + granularity: "character" | "word" + ) => { + if (!activeCursorLine()) return; + if (!selecting) { + collapseSelectionAtCaret(); // anchor at the caret + selecting = true; + removeCaret(); // native highlight replaces the block caret + } + const sel = window.getSelection(); + if (sel && typeof sel.modify === "function") { + sel.modify("extend", dir, granularity); + } + // selectionchange → evaluate → the Comment button appears. + }; + + // confirmSelection dispatches the current selection as a text comment and + // resets — shared by the floating button (mouse/touch) and Enter (keyboard). + const confirmSelection = () => { + const data = textOffsetsFromSelection(window.getSelection(), el); + if (data) send({ action, data }); + hideButton(); + selecting = false; + window.getSelection()?.removeAllRanges(); + // The caret re-renders after the server's render (via sync()). + }; + + const showButton = (box: DOMRect) => { + if (!button) { + button = document.createElement("button"); + button.type = "button"; + button.setAttribute(TEXT_SELECT_BUTTON_ATTR, ""); + button.textContent = "Comment"; + // preventDefault on pointerdown keeps the native selection alive when the + // button is tapped (a default pointerdown would move focus and collapse + // it before our click handler reads the range). + button.addEventListener("pointerdown", (e) => e.preventDefault()); + button.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + confirmSelection(); + }); + document.body.appendChild(button); + } + // Fixed to the viewport, centered above the selection's end. Visual look + // is a sensible default overridable via --lvt-text-select-* custom props + // (mirrors area-select's --lvt-area-select-* convention). The overlay lives + // on document.body (outside the themed subtree), so it can't inherit host + // theme tokens — hence concrete fallbacks here. + // Touch devices show a NATIVE selection callout (iOS "Copy | Look Up …") + // anchored ABOVE the selection, rendered in an OS layer that beats any + // z-index. So on a coarse pointer, put our button just BELOW the selection + // (clear of that callout, still near where the user is looking); a mouse + // keeps the compact anchor just above the selection. + const coarse = + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches; + const cx = Math.round(box.left + box.width / 2); + const position = coarse + ? `left:${cx}px;top:${Math.round(box.bottom + 10)}px;transform:translateX(-50%);` + : `left:${cx}px;top:${Math.round(box.top)}px;transform:translate(-50%,calc(-100% - 6px));`; + button.style.cssText = + "position:fixed;z-index:var(--lvt-text-select-z-index,9999);margin:0;" + + "padding:var(--lvt-text-select-pad,10px 20px);cursor:pointer;white-space:nowrap;" + + "font:var(--lvt-text-select-font,600 16px/1.2 system-ui,-apple-system,sans-serif);" + + "color:var(--lvt-text-select-fg,#fff);background:var(--lvt-text-select-bg,#1f6feb);" + + "border:var(--lvt-text-select-border,0);border-radius:var(--lvt-text-select-radius,9px);" + + "box-shadow:var(--lvt-text-select-shadow,0 4px 16px rgba(0,0,0,0.35));" + + position; + }; + + const evaluate = () => { + const sel = window.getSelection(); + if (!selectionInsideHost(sel, el)) { + hideButton(); + return; + } + const range = textOffsetsFromSelection(sel, el); + if (!range) { + hideButton(); + return; + } + showButton(sel!.getRangeAt(0).getBoundingClientRect()); + }; + + const onSelectionChange = () => { + if (debounce !== null) clearTimeout(debounce); + debounce = setTimeout(evaluate, TEXT_SELECT_DEBOUNCE_MS); + }; + + // Capture-phase click guard: when a non-collapsed selection lives inside the + // host, swallow the click so the delegated (document-bubble) lvt-on:click on + // a line never fires — a drag-select's completing click would otherwise + // select the whole line. Capture on the host runs before the document-bubble + // delegation, and stopPropagation prevents the bubble entirely, so the line + // action is suppressed while the selection (and our button) survive. + const onClickCapture = (e: MouseEvent) => { + const sel = window.getSelection(); + if (sel && !sel.isCollapsed && selectionInsideHost(sel, el)) { + e.stopPropagation(); + } + }; + + // Single window-capture key handler — fires before the document-bubble + // lvt-on:keydown="selectLine" AND regardless of focus, so the caret works + // without stealing focus: + // - Left/Right → move the block caret (Ctrl/Alt = word); Up/Down fall + // through to the server's CursorUp/Down (is-cursor line move; the caret + // re-syncs after that render). + // - Shift+Left/Right → extend a selection from the caret. + // - Enter with a live selection → commit it (never selectLine). + // - Escape → drop the selection, restore the caret. + // While a text field (composer / filter) is focused, arrows are left to it. + const onKeydown = (e: KeyboardEvent) => { + // Bail entirely while a text field (composer / filter) is focused: this is a + // window-CAPTURE listener, so without this an Enter meant as a textarea + // newline — or an Escape meant to dismiss a field — would be hijacked to + // commit/cancel a still-live document selection (e.g. select via Shift+ + // Arrow, then Tab into the composer without clearing the selection). + if (isEditableFocus()) return; + // Scope to interactions that belong to THIS diff. Since it's a window- + // capture listener, without this it would swallow Left/Right/Enter/Escape + // for the whole page (breaking an unrelated arrow-key widget — dropdown, + // carousel — the user has focused). Handle only when nothing else has focus + // (page-load / body state, so the on-load caret is still keyboard-drivable) + // or focus is within the host. + const a = document.activeElement; + const scoped = + !a || a === document.body || a === document.documentElement || el.contains(a); + if (!scoped) return; + const sel = window.getSelection(); + const haveSel = !!sel && !sel.isCollapsed && selectionInsideHost(sel, el); + if (e.key === "Enter" && haveSel) { + e.preventDefault(); + e.stopPropagation(); + confirmSelection(); + return; + } + if (e.key === "Escape" && (selecting || haveSel)) { + e.preventDefault(); + e.stopPropagation(); + selecting = false; + window.getSelection()?.removeAllRanges(); + hideButton(); + renderCaret(); + return; + } + const dir = + e.key === "ArrowRight" ? "forward" : e.key === "ArrowLeft" ? "backward" : null; + if (!dir) return; // Up/Down → server cursor; everything else ignored + const granularity: "character" | "word" = e.ctrlKey || e.altKey ? "word" : "character"; + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) extendSelection(dir, granularity); + else moveCaret(dir, granularity); + }; + + document.addEventListener("selectionchange", onSelectionChange); + el.addEventListener("click", onClickCapture, true); + // NOTE: caret state (activeCursorLine/caretCol/selecting) is per-instance, and + // each armed element adds its own window keydown listener. This assumes ONE + // armed text-select host at a time (the diff view's single `.code`). If two are + // ever armed simultaneously, each keystroke would drive both carets, since + // stopPropagation doesn't stop sibling listeners on the same window target. + window.addEventListener("keydown", onKeydown, true); + + return { + action, + cleanup: () => { + document.removeEventListener("selectionchange", onSelectionChange); + el.removeEventListener("click", onClickCapture, true); + window.removeEventListener("keydown", onKeydown, true); + if (debounce !== null) clearTimeout(debounce); + removeCaret(); + hideButton(); + textSelectArmed.delete(el); + }, + sync: renderCaret, + updateSend: (s) => { + send = s; + }, + }; +} + +/** + * Apply text-select directives. `lvt-fx:text-select=""` arms a + * container so a native text selection over its line-bearing children shows a + * floating "Comment" button that dispatches the character range on click. + * Idempotent + swept exactly like area/region-select (Map, not WeakMap, so the + * sweep can iterate to tear down elements whose attribute a server diff + * removed). + */ +export function handleTextSelectDirectives( + rootElement: Element, + send: AreaSelectSendFn +): void { + for (const [element, entry] of Array.from(textSelectArmed)) { + if (!element.isConnected || !element.hasAttribute("lvt-fx:text-select")) { + entry.cleanup(); + } + } + const matches = rootElement.querySelectorAll("[lvt-fx\\:text-select]"); + if (matches.length === 0) return; + for (const el of matches) { + const action = el.getAttribute("lvt-fx:text-select"); + if (!action) { + console.warn( + `lvt-fx:text-select requires an action name, got: ${JSON.stringify(action)}` + ); + continue; + } + const existing = textSelectArmed.get(el); + if (existing && existing.action === action) { + existing.updateSend(send); + existing.sync(); // re-draw the caret after this render + continue; + } + if (existing) existing.cleanup(); + const entry = attachTextSelect(el, action, send); + textSelectArmed.set(el, entry); + entry.sync(); // draw the caret on first mount (visible from load) + } +} + +/** + * Cancel text-select listeners for every armed element under root. + * Mirror of teardownRegionSelectForRoot for the client disconnect lifecycle. + */ +export function teardownTextSelectForRoot(rootElement: Element): void { + for (const [element, entry] of Array.from(textSelectArmed)) { + if (rootElement.contains(element)) { + entry.cleanup(); + } + } +} + // proxyBridgeArmed mirrors the area/region-select singletons: one entry per // armed `lvt-fx:proxy-bridge` element, swept on disconnect. sync runs on every // render to re-apply the pin-layer transform morphdom wipes + relay a "locate diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 18c27d2..1237e28 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -16,6 +16,7 @@ import { handlePreviewBridgeDirectives, handleProxyBridgeDirectives, handleRegionSelectDirectives, + handleTextSelectDirectives, handleScrollDirectives, handleShadowRootHydration, handleToastDirectives, @@ -25,6 +26,7 @@ import { teardownPreviewBridgeForRoot, teardownProxyBridgeForRoot, teardownRegionSelectForRoot, + teardownTextSelectForRoot, teardownURLHashForRoot, teardownAutoClickTimers, setupToastClickOutside, @@ -681,6 +683,7 @@ export class LiveTemplateClient { teardownAreaSelectForRoot(this.wrapperElement); teardownResizeForRoot(this.wrapperElement); teardownRegionSelectForRoot(this.wrapperElement); + teardownTextSelectForRoot(this.wrapperElement); teardownProxyBridgeForRoot(this.wrapperElement); teardownIframeAutoHeightForRoot(this.wrapperElement); teardownPreviewBridgeForRoot(this.wrapperElement); @@ -2084,6 +2087,11 @@ export class LiveTemplateClient { // send-callback wiring; the overlay is parent light DOM so it works // on iOS (unlike the deleted in-iframe tap). handleRegionSelectDirectives(element, (message) => this.send(message)); + // text-select captures a native text selection over the diff and dispatches + // a character range (word / phrase / multi-line span). Same send-callback + // wiring; uses window.getSelection rather than a drawn box, so it covers + // mouse, touch, and keyboard from one path. + handleTextSelectDirectives(element, (message) => this.send(message)); // proxy-bridge is the --external live-site bridge: it relays the proxied // page's nav (→ setProxyURL) and scroll (→ pin-layer transform, no server // hop) from the cross-origin beacon. Same send-callback wiring; the only diff --git a/tests/text-select.test.ts b/tests/text-select.test.ts new file mode 100644 index 0000000..7a50312 --- /dev/null +++ b/tests/text-select.test.ts @@ -0,0 +1,256 @@ +import { + textOffsetsFromSelection, + handleTextSelectDirectives, + teardownTextSelectForRoot, +} from "../dom/directives"; + +// Build a `.code` host whose lines mirror prereview's structure: each line is a +// [data-line] row with a gutter span (excluded from offsets) and a +// [data-line-text] content span holding one or more token spans (as chroma +// emits). Returns the host and its content spans for boundary setting. +function mountCode( + lines: Array<{ line: number; side?: string; tokens: string[] }> +): { host: HTMLElement; contents: HTMLElement[] } { + const host = document.createElement("div"); + host.setAttribute("lvt-fx:text-select", "selectText"); + const contents: HTMLElement[] = []; + for (const l of lines) { + const row = document.createElement("div"); + row.setAttribute("data-line", String(l.line)); + if (l.side) row.setAttribute("data-side", l.side); + const gutter = document.createElement("span"); + gutter.textContent = String(l.line); // line number — must NOT count + row.appendChild(gutter); + const content = document.createElement("span"); + content.setAttribute("data-line-text", ""); + for (const tok of l.tokens) { + const span = document.createElement("span"); + span.textContent = tok; + content.appendChild(span); + } + row.appendChild(content); + host.appendChild(row); + contents.push(content); + } + document.body.appendChild(host); + return { host, contents }; +} + +// selectRange builds a native selection from (startNode, startOffset) to +// (endNode, endOffset) and returns window.getSelection(). +function selectRange( + startNode: Node, + startOffset: number, + endNode: Node, + endOffset: number +): Selection { + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + return sel; +} + +// firstText returns the text node inside the i-th token span of a content el. +function tokenText(content: HTMLElement, tokenIndex: number): Text { + return content.children[tokenIndex].firstChild as Text; +} + +afterEach(() => { + teardownTextSelectForRoot(document.body); // drop any armed window keydown listeners + document.body.replaceChildren(); + window.getSelection()?.removeAllRanges(); +}); + +describe("textOffsetsFromSelection", () => { + it("resolves a single-word selection to rune columns, excluding the gutter", () => { + // "the quick brown fox" split as tokens; select "brown". + const { host, contents } = mountCode([ + { line: 42, tokens: ["the ", "quick ", "brown ", "fox"] }, + ]); + const brown = tokenText(contents[0], 2); // "brown " + const sel = selectRange(brown, 0, brown, 5); // "brown" + const r = textOffsetsFromSelection(sel, host); + expect(r).not.toBeNull(); + expect(r).toMatchObject({ + fromLine: 42, + toLine: 42, + fromCol: 10, // "the quick " = 10 chars + toCol: 15, // + "brown" + text: "brown", + }); + expect(r!.side).toBeUndefined(); // new side + }); + + it("spans multiple token spans within one line", () => { + const { host, contents } = mountCode([ + { line: 7, tokens: ["the ", "quick ", "brown ", "fox"] }, + ]); + // from inside "quick " (offset 2 → after "qu") to inside "fox" (offset 2 → "fo") + const sel = selectRange(tokenText(contents[0], 1), 2, tokenText(contents[0], 3), 2); + const r = textOffsetsFromSelection(sel, host); + expect(r).toMatchObject({ + fromLine: 7, + toLine: 7, + fromCol: 6, // "the qu" = 6 + toCol: 18, // "the quick brown fo" = 18 + text: "ick brown fo", + }); + }); + + it("resolves a multi-line selection (fromLine != toLine)", () => { + const { host, contents } = mountCode([ + { line: 10, tokens: ["alpha beta"] }, + { line: 11, tokens: ["gamma delta"] }, + ]); + // from "beta" start on line 10 to after "gamma" on line 11 + const sel = selectRange(tokenText(contents[0], 0), 6, tokenText(contents[1], 0), 5); + const r = textOffsetsFromSelection(sel, host); + expect(r).toMatchObject({ + fromLine: 10, + fromCol: 6, // "alpha " = 6 + toLine: 11, + toCol: 5, // "gamma" = 5 + }); + }); + + it("counts a surrogate-pair char (emoji) as one rune column", () => { + // "☕ x brownY" — the ☕ before the target counts as ONE column. + const { host, contents } = mountCode([{ line: 3, tokens: ["☕ ab", "cd"] }]); + // select "cd" (offset 0..2 in second token). Preceding text "☕ ab" = 4 runes. + const sel = selectRange(tokenText(contents[0], 1), 0, tokenText(contents[0], 1), 2); + const r = textOffsetsFromSelection(sel, host); + expect(r).toMatchObject({ fromCol: 4, toCol: 6, text: "cd" }); + }); + + it("reads data-side='old' onto the range", () => { + const { host, contents } = mountCode([ + { line: 5, side: "old", tokens: ["removed line"] }, + ]); + const sel = selectRange(tokenText(contents[0], 0), 0, tokenText(contents[0], 0), 7); + const r = textOffsetsFromSelection(sel, host); + expect(r).toMatchObject({ fromLine: 5, side: "old", text: "removed" }); + }); + + it("returns null for a collapsed selection", () => { + const { host, contents } = mountCode([{ line: 1, tokens: ["abc"] }]); + const sel = selectRange(tokenText(contents[0], 0), 1, tokenText(contents[0], 0), 1); + expect(textOffsetsFromSelection(sel, host)).toBeNull(); + }); + + it("returns null when a boundary is in the gutter (not the text container)", () => { + const { host } = mountCode([{ line: 1, tokens: ["abc"] }]); + const gutter = host.querySelector("[data-line] > span:first-child")!.firstChild as Text; + const content = host.querySelector("[data-line-text]")!.firstChild!.firstChild as Text; + const sel = selectRange(gutter, 0, content, 2); + expect(textOffsetsFromSelection(sel, host)).toBeNull(); + }); + + it("returns null when the selection crosses the diff old/new boundary", () => { + const { host, contents } = mountCode([ + { line: 5, side: "old", tokens: ["deleted"] }, + { line: 6, tokens: ["added"] }, // new side + ]); + const sel = selectRange(tokenText(contents[0], 0), 0, tokenText(contents[1], 0), 3); + expect(textOffsetsFromSelection(sel, host)).toBeNull(); + }); + + it("returns null when the selection lands outside the host", () => { + const { host } = mountCode([{ line: 1, tokens: ["abc"] }]); + const outside = document.createElement("p"); + outside.textContent = "elsewhere"; + document.body.appendChild(outside); + const t = outside.firstChild as Text; + const sel = selectRange(t, 0, t, 4); + expect(textOffsetsFromSelection(sel, host)).toBeNull(); + }); +}); + +describe("keyboard commit gating (regression: bot review of #140)", () => { + // Arming renders the block caret, which reads Range layout geometry. jsdom + // does no layout (getClientRects is absent), so stub the two Range layout + // methods — real geometry is covered by the chromedp e2e, not here. + const emptyRects = Object.assign([], { item: () => null }) as unknown as DOMRectList; + const zeroRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } as DOMRect; + beforeAll(() => { + (Range.prototype as any).getClientRects = () => emptyRects; + (Range.prototype as any).getBoundingClientRect = () => zeroRect; + }); + + it("does NOT commit on Enter while a text field is focused, even with a live host selection", () => { + const { host, contents } = mountCode([{ line: 1, tokens: ["alpha beta"] }]); + host.setAttribute("lvt-fx:text-select", "selectText"); + const send = jest.fn(); + handleTextSelectDirectives(document.body, send); + + // A live, non-collapsed selection inside the host... + selectRange(tokenText(contents[0], 0), 0, tokenText(contents[0], 0), 5); + // ...but focus is in an unrelated textarea (e.g. the composer). Tab does not + // collapse the document selection, so haveSel is still true. + const ta = document.createElement("textarea"); + document.body.appendChild(ta); + ta.focus(); + expect(document.activeElement).toBe(ta); + + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }) + ); + expect(send).not.toHaveBeenCalled(); // Enter belongs to the textarea, not us + }); + + it("DOES commit on Enter when focus is not in a text field", () => { + const { host, contents } = mountCode([{ line: 1, tokens: ["alpha beta"] }]); + host.setAttribute("lvt-fx:text-select", "selectText"); + const send = jest.fn(); + handleTextSelectDirectives(document.body, send); + selectRange(tokenText(contents[0], 0), 0, tokenText(contents[0], 0), 5); // "alpha" + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }) + ); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toMatchObject({ action: "selectText" }); + }); +}); + +describe("keyboard scoping (regression: bot review round 2 of #140)", () => { + const emptyRects = Object.assign([], { item: () => null }) as unknown as DOMRectList; + const zeroRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } as DOMRect; + beforeAll(() => { + (Range.prototype as any).getClientRects = () => emptyRects; + (Range.prototype as any).getBoundingClientRect = () => zeroRect; + }); + + it("ignores Enter when an unrelated non-editable widget is focused", () => { + const { host, contents } = mountCode([{ line: 1, tokens: ["alpha beta"] }]); + host.setAttribute("lvt-fx:text-select", "selectText"); + const send = jest.fn(); + handleTextSelectDirectives(document.body, send); + selectRange(tokenText(contents[0], 0), 0, tokenText(contents[0], 0), 5); // live selection + + // Focus an unrelated widget OUTSIDE the host (e.g. a custom dropdown button). + const widget = document.createElement("button"); + document.body.appendChild(widget); + widget.focus(); + expect(document.activeElement).toBe(widget); + + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }) + ); + expect(send).not.toHaveBeenCalled(); // the widget owns the key, not us + }); + + it("does not preventDefault ArrowRight when an unrelated widget is focused", () => { + const { host } = mountCode([{ line: 1, tokens: ["alpha"] }]); + host.setAttribute("lvt-fx:text-select", "selectText"); + handleTextSelectDirectives(document.body, jest.fn()); + const widget = document.createElement("button"); + document.body.appendChild(widget); + widget.focus(); + + const ev = new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }); + window.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(false); // left for the focused widget + }); +});