@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.
Files changed (158) hide show
  1. package/LICENSE +178 -0
  2. package/NOTICE +4 -0
  3. package/README.md +180 -0
  4. package/bin/paint.js +266 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +19 -0
  7. package/package.json +81 -0
  8. package/postcss.config.mjs +8 -0
  9. package/public/dev-editor-inspector.js +1872 -0
  10. package/src/app/api/claude/analyze/route.ts +319 -0
  11. package/src/app/api/claude/apply/route.ts +185 -0
  12. package/src/app/api/claude/pick-folder/route.ts +64 -0
  13. package/src/app/api/claude/scan/route.ts +221 -0
  14. package/src/app/api/claude/status/route.ts +55 -0
  15. package/src/app/api/project/scan/route.ts +634 -0
  16. package/src/app/api/project-scan/css-variables/route.ts +238 -0
  17. package/src/app/api/project-scan/route.ts +40 -0
  18. package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
  19. package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
  20. package/src/app/docs/DocsClient.tsx +322 -0
  21. package/src/app/docs/layout.tsx +7 -0
  22. package/src/app/docs/page.tsx +855 -0
  23. package/src/app/globals.css +176 -0
  24. package/src/app/layout.tsx +19 -0
  25. package/src/app/page.tsx +46 -0
  26. package/src/bridge/api-handlers.ts +885 -0
  27. package/src/bridge/proxy-handler.ts +329 -0
  28. package/src/bridge/server.ts +113 -0
  29. package/src/components/BreakpointTabs.tsx +72 -0
  30. package/src/components/ChangeSummaryModal.tsx +267 -0
  31. package/src/components/ConnectModal.tsx +994 -0
  32. package/src/components/Editor.tsx +90 -0
  33. package/src/components/PageSelector.tsx +208 -0
  34. package/src/components/PreviewFrame.tsx +299 -0
  35. package/src/components/ProjectFolderBanner.tsx +91 -0
  36. package/src/components/ResponsiveToolbar.tsx +222 -0
  37. package/src/components/TargetSelector.tsx +243 -0
  38. package/src/components/TopBar.tsx +315 -0
  39. package/src/components/common/CollapsibleSection.tsx +36 -0
  40. package/src/components/common/ColorPicker.tsx +920 -0
  41. package/src/components/common/EditablePre.tsx +136 -0
  42. package/src/components/common/ErrorBoundary.tsx +65 -0
  43. package/src/components/common/ResizablePanel.tsx +83 -0
  44. package/src/components/common/ScanAnimation.tsx +76 -0
  45. package/src/components/common/ToastContainer.tsx +97 -0
  46. package/src/components/common/UnitInput.tsx +77 -0
  47. package/src/components/common/VariableColorPicker.tsx +622 -0
  48. package/src/components/left-panel/AddElementPanel.tsx +237 -0
  49. package/src/components/left-panel/ComponentsPanel.tsx +609 -0
  50. package/src/components/left-panel/IconSidebar.tsx +99 -0
  51. package/src/components/left-panel/LayerNode.tsx +874 -0
  52. package/src/components/left-panel/LayerSearch.tsx +23 -0
  53. package/src/components/left-panel/LayersPanel.tsx +52 -0
  54. package/src/components/left-panel/LeftPanel.tsx +122 -0
  55. package/src/components/left-panel/PagesPanel.tsx +114 -0
  56. package/src/components/left-panel/icons.tsx +162 -0
  57. package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
  58. package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
  59. package/src/components/right-panel/ElementLogBox.tsx +248 -0
  60. package/src/components/right-panel/PanelTabs.tsx +83 -0
  61. package/src/components/right-panel/RightPanel.tsx +41 -0
  62. package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
  63. package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
  64. package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
  65. package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
  66. package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
  67. package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
  68. package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
  69. package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
  70. package/src/components/right-panel/claude/DiffCard.tsx +130 -0
  71. package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
  72. package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
  73. package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
  74. package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
  75. package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
  76. package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
  77. package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
  78. package/src/components/right-panel/design/BorderSection.tsx +161 -0
  79. package/src/components/right-panel/design/CSSRawView.tsx +412 -0
  80. package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
  81. package/src/components/right-panel/design/DesignPanel.tsx +275 -0
  82. package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
  83. package/src/components/right-panel/design/GradientEditor.tsx +726 -0
  84. package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
  85. package/src/components/right-panel/design/PositionSection.tsx +865 -0
  86. package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
  87. package/src/components/right-panel/design/SVGSection.tsx +361 -0
  88. package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
  89. package/src/components/right-panel/design/SizeSection.tsx +183 -0
  90. package/src/components/right-panel/design/TextSection.tsx +719 -0
  91. package/src/components/right-panel/design/icons.tsx +948 -0
  92. package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
  93. package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
  94. package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
  95. package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
  96. package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
  97. package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
  98. package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
  99. package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
  100. package/src/hooks/useBridge.ts +95 -0
  101. package/src/hooks/useChangeTracker.ts +563 -0
  102. package/src/hooks/useClaudeAPI.ts +118 -0
  103. package/src/hooks/useDOMTree.ts +25 -0
  104. package/src/hooks/useKeyboardShortcuts.ts +76 -0
  105. package/src/hooks/usePostMessage.ts +589 -0
  106. package/src/hooks/useProjectScan.ts +204 -0
  107. package/src/hooks/useResizable.ts +20 -0
  108. package/src/hooks/useSelectedElement.ts +51 -0
  109. package/src/hooks/useTargetUrl.ts +81 -0
  110. package/src/inspector/DOMTraverser.ts +71 -0
  111. package/src/inspector/ElementSelector.ts +23 -0
  112. package/src/inspector/HoverHighlighter.ts +54 -0
  113. package/src/inspector/SelectionHighlighter.ts +27 -0
  114. package/src/inspector/StyleExtractor.ts +19 -0
  115. package/src/inspector/inspector.ts +17 -0
  116. package/src/inspector/messaging.ts +30 -0
  117. package/src/lib/apiBase.ts +15 -0
  118. package/src/lib/classifyElement.ts +430 -0
  119. package/src/lib/claude-bin.ts +197 -0
  120. package/src/lib/claude-stream.ts +158 -0
  121. package/src/lib/clientProjectScanner.ts +344 -0
  122. package/src/lib/componentMatcher.ts +156 -0
  123. package/src/lib/constants.ts +573 -0
  124. package/src/lib/cssVariableUtils.ts +409 -0
  125. package/src/lib/diffParser.ts +206 -0
  126. package/src/lib/folderPicker.ts +84 -0
  127. package/src/lib/gradientParser.ts +160 -0
  128. package/src/lib/projectScanner.ts +355 -0
  129. package/src/lib/promptBuilder.ts +402 -0
  130. package/src/lib/shadowParser.ts +124 -0
  131. package/src/lib/tailwindClassParser.ts +248 -0
  132. package/src/lib/textShadowUtils.ts +106 -0
  133. package/src/lib/utils.ts +299 -0
  134. package/src/lib/validatePath.ts +40 -0
  135. package/src/proxy.ts +92 -0
  136. package/src/server/terminal-server.ts +104 -0
  137. package/src/store/changeSlice.ts +288 -0
  138. package/src/store/claudeSlice.ts +222 -0
  139. package/src/store/componentSlice.ts +90 -0
  140. package/src/store/consoleSlice.ts +51 -0
  141. package/src/store/cssVariableSlice.ts +94 -0
  142. package/src/store/elementSlice.ts +78 -0
  143. package/src/store/index.ts +35 -0
  144. package/src/store/terminalSlice.ts +30 -0
  145. package/src/store/treeSlice.ts +69 -0
  146. package/src/store/uiSlice.ts +327 -0
  147. package/src/types/changelog.ts +49 -0
  148. package/src/types/claude.ts +131 -0
  149. package/src/types/component.ts +49 -0
  150. package/src/types/cssVariables.ts +18 -0
  151. package/src/types/element.ts +21 -0
  152. package/src/types/file-system-access.d.ts +27 -0
  153. package/src/types/gradient.ts +12 -0
  154. package/src/types/messages.ts +392 -0
  155. package/src/types/shadow.ts +8 -0
  156. package/src/types/tree.ts +9 -0
  157. package/tsconfig.json +42 -0
  158. 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
+ }