Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions dom/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;
// Keyboard block caret — a client-only visual overlay over one character.
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/text-select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>buy <strong>bulk</strong> items</p>" }]);
// select "bulk items" spanning the <strong> 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: "<p>first line</p>" },
{ from: 6, to: 8, html: "<p>second block</p>" },
]);
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: "<p>x</p>" }]);
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: "<p>abc</p>" }]);
const t = blocks[0].querySelector("p")!.firstChild as Text;
expect(textOffsetsFromSelection(selectRange(t, 1, t, 1), host)).toBeNull();
});
});
Loading