From a3486cb6d4377d33f0e3015d018deabe5431ad6e Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Wed, 1 Jul 2026 13:44:59 +0000 Subject: [PATCH] feat(dom): text-select on rendered surfaces via data-surface="block" Extends lvt-fx:text-select to rendered Markdown (host data-surface="block"): a native selection resolves to the touched [data-from]/[data-to] blocks' source line range + the exact rendered phrase, instead of character columns (rendered text can't map to source offsets). No caret and arrow keys are left alone on the block surface. The default "code" surface (char ranges + caret) is unchanged. Fixes text selection producing no Comment button in the rendered Preview view. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ey41dRwDpRgLtVHZjhYLKt --- dom/directives.ts | 46 ++++++++++++++++++++++++++--- tests/text-select.test.ts | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index 0b472cd..ae41019 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -2330,15 +2330,46 @@ function colWithin(content: Element, node: Node, offset: number): number { 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. +// blockRangeFromSelection resolves a native selection over a RENDERED surface +// (Markdown/HTML preview, host data-surface="block") to a SOURCE line range + +// the selected text. Rendered text can't map to source columns (`**b**` renders +// as `b`), so columns are 0 and the range is the [data-from, data-to] span of +// the blocks the selection touches — with the exact rendered phrase captured as +// `text` (quoted in the comment and used for drift/context). Rendered content is +// always the new side. +function blockRangeFromSelection(sel: Selection, host: Element): TextRange | null { + const range = sel.getRangeAt(0); // doc-ordered: start precedes end + const blockOf = (node: Node | null): Element | null => { + const start = node && node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element | null); + if (!start || !host.contains(start)) return null; + const b = start.closest("[data-from]"); + return b && host.contains(b) ? b : null; + }; + const startBlock = blockOf(range.startContainer); + const endBlock = blockOf(range.endContainer); + if (!startBlock || !endBlock) return null; + const from = parseInt(startBlock.getAttribute("data-from") || "", 10); + const to = parseInt(endBlock.getAttribute("data-to") || "", 10); + if (!Number.isFinite(from) || !Number.isFinite(to) || from < 1 || to < from) return null; + const text = range.toString(); + if (text.length === 0) return null; + return { fromLine: from, fromCol: 0, toLine: to, toCol: 0, side: "new", text }; +} + +// textOffsetsFromSelection resolves a native selection to a source range + the +// selected text, or null when it can't be represented. On the default "code" +// surface it yields a character range (rune columns) against [data-line] rows; +// on a "block" surface (host data-surface="block", rendered Markdown/HTML) it +// yields a line range against [data-from]/[data-to] blocks. 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; + if (host.getAttribute("data-surface") === "block") { + return blockRangeFromSelection(sel, host); + } const range = sel.getRangeAt(0); // normalized: start precedes end in doc order const startCtx = lineTextContainer(range.startContainer, host); const endCtx = lineTextContainer(range.endContainer, host); @@ -2379,6 +2410,9 @@ function attachTextSelect( initialSend: AreaSelectSendFn ): TextSelectEntry { let send = initialSend; + // Surface: "code" (diff/raw — character ranges + a keyboard block caret) or + // "block" (rendered Markdown/HTML — line ranges, no caret, arrows left alone). + const surface = el.getAttribute("data-surface") === "block" ? "block" : "code"; let button: HTMLButtonElement | null = null; let debounce: ReturnType | null = null; // Keyboard block caret — a client-only visual overlay over one character. @@ -2714,6 +2748,10 @@ function attachTextSelect( renderCaret(); return; } + // Arrow-key caret is a code-surface affordance only. On a rendered "block" + // surface there's no caret, so leave arrows to the browser (scroll) rather + // than hijacking them. + if (surface !== "code") return; const dir = e.key === "ArrowRight" ? "forward" : e.key === "ArrowLeft" ? "backward" : null; if (!dir) return; // Up/Down → server cursor; everything else ignored diff --git a/tests/text-select.test.ts b/tests/text-select.test.ts index 7a50312..9f23e35 100644 --- a/tests/text-select.test.ts +++ b/tests/text-select.test.ts @@ -254,3 +254,64 @@ describe("keyboard scoping (regression: bot review round 2 of #140)", () => { expect(ev.defaultPrevented).toBe(false); // left for the focused widget }); }); + +// mountBlocks builds a rendered-Markdown-style host: data-surface="block" with +// [data-from]/[data-to] blocks holding rendered HTML (text ≠ source columns). +function mountBlocks( + blocks: Array<{ from: number; to: number; html: string }> +): { host: HTMLElement; blocks: HTMLElement[] } { + const host = document.createElement("div"); + host.setAttribute("lvt-fx:text-select", "selectText"); + host.setAttribute("data-surface", "block"); + const els: HTMLElement[] = []; + for (const b of blocks) { + const el = document.createElement("div"); + el.setAttribute("data-from", String(b.from)); + el.setAttribute("data-to", String(b.to)); + el.innerHTML = b.html; + host.appendChild(el); + els.push(el); + } + document.body.appendChild(host); + return { host, blocks: els }; +} + +describe("textOffsetsFromSelection — block surface (rendered Markdown)", () => { + it("resolves a within-block selection to the block's source line range + phrase", () => { + const { host, blocks } = mountBlocks([{ from: 10, to: 12, html: "

buy bulk items

" }]); + // select "bulk items" spanning the and the following text node + const strong = blocks[0].querySelector("strong")!.firstChild as Text; // "bulk" + const tail = blocks[0].querySelector("p")!.lastChild as Text; // " items" + const sel = selectRange(strong, 0, tail, 6); + const r = textOffsetsFromSelection(sel, host); + expect(r).toMatchObject({ fromLine: 10, toLine: 12, fromCol: 0, toCol: 0, side: "new" }); + expect(r!.text).toContain("bulk items"); + }); + + it("spans multiple blocks: from start block's from to end block's to", () => { + const { host, blocks } = mountBlocks([ + { from: 5, to: 5, html: "

first line

" }, + { from: 6, to: 8, html: "

second block

" }, + ]); + const a = blocks[0].querySelector("p")!.firstChild as Text; + const b = blocks[1].querySelector("p")!.firstChild as Text; + const sel = selectRange(a, 0, b, 6); + const r = textOffsetsFromSelection(sel, host); + expect(r).toMatchObject({ fromLine: 5, toLine: 8, fromCol: 0, toCol: 0 }); + }); + + it("returns null when the selection is outside any block", () => { + const { host } = mountBlocks([{ from: 1, to: 1, html: "

x

" }]); + const outside = document.createElement("p"); + outside.textContent = "elsewhere"; + document.body.appendChild(outside); + const t = outside.firstChild as Text; + expect(textOffsetsFromSelection(selectRange(t, 0, t, 4), host)).toBeNull(); + }); + + it("returns null for a collapsed selection", () => { + const { host, blocks } = mountBlocks([{ from: 1, to: 1, html: "

abc

" }]); + const t = blocks[0].querySelector("p")!.firstChild as Text; + expect(textOffsetsFromSelection(selectRange(t, 1, t, 1), host)).toBeNull(); + }); +});