@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,874 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import { useEditorStore } from '@/store'
|
|
11
|
+
import { usePostMessage } from '@/hooks/usePostMessage'
|
|
12
|
+
import { useChangeTracker } from '@/hooks/useChangeTracker'
|
|
13
|
+
import type { TreeNode } from '@/types/tree'
|
|
14
|
+
|
|
15
|
+
interface LayerNodeProps {
|
|
16
|
+
node: TreeNode
|
|
17
|
+
depth: number
|
|
18
|
+
searchQuery: string
|
|
19
|
+
changedSelectors?: Set<string>
|
|
20
|
+
deletedSelectors?: Set<string>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- Element categorization ---
|
|
24
|
+
|
|
25
|
+
const COMPONENT_TAGS = new Set([
|
|
26
|
+
'nav',
|
|
27
|
+
'header',
|
|
28
|
+
'footer',
|
|
29
|
+
'main',
|
|
30
|
+
'aside',
|
|
31
|
+
'article',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
const SECTION_TAGS = new Set(['section'])
|
|
35
|
+
|
|
36
|
+
const IMAGE_TAGS = new Set(['img', 'picture', 'svg', 'video', 'canvas'])
|
|
37
|
+
|
|
38
|
+
const TEXT_TAGS = new Set([
|
|
39
|
+
'p',
|
|
40
|
+
'h1',
|
|
41
|
+
'h2',
|
|
42
|
+
'h3',
|
|
43
|
+
'h4',
|
|
44
|
+
'h5',
|
|
45
|
+
'h6',
|
|
46
|
+
'span',
|
|
47
|
+
'label',
|
|
48
|
+
'blockquote',
|
|
49
|
+
'pre',
|
|
50
|
+
'code',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
const FORM_TAGS = new Set(['input', 'textarea', 'select', 'form', 'button'])
|
|
54
|
+
|
|
55
|
+
const LIST_TAGS = new Set(['ul', 'ol', 'li'])
|
|
56
|
+
|
|
57
|
+
const LINK_TAGS = new Set(['a'])
|
|
58
|
+
|
|
59
|
+
type NodeCategory =
|
|
60
|
+
| 'body'
|
|
61
|
+
| 'component'
|
|
62
|
+
| 'section'
|
|
63
|
+
| 'image'
|
|
64
|
+
| 'text'
|
|
65
|
+
| 'form'
|
|
66
|
+
| 'list'
|
|
67
|
+
| 'link'
|
|
68
|
+
| 'div'
|
|
69
|
+
|
|
70
|
+
function hasCPrefix(className: string | null | undefined): boolean {
|
|
71
|
+
if (!className) return false
|
|
72
|
+
return className
|
|
73
|
+
.split(/\s+/)
|
|
74
|
+
.some((cls) => cls.startsWith('c-') && cls.length > 2)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function categorize(tag: string, className?: string | null): NodeCategory {
|
|
78
|
+
if (tag === 'body') return 'body'
|
|
79
|
+
if (hasCPrefix(className)) return 'component'
|
|
80
|
+
if (COMPONENT_TAGS.has(tag)) return 'component'
|
|
81
|
+
if (SECTION_TAGS.has(tag)) return 'section'
|
|
82
|
+
if (IMAGE_TAGS.has(tag)) return 'image'
|
|
83
|
+
if (TEXT_TAGS.has(tag)) return 'text'
|
|
84
|
+
if (FORM_TAGS.has(tag)) return 'form'
|
|
85
|
+
if (LIST_TAGS.has(tag)) return 'list'
|
|
86
|
+
if (LINK_TAGS.has(tag)) return 'link'
|
|
87
|
+
return 'div'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Container tags that accept child elements ---
|
|
91
|
+
|
|
92
|
+
const CONTAINER_TAGS = new Set([
|
|
93
|
+
'div',
|
|
94
|
+
'section',
|
|
95
|
+
'main',
|
|
96
|
+
'header',
|
|
97
|
+
'footer',
|
|
98
|
+
'nav',
|
|
99
|
+
'aside',
|
|
100
|
+
'article',
|
|
101
|
+
'ul',
|
|
102
|
+
'ol',
|
|
103
|
+
'li',
|
|
104
|
+
'form',
|
|
105
|
+
'fieldset',
|
|
106
|
+
'details',
|
|
107
|
+
'summary',
|
|
108
|
+
'figure',
|
|
109
|
+
'figcaption',
|
|
110
|
+
'blockquote',
|
|
111
|
+
'table',
|
|
112
|
+
'thead',
|
|
113
|
+
'tbody',
|
|
114
|
+
'tfoot',
|
|
115
|
+
'tr',
|
|
116
|
+
'td',
|
|
117
|
+
'th',
|
|
118
|
+
'body',
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
function isContainerNode(node: TreeNode): boolean {
|
|
122
|
+
return CONTAINER_TAGS.has(node.tagName) || node.children.length > 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- SVG Icons (14×14) ---
|
|
126
|
+
|
|
127
|
+
function BodyIcon() {
|
|
128
|
+
return (
|
|
129
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
130
|
+
<rect
|
|
131
|
+
x="1.5"
|
|
132
|
+
y="1.5"
|
|
133
|
+
width="11"
|
|
134
|
+
height="11"
|
|
135
|
+
rx="2"
|
|
136
|
+
stroke="currentColor"
|
|
137
|
+
strokeWidth="1.2"
|
|
138
|
+
/>
|
|
139
|
+
<line
|
|
140
|
+
x1="1.5"
|
|
141
|
+
y1="4.5"
|
|
142
|
+
x2="12.5"
|
|
143
|
+
y2="4.5"
|
|
144
|
+
stroke="currentColor"
|
|
145
|
+
strokeWidth="1"
|
|
146
|
+
/>
|
|
147
|
+
</svg>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function DivIcon() {
|
|
152
|
+
return (
|
|
153
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
154
|
+
<rect
|
|
155
|
+
x="2"
|
|
156
|
+
y="2"
|
|
157
|
+
width="10"
|
|
158
|
+
height="10"
|
|
159
|
+
rx="1.5"
|
|
160
|
+
stroke="currentColor"
|
|
161
|
+
strokeWidth="1.2"
|
|
162
|
+
/>
|
|
163
|
+
</svg>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function SectionIcon() {
|
|
168
|
+
return (
|
|
169
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
170
|
+
<rect
|
|
171
|
+
x="1.5"
|
|
172
|
+
y="3"
|
|
173
|
+
width="11"
|
|
174
|
+
height="8"
|
|
175
|
+
rx="1.5"
|
|
176
|
+
stroke="currentColor"
|
|
177
|
+
strokeWidth="1.2"
|
|
178
|
+
/>
|
|
179
|
+
<line
|
|
180
|
+
x1="5"
|
|
181
|
+
y1="3"
|
|
182
|
+
x2="5"
|
|
183
|
+
y2="11"
|
|
184
|
+
stroke="currentColor"
|
|
185
|
+
strokeWidth="0.8"
|
|
186
|
+
opacity="0.5"
|
|
187
|
+
/>
|
|
188
|
+
<line
|
|
189
|
+
x1="9"
|
|
190
|
+
y1="3"
|
|
191
|
+
x2="9"
|
|
192
|
+
y2="11"
|
|
193
|
+
stroke="currentColor"
|
|
194
|
+
strokeWidth="0.8"
|
|
195
|
+
opacity="0.5"
|
|
196
|
+
/>
|
|
197
|
+
</svg>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function ComponentIcon() {
|
|
202
|
+
return (
|
|
203
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
204
|
+
<path
|
|
205
|
+
d="M7 1.5L12.5 4.5V9.5L7 12.5L1.5 9.5V4.5L7 1.5Z"
|
|
206
|
+
stroke="currentColor"
|
|
207
|
+
strokeWidth="1.2"
|
|
208
|
+
strokeLinejoin="round"
|
|
209
|
+
/>
|
|
210
|
+
</svg>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ImageIcon() {
|
|
215
|
+
return (
|
|
216
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
217
|
+
<rect
|
|
218
|
+
x="1.5"
|
|
219
|
+
y="2.5"
|
|
220
|
+
width="11"
|
|
221
|
+
height="9"
|
|
222
|
+
rx="1.5"
|
|
223
|
+
stroke="currentColor"
|
|
224
|
+
strokeWidth="1.2"
|
|
225
|
+
/>
|
|
226
|
+
<circle cx="4.5" cy="5.5" r="1.2" stroke="currentColor" strokeWidth="1" />
|
|
227
|
+
<path
|
|
228
|
+
d="M1.5 9.5L4.5 7L7 9L9.5 6.5L12.5 9.5"
|
|
229
|
+
stroke="currentColor"
|
|
230
|
+
strokeWidth="1"
|
|
231
|
+
strokeLinejoin="round"
|
|
232
|
+
/>
|
|
233
|
+
</svg>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function TextIcon() {
|
|
238
|
+
return (
|
|
239
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
240
|
+
<path
|
|
241
|
+
d="M3 3.5H11"
|
|
242
|
+
stroke="currentColor"
|
|
243
|
+
strokeWidth="1.2"
|
|
244
|
+
strokeLinecap="round"
|
|
245
|
+
/>
|
|
246
|
+
<path
|
|
247
|
+
d="M7 3.5V11"
|
|
248
|
+
stroke="currentColor"
|
|
249
|
+
strokeWidth="1.2"
|
|
250
|
+
strokeLinecap="round"
|
|
251
|
+
/>
|
|
252
|
+
<path
|
|
253
|
+
d="M5 11H9"
|
|
254
|
+
stroke="currentColor"
|
|
255
|
+
strokeWidth="1.2"
|
|
256
|
+
strokeLinecap="round"
|
|
257
|
+
/>
|
|
258
|
+
</svg>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function FormIcon() {
|
|
263
|
+
return (
|
|
264
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
265
|
+
<rect
|
|
266
|
+
x="1.5"
|
|
267
|
+
y="4"
|
|
268
|
+
width="11"
|
|
269
|
+
height="6"
|
|
270
|
+
rx="1.5"
|
|
271
|
+
stroke="currentColor"
|
|
272
|
+
strokeWidth="1.2"
|
|
273
|
+
/>
|
|
274
|
+
<line
|
|
275
|
+
x1="3.5"
|
|
276
|
+
y1="7"
|
|
277
|
+
x2="7"
|
|
278
|
+
y2="7"
|
|
279
|
+
stroke="currentColor"
|
|
280
|
+
strokeWidth="1"
|
|
281
|
+
strokeLinecap="round"
|
|
282
|
+
/>
|
|
283
|
+
</svg>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ListIcon() {
|
|
288
|
+
return (
|
|
289
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
290
|
+
<circle cx="3" cy="4" r="1" fill="currentColor" />
|
|
291
|
+
<circle cx="3" cy="7" r="1" fill="currentColor" />
|
|
292
|
+
<circle cx="3" cy="10" r="1" fill="currentColor" />
|
|
293
|
+
<line
|
|
294
|
+
x1="5.5"
|
|
295
|
+
y1="4"
|
|
296
|
+
x2="11.5"
|
|
297
|
+
y2="4"
|
|
298
|
+
stroke="currentColor"
|
|
299
|
+
strokeWidth="1.2"
|
|
300
|
+
strokeLinecap="round"
|
|
301
|
+
/>
|
|
302
|
+
<line
|
|
303
|
+
x1="5.5"
|
|
304
|
+
y1="7"
|
|
305
|
+
x2="11.5"
|
|
306
|
+
y2="7"
|
|
307
|
+
stroke="currentColor"
|
|
308
|
+
strokeWidth="1.2"
|
|
309
|
+
strokeLinecap="round"
|
|
310
|
+
/>
|
|
311
|
+
<line
|
|
312
|
+
x1="5.5"
|
|
313
|
+
y1="10"
|
|
314
|
+
x2="11.5"
|
|
315
|
+
y2="10"
|
|
316
|
+
stroke="currentColor"
|
|
317
|
+
strokeWidth="1.2"
|
|
318
|
+
strokeLinecap="round"
|
|
319
|
+
/>
|
|
320
|
+
</svg>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function LinkIcon() {
|
|
325
|
+
return (
|
|
326
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
327
|
+
<path
|
|
328
|
+
d="M6 8L8 6"
|
|
329
|
+
stroke="currentColor"
|
|
330
|
+
strokeWidth="1.2"
|
|
331
|
+
strokeLinecap="round"
|
|
332
|
+
/>
|
|
333
|
+
<path
|
|
334
|
+
d="M8.5 5.5L9.5 4.5C10.3 3.7 11.5 3.7 12.3 4.5C13.1 5.3 13.1 6.5 12.3 7.3L11 8.5"
|
|
335
|
+
stroke="currentColor"
|
|
336
|
+
strokeWidth="1.2"
|
|
337
|
+
strokeLinecap="round"
|
|
338
|
+
/>
|
|
339
|
+
<path
|
|
340
|
+
d="M5.5 8.5L4.5 9.5C3.7 10.3 2.5 10.3 1.7 9.5C0.9 8.7 0.9 7.5 1.7 6.7L3 5.5"
|
|
341
|
+
stroke="currentColor"
|
|
342
|
+
strokeWidth="1.2"
|
|
343
|
+
strokeLinecap="round"
|
|
344
|
+
/>
|
|
345
|
+
</svg>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const ICON_MAP: Record<NodeCategory, () => ReactElement> = {
|
|
350
|
+
body: BodyIcon,
|
|
351
|
+
div: DivIcon,
|
|
352
|
+
section: SectionIcon,
|
|
353
|
+
component: ComponentIcon,
|
|
354
|
+
image: ImageIcon,
|
|
355
|
+
text: TextIcon,
|
|
356
|
+
form: FormIcon,
|
|
357
|
+
list: ListIcon,
|
|
358
|
+
link: LinkIcon,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Green categories — semantic/component elements get green tint
|
|
362
|
+
const GREEN_CATEGORIES = new Set<NodeCategory>(['component', 'section'])
|
|
363
|
+
|
|
364
|
+
// --- Display label ---
|
|
365
|
+
|
|
366
|
+
function getCPrefixClass(className: string | null | undefined): string | null {
|
|
367
|
+
if (!className) return null
|
|
368
|
+
const match = className
|
|
369
|
+
.split(/\s+/)
|
|
370
|
+
.find((cls) => cls.startsWith('c-') && cls.length > 2)
|
|
371
|
+
return match || null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function getDisplayLabel(node: TreeNode): string {
|
|
375
|
+
if (node.tagName === 'body') return 'Body'
|
|
376
|
+
// Prefer c- prefixed class (component identifier)
|
|
377
|
+
const cClass = getCPrefixClass(node.className)
|
|
378
|
+
if (cClass) return cClass
|
|
379
|
+
// Then id
|
|
380
|
+
if (node.elementId) return node.elementId
|
|
381
|
+
// Then first meaningful class
|
|
382
|
+
if (node.className) {
|
|
383
|
+
const first = node.className.split(' ')[0]
|
|
384
|
+
if (first) return first
|
|
385
|
+
}
|
|
386
|
+
return node.tagName
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- Search matching ---
|
|
390
|
+
|
|
391
|
+
function matchesSearch(node: TreeNode, query: string): boolean {
|
|
392
|
+
if (!query) return true
|
|
393
|
+
const q = query.toLowerCase()
|
|
394
|
+
return (
|
|
395
|
+
node.tagName.toLowerCase().includes(q) ||
|
|
396
|
+
(node.className?.toLowerCase().includes(q) ?? false) ||
|
|
397
|
+
(node.elementId?.toLowerCase().includes(q) ?? false)
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- Drag-and-drop helpers ---
|
|
402
|
+
|
|
403
|
+
const DRAG_DATA_TYPE = 'application/x-dev-editor-layer-move'
|
|
404
|
+
|
|
405
|
+
type DropPosition = 'before' | 'inside' | 'after'
|
|
406
|
+
|
|
407
|
+
function isDescendant(parentId: string, childId: string): boolean {
|
|
408
|
+
return childId.startsWith(parentId + ' > ')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getDropPosition(
|
|
412
|
+
e: React.DragEvent,
|
|
413
|
+
rowElement: HTMLElement,
|
|
414
|
+
): DropPosition {
|
|
415
|
+
const rect = rowElement.getBoundingClientRect()
|
|
416
|
+
const y = e.clientY - rect.top
|
|
417
|
+
const third = rect.height / 3
|
|
418
|
+
if (y < third) return 'before'
|
|
419
|
+
if (y > third * 2) return 'after'
|
|
420
|
+
return 'inside'
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// --- Component ---
|
|
424
|
+
|
|
425
|
+
export function LayerNode({
|
|
426
|
+
node,
|
|
427
|
+
depth,
|
|
428
|
+
searchQuery,
|
|
429
|
+
changedSelectors,
|
|
430
|
+
deletedSelectors,
|
|
431
|
+
}: LayerNodeProps) {
|
|
432
|
+
const selectorPath = useEditorStore((s) => s.selectorPath)
|
|
433
|
+
const highlightedNodeId = useEditorStore((s) => s.highlightedNodeId)
|
|
434
|
+
const toggleNodeExpanded = useEditorStore((s) => s.toggleNodeExpanded)
|
|
435
|
+
const styleChanges = useEditorStore((s) => s.styleChanges)
|
|
436
|
+
const { sendToInspector } = usePostMessage()
|
|
437
|
+
const { revertChange } = useChangeTracker()
|
|
438
|
+
|
|
439
|
+
const isDeleted = deletedSelectors?.has(node.id) ?? false
|
|
440
|
+
|
|
441
|
+
const rowRef = useRef<HTMLDivElement>(null)
|
|
442
|
+
const [dropIndicator, setDropIndicator] = useState<DropPosition | null>(null)
|
|
443
|
+
const [isDragSource, setIsDragSource] = useState(false)
|
|
444
|
+
|
|
445
|
+
const expandedNodeIds = useEditorStore((s) => s.expandedNodeIds)
|
|
446
|
+
|
|
447
|
+
const isSelected = selectorPath === node.id
|
|
448
|
+
const isHighlighted = highlightedNodeId === node.id
|
|
449
|
+
const isExpanded = expandedNodeIds.has(node.id)
|
|
450
|
+
const hasChildren = node.children.length > 0
|
|
451
|
+
const isBody = node.tagName === 'body'
|
|
452
|
+
|
|
453
|
+
const category = categorize(node.tagName, node.className)
|
|
454
|
+
const isGreen = GREEN_CATEGORIES.has(category)
|
|
455
|
+
const IconComponent = ICON_MAP[category]
|
|
456
|
+
const label = getDisplayLabel(node)
|
|
457
|
+
|
|
458
|
+
// Scroll selected layer into view (expansion is handled by usePostMessage)
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
if (isSelected && rowRef.current) {
|
|
461
|
+
rowRef.current.scrollIntoView({ block: 'center', behavior: 'instant' })
|
|
462
|
+
}
|
|
463
|
+
}, [isSelected])
|
|
464
|
+
|
|
465
|
+
const handleClick = useCallback(() => {
|
|
466
|
+
sendToInspector({
|
|
467
|
+
type: 'SELECT_ELEMENT',
|
|
468
|
+
payload: { selectorPath: node.id },
|
|
469
|
+
})
|
|
470
|
+
}, [node.id, sendToInspector])
|
|
471
|
+
|
|
472
|
+
const handleToggle = useCallback(
|
|
473
|
+
(e: React.MouseEvent) => {
|
|
474
|
+
e.stopPropagation()
|
|
475
|
+
toggleNodeExpanded(node.id)
|
|
476
|
+
},
|
|
477
|
+
[node.id, toggleNodeExpanded],
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
const handleRevertDelete = useCallback(
|
|
481
|
+
(e: React.MouseEvent) => {
|
|
482
|
+
e.stopPropagation()
|
|
483
|
+
const deleteChange = styleChanges.find(
|
|
484
|
+
(c) =>
|
|
485
|
+
c.elementSelector === node.id && c.property === '__element_deleted__',
|
|
486
|
+
)
|
|
487
|
+
if (deleteChange) {
|
|
488
|
+
revertChange(
|
|
489
|
+
deleteChange.id,
|
|
490
|
+
deleteChange.elementSelector,
|
|
491
|
+
deleteChange.property,
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
[node.id, styleChanges, revertChange],
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
const handleDelete = useCallback(
|
|
499
|
+
(e: React.MouseEvent) => {
|
|
500
|
+
e.stopPropagation()
|
|
501
|
+
sendToInspector({
|
|
502
|
+
type: 'DELETE_ELEMENT',
|
|
503
|
+
payload: { selectorPath: node.id },
|
|
504
|
+
})
|
|
505
|
+
},
|
|
506
|
+
[node.id, sendToInspector],
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
// --- Drag handlers ---
|
|
510
|
+
|
|
511
|
+
const handleDragStart = useCallback(
|
|
512
|
+
(e: React.DragEvent) => {
|
|
513
|
+
if (isBody) {
|
|
514
|
+
e.preventDefault()
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
e.dataTransfer.setData(
|
|
518
|
+
DRAG_DATA_TYPE,
|
|
519
|
+
JSON.stringify({
|
|
520
|
+
selectorPath: node.id,
|
|
521
|
+
tagName: node.tagName,
|
|
522
|
+
}),
|
|
523
|
+
)
|
|
524
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
525
|
+
setIsDragSource(true)
|
|
526
|
+
|
|
527
|
+
// Use a minimal drag image
|
|
528
|
+
if (rowRef.current) {
|
|
529
|
+
e.dataTransfer.setDragImage(rowRef.current, 10, 14)
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
[node.id, node.tagName, isBody],
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
const handleDragEnd = useCallback(() => {
|
|
536
|
+
setIsDragSource(false)
|
|
537
|
+
}, [])
|
|
538
|
+
|
|
539
|
+
const handleDragOver = useCallback(
|
|
540
|
+
(e: React.DragEvent) => {
|
|
541
|
+
if (!e.dataTransfer.types.includes(DRAG_DATA_TYPE)) return
|
|
542
|
+
e.preventDefault()
|
|
543
|
+
e.stopPropagation()
|
|
544
|
+
e.dataTransfer.dropEffect = 'move'
|
|
545
|
+
|
|
546
|
+
if (!rowRef.current) return
|
|
547
|
+
const pos = getDropPosition(e, rowRef.current)
|
|
548
|
+
|
|
549
|
+
// If this node is a container, show 'inside' for the middle zone
|
|
550
|
+
// Otherwise, only show before/after
|
|
551
|
+
if (pos === 'inside' && !isContainerNode(node)) {
|
|
552
|
+
setDropIndicator(null)
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
setDropIndicator(pos)
|
|
556
|
+
},
|
|
557
|
+
[node],
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
561
|
+
// Only clear if leaving the actual row (not entering a child)
|
|
562
|
+
if (rowRef.current && !rowRef.current.contains(e.relatedTarget as Node)) {
|
|
563
|
+
setDropIndicator(null)
|
|
564
|
+
}
|
|
565
|
+
}, [])
|
|
566
|
+
|
|
567
|
+
const handleDrop = useCallback(
|
|
568
|
+
(e: React.DragEvent) => {
|
|
569
|
+
e.preventDefault()
|
|
570
|
+
e.stopPropagation()
|
|
571
|
+
setDropIndicator(null)
|
|
572
|
+
|
|
573
|
+
const raw = e.dataTransfer.getData(DRAG_DATA_TYPE)
|
|
574
|
+
if (!raw) return
|
|
575
|
+
|
|
576
|
+
let dragData: { selectorPath: string; tagName: string }
|
|
577
|
+
try {
|
|
578
|
+
dragData = JSON.parse(raw)
|
|
579
|
+
} catch {
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Can't drop on itself
|
|
584
|
+
if (dragData.selectorPath === node.id) return
|
|
585
|
+
// Can't drop inside own descendant
|
|
586
|
+
if (isDescendant(dragData.selectorPath, node.id)) return
|
|
587
|
+
|
|
588
|
+
if (!rowRef.current) return
|
|
589
|
+
const pos = getDropPosition(e, rowRef.current)
|
|
590
|
+
|
|
591
|
+
// Determine the target parent and insertion index
|
|
592
|
+
// The node.id is a CSS selector path like "body > div > section"
|
|
593
|
+
// The parent path is everything before the last " > " segment
|
|
594
|
+
const parentParts = node.id.split(' > ')
|
|
595
|
+
|
|
596
|
+
if (pos === 'inside' && isContainerNode(node)) {
|
|
597
|
+
// Drop inside this node as last child
|
|
598
|
+
sendToInspector({
|
|
599
|
+
type: 'MOVE_ELEMENT',
|
|
600
|
+
payload: {
|
|
601
|
+
selectorPath: dragData.selectorPath,
|
|
602
|
+
newParentSelectorPath: node.id,
|
|
603
|
+
newIndex: node.children.length,
|
|
604
|
+
},
|
|
605
|
+
})
|
|
606
|
+
} else if (pos === 'before') {
|
|
607
|
+
// Drop before this node — same parent, at this node's index
|
|
608
|
+
if (parentParts.length < 2) return // Can't drop before body
|
|
609
|
+
const parentId = parentParts.slice(0, -1).join(' > ')
|
|
610
|
+
// Find sibling index of this node in its parent
|
|
611
|
+
const parentNode = findNodeInTree(
|
|
612
|
+
useEditorStore.getState().rootNode,
|
|
613
|
+
parentId,
|
|
614
|
+
)
|
|
615
|
+
if (!parentNode) return
|
|
616
|
+
const siblingIndex = parentNode.children.findIndex(
|
|
617
|
+
(c) => c.id === node.id,
|
|
618
|
+
)
|
|
619
|
+
sendToInspector({
|
|
620
|
+
type: 'MOVE_ELEMENT',
|
|
621
|
+
payload: {
|
|
622
|
+
selectorPath: dragData.selectorPath,
|
|
623
|
+
newParentSelectorPath: parentId,
|
|
624
|
+
newIndex: Math.max(0, siblingIndex),
|
|
625
|
+
},
|
|
626
|
+
})
|
|
627
|
+
} else if (pos === 'after') {
|
|
628
|
+
if (parentParts.length < 2) return
|
|
629
|
+
const parentId = parentParts.slice(0, -1).join(' > ')
|
|
630
|
+
const parentNode = findNodeInTree(
|
|
631
|
+
useEditorStore.getState().rootNode,
|
|
632
|
+
parentId,
|
|
633
|
+
)
|
|
634
|
+
if (!parentNode) return
|
|
635
|
+
const siblingIndex = parentNode.children.findIndex(
|
|
636
|
+
(c) => c.id === node.id,
|
|
637
|
+
)
|
|
638
|
+
sendToInspector({
|
|
639
|
+
type: 'MOVE_ELEMENT',
|
|
640
|
+
payload: {
|
|
641
|
+
selectorPath: dragData.selectorPath,
|
|
642
|
+
newParentSelectorPath: parentId,
|
|
643
|
+
newIndex: siblingIndex + 1,
|
|
644
|
+
},
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
[node, sendToInspector],
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
if (searchQuery && !matchesSearch(node, searchQuery)) {
|
|
652
|
+
const matchingChildren = node.children.filter((c) =>
|
|
653
|
+
matchesSearch(c, searchQuery),
|
|
654
|
+
)
|
|
655
|
+
if (matchingChildren.length === 0) return null
|
|
656
|
+
return (
|
|
657
|
+
<>
|
|
658
|
+
{matchingChildren.map((child) => (
|
|
659
|
+
<LayerNode
|
|
660
|
+
key={child.id}
|
|
661
|
+
node={child}
|
|
662
|
+
depth={depth}
|
|
663
|
+
searchQuery={searchQuery}
|
|
664
|
+
changedSelectors={changedSelectors}
|
|
665
|
+
deletedSelectors={deletedSelectors}
|
|
666
|
+
/>
|
|
667
|
+
))}
|
|
668
|
+
</>
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Resolve colors
|
|
673
|
+
const iconColor = isDeleted
|
|
674
|
+
? 'var(--error)'
|
|
675
|
+
: isSelected
|
|
676
|
+
? 'var(--accent)'
|
|
677
|
+
: isGreen
|
|
678
|
+
? '#4ade80'
|
|
679
|
+
: 'var(--text-muted)'
|
|
680
|
+
|
|
681
|
+
const labelColor = isDeleted
|
|
682
|
+
? 'var(--error)'
|
|
683
|
+
: isSelected
|
|
684
|
+
? 'var(--accent)'
|
|
685
|
+
: isGreen
|
|
686
|
+
? '#4ade80'
|
|
687
|
+
: 'var(--text-primary)'
|
|
688
|
+
|
|
689
|
+
// Drop indicator styles
|
|
690
|
+
const dropBorderStyle: React.CSSProperties = {}
|
|
691
|
+
if (dropIndicator === 'before') {
|
|
692
|
+
dropBorderStyle.borderTop = '2px solid #4a9eff'
|
|
693
|
+
} else if (dropIndicator === 'after') {
|
|
694
|
+
dropBorderStyle.borderBottom = '2px solid #4a9eff'
|
|
695
|
+
} else if (dropIndicator === 'inside') {
|
|
696
|
+
dropBorderStyle.background = 'rgba(74, 158, 255, 0.15)'
|
|
697
|
+
dropBorderStyle.outline = '1px dashed #4a9eff'
|
|
698
|
+
dropBorderStyle.outlineOffset = '-1px'
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return (
|
|
702
|
+
<div className="relative" style={{ opacity: isDragSource ? 0.4 : 1 }}>
|
|
703
|
+
{/* Indent guide lines */}
|
|
704
|
+
{depth > 0 && (
|
|
705
|
+
<div
|
|
706
|
+
className="absolute top-0 bottom-0"
|
|
707
|
+
style={{
|
|
708
|
+
left: depth * 16 + 2,
|
|
709
|
+
width: 1,
|
|
710
|
+
background: 'var(--border)',
|
|
711
|
+
opacity: 0.5,
|
|
712
|
+
}}
|
|
713
|
+
/>
|
|
714
|
+
)}
|
|
715
|
+
|
|
716
|
+
<div
|
|
717
|
+
ref={rowRef}
|
|
718
|
+
className="flex items-center cursor-pointer group"
|
|
719
|
+
draggable={!isBody}
|
|
720
|
+
onDragStart={handleDragStart}
|
|
721
|
+
onDragEnd={handleDragEnd}
|
|
722
|
+
onDragOver={handleDragOver}
|
|
723
|
+
onDragLeave={handleDragLeave}
|
|
724
|
+
onDrop={handleDrop}
|
|
725
|
+
style={{
|
|
726
|
+
paddingLeft: depth * 16 + 4,
|
|
727
|
+
height: 28,
|
|
728
|
+
background: isSelected
|
|
729
|
+
? 'rgba(74, 158, 255, 0.12)'
|
|
730
|
+
: isHighlighted
|
|
731
|
+
? 'rgba(255, 255, 255, 0.04)'
|
|
732
|
+
: 'transparent',
|
|
733
|
+
...dropBorderStyle,
|
|
734
|
+
}}
|
|
735
|
+
onClick={handleClick}
|
|
736
|
+
>
|
|
737
|
+
{/* Expand arrow */}
|
|
738
|
+
{hasChildren ? (
|
|
739
|
+
<button
|
|
740
|
+
onClick={handleToggle}
|
|
741
|
+
className="w-5 h-5 flex items-center justify-center flex-shrink-0 rounded hover:bg-white/10 transition-colors"
|
|
742
|
+
style={{ color: 'var(--text-muted)' }}
|
|
743
|
+
>
|
|
744
|
+
<svg
|
|
745
|
+
width="8"
|
|
746
|
+
height="8"
|
|
747
|
+
viewBox="0 0 8 8"
|
|
748
|
+
fill="currentColor"
|
|
749
|
+
style={{
|
|
750
|
+
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
751
|
+
transition: 'transform 0.15s ease',
|
|
752
|
+
}}
|
|
753
|
+
>
|
|
754
|
+
<path d="M2 1L6 4L2 7Z" />
|
|
755
|
+
</svg>
|
|
756
|
+
</button>
|
|
757
|
+
) : (
|
|
758
|
+
<span className="w-5 flex-shrink-0" />
|
|
759
|
+
)}
|
|
760
|
+
|
|
761
|
+
{/* Type icon */}
|
|
762
|
+
<span
|
|
763
|
+
className="flex-shrink-0 flex items-center justify-center w-5 h-5"
|
|
764
|
+
style={{ color: iconColor }}
|
|
765
|
+
>
|
|
766
|
+
<IconComponent />
|
|
767
|
+
</span>
|
|
768
|
+
|
|
769
|
+
{/* Label */}
|
|
770
|
+
<span
|
|
771
|
+
className="text-[11px] ml-1 leading-none whitespace-nowrap"
|
|
772
|
+
style={{
|
|
773
|
+
color: labelColor,
|
|
774
|
+
textDecoration: isDeleted ? 'line-through' : 'none',
|
|
775
|
+
opacity: isDeleted ? 0.7 : 1,
|
|
776
|
+
}}
|
|
777
|
+
>
|
|
778
|
+
{label}
|
|
779
|
+
</span>
|
|
780
|
+
|
|
781
|
+
{/* Tag badge for non-div elements when showing class name */}
|
|
782
|
+
{node.className &&
|
|
783
|
+
node.tagName !== 'div' &&
|
|
784
|
+
node.tagName !== 'body' &&
|
|
785
|
+
label !== node.tagName && (
|
|
786
|
+
<span
|
|
787
|
+
className="text-[9px] ml-1.5 flex-shrink-0 opacity-50"
|
|
788
|
+
style={{
|
|
789
|
+
color: isDeleted ? 'var(--error)' : 'var(--text-muted)',
|
|
790
|
+
}}
|
|
791
|
+
>
|
|
792
|
+
{node.tagName}
|
|
793
|
+
</span>
|
|
794
|
+
)}
|
|
795
|
+
|
|
796
|
+
{/* Revert button for deleted elements */}
|
|
797
|
+
{isDeleted && (
|
|
798
|
+
<button
|
|
799
|
+
onClick={handleRevertDelete}
|
|
800
|
+
className="ml-auto mr-1 flex items-center justify-center w-5 h-5 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white/10"
|
|
801
|
+
style={{ color: 'var(--text-muted)' }}
|
|
802
|
+
title="Restore element"
|
|
803
|
+
>
|
|
804
|
+
<svg
|
|
805
|
+
width="12"
|
|
806
|
+
height="12"
|
|
807
|
+
viewBox="0 0 24 24"
|
|
808
|
+
fill="none"
|
|
809
|
+
stroke="currentColor"
|
|
810
|
+
strokeWidth="2"
|
|
811
|
+
strokeLinecap="round"
|
|
812
|
+
strokeLinejoin="round"
|
|
813
|
+
>
|
|
814
|
+
<polyline points="1 4 1 10 7 10" />
|
|
815
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
|
816
|
+
</svg>
|
|
817
|
+
</button>
|
|
818
|
+
)}
|
|
819
|
+
|
|
820
|
+
{/* Delete button on hover (non-body, non-deleted) */}
|
|
821
|
+
{!isBody && !isDeleted && (
|
|
822
|
+
<button
|
|
823
|
+
onClick={handleDelete}
|
|
824
|
+
className="delete-layer-btn ml-auto mr-1 flex items-center justify-center w-5 h-5 rounded opacity-0 group-hover:opacity-100 transition-all hover:!opacity-100"
|
|
825
|
+
style={{ color: '#f87171' }}
|
|
826
|
+
title="Delete element"
|
|
827
|
+
>
|
|
828
|
+
<svg
|
|
829
|
+
width="12"
|
|
830
|
+
height="12"
|
|
831
|
+
viewBox="0 0 24 24"
|
|
832
|
+
fill="none"
|
|
833
|
+
stroke="currentColor"
|
|
834
|
+
strokeWidth="2"
|
|
835
|
+
strokeLinecap="round"
|
|
836
|
+
strokeLinejoin="round"
|
|
837
|
+
>
|
|
838
|
+
<polyline points="3 6 5 6 21 6" />
|
|
839
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
840
|
+
</svg>
|
|
841
|
+
</button>
|
|
842
|
+
)}
|
|
843
|
+
</div>
|
|
844
|
+
|
|
845
|
+
{/* Children */}
|
|
846
|
+
{hasChildren && isExpanded && (
|
|
847
|
+
<div>
|
|
848
|
+
{node.children.map((child) => (
|
|
849
|
+
<LayerNode
|
|
850
|
+
key={child.id}
|
|
851
|
+
node={child}
|
|
852
|
+
depth={depth + 1}
|
|
853
|
+
searchQuery={searchQuery}
|
|
854
|
+
changedSelectors={changedSelectors}
|
|
855
|
+
deletedSelectors={deletedSelectors}
|
|
856
|
+
/>
|
|
857
|
+
))}
|
|
858
|
+
</div>
|
|
859
|
+
)}
|
|
860
|
+
</div>
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// --- Tree lookup utility ---
|
|
865
|
+
|
|
866
|
+
function findNodeInTree(root: TreeNode | null, id: string): TreeNode | null {
|
|
867
|
+
if (!root) return null
|
|
868
|
+
if (root.id === id) return root
|
|
869
|
+
for (const child of root.children) {
|
|
870
|
+
const found = findNodeInTree(child, id)
|
|
871
|
+
if (found) return found
|
|
872
|
+
}
|
|
873
|
+
return null
|
|
874
|
+
}
|