Skip to content
Merged
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
213 changes: 141 additions & 72 deletions src/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ export interface CursorHighlightOptions {
}

/**
* Enables a persistent cursor highlight that follows the mouse pointer.
* The highlight remains active until `resetCursor(page)` is called.
* Calling again replaces the existing highlight.
* Shared implementation for `cursorHighlight` (always replaces) and
* `ensureCursorHighlight` (no-op when a ring is already installed).
*/
export async function cursorHighlight(
async function applyCursorHighlight(
page: Page,
opts?: CursorHighlightOptions,
opts: CursorHighlightOptions | undefined,
skipIfPresent: boolean,
): Promise<void> {
const color = opts?.color ?? '#3b82f6';
const radius = opts?.radius ?? 20;
Expand All @@ -33,82 +33,117 @@ export async function cursorHighlight(

try {
await page.evaluate(
({ color, radius, pulse, clickRipple, opacity, attr, id }) => {
// Remove existing highlight
document.getElementById(id)?.remove();
document.querySelectorAll(`[${attr}]`).forEach(el => el.remove());

// Inject keyframe styles
const style = document.createElement('style');
style.setAttribute(attr, 'style');
style.textContent = `
@keyframes argo-cursor-pulse {
0%, 100% { box-shadow: 0 0 0 2px ${color}${Math.round(opacity * 255).toString(16).padStart(2, '0')}, 0 0 ${radius * 0.6}px ${color}33; }
50% { box-shadow: 0 0 0 3px ${color}${Math.round(opacity * 255 * 0.8).toString(16).padStart(2, '0')}, 0 0 ${radius}px ${color}55; }
}
@keyframes argo-cursor-ripple {
0% { transform: translate(-50%, -50%) scale(1); opacity: ${opacity}; }
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
({ color, radius, pulse, clickRipple, opacity, attr, id, skipIfPresent }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = window as any;

// Idempotent path: the caller only wants a ring to exist, and one is
// already installed (or queued behind DOMContentLoaded). Bail before
// touching the DOM so same-document (SPA) navigations don't tear down
// and rebuild a perfectly good overlay.
if (skipIfPresent && (document.getElementById(id) || w.__argoCursorPending)) return;

// Generation counter: a later call supersedes any install still
// waiting on DOMContentLoaded, so we never stack two rings.
const gen = (w.__argoCursorGen = (w.__argoCursorGen || 0) + 1);

const install = (): void => {
if (w.__argoCursorGen !== gen) return; // superseded
w.__argoCursorPending = false;

// Remove existing highlight. Invoke its stored cleanup first —
// otherwise the previous document-level mousemove/click listeners
// survive as leaks bound to a detached node.
const previous = document.getElementById(id);
if (previous) {
try { (previous as any).__cleanup?.(); } catch { /* best-effort */ }
previous.remove();
}
`;
document.head.appendChild(style);

// Create highlight element
const dot = document.createElement('div');
dot.id = id;
dot.setAttribute(attr, 'highlight');
dot.style.cssText = `
position: fixed; z-index: 99998; pointer-events: none;
width: ${radius * 2}px; height: ${radius * 2}px;
border-radius: 50%;
border: 2px solid ${color};
opacity: ${opacity};
transform: translate(-50%, -50%);
left: -100px; top: -100px;
transition: left 0.05s ease-out, top 0.05s ease-out;
${pulse ? `animation: argo-cursor-pulse 1.5s ease-in-out infinite;` : `box-shadow: 0 0 0 2px ${color}${Math.round(opacity * 255).toString(16).padStart(2, '0')}, 0 0 ${radius * 0.6}px ${color}33;`}
`;
document.body.appendChild(dot);

// Track mouse movement
const onMove = (e: MouseEvent) => {
dot.style.left = e.clientX + 'px';
dot.style.top = e.clientY + 'px';
};
document.addEventListener('mousemove', onMove, true);
document.querySelectorAll(`[${attr}]`).forEach(el => el.remove());

// Store cleanup reference
(dot as any).__cleanup = () => {
document.removeEventListener('mousemove', onMove, true);
};
// Inject keyframe styles
const style = document.createElement('style');
style.setAttribute(attr, 'style');
style.textContent = `
@keyframes argo-cursor-pulse {
0%, 100% { box-shadow: 0 0 0 2px ${color}${Math.round(opacity * 255).toString(16).padStart(2, '0')}, 0 0 ${radius * 0.6}px ${color}33; }
50% { box-shadow: 0 0 0 3px ${color}${Math.round(opacity * 255 * 0.8).toString(16).padStart(2, '0')}, 0 0 ${radius}px ${color}55; }
}
@keyframes argo-cursor-ripple {
0% { transform: translate(-50%, -50%) scale(1); opacity: ${opacity}; }
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
}
`;
(document.head ?? document.documentElement).appendChild(style);

// Create highlight element
const dot = document.createElement('div');
dot.id = id;
dot.setAttribute(attr, 'highlight');
dot.style.cssText = `
position: fixed; z-index: 99998; pointer-events: none;
width: ${radius * 2}px; height: ${radius * 2}px;
border-radius: 50%;
border: 2px solid ${color};
opacity: ${opacity};
transform: translate(-50%, -50%);
left: -100px; top: -100px;
transition: left 0.05s ease-out, top 0.05s ease-out;
${pulse ? `animation: argo-cursor-pulse 1.5s ease-in-out infinite;` : `box-shadow: 0 0 0 2px ${color}${Math.round(opacity * 255).toString(16).padStart(2, '0')}, 0 0 ${radius * 0.6}px ${color}33;`}
`;
document.body.appendChild(dot);

// Click ripple effect
if (clickRipple) {
const onClick = (e: MouseEvent) => {
const ripple = document.createElement('div');
ripple.setAttribute(attr, 'ripple');
ripple.style.cssText = `
position: fixed; z-index: 99997; pointer-events: none;
width: ${radius * 2}px; height: ${radius * 2}px;
border-radius: 50%;
border: 2px solid ${color};
left: ${e.clientX}px; top: ${e.clientY}px;
transform: translate(-50%, -50%);
animation: argo-cursor-ripple 0.4s ease-out forwards;
`;
document.body.appendChild(ripple);
setTimeout(() => ripple.remove(), 400);
// Track mouse movement
const onMove = (e: MouseEvent) => {
dot.style.left = e.clientX + 'px';
dot.style.top = e.clientY + 'px';
};
document.addEventListener('click', onClick, true);
document.addEventListener('mousemove', onMove, true);

const origCleanup = (dot as any).__cleanup;
// Store cleanup reference
(dot as any).__cleanup = () => {
origCleanup();
document.removeEventListener('click', onClick, true);
document.removeEventListener('mousemove', onMove, true);
};

// Click ripple effect
if (clickRipple) {
const onClick = (e: MouseEvent) => {
const ripple = document.createElement('div');
ripple.setAttribute(attr, 'ripple');
ripple.style.cssText = `
position: fixed; z-index: 99997; pointer-events: none;
width: ${radius * 2}px; height: ${radius * 2}px;
border-radius: 50%;
border: 2px solid ${color};
left: ${e.clientX}px; top: ${e.clientY}px;
transform: translate(-50%, -50%);
animation: argo-cursor-ripple 0.4s ease-out forwards;
`;
document.body.appendChild(ripple);
setTimeout(() => ripple.remove(), 400);
};
document.addEventListener('click', onClick, true);

const origCleanup = (dot as any).__cleanup;
(dot as any).__cleanup = () => {
origCleanup();
document.removeEventListener('click', onClick, true);
};
}
};

// `framenavigated` fires at navigation commit — the parser may not have
// produced <body> yet, and `document.body.appendChild` would throw a
// TypeError that isn't a disposal error (so it would surface as a
// warning and leave the page permanently cursor-less). Defer instead.
if (document.body) {
install();
} else {
w.__argoCursorPending = true;
document.addEventListener('DOMContentLoaded', install, { once: true });
}
},
{ color, radius, pulse, clickRipple, opacity, attr: CURSOR_ATTR, id: CURSOR_ID },
{ color, radius, pulse, clickRipple, opacity, attr: CURSOR_ATTR, id: CURSOR_ID, skipIfPresent },
);
} catch (err) {
const msg = (err as Error)?.message ?? '';
Expand All @@ -118,12 +153,46 @@ export async function cursorHighlight(
}
}

/**
* Enables a persistent cursor highlight that follows the mouse pointer.
* The highlight remains active until `resetCursor(page)` is called.
* Calling again replaces the existing highlight.
*/
export async function cursorHighlight(
page: Page,
opts?: CursorHighlightOptions,
): Promise<void> {
return applyCursorHighlight(page, opts, false);
}

/**
* Installs the cursor highlight only when one isn't already present.
*
* Used by the automatic (`video.cursorHighlight`) path after a navigation:
* Playwright emits `framenavigated` for same-document history navigations too,
* so an unconditional reinstall would rebuild the ring on every SPA route
* change — resetting it off-screen until the next mouse event and leaking the
* previous document listeners.
*/
export async function ensureCursorHighlight(
page: Page,
opts?: CursorHighlightOptions,
): Promise<void> {
return applyCursorHighlight(page, opts, true);
}

/**
* Removes the cursor highlight and all related elements.
*/
export async function resetCursor(page: Page): Promise<void> {
try {
await page.evaluate(({ attr, id }) => {
// Invalidate any install still queued behind DOMContentLoaded.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = window as any;
w.__argoCursorGen = (w.__argoCursorGen || 0) + 1;
w.__argoCursorPending = false;

const dot = document.getElementById(id);
if (dot && (dot as any).__cleanup) {
(dot as any).__cleanup();
Expand Down
34 changes: 24 additions & 10 deletions src/narration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Writable } from 'node:stream';
import { schedulePlacements, type Placement } from './tts/align.js';
import type { CameraMove } from './camera-move.js';
import { startCdpScreencast, type CdpScreencastHandle } from './cdp-screencast.js';
import { cursorHighlight, type CursorHighlightOptions } from './cursor.js';
import { cursorHighlight, ensureCursorHighlight, type CursorHighlightOptions } from './cursor.js';

/**
* Subset of Playwright's Page we depend on — typed structurally so we don't
Expand Down Expand Up @@ -319,22 +319,36 @@ export class NarrationTimeline {

} // close legacy `else` branch — showActions + timeline anchor below run for both paths.

// Set for BOTH capture paths (CDP-direct and legacy). This also enables
// `_triggerPaint()` from `mark()` on the chromium + jpeg-stitch path, where
// it was previously a silent no-op because `_recordingPage` stayed null.
this._recordingPage = page;

// Reinstall the pseudo-cursor after a top-level navigation because the
// browser replaces the document (and its overlay/listeners). Waiting for
// the injection before nudging paint keeps the first post-navigation frame
// from briefly appearing without the cursor overlay.
// CDP screencast only emits frames on paint. After page.goto() lands,
// Chrome can stay paused for the inter-paint window — gap-fill repeats
// the last (pre-nav) JPEG and the new page doesn't appear in the video
// until the next natural paint (could be seconds on heavy SPAs). Force
// a paint right after navigation so CDP unsticks promptly.
//
// When the automatic pseudo-cursor is on we also reinstall it, because a
// cross-document navigation replaces the document (and its overlay and
// listeners). `ensureCursorHighlight` no-ops when a ring is already there,
// which matters because Playwright emits `framenavigated` for
// same-document history navigations too — an unconditional reinstall would
// rebuild the ring on every SPA route change, resetting it off-screen until
// the next mouse event. The paint nudge deliberately does NOT wait on the
// injection (that would defer the very unsticking this listener exists for,
// since the evaluate blocks on the new document's execution context); we
// nudge again once the cursor lands so it shows up promptly either way.
const navListener = (frame: { parentFrame: () => unknown }): void => {
if (frame.parentFrame() !== null) return; // ignore subframe navs
if (automaticCursor) {
void cursorHighlight(
page as unknown as Parameters<typeof cursorHighlight>[0],
void ensureCursorHighlight(
page as unknown as Parameters<typeof ensureCursorHighlight>[0],
automaticCursor,
).then(() => this._triggerPaint());
} else {
this._triggerPaint();
).finally(() => this._triggerPaint());
}
this._triggerPaint();
};
this._navListener = navListener;
page.on('framenavigated', navListener);
Expand Down
Loading
Loading