carbon-tokenization / backend /demo /lib /selection.ts
tfrere's picture
tfrere HF Staff
feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
f469961
Raw
History Blame Contribute Delete
16 kB
/**
* ProseMirror selection helpers for the showcase demo.
*
* All three helpers run inside `page.evaluate` because they need
* direct access to the editor instance exposed via `window.__demoEditor`
* (installed by `Editor.tsx` in dev mode). They return PM positions
* the caller forwards into `__demo-chat` events so the App-level
* action handlers can apply formatting / links / citations on a
* concrete range.
*/
import type { Page } from "playwright";
import { pause } from "../human-typing.js";
import { humanMove, resetMouseState } from "./mouse-cursor.js";
/**
* Read the current ProseMirror selection range. Returns null if the
* selection is collapsed or the editor isn't ready yet.
*
* Used by the chat-action helpers after `selectSubstringInParagraph`
* so we can pass exact PM positions into the `__demo-chat` event -
* works the same way for the legacy `replace` pipeline, where the
* *string-based* fallback in the App handler silently fails on
* paragraphs that contain inline math atoms.
*/
export async function getCurrentPmSelection(
page: Page,
): Promise<{ from: number; to: number } | null> {
return await page.evaluate(() => {
const editor = (
window as unknown as {
__demoEditor?: {
view: {
state: { selection: { from: number; to: number; empty: boolean } };
};
};
}
).__demoEditor;
if (!editor) return null;
const sel = editor.view.state.selection;
if (sel.empty || !(sel.to > sel.from)) return null;
return { from: sel.from, to: sel.to };
});
}
/**
* Select a plain-text substring *inside* a paragraph identified by a
* snippet of its surrounding text. Returns the resulting PM range so
* the caller can forward it to a `format` action, or null if the
* substring can't be located.
*
* How the lookup works
* --------------------
* We find the `<p>` whose textContent contains `paragraphSnippet`,
* walk its text nodes to map the `substring` onto a DOM start node +
* offset and end node + offset, then ask PM for the matching
* absolute positions via `view.posAtDOM`. Robust to marks already
* applied inside the paragraph (italics, links, ...) and works across
* text-node boundaries.
*
* Limitations
* -----------
* The substring MUST be plain text within the paragraph - it cannot
* cross an inline atom (math node, mention, image). `textContent`
* emits the atom's placeholder, which `indexOf` would match on while
* the DOM offsets would skip it. For "bold the three symbols"-style
* demos, target a clean phrase that doesn't straddle `$$...$$`.
*
* Side effects
* ------------
* On success, always seeds PM's text selection. If `setAgentFocus`
* is true, ALSO broadcasts the range as the local user's AgentFocus
* so every peer sees a `<Name> agent` tinted range - that's the
* correct behaviour for chat-driven actions (format / link /
* citation via the assistant), where the viewer should perceive
* the agent as owning the range. For pure editor UX paths (bubble
* toolbar bold, plain text selection before typing), leave it
* false so the agent label doesn't pop up out of nowhere while the
* chat panel is closed.
*/
export async function selectSubstringInParagraph(
page: Page,
paragraphSnippet: string,
substring: string,
opts: { setAgentFocus?: boolean } = {},
): Promise<{ from: number; to: number } | null> {
const setFocus = opts.setAgentFocus ?? false;
return await page.evaluate(
({ paraText, substrText, setFocus: doFocus }) => {
const g = globalThis as unknown as {
__name?: <T>(fn: T, name?: string) => T;
};
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
if (!root) return null;
const para =
(Array.from(root.querySelectorAll("p")).find((p) =>
(p.textContent || "").includes(paraText),
) as HTMLElement | undefined) ?? null;
if (!para) return null;
para.scrollIntoView({ behavior: "smooth", block: "center" });
const text = para.textContent || "";
const idx = text.indexOf(substrText);
if (idx === -1) return null;
// Walk text nodes in order, counting characters, to find the
// DOM (node, offset) pairs that bracket the substring.
const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
let walked = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const len = node.nodeValue?.length ?? 0;
if (!startNode && walked + len > idx) {
startNode = node;
startOffset = idx - walked;
}
if (walked + len >= idx + substrText.length) {
endNode = node;
endOffset = idx + substrText.length - walked;
break;
}
walked += len;
}
if (!startNode || !endNode) return null;
const editor = (
window as unknown as {
__demoEditor?: {
view: {
posAtDOM: (node: Node, offset: number, bias?: number) => number;
};
commands: {
setTextSelection: (args: { from: number; to: number }) => boolean;
setAgentFocus: (args: { from: number; to: number }) => boolean;
};
};
}
).__demoEditor;
if (!editor) return null;
let from: number;
let to: number;
try {
from = editor.view.posAtDOM(startNode, startOffset, -1);
to = editor.view.posAtDOM(endNode, endOffset, 1);
} catch {
return null;
}
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from)) {
return null;
}
editor.commands.setTextSelection({ from, to });
if (doFocus) editor.commands.setAgentFocus({ from, to });
return { from, to };
},
{
paraText: paragraphSnippet,
substrText: substring,
setFocus,
},
);
}
/**
* Select a substring by simulating a real mouse drag across it.
*
* Unlike `selectSubstringInParagraph`, which performs a purely
* programmatic PM selection, this helper moves the visible cursor
* overlay from one viewport coordinate to the next and fires actual
* `mousedown` / `mousemove` / `mouseup` events. Viewers watching a
* recorded demo see the cursor travel over the phrase while the
* selection paint follows along - this is what the user expects when
* they say "I want to *see* Alice select text".
*
* Robustness:
* - The drag can end up off by a glyph at the very edge (subpixel
* hit testing differs per platform), so after `mouseup` we always
* correct the PM selection to the exact {from, to} computed from
* the DOM TreeWalker mapping. That way the bold / link / agent-
* focus action always operates on the intended range.
* - Returns null if the paragraph or substring can't be found; the
* caller can then fall back to `selectSubstringInParagraph`.
*
* The `setAgentFocus` flag matches the sibling helper: leave it
* false for user-driven bubble toolbar interactions so the agent
* label doesn't flash up while the chat panel is closed.
*/
export async function dragSelectSubstringInParagraph(
page: Page,
paragraphSnippet: string,
substring: string,
opts: { setAgentFocus?: boolean } = {},
): Promise<{ from: number; to: number } | null> {
const setFocus = opts.setAgentFocus ?? false;
const rect = await page.evaluate(
({ paraText, substrText }) => {
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
if (!root) return null;
const para =
(Array.from(root.querySelectorAll("p")).find((p) =>
(p.textContent || "").includes(paraText),
) as HTMLElement | undefined) ?? null;
if (!para) return null;
// "auto" (instant) rather than "smooth": smooth scroll is
// still animating when our caller's humanMove lands on the
// drag-start coordinates, which means the rects we compute
// here reference an onscreen position that has already
// shifted - the pointer ends up a few lines above/below the
// phrase and the browser-native selection sweep picks up
// the heading above or the math block below. Instant scroll
// pins the layout BEFORE we measure.
para.scrollIntoView({ behavior: "auto", block: "center" });
const text = para.textContent || "";
const idx = text.indexOf(substrText);
if (idx === -1) return null;
const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
let walked = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const len = node.nodeValue?.length ?? 0;
if (!startNode && walked + len > idx) {
startNode = node;
startOffset = idx - walked;
}
if (walked + len >= idx + substrText.length) {
endNode = node;
endOffset = idx + substrText.length - walked;
break;
}
walked += len;
}
if (!startNode || !endNode) return null;
const range = document.createRange();
try {
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
} catch {
return null;
}
// Some layouts split the range across two lines; we always
// take the first client rect as the start and the last as
// the end, so the drag follows the visual reading order.
const rects = Array.from(range.getClientRects());
if (rects.length === 0) return null;
const first = rects[0];
const last = rects[rects.length - 1];
const editor = (
window as unknown as {
__demoEditor?: {
view: {
posAtDOM: (node: Node, offset: number, bias?: number) => number;
};
};
}
).__demoEditor;
if (!editor) return null;
let from: number;
let to: number;
try {
from = editor.view.posAtDOM(startNode, startOffset, -1);
to = editor.view.posAtDOM(endNode, endOffset, 1);
} catch {
return null;
}
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from)) {
return null;
}
return {
from,
to,
startX: first.left + 2,
startY: first.top + first.height / 2,
endX: last.right - 2,
endY: last.top + last.height / 2,
};
},
{ paraText: paragraphSnippet, substrText: substring },
);
if (!rect) return null;
// Post-scroll beat so the layout is 100% settled. Even with
// behavior: "auto", a scroll triggers a synchronous reflow but
// the next paint can still shift things by a frame.
await page.waitForTimeout(180);
// Phase 1: visible-cursor approach to the start of the phrase.
// NO mousedown - we are NOT doing a browser-native drag. A real
// drag is fragile: mouse.down 1px outside the first glyph can
// land on the paragraph margin and the sweep to the endpoint
// picks up the heading above or the math atom below, which is
// exactly the "3 lines with bits of title and text" artefact
// users see. Instead, we animate the overlay cursor over the
// text and extend the PM selection step-by-step as the cursor
// moves, so the selection paint GROWS under the pointer like
// a real drag but without the pixel-brittleness.
await humanMove(page, rect.startX, rect.startY, {
steps: 22,
jitter: 0,
});
await pause(120, 180);
// Phase 2: collapse the selection at the start of the phrase. The
// viewer reads the cursor "clicking" here.
await page.evaluate(
({ from }) => {
const editor = (
window as unknown as {
__demoEditor?: {
commands: {
focus: () => boolean;
setTextSelection: (args: { from: number; to: number }) => boolean;
};
};
}
).__demoEditor;
if (!editor) return;
editor.commands.focus();
editor.commands.setTextSelection({ from, to: from });
},
{ from: rect.from },
);
// Phase 3: progressive drag - interpolate cursor position AND
// selection endpoint together, so the highlight grows under the
// moving cursor in sync. `page.mouse.move` straight (no bezier)
// because a curved path during a drag would dip into adjacent
// lines. Each step updates the PM selection to
// {from, to: from + (to-from) * t} so the highlight progression
// maps 1:1 to pointer progression. Any character drift at the
// final step is patched by the canonical setTextSelection below.
const dx = rect.endX - rect.startX;
const dy = rect.endY - rect.startY;
const distance = Math.hypot(dx, dy);
const selLen = rect.to - rect.from;
const glideSteps = Math.max(18, Math.min(40, Math.round(distance / 14)));
for (let i = 1; i <= glideSteps; i++) {
const t = i / glideSteps;
const x = rect.startX + dx * t;
const y = rect.startY + dy * t;
await page.mouse.move(x, y, { steps: 2 });
const midTo = rect.from + Math.round(selLen * t);
await page.evaluate(
({ from, to }) => {
const editor = (
window as unknown as {
__demoEditor?: {
commands: {
setTextSelection: (args: {
from: number;
to: number;
}) => boolean;
};
};
}
).__demoEditor;
editor?.commands.setTextSelection({ from, to });
},
{ from: rect.from, to: midTo },
);
await page.waitForTimeout(14 + Math.floor(Math.random() * 14));
}
// Phase 4: canonical setTextSelection to guarantee the final
// range is pixel-perfect (rounding during interpolation can land
// 1 char short).
await page.evaluate(
({ from, to }) => {
const editor = (
window as unknown as {
__demoEditor?: {
commands: {
setTextSelection: (args: { from: number; to: number }) => boolean;
};
};
}
).__demoEditor;
editor?.commands.setTextSelection({ from, to });
},
{ from: rect.from, to: rect.to },
);
// Phase 5: optional AgentFocus broadcast (chat-driven paths set
// this, pure-editor paths leave it false so the agent label
// doesn't pop up out of context).
if (setFocus) {
await page.evaluate(
({ from, to }) => {
const editor = (
window as unknown as {
__demoEditor?: {
commands: {
setAgentFocus: (args: { from: number; to: number }) => boolean;
};
};
}
).__demoEditor;
editor?.commands.setAgentFocus({ from, to });
},
{ from: rect.from, to: rect.to },
);
}
// Resync cursor tracker. The progressive loop used page.mouse.move
// directly, which bypasses humanMove's internal lastX/lastY
// updates. Without this reset the next humanMove would glide
// from a stale origin.
resetMouseState(rect.endX, rect.endY);
return { from: rect.from, to: rect.to };
}
/**
* Clear the agent focus range broadcast by `selectSubstringInParagraph`.
* Call once the rewrite has fully landed and the settle animation is
* done, so the `<Name> agent` label disappears from every peer's view
* cleanly.
*/
export async function clearAgentFocus(page: Page) {
await page.evaluate(() => {
const editor = (
window as unknown as {
__demoEditor?: { commands: { clearAgentFocus: () => boolean } };
}
).__demoEditor;
editor?.commands?.clearAgentFocus?.();
});
}