@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.
- package/package.json +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +267 -36
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/tool-manager.tsx +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/level-duplication.test.ts +70 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- 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
|
+
}
|