| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import type { Locator, Page } from "playwright"; |
|
|
| const OVERLAY_ID = "__demo-mouse-cursor"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const OVERLAY_SCRIPT = ` |
| (() => { |
| const OVERLAY_ID = ${JSON.stringify(OVERLAY_ID)}; |
| try { |
| if (window.top !== window) return; |
| } catch (_) { /* cross-origin top; still install */ } |
| |
| const mount = () => { |
| if (document.getElementById(OVERLAY_ID)) return; |
| |
| const host = document.createElement("div"); |
| host.id = OVERLAY_ID; |
| host.setAttribute("aria-hidden", "true"); |
| // Use the HTML popover API so the overlay sits in the top-layer, |
| // above every native <dialog showModal()>. Regular z-index would |
| // lose to the browser's top-layer stacking order. Falls back to |
| // plain fixed positioning if the user agent is too old to |
| // support popover (our Chromium is Chrome 114+, fine). |
| try { host.setAttribute("popover", "manual"); } catch (_) { /* ignore */ } |
| host.style.position = "fixed"; |
| host.style.inset = "0"; |
| host.style.top = "0"; |
| host.style.left = "0"; |
| host.style.width = "100vw"; |
| host.style.height = "100vh"; |
| host.style.pointerEvents = "none"; |
| host.style.zIndex = "2147483646"; |
| host.style.margin = "0"; |
| host.style.padding = "0"; |
| host.style.border = "0"; |
| host.style.background = "transparent"; |
| host.style.overflow = "visible"; |
| |
| const NS = "http://www.w3.org/2000/svg"; |
| const pointer = document.createElementNS(NS, "svg"); |
| pointer.setAttribute("width", "26"); |
| pointer.setAttribute("height", "26"); |
| pointer.setAttribute("viewBox", "0 0 26 26"); |
| pointer.setAttribute("fill", "none"); |
| pointer.style.position = "absolute"; |
| pointer.style.left = "0"; |
| pointer.style.top = "0"; |
| pointer.style.transform = "translate3d(-100px, -100px, 0)"; |
| pointer.style.filter = "drop-shadow(0 2px 4px rgba(0,0,0,0.45))"; |
| pointer.style.willChange = "transform"; |
| |
| const arrow = document.createElementNS(NS, "path"); |
| arrow.setAttribute( |
| "d", |
| "M4 3 L4 20 L9 15.5 L12 22 L14.8 20.8 L11.9 14.3 L19 14.3 Z" |
| ); |
| arrow.setAttribute("fill", "#ffffff"); |
| arrow.setAttribute("stroke", "#0a0a0a"); |
| arrow.setAttribute("stroke-width", "1.4"); |
| arrow.setAttribute("stroke-linejoin", "round"); |
| pointer.appendChild(arrow); |
| |
| const ripple = document.createElement("div"); |
| ripple.style.position = "absolute"; |
| ripple.style.left = "0"; |
| ripple.style.top = "0"; |
| ripple.style.width = "22px"; |
| ripple.style.height = "22px"; |
| ripple.style.borderRadius = "50%"; |
| ripple.style.border = "2px solid rgba(255, 186, 70, 0.95)"; |
| ripple.style.background = "rgba(255, 200, 80, 0.22)"; |
| ripple.style.transform = "translate3d(-100px, -100px, 0) scale(0)"; |
| ripple.style.opacity = "0"; |
| ripple.style.pointerEvents = "none"; |
| |
| const style = document.createElement("style"); |
| style.textContent = |
| "@keyframes __demo-mouse-ripple {" + |
| " 0% { transform: translate3d(var(--rx), var(--ry), 0) scale(0.15); opacity: 0.95; }" + |
| " 80% { opacity: 0.35; }" + |
| " 100% { transform: translate3d(var(--rx), var(--ry), 0) scale(2.6); opacity: 0; }" + |
| "}" + |
| "#" + OVERLAY_ID + " [data-ripple-active] {" + |
| " animation: __demo-mouse-ripple 0.6s cubic-bezier(0.2, 0.6, 0.2, 1) forwards;" + |
| "}"; |
| |
| host.appendChild(style); |
| host.appendChild(pointer); |
| host.appendChild(ripple); |
| document.documentElement.appendChild(host); |
| |
| // Promote into the top-layer so the cursor stays above any |
| // <dialog showModal()>, popovers, or other top-layer elements. |
| try { |
| const anyHost = host; |
| if (typeof anyHost.showPopover === "function") { |
| anyHost.showPopover(); |
| } |
| } catch (_) { /* fall back to plain fixed positioning */ } |
| |
| let x = -100; |
| let y = -100; |
| const updatePointer = () => { |
| pointer.style.transform = "translate3d(" + x + "px, " + y + "px, 0)"; |
| }; |
| updatePointer(); |
| |
| window.addEventListener("mousemove", (ev) => { |
| x = ev.clientX; |
| y = ev.clientY; |
| updatePointer(); |
| }, true); |
| |
| window.addEventListener("mousedown", () => { |
| ripple.removeAttribute("data-ripple-active"); |
| ripple.style.setProperty("--rx", (x - 11) + "px"); |
| ripple.style.setProperty("--ry", (y - 11) + "px"); |
| // Force reflow so the animation restarts on every click. |
| void ripple.offsetWidth; |
| ripple.setAttribute("data-ripple-active", "true"); |
| }, true); |
| |
| // Breadcrumb so callers can verify the overlay mounted via the |
| // DOM (page.evaluate) without needing access to the page console. |
| document.documentElement.setAttribute("data-demo-mouse-cursor", "on"); |
| |
| // Re-promote ourselves in the top-layer whenever another element |
| // joins it (a <dialog showModal()> or a sibling popover). The |
| // browser stacks top-layer elements in LIFO order, so the last |
| // one promoted sits on top. Without this, our cursor would end |
| // up UNDER the author dialog as soon as Alice opens it. |
| const repromote = () => { |
| try { |
| const anyHost = host; |
| if (typeof anyHost.hidePopover === "function") { |
| anyHost.hidePopover(); |
| } |
| if (typeof anyHost.showPopover === "function") { |
| anyHost.showPopover(); |
| } |
| } catch (_) { /* ignore */ } |
| }; |
| |
| const isTopLayerCandidate = (el) => { |
| if (!el || el === host) return false; |
| if (el.nodeName === "DIALOG" && el.hasAttribute("open")) return true; |
| if (el.hasAttribute && el.hasAttribute("popover")) return true; |
| return false; |
| }; |
| |
| const domObserver = new MutationObserver((mutations) => { |
| for (const m of mutations) { |
| if (m.type === "attributes") { |
| if (isTopLayerCandidate(m.target)) { |
| repromote(); |
| return; |
| } |
| } else if (m.type === "childList") { |
| for (const n of Array.from(m.addedNodes)) { |
| if (n.nodeType === 1 && isTopLayerCandidate(n)) { |
| repromote(); |
| return; |
| } |
| } |
| } |
| } |
| }); |
| try { |
| domObserver.observe(document.documentElement, { |
| subtree: true, |
| childList: true, |
| attributes: true, |
| attributeFilter: ["open", "popover"], |
| }); |
| } catch (_) { /* ignore */ } |
| }; |
| |
| if (document.readyState === "loading") { |
| document.addEventListener("DOMContentLoaded", mount, { once: true }); |
| } else { |
| mount(); |
| } |
| |
| // Re-mount on history/SPA navigations that don't reload the document |
| // (e.g. the published article link kept inside the same SPA tree). |
| // We don't have such cases today, but the check is cheap. |
| const observer = new MutationObserver(() => { |
| if (!document.getElementById(OVERLAY_ID) && document.documentElement) { |
| mount(); |
| } |
| }); |
| try { |
| observer.observe(document.documentElement, { childList: true }); |
| } catch (_) { /* ignore */ } |
| })(); |
| `; |
|
|
| |
| |
| |
| |
| |
| |
| export async function injectMouseCursor(target: { |
| addInitScript: ( |
| script: { content: string } | string | ((arg: unknown) => unknown), |
| arg?: unknown, |
| ) => Promise<void>; |
| }): Promise<void> { |
| await target.addInitScript({ content: OVERLAY_SCRIPT }); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function verifyMouseCursor(page: Page): Promise<boolean> { |
| try { |
| return await page.evaluate( |
| () => document.documentElement.getAttribute("data-demo-mouse-cursor") === "on", |
| ); |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
|
|
| interface MoveOpts { |
| |
| durationMs?: number; |
| |
| steps?: number; |
| |
| jitter?: number; |
| } |
|
|
| let lastX = 40; |
| let lastY = 40; |
|
|
| function easeInOutCubic(t: number): number { |
| return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function humanMove( |
| page: Page, |
| x: number, |
| y: number, |
| opts: MoveOpts = {}, |
| ): Promise<void> { |
| const jitter = opts.jitter ?? 2; |
| const tx = x + (Math.random() * 2 - 1) * jitter; |
| const ty = y + (Math.random() * 2 - 1) * jitter; |
|
|
| const dx = tx - lastX; |
| const dy = ty - lastY; |
| const dist = Math.hypot(dx, dy); |
|
|
| const steps = |
| opts.steps ?? Math.max(10, Math.min(40, Math.round(dist / 18))); |
| const duration = opts.durationMs ?? Math.max(260, Math.min(700, dist * 0.9)); |
| const stepDelay = duration / steps; |
|
|
| |
| |
| |
| const perp = { x: -dy, y: dx }; |
| const perpLen = Math.hypot(perp.x, perp.y) || 1; |
| const curveSide = Math.random() < 0.5 ? 1 : -1; |
| const curveAmp = Math.min(60, dist * 0.12) * curveSide; |
| const ctrl = { |
| x: lastX + dx * 0.5 + (perp.x / perpLen) * curveAmp, |
| y: lastY + dy * 0.5 + (perp.y / perpLen) * curveAmp, |
| }; |
|
|
| for (let i = 1; i <= steps; i++) { |
| const t = easeInOutCubic(i / steps); |
| const u = 1 - t; |
| const px = u * u * lastX + 2 * u * t * ctrl.x + t * t * tx; |
| const py = u * u * lastY + 2 * u * t * ctrl.y + t * t * ty; |
| await page.mouse.move(px, py); |
| if (stepDelay > 0) { |
| await new Promise((r) => setTimeout(r, stepDelay)); |
| } |
| } |
|
|
| lastX = tx; |
| lastY = ty; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function humanHover( |
| page: Page, |
| locator: Locator, |
| opts: MoveOpts = {}, |
| ): Promise<boolean> { |
| const handle = await locator.first().elementHandle(); |
| if (!handle) return false; |
| const box = await handle.boundingBox(); |
| if (!box) return false; |
| const cx = box.x + box.width / 2; |
| const cy = box.y + box.height / 2; |
| await humanMove(page, cx, cy, opts); |
| return true; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function humanClick( |
| page: Page, |
| locator: Locator, |
| opts: MoveOpts & { settleMs?: [number, number] } = {}, |
| ): Promise<void> { |
| const moved = await humanHover(page, locator, opts); |
| if (!moved) { |
| await locator.click(); |
| return; |
| } |
| const [lo, hi] = opts.settleMs ?? [60, 140]; |
| const pause = lo + Math.random() * (hi - lo); |
| await new Promise((r) => setTimeout(r, pause)); |
| await page.mouse.down(); |
| await new Promise((r) => setTimeout(r, 30 + Math.random() * 30)); |
| await page.mouse.up(); |
| } |
|
|
| |
| |
| |
| export function resetMouseState(x = 40, y = 40): void { |
| lastX = x; |
| lastY = y; |
| } |
|
|