@antigenic-oss/paint 0.1.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 +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- package/tsconfig.server.json +12 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useEditorStore } from '@/store'
|
|
3
|
+
import { scanProjectClient } from '@/lib/clientProjectScanner'
|
|
4
|
+
import { getApiBase } from '@/lib/apiBase'
|
|
5
|
+
import type { RouteEntry } from '@/types/claude'
|
|
6
|
+
|
|
7
|
+
export interface ScanCallbacks {
|
|
8
|
+
onSuccess?: (count: number, projectName: string) => void
|
|
9
|
+
onError?: (message: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ScanResult {
|
|
13
|
+
success: boolean
|
|
14
|
+
count: number
|
|
15
|
+
projectName: string
|
|
16
|
+
pageCount: number
|
|
17
|
+
cssFileCount: number
|
|
18
|
+
assetDirCount: number
|
|
19
|
+
framework: string | null
|
|
20
|
+
cssStrategy: string[]
|
|
21
|
+
error?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EMPTY_RESULT: ScanResult = {
|
|
25
|
+
success: false,
|
|
26
|
+
count: 0,
|
|
27
|
+
projectName: '',
|
|
28
|
+
pageCount: 0,
|
|
29
|
+
cssFileCount: 0,
|
|
30
|
+
assetDirCount: 0,
|
|
31
|
+
framework: null,
|
|
32
|
+
cssStrategy: [],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shared hook that triggers a project scan and updates the store.
|
|
37
|
+
* Captures components, pages/routes, CSS files, assets, and framework info.
|
|
38
|
+
*
|
|
39
|
+
* - `triggerScan(rootPath)` — server-side scan via /api/project-scan (localhost only)
|
|
40
|
+
* - `triggerClientScan(handle)` — client-side scan via File System Access API (works on Vercel)
|
|
41
|
+
*/
|
|
42
|
+
export function useProjectScan() {
|
|
43
|
+
const setScanStatus = useEditorStore((s) => s.setScanStatus)
|
|
44
|
+
const setScanError = useEditorStore((s) => s.setScanError)
|
|
45
|
+
const setComponentFileMap = useEditorStore((s) => s.setComponentFileMap)
|
|
46
|
+
const setScannedProjectName = useEditorStore((s) => s.setScannedProjectName)
|
|
47
|
+
const setProjectScan = useEditorStore((s) => s.setProjectScan)
|
|
48
|
+
const pendingTargetUrl = useEditorStore((s) => s.pendingTargetUrl)
|
|
49
|
+
const targetUrl = useEditorStore((s) => s.targetUrl)
|
|
50
|
+
|
|
51
|
+
/** Populate store from scan data and return a ScanResult. */
|
|
52
|
+
const finalizeScan = useCallback(
|
|
53
|
+
(
|
|
54
|
+
data: {
|
|
55
|
+
componentFileMap: Record<string, string>
|
|
56
|
+
projectName: string
|
|
57
|
+
routes: RouteEntry[]
|
|
58
|
+
cssFiles: string[]
|
|
59
|
+
assetDirs: string[]
|
|
60
|
+
framework: string | null
|
|
61
|
+
cssStrategy: string[]
|
|
62
|
+
srcDirs: string[]
|
|
63
|
+
},
|
|
64
|
+
callbacks?: ScanCallbacks,
|
|
65
|
+
): ScanResult => {
|
|
66
|
+
setComponentFileMap(data.componentFileMap)
|
|
67
|
+
setScannedProjectName(data.projectName)
|
|
68
|
+
setScanStatus('complete')
|
|
69
|
+
|
|
70
|
+
const count = Object.keys(data.componentFileMap || {}).length
|
|
71
|
+
const pageCount = data.routes.filter((r) => r.type === 'page').length
|
|
72
|
+
|
|
73
|
+
const url = pendingTargetUrl || targetUrl
|
|
74
|
+
if (url) {
|
|
75
|
+
setProjectScan(url, {
|
|
76
|
+
framework: data.framework,
|
|
77
|
+
cssStrategy: data.cssStrategy,
|
|
78
|
+
cssFiles: data.cssFiles,
|
|
79
|
+
srcDirs: data.srcDirs,
|
|
80
|
+
packageName: data.projectName || null,
|
|
81
|
+
assetDirs: data.assetDirs,
|
|
82
|
+
fileMap: {
|
|
83
|
+
routes: data.routes,
|
|
84
|
+
components: Object.entries(data.componentFileMap || {}).map(
|
|
85
|
+
([name, filePath]) => ({
|
|
86
|
+
name,
|
|
87
|
+
filePath: filePath as string,
|
|
88
|
+
nameLower: name.toLowerCase(),
|
|
89
|
+
category: 'component' as const,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
callbacks?.onSuccess?.(count, data.projectName)
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
count,
|
|
100
|
+
projectName: data.projectName,
|
|
101
|
+
pageCount,
|
|
102
|
+
cssFileCount: data.cssFiles.length,
|
|
103
|
+
assetDirCount: data.assetDirs.length,
|
|
104
|
+
framework: data.framework,
|
|
105
|
+
cssStrategy: data.cssStrategy,
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
[
|
|
109
|
+
setComponentFileMap,
|
|
110
|
+
setScannedProjectName,
|
|
111
|
+
setScanStatus,
|
|
112
|
+
setProjectScan,
|
|
113
|
+
pendingTargetUrl,
|
|
114
|
+
targetUrl,
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** Server-side scan (localhost only). */
|
|
119
|
+
const triggerScan = useCallback(
|
|
120
|
+
async (
|
|
121
|
+
rootPath: string,
|
|
122
|
+
callbacks?: ScanCallbacks,
|
|
123
|
+
): Promise<ScanResult> => {
|
|
124
|
+
setScanStatus('scanning')
|
|
125
|
+
setScanError(null)
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(`${getApiBase()}/api/project-scan`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify({ projectRoot: rootPath }),
|
|
131
|
+
})
|
|
132
|
+
if (res.ok) {
|
|
133
|
+
const data = await res.json()
|
|
134
|
+
return finalizeScan(
|
|
135
|
+
{
|
|
136
|
+
componentFileMap: data.componentFileMap || {},
|
|
137
|
+
projectName: data.projectName || 'unknown',
|
|
138
|
+
routes: data.routes || [],
|
|
139
|
+
cssFiles: data.cssFiles || [],
|
|
140
|
+
assetDirs: data.assetDirs || [],
|
|
141
|
+
framework: data.framework || null,
|
|
142
|
+
cssStrategy: data.cssStrategy || [],
|
|
143
|
+
srcDirs: data.srcDirs || [],
|
|
144
|
+
},
|
|
145
|
+
callbacks,
|
|
146
|
+
)
|
|
147
|
+
} else {
|
|
148
|
+
const errData = await res
|
|
149
|
+
.json()
|
|
150
|
+
.catch(() => ({ error: 'Scan failed' }))
|
|
151
|
+
const message = errData.error || 'Scan failed'
|
|
152
|
+
setScanStatus('error')
|
|
153
|
+
setScanError(message)
|
|
154
|
+
callbacks?.onError?.(message)
|
|
155
|
+
return { ...EMPTY_RESULT, error: message }
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
const message = 'Network error during scan'
|
|
159
|
+
setScanStatus('error')
|
|
160
|
+
setScanError(message)
|
|
161
|
+
callbacks?.onError?.(message)
|
|
162
|
+
return { ...EMPTY_RESULT, error: message }
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
[setScanStatus, setScanError, finalizeScan],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
/** Client-side scan using File System Access API (works on Vercel). */
|
|
169
|
+
const triggerClientScan = useCallback(
|
|
170
|
+
async (
|
|
171
|
+
handle: FileSystemDirectoryHandle,
|
|
172
|
+
callbacks?: ScanCallbacks,
|
|
173
|
+
): Promise<ScanResult> => {
|
|
174
|
+
setScanStatus('scanning')
|
|
175
|
+
setScanError(null)
|
|
176
|
+
try {
|
|
177
|
+
const data = await scanProjectClient(handle)
|
|
178
|
+
return finalizeScan(
|
|
179
|
+
{
|
|
180
|
+
componentFileMap: data.componentFileMap,
|
|
181
|
+
projectName: data.projectName,
|
|
182
|
+
routes: data.routes,
|
|
183
|
+
cssFiles: data.cssFiles,
|
|
184
|
+
assetDirs: data.assetDirs,
|
|
185
|
+
framework: data.framework,
|
|
186
|
+
cssStrategy: data.cssStrategy,
|
|
187
|
+
srcDirs: data.srcDirs,
|
|
188
|
+
},
|
|
189
|
+
callbacks,
|
|
190
|
+
)
|
|
191
|
+
} catch (err) {
|
|
192
|
+
const message =
|
|
193
|
+
err instanceof Error ? err.message : 'Client scan failed'
|
|
194
|
+
setScanStatus('error')
|
|
195
|
+
setScanError(message)
|
|
196
|
+
callbacks?.onError?.(message)
|
|
197
|
+
return { ...EMPTY_RESULT, error: message }
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
[setScanStatus, setScanError, finalizeScan],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return { triggerScan, triggerClientScan }
|
|
204
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useEditorStore } from '@/store'
|
|
3
|
+
|
|
4
|
+
export function useResizable(side: 'left' | 'right') {
|
|
5
|
+
const width = useEditorStore((s) =>
|
|
6
|
+
side === 'left' ? s.leftPanelWidth : s.rightPanelWidth,
|
|
7
|
+
)
|
|
8
|
+
const setWidth = useEditorStore((s) =>
|
|
9
|
+
side === 'left' ? s.setLeftPanelWidth : s.setRightPanelWidth,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const handleResize = useCallback(
|
|
13
|
+
(newWidth: number) => {
|
|
14
|
+
setWidth(newWidth)
|
|
15
|
+
},
|
|
16
|
+
[setWidth],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return { width, handleResize }
|
|
20
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { usePostMessage } from './usePostMessage'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hook that manages element selection with bidirectional sync.
|
|
9
|
+
* Click tree node → sends SELECT_ELEMENT to inspector.
|
|
10
|
+
* Inspector click → updates store (handled by usePostMessage).
|
|
11
|
+
*/
|
|
12
|
+
export function useSelectedElement() {
|
|
13
|
+
const selectorPath = useEditorStore((s) => s.selectorPath)
|
|
14
|
+
const tagName = useEditorStore((s) => s.tagName)
|
|
15
|
+
const className = useEditorStore((s) => s.className)
|
|
16
|
+
const elementId = useEditorStore((s) => s.elementId)
|
|
17
|
+
const attributes = useEditorStore((s) => s.attributes)
|
|
18
|
+
const innerText = useEditorStore((s) => s.innerText)
|
|
19
|
+
const computedStyles = useEditorStore((s) => s.computedStyles)
|
|
20
|
+
const boundingRect = useEditorStore((s) => s.boundingRect)
|
|
21
|
+
const clearSelection = useEditorStore((s) => s.clearSelection)
|
|
22
|
+
const { sendToInspector } = usePostMessage()
|
|
23
|
+
|
|
24
|
+
const selectFromTree = useCallback(
|
|
25
|
+
(selectorPath: string) => {
|
|
26
|
+
sendToInspector({
|
|
27
|
+
type: 'SELECT_ELEMENT',
|
|
28
|
+
payload: { selectorPath },
|
|
29
|
+
})
|
|
30
|
+
},
|
|
31
|
+
[sendToInspector],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const deselect = useCallback(() => {
|
|
35
|
+
clearSelection()
|
|
36
|
+
}, [clearSelection])
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
selectorPath,
|
|
40
|
+
tagName,
|
|
41
|
+
className,
|
|
42
|
+
elementId,
|
|
43
|
+
attributes,
|
|
44
|
+
innerText,
|
|
45
|
+
computedStyles,
|
|
46
|
+
boundingRect,
|
|
47
|
+
selectFromTree,
|
|
48
|
+
deselect,
|
|
49
|
+
isSelected: selectorPath !== null,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { usePostMessage } from './usePostMessage'
|
|
6
|
+
import {
|
|
7
|
+
HEARTBEAT_INTERVAL_MS,
|
|
8
|
+
RECONNECT_MAX_RETRIES,
|
|
9
|
+
RECONNECT_BASE_DELAY_MS,
|
|
10
|
+
} from '@/lib/constants'
|
|
11
|
+
|
|
12
|
+
export function useTargetUrl() {
|
|
13
|
+
const connectionStatus = useEditorStore((s) => s.connectionStatus)
|
|
14
|
+
const targetUrl = useEditorStore((s) => s.targetUrl)
|
|
15
|
+
const setConnectionStatus = useEditorStore((s) => s.setConnectionStatus)
|
|
16
|
+
const { sendHeartbeat } = usePostMessage()
|
|
17
|
+
|
|
18
|
+
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
|
19
|
+
null,
|
|
20
|
+
)
|
|
21
|
+
const retryCountRef = useRef(0)
|
|
22
|
+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
23
|
+
|
|
24
|
+
const startHeartbeat = useCallback(() => {
|
|
25
|
+
if (heartbeatIntervalRef.current)
|
|
26
|
+
clearInterval(heartbeatIntervalRef.current)
|
|
27
|
+
|
|
28
|
+
heartbeatIntervalRef.current = setInterval(async () => {
|
|
29
|
+
const alive = await sendHeartbeat()
|
|
30
|
+
if (!alive) {
|
|
31
|
+
setConnectionStatus('disconnected')
|
|
32
|
+
}
|
|
33
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
34
|
+
}, [sendHeartbeat, setConnectionStatus])
|
|
35
|
+
|
|
36
|
+
const stopHeartbeat = useCallback(() => {
|
|
37
|
+
if (heartbeatIntervalRef.current) {
|
|
38
|
+
clearInterval(heartbeatIntervalRef.current)
|
|
39
|
+
heartbeatIntervalRef.current = null
|
|
40
|
+
}
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
// Start heartbeat when connected
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (connectionStatus === 'connected') {
|
|
46
|
+
retryCountRef.current = 0
|
|
47
|
+
startHeartbeat()
|
|
48
|
+
} else {
|
|
49
|
+
stopHeartbeat()
|
|
50
|
+
}
|
|
51
|
+
return stopHeartbeat
|
|
52
|
+
}, [connectionStatus, startHeartbeat, stopHeartbeat])
|
|
53
|
+
|
|
54
|
+
// Auto-reconnect on disconnect (confirming/scanning have null targetUrl so they won't trigger this)
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (
|
|
57
|
+
connectionStatus !== 'disconnected' ||
|
|
58
|
+
!targetUrl ||
|
|
59
|
+
retryCountRef.current >= RECONNECT_MAX_RETRIES
|
|
60
|
+
)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, retryCountRef.current)
|
|
64
|
+
retryTimeoutRef.current = setTimeout(() => {
|
|
65
|
+
retryCountRef.current++
|
|
66
|
+
setConnectionStatus('connecting')
|
|
67
|
+
}, delay)
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
if (retryTimeoutRef.current) {
|
|
71
|
+
clearTimeout(retryTimeoutRef.current)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, [connectionStatus, targetUrl, setConnectionStatus])
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
isConnected: connectionStatus === 'connected',
|
|
78
|
+
isConnecting: connectionStatus === 'connecting',
|
|
79
|
+
isDisconnected: connectionStatus === 'disconnected',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM tree serialization for the inspector.
|
|
3
|
+
* Converts live DOM into TreeNode structure for the editor's left panel.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { TreeNode } from '@/types/tree'
|
|
7
|
+
|
|
8
|
+
export function generateSelectorPath(element: Element): string {
|
|
9
|
+
const parts: string[] = []
|
|
10
|
+
let current: Element | null = element
|
|
11
|
+
|
|
12
|
+
while (current && current !== document.documentElement) {
|
|
13
|
+
let selector = current.tagName.toLowerCase()
|
|
14
|
+
|
|
15
|
+
if (current.id) {
|
|
16
|
+
selector += `#${current.id}`
|
|
17
|
+
parts.unshift(selector)
|
|
18
|
+
break
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (current.className && typeof current.className === 'string') {
|
|
22
|
+
const classes = current.className.trim().split(/\s+/).filter(Boolean)
|
|
23
|
+
if (classes.length > 0) {
|
|
24
|
+
selector += '.' + classes.join('.')
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parent = current.parentElement
|
|
29
|
+
if (parent) {
|
|
30
|
+
const siblings = Array.from(parent.children).filter(
|
|
31
|
+
(child) => child.tagName === current!.tagName,
|
|
32
|
+
)
|
|
33
|
+
if (siblings.length > 1) {
|
|
34
|
+
const index = siblings.indexOf(current) + 1
|
|
35
|
+
selector += `:nth-of-type(${index})`
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
parts.unshift(selector)
|
|
40
|
+
current = current.parentElement
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return parts.join(' > ')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SKIP_TAGS = new Set(['script', 'style', 'link', 'noscript'])
|
|
47
|
+
|
|
48
|
+
export function serializeTree(element: Element): TreeNode | null {
|
|
49
|
+
if (!element || element.nodeType !== 1) return null
|
|
50
|
+
|
|
51
|
+
const tagName = element.tagName.toLowerCase()
|
|
52
|
+
if (SKIP_TAGS.has(tagName)) return null
|
|
53
|
+
|
|
54
|
+
const children: TreeNode[] = []
|
|
55
|
+
for (let i = 0; i < element.children.length; i++) {
|
|
56
|
+
const child = serializeTree(element.children[i])
|
|
57
|
+
if (child) children.push(child)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: generateSelectorPath(element),
|
|
62
|
+
tagName,
|
|
63
|
+
className:
|
|
64
|
+
element.className && typeof element.className === 'string'
|
|
65
|
+
? element.className
|
|
66
|
+
: null,
|
|
67
|
+
elementId: element.id || null,
|
|
68
|
+
children,
|
|
69
|
+
imgSrc: tagName === 'img' ? element.getAttribute('src') || null : null,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Click handler in capture phase for element selection.
|
|
3
|
+
* Source reference — runtime code is inlined in the proxy route.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type SelectCallback = (element: Element) => void
|
|
7
|
+
|
|
8
|
+
export function createElementSelector(onSelect: SelectCallback) {
|
|
9
|
+
const handler = (e: MouseEvent) => {
|
|
10
|
+
e.preventDefault()
|
|
11
|
+
e.stopPropagation()
|
|
12
|
+
const el = document.elementFromPoint(e.clientX, e.clientY)
|
|
13
|
+
if (el) onSelect(el)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
document.addEventListener('click', handler, true)
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
destroy() {
|
|
20
|
+
document.removeEventListener('click', handler, true)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visual-editor-style hover highlight — dotted green border + element name label.
|
|
3
|
+
* Source reference — runtime code is inlined in the proxy route.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function createHoverHighlighter() {
|
|
7
|
+
const overlay = document.createElement('div')
|
|
8
|
+
overlay.style.cssText =
|
|
9
|
+
'position:fixed;pointer-events:none;z-index:999996;border:1px dashed rgba(74,222,128,0.30);display:none;transition:top 0.04s,left 0.04s,width 0.04s,height 0.04s;'
|
|
10
|
+
document.body.appendChild(overlay)
|
|
11
|
+
|
|
12
|
+
const label = document.createElement('div')
|
|
13
|
+
label.style.cssText =
|
|
14
|
+
'position:absolute;top:-18px;left:-1px;padding:1px 6px;font-size:10px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;line-height:14px;color:#fff;background:rgba(74,222,128,0.70);border-radius:3px 3px 0 0;white-space:nowrap;pointer-events:none;'
|
|
15
|
+
overlay.appendChild(label)
|
|
16
|
+
|
|
17
|
+
function getElementLabel(el: Element): string {
|
|
18
|
+
const tag = el.tagName.toLowerCase()
|
|
19
|
+
if (el.id) return tag + '#' + el.id
|
|
20
|
+
const cls = el.className
|
|
21
|
+
if (cls && typeof cls === 'string') {
|
|
22
|
+
const first = cls.trim().split(/\s+/)[0]
|
|
23
|
+
if (first) return tag + '.' + first
|
|
24
|
+
}
|
|
25
|
+
return tag
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
show(el: Element, rect: DOMRect) {
|
|
30
|
+
overlay.style.display = 'block'
|
|
31
|
+
overlay.style.top = rect.top + 'px'
|
|
32
|
+
overlay.style.left = rect.left + 'px'
|
|
33
|
+
overlay.style.width = rect.width + 'px'
|
|
34
|
+
overlay.style.height = rect.height + 'px'
|
|
35
|
+
label.textContent = getElementLabel(el)
|
|
36
|
+
// Flip label below if near top
|
|
37
|
+
if (rect.top < 20) {
|
|
38
|
+
label.style.top = 'auto'
|
|
39
|
+
label.style.bottom = '-18px'
|
|
40
|
+
label.style.borderRadius = '0 0 3px 3px'
|
|
41
|
+
} else {
|
|
42
|
+
label.style.top = '-18px'
|
|
43
|
+
label.style.bottom = 'auto'
|
|
44
|
+
label.style.borderRadius = '3px 3px 0 0'
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
hide() {
|
|
48
|
+
overlay.style.display = 'none'
|
|
49
|
+
},
|
|
50
|
+
destroy() {
|
|
51
|
+
overlay.remove()
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent outline on selected element.
|
|
3
|
+
* Source reference — runtime code is inlined in the proxy route.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function createSelectionHighlighter() {
|
|
7
|
+
const overlay = document.createElement('div')
|
|
8
|
+
overlay.style.cssText =
|
|
9
|
+
'position:fixed;pointer-events:none;z-index:999997;border:2px solid #4a9eff;display:none;'
|
|
10
|
+
document.body.appendChild(overlay)
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
show(rect: DOMRect) {
|
|
14
|
+
overlay.style.display = 'block'
|
|
15
|
+
overlay.style.top = rect.top + 'px'
|
|
16
|
+
overlay.style.left = rect.left + 'px'
|
|
17
|
+
overlay.style.width = rect.width + 'px'
|
|
18
|
+
overlay.style.height = rect.height + 'px'
|
|
19
|
+
},
|
|
20
|
+
hide() {
|
|
21
|
+
overlay.style.display = 'none'
|
|
22
|
+
},
|
|
23
|
+
destroy() {
|
|
24
|
+
overlay.remove()
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract computed styles for the design panel.
|
|
3
|
+
* Source reference — runtime code is inlined in the proxy route.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ALL_EDITABLE_PROPERTIES } from '@/lib/constants'
|
|
7
|
+
|
|
8
|
+
export function getComputedStylesForElement(
|
|
9
|
+
element: Element,
|
|
10
|
+
): Record<string, string> {
|
|
11
|
+
const computed = window.getComputedStyle(element)
|
|
12
|
+
const styles: Record<string, string> = {}
|
|
13
|
+
|
|
14
|
+
for (const prop of ALL_EDITABLE_PROPERTIES) {
|
|
15
|
+
styles[prop] = computed.getPropertyValue(prop)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return styles
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspector entry point.
|
|
3
|
+
* This file is the source for the inspector IIFE injected into the iframe.
|
|
4
|
+
* The actual runtime code is inlined in the proxy route for simplicity.
|
|
5
|
+
* This module serves as the canonical source reference.
|
|
6
|
+
*
|
|
7
|
+
* Modules:
|
|
8
|
+
* - DOMTraverser: Serializes DOM to TreeNode
|
|
9
|
+
* - HoverHighlighter: Blue overlay on mousemove
|
|
10
|
+
* - ElementSelector: Click to select with computed styles
|
|
11
|
+
* - SelectionHighlighter: Persistent outline on selected element
|
|
12
|
+
* - StyleExtractor: getComputedStyle reader
|
|
13
|
+
* - messaging: postMessage send/receive bridge
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { generateSelectorPath, serializeTree } from './DOMTraverser'
|
|
17
|
+
export { getComputedStylesForElement } from './StyleExtractor'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspector messaging bridge.
|
|
3
|
+
* Handles postMessage send/receive with origin checking.
|
|
4
|
+
* Runtime code is inlined in the proxy; this is the source reference.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
InspectorToEditorMessage,
|
|
9
|
+
EditorToInspectorMessage,
|
|
10
|
+
} from '@/types/messages'
|
|
11
|
+
|
|
12
|
+
const parentOrigin = typeof window !== 'undefined' ? window.location.origin : ''
|
|
13
|
+
|
|
14
|
+
export function sendToEditor(message: InspectorToEditorMessage): void {
|
|
15
|
+
window.parent.postMessage(message, parentOrigin)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MessageHandler = (message: EditorToInspectorMessage) => void
|
|
19
|
+
|
|
20
|
+
export function listenForEditorMessages(handler: MessageHandler): () => void {
|
|
21
|
+
const listener = (event: MessageEvent) => {
|
|
22
|
+
if (event.origin !== parentOrigin) return
|
|
23
|
+
const data = event.data
|
|
24
|
+
if (!data || !data.type) return
|
|
25
|
+
handler(data as EditorToInspectorMessage)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
window.addEventListener('message', listener)
|
|
29
|
+
return () => window.removeEventListener('message', listener)
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useEditorStore } from '@/store'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the base URL for API calls.
|
|
5
|
+
*
|
|
6
|
+
* - When running locally: returns '' (same-origin).
|
|
7
|
+
* - When running remotely with bridge: returns the bridge URL (e.g. 'http://localhost:4002').
|
|
8
|
+
* - When running remotely without bridge: returns '' (calls go to Vercel routes).
|
|
9
|
+
*/
|
|
10
|
+
export function getApiBase(): string {
|
|
11
|
+
if (typeof window === 'undefined') return ''
|
|
12
|
+
const h = window.location.hostname
|
|
13
|
+
if (h === 'localhost' || h === '127.0.0.1') return ''
|
|
14
|
+
return useEditorStore.getState().bridgeUrl || ''
|
|
15
|
+
}
|