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 +94 -0
- package/next.d.ts +24 -0
- package/next.js +47 -0
- package/package.json +44 -0
- package/sparkecode-select.js +902 -0
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
|
+
})();
|