@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,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { performUndo, performRedo } from './useChangeTracker'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Global keyboard shortcuts for the editor.
|
|
9
|
+
*
|
|
10
|
+
* - Escape: Deselect current element
|
|
11
|
+
* - Cmd+Z / Ctrl+Z: Undo last style change
|
|
12
|
+
* - Cmd+Shift+Z / Ctrl+Shift+Z: Redo last undone change
|
|
13
|
+
* - Cmd+[ / Ctrl+[: Toggle left panel
|
|
14
|
+
* - Cmd+] / Ctrl+]: Toggle right panel
|
|
15
|
+
*/
|
|
16
|
+
export function useKeyboardShortcuts() {
|
|
17
|
+
const clearSelection = useEditorStore((s) => s.clearSelection)
|
|
18
|
+
const toggleLeftPanel = useEditorStore((s) => s.toggleLeftPanel)
|
|
19
|
+
const toggleRightPanel = useEditorStore((s) => s.toggleRightPanel)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const handler = (e: KeyboardEvent) => {
|
|
23
|
+
// Cmd+Z / Ctrl+Z — undo (works even when focused on inputs)
|
|
24
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
performUndo()
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Cmd+Shift+Z / Ctrl+Shift+Z — redo (works even when focused on inputs)
|
|
31
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
|
|
32
|
+
e.preventDefault()
|
|
33
|
+
performRedo()
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Don't intercept other shortcuts when typing in inputs
|
|
38
|
+
const target = e.target as HTMLElement
|
|
39
|
+
if (
|
|
40
|
+
target.tagName === 'INPUT' ||
|
|
41
|
+
target.tagName === 'TEXTAREA' ||
|
|
42
|
+
target.tagName === 'SELECT' ||
|
|
43
|
+
target.isContentEditable
|
|
44
|
+
) {
|
|
45
|
+
// Escape should still work to blur inputs
|
|
46
|
+
if (e.key === 'Escape') {
|
|
47
|
+
target.blur()
|
|
48
|
+
}
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Escape — deselect element
|
|
53
|
+
if (e.key === 'Escape') {
|
|
54
|
+
clearSelection()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Cmd+[ or Ctrl+[ — toggle left panel
|
|
59
|
+
if ((e.metaKey || e.ctrlKey) && e.key === '[') {
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
toggleLeftPanel()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Cmd+] or Ctrl+] — toggle right panel
|
|
66
|
+
if ((e.metaKey || e.ctrlKey) && e.key === ']') {
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
toggleRightPanel()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
window.addEventListener('keydown', handler)
|
|
74
|
+
return () => window.removeEventListener('keydown', handler)
|
|
75
|
+
}, [clearSelection, toggleLeftPanel, toggleRightPanel])
|
|
76
|
+
}
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useCallback } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import type {
|
|
6
|
+
InspectorToEditorMessage,
|
|
7
|
+
EditorToInspectorMessage,
|
|
8
|
+
} from '@/types/messages'
|
|
9
|
+
import { generateId } from '@/lib/utils'
|
|
10
|
+
import { buildTailwindClassMap } from '@/lib/tailwindClassParser'
|
|
11
|
+
import { getApiBase } from '@/lib/apiBase'
|
|
12
|
+
|
|
13
|
+
function isAllowedOrigin(origin: string): boolean {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(origin)
|
|
16
|
+
// Always allow localhost/127.0.0.1 (primary use case)
|
|
17
|
+
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1')
|
|
18
|
+
return true
|
|
19
|
+
// Allow the editor's own origin (same-origin messages, e.g. proxy mode)
|
|
20
|
+
if (typeof window !== 'undefined' && origin === window.location.origin)
|
|
21
|
+
return true
|
|
22
|
+
// Also allow if origin matches the current target URL (for live site editing)
|
|
23
|
+
const targetUrl = useEditorStore.getState().targetUrl
|
|
24
|
+
if (targetUrl) {
|
|
25
|
+
const targetOrigin = new URL(targetUrl).origin
|
|
26
|
+
if (origin === targetOrigin) return true
|
|
27
|
+
}
|
|
28
|
+
return false
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Module-level shared iframe ref — ensures all callers of usePostMessage()
|
|
35
|
+
// share the same ref. PreviewFrame assigns it to the DOM element, and other
|
|
36
|
+
// components (DragModeToggle, etc.) can send messages through it.
|
|
37
|
+
const sharedIframeRef: React.MutableRefObject<HTMLIFrameElement | null> = {
|
|
38
|
+
current: null,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Singleton message listener — registered once to prevent duplicate handlers
|
|
42
|
+
// when multiple components call usePostMessage(). Uses useEditorStore.getState()
|
|
43
|
+
// so it always reads fresh state without stale closures.
|
|
44
|
+
let listenerRegistered = false
|
|
45
|
+
let heartbeatResolve: (() => void) | null = null
|
|
46
|
+
let componentRescanTimer: ReturnType<typeof setTimeout> | null = null
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns true when the editor is running on localhost (proxy mode).
|
|
50
|
+
* When false, the editor is deployed remotely and must use direct iframe loading.
|
|
51
|
+
*/
|
|
52
|
+
export function isEditorOnLocalhost(): boolean {
|
|
53
|
+
const h = window.location.hostname
|
|
54
|
+
return h === 'localhost' || h === '127.0.0.1'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sendViaIframe(message: EditorToInspectorMessage) {
|
|
58
|
+
const iframe = sharedIframeRef.current
|
|
59
|
+
if (!iframe?.contentWindow) return
|
|
60
|
+
// Detect the iframe's actual origin by trying same-origin access.
|
|
61
|
+
// This correctly handles all modes without relying on store state
|
|
62
|
+
// (which may not yet reflect the iframe's actual URL):
|
|
63
|
+
// - Proxy mode (same-origin): contentWindow.location.origin succeeds
|
|
64
|
+
// - Preview/direct mode (cross-origin): throws, fall back to target origin or '*'
|
|
65
|
+
// - Bridge mode (cross-origin): throws, fall back to bridge or target origin
|
|
66
|
+
// Note: postMessage silently drops messages on origin mismatch (no throw),
|
|
67
|
+
// so we must determine the correct origin upfront.
|
|
68
|
+
let targetOrigin: string
|
|
69
|
+
try {
|
|
70
|
+
// Same-origin: can read iframe's location directly
|
|
71
|
+
targetOrigin = iframe.contentWindow.location.origin
|
|
72
|
+
} catch {
|
|
73
|
+
// Cross-origin: fall back based on configuration
|
|
74
|
+
const store = useEditorStore.getState()
|
|
75
|
+
const bridgeUrl = store.bridgeUrl
|
|
76
|
+
if (bridgeUrl) {
|
|
77
|
+
try {
|
|
78
|
+
targetOrigin = new URL(bridgeUrl).origin
|
|
79
|
+
} catch {
|
|
80
|
+
targetOrigin = '*'
|
|
81
|
+
}
|
|
82
|
+
} else if (store.targetUrl) {
|
|
83
|
+
try {
|
|
84
|
+
targetOrigin = new URL(store.targetUrl).origin
|
|
85
|
+
} catch {
|
|
86
|
+
targetOrigin = '*'
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
targetOrigin = '*'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
iframe.contentWindow.postMessage(message, targetOrigin)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleMessage(event: MessageEvent) {
|
|
96
|
+
if (!isAllowedOrigin(event.origin)) return
|
|
97
|
+
const msg = event.data as InspectorToEditorMessage
|
|
98
|
+
if (!msg || !msg.type) return
|
|
99
|
+
|
|
100
|
+
const store = useEditorStore.getState()
|
|
101
|
+
|
|
102
|
+
switch (msg.type) {
|
|
103
|
+
case 'INSPECTOR_READY': {
|
|
104
|
+
store.setConnectionStatus('connected')
|
|
105
|
+
store.clearConsole()
|
|
106
|
+
// Re-sync selection mode — the fresh inspector defaults to selectionModeEnabled=true,
|
|
107
|
+
// but if the editor is in preview mode (or selection is toggled off), we need to
|
|
108
|
+
// tell the inspector immediately so clicks pass through for navigation.
|
|
109
|
+
const effectiveSelection = store.viewMode ? false : store.selectionMode
|
|
110
|
+
sendViaIframe({
|
|
111
|
+
type: 'SET_SELECTION_MODE',
|
|
112
|
+
payload: { enabled: effectiveSelection },
|
|
113
|
+
})
|
|
114
|
+
sendViaIframe({ type: 'REQUEST_DOM_TREE' })
|
|
115
|
+
sendViaIframe({ type: 'REQUEST_PAGE_LINKS' })
|
|
116
|
+
sendViaIframe({ type: 'REQUEST_CSS_VARIABLES' })
|
|
117
|
+
setTimeout(function () {
|
|
118
|
+
sendViaIframe({ type: 'REQUEST_COMPONENTS', payload: {} })
|
|
119
|
+
}, 500)
|
|
120
|
+
|
|
121
|
+
// Re-apply persisted changes to the iframe after a fresh load/refresh.
|
|
122
|
+
// The store already has them (loaded via useChangeTracker), but the
|
|
123
|
+
// iframe DOM is fresh — so we need to replay every PREVIEW_CHANGE.
|
|
124
|
+
const persisted = useEditorStore.getState().styleChanges
|
|
125
|
+
if (persisted.length > 0) {
|
|
126
|
+
for (const change of persisted) {
|
|
127
|
+
if (change.property === '__element_deleted__') {
|
|
128
|
+
// Re-hide deleted elements by sending DELETE_ELEMENT (without re-tracking,
|
|
129
|
+
// the inspector will send ELEMENT_DELETED but we deduplicate below)
|
|
130
|
+
sendViaIframe({
|
|
131
|
+
type: 'DELETE_ELEMENT',
|
|
132
|
+
payload: { selectorPath: change.elementSelector },
|
|
133
|
+
})
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
change.property === '__text_content__' ||
|
|
138
|
+
change.property === '__component_creation__' ||
|
|
139
|
+
change.property === '__element_inserted__' ||
|
|
140
|
+
change.property === '__element_moved__'
|
|
141
|
+
)
|
|
142
|
+
continue
|
|
143
|
+
sendViaIframe({
|
|
144
|
+
type: 'PREVIEW_CHANGE',
|
|
145
|
+
payload: {
|
|
146
|
+
selectorPath: change.elementSelector,
|
|
147
|
+
property: change.property,
|
|
148
|
+
value: change.newValue,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'ELEMENT_SELECTED': {
|
|
157
|
+
// Check for tracked changes on this element BEFORE updating the store.
|
|
158
|
+
// When re-selecting an element, the inspector sends fresh computedStyles
|
|
159
|
+
// that don't reflect previously applied inline changes (the inline styles
|
|
160
|
+
// may have been lost to DOM re-renders). We merge tracked change values
|
|
161
|
+
// into computedStyles so the store gets correct values in a single update,
|
|
162
|
+
// and re-apply the inline styles to the iframe DOM.
|
|
163
|
+
const trackedChanges = store.styleChanges.filter(
|
|
164
|
+
(c) => c.elementSelector === msg.payload.selectorPath,
|
|
165
|
+
)
|
|
166
|
+
if (trackedChanges.length > 0) {
|
|
167
|
+
const merged = { ...msg.payload.computedStyles }
|
|
168
|
+
for (const change of trackedChanges) {
|
|
169
|
+
merged[change.property] = change.newValue
|
|
170
|
+
sendViaIframe({
|
|
171
|
+
type: 'PREVIEW_CHANGE',
|
|
172
|
+
payload: {
|
|
173
|
+
selectorPath: msg.payload.selectorPath,
|
|
174
|
+
property: change.property,
|
|
175
|
+
value: change.newValue,
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
msg.payload.computedStyles = merged
|
|
180
|
+
}
|
|
181
|
+
store.selectElement(msg.payload)
|
|
182
|
+
store.expandToNode(msg.payload.selectorPath)
|
|
183
|
+
store.setCSSVariableUsages(msg.payload.cssVariableUsages || {})
|
|
184
|
+
// Build Tailwind class → CSS property → variable mapping
|
|
185
|
+
const twMap = buildTailwindClassMap(
|
|
186
|
+
msg.payload.className,
|
|
187
|
+
store.cssVariableDefinitions,
|
|
188
|
+
)
|
|
189
|
+
store.setTailwindClassMap(twMap)
|
|
190
|
+
store.setActiveRightTab('design')
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case 'CSS_VARIABLES': {
|
|
195
|
+
store.setCSSVariableDefinitions(
|
|
196
|
+
msg.payload.definitions,
|
|
197
|
+
msg.payload.isExplicit,
|
|
198
|
+
msg.payload.scopes,
|
|
199
|
+
)
|
|
200
|
+
const varCount = Object.keys(msg.payload.definitions).length
|
|
201
|
+
const csProjectRoot = useEditorStore.getState().projectRoot
|
|
202
|
+
|
|
203
|
+
// If the inspector found no variables, try scanning the project folder
|
|
204
|
+
if (varCount === 0 && csProjectRoot) {
|
|
205
|
+
fetch(`${getApiBase()}/api/project-scan/css-variables`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify({ projectRoot: csProjectRoot }),
|
|
209
|
+
})
|
|
210
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
211
|
+
.then((data) => {
|
|
212
|
+
if (data?.definitions && Object.keys(data.definitions).length > 0) {
|
|
213
|
+
useEditorStore
|
|
214
|
+
.getState()
|
|
215
|
+
.setCSSVariableDefinitions(data.definitions, false)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
.catch(() => {
|
|
219
|
+
/* ignore scan failures */
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Also try Tailwind config parser to supplement with theme colors
|
|
224
|
+
if (csProjectRoot) {
|
|
225
|
+
fetch(`${getApiBase()}/api/project-scan/tailwind-config`, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({ projectRoot: csProjectRoot }),
|
|
229
|
+
})
|
|
230
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
231
|
+
.then((data) => {
|
|
232
|
+
if (data?.definitions && Object.keys(data.definitions).length > 0) {
|
|
233
|
+
// Merge with existing definitions (don't overwrite runtime-scanned vars)
|
|
234
|
+
const current = useEditorStore.getState().cssVariableDefinitions
|
|
235
|
+
const merged = { ...data.definitions, ...current }
|
|
236
|
+
useEditorStore.getState().setCSSVariableDefinitions(merged, false)
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
.catch(() => {
|
|
240
|
+
/* ignore tailwind config scan failures */
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'ELEMENT_HOVERED':
|
|
247
|
+
store.setHighlightedNodeId(msg.payload.selectorPath)
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
case 'DOM_TREE':
|
|
251
|
+
store.setRootNode(msg.payload.tree)
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
case 'DOM_UPDATED':
|
|
255
|
+
store.setRootNode(msg.payload.tree)
|
|
256
|
+
if (msg.payload.removedSelectors.length > 0) {
|
|
257
|
+
const currentSelector = store.selectorPath
|
|
258
|
+
if (
|
|
259
|
+
currentSelector &&
|
|
260
|
+
msg.payload.removedSelectors.includes(currentSelector)
|
|
261
|
+
) {
|
|
262
|
+
store.clearSelection()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Debounced component rescan on DOM changes (2s to avoid
|
|
266
|
+
// excessive scanning during rapid DOM mutations)
|
|
267
|
+
if (componentRescanTimer) clearTimeout(componentRescanTimer)
|
|
268
|
+
componentRescanTimer = setTimeout(function () {
|
|
269
|
+
componentRescanTimer = null
|
|
270
|
+
sendViaIframe({ type: 'REQUEST_COMPONENTS', payload: {} })
|
|
271
|
+
}, 2000)
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
case 'HEARTBEAT_RESPONSE':
|
|
275
|
+
if (heartbeatResolve) {
|
|
276
|
+
heartbeatResolve()
|
|
277
|
+
heartbeatResolve = null
|
|
278
|
+
}
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
case 'PAGE_LINKS':
|
|
282
|
+
store.setPageLinks(msg.payload.links)
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
case 'COMPONENTS_DETECTED':
|
|
286
|
+
store.setDetectedComponents(msg.payload.components)
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
case 'VARIANT_APPLIED':
|
|
290
|
+
if (msg.payload.selectorPath === store.selectorPath) {
|
|
291
|
+
store.updateComputedStyles(msg.payload.computedStyles)
|
|
292
|
+
store.setCSSVariableUsages(msg.payload.cssVariableUsages)
|
|
293
|
+
}
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
case 'PAGE_NAVIGATE':
|
|
297
|
+
store.setCurrentPagePath(msg.payload.path)
|
|
298
|
+
store.clearSelection()
|
|
299
|
+
store.clearComponents()
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
case 'CONSOLE_MESSAGE':
|
|
303
|
+
store.addConsoleEntry(msg.payload)
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
case 'RECURSIVE_EMBED_DETECTED': {
|
|
307
|
+
// The iframe loaded pAInt's own page instead of the target.
|
|
308
|
+
// This happens when the navigation blocker failed to intercept a
|
|
309
|
+
// programmatic navigation after history.replaceState. Reload the iframe
|
|
310
|
+
// with the correct proxy URL to recover.
|
|
311
|
+
console.warn(
|
|
312
|
+
'[pAInt] Recursive embed detected — reloading iframe through proxy',
|
|
313
|
+
)
|
|
314
|
+
const iframe = sharedIframeRef.current
|
|
315
|
+
const recoverTarget = store.targetUrl
|
|
316
|
+
if (iframe && recoverTarget) {
|
|
317
|
+
const encoded = encodeURIComponent(recoverTarget)
|
|
318
|
+
const pagePath =
|
|
319
|
+
store.currentPagePath === '/' ? '' : store.currentPagePath
|
|
320
|
+
iframe.src = `/api/proxy${pagePath}?x-dev-editor-target=${encoded}`
|
|
321
|
+
}
|
|
322
|
+
break
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
case 'TEXT_CHANGED': {
|
|
326
|
+
const { selectorPath: textSelector, originalText, newText } = msg.payload
|
|
327
|
+
const textProperty = '__text_content__'
|
|
328
|
+
|
|
329
|
+
// Check if a text change already exists for this element (dedup)
|
|
330
|
+
const existingText = store.styleChanges.find(
|
|
331
|
+
(c) =>
|
|
332
|
+
c.elementSelector === textSelector && c.property === textProperty,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// Push undo action
|
|
336
|
+
store.pushUndo({
|
|
337
|
+
elementSelector: textSelector,
|
|
338
|
+
property: textProperty,
|
|
339
|
+
beforeValue: existingText ? existingText.newValue : originalText,
|
|
340
|
+
afterValue: newText,
|
|
341
|
+
breakpoint: store.activeBreakpoint,
|
|
342
|
+
wasNewChange: !existingText,
|
|
343
|
+
changeScope: store.changeScope,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// Save element snapshot
|
|
347
|
+
const textEl = store.selectorPath === textSelector ? store : null
|
|
348
|
+
store.saveElementSnapshot({
|
|
349
|
+
selectorPath: textSelector,
|
|
350
|
+
tagName: textEl?.tagName || 'unknown',
|
|
351
|
+
className: textEl?.className ?? null,
|
|
352
|
+
elementId: textEl?.elementId ?? null,
|
|
353
|
+
attributes: textEl?.attributes || {},
|
|
354
|
+
innerText: newText,
|
|
355
|
+
computedStyles: textEl?.computedStyles
|
|
356
|
+
? { ...textEl.computedStyles }
|
|
357
|
+
: {},
|
|
358
|
+
pagePath: store.currentPagePath,
|
|
359
|
+
changeScope: store.changeScope,
|
|
360
|
+
sourceInfo: textEl?.sourceInfo ?? null,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// Add style change with sentinel property
|
|
364
|
+
store.addStyleChange({
|
|
365
|
+
id: generateId(),
|
|
366
|
+
elementSelector: textSelector,
|
|
367
|
+
property: textProperty,
|
|
368
|
+
originalValue: originalText,
|
|
369
|
+
newValue: newText,
|
|
370
|
+
breakpoint: store.activeBreakpoint,
|
|
371
|
+
timestamp: Date.now(),
|
|
372
|
+
changeScope: store.changeScope,
|
|
373
|
+
})
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case 'ELEMENT_INSERTED': {
|
|
378
|
+
const {
|
|
379
|
+
selectorPath: insSelector,
|
|
380
|
+
parentSelectorPath: insParent,
|
|
381
|
+
tagName: insTag,
|
|
382
|
+
insertionIndex: insIndex,
|
|
383
|
+
placeholderText: insText,
|
|
384
|
+
defaultStyles: insDefaultStyles,
|
|
385
|
+
} = msg.payload
|
|
386
|
+
const insProperty = '__element_inserted__'
|
|
387
|
+
|
|
388
|
+
// Push undo action
|
|
389
|
+
store.pushUndo({
|
|
390
|
+
elementSelector: insSelector,
|
|
391
|
+
property: insProperty,
|
|
392
|
+
beforeValue: '',
|
|
393
|
+
afterValue: 'inserted',
|
|
394
|
+
breakpoint: store.activeBreakpoint,
|
|
395
|
+
wasNewChange: true,
|
|
396
|
+
changeScope: store.changeScope,
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Save element snapshot with default styles as computedStyles
|
|
400
|
+
store.saveElementSnapshot({
|
|
401
|
+
selectorPath: insSelector,
|
|
402
|
+
tagName: insTag,
|
|
403
|
+
className: null,
|
|
404
|
+
elementId: null,
|
|
405
|
+
attributes: {},
|
|
406
|
+
innerText: insText || null,
|
|
407
|
+
computedStyles: insDefaultStyles ? { ...insDefaultStyles } : {},
|
|
408
|
+
pagePath: store.currentPagePath,
|
|
409
|
+
changeScope: store.changeScope,
|
|
410
|
+
sourceInfo: null,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// Track the insertion
|
|
414
|
+
store.addStyleChange({
|
|
415
|
+
id: generateId(),
|
|
416
|
+
elementSelector: insSelector,
|
|
417
|
+
property: insProperty,
|
|
418
|
+
originalValue: `parent:${insParent}|index:${insIndex}`,
|
|
419
|
+
newValue: 'inserted',
|
|
420
|
+
breakpoint: store.activeBreakpoint,
|
|
421
|
+
timestamp: Date.now(),
|
|
422
|
+
changeScope: store.changeScope,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
// Record each default style as an individual style change
|
|
426
|
+
if (insDefaultStyles) {
|
|
427
|
+
const insTimestamp = Date.now()
|
|
428
|
+
for (const [prop, val] of Object.entries(insDefaultStyles)) {
|
|
429
|
+
store.addStyleChange({
|
|
430
|
+
id: generateId(),
|
|
431
|
+
elementSelector: insSelector,
|
|
432
|
+
property: prop,
|
|
433
|
+
originalValue: '',
|
|
434
|
+
newValue: val,
|
|
435
|
+
breakpoint: store.activeBreakpoint,
|
|
436
|
+
timestamp: insTimestamp,
|
|
437
|
+
changeScope: store.changeScope,
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
break
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
case 'ELEMENT_MOVED': {
|
|
445
|
+
const {
|
|
446
|
+
selectorPath: mvOldSelector,
|
|
447
|
+
newSelectorPath: mvNewSelector,
|
|
448
|
+
oldParentSelectorPath: mvOldParent,
|
|
449
|
+
newParentSelectorPath: mvNewParent,
|
|
450
|
+
oldIndex: mvOldIndex,
|
|
451
|
+
newIndex: mvNewIndex,
|
|
452
|
+
} = msg.payload
|
|
453
|
+
const mvProperty = '__element_moved__'
|
|
454
|
+
|
|
455
|
+
// Push undo action
|
|
456
|
+
store.pushUndo({
|
|
457
|
+
elementSelector: mvNewSelector,
|
|
458
|
+
property: mvProperty,
|
|
459
|
+
beforeValue: `parent:${mvOldParent}|index:${mvOldIndex}|selector:${mvOldSelector}`,
|
|
460
|
+
afterValue: `parent:${mvNewParent}|index:${mvNewIndex}`,
|
|
461
|
+
breakpoint: store.activeBreakpoint,
|
|
462
|
+
wasNewChange: true,
|
|
463
|
+
changeScope: store.changeScope,
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Track the move
|
|
467
|
+
store.addStyleChange({
|
|
468
|
+
id: generateId(),
|
|
469
|
+
elementSelector: mvNewSelector,
|
|
470
|
+
property: mvProperty,
|
|
471
|
+
originalValue: `parent:${mvOldParent}|index:${mvOldIndex}|selector:${mvOldSelector}`,
|
|
472
|
+
newValue: `parent:${mvNewParent}|index:${mvNewIndex}`,
|
|
473
|
+
breakpoint: store.activeBreakpoint,
|
|
474
|
+
timestamp: Date.now(),
|
|
475
|
+
changeScope: store.changeScope,
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
// Update selected element path if it changed — re-select via inspector
|
|
479
|
+
if (
|
|
480
|
+
store.selectorPath === mvOldSelector &&
|
|
481
|
+
mvOldSelector !== mvNewSelector
|
|
482
|
+
) {
|
|
483
|
+
sendViaIframe({
|
|
484
|
+
type: 'SELECT_ELEMENT',
|
|
485
|
+
payload: { selectorPath: mvNewSelector },
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Request updated DOM tree
|
|
490
|
+
sendViaIframe({ type: 'REQUEST_DOM_TREE' })
|
|
491
|
+
break
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
case 'ELEMENT_DELETED': {
|
|
495
|
+
const {
|
|
496
|
+
selectorPath: delSelector,
|
|
497
|
+
originalDisplay,
|
|
498
|
+
tagName: delTag,
|
|
499
|
+
className: delClass,
|
|
500
|
+
elementId: delId,
|
|
501
|
+
innerText: delText,
|
|
502
|
+
attributes: delAttrs,
|
|
503
|
+
computedStyles: delStyles,
|
|
504
|
+
} = msg.payload
|
|
505
|
+
const delProperty = '__element_deleted__'
|
|
506
|
+
|
|
507
|
+
// Skip if already tracked (e.g., replayed on reconnect)
|
|
508
|
+
const existingDelete = store.styleChanges.find(
|
|
509
|
+
(c) => c.elementSelector === delSelector && c.property === delProperty,
|
|
510
|
+
)
|
|
511
|
+
if (existingDelete) break
|
|
512
|
+
|
|
513
|
+
// Push undo action
|
|
514
|
+
store.pushUndo({
|
|
515
|
+
elementSelector: delSelector,
|
|
516
|
+
property: delProperty,
|
|
517
|
+
beforeValue: originalDisplay,
|
|
518
|
+
afterValue: 'deleted',
|
|
519
|
+
breakpoint: store.activeBreakpoint,
|
|
520
|
+
wasNewChange: true,
|
|
521
|
+
changeScope: store.changeScope,
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// Save element snapshot
|
|
525
|
+
store.saveElementSnapshot({
|
|
526
|
+
selectorPath: delSelector,
|
|
527
|
+
tagName: delTag || 'unknown',
|
|
528
|
+
className: delClass ?? null,
|
|
529
|
+
elementId: delId ?? null,
|
|
530
|
+
attributes: delAttrs || {},
|
|
531
|
+
innerText: delText,
|
|
532
|
+
computedStyles: delStyles ? { ...delStyles } : {},
|
|
533
|
+
pagePath: store.currentPagePath,
|
|
534
|
+
changeScope: store.changeScope,
|
|
535
|
+
sourceInfo: null,
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// Track the deletion
|
|
539
|
+
store.addStyleChange({
|
|
540
|
+
id: generateId(),
|
|
541
|
+
elementSelector: delSelector,
|
|
542
|
+
property: delProperty,
|
|
543
|
+
originalValue: originalDisplay,
|
|
544
|
+
newValue: 'deleted',
|
|
545
|
+
breakpoint: store.activeBreakpoint,
|
|
546
|
+
timestamp: Date.now(),
|
|
547
|
+
changeScope: store.changeScope,
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
// Clear selection since the element is now hidden
|
|
551
|
+
store.clearSelection()
|
|
552
|
+
break
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function ensureListener() {
|
|
558
|
+
if (listenerRegistered) return
|
|
559
|
+
listenerRegistered = true
|
|
560
|
+
window.addEventListener('message', handleMessage)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function usePostMessage() {
|
|
564
|
+
const iframeRef = sharedIframeRef
|
|
565
|
+
|
|
566
|
+
// Register the singleton listener on first client-side mount
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
ensureListener()
|
|
569
|
+
}, [])
|
|
570
|
+
|
|
571
|
+
const sendToInspector = useCallback((message: EditorToInspectorMessage) => {
|
|
572
|
+
sendViaIframe(message)
|
|
573
|
+
}, [])
|
|
574
|
+
|
|
575
|
+
const sendHeartbeat = useCallback((): Promise<boolean> => {
|
|
576
|
+
return new Promise((resolve) => {
|
|
577
|
+
heartbeatResolve = () => resolve(true)
|
|
578
|
+
sendToInspector({ type: 'HEARTBEAT' })
|
|
579
|
+
setTimeout(() => {
|
|
580
|
+
if (heartbeatResolve) {
|
|
581
|
+
heartbeatResolve = null
|
|
582
|
+
resolve(false)
|
|
583
|
+
}
|
|
584
|
+
}, 3000)
|
|
585
|
+
})
|
|
586
|
+
}, [sendToInspector])
|
|
587
|
+
|
|
588
|
+
return { iframeRef, sendToInspector, sendHeartbeat }
|
|
589
|
+
}
|