@pascal-app/editor 0.4.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 +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { AnimatePresence } from 'motion/react'
|
|
4
|
+
import Image from 'next/image'
|
|
5
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
6
|
+
import useEditor from '../../../../../store/use-editor'
|
|
7
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
8
|
+
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
9
|
+
import { TreeNodeActions } from './tree-node-actions'
|
|
10
|
+
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
|
11
|
+
|
|
12
|
+
interface StairTreeNodeProps {
|
|
13
|
+
node: StairNode
|
|
14
|
+
depth: number
|
|
15
|
+
isLast?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
19
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
20
|
+
const [expanded, setExpanded] = useState(false)
|
|
21
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
22
|
+
const isSelected = selectedIds.includes(node.id)
|
|
23
|
+
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
|
24
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
25
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
26
|
+
const nodes = useScene((state) => state.nodes)
|
|
27
|
+
const { drag, dropTarget } = useTreeNodeDrag()
|
|
28
|
+
|
|
29
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
30
|
+
e.stopPropagation()
|
|
31
|
+
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
|
32
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
33
|
+
useEditor.getState().setPhase('structure')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const handleDoubleClick = () => {
|
|
38
|
+
focusTreeNode(node.id)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const handleMouseEnter = () => {
|
|
42
|
+
setHoveredId(node.id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const handleMouseLeave = () => {
|
|
46
|
+
setHoveredId(null)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const segments = (node.children ?? [])
|
|
50
|
+
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
|
51
|
+
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
|
52
|
+
|
|
53
|
+
const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (isSelected || hasSelectedChild) {
|
|
57
|
+
setExpanded(true)
|
|
58
|
+
}
|
|
59
|
+
}, [isSelected, hasSelectedChild])
|
|
60
|
+
|
|
61
|
+
// Auto-expand when a segment is being dragged over this stair
|
|
62
|
+
const isDropTarget = drag !== null && dropTarget?.parentId === node.id
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (isDropTarget && !expanded) {
|
|
65
|
+
setExpanded(true)
|
|
66
|
+
}
|
|
67
|
+
}, [isDropTarget, expanded])
|
|
68
|
+
|
|
69
|
+
const segmentCount = segments.length
|
|
70
|
+
const defaultName = `Staircase (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`
|
|
71
|
+
|
|
72
|
+
// Hide the dragged segment from every stair while dragging
|
|
73
|
+
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
|
|
74
|
+
|
|
75
|
+
const isValidDropTarget = drag !== null && drag.nodeId !== node.id
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div data-drop-target={node.id}>
|
|
79
|
+
<TreeNodeWrapper
|
|
80
|
+
actions={<TreeNodeActions node={node} />}
|
|
81
|
+
depth={depth}
|
|
82
|
+
expanded={expanded}
|
|
83
|
+
hasChildren={segments.length > 0}
|
|
84
|
+
icon={
|
|
85
|
+
<Image alt="" className="object-contain" height={14} src="/icons/stairs.png" width={14} />
|
|
86
|
+
}
|
|
87
|
+
isDropTarget={isValidDropTarget && isDropTarget}
|
|
88
|
+
isHovered={isHovered || isDropTarget}
|
|
89
|
+
isLast={isLast && !expanded}
|
|
90
|
+
isSelected={isSelected}
|
|
91
|
+
isVisible={node.visible !== false}
|
|
92
|
+
label={
|
|
93
|
+
<InlineRenameInput
|
|
94
|
+
defaultName={defaultName}
|
|
95
|
+
isEditing={isEditing}
|
|
96
|
+
node={node}
|
|
97
|
+
onStartEditing={() => setIsEditing(true)}
|
|
98
|
+
onStopEditing={() => setIsEditing(false)}
|
|
99
|
+
/>
|
|
100
|
+
}
|
|
101
|
+
nodeId={node.id}
|
|
102
|
+
onClick={handleClick}
|
|
103
|
+
onDoubleClick={handleDoubleClick}
|
|
104
|
+
onMouseEnter={handleMouseEnter}
|
|
105
|
+
onMouseLeave={handleMouseLeave}
|
|
106
|
+
onToggle={() => setExpanded(!expanded)}
|
|
107
|
+
>
|
|
108
|
+
{visibleSegments.map((seg, i) => {
|
|
109
|
+
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
|
|
110
|
+
const showIndicatorAfter =
|
|
111
|
+
isDropTarget &&
|
|
112
|
+
i === visibleSegments.length - 1 &&
|
|
113
|
+
dropTarget?.insertIndex !== undefined &&
|
|
114
|
+
dropTarget.insertIndex > i
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div key={seg.id}>
|
|
118
|
+
<AnimatePresence>
|
|
119
|
+
{showIndicatorBefore && <DropIndicatorLine key="indicator-before" />}
|
|
120
|
+
</AnimatePresence>
|
|
121
|
+
<StairSegmentTreeNode
|
|
122
|
+
depth={depth + 1}
|
|
123
|
+
isLast={isLast && i === visibleSegments.length - 1 && !showIndicatorAfter}
|
|
124
|
+
node={seg}
|
|
125
|
+
/>
|
|
126
|
+
<AnimatePresence>
|
|
127
|
+
{showIndicatorAfter && <DropIndicatorLine key="indicator-after" />}
|
|
128
|
+
</AnimatePresence>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
})}
|
|
132
|
+
<AnimatePresence>
|
|
133
|
+
{isDropTarget && visibleSegments.length === 0 && <DropIndicatorLine />}
|
|
134
|
+
</AnimatePresence>
|
|
135
|
+
</TreeNodeWrapper>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function StairSegmentTreeNode({
|
|
141
|
+
node,
|
|
142
|
+
depth,
|
|
143
|
+
isLast,
|
|
144
|
+
}: {
|
|
145
|
+
node: StairSegmentNode
|
|
146
|
+
depth: number
|
|
147
|
+
isLast?: boolean
|
|
148
|
+
}) {
|
|
149
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
150
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
151
|
+
const isSelected = selectedIds.includes(node.id)
|
|
152
|
+
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
|
153
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
154
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
155
|
+
const { startDrag, isDragging } = useTreeNodeDrag()
|
|
156
|
+
|
|
157
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
158
|
+
if (isDragging) return
|
|
159
|
+
e.stopPropagation()
|
|
160
|
+
handleTreeSelection(e, node.id, selectedIds, setSelection)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const handlePointerDown = useCallback(
|
|
164
|
+
(e: React.PointerEvent) => {
|
|
165
|
+
if (e.button !== 0) return
|
|
166
|
+
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
|
|
167
|
+
const label = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
|
|
168
|
+
startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)
|
|
169
|
+
},
|
|
170
|
+
[node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
|
|
174
|
+
const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div data-drop-child={node.id}>
|
|
178
|
+
<TreeNodeWrapper
|
|
179
|
+
actions={<TreeNodeActions node={node} />}
|
|
180
|
+
depth={depth}
|
|
181
|
+
expanded={false}
|
|
182
|
+
hasChildren={false}
|
|
183
|
+
icon={
|
|
184
|
+
<Image
|
|
185
|
+
alt=""
|
|
186
|
+
className="object-contain opacity-60"
|
|
187
|
+
height={14}
|
|
188
|
+
src="/icons/stairs.png"
|
|
189
|
+
width={14}
|
|
190
|
+
/>
|
|
191
|
+
}
|
|
192
|
+
isDraggable
|
|
193
|
+
isHovered={isHovered}
|
|
194
|
+
isLast={isLast}
|
|
195
|
+
isSelected={isSelected}
|
|
196
|
+
isVisible={node.visible !== false}
|
|
197
|
+
label={
|
|
198
|
+
<InlineRenameInput
|
|
199
|
+
defaultName={defaultName}
|
|
200
|
+
isEditing={isEditing}
|
|
201
|
+
node={node}
|
|
202
|
+
onStartEditing={() => setIsEditing(true)}
|
|
203
|
+
onStopEditing={() => setIsEditing(false)}
|
|
204
|
+
/>
|
|
205
|
+
}
|
|
206
|
+
nodeId={node.id}
|
|
207
|
+
onClick={handleClick}
|
|
208
|
+
onDoubleClick={() => focusTreeNode(node.id)}
|
|
209
|
+
onMouseEnter={() => setHoveredId(node.id)}
|
|
210
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
211
|
+
onPointerDown={handlePointerDown}
|
|
212
|
+
onToggle={() => {}}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import {
|
|
6
|
+
Popover,
|
|
7
|
+
PopoverContent,
|
|
8
|
+
PopoverTrigger,
|
|
9
|
+
} from './../../../../../components/ui/primitives/popover'
|
|
10
|
+
|
|
11
|
+
interface TreeNodeActionsProps {
|
|
12
|
+
node: AnyNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
|
16
|
+
const [open, setOpen] = useState(false)
|
|
17
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
18
|
+
const updateNodes = useScene((state) => state.updateNodes)
|
|
19
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
20
|
+
const hasCamera = !!node.camera
|
|
21
|
+
const isVisible = node.visible !== false
|
|
22
|
+
|
|
23
|
+
const toggleVisibility = (e: React.MouseEvent) => {
|
|
24
|
+
e.stopPropagation()
|
|
25
|
+
const newVisibility = !isVisible
|
|
26
|
+
if (selectedIds?.includes(node.id)) {
|
|
27
|
+
updateNodes(
|
|
28
|
+
selectedIds.map((id) => ({
|
|
29
|
+
id: id as AnyNodeId,
|
|
30
|
+
data: { visible: newVisibility },
|
|
31
|
+
})),
|
|
32
|
+
)
|
|
33
|
+
} else {
|
|
34
|
+
updateNode(node.id, { visible: newVisibility })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const handleCaptureCamera = (e: React.MouseEvent) => {
|
|
39
|
+
e.stopPropagation()
|
|
40
|
+
emitter.emit('camera-controls:capture', { nodeId: node.id })
|
|
41
|
+
setOpen(false)
|
|
42
|
+
}
|
|
43
|
+
const handleViewCamera = (e: React.MouseEvent) => {
|
|
44
|
+
e.stopPropagation()
|
|
45
|
+
emitter.emit('camera-controls:view', { nodeId: node.id })
|
|
46
|
+
setOpen(false)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const handleClearCamera = (e: React.MouseEvent) => {
|
|
50
|
+
e.stopPropagation()
|
|
51
|
+
updateNode(node.id, { camera: undefined })
|
|
52
|
+
setOpen(false)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex items-center gap-0.5">
|
|
57
|
+
<button
|
|
58
|
+
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
|
59
|
+
onClick={toggleVisibility}
|
|
60
|
+
title={isVisible ? 'Hide' : 'Show'}
|
|
61
|
+
>
|
|
62
|
+
{isVisible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3 opacity-50" />}
|
|
63
|
+
</button>
|
|
64
|
+
|
|
65
|
+
<Popover onOpenChange={setOpen} open={open}>
|
|
66
|
+
<PopoverTrigger asChild>
|
|
67
|
+
<button
|
|
68
|
+
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
|
69
|
+
onClick={(e) => e.stopPropagation()}
|
|
70
|
+
title="Camera snapshot"
|
|
71
|
+
>
|
|
72
|
+
<Camera className="h-3 w-3" />
|
|
73
|
+
{hasCamera && (
|
|
74
|
+
<span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
|
75
|
+
)}
|
|
76
|
+
</button>
|
|
77
|
+
</PopoverTrigger>
|
|
78
|
+
<PopoverContent
|
|
79
|
+
align="start"
|
|
80
|
+
className="w-auto p-1"
|
|
81
|
+
onClick={(e) => e.stopPropagation()}
|
|
82
|
+
side="right"
|
|
83
|
+
>
|
|
84
|
+
<div className="flex flex-col gap-0.5">
|
|
85
|
+
{hasCamera && (
|
|
86
|
+
<button
|
|
87
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
|
|
88
|
+
onClick={handleViewCamera}
|
|
89
|
+
>
|
|
90
|
+
<Camera className="h-3.5 w-3.5" />
|
|
91
|
+
View snapshot
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
94
|
+
<button
|
|
95
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
|
|
96
|
+
onClick={handleCaptureCamera}
|
|
97
|
+
>
|
|
98
|
+
<Camera className="h-3.5 w-3.5" />
|
|
99
|
+
{hasCamera ? 'Update snapshot' : 'Take snapshot'}
|
|
100
|
+
</button>
|
|
101
|
+
{hasCamera && (
|
|
102
|
+
<button
|
|
103
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
|
|
104
|
+
onClick={handleClearCamera}
|
|
105
|
+
>
|
|
106
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
107
|
+
Clear snapshot
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</PopoverContent>
|
|
112
|
+
</Popover>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
|
+
import { motion } from 'motion/react'
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
useCallback,
|
|
9
|
+
useContext,
|
|
10
|
+
useEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import { createPortal } from 'react-dom'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Reparenting rules
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
// Maps a draggable node type to the parent types it can be dropped into.
|
|
21
|
+
const REPARENT_TARGETS: Record<string, string[]> = {
|
|
22
|
+
'roof-segment': ['roof'],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Container types that should be auto-removed when all children are moved out.
|
|
26
|
+
const REMOVE_WHEN_EMPTY = new Set(['roof'])
|
|
27
|
+
|
|
28
|
+
export function canDrag(node: AnyNode): boolean {
|
|
29
|
+
return node.type in REPARENT_TARGETS
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canDrop(draggedType: string, targetType: string): boolean {
|
|
33
|
+
return REPARENT_TARGETS[draggedType]?.includes(targetType) ?? false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Coordinate preservation
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
type Transform = {
|
|
41
|
+
position: [number, number, number]
|
|
42
|
+
rotation: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getTransform(node: AnyNode): Transform {
|
|
46
|
+
const pos =
|
|
47
|
+
'position' in node && Array.isArray(node.position)
|
|
48
|
+
? (node.position as [number, number, number])
|
|
49
|
+
: ([0, 0, 0] as [number, number, number])
|
|
50
|
+
const rot = 'rotation' in node && typeof node.rotation === 'number' ? node.rotation : 0
|
|
51
|
+
return { position: pos, rotation: rot }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute new local position + rotation so the child stays at the same
|
|
56
|
+
* absolute grid position when moved from oldParent to newParent.
|
|
57
|
+
*/
|
|
58
|
+
function computeReparentTransform(
|
|
59
|
+
child: Transform,
|
|
60
|
+
oldParent: Transform,
|
|
61
|
+
newParent: Transform,
|
|
62
|
+
): Transform {
|
|
63
|
+
// child → world: world = parentPos + rotateY(childPos, parentRot)
|
|
64
|
+
const cosOld = Math.cos(oldParent.rotation)
|
|
65
|
+
const sinOld = Math.sin(oldParent.rotation)
|
|
66
|
+
const absX = oldParent.position[0] + child.position[0] * cosOld + child.position[2] * sinOld
|
|
67
|
+
const absY = oldParent.position[1] + child.position[1]
|
|
68
|
+
const absZ = oldParent.position[2] - child.position[0] * sinOld + child.position[2] * cosOld
|
|
69
|
+
|
|
70
|
+
// world → newParent local: rotateY_inverse(world - newParentPos, newParentRot)
|
|
71
|
+
const dx = absX - newParent.position[0]
|
|
72
|
+
const dy = absY - newParent.position[1]
|
|
73
|
+
const dz = absZ - newParent.position[2]
|
|
74
|
+
const cosNew = Math.cos(-newParent.rotation)
|
|
75
|
+
const sinNew = Math.sin(-newParent.rotation)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
position: [dx * cosNew + dz * sinNew, dy, -dx * sinNew + dz * cosNew],
|
|
79
|
+
rotation: oldParent.rotation + child.rotation - newParent.rotation,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Types
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
type DragState = {
|
|
88
|
+
nodeId: string
|
|
89
|
+
nodeType: string
|
|
90
|
+
sourceParentId: string
|
|
91
|
+
label: string
|
|
92
|
+
pointerX: number
|
|
93
|
+
pointerY: number
|
|
94
|
+
} | null
|
|
95
|
+
|
|
96
|
+
type DropTarget = {
|
|
97
|
+
parentId: string
|
|
98
|
+
insertIndex: number
|
|
99
|
+
} | null
|
|
100
|
+
|
|
101
|
+
type TreeNodeDragContextValue = {
|
|
102
|
+
drag: DragState
|
|
103
|
+
dropTarget: DropTarget
|
|
104
|
+
startDrag: (
|
|
105
|
+
nodeId: string,
|
|
106
|
+
nodeType: string,
|
|
107
|
+
sourceParentId: string,
|
|
108
|
+
label: string,
|
|
109
|
+
x: number,
|
|
110
|
+
y: number,
|
|
111
|
+
) => void
|
|
112
|
+
isDragging: boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const TreeNodeDragContext = createContext<TreeNodeDragContextValue>({
|
|
116
|
+
drag: null,
|
|
117
|
+
dropTarget: null,
|
|
118
|
+
startDrag: () => {},
|
|
119
|
+
isDragging: false,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
export const useTreeNodeDrag = () => useContext(TreeNodeDragContext)
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Provider
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const DRAG_THRESHOLD = 4
|
|
129
|
+
|
|
130
|
+
export function TreeNodeDragProvider({ children }: { children: ReactNode }) {
|
|
131
|
+
const [drag, setDrag] = useState<DragState>(null)
|
|
132
|
+
const [dropTarget, setDropTarget] = useState<DropTarget>(null)
|
|
133
|
+
const pendingRef = useRef<{
|
|
134
|
+
nodeId: string
|
|
135
|
+
nodeType: string
|
|
136
|
+
sourceParentId: string
|
|
137
|
+
label: string
|
|
138
|
+
startX: number
|
|
139
|
+
startY: number
|
|
140
|
+
} | null>(null)
|
|
141
|
+
|
|
142
|
+
const commitDrop = useCallback(() => {
|
|
143
|
+
if (!(drag && dropTarget)) return
|
|
144
|
+
|
|
145
|
+
const state = useScene.getState()
|
|
146
|
+
|
|
147
|
+
if (dropTarget.parentId === drag.sourceParentId) {
|
|
148
|
+
// --- Reorder within same parent ---
|
|
149
|
+
const parent = state.nodes[dropTarget.parentId as AnyNodeId]
|
|
150
|
+
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
|
151
|
+
const currentChildren = [...parent.children] as string[]
|
|
152
|
+
const fromIndex = currentChildren.indexOf(drag.nodeId)
|
|
153
|
+
if (fromIndex === -1) return
|
|
154
|
+
currentChildren.splice(fromIndex, 1)
|
|
155
|
+
const toIndex = Math.min(dropTarget.insertIndex, currentChildren.length)
|
|
156
|
+
currentChildren.splice(toIndex, 0, drag.nodeId)
|
|
157
|
+
state.updateNode(dropTarget.parentId as AnyNodeId, { children: currentChildren } as any)
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// --- Reparent to different parent, preserving world position ---
|
|
161
|
+
const node = state.nodes[drag.nodeId as AnyNodeId]
|
|
162
|
+
const oldParent = state.nodes[drag.sourceParentId as AnyNodeId]
|
|
163
|
+
const newParent = state.nodes[dropTarget.parentId as AnyNodeId]
|
|
164
|
+
if (!(node && oldParent && newParent)) return
|
|
165
|
+
|
|
166
|
+
const newLocal = computeReparentTransform(
|
|
167
|
+
getTransform(node),
|
|
168
|
+
getTransform(oldParent),
|
|
169
|
+
getTransform(newParent),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
state.updateNode(
|
|
173
|
+
drag.nodeId as AnyNodeId,
|
|
174
|
+
{
|
|
175
|
+
parentId: dropTarget.parentId,
|
|
176
|
+
position: newLocal.position,
|
|
177
|
+
rotation: newLocal.rotation,
|
|
178
|
+
} as any,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// Place at the correct index within the new parent's children
|
|
182
|
+
const updatedParent = state.nodes[dropTarget.parentId as AnyNodeId]
|
|
183
|
+
if (updatedParent && 'children' in updatedParent && Array.isArray(updatedParent.children)) {
|
|
184
|
+
const children = [...updatedParent.children] as string[]
|
|
185
|
+
const idx = children.indexOf(drag.nodeId)
|
|
186
|
+
if (idx !== -1) {
|
|
187
|
+
children.splice(idx, 1)
|
|
188
|
+
const toIndex = Math.min(dropTarget.insertIndex, children.length)
|
|
189
|
+
children.splice(toIndex, 0, drag.nodeId)
|
|
190
|
+
state.updateNode(dropTarget.parentId as AnyNodeId, { children } as any)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Lifecycle: remove old parent if it's now empty and in REMOVE_WHEN_EMPTY
|
|
195
|
+
const staleParent = state.nodes[drag.sourceParentId as AnyNodeId]
|
|
196
|
+
if (
|
|
197
|
+
staleParent &&
|
|
198
|
+
REMOVE_WHEN_EMPTY.has(staleParent.type) &&
|
|
199
|
+
'children' in staleParent &&
|
|
200
|
+
Array.isArray(staleParent.children) &&
|
|
201
|
+
staleParent.children.length === 0
|
|
202
|
+
) {
|
|
203
|
+
state.deleteNode(drag.sourceParentId as AnyNodeId)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}, [drag, dropTarget])
|
|
207
|
+
|
|
208
|
+
const startDrag = useCallback(
|
|
209
|
+
(
|
|
210
|
+
nodeId: string,
|
|
211
|
+
nodeType: string,
|
|
212
|
+
sourceParentId: string,
|
|
213
|
+
label: string,
|
|
214
|
+
x: number,
|
|
215
|
+
y: number,
|
|
216
|
+
) => {
|
|
217
|
+
pendingRef.current = { nodeId, nodeType, sourceParentId, label, startX: x, startY: y }
|
|
218
|
+
},
|
|
219
|
+
[],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
224
|
+
if (pendingRef.current && !drag) {
|
|
225
|
+
const dx = e.clientX - pendingRef.current.startX
|
|
226
|
+
const dy = e.clientY - pendingRef.current.startY
|
|
227
|
+
if (Math.abs(dx) + Math.abs(dy) >= DRAG_THRESHOLD) {
|
|
228
|
+
const p = pendingRef.current
|
|
229
|
+
setDrag({
|
|
230
|
+
nodeId: p.nodeId,
|
|
231
|
+
nodeType: p.nodeType,
|
|
232
|
+
sourceParentId: p.sourceParentId,
|
|
233
|
+
label: p.label,
|
|
234
|
+
pointerX: e.clientX,
|
|
235
|
+
pointerY: e.clientY,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!drag) return
|
|
242
|
+
|
|
243
|
+
setDrag((prev) => (prev ? { ...prev, pointerX: e.clientX, pointerY: e.clientY } : null))
|
|
244
|
+
|
|
245
|
+
// Hit-test for drop targets
|
|
246
|
+
const els = document.elementsFromPoint(e.clientX, e.clientY)
|
|
247
|
+
let foundTarget: DropTarget = null
|
|
248
|
+
|
|
249
|
+
for (const el of els) {
|
|
250
|
+
const targetEl = (el as HTMLElement).closest?.('[data-drop-target]') as HTMLElement | null
|
|
251
|
+
if (!targetEl) continue
|
|
252
|
+
|
|
253
|
+
const parentId = targetEl.dataset.dropTarget!
|
|
254
|
+
|
|
255
|
+
// Validate this is a legal drop
|
|
256
|
+
const targetNode = useScene.getState().nodes[parentId as AnyNodeId]
|
|
257
|
+
if (!(targetNode && canDrop(drag.nodeType, targetNode.type))) continue
|
|
258
|
+
|
|
259
|
+
// Find child rows to determine insert index
|
|
260
|
+
const childRows = targetEl.querySelectorAll<HTMLElement>('[data-drop-child]')
|
|
261
|
+
let insertIndex = childRows.length
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < childRows.length; i++) {
|
|
264
|
+
const row = childRows[i]!
|
|
265
|
+
const rect = row.getBoundingClientRect()
|
|
266
|
+
const midY = rect.top + rect.height / 2
|
|
267
|
+
if (e.clientY < midY) {
|
|
268
|
+
insertIndex = i
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
foundTarget = { parentId, insertIndex }
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
setDropTarget(foundTarget)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const handlePointerUp = () => {
|
|
281
|
+
if (drag) {
|
|
282
|
+
commitDrop()
|
|
283
|
+
}
|
|
284
|
+
pendingRef.current = null
|
|
285
|
+
setDrag(null)
|
|
286
|
+
setDropTarget(null)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
window.addEventListener('pointermove', handlePointerMove)
|
|
290
|
+
window.addEventListener('pointerup', handlePointerUp)
|
|
291
|
+
return () => {
|
|
292
|
+
window.removeEventListener('pointermove', handlePointerMove)
|
|
293
|
+
window.removeEventListener('pointerup', handlePointerUp)
|
|
294
|
+
}
|
|
295
|
+
}, [drag, commitDrop])
|
|
296
|
+
|
|
297
|
+
const isDragging = drag !== null
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<TreeNodeDragContext.Provider value={{ drag, dropTarget, startDrag, isDragging }}>
|
|
301
|
+
{isDragging && <style>{'* { cursor: grabbing !important; }'}</style>}
|
|
302
|
+
{children}
|
|
303
|
+
{drag && <FloatingPreview drag={drag} />}
|
|
304
|
+
</TreeNodeDragContext.Provider>
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Floating preview (portal)
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
function FloatingPreview({ drag }: { drag: NonNullable<DragState> }) {
|
|
313
|
+
return createPortal(
|
|
314
|
+
<div
|
|
315
|
+
className="pointer-events-none fixed z-[200] flex items-center gap-1.5 rounded-lg border border-accent bg-background/95 px-2.5 py-1.5 font-medium text-foreground text-xs shadow-xl backdrop-blur-sm"
|
|
316
|
+
style={{
|
|
317
|
+
left: drag.pointerX + 12,
|
|
318
|
+
top: drag.pointerY - 14,
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<span className="opacity-60">↕</span>
|
|
322
|
+
{drag.label}
|
|
323
|
+
</div>,
|
|
324
|
+
document.body,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Drop indicator line
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
export function DropIndicatorLine() {
|
|
333
|
+
return (
|
|
334
|
+
<motion.div
|
|
335
|
+
animate={{ height: 2, opacity: 1 }}
|
|
336
|
+
className="pointer-events-none mx-3 rounded-full bg-blue-500"
|
|
337
|
+
exit={{ height: 0, opacity: 0 }}
|
|
338
|
+
initial={{ height: 0, opacity: 0 }}
|
|
339
|
+
transition={{ type: 'spring', bounce: 0.3, duration: 0.25 }}
|
|
340
|
+
/>
|
|
341
|
+
)
|
|
342
|
+
}
|