playwriter 0.0.105 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bippy.js +5 -5
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +17 -5
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cli-help.test.d.ts +2 -0
- package/dist/cli-help.test.d.ts.map +1 -0
- package/dist/cli-help.test.js +53 -0
- package/dist/cli-help.test.js.map +1 -0
- package/dist/cli.js +74 -25
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +1 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +55 -12
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +675 -27
- package/dist/extension/manifest.json +1 -1
- package/dist/ghost-cursor-client.js +170 -83
- package/dist/{recording-ghost-cursor.d.ts → ghost-cursor-controller.d.ts} +15 -10
- package/dist/ghost-cursor-controller.d.ts.map +1 -0
- package/dist/ghost-cursor-controller.js +98 -0
- package/dist/ghost-cursor-controller.js.map +1 -0
- package/dist/ghost-cursor.d.ts.map +1 -1
- package/dist/ghost-cursor.js +42 -26
- package/dist/ghost-cursor.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +6 -1
- package/dist/mcp.js.map +1 -1
- package/dist/on-mouse-action.test.js +25 -0
- package/dist/on-mouse-action.test.js.map +1 -1
- package/dist/performance-examples.d.ts +5 -0
- package/dist/performance-examples.d.ts.map +1 -0
- package/dist/performance-examples.js +112 -0
- package/dist/performance-examples.js.map +1 -0
- package/dist/performance-profiling.md +417 -0
- package/dist/prompt.md +22 -8
- package/dist/react-source.d.ts +44 -0
- package/dist/react-source.d.ts.map +1 -1
- package/dist/react-source.js +207 -20
- package/dist/react-source.js.map +1 -1
- package/dist/readability.js +1 -1
- package/dist/relay-core.test.d.ts.map +1 -1
- package/dist/relay-core.test.js +101 -1
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-session.test.js +34 -6
- package/dist/relay-session.test.js.map +1 -1
- package/dist/screen-recording.d.ts +2 -2
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +19 -7
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/package.json +7 -7
- package/src/aria-snapshots/github-interactive.txt +5 -3
- package/src/aria-snapshots/github-raw.txt +8 -5
- package/src/aria-snapshots/hackernews-interactive.txt +241 -238
- package/src/aria-snapshots/hackernews-raw.txt +269 -265
- package/src/aria-snapshots/prosemirror-interactive.txt +3 -1
- package/src/aria-snapshots/prosemirror-raw.txt +4 -1
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/cdp-relay.ts +17 -5
- package/src/cli-help.test.ts +63 -0
- package/src/cli.ts +80 -28
- package/src/executor.ts +65 -15
- package/src/ghost-cursor-client.ts +221 -96
- package/src/{recording-ghost-cursor.ts → ghost-cursor-controller.ts} +50 -34
- package/src/ghost-cursor.ts +54 -41
- package/src/mcp.ts +6 -1
- package/src/on-mouse-action.test.ts +30 -0
- package/src/performance-examples.ts +186 -0
- package/src/react-source.ts +310 -24
- package/src/relay-core.test.ts +117 -0
- package/src/relay-session.test.ts +36 -10
- package/src/screen-recording.ts +23 -10
- package/src/skill.md +33 -9
- package/src/snapshots/shadcn-ui-accessibility-full.md +6 -3
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +2 -0
- package/dist/recording-ghost-cursor.d.ts.map +0 -1
- package/dist/recording-ghost-cursor.js +0 -79
- package/dist/recording-ghost-cursor.js.map +0 -1
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-side ghost cursor renderer.
|
|
3
|
-
*
|
|
2
|
+
* Browser-side ghost cursor renderer, injected into every Playwriter-attached tab.
|
|
3
|
+
* Auto-enables on load (top frame only). Idles out after 5s of no activity.
|
|
4
|
+
*
|
|
5
|
+
* Two-element DOM structure so move and press have independent CSS transitions:
|
|
6
|
+
* outer (#__playwriter_ghost_cursor__) → translate3d, move easing/duration
|
|
7
|
+
* inner (first child) → scale + opacity, press easing/duration
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import { SCREENSTUDIO_POINTER_MACOS_TAHOE_DATA_URL } from './assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js'
|
|
7
11
|
|
|
12
|
+
// Top-frame only — skip iframes. try/catch for sandboxed iframes that throw.
|
|
13
|
+
const isTopFrame = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return window === window.top
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
})()
|
|
20
|
+
|
|
8
21
|
type GhostCursorActionType = 'move' | 'down' | 'up' | 'wheel'
|
|
9
22
|
type GhostCursorButton = 'left' | 'right' | 'middle' | 'none'
|
|
10
23
|
type GhostCursorStyle = 'minimal' | 'dot' | 'screenstudio'
|
|
@@ -39,13 +52,15 @@ interface GhostCursorRuntimeOptions {
|
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
interface GhostCursorRuntimeState {
|
|
42
|
-
|
|
55
|
+
outerElement: HTMLDivElement | null
|
|
56
|
+
innerElement: HTMLDivElement | null
|
|
43
57
|
options: GhostCursorRuntimeOptions
|
|
44
58
|
x: number
|
|
45
59
|
y: number
|
|
46
60
|
scale: number
|
|
47
61
|
hasPosition: boolean
|
|
48
62
|
enabled: boolean
|
|
63
|
+
idleHidden: boolean
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
interface GhostCursorApi {
|
|
@@ -66,27 +81,42 @@ const SCREENSTUDIO_HOTSPOT_Y_RATIO = 0.06
|
|
|
66
81
|
const MINIMAL_TRIANGLE_HOTSPOT_X_RATIO = 0.07
|
|
67
82
|
const MINIMAL_TRIANGLE_HOTSPOT_Y_RATIO = 0.06
|
|
68
83
|
|
|
84
|
+
// Animation curves from Emil Kowalski's guidelines (https://animations.dev):
|
|
85
|
+
// moves use ease-in-out (accel/decel), presses use strong ease-out (100-160ms).
|
|
86
|
+
const MOVE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)' // easeInOutCubic
|
|
87
|
+
const PRESS_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)' // strong ease-out
|
|
88
|
+
const PRESS_DURATION_MS = 140
|
|
89
|
+
|
|
90
|
+
// Cursor fades out after 5s of no activity, wakes on next action.
|
|
91
|
+
const IDLE_HIDE_DELAY_MS = 5000
|
|
92
|
+
const IDLE_FADE_OUT_MS = 600
|
|
93
|
+
|
|
69
94
|
const DEFAULT_OPTIONS: GhostCursorRuntimeOptions = {
|
|
70
95
|
style: 'minimal',
|
|
71
96
|
color: '#111827',
|
|
72
97
|
size: 22,
|
|
73
98
|
zIndex: 2147483647,
|
|
74
|
-
easing:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
easing: MOVE_EASING,
|
|
100
|
+
// Slow enough to track with the eye. Override per-call via ghostCursor.show().
|
|
101
|
+
minDurationMs: 220,
|
|
102
|
+
maxDurationMs: 1500,
|
|
103
|
+
speedPxPerMs: 1.2,
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
const runtime: GhostCursorRuntimeState = {
|
|
81
|
-
|
|
107
|
+
outerElement: null,
|
|
108
|
+
innerElement: null,
|
|
82
109
|
options: DEFAULT_OPTIONS,
|
|
83
110
|
x: 0,
|
|
84
111
|
y: 0,
|
|
85
112
|
scale: 1,
|
|
86
113
|
hasPosition: false,
|
|
87
114
|
enabled: false,
|
|
115
|
+
idleHidden: false,
|
|
88
116
|
}
|
|
89
117
|
|
|
118
|
+
let idleHideTimer: ReturnType<typeof setTimeout> | null = null
|
|
119
|
+
|
|
90
120
|
function clamp(options: { value: number; min: number; max: number }): number {
|
|
91
121
|
const { value, min, max } = options
|
|
92
122
|
return Math.min(max, Math.max(min, value))
|
|
@@ -159,13 +189,23 @@ function getBaseOpacity(): string {
|
|
|
159
189
|
return '0.72'
|
|
160
190
|
}
|
|
161
191
|
|
|
162
|
-
|
|
163
|
-
|
|
192
|
+
// Outer element: translate only (move timing).
|
|
193
|
+
function applyTranslate(): void {
|
|
194
|
+
if (!runtime.outerElement) {
|
|
164
195
|
return
|
|
165
196
|
}
|
|
166
197
|
|
|
167
198
|
const hotspot = getHotspotOffsetPx()
|
|
168
|
-
runtime.
|
|
199
|
+
runtime.outerElement.style.transform = `translate3d(${runtime.x - hotspot.x}px, ${runtime.y - hotspot.y}px, 0)`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Inner element: scale only (press timing).
|
|
203
|
+
function applyScale(): void {
|
|
204
|
+
if (!runtime.innerElement) {
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
runtime.innerElement.style.transform = `scale(${runtime.scale})`
|
|
169
209
|
}
|
|
170
210
|
|
|
171
211
|
function computeDurationMs(options: { targetX: number; targetY: number }): number {
|
|
@@ -185,93 +225,139 @@ function computeDurationMs(options: { targetX: number; targetY: number }): numbe
|
|
|
185
225
|
})
|
|
186
226
|
}
|
|
187
227
|
|
|
188
|
-
function createCursorElement() {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
function createCursorElement(): HTMLDivElement {
|
|
229
|
+
const outer = document.createElement('div')
|
|
230
|
+
outer.id = CURSOR_ID
|
|
231
|
+
outer.setAttribute('aria-hidden', 'true')
|
|
232
|
+
outer.style.position = 'fixed'
|
|
233
|
+
outer.style.left = '0'
|
|
234
|
+
outer.style.top = '0'
|
|
235
|
+
outer.style.pointerEvents = 'none'
|
|
236
|
+
outer.style.zIndex = `${runtime.options.zIndex}`
|
|
237
|
+
outer.style.transitionProperty = 'transform'
|
|
238
|
+
outer.style.transitionTimingFunction = runtime.options.easing
|
|
239
|
+
outer.style.transitionDuration = '0ms'
|
|
240
|
+
outer.style.willChange = 'transform'
|
|
241
|
+
|
|
242
|
+
const inner = document.createElement('div')
|
|
243
|
+
inner.style.transitionProperty = 'transform, opacity'
|
|
244
|
+
inner.style.transitionTimingFunction = PRESS_EASING
|
|
245
|
+
inner.style.transitionDuration = `${PRESS_DURATION_MS}ms`
|
|
246
|
+
inner.style.opacity = getBaseOpacity()
|
|
247
|
+
outer.appendChild(inner)
|
|
248
|
+
|
|
249
|
+
runtime.outerElement = outer
|
|
250
|
+
runtime.innerElement = inner
|
|
204
251
|
applyRuntimeVisualOptions()
|
|
205
252
|
|
|
206
|
-
return
|
|
253
|
+
return outer
|
|
207
254
|
}
|
|
208
255
|
|
|
209
|
-
function ensureCursorElement() {
|
|
210
|
-
const existing = document.getElementById(CURSOR_ID)
|
|
256
|
+
function ensureCursorElement(): HTMLDivElement {
|
|
257
|
+
const existing = document.getElementById(CURSOR_ID) as HTMLDivElement | null
|
|
211
258
|
if (existing) {
|
|
212
|
-
runtime.
|
|
259
|
+
runtime.outerElement = existing
|
|
260
|
+
runtime.innerElement = (existing.firstElementChild as HTMLDivElement) || null
|
|
213
261
|
return existing
|
|
214
262
|
}
|
|
215
263
|
|
|
216
|
-
const
|
|
217
|
-
runtime.cursorElement = element
|
|
264
|
+
const outer = createCursorElement()
|
|
218
265
|
const root = document.documentElement || document.body
|
|
219
|
-
root.appendChild(
|
|
220
|
-
return
|
|
266
|
+
root.appendChild(outer)
|
|
267
|
+
return outer
|
|
221
268
|
}
|
|
222
269
|
|
|
223
270
|
function applyRuntimeVisualOptions(): void {
|
|
224
|
-
if (!runtime.
|
|
271
|
+
if (!runtime.innerElement) {
|
|
225
272
|
return
|
|
226
273
|
}
|
|
227
274
|
|
|
228
275
|
const dimensions = getCursorDimensions()
|
|
229
|
-
runtime.
|
|
230
|
-
runtime.
|
|
231
|
-
|
|
232
|
-
runtime.
|
|
276
|
+
runtime.innerElement.style.width = `${dimensions.width}px`
|
|
277
|
+
runtime.innerElement.style.height = `${dimensions.height}px`
|
|
278
|
+
|
|
279
|
+
if (runtime.outerElement) {
|
|
280
|
+
runtime.outerElement.style.zIndex = `${runtime.options.zIndex}`
|
|
281
|
+
runtime.outerElement.style.transitionTimingFunction = runtime.options.easing
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Scale around the hotspot so press doesn't shift the arrow tip.
|
|
285
|
+
const hotspot = getHotspotOffsetPx()
|
|
286
|
+
runtime.innerElement.style.transformOrigin = `${hotspot.x}px ${hotspot.y}px`
|
|
233
287
|
|
|
234
288
|
if (runtime.options.style === 'screenstudio') {
|
|
235
|
-
runtime.
|
|
236
|
-
runtime.
|
|
237
|
-
runtime.
|
|
238
|
-
runtime.
|
|
239
|
-
runtime.
|
|
240
|
-
runtime.
|
|
241
|
-
runtime.
|
|
242
|
-
runtime.
|
|
243
|
-
runtime.
|
|
244
|
-
runtime.
|
|
245
|
-
runtime.
|
|
289
|
+
runtime.innerElement.style.borderRadius = '0'
|
|
290
|
+
runtime.innerElement.style.border = 'none'
|
|
291
|
+
runtime.innerElement.style.backgroundColor = 'transparent'
|
|
292
|
+
runtime.innerElement.style.backgroundImage = `url("${SCREENSTUDIO_POINTER_MACOS_TAHOE_DATA_URL}")`
|
|
293
|
+
runtime.innerElement.style.backgroundRepeat = 'no-repeat'
|
|
294
|
+
runtime.innerElement.style.backgroundPosition = 'left top'
|
|
295
|
+
runtime.innerElement.style.backgroundSize = 'contain'
|
|
296
|
+
runtime.innerElement.style.backdropFilter = 'none'
|
|
297
|
+
runtime.innerElement.style.filter = 'none'
|
|
298
|
+
runtime.innerElement.style.boxShadow = 'none'
|
|
299
|
+
runtime.innerElement.style.opacity = getBaseOpacity()
|
|
246
300
|
return
|
|
247
301
|
}
|
|
248
302
|
|
|
249
303
|
if (runtime.options.style === 'minimal') {
|
|
250
|
-
// White fill with dark border stroke, like a standard macOS cursor
|
|
251
304
|
const triangleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-1 -1 26 26"><path fill="white" stroke="${runtime.options.color}" stroke-width="1.5" stroke-linejoin="round" d="m23.284 19.124l-6.866-6.895a.4.4 0 0 1-.118-.296a.43.43 0 0 1 .163-.282l4.439-3.077a1.48 1.48 0 0 0 .621-1.48a1.48 1.48 0 0 0-1.036-1.198L1.623.302a1.14 1.14 0 0 0-1.11.282A1.13 1.13 0 0 0 .29 1.649L5.928 20.44a1.48 1.48 0 0 0 1.183 1.035a1.48 1.48 0 0 0 1.48-.621l3.078-4.44a.37.37 0 0 1 .31-.118a.43.43 0 0 1 .296.104l6.91 6.91a1.48 1.48 0 0 0 2.087 0l2.086-2.086a1.48 1.48 0 0 0-.074-2.101"/></svg>`
|
|
252
305
|
const triangleDataUrl = `url("data:image/svg+xml,${encodeURIComponent(triangleSvg)}")`
|
|
253
|
-
runtime.
|
|
254
|
-
runtime.
|
|
255
|
-
runtime.
|
|
256
|
-
runtime.
|
|
257
|
-
runtime.
|
|
258
|
-
runtime.
|
|
259
|
-
runtime.
|
|
260
|
-
runtime.
|
|
261
|
-
runtime.
|
|
262
|
-
runtime.
|
|
263
|
-
runtime.
|
|
306
|
+
runtime.innerElement.style.borderRadius = '0'
|
|
307
|
+
runtime.innerElement.style.border = 'none'
|
|
308
|
+
runtime.innerElement.style.backgroundColor = 'transparent'
|
|
309
|
+
runtime.innerElement.style.backgroundImage = triangleDataUrl
|
|
310
|
+
runtime.innerElement.style.backgroundRepeat = 'no-repeat'
|
|
311
|
+
runtime.innerElement.style.backgroundSize = 'contain'
|
|
312
|
+
runtime.innerElement.style.backgroundPosition = 'left top'
|
|
313
|
+
runtime.innerElement.style.backdropFilter = 'none'
|
|
314
|
+
runtime.innerElement.style.boxShadow = 'none'
|
|
315
|
+
runtime.innerElement.style.filter = 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))'
|
|
316
|
+
runtime.innerElement.style.opacity = getBaseOpacity()
|
|
264
317
|
return
|
|
265
318
|
}
|
|
266
319
|
|
|
267
|
-
runtime.
|
|
268
|
-
runtime.
|
|
269
|
-
runtime.
|
|
270
|
-
runtime.
|
|
271
|
-
runtime.
|
|
272
|
-
runtime.
|
|
273
|
-
runtime.
|
|
274
|
-
runtime.
|
|
320
|
+
runtime.innerElement.style.borderRadius = '999px'
|
|
321
|
+
runtime.innerElement.style.border = 'none'
|
|
322
|
+
runtime.innerElement.style.backgroundColor = runtime.options.color
|
|
323
|
+
runtime.innerElement.style.backgroundImage = 'none'
|
|
324
|
+
runtime.innerElement.style.backdropFilter = 'none'
|
|
325
|
+
runtime.innerElement.style.filter = 'none'
|
|
326
|
+
runtime.innerElement.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.18), inset 0 0 0 2px rgba(255, 255, 255, 0.55)'
|
|
327
|
+
runtime.innerElement.style.opacity = getBaseOpacity()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function clearIdleHideTimer(): void {
|
|
331
|
+
if (idleHideTimer !== null) {
|
|
332
|
+
clearTimeout(idleHideTimer)
|
|
333
|
+
idleHideTimer = null
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function scheduleIdleHide(): void {
|
|
338
|
+
clearIdleHideTimer()
|
|
339
|
+
idleHideTimer = setTimeout(() => {
|
|
340
|
+
idleHideTimer = null
|
|
341
|
+
if (!runtime.enabled || !runtime.innerElement) {
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
runtime.idleHidden = true
|
|
345
|
+
runtime.innerElement.style.transitionDuration = `${IDLE_FADE_OUT_MS}ms`
|
|
346
|
+
runtime.innerElement.style.transitionTimingFunction = PRESS_EASING
|
|
347
|
+
runtime.innerElement.style.opacity = '0'
|
|
348
|
+
}, IDLE_HIDE_DELAY_MS)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function wakeFromIdle(options: { x: number; y: number }): void {
|
|
352
|
+
// Teleport so moveCursor sees zero distance.
|
|
353
|
+
runtime.x = options.x
|
|
354
|
+
runtime.y = options.y
|
|
355
|
+
runtime.hasPosition = true
|
|
356
|
+
if (runtime.innerElement) {
|
|
357
|
+
runtime.innerElement.style.transitionDuration = `${PRESS_DURATION_MS}ms`
|
|
358
|
+
runtime.innerElement.style.transitionTimingFunction = PRESS_EASING
|
|
359
|
+
runtime.innerElement.style.opacity = getBaseOpacity()
|
|
360
|
+
}
|
|
275
361
|
}
|
|
276
362
|
|
|
277
363
|
function moveCursor(options: { x: number; y: number }): void {
|
|
@@ -279,31 +365,34 @@ function moveCursor(options: { x: number; y: number }): void {
|
|
|
279
365
|
return
|
|
280
366
|
}
|
|
281
367
|
|
|
282
|
-
|
|
368
|
+
ensureCursorElement()
|
|
283
369
|
const durationMs = computeDurationMs({ targetX: options.x, targetY: options.y })
|
|
284
|
-
|
|
370
|
+
if (runtime.outerElement) {
|
|
371
|
+
runtime.outerElement.style.transitionDuration = `${Math.round(durationMs)}ms`
|
|
372
|
+
runtime.outerElement.style.transitionTimingFunction = runtime.options.easing
|
|
373
|
+
}
|
|
285
374
|
|
|
286
375
|
runtime.x = options.x
|
|
287
376
|
runtime.y = options.y
|
|
288
377
|
runtime.hasPosition = true
|
|
289
|
-
|
|
378
|
+
applyTranslate()
|
|
290
379
|
}
|
|
291
380
|
|
|
292
381
|
function setPressed(options: { pressed: boolean }): void {
|
|
293
|
-
if (!runtime.enabled) {
|
|
382
|
+
if (!runtime.enabled || !runtime.innerElement) {
|
|
294
383
|
return
|
|
295
384
|
}
|
|
296
385
|
|
|
297
|
-
|
|
386
|
+
// Subtle press feedback (0.95). Dot style uses 0.92 — needs a bigger pulse.
|
|
298
387
|
runtime.scale = options.pressed
|
|
299
|
-
? runtime.options.style === '
|
|
300
|
-
? 0.
|
|
301
|
-
:
|
|
302
|
-
? 0.93
|
|
303
|
-
: 0.82
|
|
388
|
+
? runtime.options.style === 'dot'
|
|
389
|
+
? 0.92
|
|
390
|
+
: 0.95
|
|
304
391
|
: 1
|
|
305
|
-
|
|
306
|
-
|
|
392
|
+
runtime.innerElement.style.transitionDuration = `${PRESS_DURATION_MS}ms`
|
|
393
|
+
runtime.innerElement.style.transitionTimingFunction = PRESS_EASING
|
|
394
|
+
runtime.innerElement.style.opacity = options.pressed ? '1' : getBaseOpacity()
|
|
395
|
+
applyScale()
|
|
307
396
|
}
|
|
308
397
|
|
|
309
398
|
function enable(options?: GhostCursorClientOptions): void {
|
|
@@ -319,17 +408,27 @@ function enable(options?: GhostCursorClientOptions): void {
|
|
|
319
408
|
runtime.hasPosition = true
|
|
320
409
|
}
|
|
321
410
|
|
|
322
|
-
|
|
411
|
+
runtime.idleHidden = false
|
|
412
|
+
if (runtime.innerElement) {
|
|
413
|
+
runtime.innerElement.style.opacity = getBaseOpacity()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
applyTranslate()
|
|
417
|
+
applyScale()
|
|
418
|
+
scheduleIdleHide()
|
|
323
419
|
}
|
|
324
420
|
|
|
325
421
|
function disable(): void {
|
|
326
422
|
runtime.enabled = false
|
|
327
423
|
runtime.scale = 1
|
|
328
424
|
runtime.hasPosition = false
|
|
425
|
+
runtime.idleHidden = false
|
|
426
|
+
clearIdleHideTimer()
|
|
329
427
|
|
|
330
|
-
if (runtime.
|
|
331
|
-
runtime.
|
|
332
|
-
runtime.
|
|
428
|
+
if (runtime.outerElement) {
|
|
429
|
+
runtime.outerElement.remove()
|
|
430
|
+
runtime.outerElement = null
|
|
431
|
+
runtime.innerElement = null
|
|
333
432
|
}
|
|
334
433
|
}
|
|
335
434
|
|
|
@@ -338,21 +437,22 @@ function applyMouseAction(action: GhostCursorAction): void {
|
|
|
338
437
|
return
|
|
339
438
|
}
|
|
340
439
|
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
440
|
+
if (runtime.idleHidden) {
|
|
441
|
+
runtime.idleHidden = false
|
|
442
|
+
wakeFromIdle({ x: action.x, y: action.y })
|
|
344
443
|
}
|
|
345
444
|
|
|
346
|
-
if (action.type === '
|
|
445
|
+
if (action.type === 'move' || action.type === 'wheel') {
|
|
446
|
+
moveCursor({ x: action.x, y: action.y })
|
|
447
|
+
} else if (action.type === 'down') {
|
|
347
448
|
moveCursor({ x: action.x, y: action.y })
|
|
348
449
|
setPressed({ pressed: true })
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (action.type === 'up') {
|
|
450
|
+
} else if (action.type === 'up') {
|
|
353
451
|
moveCursor({ x: action.x, y: action.y })
|
|
354
452
|
setPressed({ pressed: false })
|
|
355
453
|
}
|
|
454
|
+
|
|
455
|
+
scheduleIdleHide()
|
|
356
456
|
}
|
|
357
457
|
|
|
358
458
|
const api: GhostCursorApi = {
|
|
@@ -364,6 +464,31 @@ const api: GhostCursorApi = {
|
|
|
364
464
|
},
|
|
365
465
|
}
|
|
366
466
|
|
|
367
|
-
|
|
467
|
+
if (isTopFrame) {
|
|
468
|
+
globalThis.__playwriterGhostCursor = api
|
|
469
|
+
|
|
470
|
+
// Auto-enable. Defer for early injection (addScriptToEvaluateOnNewDocument)
|
|
471
|
+
// when DOM isn't ready yet. After hard navigations the cursor re-centers
|
|
472
|
+
// until the next mouse action arrives.
|
|
473
|
+
try {
|
|
474
|
+
if (document.readyState === 'loading') {
|
|
475
|
+
document.addEventListener(
|
|
476
|
+
'DOMContentLoaded',
|
|
477
|
+
() => {
|
|
478
|
+
try {
|
|
479
|
+
api.enable()
|
|
480
|
+
} catch {
|
|
481
|
+
// Non-fatal — DOM may be in an unexpected state.
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
{ once: true },
|
|
485
|
+
)
|
|
486
|
+
} else {
|
|
487
|
+
api.enable()
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
// Restricted contexts (chrome://, devtools://) — silently skip.
|
|
491
|
+
}
|
|
492
|
+
}
|
|
368
493
|
|
|
369
494
|
export {}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Always-on ghost cursor controller (Node side).
|
|
3
|
+
*
|
|
4
|
+
* Wires page.onMouseAction → applyGhostCursorMouseAction for every page.
|
|
5
|
+
* Chains with any pre-existing onMouseAction callback. Cursor-apply is
|
|
6
|
+
* fire-and-forget via a per-page queue so it does not block action completion.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
import type { BrowserContext, Page } from '@xmorse/playwright-core'
|
|
@@ -11,7 +14,7 @@ import {
|
|
|
11
14
|
type GhostCursorClientOptions,
|
|
12
15
|
} from './ghost-cursor.js'
|
|
13
16
|
|
|
14
|
-
interface
|
|
17
|
+
interface GhostCursorLogger {
|
|
15
18
|
error: (...args: unknown[]) => void
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -20,12 +23,13 @@ interface RecordingTargetOptions {
|
|
|
20
23
|
sessionId?: string
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
export class
|
|
26
|
+
export class GhostCursorController {
|
|
24
27
|
private readonly previousMouseActionByPage = new WeakMap<Page, Page['onMouseAction']>()
|
|
25
28
|
private readonly cursorApplyQueueByPage = new WeakMap<Page, Promise<void>>()
|
|
26
|
-
private readonly
|
|
29
|
+
private readonly attachedPages = new WeakSet<Page>()
|
|
30
|
+
private readonly logger: GhostCursorLogger
|
|
27
31
|
|
|
28
|
-
constructor(options: { logger:
|
|
32
|
+
constructor(options: { logger: GhostCursorLogger }) {
|
|
29
33
|
this.logger = options.logger
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -53,61 +57,73 @@ export class RecordingGhostCursorController {
|
|
|
53
57
|
return defaultPage
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
|
|
60
|
+
/** Wire onMouseAction. Idempotent. */
|
|
61
|
+
attachToPage(options: { page: Page }): void {
|
|
57
62
|
const { page } = options
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
if (this.attachedPages.has(page)) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
this.attachedPages.add(page)
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
if (!this.previousMouseActionByPage.has(page)) {
|
|
70
|
+
this.previousMouseActionByPage.set(page, page.onMouseAction)
|
|
71
|
+
}
|
|
72
|
+
const previousMouseAction = this.previousMouseActionByPage.get(page)
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
page.onMouseAction = async (event) => {
|
|
75
|
+
// Ghost cursor must never crash the main Playwright action (click, move, etc).
|
|
76
|
+
// Wrap the entire cursor logic in try/catch so errors stay cosmetic.
|
|
77
|
+
try {
|
|
68
78
|
const pendingCursorApply = this.cursorApplyQueueByPage.get(page) || Promise.resolve()
|
|
69
79
|
const nextCursorApply = pendingCursorApply
|
|
70
80
|
.then(async () => {
|
|
71
81
|
await applyGhostCursorMouseAction({ page, event })
|
|
72
82
|
})
|
|
73
83
|
.catch((error) => {
|
|
84
|
+
if (page.isClosed()) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
74
87
|
this.logger.error('[playwriter] Failed to apply ghost cursor action', error)
|
|
75
88
|
})
|
|
76
89
|
this.cursorApplyQueueByPage.set(page, nextCursorApply)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.logger.error('[playwriter] Ghost cursor onMouseAction error (non-fatal)', error)
|
|
92
|
+
}
|
|
77
93
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
await previousMouseAction(event)
|
|
94
|
+
if (!previousMouseAction) {
|
|
95
|
+
return
|
|
83
96
|
}
|
|
84
|
-
|
|
85
|
-
page.onMouseAction = this.previousMouseActionByPage.get(page) ?? null
|
|
86
|
-
this.previousMouseActionByPage.delete(page)
|
|
87
|
-
this.logger.error('[playwriter] Failed to enable ghost cursor', error)
|
|
97
|
+
await previousMouseAction(event)
|
|
88
98
|
}
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
|
|
101
|
+
detachFromPage(options: { page: Page }): void {
|
|
92
102
|
const { page } = options
|
|
103
|
+
if (!this.attachedPages.has(page)) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
this.attachedPages.delete(page)
|
|
93
107
|
page.onMouseAction = this.previousMouseActionByPage.get(page) ?? null
|
|
94
108
|
this.previousMouseActionByPage.delete(page)
|
|
95
109
|
this.cursorApplyQueueByPage.delete(page)
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
await disableGhostCursor({ page })
|
|
99
|
-
} catch (error) {
|
|
100
|
-
this.logger.error('[playwriter] Failed to disable ghost cursor', error)
|
|
101
|
-
}
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
async show(options: { page: Page; cursorOptions?: GhostCursorClientOptions }): Promise<void> {
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
try {
|
|
114
|
+
const { page, cursorOptions } = options
|
|
115
|
+
await enableGhostCursor({ page, cursorOptions })
|
|
116
|
+
} catch {
|
|
117
|
+
// Non-fatal — page may be closing or navigating.
|
|
118
|
+
}
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
async hide(options: { page: Page }): Promise<void> {
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
try {
|
|
123
|
+
const { page } = options
|
|
124
|
+
await disableGhostCursor({ page })
|
|
125
|
+
} catch {
|
|
126
|
+
// Non-fatal — page may be closing or navigating.
|
|
127
|
+
}
|
|
112
128
|
}
|
|
113
129
|
}
|