@pascal-app/editor 0.9.1 → 0.9.2
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 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -7,9 +7,9 @@ import { Html } from '@react-three/drei'
|
|
|
7
7
|
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
|
8
8
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
9
9
|
import { type Camera, type Object3D, Vector3 } from 'three'
|
|
10
|
+
import { formatLinearMeasurement } from '../../lib/measurements'
|
|
10
11
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
|
11
|
-
import
|
|
12
|
-
import { formatMeasurement } from './measurement-pill'
|
|
12
|
+
import { useActiveHandleDrag } from '../../store/use-interaction-scope'
|
|
13
13
|
|
|
14
14
|
type ViewportSize = {
|
|
15
15
|
width: number
|
|
@@ -37,7 +37,7 @@ export function SiteEdgeLabels() {
|
|
|
37
37
|
const node = state.nodes[firstRoot]
|
|
38
38
|
return node?.type === 'site' ? (node as SiteNode) : null
|
|
39
39
|
})
|
|
40
|
-
const activeHandleDrag =
|
|
40
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
41
41
|
const unit = useViewer((state) => state.unit)
|
|
42
42
|
const cameraMode = useViewer((state) => state.cameraMode)
|
|
43
43
|
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
@@ -110,7 +110,7 @@ export function SiteEdgeLabels() {
|
|
|
110
110
|
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
|
|
111
111
|
}}
|
|
112
112
|
>
|
|
113
|
-
{
|
|
113
|
+
{formatLinearMeasurement(edge.dist, unit)}
|
|
114
114
|
</div>
|
|
115
115
|
</Html>
|
|
116
116
|
))}
|
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
} from 'three'
|
|
26
26
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
27
27
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
28
|
+
import { holeEditScope } from '../../lib/interaction/scope'
|
|
28
29
|
import useEditor from '../../store/use-editor'
|
|
30
|
+
import useInteractionScope, { useEditingHole } from '../../store/use-interaction-scope'
|
|
29
31
|
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
30
32
|
import { swallowNextClick } from './handles/use-handle-drag'
|
|
31
33
|
|
|
@@ -239,7 +241,9 @@ export function SlabHoleHighlights() {
|
|
|
239
241
|
}
|
|
240
242
|
}, [hoveredHole, selectedIds, setHoveredHole])
|
|
241
243
|
|
|
242
|
-
|
|
244
|
+
// Sole selection only — hole outlines are click-to-edit chrome, and a
|
|
245
|
+
// multi-selection shows highlight only (the group transforms as one piece).
|
|
246
|
+
if (selectedIds.length !== 1) return null
|
|
243
247
|
|
|
244
248
|
return (
|
|
245
249
|
<>
|
|
@@ -254,7 +258,7 @@ function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) {
|
|
|
254
258
|
const node = useScene((state) => state.nodes[slabId as AnyNodeId])
|
|
255
259
|
const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
|
|
256
260
|
const hoveredHole = useEditor((state) => state.hoveredHole)
|
|
257
|
-
const editingHole =
|
|
261
|
+
const editingHole = useEditingHole()
|
|
258
262
|
const setHoveredHole = useEditor((state) => state.setHoveredHole)
|
|
259
263
|
|
|
260
264
|
const slab = node?.type === 'slab' ? (node as SlabNode) : null
|
|
@@ -389,21 +393,25 @@ function SlabHoleHighlight({
|
|
|
389
393
|
// user edits the source rather than the synced hole. Everything else —
|
|
390
394
|
// manual holes and holes that predate holeMetadata — opens the editor.
|
|
391
395
|
if (metadata?.source === 'stair' && metadata.stairId) {
|
|
392
|
-
|
|
396
|
+
useInteractionScope
|
|
397
|
+
.getState()
|
|
398
|
+
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
|
393
399
|
useEditor.getState().setHoveredHole(null)
|
|
394
400
|
resetPointerCursor()
|
|
395
401
|
selectOwnedNode(metadata.stairId, 'stair')
|
|
396
402
|
return
|
|
397
403
|
}
|
|
398
404
|
if (metadata?.source === 'elevator' && metadata.elevatorId) {
|
|
399
|
-
|
|
405
|
+
useInteractionScope
|
|
406
|
+
.getState()
|
|
407
|
+
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
|
400
408
|
useEditor.getState().setHoveredHole(null)
|
|
401
409
|
resetPointerCursor()
|
|
402
410
|
selectOwnedNode(metadata.elevatorId, 'elevator')
|
|
403
411
|
return
|
|
404
412
|
}
|
|
405
413
|
|
|
406
|
-
|
|
414
|
+
useInteractionScope.getState().begin(holeEditScope({ nodeId: slabId, holeIndex }))
|
|
407
415
|
useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
|
|
408
416
|
},
|
|
409
417
|
[holeIndex, metadata, slabId],
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { emitter } from '@pascal-app/core'
|
|
4
|
-
import {
|
|
4
|
+
import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
|
|
5
5
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
6
|
import { useIsMobile } from '../../hooks/use-mobile'
|
|
7
7
|
import { triggerSFX } from '../../lib/sfx-bus'
|
|
8
|
-
import useEditor
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
8
|
+
import useEditor, {
|
|
9
|
+
type SnapshotCropMode,
|
|
10
|
+
type SnapshotStandardAspect,
|
|
11
|
+
} from '../../store/use-editor'
|
|
12
|
+
|
|
13
|
+
// Local alias — distinct from `useEditor.captureMode` (which describes *why*
|
|
14
|
+
// a capture is happening, e.g. `preset`). This one says HOW the captured
|
|
15
|
+
// pixels are cropped: full-frame 16:9 (`standard`), raw canvas viewport, or
|
|
16
|
+
// user-dragged area. Hosts can preselect it via `captureMode.crop`.
|
|
17
|
+
type CropMode = SnapshotCropMode
|
|
15
18
|
type CaptureState = 'idle' | 'capturing' | 'saved'
|
|
16
19
|
|
|
17
20
|
interface DragPoint {
|
|
@@ -24,12 +27,23 @@ interface Drag {
|
|
|
24
27
|
end: DragPoint
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
// Output presets for `standard` captures — long edge stays near 1920.
|
|
31
|
+
const STANDARD_SIZES: Record<SnapshotStandardAspect, { w: number; h: number }> = {
|
|
32
|
+
'16:9': { w: 1920, h: 1080 },
|
|
33
|
+
'9:16': { w: 1080, h: 1920 },
|
|
34
|
+
'4:3': { w: 1920, h: 1440 },
|
|
35
|
+
'3:4': { w: 1440, h: 1920 },
|
|
36
|
+
'1:1': { w: 1440, h: 1440 },
|
|
37
|
+
}
|
|
38
|
+
type StandardAspect = SnapshotStandardAspect
|
|
39
|
+
|
|
27
40
|
function getResolution(
|
|
28
41
|
mode: CropMode,
|
|
29
42
|
overlayEl: HTMLDivElement | null,
|
|
30
43
|
drag: Drag | null,
|
|
44
|
+
standardAspect: StandardAspect,
|
|
31
45
|
): { w: number; h: number } | null {
|
|
32
|
-
if (mode === 'standard') return
|
|
46
|
+
if (mode === 'standard') return STANDARD_SIZES[standardAspect]
|
|
33
47
|
|
|
34
48
|
if (!overlayEl) return null
|
|
35
49
|
const rect = overlayEl.getBoundingClientRect()
|
|
@@ -49,6 +63,41 @@ function getResolution(
|
|
|
49
63
|
return null
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
/** Rule-of-thirds guide rendered inside a framing surface. */
|
|
67
|
+
function ThirdsGrid() {
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className="pointer-events-none absolute inset-0"
|
|
71
|
+
style={{
|
|
72
|
+
background: `
|
|
73
|
+
linear-gradient(to right, transparent calc(33.33% - 0.5px), rgba(255,255,255,0.16) 33.33%, transparent calc(33.33% + 0.5px)),
|
|
74
|
+
linear-gradient(to right, transparent calc(66.66% - 0.5px), rgba(255,255,255,0.16) 66.66%, transparent calc(66.66% + 0.5px)),
|
|
75
|
+
linear-gradient(to bottom, transparent calc(33.33% - 0.5px), rgba(255,255,255,0.16) 33.33%, transparent calc(33.33% + 0.5px)),
|
|
76
|
+
linear-gradient(to bottom, transparent calc(66.66% - 0.5px), rgba(255,255,255,0.16) 66.66%, transparent calc(66.66% + 0.5px))`,
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Accented corner brackets on the framing rect. */
|
|
83
|
+
function CornerAccents() {
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<span className="pointer-events-none absolute -top-0.5 -left-0.5 h-5 w-5 rounded-tl-md border-primary border-t-[2.5px] border-l-[2.5px]" />
|
|
87
|
+
<span className="pointer-events-none absolute -right-0.5 -bottom-0.5 h-5 w-5 rounded-br-md border-primary border-r-[2.5px] border-b-[2.5px]" />
|
|
88
|
+
</>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const HUD_CHIP_CLASS =
|
|
93
|
+
'flex flex-col gap-px rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 backdrop-blur-md'
|
|
94
|
+
|
|
95
|
+
const CROP_LABELS: Record<CropMode, string> = {
|
|
96
|
+
standard: 'Standard',
|
|
97
|
+
viewport: 'Viewport',
|
|
98
|
+
area: 'Area',
|
|
99
|
+
}
|
|
100
|
+
|
|
52
101
|
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
53
102
|
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
|
54
103
|
const captureMode = useEditor((s) => s.captureMode)
|
|
@@ -58,12 +107,29 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
58
107
|
// a transparent background — the user picks framing but not the
|
|
59
108
|
// crop shape. Matches the unified preset-thumbnail capture flow.
|
|
60
109
|
const isPreset = captureMode.mode === 'preset'
|
|
110
|
+
const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined
|
|
111
|
+
const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined
|
|
61
112
|
|
|
62
113
|
const [mode, setMode] = useState<CropMode>('standard')
|
|
114
|
+
const [standardAspect, setStandardAspect] = useState<StandardAspect>('16:9')
|
|
115
|
+
const [aspectMenuOpen, setAspectMenuOpen] = useState(false)
|
|
63
116
|
const [drag, setDrag] = useState<Drag | null>(null)
|
|
64
117
|
const [isDragging, setIsDragging] = useState(false)
|
|
65
118
|
const [captureState, setCaptureState] = useState<CaptureState>('idle')
|
|
66
119
|
const overlayRef = useRef<HTMLDivElement>(null)
|
|
120
|
+
// Overlay size drives the computed standard-frame rect + resolution HUD.
|
|
121
|
+
const [overlaySize, setOverlaySize] = useState<{ w: number; h: number } | null>(null)
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!isCaptureMode) return
|
|
125
|
+
const el = overlayRef.current
|
|
126
|
+
if (!el) return
|
|
127
|
+
const update = () => setOverlaySize({ w: el.clientWidth, h: el.clientHeight })
|
|
128
|
+
update()
|
|
129
|
+
const observer = new ResizeObserver(update)
|
|
130
|
+
observer.observe(el)
|
|
131
|
+
return () => observer.disconnect()
|
|
132
|
+
}, [isCaptureMode])
|
|
67
133
|
|
|
68
134
|
// Dismiss on Esc
|
|
69
135
|
useEffect(() => {
|
|
@@ -82,7 +148,9 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
82
148
|
// tweak the framing, but they don't have to draw the rect first.
|
|
83
149
|
useEffect(() => {
|
|
84
150
|
if (!isCaptureMode) return
|
|
85
|
-
setMode(isPreset ? 'area' : 'standard')
|
|
151
|
+
setMode(isPreset ? 'area' : (requestedCrop ?? 'standard'))
|
|
152
|
+
setStandardAspect(requestedAspect ?? '16:9')
|
|
153
|
+
setAspectMenuOpen(false)
|
|
86
154
|
setIsDragging(false)
|
|
87
155
|
setCaptureState('idle')
|
|
88
156
|
if (isPreset && overlayRef.current) {
|
|
@@ -97,7 +165,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
97
165
|
} else {
|
|
98
166
|
setDrag(null)
|
|
99
167
|
}
|
|
100
|
-
}, [isCaptureMode, isPreset])
|
|
168
|
+
}, [isCaptureMode, isPreset, requestedCrop, requestedAspect])
|
|
101
169
|
|
|
102
170
|
// Listen for snapshot saved to show feedback then exit
|
|
103
171
|
useEffect(() => {
|
|
@@ -265,16 +333,36 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
265
333
|
projectId,
|
|
266
334
|
captureMode: mode,
|
|
267
335
|
cropRegion,
|
|
336
|
+
standardSize: mode === 'standard' ? STANDARD_SIZES[standardAspect] : undefined,
|
|
268
337
|
// In preset mode, the ThumbnailGenerator should keep the alpha
|
|
269
338
|
// channel transparent so the saved preset thumbnail composes
|
|
270
339
|
// cleanly onto any palette background.
|
|
271
340
|
transparent: isPreset,
|
|
272
341
|
})
|
|
273
|
-
}, [captureState, mode, drag, projectId, isPreset])
|
|
342
|
+
}, [captureState, mode, drag, projectId, isPreset, standardAspect])
|
|
274
343
|
|
|
275
344
|
if (!isCaptureMode) return null
|
|
276
345
|
|
|
277
|
-
const resolution = getResolution(mode, overlayRef.current, drag)
|
|
346
|
+
const resolution = getResolution(mode, overlayRef.current, drag, standardAspect)
|
|
347
|
+
|
|
348
|
+
// Standard mode framing: the output is a center-crop of the canvas to the
|
|
349
|
+
// chosen aspect (see ThumbnailGenerator) — show exactly that region as a
|
|
350
|
+
// letterboxed frame.
|
|
351
|
+
const standardFrame =
|
|
352
|
+
mode === 'standard' && overlaySize
|
|
353
|
+
? (() => {
|
|
354
|
+
const size = STANDARD_SIZES[standardAspect]
|
|
355
|
+
const targetRatio = size.w / size.h
|
|
356
|
+
const w = Math.min(overlaySize.w, overlaySize.h * targetRatio)
|
|
357
|
+
const h = w / targetRatio
|
|
358
|
+
return {
|
|
359
|
+
left: (overlaySize.w - w) / 2,
|
|
360
|
+
top: (overlaySize.h - h) / 2,
|
|
361
|
+
width: w,
|
|
362
|
+
height: h,
|
|
363
|
+
}
|
|
364
|
+
})()
|
|
365
|
+
: null
|
|
278
366
|
|
|
279
367
|
// Area selection rect (CSS px, relative to overlay)
|
|
280
368
|
const selectionStyle =
|
|
@@ -294,6 +382,23 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
294
382
|
|
|
295
383
|
return (
|
|
296
384
|
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
|
|
385
|
+
{/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */}
|
|
386
|
+
{standardFrame && (
|
|
387
|
+
<div
|
|
388
|
+
className="pointer-events-none absolute rounded-md border-[1.5px] border-white/85"
|
|
389
|
+
style={{
|
|
390
|
+
...standardFrame,
|
|
391
|
+
boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
<ThirdsGrid />
|
|
395
|
+
<CornerAccents />
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
{/* Viewport mode: the whole canvas is the frame — thirds only */}
|
|
400
|
+
{mode === 'viewport' && <ThirdsGrid />}
|
|
401
|
+
|
|
297
402
|
{/* Area mode: dim layer + crosshair cursor + drag-to-select.
|
|
298
403
|
*
|
|
299
404
|
* Preset mode reuses the same DOM but stays click-through: the
|
|
@@ -336,7 +441,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
336
441
|
has a pre-staged square, so we never show it there. */}
|
|
337
442
|
{!selectionStyle && !isPreset && (
|
|
338
443
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
339
|
-
<span className="rounded-full bg-
|
|
444
|
+
<span className="rounded-full border border-white/10 bg-neutral-950/80 px-4 py-2 text-sm text-white backdrop-blur-md">
|
|
340
445
|
Drag the area you want to capture
|
|
341
446
|
</span>
|
|
342
447
|
</div>
|
|
@@ -345,6 +450,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
345
450
|
{/* Selection rect */}
|
|
346
451
|
{selectionStyle && (
|
|
347
452
|
<div
|
|
453
|
+
className="rounded-sm border-[1.5px] border-white/85"
|
|
348
454
|
style={{
|
|
349
455
|
position: 'absolute',
|
|
350
456
|
left: selectionStyle.left,
|
|
@@ -352,11 +458,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
352
458
|
width: selectionStyle.width,
|
|
353
459
|
height: selectionStyle.height,
|
|
354
460
|
pointerEvents: 'none',
|
|
355
|
-
boxShadow: '0 0 0 9999px rgba(
|
|
356
|
-
|
|
357
|
-
background: 'rgba(255,255,255,0.04)',
|
|
461
|
+
boxShadow: '0 0 0 9999px rgba(10,10,14,0.4)',
|
|
462
|
+
background: 'rgba(255,255,255,0.03)',
|
|
358
463
|
}}
|
|
359
464
|
>
|
|
465
|
+
{hasSelection && <ThirdsGrid />}
|
|
466
|
+
<CornerAccents />
|
|
360
467
|
{/* Corner handles — preset mode locks the frame to the
|
|
361
468
|
auto-staged centered square; the user adjusts the
|
|
362
469
|
camera instead. */}
|
|
@@ -390,11 +497,34 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
390
497
|
</div>
|
|
391
498
|
)}
|
|
392
499
|
|
|
500
|
+
{/* Top-center HUD — what the shot will be. Preset captures are a fixed
|
|
501
|
+
square and carry their own "Frame your item" banner up there. */}
|
|
502
|
+
{!isMobile && !isPreset && (
|
|
503
|
+
<div className="pointer-events-none absolute top-4 left-1/2 flex -translate-x-1/2 gap-2">
|
|
504
|
+
<div className={HUD_CHIP_CLASS}>
|
|
505
|
+
<span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
|
|
506
|
+
Crop
|
|
507
|
+
</span>
|
|
508
|
+
<span className="font-semibold text-white text-xs">
|
|
509
|
+
{isPreset ? 'Preset · square' : CROP_LABELS[mode]}
|
|
510
|
+
</span>
|
|
511
|
+
</div>
|
|
512
|
+
<div className={HUD_CHIP_CLASS}>
|
|
513
|
+
<span className="font-mono text-[8.5px] text-white/50 uppercase tracking-[0.14em]">
|
|
514
|
+
Format
|
|
515
|
+
</span>
|
|
516
|
+
<span className="font-semibold text-white text-xs tabular-nums">
|
|
517
|
+
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
|
|
518
|
+
</span>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
|
|
393
523
|
{/* Top-right dismiss button (icon-only on mobile) */}
|
|
394
524
|
<div className="pointer-events-auto absolute top-4 right-4">
|
|
395
525
|
<button
|
|
396
526
|
aria-label="Close capture mode"
|
|
397
|
-
className="flex items-center gap-1.5 rounded-
|
|
527
|
+
className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 text-white/80 text-xs backdrop-blur-md transition-colors hover:bg-neutral-950 hover:text-white"
|
|
398
528
|
onClick={dismiss}
|
|
399
529
|
type="button"
|
|
400
530
|
>
|
|
@@ -403,104 +533,114 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
|
|
|
403
533
|
</button>
|
|
404
534
|
</div>
|
|
405
535
|
|
|
406
|
-
{/*
|
|
407
|
-
<div className="pointer-events-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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>
|
|
536
|
+
{/* Subtle scrim so the bottom controls stay readable on bright scenes */}
|
|
537
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/45 via-black/15 to-transparent" />
|
|
538
|
+
|
|
539
|
+
{/* Bottom-center: crop switcher, caption + shutter */}
|
|
540
|
+
<div className="pointer-events-none absolute right-0 bottom-5 left-0 flex flex-col items-center gap-2.5">
|
|
541
|
+
{!isPreset && (
|
|
542
|
+
<div className="pointer-events-auto relative flex items-center gap-1 rounded-full border border-white/10 bg-neutral-950/85 px-1.5 py-1.5 shadow-xl backdrop-blur-md">
|
|
543
|
+
{/* Clicking Standard while it's active opens the aspect picker */}
|
|
544
|
+
<ModeButton
|
|
545
|
+
active={mode === 'standard'}
|
|
546
|
+
badge={standardAspect}
|
|
547
|
+
icon={<Monitor className="h-3.5 w-3.5" />}
|
|
548
|
+
label={isMobile ? undefined : 'Standard'}
|
|
549
|
+
onClick={() => {
|
|
550
|
+
if (mode === 'standard') setAspectMenuOpen((v) => !v)
|
|
551
|
+
else setAspectMenuOpen(false)
|
|
552
|
+
setMode('standard')
|
|
553
|
+
setDrag(null)
|
|
554
|
+
}}
|
|
555
|
+
/>
|
|
556
|
+
{aspectMenuOpen && mode === 'standard' && (
|
|
557
|
+
<div className="absolute bottom-[calc(100%+8px)] left-0 flex gap-1 rounded-full border border-white/10 bg-neutral-950/90 p-1.5 shadow-xl backdrop-blur-md">
|
|
558
|
+
{(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => (
|
|
559
|
+
<button
|
|
560
|
+
className={`rounded-full px-2.5 py-1 font-mono text-[11px] transition-colors ${
|
|
561
|
+
aspect === standardAspect
|
|
562
|
+
? 'bg-white/15 text-white ring-1 ring-white/20'
|
|
563
|
+
: 'text-white/55 hover:text-white/90'
|
|
564
|
+
}`}
|
|
565
|
+
key={aspect}
|
|
566
|
+
onClick={() => {
|
|
567
|
+
setStandardAspect(aspect)
|
|
568
|
+
setAspectMenuOpen(false)
|
|
569
|
+
}}
|
|
570
|
+
type="button"
|
|
571
|
+
>
|
|
572
|
+
{aspect}
|
|
573
|
+
</button>
|
|
574
|
+
))}
|
|
490
575
|
</div>
|
|
491
|
-
)
|
|
492
|
-
|
|
576
|
+
)}
|
|
577
|
+
<ModeButton
|
|
578
|
+
active={mode === 'viewport'}
|
|
579
|
+
icon={<Maximize2 className="h-3.5 w-3.5" />}
|
|
580
|
+
label={isMobile ? undefined : 'Viewport'}
|
|
581
|
+
onClick={() => {
|
|
582
|
+
setMode('viewport')
|
|
583
|
+
setDrag(null)
|
|
584
|
+
setAspectMenuOpen(false)
|
|
585
|
+
}}
|
|
586
|
+
/>
|
|
587
|
+
<ModeButton
|
|
588
|
+
active={mode === 'area'}
|
|
589
|
+
icon={<Crop className="h-3.5 w-3.5" />}
|
|
590
|
+
label={isMobile ? undefined : 'Area'}
|
|
591
|
+
onClick={() => {
|
|
592
|
+
setMode('area')
|
|
593
|
+
setAspectMenuOpen(false)
|
|
594
|
+
}}
|
|
595
|
+
/>
|
|
596
|
+
{isMobile && (
|
|
597
|
+
<span className="px-2 text-white/50 text-xs tabular-nums">
|
|
598
|
+
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
|
|
599
|
+
</span>
|
|
600
|
+
)}
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
|
|
604
|
+
{/* Preset captures carry their own "Frame your item" banner — the
|
|
605
|
+
snapshot pitch only applies to the studio/reference flow. */}
|
|
606
|
+
{!isMobile && !isPreset && (
|
|
607
|
+
<span className="pointer-events-none max-w-90 rounded-lg border border-white/10 bg-neutral-950/85 px-3.5 py-1.5 text-center text-[11.5px] text-white/85 leading-relaxed backdrop-blur-md">
|
|
608
|
+
A <b className="font-semibold text-white">snapshot</b>
|
|
609
|
+
{' freezes this exact camera angle as a reusable reference for renders & videos.'}
|
|
610
|
+
</span>
|
|
611
|
+
)}
|
|
493
612
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
613
|
+
<button
|
|
614
|
+
aria-label={isPreset ? 'Capture' : 'Take snapshot'}
|
|
615
|
+
className="group pointer-events-auto relative grid h-14 w-14 place-items-center rounded-full disabled:opacity-50"
|
|
616
|
+
disabled={captureDisabled}
|
|
617
|
+
onClick={handleCapture}
|
|
618
|
+
type="button"
|
|
619
|
+
>
|
|
620
|
+
<span className="absolute inset-0 rounded-full border-[3px] border-white shadow-lg" />
|
|
621
|
+
<span
|
|
622
|
+
className={`h-10 w-10 rounded-full transition-transform duration-100 ${
|
|
623
|
+
captureState === 'capturing'
|
|
624
|
+
? 'scale-[0.55] bg-primary'
|
|
625
|
+
: 'bg-white group-hover:scale-90 group-active:scale-75'
|
|
626
|
+
}`}
|
|
627
|
+
/>
|
|
628
|
+
{captureState === 'capturing' && (
|
|
629
|
+
<Loader2 className="absolute h-4 w-4 animate-spin text-white" />
|
|
630
|
+
)}
|
|
631
|
+
{captureState === 'saved' && (
|
|
632
|
+
<Check className="absolute h-5 w-5 stroke-3 text-neutral-900" />
|
|
633
|
+
)}
|
|
634
|
+
</button>
|
|
635
|
+
<span className="pointer-events-none font-mono text-[10.5px] text-white uppercase tracking-[0.12em] drop-shadow">
|
|
636
|
+
{captureState === 'capturing'
|
|
637
|
+
? 'Capturing…'
|
|
638
|
+
: captureState === 'saved'
|
|
639
|
+
? 'Saved'
|
|
640
|
+
: isPreset
|
|
641
|
+
? 'Capture'
|
|
642
|
+
: 'Take snapshot'}
|
|
643
|
+
</span>
|
|
504
644
|
</div>
|
|
505
645
|
</div>
|
|
506
646
|
)
|
|
@@ -515,7 +655,7 @@ function ModeButton({
|
|
|
515
655
|
}: {
|
|
516
656
|
active: boolean
|
|
517
657
|
icon: React.ReactNode
|
|
518
|
-
label
|
|
658
|
+
label?: string
|
|
519
659
|
badge?: string
|
|
520
660
|
onClick: () => void
|
|
521
661
|
}) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Camera, Raycaster } from 'three'
|
|
2
|
+
|
|
3
|
+
// Escape hatch for DOM-level interaction sessions (group pick-up move) that
|
|
4
|
+
// need to raycast the 3D view without living inside the R3F tree. The
|
|
5
|
+
// editor's SelectionManager (always mounted with the canvas) publishes the
|
|
6
|
+
// live camera / raycaster / canvas here; consumers must handle `null`
|
|
7
|
+
// (canvas not mounted yet, or torn down).
|
|
8
|
+
export type EditorThreeContext = {
|
|
9
|
+
camera: Camera
|
|
10
|
+
raycaster: Raycaster
|
|
11
|
+
domElement: HTMLCanvasElement
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let current: EditorThreeContext | null = null
|
|
15
|
+
|
|
16
|
+
export function setEditorThreeContext(ctx: EditorThreeContext | null) {
|
|
17
|
+
current = ctx
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getEditorThreeContext(): EditorThreeContext | null {
|
|
21
|
+
return current
|
|
22
|
+
}
|