sparkecode-devtools 0.1.37

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/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Sparkecode Dev Tools
2
+
3
+ Click any React component on a page to copy helpful context to your clipboard.
4
+
5
+ ## Script tag (unpkg/jsDelivr)
6
+
7
+ ```html
8
+ <script src="https://unpkg.com/sparkecode-devtools/sparkecode-select.js"></script>
9
+ <script>
10
+ window.SparkeCodeSelect.init();
11
+ </script>
12
+ ```
13
+
14
+ ## Next.js (App or Pages Router)
15
+
16
+ ```tsx
17
+ import { SparkeCodeSelect } from 'sparkecode-devtools/next';
18
+
19
+ export default function Layout({ children }) {
20
+ return (
21
+ <>
22
+ <SparkeCodeSelect />
23
+ {children}
24
+ </>
25
+ );
26
+ }
27
+ ```
28
+
29
+ Default behavior:
30
+ - Runs only in development
31
+ - Auto-connects to a local Sparkecoder instance and sends selections into the latest session
32
+
33
+ Optional overrides:
34
+
35
+ ```tsx
36
+ <SparkeCodeSelect
37
+ enabled={true}
38
+ scriptUrl="https://unpkg.com/sparkecode-devtools/sparkecode-select.js"
39
+ sparkecoderEnabled={true}
40
+ config={{
41
+ sparkecoder: {
42
+ apiBase: "http://127.0.0.1:3141",
43
+ sessionId: "your-session-id"
44
+ },
45
+ primaryColor: "#8b5cf6"
46
+ }}
47
+ />
48
+ ```
49
+
50
+ ## Sparkecoder integration (local)
51
+
52
+ If you are running Sparkecoder locally, you can auto-send selections into the
53
+ most recently active session:
54
+
55
+ ```html
56
+ <script>
57
+ window.SparkeCodeSelect.init({
58
+ sparkecoder: { enabled: true }
59
+ });
60
+ </script>
61
+ ```
62
+
63
+ Optional overrides:
64
+
65
+ ```html
66
+ <script>
67
+ window.SparkeCodeSelect.init({
68
+ sparkecoder: {
69
+ enabled: true,
70
+ apiBase: "http://127.0.0.1:3141",
71
+ sessionId: "your-session-id"
72
+ }
73
+ });
74
+ </script>
75
+ ```
76
+
77
+ ## Webhook (custom)
78
+
79
+ You can also send copy events to any endpoint:
80
+
81
+ ```html
82
+ <script>
83
+ window.SparkeCodeSelect.init({
84
+ webhookUrl: "https://example.com/webhook"
85
+ });
86
+ </script>
87
+ ```
88
+
89
+ ## Controls
90
+
91
+ - Click the floating button to toggle selection
92
+ - Drag the button to reposition
93
+ - Press `Cmd/Ctrl+Shift+S` to toggle
94
+ - Press `Esc` to exit selection mode
package/next.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { JSX } from 'react';
2
+
3
+ export interface SparkeCodeSelectConfig {
4
+ webhookUrl?: string | null;
5
+ primaryColor?: string;
6
+ successColor?: string;
7
+ bgColor?: string;
8
+ textColor?: string;
9
+ sparkecoder?: {
10
+ enabled?: boolean;
11
+ apiBase?: string | null;
12
+ sessionId?: string | null;
13
+ };
14
+ }
15
+
16
+ export interface SparkeCodeSelectProps {
17
+ enabled?: boolean;
18
+ scriptUrl?: string;
19
+ config?: SparkeCodeSelectConfig;
20
+ sparkecoderEnabled?: boolean;
21
+ strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker';
22
+ }
23
+
24
+ export function SparkeCodeSelect(props: SparkeCodeSelectProps): JSX.Element | null;
package/next.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import Script from 'next/script';
4
+ import { useEffect, useMemo } from 'react';
5
+
6
+ const DEFAULT_SCRIPT_URL = 'https://unpkg.com/sparkecode-devtools/sparkecode-select.js';
7
+
8
+ export function SparkeCodeSelect({
9
+ enabled = process.env.NODE_ENV !== 'production',
10
+ scriptUrl = DEFAULT_SCRIPT_URL,
11
+ config,
12
+ sparkecoderEnabled = true,
13
+ strategy = 'afterInteractive',
14
+ }) {
15
+ const mergedConfig = useMemo(() => ({
16
+ ...(config || {}),
17
+ sparkecoder: {
18
+ enabled: sparkecoderEnabled,
19
+ ...(config && config.sparkecoder ? config.sparkecoder : {}),
20
+ },
21
+ }), [config, sparkecoderEnabled]);
22
+
23
+ useEffect(() => {
24
+ if (!enabled) return;
25
+ if (typeof window === 'undefined') return;
26
+ if (window.SparkeCodeSelect && typeof window.SparkeCodeSelect.init === 'function') {
27
+ window.SparkeCodeSelect.init(mergedConfig);
28
+ }
29
+ }, [enabled, mergedConfig]);
30
+
31
+ if (!enabled) return null;
32
+
33
+ const handleLoad = () => {
34
+ if (typeof window === 'undefined') return;
35
+ if (window.SparkeCodeSelect && typeof window.SparkeCodeSelect.init === 'function') {
36
+ window.SparkeCodeSelect.init(mergedConfig);
37
+ }
38
+ };
39
+
40
+ return (
41
+ <Script
42
+ src={scriptUrl}
43
+ strategy={strategy}
44
+ onLoad={handleLoad}
45
+ />
46
+ );
47
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "sparkecode-devtools",
3
+ "version": "0.1.37",
4
+ "description": "Sparkecode Dev Tools for selecting and sharing components.",
5
+ "type": "module",
6
+ "main": "sparkecode-select.js",
7
+ "unpkg": "sparkecode-select.js",
8
+ "jsdelivr": "sparkecode-select.js",
9
+ "exports": {
10
+ ".": "./sparkecode-select.js",
11
+ "./next": {
12
+ "types": "./next.d.ts",
13
+ "default": "./next.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "sparkecode-select.js",
18
+ "next.js",
19
+ "next.d.ts",
20
+ "README.md"
21
+ ],
22
+ "keywords": [
23
+ "react",
24
+ "nextjs",
25
+ "devtools",
26
+ "component",
27
+ "selector",
28
+ "clipboard"
29
+ ],
30
+ "author": "gostudyfetchgo",
31
+ "license": "UNLICENSED",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/gostudyfetchgo/sparkecoder.git"
35
+ },
36
+ "homepage": "https://github.com/gostudyfetchgo/sparkecoder#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/gostudyfetchgo/sparkecoder/issues"
39
+ },
40
+ "sideEffects": true,
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,902 @@
1
+ /**
2
+ * SparkeCode Select - Component Selector & Copier
3
+ *
4
+ * Click on any React component to instantly copy it to your clipboard.
5
+ * Simple, fast, and easy to use.
6
+ */
7
+
8
+ (function() {
9
+ 'use strict';
10
+
11
+ // ============================================
12
+ // Configuration
13
+ // ============================================
14
+
15
+ const DEFAULT_CONFIG = {
16
+ webhookUrl: null,
17
+ primaryColor: '#8b5cf6',
18
+ successColor: '#10b981',
19
+ bgColor: '#1e1e2e',
20
+ textColor: '#ffffff',
21
+ sparkecoder: {
22
+ enabled: false,
23
+ apiBase: null,
24
+ sessionId: null,
25
+ },
26
+ };
27
+
28
+ let config = { ...DEFAULT_CONFIG };
29
+ let isActive = false;
30
+ let isInitialized = false;
31
+ let hoveredElement = null;
32
+ let overlay = null;
33
+ let hoverBox = null;
34
+ let label = null;
35
+ let floatingButton = null;
36
+ let sparkecoderState = { apiBase: null, sessionId: null, inFlight: null };
37
+
38
+ // Drag state
39
+ let isDragging = false;
40
+ let dragOffset = { x: 0, y: 0 };
41
+ let buttonPos = { x: 16, y: 16 };
42
+
43
+ // ============================================
44
+ // Utilities
45
+ // ============================================
46
+
47
+ function isMac() {
48
+ return navigator.platform.toUpperCase().indexOf('MAC') >= 0;
49
+ }
50
+
51
+ function applyConfig(options = {}) {
52
+ const next = { ...config, ...options };
53
+ if (options.sparkecoder) {
54
+ next.sparkecoder = { ...config.sparkecoder, ...options.sparkecoder };
55
+ sparkecoderState = {
56
+ apiBase: options.sparkecoder.apiBase || null,
57
+ sessionId: options.sparkecoder.sessionId || null,
58
+ inFlight: null,
59
+ };
60
+ }
61
+ config = next;
62
+ return config;
63
+ }
64
+
65
+ function isInDOM(element) {
66
+ return element && document.body.contains(element);
67
+ }
68
+
69
+ function fetchWithTimeout(url, options = {}, timeoutMs = 1500) {
70
+ const controller = new AbortController();
71
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
72
+ return fetch(url, { ...options, signal: controller.signal })
73
+ .finally(() => clearTimeout(timeoutId));
74
+ }
75
+
76
+ function throttle(fn, delay) {
77
+ let lastCall = 0;
78
+ return function(...args) {
79
+ const now = Date.now();
80
+ if (now - lastCall >= delay) {
81
+ lastCall = now;
82
+ return fn.apply(this, args);
83
+ }
84
+ };
85
+ }
86
+
87
+ // ============================================
88
+ // React Fiber Detection
89
+ // ============================================
90
+
91
+ const INTERNAL_NAMES = new Set([
92
+ 'Fragment', 'Suspense', 'StrictMode', 'Profiler', 'Provider', 'Consumer',
93
+ 'Context', 'ForwardRef', 'Memo', 'Lazy', 'Portal', 'Router', 'ErrorBoundary',
94
+ 'InnerLayoutRouter', 'OuterLayoutRouter', 'RenderFromTemplateContext',
95
+ 'ScrollAndFocusHandler', 'RedirectBoundary', 'NotFoundBoundary',
96
+ 'LoadingBoundary', 'HotReload', 'AppRouter', 'ServerRoot',
97
+ 'HTTPAccessFallbackBoundary', 'DevRootHTTPAccessFallbackBoundary',
98
+ 'ClientPageRoot', 'InnerScrollAndFocusHandler', 'RedirectErrorBoundary',
99
+ ]);
100
+
101
+ function getFiber(element) {
102
+ if (!element) return null;
103
+ const keys = Object.keys(element);
104
+ for (const key of keys) {
105
+ if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {
106
+ return element[key];
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function isInternalComponent(name) {
113
+ if (!name || name.length <= 1) return true;
114
+ if (name.startsWith('_')) return true;
115
+ if (INTERNAL_NAMES.has(name)) return true;
116
+ if (name.includes('Provider') && name.includes('Context')) return true;
117
+ if (name[0] === name[0].toLowerCase()) return true;
118
+ return false;
119
+ }
120
+
121
+ function isSourceFile(filePath) {
122
+ if (!filePath) return false;
123
+ if (filePath.includes('node_modules')) return false;
124
+ if (filePath.includes('react-dom')) return false;
125
+ if (filePath.includes('react/')) return false;
126
+ if (filePath.includes('next/dist')) return false;
127
+ return true;
128
+ }
129
+
130
+ function normalizeFilePath(filePath) {
131
+ if (!filePath) return null;
132
+
133
+ let normalized = filePath
134
+ .replace(/^webpack-internal:\/\/\//, '')
135
+ .replace(/^webpack:\/\/[^/]*\//, '')
136
+ .replace(/^\(app-pages-browser\)\//, '')
137
+ .replace(/^\(rsc\)\//, '')
138
+ .replace(/^\(ssr\)\//, '')
139
+ .replace(/\?.*$/, '')
140
+ .replace(/^\.\//, '')
141
+ .replace(/^\/+/, '');
142
+
143
+ const appMatch = normalized.match(/\/(app|src|pages|components)\/(.+)$/);
144
+ if (appMatch) {
145
+ normalized = appMatch[1] + '/' + appMatch[2];
146
+ }
147
+
148
+ if (!isSourceFile(normalized)) return null;
149
+ return normalized;
150
+ }
151
+
152
+ function getSourceFromFiber(fiber) {
153
+ if (!fiber) return null;
154
+
155
+ if (fiber._debugSource?.fileName) return fiber._debugSource;
156
+ if (fiber._source?.fileName) return fiber._source;
157
+ if (fiber.elementType?._source) return fiber.elementType._source;
158
+ if (fiber.type?._source) return fiber.type._source;
159
+ if (fiber.memoizedProps?.__source) return fiber.memoizedProps.__source;
160
+
161
+ return null;
162
+ }
163
+
164
+ function getDisplayName(fiber) {
165
+ if (!fiber?.type) return null;
166
+ const type = fiber.type;
167
+ if (typeof type === 'function') return type.displayName || type.name || null;
168
+ if (typeof type === 'object') {
169
+ if (type.displayName) return type.displayName;
170
+ if (type.type) return type.type.displayName || type.type.name || null;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function getComponentInfo(element) {
176
+ if (!element || !isInDOM(element)) return null;
177
+
178
+ const fiber = getFiber(element);
179
+ if (!fiber) return null;
180
+
181
+ let current = fiber;
182
+ let foundName = null;
183
+ let foundSource = null;
184
+ const seen = new Set();
185
+
186
+ while (current && !seen.has(current)) {
187
+ seen.add(current);
188
+
189
+ const name = getDisplayName(current);
190
+ if (name && !isInternalComponent(name)) {
191
+ if (!foundName) foundName = name;
192
+
193
+ const source = getSourceFromFiber(current);
194
+ if (source?.fileName) {
195
+ const normalized = normalizeFilePath(source.fileName);
196
+ if (normalized) {
197
+ foundSource = { fileName: normalized, lineNumber: source.lineNumber || null };
198
+ break;
199
+ }
200
+ }
201
+ }
202
+ current = current.return;
203
+ }
204
+
205
+ if (!foundName) return null;
206
+ return { name: foundName, fileName: foundSource?.fileName || null, lineNumber: foundSource?.lineNumber || null };
207
+ }
208
+
209
+ function getComponentStack(element) {
210
+ if (!element || !isInDOM(element)) return [];
211
+
212
+ const fiber = getFiber(element);
213
+ if (!fiber) return [];
214
+
215
+ const stack = [];
216
+ let current = fiber;
217
+ const seen = new Set();
218
+
219
+ while (current && !seen.has(current) && stack.length < 5) {
220
+ seen.add(current);
221
+
222
+ const name = getDisplayName(current);
223
+ if (name && !isInternalComponent(name)) {
224
+ const source = getSourceFromFiber(current);
225
+ const fileName = source?.fileName ? normalizeFilePath(source.fileName) : null;
226
+ stack.push({ name, fileName, lineNumber: source?.lineNumber || null });
227
+ }
228
+ current = current.return;
229
+ }
230
+ return stack;
231
+ }
232
+
233
+ // ============================================
234
+ // Webhook
235
+ // ============================================
236
+
237
+ async function sendWebhook(event, data = {}) {
238
+ if (!config.webhookUrl) return;
239
+ try {
240
+ await fetch(config.webhookUrl, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify({ event, timestamp: new Date().toISOString(), url: window.location.href, ...data }),
244
+ });
245
+ } catch (e) {
246
+ console.warn('[SparkeCode] Webhook error:', e.message);
247
+ }
248
+ }
249
+
250
+ // ============================================
251
+ // Sparkecoder Integration
252
+ // ============================================
253
+
254
+ const SPARKECODER_DEFAULT_BASES = [
255
+ 'http://127.0.0.1:3141',
256
+ 'http://localhost:3141',
257
+ ];
258
+
259
+ async function detectSparkecoderBase() {
260
+ for (const base of SPARKECODER_DEFAULT_BASES) {
261
+ try {
262
+ const res = await fetchWithTimeout(`${base}/health/ready`, { method: 'GET' }, 800);
263
+ if (res.ok) return base;
264
+ } catch (e) {
265
+ // Ignore and try next base
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ async function getLatestSessionId(apiBase) {
272
+ try {
273
+ const res = await fetchWithTimeout(`${apiBase}/sessions`, { method: 'GET' }, 1500);
274
+ if (!res.ok) return null;
275
+ const data = await res.json();
276
+ const sessions = Array.isArray(data.sessions) ? data.sessions : [];
277
+ const candidates = sessions.filter((s) => s && s.status !== 'error');
278
+ if (candidates.length === 0) return null;
279
+ candidates.sort((a, b) => {
280
+ const aTime = a.updatedAt ? Date.parse(a.updatedAt) : (a.createdAt ? Date.parse(a.createdAt) : 0);
281
+ const bTime = b.updatedAt ? Date.parse(b.updatedAt) : (b.createdAt ? Date.parse(b.createdAt) : 0);
282
+ return bTime - aTime;
283
+ });
284
+ return candidates[0].id || null;
285
+ } catch (e) {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ async function ensureSparkecoderConnection() {
291
+ if (!config.sparkecoder?.enabled) return null;
292
+ if (sparkecoderState.apiBase && sparkecoderState.sessionId) {
293
+ return { apiBase: sparkecoderState.apiBase, sessionId: sparkecoderState.sessionId };
294
+ }
295
+ if (sparkecoderState.inFlight) return sparkecoderState.inFlight;
296
+
297
+ sparkecoderState.inFlight = (async () => {
298
+ const apiBase = config.sparkecoder.apiBase || await detectSparkecoderBase();
299
+ if (!apiBase) return null;
300
+ const sessionId = config.sparkecoder.sessionId || await getLatestSessionId(apiBase);
301
+ if (!sessionId) return null;
302
+ sparkecoderState.apiBase = apiBase;
303
+ sparkecoderState.sessionId = sessionId;
304
+ return { apiBase, sessionId };
305
+ })();
306
+
307
+ try {
308
+ return await sparkecoderState.inFlight;
309
+ } finally {
310
+ sparkecoderState.inFlight = null;
311
+ }
312
+ }
313
+
314
+ async function sendToSparkecoder(promptText) {
315
+ const connection = await ensureSparkecoderConnection();
316
+ if (!connection) return false;
317
+
318
+ try {
319
+ // Post to pending-input endpoint so it appears in the web UI input field
320
+ // (instead of running the agent immediately)
321
+ const res = await fetchWithTimeout(
322
+ `${connection.apiBase}/sessions/${connection.sessionId}/pending-input`,
323
+ {
324
+ method: 'POST',
325
+ headers: { 'Content-Type': 'application/json' },
326
+ body: JSON.stringify({ text: promptText }),
327
+ },
328
+ 1500
329
+ );
330
+ return res.ok;
331
+ } catch (e) {
332
+ return false;
333
+ }
334
+ }
335
+
336
+ // Heartbeat interval ID
337
+ let heartbeatIntervalId = null;
338
+ let lastHeartbeatPath = null;
339
+
340
+ async function sendDevtoolsHeartbeat() {
341
+ const connection = await ensureSparkecoderConnection();
342
+ if (!connection) return;
343
+
344
+ const path = window.location.pathname;
345
+ const url = window.location.href;
346
+ const pageName = getPageName();
347
+
348
+ // Skip if path hasn't changed (unless it's the first heartbeat)
349
+ if (lastHeartbeatPath === path && lastHeartbeatPath !== null) return;
350
+ lastHeartbeatPath = path;
351
+
352
+ try {
353
+ await fetchWithTimeout(
354
+ `${connection.apiBase}/sessions/${connection.sessionId}/devtools-context`,
355
+ {
356
+ method: 'POST',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({ url, path, pageName }),
359
+ },
360
+ 1500
361
+ );
362
+ } catch (e) {
363
+ // Ignore heartbeat errors
364
+ }
365
+ }
366
+
367
+ function startHeartbeat() {
368
+ if (heartbeatIntervalId) return;
369
+
370
+ // Send initial heartbeat
371
+ sendDevtoolsHeartbeat();
372
+
373
+ // Send heartbeat every 5 seconds (will only actually send if path changed)
374
+ heartbeatIntervalId = setInterval(() => {
375
+ // Force send every interval to keep connection alive
376
+ lastHeartbeatPath = null;
377
+ sendDevtoolsHeartbeat();
378
+ }, 5000);
379
+
380
+ // Also send on navigation (for SPAs)
381
+ if (typeof window !== 'undefined') {
382
+ window.addEventListener('popstate', sendDevtoolsHeartbeat);
383
+
384
+ // Intercept pushState/replaceState for SPA navigation
385
+ const originalPushState = history.pushState;
386
+ const originalReplaceState = history.replaceState;
387
+
388
+ history.pushState = function(...args) {
389
+ const result = originalPushState.apply(this, args);
390
+ sendDevtoolsHeartbeat();
391
+ return result;
392
+ };
393
+
394
+ history.replaceState = function(...args) {
395
+ const result = originalReplaceState.apply(this, args);
396
+ sendDevtoolsHeartbeat();
397
+ return result;
398
+ };
399
+ }
400
+ }
401
+
402
+ function stopHeartbeat() {
403
+ if (heartbeatIntervalId) {
404
+ clearInterval(heartbeatIntervalId);
405
+ heartbeatIntervalId = null;
406
+ }
407
+ }
408
+
409
+ // ============================================
410
+ // Page Path Detection
411
+ // ============================================
412
+
413
+ function getPagePath() {
414
+ if (typeof window !== 'undefined' && window.__NEXT_DATA__) {
415
+ const route = window.__NEXT_DATA__.page;
416
+ const path = window.location.pathname;
417
+ if (route && route !== path) return `${path} (route: ${route})`;
418
+ return path || '/';
419
+ }
420
+ return window.location.pathname || '/';
421
+ }
422
+
423
+ function getPageName() {
424
+ const path = getPagePath().split(' (route:')[0].split('?')[0];
425
+ if (path === '/' || path === '') return 'Home page';
426
+
427
+ const parts = path.split('/').filter(p => p && !/^\d+$/.test(p) && !/^[a-f0-9-]{36}$/i.test(p));
428
+ if (parts.length === 0) return `"${path}" page`;
429
+
430
+ return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).replace(/-/g, ' ')).join(' > ') + ' page';
431
+ }
432
+
433
+ // ============================================
434
+ // UI Components
435
+ // ============================================
436
+
437
+ function createFloatingButton() {
438
+ floatingButton = document.createElement('div');
439
+ floatingButton.id = 'sparkecode-button';
440
+ floatingButton.innerHTML = `<span style="font-weight: 600; font-size: 13px;">SparkeCode Select</span>`;
441
+ floatingButton.style.cssText = `
442
+ position: fixed;
443
+ top: ${buttonPos.y}px;
444
+ left: ${buttonPos.x}px;
445
+ height: 40px;
446
+ padding: 0 16px;
447
+ background: ${config.primaryColor};
448
+ border-radius: 10px;
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ cursor: grab;
453
+ z-index: 999998;
454
+ box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
455
+ transition: box-shadow 0.2s, background 0.2s;
456
+ color: white;
457
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
458
+ user-select: none;
459
+ `;
460
+ floatingButton.title = 'Drag to move • Click to toggle';
461
+
462
+ // Drag handling
463
+ floatingButton.addEventListener('mousedown', startDrag);
464
+ floatingButton.addEventListener('touchstart', startDrag, { passive: false });
465
+
466
+ document.body.appendChild(floatingButton);
467
+ }
468
+
469
+ function startDrag(e) {
470
+ if (e.target.closest('#sparkecode-button')) {
471
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
472
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
473
+
474
+ dragOffset.x = clientX - buttonPos.x;
475
+ dragOffset.y = clientY - buttonPos.y;
476
+ isDragging = false;
477
+
478
+ const startX = clientX;
479
+ const startY = clientY;
480
+
481
+ const onMove = (e) => {
482
+ const moveX = e.touches ? e.touches[0].clientX : e.clientX;
483
+ const moveY = e.touches ? e.touches[0].clientY : e.clientY;
484
+
485
+ // Only start dragging if moved more than 5px
486
+ if (!isDragging && (Math.abs(moveX - startX) > 5 || Math.abs(moveY - startY) > 5)) {
487
+ isDragging = true;
488
+ floatingButton.style.cursor = 'grabbing';
489
+ }
490
+
491
+ if (isDragging) {
492
+ e.preventDefault();
493
+ buttonPos.x = Math.max(0, Math.min(window.innerWidth - 200, moveX - dragOffset.x));
494
+ buttonPos.y = Math.max(0, Math.min(window.innerHeight - 50, moveY - dragOffset.y));
495
+ floatingButton.style.left = buttonPos.x + 'px';
496
+ floatingButton.style.top = buttonPos.y + 'px';
497
+ }
498
+ };
499
+
500
+ const onUp = () => {
501
+ document.removeEventListener('mousemove', onMove);
502
+ document.removeEventListener('mouseup', onUp);
503
+ document.removeEventListener('touchmove', onMove);
504
+ document.removeEventListener('touchend', onUp);
505
+
506
+ floatingButton.style.cursor = 'grab';
507
+
508
+ // If not dragging, it was a click
509
+ if (!isDragging) {
510
+ toggleActive();
511
+ }
512
+ isDragging = false;
513
+ };
514
+
515
+ document.addEventListener('mousemove', onMove);
516
+ document.addEventListener('mouseup', onUp);
517
+ document.addEventListener('touchmove', onMove, { passive: false });
518
+ document.addEventListener('touchend', onUp);
519
+ }
520
+ }
521
+
522
+ function createOverlay() {
523
+ overlay = document.createElement('div');
524
+ overlay.id = 'sparkecode-overlay';
525
+ overlay.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 999999;`;
526
+ document.body.appendChild(overlay);
527
+
528
+ hoverBox = document.createElement('div');
529
+ hoverBox.id = 'sparkecode-hover';
530
+ hoverBox.style.cssText = `
531
+ position: fixed; pointer-events: none;
532
+ border: 2px solid ${config.primaryColor};
533
+ background: ${config.primaryColor}15;
534
+ border-radius: 6px; display: none;
535
+ transition: all 0.05s ease-out;
536
+ `;
537
+ overlay.appendChild(hoverBox);
538
+
539
+ label = document.createElement('div');
540
+ label.id = 'sparkecode-label';
541
+ label.style.cssText = `
542
+ position: fixed;
543
+ background: ${config.bgColor};
544
+ color: ${config.textColor};
545
+ padding: 8px 14px;
546
+ border-radius: 8px;
547
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
548
+ font-size: 13px;
549
+ font-weight: 500;
550
+ display: none;
551
+ z-index: 1000000;
552
+ pointer-events: none;
553
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
554
+ border: 1px solid rgba(255,255,255,0.1);
555
+ `;
556
+ overlay.appendChild(label);
557
+ }
558
+
559
+ function removeOverlay() {
560
+ if (overlay) { overlay.remove(); overlay = null; hoverBox = null; label = null; }
561
+ }
562
+
563
+ function updateHoverHighlight(element) {
564
+ if (!hoverBox || !label) return;
565
+
566
+ if (!element || !isInDOM(element)) {
567
+ hoverBox.style.display = 'none';
568
+ label.style.display = 'none';
569
+ return;
570
+ }
571
+
572
+ const rect = element.getBoundingClientRect();
573
+ if (rect.width === 0 || rect.height === 0) {
574
+ hoverBox.style.display = 'none';
575
+ label.style.display = 'none';
576
+ return;
577
+ }
578
+
579
+ hoverBox.style.display = 'block';
580
+ hoverBox.style.left = `${rect.left - 2}px`;
581
+ hoverBox.style.top = `${rect.top - 2}px`;
582
+ hoverBox.style.width = `${rect.width + 4}px`;
583
+ hoverBox.style.height = `${rect.height + 4}px`;
584
+
585
+ const info = getComponentInfo(element);
586
+ if (info?.name) {
587
+ let text = `<${info.name}>`;
588
+ if (info.fileName) {
589
+ text += ` · ${info.fileName}${info.lineNumber ? `:${info.lineNumber}` : ''}`;
590
+ }
591
+ text += ' ⎘ Click to copy';
592
+
593
+ label.textContent = text;
594
+ label.style.display = 'block';
595
+ label.style.left = `${Math.max(10, Math.min(rect.left, window.innerWidth - 400))}px`;
596
+ label.style.top = `${rect.top > 50 ? rect.top - 44 : rect.bottom + 8}px`;
597
+ } else {
598
+ label.style.display = 'none';
599
+ }
600
+ }
601
+
602
+ // ============================================
603
+ // Copy Functions
604
+ // ============================================
605
+
606
+ function generateContent(element) {
607
+ const info = getComponentInfo(element);
608
+ const stack = getComponentStack(element);
609
+
610
+ let html = '';
611
+ try {
612
+ html = element.outerHTML.replace(/\s+/g, ' ').trim();
613
+ if (html.length > 500) html = html.substring(0, 500) + '...';
614
+ } catch (e) {
615
+ html = '[HTML unavailable]';
616
+ }
617
+
618
+ const pageName = getPageName();
619
+ const pagePath = getPagePath();
620
+
621
+ let content = `I've selected this component on the ${pageName}:\n`;
622
+ content += `Path: ${pagePath}\n\n`;
623
+ content += `<!-- Component: ${info?.name || 'Unknown'} -->\n`;
624
+ content += html + '\n\n';
625
+
626
+ if (stack.length > 0) {
627
+ content += 'Component Stack:\n';
628
+ stack.forEach((comp, i) => {
629
+ content += ' '.repeat(i) + `<${comp.name}>`;
630
+ if (comp.fileName) {
631
+ content += ` at ${comp.fileName}${comp.lineNumber ? `:${comp.lineNumber}` : ''}`;
632
+ }
633
+ content += '\n';
634
+ });
635
+ }
636
+
637
+ return content;
638
+ }
639
+
640
+ async function copyToClipboard(text) {
641
+ try {
642
+ await navigator.clipboard.writeText(text);
643
+ return true;
644
+ } catch (e) {
645
+ const textarea = document.createElement('textarea');
646
+ textarea.value = text;
647
+ textarea.style.cssText = 'position:fixed;left:-9999px;';
648
+ document.body.appendChild(textarea);
649
+ textarea.select();
650
+ document.execCommand('copy');
651
+ textarea.remove();
652
+ return true;
653
+ }
654
+ }
655
+
656
+ function showToast(message) {
657
+ const existing = document.querySelector('.sparkecode-toast');
658
+ if (existing) existing.remove();
659
+
660
+ const toast = document.createElement('div');
661
+ toast.className = 'sparkecode-toast';
662
+ toast.style.cssText = `
663
+ position: fixed;
664
+ bottom: 20px;
665
+ left: 50%;
666
+ transform: translateX(-50%) translateY(10px);
667
+ background: ${config.successColor};
668
+ color: white;
669
+ padding: 12px 24px;
670
+ border-radius: 10px;
671
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
672
+ font-size: 14px;
673
+ font-weight: 600;
674
+ z-index: 1000002;
675
+ opacity: 0;
676
+ transition: all 0.2s ease;
677
+ box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
678
+ `;
679
+ toast.textContent = message;
680
+ document.body.appendChild(toast);
681
+
682
+ requestAnimationFrame(() => {
683
+ toast.style.opacity = '1';
684
+ toast.style.transform = 'translateX(-50%) translateY(0)';
685
+ });
686
+
687
+ setTimeout(() => {
688
+ toast.style.opacity = '0';
689
+ toast.style.transform = 'translateX(-50%) translateY(10px)';
690
+ setTimeout(() => toast.remove(), 200);
691
+ }, 1500);
692
+ }
693
+
694
+ // ============================================
695
+ // Event Handlers
696
+ // ============================================
697
+
698
+ function isValidElement(element) {
699
+ if (!element || !isInDOM(element)) return false;
700
+ if (element.id?.startsWith('sparkecode-')) return false;
701
+ if (element.closest('#sparkecode-button')) return false;
702
+ if (element === document.body || element === document.documentElement) return false;
703
+ try {
704
+ const style = getComputedStyle(element);
705
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
706
+ } catch (e) { return false; }
707
+ return true;
708
+ }
709
+
710
+ function getElementAtPosition(x, y) {
711
+ const elements = document.elementsFromPoint(x, y);
712
+ for (const el of elements) {
713
+ if (isValidElement(el) && getComponentInfo(el)) return el;
714
+ }
715
+ for (const el of elements) {
716
+ if (isValidElement(el)) return el;
717
+ }
718
+ return null;
719
+ }
720
+
721
+ const handleMouseMove = throttle(function(e) {
722
+ if (!isActive) return;
723
+ const element = getElementAtPosition(e.clientX, e.clientY);
724
+ if (element !== hoveredElement) {
725
+ hoveredElement = element;
726
+ updateHoverHighlight(element);
727
+ }
728
+ }, 16);
729
+
730
+ async function handleClick(e) {
731
+ if (!isActive) return;
732
+ if (e.target.closest('#sparkecode-button')) return;
733
+
734
+ e.preventDefault();
735
+ e.stopPropagation();
736
+
737
+ const element = getElementAtPosition(e.clientX, e.clientY);
738
+ if (!element) return;
739
+
740
+ const content = generateContent(element);
741
+ await copyToClipboard(content);
742
+
743
+ const info = getComponentInfo(element);
744
+ showToast(`Copied <${info?.name || 'Component'}>`);
745
+
746
+ // Flash effect
747
+ if (hoverBox) {
748
+ hoverBox.style.background = `${config.successColor}40`;
749
+ hoverBox.style.borderColor = config.successColor;
750
+ setTimeout(() => {
751
+ hoverBox.style.background = `${config.primaryColor}15`;
752
+ hoverBox.style.borderColor = config.primaryColor;
753
+ }, 200);
754
+ }
755
+
756
+ sendWebhook('copy', {
757
+ component: info?.name,
758
+ fileName: info?.fileName,
759
+ lineNumber: info?.lineNumber,
760
+ page: getPagePath(),
761
+ });
762
+
763
+ if (config.sparkecoder?.enabled) {
764
+ sendToSparkecoder(content).catch(() => {});
765
+ }
766
+ }
767
+
768
+ function handleKeyDown(e) {
769
+ const modKey = isMac() ? e.metaKey : e.ctrlKey;
770
+
771
+ if (modKey && e.shiftKey && e.key.toLowerCase() === 's') {
772
+ e.preventDefault();
773
+ e.stopPropagation();
774
+ toggleActive();
775
+ return;
776
+ }
777
+
778
+ if (isActive && e.key === 'Escape') {
779
+ e.preventDefault();
780
+ deactivate();
781
+ }
782
+ }
783
+
784
+ function handleScroll() {
785
+ if (!isActive || !hoveredElement) return;
786
+ requestAnimationFrame(() => {
787
+ if (hoveredElement && isInDOM(hoveredElement)) {
788
+ updateHoverHighlight(hoveredElement);
789
+ } else {
790
+ hoveredElement = null;
791
+ updateHoverHighlight(null);
792
+ }
793
+ });
794
+ }
795
+
796
+ // ============================================
797
+ // Activation
798
+ // ============================================
799
+
800
+ function activate() {
801
+ if (isActive) return;
802
+ isActive = true;
803
+
804
+ createOverlay();
805
+
806
+ if (floatingButton) {
807
+ floatingButton.style.background = config.successColor;
808
+ floatingButton.style.boxShadow = '0 4px 20px rgba(16, 185, 129, 0.4)';
809
+ }
810
+
811
+ document.addEventListener('mousemove', handleMouseMove, true);
812
+ document.addEventListener('click', handleClick, true);
813
+ document.addEventListener('scroll', handleScroll, true);
814
+ window.addEventListener('resize', handleScroll);
815
+
816
+ document.body.style.userSelect = 'none';
817
+
818
+ sendWebhook('activate');
819
+ }
820
+
821
+ function deactivate() {
822
+ if (!isActive) return;
823
+ isActive = false;
824
+
825
+ removeOverlay();
826
+
827
+ if (floatingButton) {
828
+ floatingButton.style.background = config.primaryColor;
829
+ floatingButton.style.boxShadow = '0 4px 20px rgba(139, 92, 246, 0.4)';
830
+ }
831
+
832
+ document.removeEventListener('mousemove', handleMouseMove, true);
833
+ document.removeEventListener('click', handleClick, true);
834
+ document.removeEventListener('scroll', handleScroll, true);
835
+ window.removeEventListener('resize', handleScroll);
836
+
837
+ document.body.style.userSelect = '';
838
+ hoveredElement = null;
839
+
840
+ sendWebhook('deactivate');
841
+ }
842
+
843
+ function toggleActive() {
844
+ if (isActive) deactivate();
845
+ else activate();
846
+ }
847
+
848
+ // ============================================
849
+ // Public API
850
+ // ============================================
851
+
852
+ const SparkeCodeSelect = {
853
+ init(options = {}) {
854
+ if (isInitialized) {
855
+ if (Object.keys(options).length > 0) this.configure(options);
856
+ return this;
857
+ }
858
+
859
+ isInitialized = true;
860
+ config = { ...DEFAULT_CONFIG };
861
+ applyConfig(options);
862
+
863
+ document.addEventListener('keydown', handleKeyDown, true);
864
+ createFloatingButton();
865
+
866
+ const modKey = isMac() ? 'Cmd' : 'Ctrl';
867
+ console.log(`[SparkeCode] Ready! Click button or press ${modKey}+Shift+S`);
868
+
869
+ sendWebhook('init', { version: this.version });
870
+
871
+ if (config.sparkecoder?.enabled) {
872
+ ensureSparkecoderConnection().then(() => {
873
+ startHeartbeat();
874
+ }).catch(() => {});
875
+ }
876
+ return this;
877
+ },
878
+
879
+ activate,
880
+ deactivate,
881
+ toggle: toggleActive,
882
+ isActive: () => isActive,
883
+
884
+ configure(options) {
885
+ applyConfig(options);
886
+ return this;
887
+ },
888
+
889
+ getConfig: () => ({ ...config }),
890
+ sendEvent: (event, data) => sendWebhook(event, data),
891
+ getComponentInfo,
892
+ getComponentStack,
893
+
894
+ version: '1.0.0',
895
+ };
896
+
897
+ if (typeof window !== 'undefined') {
898
+ window.SparkeCodeSelect = SparkeCodeSelect;
899
+ window.ReactGrab = SparkeCodeSelect; // Alias
900
+ }
901
+
902
+ })();