@treelocator/runtime 0.3.2 → 0.4.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/browserApi.d.ts +45 -0
- package/dist/browserApi.js +23 -1
- package/dist/components/RecordingOutline.d.ts +5 -0
- package/dist/components/RecordingOutline.js +53 -0
- package/dist/components/RecordingResults.d.ts +25 -0
- package/dist/components/RecordingResults.js +272 -0
- package/dist/components/Runtime.js +505 -70
- package/dist/dejitter/recorder.d.ts +91 -0
- package/dist/dejitter/recorder.js +908 -0
- package/dist/functions/enrichAncestrySourceMaps.js +9 -2
- package/dist/output.css +13 -0
- package/package.json +2 -2
- package/src/browserApi.ts +74 -1
- package/src/components/RecordingOutline.tsx +66 -0
- package/src/components/RecordingResults.tsx +287 -0
- package/src/components/Runtime.tsx +534 -80
- package/src/dejitter/recorder.ts +938 -0
- package/src/functions/enrichAncestrySourceMaps.ts +9 -2
- package/.turbo/turbo-build.log +0 -32
- package/.turbo/turbo-dev.log +0 -32
- package/.turbo/turbo-test.log +0 -14
- package/.turbo/turbo-ts.log +0 -4
- package/LICENSE +0 -22
|
@@ -11,6 +11,9 @@ import { collectAncestry, formatAncestryChain } from "../functions/formatAncestr
|
|
|
11
11
|
import { enrichAncestryWithSourceMaps } from "../functions/enrichAncestrySourceMaps";
|
|
12
12
|
import { createTreeNode } from "../adapters/createTreeNode";
|
|
13
13
|
import treeIconUrl from "../_generated_tree_icon";
|
|
14
|
+
import { createDejitterRecorder, DejitterAPI, DejitterFinding, DejitterSummary } from "../dejitter/recorder";
|
|
15
|
+
import { RecordingOutline } from "./RecordingOutline";
|
|
16
|
+
import { RecordingResults, InteractionEvent } from "./RecordingResults";
|
|
14
17
|
|
|
15
18
|
type RuntimeProps = {
|
|
16
19
|
adapterId?: AdapterId;
|
|
@@ -25,7 +28,58 @@ function Runtime(props: RuntimeProps) {
|
|
|
25
28
|
const [toastMessage, setToastMessage] = createSignal<string | null>(null);
|
|
26
29
|
const [locatorActive, setLocatorActive] = createSignal<boolean>(false);
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
// Recording state machine: idle -> selecting -> recording -> results -> idle
|
|
32
|
+
type RecordingState = 'idle' | 'selecting' | 'recording' | 'results';
|
|
33
|
+
|
|
34
|
+
// --- localStorage persistence ---
|
|
35
|
+
const STORAGE_KEY = '__treelocator_recording__';
|
|
36
|
+
|
|
37
|
+
type SavedRecording = {
|
|
38
|
+
findings: DejitterFinding[];
|
|
39
|
+
summary: DejitterSummary | null;
|
|
40
|
+
data: any;
|
|
41
|
+
elementPath: string;
|
|
42
|
+
interactions: InteractionEvent[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function loadFromStorage(): { last: SavedRecording | null; previous: SavedRecording | null } {
|
|
46
|
+
try {
|
|
47
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
48
|
+
if (raw) return JSON.parse(raw);
|
|
49
|
+
} catch {}
|
|
50
|
+
return { last: null, previous: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveToStorage(current: SavedRecording) {
|
|
54
|
+
try {
|
|
55
|
+
const stored = loadFromStorage();
|
|
56
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
57
|
+
last: current,
|
|
58
|
+
previous: stored.last,
|
|
59
|
+
}));
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Restore last results on mount
|
|
64
|
+
const restored = loadFromStorage();
|
|
65
|
+
const restoredLast = restored.last;
|
|
66
|
+
|
|
67
|
+
const [recordingState, setRecordingState] = createSignal<RecordingState>(restoredLast ? 'results' : 'idle');
|
|
68
|
+
const [recordedElement, setRecordedElement] = createSignal<HTMLElement | null>(null);
|
|
69
|
+
const [recordingFindings, setRecordingFindings] = createSignal<DejitterFinding[]>(restoredLast?.findings ?? []);
|
|
70
|
+
const [recordingSummary, setRecordingSummary] = createSignal<DejitterSummary | null>(restoredLast?.summary ?? null);
|
|
71
|
+
const [interactionLog, setInteractionLog] = createSignal<InteractionEvent[]>(restoredLast?.interactions ?? []);
|
|
72
|
+
const [recordingData, setRecordingData] = createSignal<any>(restoredLast?.data ?? null);
|
|
73
|
+
const [recordingElementPath, setRecordingElementPath] = createSignal<string>(restoredLast?.elementPath ?? "");
|
|
74
|
+
const [replayBox, setReplayBox] = createSignal<{ x: number; y: number; w: number; h: number } | null>(null);
|
|
75
|
+
const [replaying, setReplaying] = createSignal(false);
|
|
76
|
+
const [viewingPrevious, setViewingPrevious] = createSignal(false);
|
|
77
|
+
let dejitterInstance: DejitterAPI | null = null;
|
|
78
|
+
let interactionClickHandler: ((e: MouseEvent) => void) | null = null;
|
|
79
|
+
let recordingStartTime = 0;
|
|
80
|
+
let replayTimerId: number | null = null;
|
|
81
|
+
|
|
82
|
+
const isActive = () => (holdingModKey() || locatorActive() || recordingState() === 'selecting') && currentElement();
|
|
29
83
|
|
|
30
84
|
createEffect(() => {
|
|
31
85
|
if (isActive()) {
|
|
@@ -35,6 +89,13 @@ function Runtime(props: RuntimeProps) {
|
|
|
35
89
|
}
|
|
36
90
|
});
|
|
37
91
|
|
|
92
|
+
// Expose replay functions on the browser API
|
|
93
|
+
if (typeof window !== "undefined" && (window as any).__treelocator__) {
|
|
94
|
+
(window as any).__treelocator__.replay = () => replayRecording();
|
|
95
|
+
(window as any).__treelocator__.replayWithRecord = (elementOrSelector: HTMLElement | string) =>
|
|
96
|
+
replayWithRecord(elementOrSelector);
|
|
97
|
+
}
|
|
98
|
+
|
|
38
99
|
function keyUpListener(e: KeyboardEvent) {
|
|
39
100
|
setHoldingModKey(isCombinationModifiersPressed(e));
|
|
40
101
|
}
|
|
@@ -48,92 +109,379 @@ function Runtime(props: RuntimeProps) {
|
|
|
48
109
|
setHoldingModKey(e.altKey);
|
|
49
110
|
}
|
|
50
111
|
|
|
51
|
-
function
|
|
52
|
-
// Also update modifier state
|
|
53
|
-
setHoldingModKey(e.altKey);
|
|
54
|
-
|
|
55
|
-
// Use elementsFromPoint to find elements including ones with pointer-events-none
|
|
112
|
+
function findElementAtPoint(e: MouseEvent): HTMLElement | null {
|
|
56
113
|
const elementsAtPoint = document.elementsFromPoint(e.clientX, e.clientY);
|
|
57
|
-
|
|
58
|
-
// Find the topmost element with locator data for highlighting
|
|
59
|
-
let element: HTMLElement | null = null;
|
|
60
114
|
for (const el of elementsAtPoint) {
|
|
61
|
-
if (isLocatorsOwnElement(el as HTMLElement))
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
115
|
+
if (isLocatorsOwnElement(el as HTMLElement)) continue;
|
|
64
116
|
if (el instanceof HTMLElement || el instanceof SVGElement) {
|
|
65
117
|
const withLocator = el.closest('[data-locatorjs-id], [data-locatorjs]');
|
|
66
|
-
if (withLocator)
|
|
67
|
-
element = withLocator as HTMLElement;
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
118
|
+
if (withLocator) return withLocator as HTMLElement;
|
|
70
119
|
}
|
|
71
120
|
}
|
|
72
|
-
|
|
73
121
|
// Fallback to e.target
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
122
|
+
const target = e.target;
|
|
123
|
+
if (target && (target instanceof HTMLElement || target instanceof SVGElement)) {
|
|
124
|
+
const el = target instanceof SVGElement
|
|
125
|
+
? (target.closest('[data-locatorjs-id], [data-locatorjs]') as HTMLElement | null) ??
|
|
126
|
+
(target.closest('svg') as HTMLElement | null) ??
|
|
127
|
+
(target as unknown as HTMLElement)
|
|
128
|
+
: target;
|
|
129
|
+
if (el && !isLocatorsOwnElement(el)) return el;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Recording lifecycle ---
|
|
135
|
+
|
|
136
|
+
function handleRecordClick() {
|
|
137
|
+
switch (recordingState()) {
|
|
138
|
+
case 'idle':
|
|
139
|
+
setRecordingState('selecting');
|
|
140
|
+
break;
|
|
141
|
+
case 'selecting':
|
|
142
|
+
setRecordingState('idle');
|
|
143
|
+
break;
|
|
144
|
+
case 'recording':
|
|
145
|
+
stopRecording();
|
|
146
|
+
break;
|
|
147
|
+
case 'results':
|
|
148
|
+
dismissResults();
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function startRecording(element: HTMLElement) {
|
|
154
|
+
element.setAttribute('data-treelocator-recording', 'true');
|
|
155
|
+
setRecordedElement(element);
|
|
156
|
+
|
|
157
|
+
dejitterInstance = createDejitterRecorder();
|
|
158
|
+
dejitterInstance.configure({
|
|
159
|
+
selector: '[data-treelocator-recording]',
|
|
160
|
+
props: ['opacity', 'transform', 'boundingRect', 'width', 'height'],
|
|
161
|
+
sampleRate: 15,
|
|
162
|
+
maxDuration: 30000,
|
|
163
|
+
idleTimeout: 0,
|
|
164
|
+
mutations: true,
|
|
165
|
+
});
|
|
166
|
+
dejitterInstance.start();
|
|
167
|
+
startInteractionTracker();
|
|
168
|
+
setRecordingState('recording');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stopRecording() {
|
|
172
|
+
if (!dejitterInstance) return;
|
|
173
|
+
dejitterInstance.stop();
|
|
174
|
+
const findings = dejitterInstance.findings(true) as DejitterFinding[];
|
|
175
|
+
const summary = dejitterInstance.summary(true) as DejitterSummary;
|
|
176
|
+
const data = dejitterInstance.getData();
|
|
177
|
+
|
|
178
|
+
// Collect ancestry path from treelocator before clearing
|
|
179
|
+
const el = recordedElement();
|
|
180
|
+
let elementPath = "";
|
|
181
|
+
if (el) {
|
|
182
|
+
const treeNode = createTreeNode(el, props.adapterId);
|
|
183
|
+
if (treeNode) {
|
|
184
|
+
const ancestry = collectAncestry(treeNode);
|
|
185
|
+
elementPath = formatAncestryChain(ancestry);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setRecordingFindings(findings);
|
|
190
|
+
setRecordingSummary(summary);
|
|
191
|
+
setRecordingData(data);
|
|
192
|
+
setRecordingElementPath(elementPath);
|
|
193
|
+
stopInteractionTracker();
|
|
194
|
+
|
|
195
|
+
// Persist to localStorage (moves previous "last" to "previous")
|
|
196
|
+
saveToStorage({
|
|
197
|
+
findings,
|
|
198
|
+
summary,
|
|
199
|
+
data,
|
|
200
|
+
elementPath,
|
|
201
|
+
interactions: interactionLog(),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
el?.removeAttribute('data-treelocator-recording');
|
|
205
|
+
setRecordingState('results');
|
|
206
|
+
dejitterInstance = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function replayRecording() {
|
|
210
|
+
const events = interactionLog();
|
|
211
|
+
if (events.length === 0) return;
|
|
212
|
+
|
|
213
|
+
stopReplay();
|
|
214
|
+
setReplaying(true);
|
|
215
|
+
|
|
216
|
+
let eventIdx = 0;
|
|
217
|
+
|
|
218
|
+
function scheduleNext() {
|
|
219
|
+
if (eventIdx >= events.length) {
|
|
220
|
+
stopReplay();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const evt = events[eventIdx]!;
|
|
225
|
+
const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
|
|
226
|
+
|
|
227
|
+
replayTimerId = window.setTimeout(() => {
|
|
228
|
+
// Show click indicator
|
|
229
|
+
setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
|
|
230
|
+
|
|
231
|
+
// Dispatch a real click at the recorded position
|
|
232
|
+
const target = document.elementFromPoint(evt.x, evt.y);
|
|
233
|
+
if (target) {
|
|
234
|
+
target.dispatchEvent(new MouseEvent('click', {
|
|
235
|
+
bubbles: true,
|
|
236
|
+
cancelable: true,
|
|
237
|
+
clientX: evt.x,
|
|
238
|
+
clientY: evt.y,
|
|
239
|
+
view: window,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Clear click indicator after a short flash
|
|
244
|
+
window.setTimeout(() => setReplayBox(null), 200);
|
|
245
|
+
|
|
246
|
+
eventIdx++;
|
|
247
|
+
scheduleNext();
|
|
248
|
+
}, Math.max(delay, 50));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
scheduleNext();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function stopReplay() {
|
|
255
|
+
if (replayTimerId) {
|
|
256
|
+
clearTimeout(replayTimerId);
|
|
257
|
+
replayTimerId = null;
|
|
258
|
+
}
|
|
259
|
+
setReplaying(false);
|
|
260
|
+
setReplayBox(null);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function replayWithRecord(elementOrSelector: HTMLElement | string): Promise<{
|
|
264
|
+
path: string;
|
|
265
|
+
findings: DejitterFinding[];
|
|
266
|
+
summary: DejitterSummary | null;
|
|
267
|
+
data: any;
|
|
268
|
+
interactions: InteractionEvent[];
|
|
269
|
+
} | null> {
|
|
270
|
+
// Resolve element
|
|
271
|
+
let element: HTMLElement | null;
|
|
272
|
+
if (typeof elementOrSelector === 'string') {
|
|
273
|
+
const found = document.querySelector(elementOrSelector);
|
|
274
|
+
element = found instanceof HTMLElement ? found : null;
|
|
275
|
+
} else {
|
|
276
|
+
element = elementOrSelector;
|
|
277
|
+
}
|
|
278
|
+
if (!element) return Promise.resolve(null);
|
|
279
|
+
|
|
280
|
+
// Get stored interactions to replay
|
|
281
|
+
const stored = loadFromStorage();
|
|
282
|
+
const events = stored.last?.interactions ?? interactionLog();
|
|
283
|
+
if (events.length === 0) return Promise.resolve(null);
|
|
284
|
+
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
// Start recording on the element
|
|
287
|
+
element!.setAttribute('data-treelocator-recording', 'true');
|
|
288
|
+
setRecordedElement(element);
|
|
289
|
+
|
|
290
|
+
dejitterInstance = createDejitterRecorder();
|
|
291
|
+
dejitterInstance.configure({
|
|
292
|
+
selector: '[data-treelocator-recording]',
|
|
293
|
+
props: ['opacity', 'transform', 'boundingRect', 'width', 'height'],
|
|
294
|
+
sampleRate: 15,
|
|
295
|
+
maxDuration: 30000,
|
|
296
|
+
idleTimeout: 0,
|
|
297
|
+
mutations: true,
|
|
298
|
+
});
|
|
299
|
+
dejitterInstance.start();
|
|
300
|
+
setRecordingState('recording');
|
|
301
|
+
setReplaying(true);
|
|
302
|
+
|
|
303
|
+
let eventIdx = 0;
|
|
304
|
+
|
|
305
|
+
function finishRecording() {
|
|
306
|
+
setReplaying(false);
|
|
307
|
+
setReplayBox(null);
|
|
308
|
+
|
|
309
|
+
if (!dejitterInstance) { resolve(null); return; }
|
|
310
|
+
dejitterInstance.stop();
|
|
311
|
+
const findings = dejitterInstance.findings(true) as DejitterFinding[];
|
|
312
|
+
const summary = dejitterInstance.summary(true) as DejitterSummary;
|
|
313
|
+
const data = dejitterInstance.getData();
|
|
314
|
+
|
|
315
|
+
const el = recordedElement();
|
|
316
|
+
let elementPath = "";
|
|
317
|
+
if (el) {
|
|
318
|
+
const treeNode = createTreeNode(el, props.adapterId);
|
|
319
|
+
if (treeNode) {
|
|
320
|
+
const ancestry = collectAncestry(treeNode);
|
|
321
|
+
elementPath = formatAncestryChain(ancestry);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
setRecordingFindings(findings);
|
|
326
|
+
setRecordingSummary(summary);
|
|
327
|
+
setRecordingData(data);
|
|
328
|
+
setRecordingElementPath(elementPath);
|
|
329
|
+
setInteractionLog(events);
|
|
330
|
+
|
|
331
|
+
saveToStorage({ findings, summary, data, elementPath, interactions: events });
|
|
332
|
+
|
|
333
|
+
el?.removeAttribute('data-treelocator-recording');
|
|
334
|
+
setRecordingState('results');
|
|
335
|
+
dejitterInstance = null;
|
|
336
|
+
|
|
337
|
+
resolve({ path: elementPath, findings, summary, data, interactions: events });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function scheduleNext() {
|
|
341
|
+
if (eventIdx >= events.length) {
|
|
342
|
+
// Wait for CSS transitions to settle before stopping recording
|
|
343
|
+
replayTimerId = window.setTimeout(finishRecording, 500);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const evt = events[eventIdx]!;
|
|
348
|
+
const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
|
|
349
|
+
|
|
350
|
+
replayTimerId = window.setTimeout(() => {
|
|
351
|
+
setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
|
|
352
|
+
|
|
353
|
+
const target = document.elementFromPoint(evt.x, evt.y);
|
|
354
|
+
if (target) {
|
|
355
|
+
target.dispatchEvent(new MouseEvent('click', {
|
|
356
|
+
bubbles: true,
|
|
357
|
+
cancelable: true,
|
|
358
|
+
clientX: evt.x,
|
|
359
|
+
clientY: evt.y,
|
|
360
|
+
view: window,
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
window.setTimeout(() => setReplayBox(null), 200);
|
|
365
|
+
|
|
366
|
+
eventIdx++;
|
|
367
|
+
scheduleNext();
|
|
368
|
+
}, Math.max(delay, 50));
|
|
82
369
|
}
|
|
370
|
+
|
|
371
|
+
scheduleNext();
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function dismissResults() {
|
|
376
|
+
stopReplay();
|
|
377
|
+
setRecordingFindings([]);
|
|
378
|
+
setRecordingSummary(null);
|
|
379
|
+
setRecordingData(null);
|
|
380
|
+
setRecordingElementPath("");
|
|
381
|
+
setInteractionLog([]);
|
|
382
|
+
setRecordedElement(null);
|
|
383
|
+
setViewingPrevious(false);
|
|
384
|
+
setRecordingState('idle');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function hasPreviousRecording(): boolean {
|
|
388
|
+
return loadFromStorage().previous !== null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function loadPreviousRecording() {
|
|
392
|
+
const stored = loadFromStorage();
|
|
393
|
+
if (!stored.previous) return;
|
|
394
|
+
const prev = stored.previous;
|
|
395
|
+
setRecordingFindings(prev.findings);
|
|
396
|
+
setRecordingSummary(prev.summary);
|
|
397
|
+
setRecordingData(prev.data);
|
|
398
|
+
setRecordingElementPath(prev.elementPath);
|
|
399
|
+
setInteractionLog(prev.interactions);
|
|
400
|
+
setViewingPrevious(true);
|
|
401
|
+
setRecordingState('results');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function loadLatestRecording() {
|
|
405
|
+
const stored = loadFromStorage();
|
|
406
|
+
if (!stored.last) return;
|
|
407
|
+
const last = stored.last;
|
|
408
|
+
setRecordingFindings(last.findings);
|
|
409
|
+
setRecordingSummary(last.summary);
|
|
410
|
+
setRecordingData(last.data);
|
|
411
|
+
setRecordingElementPath(last.elementPath);
|
|
412
|
+
setInteractionLog(last.interactions);
|
|
413
|
+
setViewingPrevious(false);
|
|
414
|
+
setRecordingState('results');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function startInteractionTracker() {
|
|
418
|
+
recordingStartTime = performance.now();
|
|
419
|
+
setInteractionLog([]);
|
|
420
|
+
interactionClickHandler = (e: MouseEvent) => {
|
|
421
|
+
if (isLocatorsOwnElement(e.target as HTMLElement)) return;
|
|
422
|
+
const el = e.target as HTMLElement;
|
|
423
|
+
const tag = el.tagName?.toLowerCase() || 'unknown';
|
|
424
|
+
const id = el.id ? '#' + el.id : '';
|
|
425
|
+
const cls = el.className && typeof el.className === 'string' ? '.' + el.className.split(' ')[0] : '';
|
|
426
|
+
setInteractionLog((prev) => [...prev, {
|
|
427
|
+
t: Math.round(performance.now() - recordingStartTime),
|
|
428
|
+
type: 'click',
|
|
429
|
+
target: `${tag}${id}${cls}`,
|
|
430
|
+
x: e.clientX,
|
|
431
|
+
y: e.clientY,
|
|
432
|
+
}]);
|
|
433
|
+
};
|
|
434
|
+
document.addEventListener('click', interactionClickHandler, { capture: true });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function stopInteractionTracker() {
|
|
438
|
+
if (interactionClickHandler) {
|
|
439
|
+
document.removeEventListener('click', interactionClickHandler, { capture: true });
|
|
440
|
+
interactionClickHandler = null;
|
|
83
441
|
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function mouseOverListener(e: MouseEvent) {
|
|
445
|
+
setHoldingModKey(e.altKey);
|
|
446
|
+
|
|
447
|
+
// Don't update hovered element while recording -- highlight is sticky
|
|
448
|
+
if (recordingState() === 'recording') return;
|
|
84
449
|
|
|
85
|
-
|
|
450
|
+
const element = findElementAtPoint(e);
|
|
451
|
+
if (element) {
|
|
86
452
|
setCurrentElement(element);
|
|
87
453
|
}
|
|
88
454
|
}
|
|
89
455
|
|
|
90
456
|
function mouseDownUpListener(e: MouseEvent) {
|
|
91
|
-
// Update modifier state
|
|
92
457
|
setHoldingModKey(e.altKey);
|
|
93
458
|
|
|
94
|
-
if (e.altKey || locatorActive()) {
|
|
459
|
+
if (e.altKey || locatorActive() || recordingState() === 'selecting') {
|
|
95
460
|
e.preventDefault();
|
|
96
461
|
e.stopPropagation();
|
|
97
462
|
}
|
|
98
463
|
}
|
|
99
464
|
|
|
100
465
|
function clickListener(e: MouseEvent) {
|
|
101
|
-
//
|
|
102
|
-
if (
|
|
466
|
+
// Handle recording element selection
|
|
467
|
+
if (recordingState() === 'selecting') {
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
e.stopPropagation();
|
|
470
|
+
const element = findElementAtPoint(e);
|
|
471
|
+
if (element && !isLocatorsOwnElement(element)) {
|
|
472
|
+
startRecording(element);
|
|
473
|
+
}
|
|
103
474
|
return;
|
|
104
475
|
}
|
|
105
476
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
const elementsAtPoint = document.elementsFromPoint(e.clientX, e.clientY);
|
|
477
|
+
// During recording, let clicks pass through (tracked by interaction logger)
|
|
478
|
+
if (recordingState() === 'recording') return;
|
|
109
479
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
for (const el of elementsAtPoint) {
|
|
113
|
-
if (isLocatorsOwnElement(el as HTMLElement)) {
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
if (el instanceof HTMLElement || el instanceof SVGElement) {
|
|
117
|
-
// Check if this element or its closest ancestor has locator data
|
|
118
|
-
const withLocator = el.closest('[data-locatorjs-id], [data-locatorjs]');
|
|
119
|
-
if (withLocator) {
|
|
120
|
-
element = withLocator;
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
480
|
+
if (!e.altKey && !isCombinationModifiersPressed(e) && !locatorActive()) {
|
|
481
|
+
return;
|
|
124
482
|
}
|
|
125
483
|
|
|
126
|
-
|
|
127
|
-
if (!element) {
|
|
128
|
-
const target = e.target;
|
|
129
|
-
if (target && (target instanceof HTMLElement || target instanceof SVGElement)) {
|
|
130
|
-
element = target instanceof SVGElement
|
|
131
|
-
? (target.closest('[data-locatorjs-id], [data-locatorjs]') as Element | null) ??
|
|
132
|
-
(target.closest('svg') as Element | null) ??
|
|
133
|
-
target
|
|
134
|
-
: target;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
484
|
+
const element = findElementAtPoint(e);
|
|
137
485
|
|
|
138
486
|
if (!element) {
|
|
139
487
|
return;
|
|
@@ -250,6 +598,43 @@ function Runtime(props: RuntimeProps) {
|
|
|
250
598
|
targets={props.targets}
|
|
251
599
|
/>
|
|
252
600
|
) : null}
|
|
601
|
+
{recordingState() === 'recording' && recordedElement() ? (
|
|
602
|
+
<RecordingOutline element={recordedElement()!} />
|
|
603
|
+
) : null}
|
|
604
|
+
{replayBox() ? (
|
|
605
|
+
<div
|
|
606
|
+
style={{
|
|
607
|
+
position: "fixed",
|
|
608
|
+
"z-index": "2147483645",
|
|
609
|
+
left: replayBox()!.x + "px",
|
|
610
|
+
top: replayBox()!.y + "px",
|
|
611
|
+
width: replayBox()!.w + "px",
|
|
612
|
+
height: replayBox()!.h + "px",
|
|
613
|
+
"border-radius": "50%",
|
|
614
|
+
"pointer-events": "none",
|
|
615
|
+
background: "rgba(59, 130, 246, 0.4)",
|
|
616
|
+
border: "2px solid #3b82f6",
|
|
617
|
+
"box-shadow": "0 0 12px rgba(59, 130, 246, 0.5)",
|
|
618
|
+
}}
|
|
619
|
+
/>
|
|
620
|
+
) : null}
|
|
621
|
+
{recordingState() === 'results' ? (
|
|
622
|
+
<RecordingResults
|
|
623
|
+
findings={recordingFindings()}
|
|
624
|
+
summary={recordingSummary()}
|
|
625
|
+
data={recordingData()}
|
|
626
|
+
elementPath={recordingElementPath()}
|
|
627
|
+
interactions={interactionLog()}
|
|
628
|
+
onDismiss={dismissResults}
|
|
629
|
+
onReplay={replayRecording}
|
|
630
|
+
replaying={replaying()}
|
|
631
|
+
onToast={setToastMessage}
|
|
632
|
+
hasPrevious={!viewingPrevious() && hasPreviousRecording()}
|
|
633
|
+
onLoadPrevious={loadPreviousRecording}
|
|
634
|
+
hasNext={viewingPrevious()}
|
|
635
|
+
onLoadNext={loadLatestRecording}
|
|
636
|
+
/>
|
|
637
|
+
) : null}
|
|
253
638
|
{toastMessage() && (
|
|
254
639
|
<Toast
|
|
255
640
|
message={toastMessage()!}
|
|
@@ -263,30 +648,102 @@ function Runtime(props: RuntimeProps) {
|
|
|
263
648
|
data-treelocator-api="window.__treelocator__"
|
|
264
649
|
data-treelocator-help="window.__treelocator__.help()"
|
|
265
650
|
>
|
|
651
|
+
<style>{`
|
|
652
|
+
@keyframes treelocator-rec-pulse {
|
|
653
|
+
0%, 100% { opacity: 1; }
|
|
654
|
+
50% { opacity: 0.3; }
|
|
655
|
+
}
|
|
656
|
+
`}</style>
|
|
657
|
+
{/* Combined pill button: tree (left) | record (right) */}
|
|
266
658
|
<div
|
|
267
|
-
class="rounded-full bg-white shadow-lg flex items-center justify-center cursor-pointer overflow-hidden"
|
|
268
659
|
style={{
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
"
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
660
|
+
display: "flex",
|
|
661
|
+
"align-items": "stretch",
|
|
662
|
+
"border-radius": "27px",
|
|
663
|
+
overflow: "hidden",
|
|
664
|
+
"box-shadow":
|
|
665
|
+
locatorActive()
|
|
666
|
+
? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
|
|
667
|
+
: recordingState() === 'selecting'
|
|
668
|
+
? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
|
|
669
|
+
: recordingState() === 'recording'
|
|
670
|
+
? "0 0 0 3px #ef4444, 0 4px 14px rgba(0, 0, 0, 0.25)"
|
|
671
|
+
: "0 4px 14px rgba(0, 0, 0, 0.25)",
|
|
672
|
+
transition: "box-shadow 0.2s ease-in-out",
|
|
275
673
|
}}
|
|
276
|
-
onMouseEnter={(e) => e.currentTarget.style.transform = "scale(1.25)"}
|
|
277
|
-
onMouseLeave={(e) => e.currentTarget.style.transform = "scale(1)"}
|
|
278
|
-
onClick={() => setLocatorActive(!locatorActive())}
|
|
279
|
-
aria-label="TreeLocatorJS: Get component paths using window.__treelocator__.getPath(selector)"
|
|
280
|
-
role="button"
|
|
281
674
|
>
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
675
|
+
{/* Left half: Tree icon */}
|
|
676
|
+
<div
|
|
677
|
+
style={{
|
|
678
|
+
width: "54px",
|
|
679
|
+
height: "54px",
|
|
680
|
+
background: "#ffffff",
|
|
681
|
+
display: "flex",
|
|
682
|
+
"align-items": "center",
|
|
683
|
+
"justify-content": "center",
|
|
684
|
+
cursor: "pointer",
|
|
685
|
+
overflow: "hidden",
|
|
686
|
+
"border-right": "1px solid rgba(0, 0, 0, 0.1)",
|
|
687
|
+
transition: "background 0.15s ease-in-out",
|
|
688
|
+
}}
|
|
689
|
+
onMouseEnter={(e) => e.currentTarget.style.background = "#f0f0f0"}
|
|
690
|
+
onMouseLeave={(e) => e.currentTarget.style.background = "#ffffff"}
|
|
691
|
+
onClick={() => setLocatorActive(!locatorActive())}
|
|
692
|
+
aria-label="TreeLocatorJS: Get component paths using window.__treelocator__.getPath(selector)"
|
|
693
|
+
role="button"
|
|
694
|
+
>
|
|
695
|
+
<img
|
|
696
|
+
src={treeIconUrl}
|
|
697
|
+
alt="TreeLocatorJS"
|
|
698
|
+
width={44}
|
|
699
|
+
height={44}
|
|
700
|
+
/>
|
|
701
|
+
</div>
|
|
702
|
+
{/* Right half: Record button */}
|
|
703
|
+
<div
|
|
704
|
+
style={{
|
|
705
|
+
width: "54px",
|
|
706
|
+
height: "54px",
|
|
707
|
+
background: recordingState() === 'recording' ? "#ef4444" : "#ffffff",
|
|
708
|
+
display: "flex",
|
|
709
|
+
"align-items": "center",
|
|
710
|
+
"justify-content": "center",
|
|
711
|
+
cursor: "pointer",
|
|
712
|
+
transition: "background 0.15s ease-in-out",
|
|
713
|
+
}}
|
|
714
|
+
onMouseEnter={(e) => {
|
|
715
|
+
if (recordingState() !== 'recording') e.currentTarget.style.background = "#f0f0f0";
|
|
716
|
+
}}
|
|
717
|
+
onMouseLeave={(e) => {
|
|
718
|
+
if (recordingState() !== 'recording') e.currentTarget.style.background = "#ffffff";
|
|
719
|
+
}}
|
|
720
|
+
onClick={handleRecordClick}
|
|
721
|
+
aria-label={
|
|
722
|
+
recordingState() === 'idle' ? "Record element changes. API: window.__treelocator__.replayWithRecord(selector)" :
|
|
723
|
+
recordingState() === 'selecting' ? "Cancel recording selection" :
|
|
724
|
+
recordingState() === 'recording' ? "Stop recording" :
|
|
725
|
+
"Dismiss results"
|
|
726
|
+
}
|
|
727
|
+
role="button"
|
|
728
|
+
>
|
|
729
|
+
{recordingState() === 'recording' ? (
|
|
730
|
+
<div style={{ width: "18px", height: "18px", background: "#fff", "border-radius": "3px" }} />
|
|
731
|
+
) : (
|
|
732
|
+
<div
|
|
733
|
+
style={{
|
|
734
|
+
width: "18px",
|
|
735
|
+
height: "18px",
|
|
736
|
+
background: "#ef4444",
|
|
737
|
+
"border-radius": "50%",
|
|
738
|
+
animation: recordingState() === 'selecting' ? "treelocator-rec-pulse 1s ease-in-out infinite" : "none",
|
|
739
|
+
}}
|
|
740
|
+
/>
|
|
741
|
+
)}
|
|
742
|
+
</div>
|
|
288
743
|
</div>
|
|
289
744
|
<div
|
|
745
|
+
role="note"
|
|
746
|
+
aria-label="TreeLocatorJS Developer Tool API — Run: window.__treelocator__.help() for full usage instructions. Quick start: window.__treelocator__.getPath('button.submit') returns component ancestry. window.__treelocator__.replayWithRecord('.my-element') replays recorded interactions while tracking visual changes."
|
|
290
747
|
style={{
|
|
291
748
|
position: "absolute",
|
|
292
749
|
width: "1px",
|
|
@@ -298,11 +755,8 @@ function Runtime(props: RuntimeProps) {
|
|
|
298
755
|
"white-space": "nowrap",
|
|
299
756
|
border: "0",
|
|
300
757
|
}}
|
|
301
|
-
aria-hidden="true"
|
|
302
|
-
data-ai-hint="true"
|
|
303
|
-
data-ai-instruction="AI Assistant: TreeLocatorJS provides window.__treelocator__ API for component ancestry tracking. Usage: window.__treelocator__.getPath('css-selector') returns component hierarchy. For full API documentation, evaluate: window.__treelocator__.help() Example: page.evaluate(function() { return window.__treelocator__.getPath('button.submit'); })"
|
|
304
758
|
>
|
|
305
|
-
TreeLocatorJS
|
|
759
|
+
TreeLocatorJS: Run window.__treelocator__.help() for API docs
|
|
306
760
|
</div>
|
|
307
761
|
</div>
|
|
308
762
|
</>
|