| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import type { Page } from "playwright"; |
| import { humanType, pause } from "../human-typing.js"; |
| import { humanMove } from "./mouse-cursor.js"; |
|
|
| export interface ScaffoldSection { |
| heading: string; |
| |
| slots: number; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function pickHeading2ViaSlashMenu(page: Page): Promise<boolean> { |
| |
| |
| |
| |
| |
| |
| try { |
| const rect = await page.evaluate(() => { |
| const root = document.querySelector(".ProseMirror") as HTMLElement | null; |
| if (!root) return null; |
| const sel = window.getSelection(); |
| |
| |
| |
| if (sel && sel.rangeCount > 0) { |
| const r = sel.getRangeAt(0).getClientRects()[0]; |
| if (r && r.width >= 0 && r.height > 0) { |
| return { x: r.left + 6, y: r.top + r.height / 2 }; |
| } |
| } |
| const paras = root.querySelectorAll("p"); |
| const last = paras[paras.length - 1] as HTMLElement | undefined; |
| if (!last) return null; |
| const lr = last.getBoundingClientRect(); |
| return { x: lr.left + 24, y: lr.top + lr.height / 2 }; |
| }); |
| if (rect) await humanMove(page, rect.x, rect.y, { steps: 18 }); |
| } catch { |
| |
| } |
| await pause(80, 140); |
| await page.keyboard.type("/", { delay: 30 }); |
| |
| |
| |
| const popoverLocator = page.locator(".slash-menu").first(); |
| try { |
| await popoverLocator.waitFor({ state: "visible", timeout: 1_200 }); |
| } catch { |
| |
| |
| |
| await page.keyboard.press("Backspace"); |
| await pause(40, 80); |
| return false; |
| } |
| |
| |
| await pause(420, 640); |
| |
| |
| |
| |
| await page.keyboard.press("ArrowDown"); |
| await pause(160, 260); |
| await page.keyboard.press("Enter"); |
| await pause(140, 220); |
| return true; |
| } |
|
|
| export async function writeScaffold( |
| page: Page, |
| sections: ScaffoldSection[], |
| speed: number, |
| ): Promise<void> { |
| for (let s = 0; s < sections.length; s++) { |
| const section = sections[s]; |
| const isLast = s === sections.length - 1; |
|
|
| |
| |
| |
| |
| let createdViaSlash = false; |
| if (s === 0) { |
| createdViaSlash = await pickHeading2ViaSlashMenu(page); |
| } |
| if (!createdViaSlash) { |
| await page.keyboard.type("## ", { delay: 22 }); |
| await pause(40, 80); |
| } |
| await humanType(page, section.heading, { |
| speed: speed * 2.2, |
| typoRate: 0, |
| thinkRate: 0.01, |
| }); |
| |
| |
| |
| |
| |
| |
| const enters = isLast ? section.slots : section.slots + 1; |
| for (let i = 0; i < enters; i++) { |
| await page.keyboard.press("Enter"); |
| await pause(40, 80); |
| } |
| } |
| } |
|
|
| export async function waitForHeading2( |
| page: Page, |
| text: string, |
| timeout = 60_000, |
| ): Promise<void> { |
| await page |
| .locator(".ProseMirror h2") |
| .filter({ hasText: text }) |
| .first() |
| .waitFor({ state: "visible", timeout }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function waitForPeersSettled( |
| page: Page, |
| opts: { |
| timeout?: number; |
| layoutSettleMs?: number; |
| } = {}, |
| ): Promise<void> { |
| const timeout = opts.timeout ?? 45_000; |
| const layoutSettleMs = opts.layoutSettleMs ?? 700; |
|
|
| const bobMarker = page |
| .locator(".ProseMirror [data-type='block-math']") |
| .first(); |
| const carolMarker = page |
| .locator(".ProseMirror pre") |
| .filter({ hasText: /import\s+torch/i }) |
| .first(); |
|
|
| try { |
| await Promise.all([ |
| bobMarker.waitFor({ state: "attached", timeout }), |
| carolMarker.waitFor({ state: "attached", timeout }), |
| ]); |
| } catch (err) { |
| console.warn( |
| "[sync] waitForPeersSettled timed out:", |
| err instanceof Error ? err.message : err, |
| ); |
| } |
|
|
| |
| |
| |
| await page.waitForTimeout(layoutSettleMs); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function focusParagraphAfterHeading( |
| page: Page, |
| headingText: string, |
| ): Promise<boolean> { |
| return await page.evaluate((text) => { |
| const root = document.querySelector(".ProseMirror") as HTMLElement | null; |
| if (!root) return false; |
| const headings = Array.from(root.querySelectorAll("h2")); |
| const wanted = text.toLowerCase(); |
| const heading = headings.find( |
| (h) => (h.textContent || "").trim().toLowerCase().includes(wanted), |
| ); |
| if (!heading) return false; |
| let para: Element | null = heading.nextElementSibling; |
| while (para && para.tagName !== "P") para = para.nextElementSibling; |
| if (!para) return false; |
| (para as HTMLElement).focus(); |
| const range = document.createRange(); |
| range.selectNodeContents(para); |
| range.collapse(false); |
| const sel = window.getSelection(); |
| if (!sel) return false; |
| sel.removeAllRanges(); |
| sel.addRange(range); |
| return true; |
| }, headingText); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function focusNthParagraphInSection( |
| page: Page, |
| headingText: string, |
| slotIndex: number, |
| ): Promise<boolean> { |
| |
| |
| |
| |
| const rect = await page.evaluate( |
| ({ text, n }) => { |
| const root = document.querySelector(".ProseMirror") as HTMLElement | null; |
| if (!root) return null; |
| const headings = Array.from(root.querySelectorAll("h2")); |
| const wanted = text.toLowerCase(); |
| const heading = headings.find( |
| (h) => (h.textContent || "").trim().toLowerCase().includes(wanted), |
| ); |
| if (!heading) return null; |
|
|
| const slots: HTMLElement[] = []; |
| let node: Element | null = heading.nextElementSibling; |
| while (node) { |
| if (/^H[1-6]$/.test(node.tagName)) break; |
| slots.push(node as HTMLElement); |
| node = node.nextElementSibling; |
| } |
|
|
| const target = slots[n]; |
| if (!target) return null; |
| target.scrollIntoView({ behavior: "auto", block: "center" }); |
|
|
| const r = target.getBoundingClientRect(); |
| return { |
| x: Math.max(r.left + 1, r.right - 4), |
| y: r.top + r.height / 2, |
| }; |
| }, |
| { text: headingText, n: slotIndex }, |
| ); |
|
|
| if (!rect) return false; |
|
|
| |
| |
| await pause(120, 220); |
| await humanMove(page, rect.x, rect.y, { steps: 20 }); |
|
|
| |
| |
| |
| return await page.evaluate( |
| ({ text, n, clickX, clickY }) => { |
| const g = globalThis as unknown as { __name?: <T>(fn: T) => T }; |
| if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn; |
|
|
| const root = document.querySelector(".ProseMirror") as HTMLElement | null; |
| if (!root) return false; |
| const headings = Array.from(root.querySelectorAll("h2")); |
| const wanted = text.toLowerCase(); |
| const heading = headings.find( |
| (h) => (h.textContent || "").trim().toLowerCase().includes(wanted), |
| ); |
| if (!heading) return false; |
|
|
| const slots: HTMLElement[] = []; |
| let node: Element | null = heading.nextElementSibling; |
| while (node) { |
| if (/^H[1-6]$/.test(node.tagName)) break; |
| slots.push(node as HTMLElement); |
| node = node.nextElementSibling; |
| } |
|
|
| const target = slots[n]; |
| if (!target) return false; |
|
|
| const mk = (type: string) => |
| new MouseEvent(type, { |
| bubbles: true, |
| cancelable: true, |
| view: window, |
| clientX: clickX, |
| clientY: clickY, |
| button: 0, |
| buttons: type === "mousedown" ? 1 : 0, |
| }); |
|
|
| target.dispatchEvent(mk("mousedown")); |
| target.dispatchEvent(mk("mouseup")); |
| target.dispatchEvent(mk("click")); |
|
|
| const range = document.createRange(); |
| range.selectNodeContents(target); |
| range.collapse(false); |
| const sel = window.getSelection(); |
| if (sel) { |
| sel.removeAllRanges(); |
| sel.addRange(range); |
| } |
| return true; |
| }, |
| { text: headingText, n: slotIndex, clickX: rect.x, clickY: rect.y }, |
| ); |
| } |
|
|