/** * 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 `

` 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 ` 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?: (fn: T, name?: string) => T; }; if (typeof g.__name !== "function") g.__name = (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 ` 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?.(); }); }