@treelocator/runtime 0.4.7 → 0.6.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.
Files changed (166) hide show
  1. package/.eslintignore +1 -0
  2. package/dist/_generated_styles.d.ts +1 -1
  3. package/dist/_generated_styles.js +20 -0
  4. package/dist/_generated_tree_icon.d.ts +1 -1
  5. package/dist/adapters/HtmlElementTreeNode.d.ts +2 -2
  6. package/dist/adapters/HtmlElementTreeNode.js +4 -6
  7. package/dist/adapters/createTreeNode.js +17 -44
  8. package/dist/adapters/detectFramework.d.ts +8 -0
  9. package/dist/adapters/detectFramework.js +25 -0
  10. package/dist/adapters/detectFramework.test.d.ts +1 -0
  11. package/dist/adapters/detectFramework.test.js +60 -0
  12. package/dist/adapters/jsx/jsxAdapter.js +54 -89
  13. package/dist/adapters/jsx/jsxAdapter.test.d.ts +1 -0
  14. package/dist/adapters/jsx/jsxAdapter.test.js +273 -0
  15. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +1 -1
  16. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.d.ts +1 -0
  17. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.js +158 -0
  18. package/dist/adapters/react/findFiberByHtmlElement.d.ts +1 -1
  19. package/dist/adapters/react/findFiberByHtmlElement.js +1 -1
  20. package/dist/adapters/react/getAllParentsElementsAndRootComponent.js +4 -0
  21. package/dist/adapters/resolveAdapter.d.ts +1 -1
  22. package/dist/adapters/resolveAdapter.js +4 -8
  23. package/dist/adapters/svelte/svelteAdapter.test.d.ts +1 -0
  24. package/dist/adapters/svelte/svelteAdapter.test.js +280 -0
  25. package/dist/adapters/vue/vueAdapter.test.d.ts +1 -0
  26. package/dist/adapters/vue/vueAdapter.test.js +222 -0
  27. package/dist/browserApi.d.ts +148 -0
  28. package/dist/browserApi.js +146 -5
  29. package/dist/browserApi.test.d.ts +1 -0
  30. package/dist/browserApi.test.js +287 -0
  31. package/dist/components/RecordingPillButton.d.ts +11 -0
  32. package/dist/components/RecordingPillButton.js +202 -0
  33. package/dist/components/RecordingResults.d.ts +2 -0
  34. package/dist/components/RecordingResults.js +213 -78
  35. package/dist/components/Runtime.js +161 -554
  36. package/dist/components/SettingsPanel.d.ts +5 -0
  37. package/dist/components/SettingsPanel.js +312 -0
  38. package/dist/consoleCapture.d.ts +9 -0
  39. package/dist/consoleCapture.js +95 -0
  40. package/dist/dejitter/recorder.d.ts +7 -1
  41. package/dist/dejitter/recorder.js +64 -1
  42. package/dist/functions/cssRuleInspector.d.ts +83 -0
  43. package/dist/functions/cssRuleInspector.js +608 -0
  44. package/dist/functions/cssRuleInspector.test.d.ts +1 -0
  45. package/dist/functions/cssRuleInspector.test.js +439 -0
  46. package/dist/functions/deduplicateLabels.test.d.ts +1 -0
  47. package/dist/functions/deduplicateLabels.test.js +178 -0
  48. package/dist/functions/enrichAncestrySourceMaps.js +0 -1
  49. package/dist/functions/extractComputedStyles.d.ts +51 -0
  50. package/dist/functions/extractComputedStyles.js +447 -0
  51. package/dist/functions/extractComputedStyles.test.d.ts +1 -0
  52. package/dist/functions/extractComputedStyles.test.js +549 -0
  53. package/dist/functions/formatAncestryChain.d.ts +8 -0
  54. package/dist/functions/formatAncestryChain.js +21 -1
  55. package/dist/functions/formatAncestryChain.test.js +18 -0
  56. package/dist/functions/getUsableName.test.d.ts +1 -0
  57. package/dist/functions/getUsableName.test.js +219 -0
  58. package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
  59. package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
  60. package/dist/functions/mergeRects.test.js +210 -1
  61. package/dist/functions/namedSnapshots.d.ts +52 -0
  62. package/dist/functions/namedSnapshots.js +161 -0
  63. package/dist/functions/namedSnapshots.test.d.ts +1 -0
  64. package/dist/functions/namedSnapshots.test.js +85 -0
  65. package/dist/functions/normalizeFilePath.test.d.ts +1 -0
  66. package/dist/functions/normalizeFilePath.test.js +66 -0
  67. package/dist/functions/parseDataId.test.d.ts +1 -0
  68. package/dist/functions/parseDataId.test.js +101 -0
  69. package/dist/hooks/getStorage.d.ts +3 -0
  70. package/dist/hooks/getStorage.js +17 -0
  71. package/dist/hooks/useEventListeners.d.ts +15 -0
  72. package/dist/hooks/useEventListeners.js +56 -0
  73. package/dist/hooks/useLocatorStorage.d.ts +18 -0
  74. package/dist/hooks/useLocatorStorage.js +41 -0
  75. package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
  76. package/dist/hooks/useLocatorStorage.test.js +124 -0
  77. package/dist/hooks/useRecordingState.d.ts +43 -0
  78. package/dist/hooks/useRecordingState.js +387 -0
  79. package/dist/hooks/useSettings.d.ts +13 -0
  80. package/dist/hooks/useSettings.js +66 -0
  81. package/dist/index.d.ts +5 -2
  82. package/dist/index.js +4 -2
  83. package/dist/initRuntime.d.ts +3 -1
  84. package/dist/initRuntime.js +4 -1
  85. package/dist/mcpBridge.d.ts +61 -0
  86. package/dist/mcpBridge.js +534 -0
  87. package/dist/mcpBridge.test.d.ts +1 -0
  88. package/dist/mcpBridge.test.js +248 -0
  89. package/dist/output.css +20 -0
  90. package/dist/visualDiff/diff.d.ts +9 -0
  91. package/dist/visualDiff/diff.js +209 -0
  92. package/dist/visualDiff/diff.test.d.ts +1 -0
  93. package/dist/visualDiff/diff.test.js +253 -0
  94. package/dist/visualDiff/settle.d.ts +3 -0
  95. package/dist/visualDiff/settle.js +50 -0
  96. package/dist/visualDiff/settle.test.d.ts +1 -0
  97. package/dist/visualDiff/settle.test.js +65 -0
  98. package/dist/visualDiff/snapshot.d.ts +4 -0
  99. package/dist/visualDiff/snapshot.js +84 -0
  100. package/dist/visualDiff/snapshot.test.d.ts +1 -0
  101. package/dist/visualDiff/snapshot.test.js +245 -0
  102. package/dist/visualDiff/types.d.ts +37 -0
  103. package/dist/visualDiff/types.js +1 -0
  104. package/package.json +2 -2
  105. package/scripts/wrapCSS.js +1 -1
  106. package/scripts/wrapImage.js +1 -1
  107. package/src/_generated_styles.ts +21 -1
  108. package/src/_generated_tree_icon.ts +1 -1
  109. package/src/adapters/HtmlElementTreeNode.ts +10 -7
  110. package/src/adapters/createTreeNode.ts +12 -51
  111. package/src/adapters/detectFramework.test.ts +73 -0
  112. package/src/adapters/detectFramework.ts +28 -0
  113. package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
  114. package/src/adapters/jsx/jsxAdapter.ts +53 -106
  115. package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
  116. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
  117. package/src/adapters/react/findDebugSource.ts +5 -6
  118. package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
  119. package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
  120. package/src/adapters/react/reactAdapter.ts +1 -2
  121. package/src/adapters/resolveAdapter.ts +4 -14
  122. package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
  123. package/src/adapters/vue/vueAdapter.test.ts +259 -0
  124. package/src/browserApi.test.ts +329 -0
  125. package/src/browserApi.ts +351 -4
  126. package/src/components/RecordingPillButton.tsx +301 -0
  127. package/src/components/RecordingResults.tsx +114 -13
  128. package/src/components/Runtime.tsx +176 -621
  129. package/src/components/SettingsPanel.tsx +339 -0
  130. package/src/consoleCapture.ts +113 -0
  131. package/src/dejitter/recorder.ts +67 -3
  132. package/src/functions/cssRuleInspector.test.ts +517 -0
  133. package/src/functions/cssRuleInspector.ts +708 -0
  134. package/src/functions/deduplicateLabels.test.ts +115 -0
  135. package/src/functions/enrichAncestrySourceMaps.ts +6 -3
  136. package/src/functions/extractComputedStyles.test.ts +681 -0
  137. package/src/functions/extractComputedStyles.ts +768 -0
  138. package/src/functions/formatAncestryChain.test.ts +23 -1
  139. package/src/functions/formatAncestryChain.ts +22 -1
  140. package/src/functions/getUsableName.test.ts +242 -0
  141. package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
  142. package/src/functions/mergeRects.test.ts +111 -1
  143. package/src/functions/namedSnapshots.test.ts +106 -0
  144. package/src/functions/namedSnapshots.ts +232 -0
  145. package/src/functions/normalizeFilePath.test.ts +80 -0
  146. package/src/functions/parseDataId.test.ts +125 -0
  147. package/src/hooks/getStorage.ts +26 -0
  148. package/src/hooks/useEventListeners.ts +97 -0
  149. package/src/hooks/useLocatorStorage.test.ts +127 -0
  150. package/src/hooks/useLocatorStorage.ts +60 -0
  151. package/src/hooks/useRecordingState.ts +516 -0
  152. package/src/hooks/useSettings.ts +83 -0
  153. package/src/index.ts +10 -5
  154. package/src/initRuntime.ts +5 -0
  155. package/src/mcpBridge.test.ts +260 -0
  156. package/src/mcpBridge.ts +677 -0
  157. package/src/visualDiff/diff.test.ts +167 -0
  158. package/src/visualDiff/diff.ts +242 -0
  159. package/src/visualDiff/settle.test.ts +77 -0
  160. package/src/visualDiff/settle.ts +62 -0
  161. package/src/visualDiff/snapshot.test.ts +200 -0
  162. package/src/visualDiff/snapshot.ts +119 -0
  163. package/src/visualDiff/types.ts +40 -0
  164. package/tsconfig.json +3 -1
  165. package/vitest.config.ts +18 -0
  166. package/jest.config.ts +0 -195
@@ -1,5 +1,5 @@
1
1
  import { Targets } from "@locator/shared";
2
- import { createEffect, createSignal, onCleanup } from "solid-js";
2
+ import { createEffect, createSignal } from "solid-js";
3
3
  import { render } from "solid-js/web";
4
4
  import { AdapterId } from "../consts";
5
5
  import { isCombinationModifiersPressed } from "../functions/isCombinationModifiersPressed";
@@ -7,22 +7,17 @@ import { Targets as SetupTargets } from "../types/types";
7
7
  import { MaybeOutline } from "./MaybeOutline";
8
8
  import { isLocatorsOwnElement } from "../functions/isLocatorsOwnElement";
9
9
  import { Toast } from "./Toast";
10
- import { collectAncestry, formatAncestryChain, truncateAtFirstFile } from "../functions/formatAncestryChain";
10
+ import { collectAncestry, formatAncestryChain, truncateAtFirstFile, getElementLabel } from "../functions/formatAncestryChain";
11
11
  import { enrichAncestryWithSourceMaps } from "../functions/enrichAncestrySourceMaps";
12
+ import { extractComputedStyles } from "../functions/extractComputedStyles";
12
13
  import { createTreeNode } from "../adapters/createTreeNode";
13
- import treeIconUrl from "../_generated_tree_icon";
14
- import { createDejitterRecorder, DejitterAPI, DejitterFinding, DejitterSummary } from "../dejitter/recorder";
15
14
  import { RecordingOutline } from "./RecordingOutline";
16
- import { RecordingResults, InteractionEvent } from "./RecordingResults";
17
-
18
- const DEJITTER_CONFIG: Partial<import("../dejitter/recorder").DejitterConfig> = {
19
- selector: '[data-treelocator-recording]',
20
- props: ['opacity', 'transform', 'boundingRect', 'width', 'height'],
21
- sampleRate: 15,
22
- maxDuration: 30000,
23
- idleTimeout: 0,
24
- mutations: true,
25
- };
15
+ import { RecordingResults } from "./RecordingResults";
16
+ import { RecordingPillButton } from "./RecordingPillButton";
17
+ import { SettingsPanel } from "./SettingsPanel";
18
+ import { useRecordingState } from "../hooks/useRecordingState";
19
+ import { useEventListeners } from "../hooks/useEventListeners";
20
+ import { settings } from "../hooks/useSettings";
26
21
 
27
22
  type RuntimeProps = {
28
23
  adapterId?: AdapterId;
@@ -37,59 +32,15 @@ function Runtime(props: RuntimeProps) {
37
32
  );
38
33
  const [toastMessage, setToastMessage] = createSignal<string | null>(null);
39
34
  const [locatorActive, setLocatorActive] = createSignal<boolean>(false);
35
+ const [settingsOpen, setSettingsOpen] = createSignal<boolean>(false);
40
36
 
41
- // Recording state machine: idle -> selecting -> recording -> results -> idle
42
- type RecordingState = 'idle' | 'selecting' | 'recording' | 'results';
43
-
44
- // --- localStorage persistence ---
45
- const STORAGE_KEY = '__treelocator_recording__';
46
-
47
- type SavedRecording = {
48
- findings: DejitterFinding[];
49
- summary: DejitterSummary | null;
50
- data: any;
51
- elementPath: string;
52
- interactions: InteractionEvent[];
53
- };
54
-
55
- function loadFromStorage(): { last: SavedRecording | null; previous: SavedRecording | null } {
56
- try {
57
- const raw = localStorage.getItem(STORAGE_KEY);
58
- if (raw) return JSON.parse(raw);
59
- } catch {}
60
- return { last: null, previous: null };
61
- }
62
-
63
- function saveToStorage(current: SavedRecording) {
64
- try {
65
- const stored = loadFromStorage();
66
- localStorage.setItem(STORAGE_KEY, JSON.stringify({
67
- last: current,
68
- previous: stored.last,
69
- }));
70
- } catch {}
71
- }
72
-
73
- // Restore last results on mount
74
- const restored = loadFromStorage();
75
- const restoredLast = restored.last;
76
-
77
- const [recordingState, setRecordingState] = createSignal<RecordingState>(restoredLast ? 'results' : 'idle');
78
- const [recordedElement, setRecordedElement] = createSignal<HTMLElement | null>(null);
79
- const [recordingFindings, setRecordingFindings] = createSignal<DejitterFinding[]>(restoredLast?.findings ?? []);
80
- const [recordingSummary, setRecordingSummary] = createSignal<DejitterSummary | null>(restoredLast?.summary ?? null);
81
- const [interactionLog, setInteractionLog] = createSignal<InteractionEvent[]>(restoredLast?.interactions ?? []);
82
- const [recordingData, setRecordingData] = createSignal<any>(restoredLast?.data ?? null);
83
- const [recordingElementPath, setRecordingElementPath] = createSignal<string>(restoredLast?.elementPath ?? "");
84
- const [replayBox, setReplayBox] = createSignal<{ x: number; y: number; w: number; h: number } | null>(null);
85
- const [replaying, setReplaying] = createSignal(false);
86
- const [viewingPrevious, setViewingPrevious] = createSignal(false);
87
- let dejitterInstance: DejitterAPI | null = null;
88
- let interactionClickHandler: ((e: MouseEvent) => void) | null = null;
89
- let recordingStartTime = 0;
90
- let replayTimerId: number | null = null;
37
+ const recording = useRecordingState(props.adapterId);
91
38
 
92
- const isActive = () => (holdingModKey() || locatorActive() || recordingState() === 'selecting') && currentElement();
39
+ const isActive = () =>
40
+ (holdingModKey() ||
41
+ locatorActive() ||
42
+ recording.recordingState() === "selecting") &&
43
+ currentElement();
93
44
 
94
45
  createEffect(() => {
95
46
  if (isActive()) {
@@ -101,25 +52,24 @@ function Runtime(props: RuntimeProps) {
101
52
 
102
53
  // Expose replay functions on the browser API
103
54
  if (typeof window !== "undefined" && (window as any).__treelocator__) {
104
- (window as any).__treelocator__.replay = () => replayRecording();
105
- (window as any).__treelocator__.replayWithRecord = (elementOrSelector: HTMLElement | string) =>
106
- replayWithRecord(elementOrSelector);
55
+ (window as any).__treelocator__.replay = () => recording.replayRecording();
56
+ (window as any).__treelocator__.replayWithRecord = (
57
+ elementOrSelector: HTMLElement | string
58
+ ) => recording.replayWithRecord(elementOrSelector);
107
59
  }
108
60
 
109
- function keyUpListener(e: KeyboardEvent) {
110
- setHoldingModKey(isCombinationModifiersPressed(e));
111
- setHoldingShift(e.shiftKey);
112
- }
113
-
114
- function keyDownListener(e: KeyboardEvent) {
115
- setHoldingModKey(isCombinationModifiersPressed(e, true));
116
- setHoldingShift(e.shiftKey);
117
- }
118
-
119
- function mouseMoveListener(e: MouseEvent) {
120
- // Update modifier state from mouse events - more reliable than keydown/keyup
121
- setHoldingModKey(e.altKey);
122
- setHoldingShift(e.shiftKey);
61
+ // --- Event handlers ---
62
+
63
+ function eventPathHasAttribute(e: Event, attr: string): boolean {
64
+ const path =
65
+ typeof e.composedPath === "function"
66
+ ? e.composedPath()
67
+ : e.target
68
+ ? [e.target]
69
+ : [];
70
+ return path.some(
71
+ (node) => node instanceof Element && node.hasAttribute(attr)
72
+ );
123
73
  }
124
74
 
125
75
  function findElementAtPoint(e: MouseEvent): HTMLElement | null {
@@ -127,356 +77,52 @@ function Runtime(props: RuntimeProps) {
127
77
  for (const el of elementsAtPoint) {
128
78
  if (isLocatorsOwnElement(el as HTMLElement)) continue;
129
79
  if (el instanceof HTMLElement || el instanceof SVGElement) {
130
- const withLocator = el.closest('[data-locatorjs-id], [data-locatorjs]');
80
+ const withLocator = el.closest(
81
+ "[data-locatorjs-id], [data-locatorjs]"
82
+ );
131
83
  if (withLocator) return withLocator as HTMLElement;
132
84
  }
133
85
  }
134
- // Fallback to e.target
135
86
  const target = e.target;
136
- if (target && (target instanceof HTMLElement || target instanceof SVGElement)) {
137
- const el = target instanceof SVGElement
138
- ? (target.closest('[data-locatorjs-id], [data-locatorjs]') as HTMLElement | null) ??
139
- (target.closest('svg') as HTMLElement | null) ??
140
- (target as unknown as HTMLElement)
141
- : target;
87
+ if (
88
+ target &&
89
+ (target instanceof HTMLElement || target instanceof SVGElement)
90
+ ) {
91
+ const el =
92
+ target instanceof SVGElement
93
+ ? ((target.closest(
94
+ "[data-locatorjs-id], [data-locatorjs]"
95
+ ) as HTMLElement | null) ??
96
+ (target.closest("svg") as HTMLElement | null) ??
97
+ (target as unknown as HTMLElement))
98
+ : target;
142
99
  if (el && !isLocatorsOwnElement(el)) return el;
143
100
  }
144
101
  return null;
145
102
  }
146
103
 
147
- // --- Recording lifecycle ---
148
-
149
- function handleRecordClick() {
150
- switch (recordingState()) {
151
- case 'idle':
152
- setRecordingState('selecting');
153
- break;
154
- case 'selecting':
155
- setRecordingState('idle');
156
- break;
157
- case 'recording':
158
- stopRecording();
159
- break;
160
- case 'results':
161
- dismissResults();
162
- break;
163
- }
164
- }
165
-
166
- function startRecording(element: HTMLElement) {
167
- element.setAttribute('data-treelocator-recording', 'true');
168
- setRecordedElement(element);
169
-
170
- dejitterInstance = createDejitterRecorder();
171
- dejitterInstance.configure(DEJITTER_CONFIG);
172
- dejitterInstance.start();
173
- startInteractionTracker();
174
- setRecordingState('recording');
175
- }
176
-
177
- function stopRecording() {
178
- if (!dejitterInstance) return;
179
- dejitterInstance.stop();
180
- const findings = dejitterInstance.findings(true) as DejitterFinding[];
181
- const summary = dejitterInstance.summary(true) as DejitterSummary;
182
- const data = dejitterInstance.getData();
183
-
184
- // Collect ancestry path from treelocator before clearing
185
- const el = recordedElement();
186
- let elementPath = "";
187
- if (el) {
188
- const treeNode = createTreeNode(el, props.adapterId);
189
- if (treeNode) {
190
- const ancestry = collectAncestry(treeNode);
191
- elementPath = formatAncestryChain(ancestry);
192
- }
193
- }
194
-
195
- setRecordingFindings(findings);
196
- setRecordingSummary(summary);
197
- setRecordingData(data);
198
- setRecordingElementPath(elementPath);
199
- stopInteractionTracker();
200
-
201
- // Persist to localStorage (moves previous "last" to "previous")
202
- saveToStorage({
203
- findings,
204
- summary,
205
- data,
206
- elementPath,
207
- interactions: interactionLog(),
208
- });
209
-
210
- el?.removeAttribute('data-treelocator-recording');
211
- setRecordingState('results');
212
- dejitterInstance = null;
213
- }
214
-
215
- function replayRecording() {
216
- const events = interactionLog();
217
- if (events.length === 0) return;
218
-
219
- stopReplay();
220
- setReplaying(true);
221
-
222
- let eventIdx = 0;
223
-
224
- function scheduleNext() {
225
- if (eventIdx >= events.length) {
226
- stopReplay();
227
- return;
228
- }
229
-
230
- const evt = events[eventIdx]!;
231
- const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
232
-
233
- replayTimerId = window.setTimeout(() => {
234
- // Show click indicator
235
- setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
236
-
237
- // Dispatch a real click at the recorded position
238
- const target = document.elementFromPoint(evt.x, evt.y);
239
- if (target) {
240
- target.dispatchEvent(new MouseEvent('click', {
241
- bubbles: true,
242
- cancelable: true,
243
- clientX: evt.x,
244
- clientY: evt.y,
245
- view: window,
246
- }));
247
- }
248
-
249
- // Clear click indicator after a short flash
250
- window.setTimeout(() => setReplayBox(null), 200);
251
-
252
- eventIdx++;
253
- scheduleNext();
254
- }, Math.max(delay, 50));
255
- }
256
-
257
- scheduleNext();
258
- }
259
-
260
- function stopReplay() {
261
- if (replayTimerId) {
262
- clearTimeout(replayTimerId);
263
- replayTimerId = null;
264
- }
265
- setReplaying(false);
266
- setReplayBox(null);
267
- }
268
-
269
- function replayWithRecord(elementOrSelector: HTMLElement | string): Promise<{
270
- path: string;
271
- findings: DejitterFinding[];
272
- summary: DejitterSummary | null;
273
- data: any;
274
- interactions: InteractionEvent[];
275
- } | null> {
276
- // Resolve element
277
- let element: HTMLElement | null;
278
- if (typeof elementOrSelector === 'string') {
279
- const found = document.querySelector(elementOrSelector);
280
- element = found instanceof HTMLElement ? found : null;
281
- } else {
282
- element = elementOrSelector;
283
- }
284
- if (!element) return Promise.resolve(null);
285
-
286
- // Get stored interactions to replay
287
- const stored = loadFromStorage();
288
- const events = stored.last?.interactions ?? interactionLog();
289
- if (events.length === 0) return Promise.resolve(null);
290
-
291
- return new Promise((resolve) => {
292
- // Start recording on the element
293
- element!.setAttribute('data-treelocator-recording', 'true');
294
- setRecordedElement(element);
295
-
296
- dejitterInstance = createDejitterRecorder();
297
- dejitterInstance.configure(DEJITTER_CONFIG);
298
- dejitterInstance.start();
299
- setRecordingState('recording');
300
- setReplaying(true);
301
-
302
- let eventIdx = 0;
303
-
304
- function finishRecording() {
305
- setReplaying(false);
306
- setReplayBox(null);
307
-
308
- if (!dejitterInstance) { resolve(null); return; }
309
- dejitterInstance.stop();
310
- const findings = dejitterInstance.findings(true) as DejitterFinding[];
311
- const summary = dejitterInstance.summary(true) as DejitterSummary;
312
- const data = dejitterInstance.getData();
313
-
314
- const el = recordedElement();
315
- let elementPath = "";
316
- if (el) {
317
- const treeNode = createTreeNode(el, props.adapterId);
318
- if (treeNode) {
319
- const ancestry = collectAncestry(treeNode);
320
- elementPath = formatAncestryChain(ancestry);
321
- }
322
- }
323
-
324
- setRecordingFindings(findings);
325
- setRecordingSummary(summary);
326
- setRecordingData(data);
327
- setRecordingElementPath(elementPath);
328
- setInteractionLog(events);
329
-
330
- saveToStorage({ findings, summary, data, elementPath, interactions: events });
331
-
332
- el?.removeAttribute('data-treelocator-recording');
333
- setRecordingState('results');
334
- dejitterInstance = null;
335
-
336
- resolve({ path: elementPath, findings, summary, data, interactions: events });
337
- }
338
-
339
- function scheduleNext() {
340
- if (eventIdx >= events.length) {
341
- // Wait for CSS transitions to settle before stopping recording
342
- replayTimerId = window.setTimeout(finishRecording, 500);
343
- return;
344
- }
345
-
346
- const evt = events[eventIdx]!;
347
- const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
348
-
349
- replayTimerId = window.setTimeout(() => {
350
- setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
351
-
352
- const target = document.elementFromPoint(evt.x, evt.y);
353
- if (target) {
354
- target.dispatchEvent(new MouseEvent('click', {
355
- bubbles: true,
356
- cancelable: true,
357
- clientX: evt.x,
358
- clientY: evt.y,
359
- view: window,
360
- }));
361
- }
362
-
363
- window.setTimeout(() => setReplayBox(null), 200);
364
-
365
- eventIdx++;
366
- scheduleNext();
367
- }, Math.max(delay, 50));
368
- }
369
-
370
- scheduleNext();
371
- });
372
- }
373
-
374
- function dismissResults() {
375
- stopReplay();
376
- setRecordingFindings([]);
377
- setRecordingSummary(null);
378
- setRecordingData(null);
379
- setRecordingElementPath("");
380
- setInteractionLog([]);
381
- setRecordedElement(null);
382
- setViewingPrevious(false);
383
- setRecordingState('idle');
384
- try { localStorage.removeItem(STORAGE_KEY); } catch {}
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;
441
- }
442
- }
443
-
444
- function mouseOverListener(e: MouseEvent) {
445
- setHoldingModKey(e.altKey);
446
- setHoldingShift(e.shiftKey);
447
-
448
- // Don't update hovered element while recording -- highlight is sticky
449
- if (recordingState() === 'recording') return;
450
-
451
- const element = findElementAtPoint(e);
452
- if (element) {
453
- setCurrentElement(element);
454
- }
455
- }
456
-
457
- function mouseDownUpListener(e: MouseEvent) {
458
- setHoldingModKey(e.altKey);
459
-
460
- if (e.altKey || locatorActive() || recordingState() === 'selecting') {
461
- e.preventDefault();
462
- e.stopPropagation();
104
+ function clickListener(e: MouseEvent) {
105
+ if (
106
+ settingsOpen() &&
107
+ !eventPathHasAttribute(e, "data-treelocator-settings-panel") &&
108
+ !eventPathHasAttribute(e, "data-treelocator-settings-toggle")
109
+ ) {
110
+ setSettingsOpen(false);
463
111
  }
464
- }
465
112
 
466
- function clickListener(e: MouseEvent) {
467
113
  // Handle recording element selection
468
- if (recordingState() === 'selecting') {
114
+ if (recording.recordingState() === "selecting") {
469
115
  e.preventDefault();
470
116
  e.stopPropagation();
471
117
  const element = findElementAtPoint(e);
472
118
  if (element && !isLocatorsOwnElement(element)) {
473
- startRecording(element);
119
+ recording.startRecording(element);
474
120
  }
475
121
  return;
476
122
  }
477
123
 
478
124
  // During recording, let clicks pass through (tracked by interaction logger)
479
- if (recordingState() === 'recording') return;
125
+ if (recording.recordingState() === "recording") return;
480
126
 
481
127
  if (!e.altKey && !isCombinationModifiersPressed(e) && !locatorActive()) {
482
128
  return;
@@ -484,22 +130,14 @@ function Runtime(props: RuntimeProps) {
484
130
 
485
131
  const element = findElementAtPoint(e);
486
132
 
487
- if (!element) {
488
- return;
489
- }
490
-
491
- if (element instanceof HTMLElement && element.shadowRoot) {
492
- return;
493
- }
494
-
495
- if (isLocatorsOwnElement(element as HTMLElement)) {
496
- return;
497
- }
133
+ if (!element) return;
134
+ if (element instanceof HTMLElement && element.shadowRoot) return;
135
+ if (isLocatorsOwnElement(element as HTMLElement)) return;
498
136
 
499
137
  e.preventDefault();
500
138
  e.stopPropagation();
501
139
 
502
- // Copy ancestry to clipboard on alt+click
140
+ // Copy ancestry + computed styles to clipboard on alt+click
503
141
  const treeNode = createTreeNode(element as HTMLElement, props.adapterId);
504
142
  if (treeNode) {
505
143
  let ancestry = collectAncestry(treeNode);
@@ -509,18 +147,45 @@ function Runtime(props: RuntimeProps) {
509
147
  ancestry = truncateAtFirstFile(ancestry);
510
148
  }
511
149
 
150
+ // Extract computed styles for the clicked element (if enabled)
151
+ const stylesEnabled = settings().computedStyles;
152
+ const elementLabel = getElementLabel(ancestry);
153
+ const stylesResult = stylesEnabled
154
+ ? extractComputedStyles(element as Element, elementLabel, {
155
+ includeDefaults: settings().computedStylesIncludeDefaults,
156
+ })
157
+ : null;
158
+
512
159
  // Write immediately with component names (preserves user gesture for clipboard API)
513
160
  const formatted = formatAncestryChain(ancestry);
514
- navigator.clipboard.writeText(formatted).then(() => {
161
+ const fullOutput = stylesResult
162
+ ? formatted + "\n\n" + stylesResult.formatted
163
+ : formatted;
164
+ navigator.clipboard.writeText(fullOutput).then(() => {
515
165
  setToastMessage("Copied to clipboard");
516
166
  });
517
167
 
518
- // For React 19+: try to enrich with source map file paths and re-copy
168
+ // For React 19+: try to enrich with source map file paths and re-copy.
169
+ // If the enriched label differs, re-extract with forceFull:true so the
170
+ // diff-mode fast path doesn't collapse the second extraction (for the
171
+ // same element within the diff window) into "No changes detected".
519
172
  enrichAncestryWithSourceMaps(ancestry, element as HTMLElement).then(
520
173
  (enriched) => {
521
174
  const enrichedFormatted = formatAncestryChain(enriched);
522
175
  if (enrichedFormatted !== formatted) {
523
- navigator.clipboard.writeText(enrichedFormatted).then(() => {
176
+ let enrichedFull = enrichedFormatted;
177
+ if (stylesResult) {
178
+ const enrichedLabel = getElementLabel(enriched);
179
+ const enrichedStyles =
180
+ enrichedLabel !== elementLabel
181
+ ? extractComputedStyles(element as Element, enrichedLabel, {
182
+ forceFull: true,
183
+ includeDefaults: settings().computedStylesIncludeDefaults,
184
+ }).formatted
185
+ : stylesResult.formatted;
186
+ enrichedFull = enrichedFormatted + "\n\n" + enrichedStyles;
187
+ }
188
+ navigator.clipboard.writeText(enrichedFull).then(() => {
524
189
  setToastMessage("Copied to clipboard");
525
190
  });
526
191
  }
@@ -534,67 +199,60 @@ function Runtime(props: RuntimeProps) {
534
199
  }
535
200
  }
536
201
 
537
- function scrollListener() {
538
- setCurrentElement(null);
539
- }
202
+ function mouseOverListener(e: MouseEvent) {
203
+ setHoldingModKey(e.altKey);
204
+ setHoldingShift(e.shiftKey);
540
205
 
541
- const roots: (Document | ShadowRoot)[] = [document];
542
- document.querySelectorAll("*").forEach((node) => {
543
- if (node.id === "locatorjs-wrapper") {
544
- return;
545
- }
546
- if (node.shadowRoot) {
547
- roots.push(node.shadowRoot);
548
- }
549
- });
206
+ // Don't update hovered element while recording — highlight is sticky
207
+ if (recording.recordingState() === "recording") return;
550
208
 
551
- for (const root of roots) {
552
- root.addEventListener("mouseover", mouseOverListener as EventListener, {
553
- capture: true,
554
- });
555
- root.addEventListener("mousemove", mouseMoveListener as EventListener, {
556
- capture: true,
557
- });
558
- root.addEventListener("keydown", keyDownListener as EventListener);
559
- root.addEventListener("keyup", keyUpListener as EventListener);
560
- root.addEventListener("click", clickListener as EventListener, {
561
- capture: true,
562
- });
563
- root.addEventListener("mousedown", mouseDownUpListener as EventListener, {
564
- capture: true,
565
- });
566
- root.addEventListener("mouseup", mouseDownUpListener as EventListener, {
567
- capture: true,
568
- });
569
- root.addEventListener("scroll", scrollListener);
209
+ const element = findElementAtPoint(e);
210
+ if (element) {
211
+ setCurrentElement(element);
212
+ }
570
213
  }
571
214
 
572
- onCleanup(() => {
573
- stopReplay();
574
- stopInteractionTracker();
575
- for (const root of roots) {
576
- root.removeEventListener("keyup", keyUpListener as EventListener);
577
- root.removeEventListener("keydown", keyDownListener as EventListener);
578
- root.removeEventListener(
579
- "mouseover",
580
- mouseOverListener as EventListener,
581
- { capture: true }
582
- );
583
- root.removeEventListener("click", clickListener as EventListener, {
584
- capture: true,
585
- });
586
- root.removeEventListener(
587
- "mousedown",
588
- mouseDownUpListener as EventListener,
589
- { capture: true }
590
- );
591
- root.removeEventListener(
592
- "mouseup",
593
- mouseDownUpListener as EventListener,
594
- { capture: true }
595
- );
596
- root.removeEventListener("scroll", scrollListener);
597
- }
215
+ useEventListeners({
216
+ mouseOverListener,
217
+ mouseMoveListener(e: MouseEvent) {
218
+ setHoldingModKey(e.altKey);
219
+ setHoldingShift(e.shiftKey);
220
+ },
221
+ keyDownListener(e: KeyboardEvent) {
222
+ setHoldingModKey(isCombinationModifiersPressed(e, true));
223
+ setHoldingShift(e.shiftKey);
224
+ if (e.key === "Escape") {
225
+ const wasSelecting = recording.recordingState() === "selecting";
226
+ const wasLocatorActive = locatorActive();
227
+ if (wasSelecting || wasLocatorActive) {
228
+ e.preventDefault();
229
+ e.stopPropagation();
230
+ if (wasLocatorActive) setLocatorActive(false);
231
+ if (wasSelecting) recording.handleRecordClick();
232
+ setCurrentElement(null);
233
+ }
234
+ }
235
+ },
236
+ keyUpListener(e: KeyboardEvent) {
237
+ setHoldingModKey(isCombinationModifiersPressed(e));
238
+ setHoldingShift(e.shiftKey);
239
+ },
240
+ clickListener,
241
+ mouseDownUpListener(e: MouseEvent) {
242
+ setHoldingModKey(e.altKey);
243
+ if (
244
+ e.altKey ||
245
+ locatorActive() ||
246
+ recording.recordingState() === "selecting"
247
+ ) {
248
+ e.preventDefault();
249
+ e.stopPropagation();
250
+ }
251
+ },
252
+ scrollListener() {
253
+ setCurrentElement(null);
254
+ },
255
+ onCleanup: recording.cleanup,
598
256
  });
599
257
 
600
258
  return (
@@ -607,18 +265,19 @@ function Runtime(props: RuntimeProps) {
607
265
  dashed={holdingShift()}
608
266
  />
609
267
  ) : null}
610
- {recordingState() === 'recording' && recordedElement() ? (
611
- <RecordingOutline element={recordedElement()!} />
268
+ {recording.recordingState() === "recording" &&
269
+ recording.recordedElement() ? (
270
+ <RecordingOutline element={recording.recordedElement()!} />
612
271
  ) : null}
613
- {replayBox() ? (
272
+ {recording.replayBox() ? (
614
273
  <div
615
274
  style={{
616
275
  position: "fixed",
617
276
  "z-index": "2147483645",
618
- left: replayBox()!.x + "px",
619
- top: replayBox()!.y + "px",
620
- width: replayBox()!.w + "px",
621
- height: replayBox()!.h + "px",
277
+ left: recording.replayBox()!.x + "px",
278
+ top: recording.replayBox()!.y + "px",
279
+ width: recording.replayBox()!.w + "px",
280
+ height: recording.replayBox()!.h + "px",
622
281
  "border-radius": "50%",
623
282
  "pointer-events": "none",
624
283
  background: "rgba(59, 130, 246, 0.4)",
@@ -627,147 +286,43 @@ function Runtime(props: RuntimeProps) {
627
286
  }}
628
287
  />
629
288
  ) : null}
630
- {recordingState() === 'results' ? (
289
+ {recording.recordingState() === "results" ? (
631
290
  <RecordingResults
632
- findings={recordingFindings()}
633
- summary={recordingSummary()}
634
- data={recordingData()}
635
- elementPath={recordingElementPath()}
636
- interactions={interactionLog()}
637
- onDismiss={dismissResults}
638
- onReplay={replayRecording}
639
- replaying={replaying()}
291
+ findings={recording.recordingFindings()}
292
+ summary={recording.recordingSummary()}
293
+ data={recording.recordingData()}
294
+ elementPath={recording.recordingElementPath()}
295
+ interactions={recording.interactionLog()}
296
+ visualDiff={recording.visualDiff()}
297
+ onDismiss={recording.dismissResults}
298
+ onReplay={recording.replayRecording}
299
+ replaying={recording.replaying()}
640
300
  onToast={setToastMessage}
641
- hasPrevious={!viewingPrevious() && hasPreviousRecording()}
642
- onLoadPrevious={loadPreviousRecording}
643
- hasNext={viewingPrevious()}
644
- onLoadNext={loadLatestRecording}
301
+ hasPrevious={
302
+ !recording.viewingPrevious() && recording.hasPreviousRecording()
303
+ }
304
+ onLoadPrevious={recording.loadPreviousRecording}
305
+ hasNext={recording.viewingPrevious()}
306
+ onLoadNext={recording.loadLatestRecording}
645
307
  />
646
308
  ) : null}
309
+ {settingsOpen() ? (
310
+ <SettingsPanel onDismiss={() => setSettingsOpen(false)} />
311
+ ) : null}
647
312
  {toastMessage() && (
648
313
  <Toast
649
314
  message={toastMessage()!}
650
315
  onClose={() => setToastMessage(null)}
651
316
  />
652
317
  )}
653
- <div
654
- class="fixed pointer-events-auto"
655
- style={{ "z-index": "2147483646", bottom: "20px", right: "20px" }}
656
- title="TreeLocatorJS - Component Ancestry Tracker"
657
- data-treelocator-api="window.__treelocator__"
658
- data-treelocator-help="window.__treelocator__.help()"
659
- >
660
- <style>{`
661
- @keyframes treelocator-rec-pulse {
662
- 0%, 100% { opacity: 1; }
663
- 50% { opacity: 0.3; }
664
- }
665
- `}</style>
666
- {/* Combined pill button: tree (left) | record (right) */}
667
- <div
668
- style={{
669
- display: "flex",
670
- "align-items": "stretch",
671
- "border-radius": "27px",
672
- overflow: "hidden",
673
- "box-shadow":
674
- locatorActive()
675
- ? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
676
- : recordingState() === 'selecting'
677
- ? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
678
- : recordingState() === 'recording'
679
- ? "0 0 0 3px #ef4444, 0 4px 14px rgba(0, 0, 0, 0.25)"
680
- : "0 4px 14px rgba(0, 0, 0, 0.25)",
681
- transition: "box-shadow 0.2s ease-in-out",
682
- }}
683
- >
684
- {/* Left half: Tree icon */}
685
- <div
686
- style={{
687
- width: "54px",
688
- height: "54px",
689
- background: "#ffffff",
690
- display: "flex",
691
- "align-items": "center",
692
- "justify-content": "center",
693
- cursor: "pointer",
694
- overflow: "hidden",
695
- "border-right": "1px solid rgba(0, 0, 0, 0.1)",
696
- transition: "background 0.15s ease-in-out",
697
- }}
698
- onMouseEnter={(e) => e.currentTarget.style.background = "#f0f0f0"}
699
- onMouseLeave={(e) => e.currentTarget.style.background = "#ffffff"}
700
- onClick={() => setLocatorActive(!locatorActive())}
701
- aria-label="TreeLocatorJS: Get component paths using window.__treelocator__.getPath(selector)"
702
- role="button"
703
- >
704
- <img
705
- src={treeIconUrl}
706
- alt="TreeLocatorJS"
707
- width={44}
708
- height={44}
709
- />
710
- </div>
711
- {/* Right half: Record button */}
712
- <div
713
- style={{
714
- width: "54px",
715
- height: "54px",
716
- background: recordingState() === 'recording' ? "#ef4444" : "#ffffff",
717
- display: "flex",
718
- "align-items": "center",
719
- "justify-content": "center",
720
- cursor: "pointer",
721
- transition: "background 0.15s ease-in-out",
722
- }}
723
- onMouseEnter={(e) => {
724
- if (recordingState() !== 'recording') e.currentTarget.style.background = "#f0f0f0";
725
- }}
726
- onMouseLeave={(e) => {
727
- if (recordingState() !== 'recording') e.currentTarget.style.background = "#ffffff";
728
- }}
729
- onClick={handleRecordClick}
730
- aria-label={
731
- recordingState() === 'idle' ? "Record element changes. API: window.__treelocator__.replayWithRecord(selector)" :
732
- recordingState() === 'selecting' ? "Cancel recording selection" :
733
- recordingState() === 'recording' ? "Stop recording" :
734
- "Dismiss results"
735
- }
736
- role="button"
737
- >
738
- {recordingState() === 'recording' ? (
739
- <div style={{ width: "18px", height: "18px", background: "#fff", "border-radius": "3px" }} />
740
- ) : (
741
- <div
742
- style={{
743
- width: "18px",
744
- height: "18px",
745
- background: "#ef4444",
746
- "border-radius": "50%",
747
- animation: recordingState() === 'selecting' ? "treelocator-rec-pulse 1s ease-in-out infinite" : "none",
748
- }}
749
- />
750
- )}
751
- </div>
752
- </div>
753
- <div
754
- role="note"
755
- 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."
756
- style={{
757
- position: "absolute",
758
- width: "1px",
759
- height: "1px",
760
- padding: "0",
761
- margin: "-1px",
762
- overflow: "hidden",
763
- clip: "rect(0,0,0,0)",
764
- "white-space": "nowrap",
765
- border: "0",
766
- }}
767
- >
768
- TreeLocatorJS: Run window.__treelocator__.help() for API docs
769
- </div>
770
- </div>
318
+ <RecordingPillButton
319
+ locatorActive={locatorActive()}
320
+ recordingState={recording.recordingState()}
321
+ settingsOpen={settingsOpen()}
322
+ onLocatorToggle={() => setLocatorActive(!locatorActive())}
323
+ onRecordClick={recording.handleRecordClick}
324
+ onSettingsClick={() => setSettingsOpen(!settingsOpen())}
325
+ />
771
326
  </>
772
327
  );
773
328
  }