@antigenic-oss/paint 0.2.9 → 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/bin/paint.js +32 -0
- package/next.config.mjs +8 -0
- package/package.json +1 -1
- package/public/dev-editor-inspector.js +14 -0
- package/public/sw-proxy/sw.js +886 -0
- 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 +112 -405
- 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/hooks/usePostMessage.ts +7 -1
- package/src/lib/serviceWorkerRegistration.ts +163 -0
- package/src/store/treeSlice.ts +29 -17
- package/src/store/uiSlice.ts +6 -0
|
@@ -49,43 +49,14 @@ export function ConnectModal() {
|
|
|
49
49
|
const [isBrowsing, setIsBrowsing] = useState(false)
|
|
50
50
|
const [error, setError] = useState<string | null>(null)
|
|
51
51
|
const [howToOpen, setHowToOpen] = useState(false)
|
|
52
|
-
const [showScriptFallback, setShowScriptFallback] = useState(false)
|
|
53
|
-
const [scriptCopied, setScriptCopied] = useState(false)
|
|
54
52
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null)
|
|
55
53
|
const [scanDone, setScanDone] = useState(false)
|
|
56
|
-
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
57
54
|
const autoAdvanceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
58
55
|
|
|
59
56
|
const isConnecting = connectionStatus === 'connecting'
|
|
60
57
|
const isConfirming = connectionStatus === 'confirming'
|
|
61
58
|
const isScanning = connectionStatus === 'scanning'
|
|
62
59
|
|
|
63
|
-
// Show script tag fallback after 5s of connecting (immediately when deployed)
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (isConnecting && targetUrl) {
|
|
66
|
-
if (!isLocal) {
|
|
67
|
-
// On Vercel, show immediately — proxy won't inject the script
|
|
68
|
-
setShowScriptFallback(true)
|
|
69
|
-
} else {
|
|
70
|
-
fallbackTimerRef.current = setTimeout(() => {
|
|
71
|
-
setShowScriptFallback(true)
|
|
72
|
-
}, 5000)
|
|
73
|
-
}
|
|
74
|
-
} else {
|
|
75
|
-
setShowScriptFallback(false)
|
|
76
|
-
if (fallbackTimerRef.current) {
|
|
77
|
-
clearTimeout(fallbackTimerRef.current)
|
|
78
|
-
fallbackTimerRef.current = null
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return () => {
|
|
82
|
-
if (fallbackTimerRef.current) {
|
|
83
|
-
clearTimeout(fallbackTimerRef.current)
|
|
84
|
-
fallbackTimerRef.current = null
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}, [isConnecting, targetUrl, isLocal])
|
|
88
|
-
|
|
89
60
|
// Cleanup auto-advance timer
|
|
90
61
|
useEffect(() => {
|
|
91
62
|
return () => {
|
|
@@ -104,31 +75,14 @@ export function ConnectModal() {
|
|
|
104
75
|
if (isConfirming || isScanning) {
|
|
105
76
|
cancelPendingConnection()
|
|
106
77
|
}
|
|
107
|
-
setShowScriptFallback(false)
|
|
108
|
-
setScriptCopied(false)
|
|
109
78
|
setScanResult(null)
|
|
110
79
|
setScanDone(false)
|
|
111
|
-
if (fallbackTimerRef.current) {
|
|
112
|
-
clearTimeout(fallbackTimerRef.current)
|
|
113
|
-
fallbackTimerRef.current = null
|
|
114
|
-
}
|
|
115
80
|
if (autoAdvanceRef.current) {
|
|
116
81
|
clearTimeout(autoAdvanceRef.current)
|
|
117
82
|
autoAdvanceRef.current = null
|
|
118
83
|
}
|
|
119
84
|
}
|
|
120
85
|
|
|
121
|
-
const handleCopyScript = async () => {
|
|
122
|
-
const scriptTag = `<script src="${window.location.origin}/dev-editor-inspector.js"></script>`
|
|
123
|
-
try {
|
|
124
|
-
await navigator.clipboard.writeText(scriptTag)
|
|
125
|
-
setScriptCopied(true)
|
|
126
|
-
setTimeout(() => setScriptCopied(false), 2000)
|
|
127
|
-
} catch {
|
|
128
|
-
/* fallback: user can manually copy */
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
86
|
// Pre-fill folder path from portRoots when selected URL changes
|
|
133
87
|
const currentUrl = urlMode
|
|
134
88
|
? customUrl.trim()
|
|
@@ -191,6 +145,19 @@ export function ConnectModal() {
|
|
|
191
145
|
}
|
|
192
146
|
}
|
|
193
147
|
|
|
148
|
+
const handleLoginFirst = () => {
|
|
149
|
+
setError(null)
|
|
150
|
+
const raw = urlMode ? customUrl.trim() : `http://localhost:${selectedPort}`
|
|
151
|
+
if (urlMode && !raw) {
|
|
152
|
+
setError('Enter a URL')
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
const normalized = normalizeTargetUrl(raw)
|
|
156
|
+
setTargetUrl(normalized)
|
|
157
|
+
setConnectionStatus('authenticating')
|
|
158
|
+
addRecentUrl(normalized)
|
|
159
|
+
}
|
|
160
|
+
|
|
194
161
|
const handleConfirm = async () => {
|
|
195
162
|
if (!pendingTargetUrl || !pendingFolderPath) return
|
|
196
163
|
setConnectionStatus('scanning')
|
|
@@ -237,16 +204,17 @@ export function ConnectModal() {
|
|
|
237
204
|
}
|
|
238
205
|
}
|
|
239
206
|
|
|
207
|
+
// Auto-dismiss modal once connecting/authenticating starts — top bar shows status
|
|
208
|
+
if (isConnecting || connectionStatus === 'connected' || connectionStatus === 'authenticating') return null
|
|
209
|
+
|
|
240
210
|
// Header subtitle changes per step
|
|
241
211
|
const headerSubtitle = isConfirming
|
|
242
212
|
? 'Confirm connection details'
|
|
243
213
|
: isScanning
|
|
244
214
|
? 'Scanning project folder'
|
|
245
|
-
:
|
|
246
|
-
? '
|
|
247
|
-
:
|
|
248
|
-
? 'Connect to your localhost project'
|
|
249
|
-
: 'Connect to your project'
|
|
215
|
+
: isLocal
|
|
216
|
+
? 'Connect to your localhost project'
|
|
217
|
+
: 'Connect to your project'
|
|
250
218
|
|
|
251
219
|
return (
|
|
252
220
|
<div
|
|
@@ -574,58 +542,28 @@ export function ConnectModal() {
|
|
|
574
542
|
color: 'var(--text-secondary)',
|
|
575
543
|
}}
|
|
576
544
|
>
|
|
577
|
-
{/*
|
|
545
|
+
{/* How It Works */}
|
|
578
546
|
<div>
|
|
579
547
|
<h4
|
|
580
548
|
className="text-[11px] font-semibold uppercase tracking-wide mb-2"
|
|
581
549
|
style={{ color: 'var(--text-primary)' }}
|
|
582
550
|
>
|
|
583
|
-
|
|
551
|
+
How It Works
|
|
584
552
|
</h4>
|
|
585
553
|
<div className="flex flex-col gap-2">
|
|
586
554
|
<div>
|
|
555
|
+
pAInt uses a{' '}
|
|
587
556
|
<span style={{ color: 'var(--success)' }}>
|
|
588
|
-
|
|
589
|
-
</span>{' '}
|
|
590
|
-
— Default. The editor loads your page through a built-in
|
|
591
|
-
proxy and injects the inspector script automatically.
|
|
592
|
-
</div>
|
|
593
|
-
<div>
|
|
594
|
-
<span style={{ color: 'var(--warning)' }}>
|
|
595
|
-
Manual (Script Tag)
|
|
557
|
+
Service Worker proxy
|
|
596
558
|
</span>{' '}
|
|
597
|
-
|
|
598
|
-
|
|
559
|
+
to load your page, inject the inspector script, and
|
|
560
|
+
strip security headers — all automatically. No script
|
|
561
|
+
tags or project modifications needed.
|
|
599
562
|
</div>
|
|
600
563
|
<div>
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
— Add the inspector script dynamically in your root
|
|
605
|
-
layout:
|
|
606
|
-
<pre
|
|
607
|
-
className="mt-1.5 px-3 py-2.5 rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre"
|
|
608
|
-
style={{
|
|
609
|
-
background: 'var(--bg-tertiary)',
|
|
610
|
-
color: 'var(--text-primary)',
|
|
611
|
-
border: '1px solid var(--border)',
|
|
612
|
-
}}
|
|
613
|
-
>{`useEffect(() => {
|
|
614
|
-
if (Platform.OS === 'web') {
|
|
615
|
-
const script1 = document.createElement('script');
|
|
616
|
-
script1.src = 'http://localhost:4000/dev-editor-inspector.js';
|
|
617
|
-
document.body.appendChild(script1);
|
|
618
|
-
|
|
619
|
-
const script2 = document.createElement('script');
|
|
620
|
-
script2.src = 'https://dev-editor-flow.vercel.app/dev-editor-inspector.js';
|
|
621
|
-
document.body.appendChild(script2);
|
|
622
|
-
|
|
623
|
-
return () => {
|
|
624
|
-
document.body.removeChild(script1);
|
|
625
|
-
document.body.removeChild(script2);
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
}`}</pre>
|
|
564
|
+
Your page's scripts and client-side rendering
|
|
565
|
+
work normally, so interactive features (3D, animations,
|
|
566
|
+
routing) are fully preserved.
|
|
629
567
|
</div>
|
|
630
568
|
</div>
|
|
631
569
|
</div>
|
|
@@ -817,75 +755,8 @@ export function ConnectModal() {
|
|
|
817
755
|
</div>
|
|
818
756
|
)}
|
|
819
757
|
|
|
820
|
-
{/* ─── STEP: CONNECTING ─── */}
|
|
821
|
-
{isConnecting && (
|
|
822
|
-
<div className="flex flex-col items-center py-8 gap-3">
|
|
823
|
-
{/* Spinner */}
|
|
824
|
-
<div
|
|
825
|
-
className="w-8 h-8 rounded-full"
|
|
826
|
-
style={{
|
|
827
|
-
border: '2px solid var(--border)',
|
|
828
|
-
borderTopColor: 'var(--accent)',
|
|
829
|
-
animation: 'spin 0.8s linear infinite',
|
|
830
|
-
}}
|
|
831
|
-
/>
|
|
832
|
-
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
833
|
-
Connecting to {targetUrl?.replace(/^https?:\/\//, '')}...
|
|
834
|
-
</p>
|
|
835
|
-
</div>
|
|
836
|
-
)}
|
|
837
758
|
</div>
|
|
838
759
|
|
|
839
|
-
{/* Script fallback banner — shown after 5s of connecting */}
|
|
840
|
-
{showScriptFallback && (
|
|
841
|
-
<div
|
|
842
|
-
className="px-6 py-3 flex-shrink-0"
|
|
843
|
-
style={{
|
|
844
|
-
borderTop: '1px solid var(--border)',
|
|
845
|
-
background: 'var(--bg-secondary)',
|
|
846
|
-
}}
|
|
847
|
-
>
|
|
848
|
-
<div
|
|
849
|
-
className="text-xs font-medium mb-1"
|
|
850
|
-
style={{ color: 'var(--warning)' }}
|
|
851
|
-
>
|
|
852
|
-
{isLocal
|
|
853
|
-
? 'Inspector script not detected'
|
|
854
|
-
: 'Script tag required'}
|
|
855
|
-
</div>
|
|
856
|
-
<div
|
|
857
|
-
className="text-[11px] mb-2"
|
|
858
|
-
style={{ color: 'var(--text-secondary)' }}
|
|
859
|
-
>
|
|
860
|
-
{isLocal
|
|
861
|
-
? "Add this script tag to your project's HTML layout:"
|
|
862
|
-
: "Since the editor is running remotely, add this script tag to your project's HTML layout to enable inspection:"}
|
|
863
|
-
</div>
|
|
864
|
-
<div className="flex items-center gap-2">
|
|
865
|
-
<code
|
|
866
|
-
className="flex-1 text-[11px] px-2 py-1.5 rounded overflow-x-auto whitespace-nowrap"
|
|
867
|
-
style={{
|
|
868
|
-
background: 'var(--bg-primary)',
|
|
869
|
-
color: 'var(--text-primary)',
|
|
870
|
-
border: '1px solid var(--border)',
|
|
871
|
-
}}
|
|
872
|
-
>
|
|
873
|
-
{`<script src="${typeof window !== 'undefined' ? window.location.origin : ''}/dev-editor-inspector.js"></script>`}
|
|
874
|
-
</code>
|
|
875
|
-
<button
|
|
876
|
-
onClick={handleCopyScript}
|
|
877
|
-
className="px-3 py-1.5 text-[11px] font-medium rounded whitespace-nowrap transition-colors flex-shrink-0"
|
|
878
|
-
style={{
|
|
879
|
-
background: scriptCopied ? 'var(--success)' : 'var(--accent)',
|
|
880
|
-
color: '#fff',
|
|
881
|
-
}}
|
|
882
|
-
>
|
|
883
|
-
{scriptCopied ? 'Copied!' : 'Copy'}
|
|
884
|
-
</button>
|
|
885
|
-
</div>
|
|
886
|
-
</div>
|
|
887
|
-
)}
|
|
888
|
-
|
|
889
760
|
{/* Footer */}
|
|
890
761
|
<div
|
|
891
762
|
className="px-6 py-4 flex-shrink-0"
|
|
@@ -897,20 +768,80 @@ export function ConnectModal() {
|
|
|
897
768
|
</p>
|
|
898
769
|
)}
|
|
899
770
|
|
|
900
|
-
{/* SETUP footer: Connect
|
|
771
|
+
{/* SETUP footer: Connect + Login first buttons */}
|
|
901
772
|
{connectionStatus === 'disconnected' && (
|
|
902
773
|
<>
|
|
903
|
-
<
|
|
904
|
-
|
|
905
|
-
|
|
774
|
+
<div className="flex items-center gap-2">
|
|
775
|
+
<button
|
|
776
|
+
onClick={handleConnect}
|
|
777
|
+
className="flex-1 py-2 text-xs rounded font-medium transition-colors"
|
|
778
|
+
style={{
|
|
779
|
+
background: 'var(--accent)',
|
|
780
|
+
color: '#fff',
|
|
781
|
+
}}
|
|
782
|
+
>
|
|
783
|
+
Connect
|
|
784
|
+
</button>
|
|
785
|
+
<button
|
|
786
|
+
onClick={handleLoginFirst}
|
|
787
|
+
className="py-2 px-4 text-xs rounded font-medium transition-colors flex-shrink-0"
|
|
788
|
+
style={{
|
|
789
|
+
background: 'var(--bg-tertiary)',
|
|
790
|
+
color: 'var(--text-secondary)',
|
|
791
|
+
border: '1px solid var(--border)',
|
|
792
|
+
}}
|
|
793
|
+
title="Log in to your project first, then connect the editor"
|
|
794
|
+
>
|
|
795
|
+
Login first
|
|
796
|
+
</button>
|
|
797
|
+
</div>
|
|
798
|
+
{/* Auth hint */}
|
|
799
|
+
<p
|
|
800
|
+
className="text-[11px] mt-2 text-center"
|
|
801
|
+
style={{ color: 'var(--text-muted)' }}
|
|
802
|
+
>
|
|
803
|
+
Project requires authentication? Use{' '}
|
|
804
|
+
<span style={{ color: 'var(--text-secondary)' }}>Login first</span>{' '}
|
|
805
|
+
to sign in before the editor connects.
|
|
806
|
+
</p>
|
|
807
|
+
{/* Incognito tip banner */}
|
|
808
|
+
<div
|
|
809
|
+
className="mt-3 rounded-lg px-4 py-3 flex items-start gap-3"
|
|
906
810
|
style={{
|
|
907
|
-
background: '
|
|
908
|
-
|
|
811
|
+
background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.15) 0%, rgba(251, 146, 0, 0.08) 100%)',
|
|
812
|
+
border: '1px solid rgba(251, 191, 36, 0.4)',
|
|
909
813
|
}}
|
|
910
814
|
>
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
815
|
+
{/* Incognito icon */}
|
|
816
|
+
<div
|
|
817
|
+
className="flex-shrink-0 mt-0.5"
|
|
818
|
+
style={{ color: 'var(--warning)', opacity: 0.9 }}
|
|
819
|
+
>
|
|
820
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
821
|
+
<path d="M12 12c2.5 0 4.5-1 6-3H6c1.5 2 3.5 3 6 3z" />
|
|
822
|
+
<path d="M3 9h18" />
|
|
823
|
+
<circle cx="7.5" cy="15.5" r="2.5" />
|
|
824
|
+
<circle cx="16.5" cy="15.5" r="2.5" />
|
|
825
|
+
<path d="M10 15.5h4" />
|
|
826
|
+
</svg>
|
|
827
|
+
</div>
|
|
828
|
+
<div className="flex flex-col gap-1">
|
|
829
|
+
<span
|
|
830
|
+
className="text-xs font-medium leading-tight"
|
|
831
|
+
style={{ color: 'var(--warning)' }}
|
|
832
|
+
>
|
|
833
|
+
Use Incognito for the best experience
|
|
834
|
+
</span>
|
|
835
|
+
<span
|
|
836
|
+
className="text-[11px] leading-relaxed"
|
|
837
|
+
style={{ color: 'var(--text-primary)' }}
|
|
838
|
+
>
|
|
839
|
+
Browser extensions and cached data can interfere with the
|
|
840
|
+
connection. If it gets stuck, try an Incognito window.
|
|
841
|
+
</span>
|
|
842
|
+
</div>
|
|
843
|
+
</div>
|
|
844
|
+
<div className="mt-2.5 text-center">
|
|
914
845
|
<a
|
|
915
846
|
href="/docs"
|
|
916
847
|
target="_blank"
|
|
@@ -972,20 +903,6 @@ export function ConnectModal() {
|
|
|
972
903
|
</button>
|
|
973
904
|
)}
|
|
974
905
|
|
|
975
|
-
{/* CONNECTING footer: Cancel */}
|
|
976
|
-
{isConnecting && (
|
|
977
|
-
<button
|
|
978
|
-
onClick={cancelConnection}
|
|
979
|
-
className="w-full py-2 text-xs rounded font-medium transition-colors"
|
|
980
|
-
style={{
|
|
981
|
-
background: 'var(--bg-tertiary)',
|
|
982
|
-
color: 'var(--text-secondary)',
|
|
983
|
-
border: '1px solid var(--border)',
|
|
984
|
-
}}
|
|
985
|
-
>
|
|
986
|
-
Cancel
|
|
987
|
-
</button>
|
|
988
|
-
)}
|
|
989
906
|
</div>
|
|
990
907
|
</div>
|
|
991
908
|
</div>
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PREVIEW_WIDTH_MAX,
|
|
9
9
|
PROXY_HEADER,
|
|
10
10
|
} from '@/lib/constants'
|
|
11
|
+
import { isSwProxyReady } from '@/lib/serviceWorkerRegistration'
|
|
11
12
|
import { ResponsiveToolbar } from './ResponsiveToolbar'
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -49,12 +50,38 @@ function buildDirectUrl(targetUrl: string, pagePath: string): string {
|
|
|
49
50
|
return `${targetUrl}${path}`
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Build the SW proxy URL for the iframe. Routes through the Service Worker
|
|
55
|
+
* at /sw-proxy/ which intercepts all requests and proxies them to the target,
|
|
56
|
+
* preserving all scripts for full client-rendered content.
|
|
57
|
+
*/
|
|
58
|
+
function buildSwProxyUrl(targetUrl: string, pagePath: string): string {
|
|
59
|
+
const path = pagePath === '/' ? '/' : pagePath
|
|
60
|
+
const encoded = encodeURIComponent(targetUrl)
|
|
61
|
+
return `/sw-proxy${path}?__sw_target=${encoded}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a SW proxy URL for auth mode. Same as regular SW proxy but adds
|
|
66
|
+
* __sw_auth=1 so the SW skips inspector/nav-blocker injection, letting
|
|
67
|
+
* the user interact with login forms normally.
|
|
68
|
+
*/
|
|
69
|
+
function buildSwProxyAuthUrl(targetUrl: string, pagePath: string): string {
|
|
70
|
+
const path = pagePath === '/' ? '/' : pagePath
|
|
71
|
+
const encoded = encodeURIComponent(targetUrl)
|
|
72
|
+
return `/sw-proxy${path}?__sw_target=${encoded}&__sw_auth=1`
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
/**
|
|
53
76
|
* Build the appropriate iframe URL based on whether the editor is local,
|
|
54
77
|
* has a bridge connection, or is remote without bridge.
|
|
78
|
+
* Prefers SW proxy when available for full client-rendered content.
|
|
55
79
|
*/
|
|
56
80
|
function buildIframeUrl(targetUrl: string, pagePath: string): string {
|
|
57
81
|
if (isEditorOnLocalhost()) {
|
|
82
|
+
if (isSwProxyReady()) {
|
|
83
|
+
return buildSwProxyUrl(targetUrl, pagePath)
|
|
84
|
+
}
|
|
58
85
|
return buildProxyUrl(targetUrl, pagePath)
|
|
59
86
|
}
|
|
60
87
|
// Check for bridge connection
|
|
@@ -78,6 +105,24 @@ export function PreviewFrame() {
|
|
|
78
105
|
const lastSrcRef = useRef<string | null>(null)
|
|
79
106
|
const [isDragging, setIsDragging] = useState(false)
|
|
80
107
|
|
|
108
|
+
// Handle auth mode — load target without inspector so user can log in
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!targetUrl || connectionStatus !== 'authenticating') return
|
|
111
|
+
|
|
112
|
+
const iframe = iframeRef.current
|
|
113
|
+
if (!iframe) return
|
|
114
|
+
|
|
115
|
+
const newSrc = isSwProxyReady()
|
|
116
|
+
? buildSwProxyAuthUrl(targetUrl, currentPagePath)
|
|
117
|
+
: buildProxyUrl(targetUrl, currentPagePath)
|
|
118
|
+
console.debug('[PreviewFrame] auth mode iframe src:', newSrc)
|
|
119
|
+
|
|
120
|
+
if (lastSrcRef.current !== newSrc) {
|
|
121
|
+
lastSrcRef.current = newSrc
|
|
122
|
+
iframe.src = newSrc
|
|
123
|
+
}
|
|
124
|
+
}, [targetUrl, connectionStatus, currentPagePath, iframeRef])
|
|
125
|
+
|
|
81
126
|
// Handle initial connection — load through proxy
|
|
82
127
|
useEffect(() => {
|
|
83
128
|
if (!targetUrl || connectionStatus !== 'connecting') return
|
|
@@ -86,6 +131,8 @@ export function PreviewFrame() {
|
|
|
86
131
|
if (!iframe) return
|
|
87
132
|
|
|
88
133
|
const newSrc = buildIframeUrl(targetUrl, currentPagePath)
|
|
134
|
+
const usingSw = newSrc.startsWith('/sw-proxy/')
|
|
135
|
+
console.debug('[PreviewFrame] iframe src:', newSrc, '| SW ready:', isSwProxyReady())
|
|
89
136
|
|
|
90
137
|
if (lastSrcRef.current !== newSrc) {
|
|
91
138
|
lastSrcRef.current = newSrc
|
|
@@ -98,8 +145,23 @@ export function PreviewFrame() {
|
|
|
98
145
|
|
|
99
146
|
iframe.addEventListener('error', handleError)
|
|
100
147
|
|
|
148
|
+
// Fallback: if SW proxy doesn't connect within 8s, retry with old proxy.
|
|
149
|
+
// This handles stale SWs, extension interference, or other SW issues.
|
|
150
|
+
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
|
|
151
|
+
if (usingSw) {
|
|
152
|
+
fallbackTimer = setTimeout(() => {
|
|
153
|
+
// Only fall back if still connecting (not yet connected)
|
|
154
|
+
if (useEditorStore.getState().connectionStatus !== 'connecting') return
|
|
155
|
+
console.debug('[PreviewFrame] SW proxy timeout — falling back to reverse proxy')
|
|
156
|
+
const fallbackSrc = buildProxyUrl(targetUrl, currentPagePath)
|
|
157
|
+
lastSrcRef.current = fallbackSrc
|
|
158
|
+
iframe.src = fallbackSrc
|
|
159
|
+
}, 8000)
|
|
160
|
+
}
|
|
161
|
+
|
|
101
162
|
return () => {
|
|
102
163
|
iframe.removeEventListener('error', handleError)
|
|
164
|
+
if (fallbackTimer) clearTimeout(fallbackTimer)
|
|
103
165
|
}
|
|
104
166
|
}, [
|
|
105
167
|
targetUrl,
|
|
@@ -219,6 +281,13 @@ export function PreviewFrame() {
|
|
|
219
281
|
|
|
220
282
|
const showHandles = !isFullWidth && connectionStatus === 'connected'
|
|
221
283
|
|
|
284
|
+
const handleFinishAuth = () => {
|
|
285
|
+
// Force a fresh load by clearing lastSrcRef so the connecting effect
|
|
286
|
+
// picks up the new (non-auth) URL even if the path hasn't changed.
|
|
287
|
+
lastSrcRef.current = null
|
|
288
|
+
setConnectionStatus('connecting')
|
|
289
|
+
}
|
|
290
|
+
|
|
222
291
|
return (
|
|
223
292
|
<div
|
|
224
293
|
className="flex flex-col h-full"
|
|
@@ -226,6 +295,28 @@ export function PreviewFrame() {
|
|
|
226
295
|
>
|
|
227
296
|
{connectionStatus === 'connected' && <ResponsiveToolbar />}
|
|
228
297
|
|
|
298
|
+
{/* Auth mode banner */}
|
|
299
|
+
{connectionStatus === 'authenticating' && (
|
|
300
|
+
<div
|
|
301
|
+
className="flex items-center justify-between px-4 py-2 flex-shrink-0"
|
|
302
|
+
style={{
|
|
303
|
+
background: 'var(--bg-secondary)',
|
|
304
|
+
borderBottom: '1px solid var(--border)',
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
308
|
+
Log in to your project below, then click <strong>Connect Editor</strong> when ready.
|
|
309
|
+
</span>
|
|
310
|
+
<button
|
|
311
|
+
onClick={handleFinishAuth}
|
|
312
|
+
className="px-3 py-1 text-xs rounded font-medium ml-3 flex-shrink-0"
|
|
313
|
+
style={{ background: 'var(--accent)', color: '#fff' }}
|
|
314
|
+
>
|
|
315
|
+
Connect Editor
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
229
320
|
<div
|
|
230
321
|
ref={containerRef}
|
|
231
322
|
className="flex items-start justify-center flex-1 overflow-auto relative"
|
|
@@ -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>()
|