@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.
- package/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- 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/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- 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/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- 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/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- 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
|
+
}
|