@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,333 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useEffect, useRef } from 'react'
4
+ import { parseCSSValue, formatCSSValue } from '@/lib/utils'
5
+ import { useEditorStore } from '@/store'
6
+
7
+ interface CompactInputProps {
8
+ label?: string
9
+ placeholder?: string
10
+ value: string
11
+ property: string
12
+ onChange: (property: string, value: string) => void
13
+ onReset?: (property: string) => void
14
+ units?: string[]
15
+ min?: number
16
+ max?: number
17
+ step?: number
18
+ className?: string
19
+ }
20
+
21
+ export function CompactInput({
22
+ label,
23
+ placeholder,
24
+ value,
25
+ property,
26
+ onChange,
27
+ onReset,
28
+ units = ['px', '%', 'em', 'rem', 'auto'],
29
+ min,
30
+ max,
31
+ step = 1,
32
+ className,
33
+ }: CompactInputProps) {
34
+ const parsed = parseCSSValue(value)
35
+ const [localValue, setLocalValue] = useState(
36
+ value === 'auto' ? '' : String(parsed.number),
37
+ )
38
+ const [unit, setUnit] = useState(
39
+ value === 'auto' ? 'auto' : parsed.unit || 'px',
40
+ )
41
+ const inputRef = useRef<HTMLInputElement>(null)
42
+ const labelRef = useRef<HTMLSpanElement>(null)
43
+ const unitBtnRef = useRef<HTMLButtonElement>(null)
44
+ const unitPopoverRef = useRef<HTMLDivElement>(null)
45
+ const isDragging = useRef(false)
46
+ const [showUnits, setShowUnits] = useState(false)
47
+ const dragStartX = useRef(0)
48
+ const dragStartValue = useRef(0)
49
+
50
+ // Check if this property has a tracked change (modified from original)
51
+ const hasChange = useEditorStore((s) => {
52
+ const sp = s.selectorPath
53
+ return sp
54
+ ? s.styleChanges.some(
55
+ (c) => c.elementSelector === sp && c.property === property,
56
+ )
57
+ : false
58
+ })
59
+
60
+ const handleDoubleClick = useCallback(() => {
61
+ if (unit === 'auto') return
62
+ inputRef.current?.focus()
63
+ inputRef.current?.select()
64
+ }, [unit])
65
+
66
+ // Close unit popover on outside click
67
+ useEffect(() => {
68
+ if (!showUnits) return
69
+ const handler = (e: MouseEvent) => {
70
+ if (
71
+ unitBtnRef.current &&
72
+ !unitBtnRef.current.contains(e.target as Node) &&
73
+ unitPopoverRef.current &&
74
+ !unitPopoverRef.current.contains(e.target as Node)
75
+ ) {
76
+ setShowUnits(false)
77
+ }
78
+ }
79
+ document.addEventListener('mousedown', handler)
80
+ return () => document.removeEventListener('mousedown', handler)
81
+ }, [showUnits])
82
+
83
+ useEffect(() => {
84
+ if (value === 'auto') {
85
+ setLocalValue('')
86
+ setUnit('auto')
87
+ } else {
88
+ const p = parseCSSValue(value)
89
+ setLocalValue(String(p.number))
90
+ setUnit(p.unit || 'px')
91
+ }
92
+ }, [value])
93
+
94
+ const clampValue = useCallback(
95
+ (num: number): number => {
96
+ let clamped = num
97
+ if (min !== undefined) clamped = Math.max(clamped, min)
98
+ if (max !== undefined) clamped = Math.min(clamped, max)
99
+ return clamped
100
+ },
101
+ [min, max],
102
+ )
103
+
104
+ // --- Figma-style drag-to-scrub on label ---
105
+ const handleLabelPointerDown = useCallback(
106
+ (e: React.PointerEvent) => {
107
+ if (unit === 'auto') return
108
+ e.preventDefault()
109
+ isDragging.current = true
110
+ dragStartX.current = e.clientX
111
+ dragStartValue.current = parseFloat(localValue || '0')
112
+
113
+ const labelEl = labelRef.current
114
+ if (labelEl) labelEl.setPointerCapture(e.pointerId)
115
+
116
+ document.body.style.cursor = 'ew-resize'
117
+ document.body.style.userSelect = 'none'
118
+ },
119
+ [localValue, unit],
120
+ )
121
+
122
+ const handleLabelPointerMove = useCallback(
123
+ (e: React.PointerEvent) => {
124
+ if (!isDragging.current) return
125
+ const delta = e.clientX - dragStartX.current
126
+ // Base: 2 per pixel. Shift = 10x, Alt/Option = 0.1x
127
+ const multiplier = e.shiftKey ? 10 : e.altKey ? 0.1 : 1
128
+ const next = clampValue(
129
+ Math.round(
130
+ (dragStartValue.current + delta * 2 * step * multiplier) * 100,
131
+ ) / 100,
132
+ )
133
+ const nextStr = String(next)
134
+ setLocalValue(nextStr)
135
+ onChange(property, formatCSSValue(next, unit))
136
+ },
137
+ [step, clampValue, onChange, property, unit],
138
+ )
139
+
140
+ const handleLabelPointerUp = useCallback((e: React.PointerEvent) => {
141
+ if (!isDragging.current) return
142
+ isDragging.current = false
143
+
144
+ const labelEl = labelRef.current
145
+ if (labelEl) labelEl.releasePointerCapture(e.pointerId)
146
+
147
+ document.body.style.cursor = ''
148
+ document.body.style.userSelect = ''
149
+ }, [])
150
+
151
+ const commit = useCallback(
152
+ (num: string, u: string) => {
153
+ if (u === 'auto') {
154
+ onChange(property, 'auto')
155
+ } else {
156
+ const n = parseFloat(num)
157
+ if (!isNaN(n)) {
158
+ const clamped = clampValue(n)
159
+ onChange(property, formatCSSValue(clamped, u))
160
+ }
161
+ }
162
+ },
163
+ [onChange, property, clampValue],
164
+ )
165
+
166
+ const selectUnit = useCallback(
167
+ (nextUnit: string) => {
168
+ setUnit(nextUnit)
169
+ setShowUnits(false)
170
+
171
+ if (nextUnit === 'auto') {
172
+ setLocalValue('')
173
+ onChange(property, 'auto')
174
+ } else {
175
+ const num = parseFloat(localValue || '0')
176
+ if (!isNaN(num)) {
177
+ const clamped = clampValue(num)
178
+ setLocalValue(String(clamped))
179
+ onChange(property, formatCSSValue(clamped, nextUnit))
180
+ }
181
+ }
182
+ },
183
+ [localValue, onChange, property, clampValue],
184
+ )
185
+
186
+ const handleKeyDown = useCallback(
187
+ (e: React.KeyboardEvent) => {
188
+ if (e.key === 'Enter') {
189
+ commit(localValue, unit)
190
+ inputRef.current?.blur()
191
+ } else if (e.key === 'ArrowUp') {
192
+ e.preventDefault()
193
+ const increment = e.shiftKey ? step * 10 : step
194
+ const next = clampValue(parseFloat(localValue || '0') + increment)
195
+ const nextStr = String(next)
196
+ setLocalValue(nextStr)
197
+ commit(nextStr, unit)
198
+ } else if (e.key === 'ArrowDown') {
199
+ e.preventDefault()
200
+ const decrement = e.shiftKey ? step * 10 : step
201
+ const next = clampValue(parseFloat(localValue || '0') - decrement)
202
+ const nextStr = String(next)
203
+ setLocalValue(nextStr)
204
+ commit(nextStr, unit)
205
+ }
206
+ },
207
+ [localValue, unit, step, commit, clampValue],
208
+ )
209
+
210
+ const isAuto = unit === 'auto'
211
+
212
+ return (
213
+ <div className={`relative ${className ?? ''}`}>
214
+ <div
215
+ className="flex items-center h-6 rounded overflow-hidden"
216
+ style={{
217
+ background: 'var(--bg-tertiary)',
218
+ border: '1px solid var(--border)',
219
+ }}
220
+ >
221
+ {label && (
222
+ <span
223
+ ref={labelRef}
224
+ onPointerDown={handleLabelPointerDown}
225
+ onPointerMove={handleLabelPointerMove}
226
+ onPointerUp={handleLabelPointerUp}
227
+ onDoubleClick={handleDoubleClick}
228
+ className="relative flex-shrink-0 flex items-center justify-center w-6 h-full text-[11px] select-none"
229
+ style={{
230
+ color: hasChange ? 'var(--accent)' : 'var(--text-secondary)',
231
+ borderRight: '1px solid var(--border)',
232
+ cursor: isAuto ? 'default' : 'ew-resize',
233
+ }}
234
+ title={hasChange ? 'Double-click to reset' : undefined}
235
+ >
236
+ {label}
237
+ {hasChange && (
238
+ <span
239
+ className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full"
240
+ style={{ background: 'var(--accent)' }}
241
+ />
242
+ )}
243
+ </span>
244
+ )}
245
+
246
+ <input
247
+ ref={inputRef}
248
+ type="text"
249
+ inputMode="numeric"
250
+ value={isAuto ? 'auto' : localValue}
251
+ placeholder={placeholder}
252
+ onChange={(e) => {
253
+ if (!isAuto) {
254
+ setLocalValue(e.target.value)
255
+ }
256
+ }}
257
+ onBlur={() => commit(localValue, unit)}
258
+ onKeyDown={handleKeyDown}
259
+ disabled={isAuto}
260
+ className="flex-1 min-w-0 h-full px-1.5 text-[11px] bg-transparent border-none outline-none"
261
+ style={{
262
+ color: 'var(--text-primary)',
263
+ opacity: isAuto ? 0.5 : 1,
264
+ }}
265
+ />
266
+
267
+ <button
268
+ ref={unitBtnRef}
269
+ type="button"
270
+ onClick={() => setShowUnits(!showUnits)}
271
+ className="flex-shrink-0 flex items-center justify-center h-6 px-1.5 text-[11px] cursor-pointer select-none hover:opacity-80 bg-transparent border-none outline-none gap-0.5"
272
+ style={{
273
+ color: 'var(--text-secondary)',
274
+ borderLeft: '1px solid var(--border)',
275
+ }}
276
+ >
277
+ {unit}
278
+ <svg
279
+ width={6}
280
+ height={6}
281
+ viewBox="0 0 6 6"
282
+ fill="none"
283
+ style={{ opacity: 0.5 }}
284
+ >
285
+ <path
286
+ d="M1 2l2 2 2-2"
287
+ stroke="currentColor"
288
+ strokeWidth={1}
289
+ strokeLinecap="round"
290
+ strokeLinejoin="round"
291
+ />
292
+ </svg>
293
+ </button>
294
+ </div>
295
+
296
+ {showUnits && (
297
+ <div
298
+ ref={unitPopoverRef}
299
+ className="absolute z-50 right-0 mt-1 rounded-md overflow-hidden shadow-lg"
300
+ style={{
301
+ background: '#252525',
302
+ border: '1px solid var(--border)',
303
+ minWidth: 56,
304
+ top: '100%',
305
+ }}
306
+ >
307
+ {units.map((u) => {
308
+ const isActive = u === unit
309
+ return (
310
+ <button
311
+ key={u}
312
+ type="button"
313
+ onMouseDown={(e) => {
314
+ e.preventDefault()
315
+ selectUnit(u)
316
+ }}
317
+ className="flex items-center w-full px-2.5 py-1 text-[11px] transition-colors"
318
+ style={{
319
+ color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
320
+ background: isActive
321
+ ? 'rgba(74, 158, 255, 0.08)'
322
+ : 'transparent',
323
+ }}
324
+ >
325
+ {u}
326
+ </button>
327
+ )
328
+ })}
329
+ </div>
330
+ )}
331
+ </div>
332
+ )
333
+ }
@@ -0,0 +1,118 @@
1
+ 'use client'
2
+
3
+ import { useRef, useCallback } from 'react'
4
+ import { parseCSSValue, formatCSSValue } from '@/lib/utils'
5
+
6
+ interface DraggableLabelProps {
7
+ children: React.ReactNode
8
+ /** Current CSS value, e.g. "26.875px" */
9
+ value: string
10
+ property: string
11
+ onChange: (property: string, value: string) => void
12
+ step?: number
13
+ min?: number
14
+ max?: number
15
+ className?: string
16
+ style?: React.CSSProperties
17
+ }
18
+
19
+ /**
20
+ * A label that supports Figma-style drag-to-scrub.
21
+ * Drag left/right to decrement/increment the numeric value.
22
+ * Hold Shift for 10x, Alt/Option for 0.1x.
23
+ */
24
+ export function DraggableLabel({
25
+ children,
26
+ value,
27
+ property,
28
+ onChange,
29
+ step = 1,
30
+ min,
31
+ max,
32
+ className,
33
+ style,
34
+ }: DraggableLabelProps) {
35
+ const isDragging = useRef(false)
36
+ const dragStartX = useRef(0)
37
+ const dragStartValue = useRef(0)
38
+ const unitRef = useRef('px')
39
+
40
+ const clamp = useCallback(
41
+ (num: number) => {
42
+ let v = num
43
+ if (min !== undefined) v = Math.max(v, min)
44
+ if (max !== undefined) v = Math.min(v, max)
45
+ return v
46
+ },
47
+ [min, max],
48
+ )
49
+
50
+ const handlePointerDown = useCallback(
51
+ (e: React.PointerEvent) => {
52
+ const parsed = parseCSSValue(value)
53
+ if (
54
+ value === 'auto' ||
55
+ value === 'none' ||
56
+ value === 'normal' ||
57
+ isNaN(parsed.number)
58
+ )
59
+ return
60
+
61
+ e.preventDefault()
62
+ isDragging.current = true
63
+ dragStartX.current = e.clientX
64
+ dragStartValue.current = parsed.number
65
+ unitRef.current = parsed.unit || 'px'
66
+
67
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
68
+ document.body.style.cursor = 'ew-resize'
69
+ document.body.style.userSelect = 'none'
70
+ },
71
+ [value],
72
+ )
73
+
74
+ const handlePointerMove = useCallback(
75
+ (e: React.PointerEvent) => {
76
+ if (!isDragging.current) return
77
+ const delta = e.clientX - dragStartX.current
78
+ const multiplier = e.shiftKey ? 10 : e.altKey ? 0.1 : 1
79
+ const next = clamp(
80
+ Math.round((dragStartValue.current + delta * step * multiplier) * 100) /
81
+ 100,
82
+ )
83
+ onChange(property, formatCSSValue(next, unitRef.current))
84
+ },
85
+ [step, clamp, onChange, property],
86
+ )
87
+
88
+ const handlePointerUp = useCallback((e: React.PointerEvent) => {
89
+ if (!isDragging.current) return
90
+ isDragging.current = false
91
+ ;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
92
+ document.body.style.cursor = ''
93
+ document.body.style.userSelect = ''
94
+ }, [])
95
+
96
+ const parsed = parseCSSValue(value)
97
+ const isDraggable =
98
+ value !== 'auto' &&
99
+ value !== 'none' &&
100
+ value !== 'normal' &&
101
+ !isNaN(parsed.number)
102
+
103
+ return (
104
+ <span
105
+ onPointerDown={handlePointerDown}
106
+ onPointerMove={handlePointerMove}
107
+ onPointerUp={handlePointerUp}
108
+ className={className}
109
+ style={{
110
+ cursor: isDraggable ? 'ew-resize' : 'default',
111
+ userSelect: 'none',
112
+ ...style,
113
+ }}
114
+ >
115
+ {children}
116
+ </span>
117
+ )
118
+ }
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+
5
+ interface IconToggleOption {
6
+ value: string
7
+ icon: React.ReactNode
8
+ tooltip?: string
9
+ }
10
+
11
+ interface IconToggleGroupProps {
12
+ options: IconToggleOption[]
13
+ value: string
14
+ onChange: (value: string) => void
15
+ }
16
+
17
+ export function IconToggleGroup({
18
+ options,
19
+ value,
20
+ onChange,
21
+ }: IconToggleGroupProps) {
22
+ return (
23
+ <div
24
+ className="inline-flex rounded"
25
+ style={{
26
+ background: 'var(--bg-tertiary)',
27
+ border: '1px solid var(--border)',
28
+ }}
29
+ >
30
+ {options.map((option) => {
31
+ const isActive = option.value === value
32
+ return (
33
+ <button
34
+ key={option.value}
35
+ type="button"
36
+ title={option.tooltip}
37
+ onClick={() => onChange(option.value)}
38
+ className="flex items-center justify-center transition-colors"
39
+ style={{
40
+ width: 24,
41
+ height: 24,
42
+ background: isActive
43
+ ? 'var(--accent-bg, rgba(74,158,255,0.15))'
44
+ : 'transparent',
45
+ color: isActive ? 'var(--accent)' : 'var(--text-muted)',
46
+ }}
47
+ >
48
+ {option.icon}
49
+ </button>
50
+ )
51
+ })}
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,174 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback } from 'react'
4
+ import { parseCSSValue } from '@/lib/utils'
5
+ import { CompactInput } from './CompactInput'
6
+ import { LinkIcon, UnlinkIcon } from '@/components/right-panel/design/icons'
7
+
8
+ interface LinkedInputPairProps {
9
+ label: string
10
+ values: { top: string; right: string; bottom: string; left: string }
11
+ properties: { top: string; right: string; bottom: string; left: string }
12
+ onChange: (property: string, value: string) => void
13
+ onReset?: (property: string) => void
14
+ units?: string[]
15
+ }
16
+
17
+ function areAllEqual(values: {
18
+ top: string
19
+ right: string
20
+ bottom: string
21
+ left: string
22
+ }): boolean {
23
+ const t = parseCSSValue(values.top)
24
+ const r = parseCSSValue(values.right)
25
+ const b = parseCSSValue(values.bottom)
26
+ const l = parseCSSValue(values.left)
27
+ return (
28
+ t.number === r.number &&
29
+ t.number === b.number &&
30
+ t.number === l.number &&
31
+ (t.unit || 'px') === (r.unit || 'px') &&
32
+ (t.unit || 'px') === (b.unit || 'px') &&
33
+ (t.unit || 'px') === (l.unit || 'px')
34
+ )
35
+ }
36
+
37
+ function areHVEqual(values: {
38
+ top: string
39
+ right: string
40
+ bottom: string
41
+ left: string
42
+ }): boolean {
43
+ const t = parseCSSValue(values.top)
44
+ const r = parseCSSValue(values.right)
45
+ const b = parseCSSValue(values.bottom)
46
+ const l = parseCSSValue(values.left)
47
+ return (
48
+ t.number === b.number &&
49
+ (t.unit || 'px') === (b.unit || 'px') &&
50
+ r.number === l.number &&
51
+ (r.unit || 'px') === (l.unit || 'px')
52
+ )
53
+ }
54
+
55
+ export function LinkedInputPair({
56
+ label,
57
+ values,
58
+ properties,
59
+ onChange,
60
+ onReset,
61
+ units = ['px', '%', 'em', 'rem'],
62
+ }: LinkedInputPairProps) {
63
+ const [isLinked, setIsLinked] = useState(() => areHVEqual(values))
64
+
65
+ useEffect(() => {
66
+ if (areHVEqual(values)) {
67
+ setIsLinked(true)
68
+ }
69
+ }, [values])
70
+
71
+ const handleLinkedHChange = (property: string, value: string) => {
72
+ onChange(properties.left, value)
73
+ onChange(properties.right, value)
74
+ }
75
+
76
+ const handleLinkedVChange = (property: string, value: string) => {
77
+ onChange(properties.top, value)
78
+ onChange(properties.bottom, value)
79
+ }
80
+
81
+ // When linked, reset both sides together
82
+ const handleLinkedHReset = useCallback(() => {
83
+ if (!onReset) return
84
+ onReset(properties.left)
85
+ onReset(properties.right)
86
+ }, [onReset, properties.left, properties.right])
87
+
88
+ const handleLinkedVReset = useCallback(() => {
89
+ if (!onReset) return
90
+ onReset(properties.top)
91
+ onReset(properties.bottom)
92
+ }, [onReset, properties.top, properties.bottom])
93
+
94
+ return (
95
+ <div>
96
+ <div className="flex items-center justify-between mb-1">
97
+ <span
98
+ className="text-[10px] font-medium"
99
+ style={{ color: 'var(--text-muted)' }}
100
+ >
101
+ {label}
102
+ </span>
103
+ <button
104
+ type="button"
105
+ onClick={() => setIsLinked(!isLinked)}
106
+ className="flex items-center justify-center w-5 h-5 rounded hover:opacity-80"
107
+ style={{
108
+ color: isLinked ? 'var(--accent)' : 'var(--text-muted)',
109
+ background: 'transparent',
110
+ }}
111
+ title={isLinked ? 'Unlink sides' : 'Link sides'}
112
+ >
113
+ {isLinked ? <LinkIcon /> : <UnlinkIcon />}
114
+ </button>
115
+ </div>
116
+
117
+ {isLinked ? (
118
+ <div className="grid grid-cols-2 gap-1.5">
119
+ <CompactInput
120
+ label="H"
121
+ value={values.left}
122
+ property={properties.left}
123
+ onChange={handleLinkedHChange}
124
+ onReset={handleLinkedHReset}
125
+ units={units}
126
+ />
127
+ <CompactInput
128
+ label="V"
129
+ value={values.top}
130
+ property={properties.top}
131
+ onChange={handleLinkedVChange}
132
+ onReset={handleLinkedVReset}
133
+ units={units}
134
+ />
135
+ </div>
136
+ ) : (
137
+ <div className="grid grid-cols-2 gap-1.5">
138
+ <CompactInput
139
+ label="T"
140
+ value={values.top}
141
+ property={properties.top}
142
+ onChange={onChange}
143
+ onReset={onReset}
144
+ units={units}
145
+ />
146
+ <CompactInput
147
+ label="R"
148
+ value={values.right}
149
+ property={properties.right}
150
+ onChange={onChange}
151
+ onReset={onReset}
152
+ units={units}
153
+ />
154
+ <CompactInput
155
+ label="B"
156
+ value={values.bottom}
157
+ property={properties.bottom}
158
+ onChange={onChange}
159
+ onReset={onReset}
160
+ units={units}
161
+ />
162
+ <CompactInput
163
+ label="L"
164
+ value={values.left}
165
+ property={properties.left}
166
+ onChange={onChange}
167
+ onReset={onReset}
168
+ units={units}
169
+ />
170
+ </div>
171
+ )}
172
+ </div>
173
+ )
174
+ }