@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -0,0 +1,465 @@
1
+ 'use client'
2
+
3
+ import { emitter } from '@pascal-app/core'
4
+ import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
5
+ import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import { useIsMobile } from '../../hooks/use-mobile'
7
+ import { triggerSFX } from '../../lib/sfx-bus'
8
+ import useEditor from '../../store/use-editor'
9
+
10
+ type CaptureMode = 'standard' | 'viewport' | 'area'
11
+ type CaptureState = 'idle' | 'capturing' | 'saved'
12
+
13
+ interface DragPoint {
14
+ x: number
15
+ y: number
16
+ }
17
+
18
+ interface Drag {
19
+ start: DragPoint
20
+ end: DragPoint
21
+ }
22
+
23
+ function getResolution(
24
+ mode: CaptureMode,
25
+ overlayEl: HTMLDivElement | null,
26
+ drag: Drag | null,
27
+ ): { w: number; h: number } | null {
28
+ if (mode === 'standard') return { w: 1920, h: 1080 }
29
+
30
+ if (!overlayEl) return null
31
+ const rect = overlayEl.getBoundingClientRect()
32
+ const dpr = Math.min(window.devicePixelRatio, 1.5)
33
+
34
+ if (mode === 'viewport') {
35
+ return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
36
+ }
37
+
38
+ if (mode === 'area' && drag) {
39
+ const w = Math.abs(drag.end.x - drag.start.x)
40
+ const h = Math.abs(drag.end.y - drag.start.y)
41
+ if (w < 4 || h < 4) return null
42
+ return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
43
+ }
44
+
45
+ return null
46
+ }
47
+
48
+ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
49
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
50
+ const setCaptureMode = useEditor((s) => s.setCaptureMode)
51
+ const isMobile = useIsMobile()
52
+
53
+ const [mode, setMode] = useState<CaptureMode>('standard')
54
+ const [drag, setDrag] = useState<Drag | null>(null)
55
+ const [isDragging, setIsDragging] = useState(false)
56
+ const [captureState, setCaptureState] = useState<CaptureState>('idle')
57
+ const overlayRef = useRef<HTMLDivElement>(null)
58
+
59
+ // Dismiss on Esc
60
+ useEffect(() => {
61
+ if (!isCaptureMode) return
62
+ const onKey = (e: KeyboardEvent) => {
63
+ if (e.key === 'Escape') setCaptureMode(false)
64
+ }
65
+ window.addEventListener('keydown', onKey)
66
+ return () => window.removeEventListener('keydown', onKey)
67
+ }, [isCaptureMode, setCaptureMode])
68
+
69
+ // Reset local state when entering capture mode
70
+ useEffect(() => {
71
+ if (isCaptureMode) {
72
+ setMode('standard')
73
+ setDrag(null)
74
+ setIsDragging(false)
75
+ setCaptureState('idle')
76
+ }
77
+ }, [isCaptureMode])
78
+
79
+ // Listen for snapshot saved to show feedback then exit
80
+ useEffect(() => {
81
+ const handler = () => {
82
+ setCaptureState('saved')
83
+ setTimeout(() => {
84
+ setCaptureMode(false)
85
+ setCaptureState('idle')
86
+ }, 1500)
87
+ }
88
+ emitter.on('snapshot:saved', handler)
89
+ return () => emitter.off('snapshot:saved', handler)
90
+ }, [setCaptureMode])
91
+
92
+ const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
93
+
94
+ // Tracks whether the active drag is a "move entire rect" gesture
95
+ const moveStartRef = useRef<{ pt: DragPoint; drag: Drag } | null>(null)
96
+
97
+ // Area drag handlers — relative to the overlay container
98
+ const onPointerDown = useCallback(
99
+ (e: React.PointerEvent<HTMLDivElement>) => {
100
+ if (mode !== 'area' || captureState !== 'idle') return
101
+ e.preventDefault()
102
+ const rect = overlayRef.current!.getBoundingClientRect()
103
+ const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }
104
+
105
+ // If clicking inside an existing selection → move mode
106
+ if (drag) {
107
+ const x0 = Math.min(drag.start.x, drag.end.x)
108
+ const y0 = Math.min(drag.start.y, drag.end.y)
109
+ const x1 = Math.max(drag.start.x, drag.end.x)
110
+ const y1 = Math.max(drag.start.y, drag.end.y)
111
+ if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) {
112
+ moveStartRef.current = { pt, drag }
113
+ setIsDragging(true)
114
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
115
+ return
116
+ }
117
+ }
118
+
119
+ // Outside / no selection → start new drag
120
+ moveStartRef.current = null
121
+ setDrag({ start: pt, end: pt })
122
+ setIsDragging(true)
123
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
124
+ },
125
+ [mode, captureState, drag],
126
+ )
127
+
128
+ const onPointerMove = useCallback(
129
+ (e: React.PointerEvent<HTMLDivElement>) => {
130
+ if (!isDragging) return
131
+ const rect = overlayRef.current!.getBoundingClientRect()
132
+ const pt = {
133
+ x: Math.max(0, Math.min(e.clientX - rect.left, rect.width)),
134
+ y: Math.max(0, Math.min(e.clientY - rect.top, rect.height)),
135
+ }
136
+ if (moveStartRef.current) {
137
+ // Move mode: translate the whole rect by the delta
138
+ const { pt: origin, drag: snapshot } = moveStartRef.current
139
+ const dx = pt.x - origin.x
140
+ const dy = pt.y - origin.y
141
+ setDrag({
142
+ start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
143
+ end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
144
+ })
145
+ } else {
146
+ setDrag((d) => (d ? { start: d.start, end: pt } : null))
147
+ }
148
+ },
149
+ [isDragging],
150
+ )
151
+
152
+ const onPointerUp = useCallback(() => {
153
+ const wasMoving = moveStartRef.current !== null
154
+ setIsDragging(false)
155
+ moveStartRef.current = null
156
+ // Clear the rect if the user just clicked without drawing (not a move gesture)
157
+ if (!wasMoving) {
158
+ setDrag((d) => {
159
+ if (!d) return null
160
+ const w = Math.abs(d.end.x - d.start.x)
161
+ const h = Math.abs(d.end.y - d.start.y)
162
+ return w < 4 && h < 4 ? null : d
163
+ })
164
+ }
165
+ }, [])
166
+
167
+ // Corner-handle resize: re-anchor to the opposite corner then reuse the same drag machinery
168
+ const onCornerPointerDown = useCallback(
169
+ (e: React.PointerEvent<HTMLDivElement>, cornerIndex: number) => {
170
+ if (captureState !== 'idle' || !drag) return
171
+ e.stopPropagation()
172
+ e.preventDefault()
173
+ moveStartRef.current = null
174
+ const x0 = Math.min(drag.start.x, drag.end.x)
175
+ const y0 = Math.min(drag.start.y, drag.end.y)
176
+ const x1 = Math.max(drag.start.x, drag.end.x)
177
+ const y1 = Math.max(drag.start.y, drag.end.y)
178
+ // anchor = opposite corner; dragged = current corner
179
+ const corners: [DragPoint, DragPoint][] = [
180
+ [
181
+ { x: x1, y: y1 },
182
+ { x: x0, y: y0 },
183
+ ], // TL → anchor BR
184
+ [
185
+ { x: x0, y: y1 },
186
+ { x: x1, y: y0 },
187
+ ], // TR → anchor BL
188
+ [
189
+ { x: x1, y: y0 },
190
+ { x: x0, y: y1 },
191
+ ], // BL → anchor TR
192
+ [
193
+ { x: x0, y: y0 },
194
+ { x: x1, y: y1 },
195
+ ], // BR → anchor TL
196
+ ]
197
+ const [anchor, current] = corners[cornerIndex]!
198
+ setDrag({ start: anchor, end: current })
199
+ setIsDragging(true)
200
+ },
201
+ [captureState, drag],
202
+ )
203
+
204
+ const handleCapture = useCallback(() => {
205
+ if (captureState !== 'idle') return
206
+
207
+ let cropRegion: { x: number; y: number; width: number; height: number } | undefined
208
+ if (mode === 'area' && drag && overlayRef.current) {
209
+ const rect = overlayRef.current.getBoundingClientRect()
210
+ const x0 = Math.min(drag.start.x, drag.end.x)
211
+ const y0 = Math.min(drag.start.y, drag.end.y)
212
+ const w = Math.abs(drag.end.x - drag.start.x)
213
+ const h = Math.abs(drag.end.y - drag.start.y)
214
+ cropRegion = {
215
+ x: x0 / rect.width,
216
+ y: y0 / rect.height,
217
+ width: w / rect.width,
218
+ height: h / rect.height,
219
+ }
220
+ }
221
+
222
+ setCaptureState('capturing')
223
+ triggerSFX('sfx:snapshot-capture')
224
+ emitter.emit('camera-controls:generate-thumbnail', {
225
+ projectId,
226
+ captureMode: mode,
227
+ cropRegion,
228
+ })
229
+ }, [captureState, mode, drag, projectId])
230
+
231
+ if (!isCaptureMode) return null
232
+
233
+ const resolution = getResolution(mode, overlayRef.current, drag)
234
+
235
+ // Area selection rect (CSS px, relative to overlay)
236
+ const selectionStyle =
237
+ mode === 'area' && drag
238
+ ? {
239
+ left: Math.min(drag.start.x, drag.end.x),
240
+ top: Math.min(drag.start.y, drag.end.y),
241
+ width: Math.abs(drag.end.x - drag.start.x),
242
+ height: Math.abs(drag.end.y - drag.start.y),
243
+ }
244
+ : null
245
+
246
+ const hasSelection =
247
+ selectionStyle != null && selectionStyle.width > 3 && selectionStyle.height > 3
248
+
249
+ const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
250
+
251
+ return (
252
+ <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
253
+ {/* Area mode: dim layer + crosshair cursor + drag-to-select */}
254
+ {mode === 'area' && (
255
+ <div
256
+ className="pointer-events-auto absolute inset-0 bg-black/30"
257
+ onPointerDown={onPointerDown}
258
+ onPointerMove={(e) => {
259
+ onPointerMove(e)
260
+ // Update cursor: 'move' when hovering inside an existing selection
261
+ if (!isDragging && drag && overlayRef.current) {
262
+ const rect = overlayRef.current.getBoundingClientRect()
263
+ const px = e.clientX - rect.left
264
+ const py = e.clientY - rect.top
265
+ const x0 = Math.min(drag.start.x, drag.end.x)
266
+ const y0 = Math.min(drag.start.y, drag.end.y)
267
+ const x1 = Math.max(drag.start.x, drag.end.x)
268
+ const y1 = Math.max(drag.start.y, drag.end.y)
269
+ e.currentTarget.style.cursor =
270
+ px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
271
+ }
272
+ }}
273
+ onPointerUp={onPointerUp}
274
+ style={{ cursor: 'crosshair' }}
275
+ >
276
+ {/* "No selection" hint */}
277
+ {!selectionStyle && (
278
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
279
+ <span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
280
+ Drag the area you want to capture
281
+ </span>
282
+ </div>
283
+ )}
284
+
285
+ {/* Selection rect */}
286
+ {selectionStyle && (
287
+ <div
288
+ style={{
289
+ position: 'absolute',
290
+ left: selectionStyle.left,
291
+ top: selectionStyle.top,
292
+ width: selectionStyle.width,
293
+ height: selectionStyle.height,
294
+ pointerEvents: 'none',
295
+ boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
296
+ border: '2px dashed rgba(255,255,255,0.85)',
297
+ background: 'rgba(255,255,255,0.04)',
298
+ }}
299
+ >
300
+ {/* Corner handles */}
301
+ {(
302
+ [
303
+ { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
304
+ { pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
305
+ { pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
306
+ { pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
307
+ ] as const
308
+ ).map(({ pos, cursor }, i) => (
309
+ <div
310
+ key={i}
311
+ onPointerDown={(e) => onCornerPointerDown(e, i)}
312
+ style={{
313
+ position: 'absolute',
314
+ width: 10,
315
+ height: 10,
316
+ borderRadius: '50%',
317
+ background: 'white',
318
+ boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
319
+ pointerEvents: 'auto',
320
+ cursor,
321
+ ...pos,
322
+ }}
323
+ />
324
+ ))}
325
+ </div>
326
+ )}
327
+ </div>
328
+ )}
329
+
330
+ {/* Top-right dismiss button (icon-only on mobile) */}
331
+ <div className="pointer-events-auto absolute top-4 right-4">
332
+ <button
333
+ aria-label="Close capture mode"
334
+ className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
335
+ onClick={dismiss}
336
+ type="button"
337
+ >
338
+ <X className="h-3 w-3" />
339
+ {!isMobile && 'Esc to cancel'}
340
+ </button>
341
+ </div>
342
+
343
+ {/* Bottom-center mode toolbar */}
344
+ <div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
345
+ {(() => {
346
+ const modeButtons = (
347
+ <>
348
+ <ModeButton
349
+ active={mode === 'standard'}
350
+ badge="16:9"
351
+ icon={<Monitor className="h-3.5 w-3.5" />}
352
+ label="Standard"
353
+ onClick={() => {
354
+ setMode('standard')
355
+ setDrag(null)
356
+ }}
357
+ />
358
+ <ModeButton
359
+ active={mode === 'viewport'}
360
+ icon={<Maximize2 className="h-3.5 w-3.5" />}
361
+ label="Viewport"
362
+ onClick={() => {
363
+ setMode('viewport')
364
+ setDrag(null)
365
+ }}
366
+ />
367
+ <ModeButton
368
+ active={mode === 'area'}
369
+ icon={<Crop className="h-3.5 w-3.5" />}
370
+ label="Area"
371
+ onClick={() => setMode('area')}
372
+ />
373
+ </>
374
+ )
375
+
376
+ const resolutionDisplay = (
377
+ <span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
378
+ {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
379
+ </span>
380
+ )
381
+
382
+ const captureButton = (
383
+ <button
384
+ className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
385
+ disabled={captureDisabled}
386
+ onClick={handleCapture}
387
+ type="button"
388
+ >
389
+ {captureState === 'capturing' ? (
390
+ <>
391
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
392
+ Capturing
393
+ </>
394
+ ) : captureState === 'saved' ? (
395
+ <>
396
+ <Check className="h-3.5 w-3.5" />
397
+ Saved
398
+ </>
399
+ ) : (
400
+ <>
401
+ <Camera className="h-3.5 w-3.5" />
402
+ Capture
403
+ </>
404
+ )}
405
+ </button>
406
+ )
407
+
408
+ if (isMobile) {
409
+ return (
410
+ <div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
411
+ <div className="flex items-center justify-center gap-1">{modeButtons}</div>
412
+ <div className="flex items-center justify-center gap-2 border-white/10 border-t pt-2">
413
+ {resolutionDisplay}
414
+ {captureButton}
415
+ </div>
416
+ </div>
417
+ )
418
+ }
419
+
420
+ return (
421
+ <div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
422
+ {modeButtons}
423
+ <div className="mx-1 h-4 w-px bg-white/10" />
424
+ {resolutionDisplay}
425
+ <div className="mx-1 h-4 w-px bg-white/10" />
426
+ {captureButton}
427
+ </div>
428
+ )
429
+ })()}
430
+ </div>
431
+ </div>
432
+ )
433
+ }
434
+
435
+ function ModeButton({
436
+ active,
437
+ icon,
438
+ label,
439
+ badge,
440
+ onClick,
441
+ }: {
442
+ active: boolean
443
+ icon: React.ReactNode
444
+ label: string
445
+ badge?: string
446
+ onClick: () => void
447
+ }) {
448
+ return (
449
+ <button
450
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors ${
451
+ active ? 'bg-white/15 text-white ring-1 ring-white/20' : 'text-white/50 hover:text-white/90'
452
+ }`}
453
+ onClick={onClick}
454
+ type="button"
455
+ >
456
+ {icon}
457
+ {label}
458
+ {badge && (
459
+ <span className="rounded-sm bg-white/10 px-1 py-0.5 font-medium text-[10px] text-white/40 leading-none">
460
+ {badge}
461
+ </span>
462
+ )}
463
+ </button>
464
+ )
465
+ }