heroshot 0.0.2-alpha.1 → 0.0.2-alpha.2
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/cli.js +1 -1
- package/package.json +6 -2
- package/toolbar/dist/toolbar.js +4019 -0
- package/eslint.config.js +0 -327
- package/knip.json +0 -6
- package/scripts/pre-commit.sh +0 -71
- package/src/browser.ts +0 -302
- package/src/capture.ts +0 -7
- package/src/cli.ts +0 -60
- package/src/config.ts +0 -94
- package/src/configFile.ts +0 -25
- package/src/sync.ts +0 -214
- package/src/types.ts +0 -24
- package/tests/types.test.ts +0 -44
- package/toolbar/src/components/ListDialog.svelte +0 -230
- package/toolbar/src/components/NameModal.svelte +0 -186
- package/toolbar/src/components/Toolbar.svelte +0 -540
- package/toolbar/src/lib/dom.ts +0 -178
- package/toolbar/src/lib/tests/dom.test.ts +0 -262
- package/toolbar/src/main.ts +0 -74
- package/toolbar/src/svelte.d.ts +0 -6
- package/toolbar/src/types.ts +0 -43
- package/toolbar/svelte.config.js +0 -8
- package/toolbar/tsconfig.json +0 -9
- package/toolbar/vite.config.ts +0 -52
- package/tsconfig.json +0 -34
- package/tsup.config.ts +0 -12
- package/vitest.config.ts +0 -15
|
@@ -1,540 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { deepElementFromPoint, getSelector } from '../lib/dom';
|
|
3
|
-
import type { ScreenshotItem, ToolbarJob } from '../types';
|
|
4
|
-
import ListDialog from './ListDialog.svelte';
|
|
5
|
-
import NameModal from './NameModal.svelte';
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
initialScreenshots?: ScreenshotItem[];
|
|
9
|
-
pendingJob?: ToolbarJob | null;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const props: Props = $props();
|
|
13
|
-
|
|
14
|
-
// Emit event to CLI
|
|
15
|
-
function emit(event: Parameters<typeof globalThis.__heroshot.emit>[0]): void {
|
|
16
|
-
globalThis.__heroshot?.emit(event);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// State
|
|
20
|
-
let isPickerActive = $state(false);
|
|
21
|
-
let isHighlighting = $state(false); // True when showing highlight from job
|
|
22
|
-
let currentElement = $state<Element | null>(null);
|
|
23
|
-
let statusText = $state('Click crosshair to pick element');
|
|
24
|
-
let screenshots = $state<ScreenshotItem[]>([...(props.initialScreenshots ?? [])]);
|
|
25
|
-
let pendingPick = $state<{ url: string; selector: string } | null>(null);
|
|
26
|
-
let showNameModal = $state(false);
|
|
27
|
-
let showListDialog = $state(false);
|
|
28
|
-
|
|
29
|
-
// Scroll position tracker - used to trigger overlay recalculation
|
|
30
|
-
let scrollY = $state(globalThis.scrollY ?? 0);
|
|
31
|
-
let scrollX = $state(globalThis.scrollX ?? 0);
|
|
32
|
-
|
|
33
|
-
// Derived
|
|
34
|
-
let screenshotCount = $derived(screenshots.length);
|
|
35
|
-
let showOverlay = $derived((isPickerActive || isHighlighting) && overlayRects !== null);
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Toggle picker mode on/off
|
|
39
|
-
*/
|
|
40
|
-
function togglePicker(): void {
|
|
41
|
-
isPickerActive = !isPickerActive;
|
|
42
|
-
isHighlighting = false;
|
|
43
|
-
|
|
44
|
-
if (isPickerActive) {
|
|
45
|
-
statusText = 'Hover over element, click to select';
|
|
46
|
-
document.body.style.cursor = 'crosshair';
|
|
47
|
-
} else {
|
|
48
|
-
statusText = 'Click crosshair to pick element';
|
|
49
|
-
document.body.style.cursor = '';
|
|
50
|
-
currentElement = null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Handle mouse movement - highlight element under cursor
|
|
56
|
-
*/
|
|
57
|
-
function handleMouseMove(event: MouseEvent): void {
|
|
58
|
-
if (!isPickerActive) return;
|
|
59
|
-
|
|
60
|
-
const element = deepElementFromPoint(event.clientX, event.clientY);
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
element &&
|
|
64
|
-
!element.closest('#heroshot-root') &&
|
|
65
|
-
!element.closest('#heroshot-overlay')
|
|
66
|
-
) {
|
|
67
|
-
currentElement = element;
|
|
68
|
-
const selector = getSelector(element);
|
|
69
|
-
statusText = selector;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Handle click - select element and show name modal
|
|
75
|
-
*/
|
|
76
|
-
function handleClick(event: MouseEvent): void {
|
|
77
|
-
if (!isPickerActive) return;
|
|
78
|
-
|
|
79
|
-
const { target } = event;
|
|
80
|
-
if (target instanceof Element && target.closest('#heroshot-root')) {
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
event.stopPropagation();
|
|
86
|
-
|
|
87
|
-
if (currentElement) {
|
|
88
|
-
const selector = getSelector(currentElement);
|
|
89
|
-
const { href } = globalThis.location;
|
|
90
|
-
|
|
91
|
-
pendingPick = { url: href, selector };
|
|
92
|
-
showNameModal = true;
|
|
93
|
-
|
|
94
|
-
// Deactivate picker
|
|
95
|
-
isPickerActive = false;
|
|
96
|
-
document.body.style.cursor = '';
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Handle keyboard events - ESC to cancel
|
|
102
|
-
*/
|
|
103
|
-
function handleKeyDown(event: KeyboardEvent): void {
|
|
104
|
-
if (event.key === 'Escape') {
|
|
105
|
-
if (showNameModal) {
|
|
106
|
-
showNameModal = false;
|
|
107
|
-
pendingPick = null;
|
|
108
|
-
} else if (showListDialog) {
|
|
109
|
-
showListDialog = false;
|
|
110
|
-
} else if (isPickerActive) {
|
|
111
|
-
togglePicker();
|
|
112
|
-
} else if (isHighlighting) {
|
|
113
|
-
// Clear highlight mode
|
|
114
|
-
isHighlighting = false;
|
|
115
|
-
currentElement = null;
|
|
116
|
-
statusText = 'Click crosshair to pick element';
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Handle name modal save
|
|
123
|
-
*/
|
|
124
|
-
function handleNameSave(name: string): void {
|
|
125
|
-
if (!pendingPick) return;
|
|
126
|
-
|
|
127
|
-
const screenshotData: ScreenshotItem = {
|
|
128
|
-
id: generateUid(),
|
|
129
|
-
name,
|
|
130
|
-
url: pendingPick.url,
|
|
131
|
-
selector: pendingPick.selector,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
screenshots = [...screenshots, screenshotData];
|
|
135
|
-
emit({ type: 'screenshot-added', data: screenshotData });
|
|
136
|
-
|
|
137
|
-
showNameModal = false;
|
|
138
|
-
pendingPick = null;
|
|
139
|
-
currentElement = null;
|
|
140
|
-
statusText = 'Click crosshair to pick element';
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Handle name modal cancel
|
|
145
|
-
*/
|
|
146
|
-
function handleNameCancel(): void {
|
|
147
|
-
showNameModal = false;
|
|
148
|
-
pendingPick = null;
|
|
149
|
-
currentElement = null;
|
|
150
|
-
statusText = 'Click crosshair to pick element';
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Handle screenshot removal from list
|
|
155
|
-
*/
|
|
156
|
-
function handleRemoveScreenshot(id: string): void {
|
|
157
|
-
screenshots = screenshots.filter((s) => s.id !== id);
|
|
158
|
-
emit({ type: 'screenshot-removed', id });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Handle screenshot selection - tell CLI to navigate and highlight
|
|
163
|
-
*/
|
|
164
|
-
function handleSelectScreenshot(screenshot: ScreenshotItem): void {
|
|
165
|
-
showListDialog = false;
|
|
166
|
-
emit({
|
|
167
|
-
type: 'screenshot-selected',
|
|
168
|
-
id: screenshot.id,
|
|
169
|
-
url: screenshot.url,
|
|
170
|
-
selector: screenshot.selector,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Query selector that pierces shadow DOM using >>> syntax
|
|
176
|
-
* e.g., "host-element >>> .inner-class >>> span"
|
|
177
|
-
*/
|
|
178
|
-
function querySelectorPiercing(selector: string): Element | null {
|
|
179
|
-
const parts = selector.split('>>>').map(s => s.trim());
|
|
180
|
-
let current: Element | Document = document;
|
|
181
|
-
|
|
182
|
-
for (const part of parts) {
|
|
183
|
-
if (!part) continue;
|
|
184
|
-
|
|
185
|
-
// Query within current context (document or shadow root)
|
|
186
|
-
const root = current instanceof Element
|
|
187
|
-
? (current.shadowRoot ?? current)
|
|
188
|
-
: current;
|
|
189
|
-
|
|
190
|
-
const found = root.querySelector(part);
|
|
191
|
-
if (!found) {
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
current = found;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return current instanceof Element ? current : null;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Find and highlight an element by selector with retry
|
|
203
|
-
*/
|
|
204
|
-
function highlightElement(selector: string, attempt = 1): void {
|
|
205
|
-
const maxAttempts = 5;
|
|
206
|
-
|
|
207
|
-
// Use shadow-piercing query for >>> selectors, regular querySelector otherwise
|
|
208
|
-
const element = selector.includes('>>>')
|
|
209
|
-
? querySelectorPiercing(selector)
|
|
210
|
-
: document.querySelector(selector);
|
|
211
|
-
|
|
212
|
-
if (element) {
|
|
213
|
-
currentElement = element;
|
|
214
|
-
isHighlighting = true;
|
|
215
|
-
statusText = selector;
|
|
216
|
-
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
217
|
-
emit({ type: 'job-complete' });
|
|
218
|
-
} else if (attempt < maxAttempts) {
|
|
219
|
-
// Retry after 1 second
|
|
220
|
-
statusText = `Looking for element... (attempt ${attempt}/${maxAttempts})`;
|
|
221
|
-
globalThis.setTimeout(() => highlightElement(selector, attempt + 1), 1000);
|
|
222
|
-
} else {
|
|
223
|
-
statusText = `Element not found: ${selector}`;
|
|
224
|
-
emit({ type: 'job-complete' });
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Execute pending job from CLI
|
|
230
|
-
*/
|
|
231
|
-
function executePendingJob(job: ToolbarJob): void {
|
|
232
|
-
// Both job types just highlight the selector
|
|
233
|
-
// For navigate-and-highlight, CLI already navigated us here
|
|
234
|
-
highlightElement(job.selector);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Type guard for ToolbarJob
|
|
239
|
-
*/
|
|
240
|
-
function isToolbarJob(value: unknown): value is ToolbarJob {
|
|
241
|
-
return typeof value === 'object' && value !== null && 'type' in value && 'selector' in value;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Handle new job events from CLI
|
|
246
|
-
*/
|
|
247
|
-
function handleNewJob(event: Event): void {
|
|
248
|
-
if (event instanceof CustomEvent && isToolbarJob(event.detail)) {
|
|
249
|
-
executePendingJob(event.detail);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Check for pending job on init
|
|
254
|
-
$effect(() => {
|
|
255
|
-
const job = props.pendingJob;
|
|
256
|
-
if (job) {
|
|
257
|
-
// Small delay to ensure DOM is ready
|
|
258
|
-
globalThis.setTimeout(() => executePendingJob(job), 100);
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
// Listen for new jobs from CLI (when toolbar already running)
|
|
263
|
-
$effect(() => {
|
|
264
|
-
globalThis.addEventListener('heroshot-job', handleNewJob);
|
|
265
|
-
return () => globalThis.removeEventListener('heroshot-job', handleNewJob);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Handle done button - close toolbar and signal completion
|
|
271
|
-
*/
|
|
272
|
-
function handleDone(): void {
|
|
273
|
-
emit({ type: 'done' });
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Generate a random UID (8 chars)
|
|
278
|
-
*/
|
|
279
|
-
function generateUid(): string {
|
|
280
|
-
// eslint-disable-next-line sonarjs/pseudo-random -- Not used for security, just unique IDs
|
|
281
|
-
return Math.random().toString(36).slice(2, 10);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Handle scroll events to update overlay position
|
|
286
|
-
*/
|
|
287
|
-
function handleScroll(): void {
|
|
288
|
-
scrollY = globalThis.scrollY;
|
|
289
|
-
scrollX = globalThis.scrollX;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Calculate overlay rectangles for darkening around element
|
|
294
|
-
* Dependencies on scrollX/scrollY ensure recalculation on scroll
|
|
295
|
-
*/
|
|
296
|
-
function getOverlayRects(element: Element | null, _scrollX: number, _scrollY: number) {
|
|
297
|
-
if (!element) return null;
|
|
298
|
-
|
|
299
|
-
const rect = element.getBoundingClientRect();
|
|
300
|
-
const { innerWidth, innerHeight } = globalThis;
|
|
301
|
-
|
|
302
|
-
return {
|
|
303
|
-
top: { top: 0, left: 0, width: innerWidth, height: rect.top },
|
|
304
|
-
bottom: { top: rect.bottom, left: 0, width: innerWidth, height: innerHeight - rect.bottom },
|
|
305
|
-
left: { top: rect.top, left: 0, width: rect.left, height: rect.height },
|
|
306
|
-
right: { top: rect.top, left: rect.right, width: innerWidth - rect.right, height: rect.height },
|
|
307
|
-
highlight: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
let overlayRects = $derived(getOverlayRects(currentElement, scrollX, scrollY));
|
|
312
|
-
</script>
|
|
313
|
-
|
|
314
|
-
<svelte:window onscroll={handleScroll} />
|
|
315
|
-
|
|
316
|
-
<svelte:document
|
|
317
|
-
onmousemove={handleMouseMove}
|
|
318
|
-
onclick={handleClick}
|
|
319
|
-
onkeydown={handleKeyDown}
|
|
320
|
-
/>
|
|
321
|
-
|
|
322
|
-
<!-- Toolbar -->
|
|
323
|
-
<div class="toolbar">
|
|
324
|
-
<button
|
|
325
|
-
class="picker-btn"
|
|
326
|
-
class:active={isPickerActive}
|
|
327
|
-
onclick={togglePicker}
|
|
328
|
-
title="Pick element"
|
|
329
|
-
>
|
|
330
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
331
|
-
<circle cx="12" cy="12" r="10"/>
|
|
332
|
-
<circle cx="12" cy="12" r="3"/>
|
|
333
|
-
<line x1="12" y1="2" x2="12" y2="6"/>
|
|
334
|
-
<line x1="12" y1="18" x2="12" y2="22"/>
|
|
335
|
-
<line x1="2" y1="12" x2="6" y2="12"/>
|
|
336
|
-
<line x1="18" y1="12" x2="22" y2="12"/>
|
|
337
|
-
</svg>
|
|
338
|
-
</button>
|
|
339
|
-
|
|
340
|
-
<span class="status">{statusText}</span>
|
|
341
|
-
|
|
342
|
-
<button
|
|
343
|
-
class="list-btn"
|
|
344
|
-
class:has-items={screenshotCount > 0}
|
|
345
|
-
onclick={() => showListDialog = true}
|
|
346
|
-
title="View screenshots"
|
|
347
|
-
>
|
|
348
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
349
|
-
<line x1="8" y1="6" x2="21" y2="6"/>
|
|
350
|
-
<line x1="8" y1="12" x2="21" y2="12"/>
|
|
351
|
-
<line x1="8" y1="18" x2="21" y2="18"/>
|
|
352
|
-
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
|
353
|
-
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
|
354
|
-
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
|
355
|
-
</svg>
|
|
356
|
-
{#if screenshotCount > 0}
|
|
357
|
-
<span class="badge">{screenshotCount}</span>
|
|
358
|
-
{/if}
|
|
359
|
-
</button>
|
|
360
|
-
|
|
361
|
-
<button
|
|
362
|
-
class="done-btn"
|
|
363
|
-
onclick={handleDone}
|
|
364
|
-
title="Done - save and close"
|
|
365
|
-
>
|
|
366
|
-
Done
|
|
367
|
-
</button>
|
|
368
|
-
</div>
|
|
369
|
-
|
|
370
|
-
<!-- Overlay for element highlighting -->
|
|
371
|
-
{#if showOverlay && overlayRects}
|
|
372
|
-
<div class="overlay">
|
|
373
|
-
<div
|
|
374
|
-
class="overlay-dark"
|
|
375
|
-
style="top:{overlayRects.top.top}px;left:{overlayRects.top.left}px;width:{overlayRects.top.width}px;height:{overlayRects.top.height}px;"
|
|
376
|
-
></div>
|
|
377
|
-
<div
|
|
378
|
-
class="overlay-dark"
|
|
379
|
-
style="top:{overlayRects.bottom.top}px;left:{overlayRects.bottom.left}px;width:{overlayRects.bottom.width}px;height:{overlayRects.bottom.height}px;"
|
|
380
|
-
></div>
|
|
381
|
-
<div
|
|
382
|
-
class="overlay-dark"
|
|
383
|
-
style="top:{overlayRects.left.top}px;left:{overlayRects.left.left}px;width:{overlayRects.left.width}px;height:{overlayRects.left.height}px;"
|
|
384
|
-
></div>
|
|
385
|
-
<div
|
|
386
|
-
class="overlay-dark"
|
|
387
|
-
style="top:{overlayRects.right.top}px;left:{overlayRects.right.left}px;width:{overlayRects.right.width}px;height:{overlayRects.right.height}px;"
|
|
388
|
-
></div>
|
|
389
|
-
<div
|
|
390
|
-
class="highlight"
|
|
391
|
-
style="top:{overlayRects.highlight.top}px;left:{overlayRects.highlight.left}px;width:{overlayRects.highlight.width}px;height:{overlayRects.highlight.height}px;"
|
|
392
|
-
></div>
|
|
393
|
-
</div>
|
|
394
|
-
{/if}
|
|
395
|
-
|
|
396
|
-
<!-- Name Modal -->
|
|
397
|
-
{#if showNameModal && pendingPick}
|
|
398
|
-
<NameModal
|
|
399
|
-
selector={pendingPick.selector}
|
|
400
|
-
onSave={handleNameSave}
|
|
401
|
-
onCancel={handleNameCancel}
|
|
402
|
-
/>
|
|
403
|
-
{/if}
|
|
404
|
-
|
|
405
|
-
<!-- List Dialog -->
|
|
406
|
-
{#if showListDialog}
|
|
407
|
-
<ListDialog
|
|
408
|
-
{screenshots}
|
|
409
|
-
onSelect={handleSelectScreenshot}
|
|
410
|
-
onRemove={handleRemoveScreenshot}
|
|
411
|
-
onClose={() => showListDialog = false}
|
|
412
|
-
/>
|
|
413
|
-
{/if}
|
|
414
|
-
|
|
415
|
-
<style>
|
|
416
|
-
/*
|
|
417
|
-
* Styles are encapsulated via Shadow DOM (see main.ts).
|
|
418
|
-
* No !important needed - host page CSS cannot leak in.
|
|
419
|
-
*/
|
|
420
|
-
|
|
421
|
-
.toolbar {
|
|
422
|
-
position: fixed;
|
|
423
|
-
bottom: 20px;
|
|
424
|
-
left: 50%;
|
|
425
|
-
transform: translateX(-50%);
|
|
426
|
-
z-index: 2147483647;
|
|
427
|
-
background: #1a1a2e;
|
|
428
|
-
border-radius: 8px;
|
|
429
|
-
padding: 8px 16px;
|
|
430
|
-
display: flex;
|
|
431
|
-
align-items: center;
|
|
432
|
-
gap: 12px;
|
|
433
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
434
|
-
font-size: 13px;
|
|
435
|
-
color: #fff;
|
|
436
|
-
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
437
|
-
user-select: none;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
.picker-btn,
|
|
441
|
-
.list-btn {
|
|
442
|
-
width: 36px;
|
|
443
|
-
height: 36px;
|
|
444
|
-
border: 2px solid transparent;
|
|
445
|
-
border-radius: 6px;
|
|
446
|
-
background: #2d2d44;
|
|
447
|
-
color: #fff;
|
|
448
|
-
cursor: pointer;
|
|
449
|
-
display: flex;
|
|
450
|
-
align-items: center;
|
|
451
|
-
justify-content: center;
|
|
452
|
-
transition: all 0.2s;
|
|
453
|
-
position: relative;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
.picker-btn:hover,
|
|
457
|
-
.list-btn:hover {
|
|
458
|
-
background: #3d3d5c;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
.picker-btn.active {
|
|
462
|
-
background: #22c55e;
|
|
463
|
-
border-color: #16a34a;
|
|
464
|
-
color: #fff;
|
|
465
|
-
animation: pulse 1s infinite;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
@keyframes pulse {
|
|
469
|
-
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7); }
|
|
470
|
-
50% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
.list-btn.has-items {
|
|
474
|
-
background: #3b82f6;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
.badge {
|
|
478
|
-
position: absolute;
|
|
479
|
-
top: -6px;
|
|
480
|
-
right: -6px;
|
|
481
|
-
background: #ef4444;
|
|
482
|
-
color: #fff;
|
|
483
|
-
font-size: 10px;
|
|
484
|
-
font-weight: bold;
|
|
485
|
-
min-width: 16px;
|
|
486
|
-
height: 16px;
|
|
487
|
-
border-radius: 8px;
|
|
488
|
-
display: flex;
|
|
489
|
-
align-items: center;
|
|
490
|
-
justify-content: center;
|
|
491
|
-
padding: 0 4px;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
.status {
|
|
495
|
-
color: #aaa;
|
|
496
|
-
max-width: 400px;
|
|
497
|
-
overflow: hidden;
|
|
498
|
-
text-overflow: ellipsis;
|
|
499
|
-
white-space: nowrap;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
.done-btn {
|
|
503
|
-
padding: 8px 16px;
|
|
504
|
-
border: none;
|
|
505
|
-
border-radius: 6px;
|
|
506
|
-
background: #22c55e;
|
|
507
|
-
color: #fff;
|
|
508
|
-
font-weight: 600;
|
|
509
|
-
cursor: pointer;
|
|
510
|
-
transition: all 0.2s;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
.done-btn:hover {
|
|
514
|
-
background: #16a34a;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
.overlay {
|
|
518
|
-
position: fixed;
|
|
519
|
-
top: 0;
|
|
520
|
-
left: 0;
|
|
521
|
-
width: 100vw;
|
|
522
|
-
height: 100vh;
|
|
523
|
-
z-index: 2147483646;
|
|
524
|
-
pointer-events: none;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
.overlay-dark {
|
|
528
|
-
position: fixed;
|
|
529
|
-
background: rgba(0, 0, 0, 0.5);
|
|
530
|
-
pointer-events: none;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
.highlight {
|
|
534
|
-
position: fixed;
|
|
535
|
-
border: 3px solid #22c55e;
|
|
536
|
-
background: rgba(34, 197, 94, 0.1);
|
|
537
|
-
pointer-events: none;
|
|
538
|
-
box-sizing: border-box;
|
|
539
|
-
}
|
|
540
|
-
</style>
|
package/toolbar/src/lib/dom.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DOM utility functions for the element picker
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Get element from point, piercing shadow DOMs
|
|
7
|
-
*/
|
|
8
|
-
export function deepElementFromPoint(x: number, y: number): Element | null {
|
|
9
|
-
let element = document.elementFromPoint(x, y);
|
|
10
|
-
if (!element) return null;
|
|
11
|
-
|
|
12
|
-
// Traverse into shadow roots
|
|
13
|
-
while (element?.shadowRoot) {
|
|
14
|
-
const inner: Element | null = element.shadowRoot.elementFromPoint(x, y);
|
|
15
|
-
if (!inner || inner === element) break;
|
|
16
|
-
element = inner;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return element;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Get unique CSS selector for element, with shadow DOM support
|
|
24
|
-
*/
|
|
25
|
-
export function getSelector(element: Element): string {
|
|
26
|
-
// Quick return for light DOM elements with ID
|
|
27
|
-
const elementRoot = element.getRootNode();
|
|
28
|
-
if (element.id && !element.id.startsWith('heroshot') && !(elementRoot instanceof ShadowRoot)) {
|
|
29
|
-
return `#${element.id}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const path: string[] = [];
|
|
33
|
-
let current: Element | null = element;
|
|
34
|
-
|
|
35
|
-
for (let depth = 0; current?.nodeType === Node.ELEMENT_NODE && depth < 20; depth++) {
|
|
36
|
-
const root = current.getRootNode();
|
|
37
|
-
const isInShadow = root instanceof ShadowRoot;
|
|
38
|
-
const parent: HTMLElement | null = current.parentElement;
|
|
39
|
-
|
|
40
|
-
let selector = current.tagName.toLowerCase();
|
|
41
|
-
|
|
42
|
-
// Use ID if available
|
|
43
|
-
if (current.id && !current.id.startsWith('heroshot')) {
|
|
44
|
-
selector = `#${current.id}`;
|
|
45
|
-
// In light DOM, ID is unique - we can stop here
|
|
46
|
-
if (!isInShadow) {
|
|
47
|
-
path.unshift(selector);
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
// In shadow DOM, ID is scoped - use it but continue building path
|
|
51
|
-
} else {
|
|
52
|
-
// Add classes for non-ID elements
|
|
53
|
-
if (current.className && typeof current.className === 'string') {
|
|
54
|
-
const classes = current.className
|
|
55
|
-
.trim()
|
|
56
|
-
.split(/\s+/)
|
|
57
|
-
.filter((cls) => cls && !cls.startsWith('heroshot'));
|
|
58
|
-
if (classes.length > 0) {
|
|
59
|
-
selector += '.' + classes.slice(0, 2).join('.');
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Add nth-of-type to disambiguate siblings with same tag
|
|
65
|
-
// Get siblings from parent, or from shadow root for top-level shadow elements
|
|
66
|
-
const siblingContainer: ParentNode | null = parent ?? (root instanceof ShadowRoot ? root : null);
|
|
67
|
-
|
|
68
|
-
if (siblingContainer) {
|
|
69
|
-
const currentTagName: string = current.tagName;
|
|
70
|
-
const siblings: Element[] = [...siblingContainer.children].filter(
|
|
71
|
-
(child) => child.tagName === currentTagName
|
|
72
|
-
);
|
|
73
|
-
if (siblings.length > 1) {
|
|
74
|
-
const index = siblings.indexOf(current) + 1;
|
|
75
|
-
selector += `:nth-of-type(${index})`;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
path.unshift(selector);
|
|
80
|
-
|
|
81
|
-
// Determine next element
|
|
82
|
-
if (parent) {
|
|
83
|
-
// Continue up the tree (works both in light DOM and within shadow DOM)
|
|
84
|
-
current = parent;
|
|
85
|
-
} else if (root instanceof ShadowRoot) {
|
|
86
|
-
// Reached top of shadow tree, pierce to host
|
|
87
|
-
path.unshift('>>>');
|
|
88
|
-
current = root.host;
|
|
89
|
-
} else {
|
|
90
|
-
current = null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Join path and clean up shadow DOM syntax
|
|
95
|
-
return path.join(' > ').replaceAll('> >>> >', ' >>> ');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Create the toolbar HTML element
|
|
100
|
-
*/
|
|
101
|
-
export function createToolbar(): HTMLDivElement {
|
|
102
|
-
const toolbar = document.createElement('div');
|
|
103
|
-
toolbar.id = 'heroshot-toolbar';
|
|
104
|
-
toolbar.innerHTML = `
|
|
105
|
-
<button id="heroshot-picker-btn" title="Pick element">
|
|
106
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
107
|
-
<circle cx="12" cy="12" r="10"/>
|
|
108
|
-
<circle cx="12" cy="12" r="3"/>
|
|
109
|
-
<line x1="12" y1="2" x2="12" y2="6"/>
|
|
110
|
-
<line x1="12" y1="18" x2="12" y2="22"/>
|
|
111
|
-
<line x1="2" y1="12" x2="6" y2="12"/>
|
|
112
|
-
<line x1="18" y1="12" x2="22" y2="12"/>
|
|
113
|
-
</svg>
|
|
114
|
-
</button>
|
|
115
|
-
<span id="heroshot-status">Click crosshair to pick element</span>
|
|
116
|
-
`;
|
|
117
|
-
return toolbar;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Create the overlay container element
|
|
122
|
-
*/
|
|
123
|
-
export function createOverlay(): HTMLDivElement {
|
|
124
|
-
const overlay = document.createElement('div');
|
|
125
|
-
overlay.id = 'heroshot-overlay';
|
|
126
|
-
overlay.style.display = 'none';
|
|
127
|
-
return overlay;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Update overlay to show dark areas around element
|
|
132
|
-
*/
|
|
133
|
-
export function updateOverlay(
|
|
134
|
-
overlay: HTMLDivElement,
|
|
135
|
-
rect: DOMRect | null
|
|
136
|
-
): void {
|
|
137
|
-
// Clear previous overlay
|
|
138
|
-
overlay.innerHTML = '';
|
|
139
|
-
|
|
140
|
-
if (!rect) {
|
|
141
|
-
overlay.style.display = 'none';
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
overlay.style.display = 'block';
|
|
146
|
-
|
|
147
|
-
const { innerWidth, innerHeight } = globalThis;
|
|
148
|
-
|
|
149
|
-
// Top dark area
|
|
150
|
-
const top = document.createElement('div');
|
|
151
|
-
top.className = 'heroshot-overlay-dark';
|
|
152
|
-
top.style.cssText = `top:0;left:0;width:${String(innerWidth)}px;height:${String(rect.top)}px;`;
|
|
153
|
-
overlay.append(top);
|
|
154
|
-
|
|
155
|
-
// Bottom dark area
|
|
156
|
-
const bottom = document.createElement('div');
|
|
157
|
-
bottom.className = 'heroshot-overlay-dark';
|
|
158
|
-
bottom.style.cssText = `top:${String(rect.bottom)}px;left:0;width:${String(innerWidth)}px;height:${String(innerHeight - rect.bottom)}px;`;
|
|
159
|
-
overlay.append(bottom);
|
|
160
|
-
|
|
161
|
-
// Left dark area
|
|
162
|
-
const left = document.createElement('div');
|
|
163
|
-
left.className = 'heroshot-overlay-dark';
|
|
164
|
-
left.style.cssText = `top:${String(rect.top)}px;left:0;width:${String(rect.left)}px;height:${String(rect.height)}px;`;
|
|
165
|
-
overlay.append(left);
|
|
166
|
-
|
|
167
|
-
// Right dark area
|
|
168
|
-
const right = document.createElement('div');
|
|
169
|
-
right.className = 'heroshot-overlay-dark';
|
|
170
|
-
right.style.cssText = `top:${String(rect.top)}px;left:${String(rect.right)}px;width:${String(innerWidth - rect.right)}px;height:${String(rect.height)}px;`;
|
|
171
|
-
overlay.append(right);
|
|
172
|
-
|
|
173
|
-
// Highlight border around element
|
|
174
|
-
const highlight = document.createElement('div');
|
|
175
|
-
highlight.className = 'heroshot-highlight';
|
|
176
|
-
highlight.style.cssText = `top:${String(rect.top)}px;left:${String(rect.left)}px;width:${String(rect.width)}px;height:${String(rect.height)}px;`;
|
|
177
|
-
overlay.append(highlight);
|
|
178
|
-
}
|