@pascal-app/editor 0.7.0 → 0.9.1

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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,539 @@
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
+ // Local crop-mode enum — distinct from `useEditor.captureMode` (which
11
+ // describes *why* a capture is happening, e.g. `preset`). This one says
12
+ // HOW the captured pixels are cropped: full-frame 16:9 (`standard`),
13
+ // raw canvas viewport, or user-dragged area.
14
+ type CropMode = 'standard' | 'viewport' | 'area'
15
+ type CaptureState = 'idle' | 'capturing' | 'saved'
16
+
17
+ interface DragPoint {
18
+ x: number
19
+ y: number
20
+ }
21
+
22
+ interface Drag {
23
+ start: DragPoint
24
+ end: DragPoint
25
+ }
26
+
27
+ function getResolution(
28
+ mode: CropMode,
29
+ overlayEl: HTMLDivElement | null,
30
+ drag: Drag | null,
31
+ ): { w: number; h: number } | null {
32
+ if (mode === 'standard') return { w: 1920, h: 1080 }
33
+
34
+ if (!overlayEl) return null
35
+ const rect = overlayEl.getBoundingClientRect()
36
+ const dpr = Math.min(window.devicePixelRatio, 1.5)
37
+
38
+ if (mode === 'viewport') {
39
+ return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
40
+ }
41
+
42
+ if (mode === 'area' && drag) {
43
+ const w = Math.abs(drag.end.x - drag.start.x)
44
+ const h = Math.abs(drag.end.y - drag.start.y)
45
+ if (w < 4 || h < 4) return null
46
+ return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
47
+ }
48
+
49
+ return null
50
+ }
51
+
52
+ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
53
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
54
+ const captureMode = useEditor((s) => s.captureMode)
55
+ const setCaptureMode = useEditor((s) => s.setCaptureMode)
56
+ const isMobile = useIsMobile()
57
+ // `preset` capture mode locks the overlay to a square area crop with
58
+ // a transparent background — the user picks framing but not the
59
+ // crop shape. Matches the unified preset-thumbnail capture flow.
60
+ const isPreset = captureMode.mode === 'preset'
61
+
62
+ const [mode, setMode] = useState<CropMode>('standard')
63
+ const [drag, setDrag] = useState<Drag | null>(null)
64
+ const [isDragging, setIsDragging] = useState(false)
65
+ const [captureState, setCaptureState] = useState<CaptureState>('idle')
66
+ const overlayRef = useRef<HTMLDivElement>(null)
67
+
68
+ // Dismiss on Esc
69
+ useEffect(() => {
70
+ if (!isCaptureMode) return
71
+ const onKey = (e: KeyboardEvent) => {
72
+ if (e.key === 'Escape') setCaptureMode(false)
73
+ }
74
+ window.addEventListener('keydown', onKey)
75
+ return () => window.removeEventListener('keydown', onKey)
76
+ }, [isCaptureMode, setCaptureMode])
77
+
78
+ // Reset local state when entering capture mode. Preset mode also
79
+ // auto-stages a centered square crop sized to ~75% of the shorter
80
+ // viewport dimension so the user can capture immediately — the
81
+ // overlay's pan/move/resize handles still apply if they want to
82
+ // tweak the framing, but they don't have to draw the rect first.
83
+ useEffect(() => {
84
+ if (!isCaptureMode) return
85
+ setMode(isPreset ? 'area' : 'standard')
86
+ setIsDragging(false)
87
+ setCaptureState('idle')
88
+ if (isPreset && overlayRef.current) {
89
+ const rect = overlayRef.current.getBoundingClientRect()
90
+ const side = Math.min(rect.width, rect.height) * 0.75
91
+ const cx = rect.width / 2
92
+ const cy = rect.height / 2
93
+ setDrag({
94
+ start: { x: cx - side / 2, y: cy - side / 2 },
95
+ end: { x: cx + side / 2, y: cy + side / 2 },
96
+ })
97
+ } else {
98
+ setDrag(null)
99
+ }
100
+ }, [isCaptureMode, isPreset])
101
+
102
+ // Listen for snapshot saved to show feedback then exit
103
+ useEffect(() => {
104
+ const handler = () => {
105
+ setCaptureState('saved')
106
+ setTimeout(() => {
107
+ setCaptureMode(false)
108
+ setCaptureState('idle')
109
+ }, 1500)
110
+ }
111
+ emitter.on('snapshot:saved', handler)
112
+ return () => emitter.off('snapshot:saved', handler)
113
+ }, [setCaptureMode])
114
+
115
+ const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
116
+
117
+ // Tracks whether the active drag is a "move entire rect" gesture
118
+ const moveStartRef = useRef<{ pt: DragPoint; drag: Drag } | null>(null)
119
+
120
+ // Area drag handlers — relative to the overlay container
121
+ const onPointerDown = useCallback(
122
+ (e: React.PointerEvent<HTMLDivElement>) => {
123
+ if (mode !== 'area' || captureState !== 'idle') return
124
+ e.preventDefault()
125
+ const rect = overlayRef.current!.getBoundingClientRect()
126
+ const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }
127
+
128
+ // If clicking inside an existing selection → move mode
129
+ if (drag) {
130
+ const x0 = Math.min(drag.start.x, drag.end.x)
131
+ const y0 = Math.min(drag.start.y, drag.end.y)
132
+ const x1 = Math.max(drag.start.x, drag.end.x)
133
+ const y1 = Math.max(drag.start.y, drag.end.y)
134
+ if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) {
135
+ moveStartRef.current = { pt, drag }
136
+ setIsDragging(true)
137
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
138
+ return
139
+ }
140
+ }
141
+
142
+ // Outside / no selection → start new drag
143
+ moveStartRef.current = null
144
+ setDrag({ start: pt, end: pt })
145
+ setIsDragging(true)
146
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
147
+ },
148
+ [mode, captureState, drag],
149
+ )
150
+
151
+ const onPointerMove = useCallback(
152
+ (e: React.PointerEvent<HTMLDivElement>) => {
153
+ if (!isDragging) return
154
+ const rect = overlayRef.current!.getBoundingClientRect()
155
+ const pt = {
156
+ x: Math.max(0, Math.min(e.clientX - rect.left, rect.width)),
157
+ y: Math.max(0, Math.min(e.clientY - rect.top, rect.height)),
158
+ }
159
+ if (moveStartRef.current) {
160
+ // Move mode: translate the whole rect by the delta
161
+ const { pt: origin, drag: snapshot } = moveStartRef.current
162
+ const dx = pt.x - origin.x
163
+ const dy = pt.y - origin.y
164
+ setDrag({
165
+ start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
166
+ end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
167
+ })
168
+ } else if (isPreset) {
169
+ // Preset mode locks the rect to a square — use the smaller
170
+ // axis to keep the drag predictable, sign-correct so the user
171
+ // can still drag in any quadrant.
172
+ setDrag((d) => {
173
+ if (!d) return null
174
+ const dx = pt.x - d.start.x
175
+ const dy = pt.y - d.start.y
176
+ const side = Math.min(Math.abs(dx), Math.abs(dy))
177
+ return {
178
+ start: d.start,
179
+ end: {
180
+ x: d.start.x + Math.sign(dx || 1) * side,
181
+ y: d.start.y + Math.sign(dy || 1) * side,
182
+ },
183
+ }
184
+ })
185
+ } else {
186
+ setDrag((d) => (d ? { start: d.start, end: pt } : null))
187
+ }
188
+ },
189
+ [isDragging, isPreset],
190
+ )
191
+
192
+ const onPointerUp = useCallback(() => {
193
+ const wasMoving = moveStartRef.current !== null
194
+ setIsDragging(false)
195
+ moveStartRef.current = null
196
+ // Clear the rect if the user just clicked without drawing (not a move gesture)
197
+ if (!wasMoving) {
198
+ setDrag((d) => {
199
+ if (!d) return null
200
+ const w = Math.abs(d.end.x - d.start.x)
201
+ const h = Math.abs(d.end.y - d.start.y)
202
+ return w < 4 && h < 4 ? null : d
203
+ })
204
+ }
205
+ }, [])
206
+
207
+ // Corner-handle resize: re-anchor to the opposite corner then reuse the same drag machinery
208
+ const onCornerPointerDown = useCallback(
209
+ (e: React.PointerEvent<HTMLDivElement>, cornerIndex: number) => {
210
+ if (captureState !== 'idle' || !drag) return
211
+ e.stopPropagation()
212
+ e.preventDefault()
213
+ moveStartRef.current = null
214
+ const x0 = Math.min(drag.start.x, drag.end.x)
215
+ const y0 = Math.min(drag.start.y, drag.end.y)
216
+ const x1 = Math.max(drag.start.x, drag.end.x)
217
+ const y1 = Math.max(drag.start.y, drag.end.y)
218
+ // anchor = opposite corner; dragged = current corner
219
+ const corners: [DragPoint, DragPoint][] = [
220
+ [
221
+ { x: x1, y: y1 },
222
+ { x: x0, y: y0 },
223
+ ], // TL → anchor BR
224
+ [
225
+ { x: x0, y: y1 },
226
+ { x: x1, y: y0 },
227
+ ], // TR → anchor BL
228
+ [
229
+ { x: x1, y: y0 },
230
+ { x: x0, y: y1 },
231
+ ], // BL → anchor TR
232
+ [
233
+ { x: x0, y: y0 },
234
+ { x: x1, y: y1 },
235
+ ], // BR → anchor TL
236
+ ]
237
+ const [anchor, current] = corners[cornerIndex]!
238
+ setDrag({ start: anchor, end: current })
239
+ setIsDragging(true)
240
+ },
241
+ [captureState, drag],
242
+ )
243
+
244
+ const handleCapture = useCallback(() => {
245
+ if (captureState !== 'idle') return
246
+
247
+ let cropRegion: { x: number; y: number; width: number; height: number } | undefined
248
+ if (mode === 'area' && drag && overlayRef.current) {
249
+ const rect = overlayRef.current.getBoundingClientRect()
250
+ const x0 = Math.min(drag.start.x, drag.end.x)
251
+ const y0 = Math.min(drag.start.y, drag.end.y)
252
+ const w = Math.abs(drag.end.x - drag.start.x)
253
+ const h = Math.abs(drag.end.y - drag.start.y)
254
+ cropRegion = {
255
+ x: x0 / rect.width,
256
+ y: y0 / rect.height,
257
+ width: w / rect.width,
258
+ height: h / rect.height,
259
+ }
260
+ }
261
+
262
+ setCaptureState('capturing')
263
+ triggerSFX('sfx:snapshot-capture')
264
+ emitter.emit('camera-controls:generate-thumbnail', {
265
+ projectId,
266
+ captureMode: mode,
267
+ cropRegion,
268
+ // In preset mode, the ThumbnailGenerator should keep the alpha
269
+ // channel transparent so the saved preset thumbnail composes
270
+ // cleanly onto any palette background.
271
+ transparent: isPreset,
272
+ })
273
+ }, [captureState, mode, drag, projectId, isPreset])
274
+
275
+ if (!isCaptureMode) return null
276
+
277
+ const resolution = getResolution(mode, overlayRef.current, drag)
278
+
279
+ // Area selection rect (CSS px, relative to overlay)
280
+ const selectionStyle =
281
+ mode === 'area' && drag
282
+ ? {
283
+ left: Math.min(drag.start.x, drag.end.x),
284
+ top: Math.min(drag.start.y, drag.end.y),
285
+ width: Math.abs(drag.end.x - drag.start.x),
286
+ height: Math.abs(drag.end.y - drag.start.y),
287
+ }
288
+ : null
289
+
290
+ const hasSelection =
291
+ selectionStyle != null && selectionStyle.width > 3 && selectionStyle.height > 3
292
+
293
+ const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
294
+
295
+ return (
296
+ <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
297
+ {/* Area mode: dim layer + crosshair cursor + drag-to-select.
298
+ *
299
+ * Preset mode reuses the same DOM but stays click-through: the
300
+ * crop frame is auto-staged and locked, so the user adjusts the
301
+ * camera (orbit / pan / zoom) instead of dragging the rect. The
302
+ * dim letterbox + dashed border still render via the inline
303
+ * `box-shadow` on the selection rect — they're cosmetic. */}
304
+ {mode === 'area' && (
305
+ <div
306
+ className={
307
+ isPreset
308
+ ? 'pointer-events-none absolute inset-0'
309
+ : 'pointer-events-auto absolute inset-0 bg-black/30'
310
+ }
311
+ onPointerDown={isPreset ? undefined : onPointerDown}
312
+ onPointerMove={
313
+ isPreset
314
+ ? undefined
315
+ : (e) => {
316
+ onPointerMove(e)
317
+ // Update cursor: 'move' when hovering inside an existing selection
318
+ if (!isDragging && drag && overlayRef.current) {
319
+ const rect = overlayRef.current.getBoundingClientRect()
320
+ const px = e.clientX - rect.left
321
+ const py = e.clientY - rect.top
322
+ const x0 = Math.min(drag.start.x, drag.end.x)
323
+ const y0 = Math.min(drag.start.y, drag.end.y)
324
+ const x1 = Math.max(drag.start.x, drag.end.x)
325
+ const y1 = Math.max(drag.start.y, drag.end.y)
326
+ e.currentTarget.style.cursor =
327
+ px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
328
+ }
329
+ }
330
+ }
331
+ onPointerUp={isPreset ? undefined : onPointerUp}
332
+ style={isPreset ? undefined : { cursor: 'crosshair' }}
333
+ >
334
+ {/* "No selection" hint — only when the user has to draw the
335
+ area themselves (`standard` capture). Preset mode always
336
+ has a pre-staged square, so we never show it there. */}
337
+ {!selectionStyle && !isPreset && (
338
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
339
+ <span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
340
+ Drag the area you want to capture
341
+ </span>
342
+ </div>
343
+ )}
344
+
345
+ {/* Selection rect */}
346
+ {selectionStyle && (
347
+ <div
348
+ style={{
349
+ position: 'absolute',
350
+ left: selectionStyle.left,
351
+ top: selectionStyle.top,
352
+ width: selectionStyle.width,
353
+ height: selectionStyle.height,
354
+ pointerEvents: 'none',
355
+ boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
356
+ border: '2px dashed rgba(255,255,255,0.85)',
357
+ background: 'rgba(255,255,255,0.04)',
358
+ }}
359
+ >
360
+ {/* Corner handles — preset mode locks the frame to the
361
+ auto-staged centered square; the user adjusts the
362
+ camera instead. */}
363
+ {!isPreset &&
364
+ (
365
+ [
366
+ { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
367
+ { pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
368
+ { pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
369
+ { pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
370
+ ] as const
371
+ ).map(({ pos, cursor }, i) => (
372
+ <div
373
+ key={i}
374
+ onPointerDown={(e) => onCornerPointerDown(e, i)}
375
+ style={{
376
+ position: 'absolute',
377
+ width: 10,
378
+ height: 10,
379
+ borderRadius: '50%',
380
+ background: 'white',
381
+ boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
382
+ pointerEvents: 'auto',
383
+ cursor,
384
+ ...pos,
385
+ }}
386
+ />
387
+ ))}
388
+ </div>
389
+ )}
390
+ </div>
391
+ )}
392
+
393
+ {/* Top-right dismiss button (icon-only on mobile) */}
394
+ <div className="pointer-events-auto absolute top-4 right-4">
395
+ <button
396
+ aria-label="Close capture mode"
397
+ 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"
398
+ onClick={dismiss}
399
+ type="button"
400
+ >
401
+ <X className="h-3 w-3" />
402
+ {!isMobile && 'Esc to cancel'}
403
+ </button>
404
+ </div>
405
+
406
+ {/* Bottom-center mode toolbar */}
407
+ <div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
408
+ {(() => {
409
+ // Preset capture mode locks both the crop shape (square) and
410
+ // the transparent-background output — hide the per-shape
411
+ // mode buttons so the user has nothing to second-guess.
412
+ const modeButtons = isPreset ? null : (
413
+ <>
414
+ <ModeButton
415
+ active={mode === 'standard'}
416
+ badge="16:9"
417
+ icon={<Monitor className="h-3.5 w-3.5" />}
418
+ label="Standard"
419
+ onClick={() => {
420
+ setMode('standard')
421
+ setDrag(null)
422
+ }}
423
+ />
424
+ <ModeButton
425
+ active={mode === 'viewport'}
426
+ icon={<Maximize2 className="h-3.5 w-3.5" />}
427
+ label="Viewport"
428
+ onClick={() => {
429
+ setMode('viewport')
430
+ setDrag(null)
431
+ }}
432
+ />
433
+ <ModeButton
434
+ active={mode === 'area'}
435
+ icon={<Crop className="h-3.5 w-3.5" />}
436
+ label="Area"
437
+ onClick={() => setMode('area')}
438
+ />
439
+ </>
440
+ )
441
+
442
+ const resolutionDisplay = (
443
+ <span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
444
+ {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
445
+ </span>
446
+ )
447
+
448
+ const captureButton = (
449
+ <button
450
+ 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"
451
+ disabled={captureDisabled}
452
+ onClick={handleCapture}
453
+ type="button"
454
+ >
455
+ {captureState === 'capturing' ? (
456
+ <>
457
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
458
+ Capturing
459
+ </>
460
+ ) : captureState === 'saved' ? (
461
+ <>
462
+ <Check className="h-3.5 w-3.5" />
463
+ Saved
464
+ </>
465
+ ) : (
466
+ <>
467
+ <Camera className="h-3.5 w-3.5" />
468
+ Capture
469
+ </>
470
+ )}
471
+ </button>
472
+ )
473
+
474
+ if (isMobile) {
475
+ return (
476
+ <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">
477
+ {modeButtons && (
478
+ <div className="flex items-center justify-center gap-1">{modeButtons}</div>
479
+ )}
480
+ <div
481
+ className={
482
+ modeButtons
483
+ ? 'flex items-center justify-center gap-2 border-white/10 border-t pt-2'
484
+ : 'flex items-center justify-center gap-2'
485
+ }
486
+ >
487
+ {resolutionDisplay}
488
+ {captureButton}
489
+ </div>
490
+ </div>
491
+ )
492
+ }
493
+
494
+ return (
495
+ <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">
496
+ {modeButtons}
497
+ {modeButtons && <div className="mx-1 h-4 w-px bg-white/10" />}
498
+ {resolutionDisplay}
499
+ <div className="mx-1 h-4 w-px bg-white/10" />
500
+ {captureButton}
501
+ </div>
502
+ )
503
+ })()}
504
+ </div>
505
+ </div>
506
+ )
507
+ }
508
+
509
+ function ModeButton({
510
+ active,
511
+ icon,
512
+ label,
513
+ badge,
514
+ onClick,
515
+ }: {
516
+ active: boolean
517
+ icon: React.ReactNode
518
+ label: string
519
+ badge?: string
520
+ onClick: () => void
521
+ }) {
522
+ return (
523
+ <button
524
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors ${
525
+ active ? 'bg-white/15 text-white ring-1 ring-white/20' : 'text-white/50 hover:text-white/90'
526
+ }`}
527
+ onClick={onClick}
528
+ type="button"
529
+ >
530
+ {icon}
531
+ {label}
532
+ {badge && (
533
+ <span className="rounded-sm bg-white/10 px-1 py-0.5 font-medium text-[10px] text-white/40 leading-none">
534
+ {badge}
535
+ </span>
536
+ )}
537
+ </button>
538
+ )
539
+ }