@antigenic-oss/paint 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/NOTICE +2 -2
- package/README.md +42 -15
- package/bin/paint.js +32 -0
- package/next.config.mjs +8 -0
- package/package.json +10 -8
- package/public/dev-editor-inspector.js +14 -0
- package/public/sw-proxy/sw.js +886 -0
- package/src/app/api/proxy/[[...path]]/route.ts +12 -1
- package/src/app/api/sw-fetch/[[...path]]/route.ts +149 -0
- package/src/app/docs/DocsClient.tsx +1 -1
- package/src/app/docs/page.tsx +134 -407
- package/src/app/layout.tsx +48 -2
- package/src/app/page.tsx +2 -0
- package/src/components/ConnectModal.tsx +98 -181
- package/src/components/PreviewFrame.tsx +91 -0
- package/src/components/TargetSelector.tsx +49 -15
- package/src/components/left-panel/LayerNode.tsx +5 -2
- package/src/components/left-panel/LayersPanel.tsx +10 -1
- package/src/components/right-panel/changes/ChangesPanel.tsx +7 -1
- package/src/hooks/useChangeTracker.ts +34 -26
- package/src/hooks/usePostMessage.ts +27 -1
- package/src/lib/serviceWorkerRegistration.ts +163 -0
- package/src/store/treeSlice.ts +29 -17
- package/src/store/uiSlice.ts +6 -0
- package/src/types/messages.ts +6 -0
|
@@ -27,6 +27,7 @@ export function TargetSelector() {
|
|
|
27
27
|
const [error, setError] = useState<string | null>(null)
|
|
28
28
|
|
|
29
29
|
const isConnected = connectionStatus === 'connected'
|
|
30
|
+
const isAuthenticating = connectionStatus === 'authenticating'
|
|
30
31
|
|
|
31
32
|
const handleConnect = () => {
|
|
32
33
|
setError(null)
|
|
@@ -41,6 +42,19 @@ export function TargetSelector() {
|
|
|
41
42
|
addRecentUrl(normalized)
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
const handleLoginFirst = () => {
|
|
46
|
+
setError(null)
|
|
47
|
+
const raw = urlMode ? customUrl.trim() : `http://localhost:${selectedPort}`
|
|
48
|
+
if (urlMode && !raw) {
|
|
49
|
+
setError('Enter a URL')
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const normalized = normalizeTargetUrl(raw)
|
|
53
|
+
setTargetUrl(normalized)
|
|
54
|
+
setConnectionStatus('authenticating')
|
|
55
|
+
addRecentUrl(normalized)
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
const handleDisconnect = () => {
|
|
45
59
|
// Clear persisted changes for this URL so reconnect loads fresh content
|
|
46
60
|
if (targetUrl) {
|
|
@@ -70,11 +84,13 @@ export function TargetSelector() {
|
|
|
70
84
|
const statusColor =
|
|
71
85
|
connectionStatus === 'connected'
|
|
72
86
|
? 'var(--success)'
|
|
73
|
-
: connectionStatus === '
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
: connectionStatus === 'authenticating'
|
|
88
|
+
? 'var(--accent)'
|
|
89
|
+
: connectionStatus === 'connecting' ||
|
|
90
|
+
connectionStatus === 'confirming' ||
|
|
91
|
+
connectionStatus === 'scanning'
|
|
92
|
+
? 'var(--warning)'
|
|
93
|
+
: 'var(--error)'
|
|
78
94
|
|
|
79
95
|
return (
|
|
80
96
|
<div className="flex items-center gap-2 relative">
|
|
@@ -88,7 +104,7 @@ export function TargetSelector() {
|
|
|
88
104
|
{/* Toggle between dropdown and URL input */}
|
|
89
105
|
<button
|
|
90
106
|
onClick={() => {
|
|
91
|
-
if (!isConnected) {
|
|
107
|
+
if (!isConnected && !isAuthenticating) {
|
|
92
108
|
setUrlMode(!urlMode)
|
|
93
109
|
setError(null)
|
|
94
110
|
}
|
|
@@ -96,8 +112,8 @@ export function TargetSelector() {
|
|
|
96
112
|
className="p-1 rounded hover:bg-[var(--bg-hover)] transition-colors flex-shrink-0"
|
|
97
113
|
style={{
|
|
98
114
|
color: urlMode ? 'var(--accent)' : 'var(--text-muted)',
|
|
99
|
-
cursor: isConnected ? 'default' : 'pointer',
|
|
100
|
-
opacity: isConnected ? 0.5 : 1,
|
|
115
|
+
cursor: isConnected || isAuthenticating ? 'default' : 'pointer',
|
|
116
|
+
opacity: isConnected || isAuthenticating ? 0.5 : 1,
|
|
101
117
|
}}
|
|
102
118
|
title={urlMode ? 'Switch to port selector' : 'Switch to URL input'}
|
|
103
119
|
>
|
|
@@ -133,7 +149,7 @@ export function TargetSelector() {
|
|
|
133
149
|
)}
|
|
134
150
|
</button>
|
|
135
151
|
|
|
136
|
-
{isConnected ? (
|
|
152
|
+
{isConnected || isAuthenticating ? (
|
|
137
153
|
<div
|
|
138
154
|
className="w-56 text-sm rounded px-2 py-1 truncate"
|
|
139
155
|
style={{
|
|
@@ -172,20 +188,38 @@ export function TargetSelector() {
|
|
|
172
188
|
</select>
|
|
173
189
|
)}
|
|
174
190
|
|
|
191
|
+
{/* Login first button — only shown when disconnected */}
|
|
192
|
+
{!isConnected && !isAuthenticating && connectionStatus !== 'connecting' && (
|
|
193
|
+
<button
|
|
194
|
+
onClick={handleLoginFirst}
|
|
195
|
+
className="px-2 py-1 text-xs rounded transition-colors font-medium"
|
|
196
|
+
style={{
|
|
197
|
+
background: 'var(--bg-hover)',
|
|
198
|
+
color: 'var(--text-secondary)',
|
|
199
|
+
border: '1px solid var(--border)',
|
|
200
|
+
}}
|
|
201
|
+
title="Log in to your project first, then connect the editor"
|
|
202
|
+
>
|
|
203
|
+
Login first
|
|
204
|
+
</button>
|
|
205
|
+
)}
|
|
206
|
+
|
|
175
207
|
{/* Connect / Disconnect button */}
|
|
176
208
|
<button
|
|
177
|
-
onClick={isConnected ? handleDisconnect : handleConnect}
|
|
209
|
+
onClick={isConnected || isAuthenticating ? handleDisconnect : handleConnect}
|
|
178
210
|
className="px-3 py-1 text-xs rounded transition-colors font-medium"
|
|
179
211
|
style={{
|
|
180
|
-
background: isConnected ? 'var(--bg-hover)' : 'var(--accent)',
|
|
181
|
-
color: isConnected ? 'var(--text-secondary)' : '#fff',
|
|
212
|
+
background: isConnected || isAuthenticating ? 'var(--bg-hover)' : 'var(--accent)',
|
|
213
|
+
color: isConnected || isAuthenticating ? 'var(--text-secondary)' : '#fff',
|
|
182
214
|
}}
|
|
183
215
|
>
|
|
184
216
|
{isConnected
|
|
185
217
|
? 'Disconnect'
|
|
186
|
-
:
|
|
187
|
-
? '
|
|
188
|
-
: '
|
|
218
|
+
: isAuthenticating
|
|
219
|
+
? 'Cancel'
|
|
220
|
+
: connectionStatus === 'connecting'
|
|
221
|
+
? 'Connecting...'
|
|
222
|
+
: 'Connect'}
|
|
189
223
|
</button>
|
|
190
224
|
|
|
191
225
|
{/* Bridge status indicator (shown when running on Vercel) */}
|
|
@@ -455,10 +455,13 @@ export function LayerNode({
|
|
|
455
455
|
const IconComponent = ICON_MAP[category]
|
|
456
456
|
const label = getDisplayLabel(node)
|
|
457
457
|
|
|
458
|
-
// Scroll selected layer into view
|
|
458
|
+
// Scroll selected layer into view after tree expansion renders it.
|
|
459
|
+
// Use requestAnimationFrame to wait for layout after mount/expansion.
|
|
459
460
|
useEffect(() => {
|
|
460
461
|
if (isSelected && rowRef.current) {
|
|
461
|
-
|
|
462
|
+
requestAnimationFrame(() => {
|
|
463
|
+
rowRef.current?.scrollIntoView({ block: 'center', behavior: 'instant' })
|
|
464
|
+
})
|
|
462
465
|
}
|
|
463
466
|
}, [isSelected])
|
|
464
467
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useMemo } from 'react'
|
|
3
|
+
import { useEffect, useMemo } from 'react'
|
|
4
4
|
import { useEditorStore } from '@/store'
|
|
5
5
|
import { LayerNode } from './LayerNode'
|
|
6
6
|
import { LayerSearch } from './LayerSearch'
|
|
@@ -9,6 +9,15 @@ export function LayersPanel() {
|
|
|
9
9
|
const rootNode = useEditorStore((s) => s.rootNode)
|
|
10
10
|
const searchQuery = useEditorStore((s) => s.searchQuery)
|
|
11
11
|
const styleChanges = useEditorStore((s) => s.styleChanges)
|
|
12
|
+
const selectorPath = useEditorStore((s) => s.selectorPath)
|
|
13
|
+
|
|
14
|
+
// Auto-expand tree to the selected element whenever selection changes.
|
|
15
|
+
// This also re-expands after DOM_UPDATED replaces the tree.
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (selectorPath) {
|
|
18
|
+
useEditorStore.getState().expandToNode(selectorPath)
|
|
19
|
+
}
|
|
20
|
+
}, [selectorPath, rootNode])
|
|
12
21
|
|
|
13
22
|
const { changedSelectors, deletedSelectors } = useMemo(() => {
|
|
14
23
|
const changed = new Set<string>()
|
|
@@ -1198,7 +1198,12 @@ export function ChangesPanel() {
|
|
|
1198
1198
|
])
|
|
1199
1199
|
|
|
1200
1200
|
const handleAiScan = useCallback(() => {
|
|
1201
|
-
if (!targetUrl ||
|
|
1201
|
+
if (!targetUrl || breakpointChanges.length === 0) return
|
|
1202
|
+
if (!projectRoot) {
|
|
1203
|
+
showToast('error', 'Set a project folder first — click the folder icon in the Claude tab')
|
|
1204
|
+
setActiveRightTab('claude')
|
|
1205
|
+
return
|
|
1206
|
+
}
|
|
1202
1207
|
|
|
1203
1208
|
setAiScanStatus('scanning')
|
|
1204
1209
|
setAiScanError(null)
|
|
@@ -1251,6 +1256,7 @@ export function ChangesPanel() {
|
|
|
1251
1256
|
setAiScanResult,
|
|
1252
1257
|
showToast,
|
|
1253
1258
|
setActiveLeftTab,
|
|
1259
|
+
setActiveRightTab,
|
|
1254
1260
|
])
|
|
1255
1261
|
|
|
1256
1262
|
const handleSendToClaudeCode = useCallback(
|
|
@@ -261,6 +261,32 @@ export function performRevertAll() {
|
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
// Module-level singletons for auto-persist and load — prevents duplicate
|
|
265
|
+
// subscriptions and stale-data overwrites when multiple components call
|
|
266
|
+
// useChangeTracker() and some of them remount (e.g. LayerNode after tree updates).
|
|
267
|
+
let persistSubscribed = false
|
|
268
|
+
let persistTimeout: ReturnType<typeof setTimeout> | null = null
|
|
269
|
+
let prevChangesRef: unknown = null
|
|
270
|
+
let lastLoadedUrl: string | null = null
|
|
271
|
+
|
|
272
|
+
function ensurePersistSubscription() {
|
|
273
|
+
if (persistSubscribed) return
|
|
274
|
+
persistSubscribed = true
|
|
275
|
+
useEditorStore.subscribe((state) => {
|
|
276
|
+
const ref = state.styleChanges
|
|
277
|
+
if (ref === prevChangesRef) return
|
|
278
|
+
prevChangesRef = ref
|
|
279
|
+
|
|
280
|
+
const url = state.targetUrl
|
|
281
|
+
if (!url) return
|
|
282
|
+
|
|
283
|
+
if (persistTimeout) clearTimeout(persistTimeout)
|
|
284
|
+
persistTimeout = setTimeout(() => {
|
|
285
|
+
useEditorStore.getState().persistChanges(url)
|
|
286
|
+
}, 300)
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
264
290
|
/**
|
|
265
291
|
* Hook that tracks style changes, sends PREVIEW_CHANGE to inspector,
|
|
266
292
|
* and auto-persists changes to localStorage.
|
|
@@ -273,36 +299,18 @@ export function useChangeTracker() {
|
|
|
273
299
|
const pushUndo = useEditorStore((s) => s.pushUndo)
|
|
274
300
|
const { sendToInspector } = usePostMessage()
|
|
275
301
|
|
|
276
|
-
//
|
|
277
|
-
const persistTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
278
|
-
const prevChangesRef = useRef<unknown>(null)
|
|
279
|
-
|
|
302
|
+
// Set up singleton auto-persist subscription (once globally, not per component)
|
|
280
303
|
useEffect(() => {
|
|
281
|
-
|
|
282
|
-
// Trigger on any styleChanges or elementSnapshots reference change
|
|
283
|
-
const ref = state.styleChanges
|
|
284
|
-
if (ref === prevChangesRef.current) return
|
|
285
|
-
prevChangesRef.current = ref
|
|
286
|
-
|
|
287
|
-
const url = state.targetUrl
|
|
288
|
-
if (!url) return
|
|
289
|
-
|
|
290
|
-
// Debounce persistence
|
|
291
|
-
if (persistTimeoutRef.current) clearTimeout(persistTimeoutRef.current)
|
|
292
|
-
persistTimeoutRef.current = setTimeout(() => {
|
|
293
|
-
useEditorStore.getState().persistChanges(url)
|
|
294
|
-
}, 300)
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
return () => {
|
|
298
|
-
unsubscribe()
|
|
299
|
-
if (persistTimeoutRef.current) clearTimeout(persistTimeoutRef.current)
|
|
300
|
-
}
|
|
304
|
+
ensurePersistSubscription()
|
|
301
305
|
}, [])
|
|
302
306
|
|
|
303
|
-
// Load persisted changes when target URL changes
|
|
307
|
+
// Load persisted changes only when target URL actually changes,
|
|
308
|
+
// not on every component mount. This prevents remounting components
|
|
309
|
+
// (e.g. LayerNode after tree updates) from overwriting in-memory
|
|
310
|
+
// changes with stale localStorage data.
|
|
304
311
|
useEffect(() => {
|
|
305
|
-
if (targetUrl) {
|
|
312
|
+
if (targetUrl && targetUrl !== lastLoadedUrl) {
|
|
313
|
+
lastLoadedUrl = targetUrl
|
|
306
314
|
useEditorStore.getState().loadPersistedChanges(targetUrl)
|
|
307
315
|
}
|
|
308
316
|
}, [targetUrl])
|
|
@@ -280,7 +280,13 @@ function handleMessage(event: MessageEvent) {
|
|
|
280
280
|
break
|
|
281
281
|
|
|
282
282
|
case 'PAGE_LINKS':
|
|
283
|
-
|
|
283
|
+
// Strip /sw-proxy/ or /api/proxy prefix from page link paths
|
|
284
|
+
store.setPageLinks(
|
|
285
|
+
msg.payload.links.map((link: { href: string; text: string }) => ({
|
|
286
|
+
...link,
|
|
287
|
+
href: link.href.replace(/^\/(sw-proxy|api\/proxy)/, '') || '/',
|
|
288
|
+
})),
|
|
289
|
+
)
|
|
284
290
|
break
|
|
285
291
|
|
|
286
292
|
case 'COMPONENTS_DETECTED':
|
|
@@ -450,6 +456,12 @@ function handleMessage(event: MessageEvent) {
|
|
|
450
456
|
newParentSelectorPath: mvNewParent,
|
|
451
457
|
oldIndex: mvOldIndex,
|
|
452
458
|
newIndex: mvNewIndex,
|
|
459
|
+
tagName: mvTag,
|
|
460
|
+
className: mvClass,
|
|
461
|
+
elementId: mvId,
|
|
462
|
+
innerText: mvText,
|
|
463
|
+
attributes: mvAttrs,
|
|
464
|
+
computedStyles: mvStyles,
|
|
453
465
|
} = msg.payload
|
|
454
466
|
const mvProperty = '__element_moved__'
|
|
455
467
|
|
|
@@ -464,6 +476,20 @@ function handleMessage(event: MessageEvent) {
|
|
|
464
476
|
changeScope: store.changeScope,
|
|
465
477
|
})
|
|
466
478
|
|
|
479
|
+
// Save element snapshot so move appears in Changes panel
|
|
480
|
+
store.saveElementSnapshot({
|
|
481
|
+
selectorPath: mvNewSelector,
|
|
482
|
+
tagName: mvTag || 'unknown',
|
|
483
|
+
className: mvClass ?? null,
|
|
484
|
+
elementId: mvId ?? null,
|
|
485
|
+
attributes: mvAttrs || {},
|
|
486
|
+
innerText: mvText,
|
|
487
|
+
computedStyles: mvStyles ? { ...mvStyles } : {},
|
|
488
|
+
pagePath: store.currentPagePath,
|
|
489
|
+
changeScope: store.changeScope,
|
|
490
|
+
sourceInfo: null,
|
|
491
|
+
})
|
|
492
|
+
|
|
467
493
|
// Track the move
|
|
468
494
|
store.addStyleChange({
|
|
469
495
|
id: generateId(),
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEditorStore } from '@/store'
|
|
2
|
+
|
|
3
|
+
let swReady = false
|
|
4
|
+
|
|
5
|
+
// Must match SW_VERSION in /public/sw-proxy/sw.js
|
|
6
|
+
const EXPECTED_SW_VERSION = 10
|
|
7
|
+
|
|
8
|
+
const log = (...args: unknown[]) =>
|
|
9
|
+
console.debug('[sw-proxy]', ...args)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check the active SW's version via message. Returns the version number,
|
|
13
|
+
* or 0 if the SW doesn't respond (old version without VERSION_CHECK).
|
|
14
|
+
*/
|
|
15
|
+
function checkSwVersion(sw: ServiceWorker): Promise<number> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const timeout = setTimeout(() => resolve(0), 1000)
|
|
18
|
+
const channel = new MessageChannel()
|
|
19
|
+
channel.port1.onmessage = (event) => {
|
|
20
|
+
clearTimeout(timeout)
|
|
21
|
+
resolve(event.data?.version ?? 0)
|
|
22
|
+
}
|
|
23
|
+
sw.postMessage({ type: 'VERSION_CHECK' }, [channel.port2])
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register the SW proxy at /sw-proxy/sw.js with scope /sw-proxy/.
|
|
29
|
+
* Sets swProxyReady in the Zustand store when activation completes.
|
|
30
|
+
* Returns true if the SW is active, false otherwise.
|
|
31
|
+
*
|
|
32
|
+
* Handles upgrades from old SW versions by checking the version number.
|
|
33
|
+
* If the active SW is outdated, it's unregistered and a fresh install
|
|
34
|
+
* is performed — this ensures existing users get the latest SW code.
|
|
35
|
+
*/
|
|
36
|
+
export async function registerSwProxy(): Promise<boolean> {
|
|
37
|
+
if (!('serviceWorker' in navigator)) {
|
|
38
|
+
log('Service Workers not supported — using fallback proxy')
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
log('Registering...')
|
|
44
|
+
let registration = await navigator.serviceWorker.register(
|
|
45
|
+
'/sw-proxy/sw.js',
|
|
46
|
+
{ scope: '/sw-proxy/', updateViaCache: 'none' },
|
|
47
|
+
)
|
|
48
|
+
log('Registration successful, scope:', registration.scope)
|
|
49
|
+
|
|
50
|
+
// If there's an active SW, check its version
|
|
51
|
+
if (registration.active) {
|
|
52
|
+
const version = await checkSwVersion(registration.active)
|
|
53
|
+
log('Active SW version:', version, '(expected:', EXPECTED_SW_VERSION + ')')
|
|
54
|
+
|
|
55
|
+
if (version < EXPECTED_SW_VERSION) {
|
|
56
|
+
// Old SW — force unregister and re-register clean
|
|
57
|
+
log('Stale SW detected — forcing clean re-registration')
|
|
58
|
+
await registration.unregister()
|
|
59
|
+
registration = await navigator.serviceWorker.register(
|
|
60
|
+
'/sw-proxy/sw.js',
|
|
61
|
+
{ scope: '/sw-proxy/', updateViaCache: 'none' },
|
|
62
|
+
)
|
|
63
|
+
// Fall through to wait for fresh install below
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Helper to mark SW as ready
|
|
68
|
+
const markReady = () => {
|
|
69
|
+
if (!swReady) {
|
|
70
|
+
swReady = true
|
|
71
|
+
useEditorStore.getState().setSwProxyReady(true)
|
|
72
|
+
log('Ready')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// If there's a waiting SW, tell it to activate immediately
|
|
77
|
+
if (registration.waiting) {
|
|
78
|
+
log('Found waiting SW — sending SKIP_WAITING')
|
|
79
|
+
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// If an update is installing, wait for it before marking ready
|
|
83
|
+
const installing = registration.installing
|
|
84
|
+
if (installing) {
|
|
85
|
+
log('SW installing, waiting for activation...')
|
|
86
|
+
return new Promise<boolean>((resolve) => {
|
|
87
|
+
installing.addEventListener('statechange', () => {
|
|
88
|
+
log('Installing SW state:', installing.state)
|
|
89
|
+
if (installing.state === 'installed' && registration.waiting) {
|
|
90
|
+
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
|
91
|
+
}
|
|
92
|
+
if (installing.state === 'activated') {
|
|
93
|
+
markReady()
|
|
94
|
+
resolve(true)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Listen for future updates during this page session
|
|
101
|
+
registration.addEventListener('updatefound', () => {
|
|
102
|
+
const newSw = registration.installing
|
|
103
|
+
if (!newSw) return
|
|
104
|
+
log('Update found, tracking new SW...')
|
|
105
|
+
newSw.addEventListener('statechange', () => {
|
|
106
|
+
if (newSw.state === 'installed' && registration.waiting) {
|
|
107
|
+
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
|
108
|
+
}
|
|
109
|
+
if (newSw.state === 'activated') {
|
|
110
|
+
markReady()
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// No update in progress — if already active, we're ready
|
|
116
|
+
if (registration.active) {
|
|
117
|
+
markReady()
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// First-time install — wait for activation
|
|
122
|
+
const sw = registration.waiting
|
|
123
|
+
if (!sw) {
|
|
124
|
+
log('No SW instance found')
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
log('Waiting for activation, state:', sw.state)
|
|
129
|
+
return new Promise<boolean>((resolve) => {
|
|
130
|
+
sw.addEventListener('statechange', () => {
|
|
131
|
+
log('State changed to:', sw.state)
|
|
132
|
+
if (sw.state === 'activated') {
|
|
133
|
+
markReady()
|
|
134
|
+
resolve(true)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn('[sw-proxy] Registration failed:', err)
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Synchronous readiness check. */
|
|
145
|
+
export function isSwProxyReady(): boolean {
|
|
146
|
+
return swReady
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Unregister the SW proxy and reset state. */
|
|
150
|
+
export async function unregisterSwProxy(): Promise<void> {
|
|
151
|
+
swReady = false
|
|
152
|
+
useEditorStore.getState().setSwProxyReady(false)
|
|
153
|
+
log('Unregistered')
|
|
154
|
+
|
|
155
|
+
if (!('serviceWorker' in navigator)) return
|
|
156
|
+
|
|
157
|
+
const registrations = await navigator.serviceWorker.getRegistrations()
|
|
158
|
+
for (const reg of registrations) {
|
|
159
|
+
if (reg.scope.includes('/sw-proxy/')) {
|
|
160
|
+
await reg.unregister()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/store/treeSlice.ts
CHANGED
|
@@ -13,6 +13,7 @@ function _collectAllIds(node: TreeNode, ids: Set<string>) {
|
|
|
13
13
|
export interface TreeSlice {
|
|
14
14
|
rootNode: TreeNode | null
|
|
15
15
|
expandedNodeIds: Set<string>
|
|
16
|
+
collapsedNodeIds: Set<string>
|
|
16
17
|
searchQuery: string
|
|
17
18
|
highlightedNodeId: string | null
|
|
18
19
|
|
|
@@ -29,41 +30,52 @@ export const createTreeSlice: StateCreator<TreeSlice, [], [], TreeSlice> = (
|
|
|
29
30
|
) => ({
|
|
30
31
|
rootNode: null,
|
|
31
32
|
expandedNodeIds: new Set<string>(),
|
|
33
|
+
collapsedNodeIds: new Set<string>(),
|
|
32
34
|
searchQuery: '',
|
|
33
35
|
highlightedNodeId: null,
|
|
34
36
|
|
|
35
37
|
setRootNode: (node) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
if (!node) {
|
|
39
|
+
set({ rootNode: null, expandedNodeIds: new Set<string>(), collapsedNodeIds: new Set<string>() })
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
// Expand all nodes by default, but respect user-collapsed nodes.
|
|
43
|
+
const all = new Set<string>()
|
|
44
|
+
_collectAllIds(node, all)
|
|
45
|
+
const { collapsedNodeIds } = get()
|
|
46
|
+
for (const id of collapsedNodeIds) {
|
|
47
|
+
all.delete(id)
|
|
43
48
|
}
|
|
49
|
+
set({ rootNode: node, expandedNodeIds: all })
|
|
44
50
|
},
|
|
45
51
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
|
46
52
|
setHighlightedNodeId: (id) => set({ highlightedNodeId: id }),
|
|
47
53
|
|
|
48
54
|
toggleNodeExpanded: (nodeId) => {
|
|
49
|
-
const { expandedNodeIds } = get()
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
const { expandedNodeIds, collapsedNodeIds } = get()
|
|
56
|
+
const nextExpanded = new Set(expandedNodeIds)
|
|
57
|
+
const nextCollapsed = new Set(collapsedNodeIds)
|
|
58
|
+
if (nextExpanded.has(nodeId)) {
|
|
59
|
+
nextExpanded.delete(nodeId)
|
|
60
|
+
nextCollapsed.add(nodeId)
|
|
53
61
|
} else {
|
|
54
|
-
|
|
62
|
+
nextExpanded.add(nodeId)
|
|
63
|
+
nextCollapsed.delete(nodeId)
|
|
55
64
|
}
|
|
56
|
-
set({ expandedNodeIds:
|
|
65
|
+
set({ expandedNodeIds: nextExpanded, collapsedNodeIds: nextCollapsed })
|
|
57
66
|
},
|
|
58
67
|
|
|
59
68
|
expandToNode: (nodeId) => {
|
|
60
|
-
// Merge ancestors into existing expanded state
|
|
61
|
-
const { expandedNodeIds: prev } = get()
|
|
69
|
+
// Merge ancestors into existing expanded state and clear them from collapsed
|
|
70
|
+
const { expandedNodeIds: prev, collapsedNodeIds: prevCollapsed } = get()
|
|
62
71
|
const next = new Set(prev)
|
|
72
|
+
const nextCollapsed = new Set(prevCollapsed)
|
|
63
73
|
const parts = nodeId.split(' > ')
|
|
64
74
|
for (let i = 1; i <= parts.length; i++) {
|
|
65
|
-
|
|
75
|
+
const ancestor = parts.slice(0, i).join(' > ')
|
|
76
|
+
next.add(ancestor)
|
|
77
|
+
nextCollapsed.delete(ancestor)
|
|
66
78
|
}
|
|
67
|
-
set({ expandedNodeIds: next })
|
|
79
|
+
set({ expandedNodeIds: next, collapsedNodeIds: nextCollapsed })
|
|
68
80
|
},
|
|
69
81
|
})
|
package/src/store/uiSlice.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type ConnectionStatus =
|
|
|
18
18
|
| 'disconnected'
|
|
19
19
|
| 'confirming'
|
|
20
20
|
| 'scanning'
|
|
21
|
+
| 'authenticating'
|
|
21
22
|
| 'connecting'
|
|
22
23
|
| 'connected'
|
|
23
24
|
export type BridgeStatus =
|
|
@@ -48,6 +49,7 @@ export interface UISlice {
|
|
|
48
49
|
toasts: Toast[]
|
|
49
50
|
bridgeUrl: string | null
|
|
50
51
|
bridgeStatus: BridgeStatus
|
|
52
|
+
swProxyReady: boolean
|
|
51
53
|
|
|
52
54
|
setTargetUrl: (url: string | null) => void
|
|
53
55
|
setConnectionStatus: (status: ConnectionStatus) => void
|
|
@@ -77,6 +79,7 @@ export interface UISlice {
|
|
|
77
79
|
dismissToast: (id: string) => void
|
|
78
80
|
setBridgeUrl: (url: string | null) => void
|
|
79
81
|
setBridgeStatus: (status: BridgeStatus) => void
|
|
82
|
+
setSwProxyReady: (ready: boolean) => void
|
|
80
83
|
loadPersistedUI: () => void
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -105,6 +108,7 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (
|
|
|
105
108
|
toasts: [],
|
|
106
109
|
bridgeUrl: null,
|
|
107
110
|
bridgeStatus: 'disconnected',
|
|
111
|
+
swProxyReady: false,
|
|
108
112
|
|
|
109
113
|
setTargetUrl: (url) => {
|
|
110
114
|
set({ targetUrl: url })
|
|
@@ -300,6 +304,8 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (
|
|
|
300
304
|
|
|
301
305
|
setBridgeStatus: (status) => set({ bridgeStatus: status }),
|
|
302
306
|
|
|
307
|
+
setSwProxyReady: (ready) => set({ swProxyReady: ready }),
|
|
308
|
+
|
|
303
309
|
loadPersistedUI: () => {
|
|
304
310
|
try {
|
|
305
311
|
const urls = localStorage.getItem(LOCAL_STORAGE_KEYS.RECENT_URLS)
|
package/src/types/messages.ts
CHANGED
|
@@ -337,6 +337,12 @@ export interface ElementMovedMessage {
|
|
|
337
337
|
newParentSelectorPath: string
|
|
338
338
|
oldIndex: number
|
|
339
339
|
newIndex: number
|
|
340
|
+
tagName: string
|
|
341
|
+
className: string | null
|
|
342
|
+
elementId: string | null
|
|
343
|
+
innerText: string | null
|
|
344
|
+
attributes: Record<string, string>
|
|
345
|
+
computedStyles: Record<string, string>
|
|
340
346
|
}
|
|
341
347
|
}
|
|
342
348
|
|