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.
@@ -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>
@@ -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
- }