@pascal-app/editor 0.4.0 → 0.6.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 +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +377 -50
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +125 -10
|
@@ -2,7 +2,8 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from
|
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { AnimatePresence } from 'motion/react'
|
|
4
4
|
import Image from 'next/image'
|
|
5
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
5
|
+
import { memo, useCallback, useEffect, useState } from 'react'
|
|
6
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
6
7
|
import useEditor from '../../../../../store/use-editor'
|
|
7
8
|
import { InlineRenameInput } from './inline-rename-input'
|
|
8
9
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
@@ -10,47 +11,62 @@ import { TreeNodeActions } from './tree-node-actions'
|
|
|
10
11
|
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
|
11
12
|
|
|
12
13
|
interface StairTreeNodeProps {
|
|
13
|
-
|
|
14
|
+
nodeId: AnyNodeId
|
|
14
15
|
depth: number
|
|
15
16
|
isLast?: boolean
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export
|
|
19
|
+
export const StairTreeNode = memo(function StairTreeNode({
|
|
20
|
+
nodeId,
|
|
21
|
+
depth,
|
|
22
|
+
isLast,
|
|
23
|
+
}: StairTreeNodeProps) {
|
|
19
24
|
const [isEditing, setIsEditing] = useState(false)
|
|
20
25
|
const [expanded, setExpanded] = useState(false)
|
|
21
|
-
const
|
|
22
|
-
const isSelected = selectedIds.includes(
|
|
23
|
-
const isHovered = useViewer((state) => state.hoveredId ===
|
|
26
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
27
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
28
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
24
29
|
const setSelection = useViewer((state) => state.setSelection)
|
|
25
30
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
26
|
-
const nodes = useScene((state) => state.nodes)
|
|
27
31
|
const { drag, dropTarget } = useTreeNodeDrag()
|
|
28
32
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
focusTreeNode(node.id)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const handleMouseEnter = () => {
|
|
42
|
-
setHoveredId(node.id)
|
|
43
|
-
}
|
|
33
|
+
const segments = useScene(
|
|
34
|
+
useShallow((s) => {
|
|
35
|
+
const n = s.nodes[nodeId] as StairNode | undefined
|
|
36
|
+
if (!n) return [] as StairSegmentNode[]
|
|
37
|
+
return (n.children ?? [])
|
|
38
|
+
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
|
39
|
+
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// Targeted selector — only re-renders when a segment of THIS stair is selected/deselected
|
|
44
|
+
const hasSelectedChild = useViewer((state) =>
|
|
45
|
+
segments.some((seg) => state.selection.selectedIds.includes(seg.id)),
|
|
46
|
+
)
|
|
48
47
|
|
|
49
|
-
const
|
|
50
|
-
.
|
|
51
|
-
|
|
48
|
+
const handleClick = useCallback(
|
|
49
|
+
(e: React.MouseEvent) => {
|
|
50
|
+
e.stopPropagation()
|
|
51
|
+
const handled = handleTreeSelection(
|
|
52
|
+
e,
|
|
53
|
+
nodeId,
|
|
54
|
+
useViewer.getState().selection.selectedIds,
|
|
55
|
+
setSelection,
|
|
56
|
+
)
|
|
57
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
58
|
+
useEditor.getState().setPhase('structure')
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
[nodeId, setSelection],
|
|
62
|
+
)
|
|
52
63
|
|
|
53
|
-
const
|
|
64
|
+
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
|
65
|
+
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
|
66
|
+
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
|
67
|
+
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
|
|
68
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
69
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
54
70
|
|
|
55
71
|
useEffect(() => {
|
|
56
72
|
if (isSelected || hasSelectedChild) {
|
|
@@ -59,7 +75,7 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
|
59
75
|
}, [isSelected, hasSelectedChild])
|
|
60
76
|
|
|
61
77
|
// Auto-expand when a segment is being dragged over this stair
|
|
62
|
-
const isDropTarget = drag !== null && dropTarget?.parentId ===
|
|
78
|
+
const isDropTarget = drag !== null && dropTarget?.parentId === nodeId
|
|
63
79
|
useEffect(() => {
|
|
64
80
|
if (isDropTarget && !expanded) {
|
|
65
81
|
setExpanded(true)
|
|
@@ -72,12 +88,12 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
|
72
88
|
// Hide the dragged segment from every stair while dragging
|
|
73
89
|
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
|
|
74
90
|
|
|
75
|
-
const isValidDropTarget = drag !== null && drag.nodeId !==
|
|
91
|
+
const isValidDropTarget = drag !== null && drag.nodeId !== nodeId
|
|
76
92
|
|
|
77
93
|
return (
|
|
78
|
-
<div data-drop-target={
|
|
94
|
+
<div data-drop-target={nodeId}>
|
|
79
95
|
<TreeNodeWrapper
|
|
80
|
-
actions={<TreeNodeActions
|
|
96
|
+
actions={<TreeNodeActions nodeId={nodeId} />}
|
|
81
97
|
depth={depth}
|
|
82
98
|
expanded={expanded}
|
|
83
99
|
hasChildren={segments.length > 0}
|
|
@@ -88,22 +104,22 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
|
88
104
|
isHovered={isHovered || isDropTarget}
|
|
89
105
|
isLast={isLast && !expanded}
|
|
90
106
|
isSelected={isSelected}
|
|
91
|
-
isVisible={
|
|
107
|
+
isVisible={isVisible}
|
|
92
108
|
label={
|
|
93
109
|
<InlineRenameInput
|
|
94
110
|
defaultName={defaultName}
|
|
95
111
|
isEditing={isEditing}
|
|
96
|
-
|
|
97
|
-
onStartEditing={
|
|
98
|
-
onStopEditing={
|
|
112
|
+
nodeId={nodeId}
|
|
113
|
+
onStartEditing={handleStartEditing}
|
|
114
|
+
onStopEditing={handleStopEditing}
|
|
99
115
|
/>
|
|
100
116
|
}
|
|
101
|
-
nodeId={
|
|
117
|
+
nodeId={nodeId}
|
|
102
118
|
onClick={handleClick}
|
|
103
119
|
onDoubleClick={handleDoubleClick}
|
|
104
120
|
onMouseEnter={handleMouseEnter}
|
|
105
121
|
onMouseLeave={handleMouseLeave}
|
|
106
|
-
onToggle={
|
|
122
|
+
onToggle={handleToggle}
|
|
107
123
|
>
|
|
108
124
|
{visibleSegments.map((seg, i) => {
|
|
109
125
|
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
|
|
@@ -135,7 +151,7 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
|
135
151
|
</TreeNodeWrapper>
|
|
136
152
|
</div>
|
|
137
153
|
)
|
|
138
|
-
}
|
|
154
|
+
})
|
|
139
155
|
|
|
140
156
|
function StairSegmentTreeNode({
|
|
141
157
|
node,
|
|
@@ -147,18 +163,20 @@ function StairSegmentTreeNode({
|
|
|
147
163
|
isLast?: boolean
|
|
148
164
|
}) {
|
|
149
165
|
const [isEditing, setIsEditing] = useState(false)
|
|
150
|
-
const
|
|
151
|
-
const isSelected = selectedIds.includes(node.id)
|
|
166
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
|
152
167
|
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
|
153
168
|
const setSelection = useViewer((state) => state.setSelection)
|
|
154
169
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
155
170
|
const { startDrag, isDragging } = useTreeNodeDrag()
|
|
156
171
|
|
|
157
|
-
const handleClick = (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
172
|
+
const handleClick = useCallback(
|
|
173
|
+
(e: React.MouseEvent) => {
|
|
174
|
+
if (isDragging) return
|
|
175
|
+
e.stopPropagation()
|
|
176
|
+
handleTreeSelection(e, node.id, useViewer.getState().selection.selectedIds, setSelection)
|
|
177
|
+
},
|
|
178
|
+
[node.id, isDragging, setSelection],
|
|
179
|
+
)
|
|
162
180
|
|
|
163
181
|
const handlePointerDown = useCallback(
|
|
164
182
|
(e: React.PointerEvent) => {
|
|
@@ -170,13 +188,16 @@ function StairSegmentTreeNode({
|
|
|
170
188
|
[node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],
|
|
171
189
|
)
|
|
172
190
|
|
|
191
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
192
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
193
|
+
|
|
173
194
|
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
|
|
174
195
|
const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
|
|
175
196
|
|
|
176
197
|
return (
|
|
177
198
|
<div data-drop-child={node.id}>
|
|
178
199
|
<TreeNodeWrapper
|
|
179
|
-
actions={<TreeNodeActions
|
|
200
|
+
actions={<TreeNodeActions nodeId={node.id} />}
|
|
180
201
|
depth={depth}
|
|
181
202
|
expanded={false}
|
|
182
203
|
hasChildren={false}
|
|
@@ -198,9 +219,9 @@ function StairSegmentTreeNode({
|
|
|
198
219
|
<InlineRenameInput
|
|
199
220
|
defaultName={defaultName}
|
|
200
221
|
isEditing={isEditing}
|
|
201
|
-
|
|
202
|
-
onStartEditing={
|
|
203
|
-
onStopEditing={
|
|
222
|
+
nodeId={node.id}
|
|
223
|
+
onStartEditing={handleStartEditing}
|
|
224
|
+
onStopEditing={handleStopEditing}
|
|
204
225
|
/>
|
|
205
226
|
}
|
|
206
227
|
nodeId={node.id}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
|
|
4
|
-
import { useState } from 'react'
|
|
4
|
+
import { memo, useState } from 'react'
|
|
5
5
|
import {
|
|
6
6
|
Popover,
|
|
7
7
|
PopoverContent,
|
|
@@ -9,21 +9,21 @@ import {
|
|
|
9
9
|
} from './../../../../../components/ui/primitives/popover'
|
|
10
10
|
|
|
11
11
|
interface TreeNodeActionsProps {
|
|
12
|
-
|
|
12
|
+
nodeId: AnyNodeId
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function TreeNodeActions({
|
|
15
|
+
export const TreeNodeActions = memo(function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
|
|
16
16
|
const [open, setOpen] = useState(false)
|
|
17
17
|
const updateNode = useScene((state) => state.updateNode)
|
|
18
18
|
const updateNodes = useScene((state) => state.updateNodes)
|
|
19
|
-
const
|
|
20
|
-
const hasCamera = !!
|
|
21
|
-
const isVisible = node.visible !== false
|
|
19
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
20
|
+
const hasCamera = useScene((s) => !!(s.nodes[nodeId] as any)?.camera)
|
|
22
21
|
|
|
23
22
|
const toggleVisibility = (e: React.MouseEvent) => {
|
|
24
23
|
e.stopPropagation()
|
|
25
24
|
const newVisibility = !isVisible
|
|
26
|
-
|
|
25
|
+
const selectedIds = useViewer.getState().selection.selectedIds
|
|
26
|
+
if (selectedIds?.includes(nodeId)) {
|
|
27
27
|
updateNodes(
|
|
28
28
|
selectedIds.map((id) => ({
|
|
29
29
|
id: id as AnyNodeId,
|
|
@@ -31,24 +31,24 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
|
|
31
31
|
})),
|
|
32
32
|
)
|
|
33
33
|
} else {
|
|
34
|
-
updateNode(
|
|
34
|
+
updateNode(nodeId, { visible: newVisibility })
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const handleCaptureCamera = (e: React.MouseEvent) => {
|
|
39
39
|
e.stopPropagation()
|
|
40
|
-
emitter.emit('camera-controls:capture', { nodeId
|
|
40
|
+
emitter.emit('camera-controls:capture', { nodeId })
|
|
41
41
|
setOpen(false)
|
|
42
42
|
}
|
|
43
43
|
const handleViewCamera = (e: React.MouseEvent) => {
|
|
44
44
|
e.stopPropagation()
|
|
45
|
-
emitter.emit('camera-controls:view', { nodeId
|
|
45
|
+
emitter.emit('camera-controls:view', { nodeId })
|
|
46
46
|
setOpen(false)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const handleClearCamera = (e: React.MouseEvent) => {
|
|
50
50
|
e.stopPropagation()
|
|
51
|
-
updateNode(
|
|
51
|
+
updateNode(nodeId, { camera: undefined })
|
|
52
52
|
setOpen(false)
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -112,4 +112,4 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
|
|
112
112
|
</Popover>
|
|
113
113
|
</div>
|
|
114
114
|
)
|
|
115
|
-
}
|
|
115
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
2
2
|
import { ChevronRight } from 'lucide-react'
|
|
3
3
|
import { AnimatePresence, motion } from 'motion/react'
|
|
4
|
-
import { forwardRef, useEffect, useRef } from 'react'
|
|
4
|
+
import { forwardRef, memo, useEffect, useRef } from 'react'
|
|
5
5
|
|
|
6
6
|
export function handleTreeSelection(
|
|
7
7
|
e: React.MouseEvent,
|
|
@@ -57,6 +57,7 @@ import { cn } from '../../../../../lib/utils'
|
|
|
57
57
|
import { BuildingTreeNode } from './building-tree-node'
|
|
58
58
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
|
59
59
|
import { DoorTreeNode } from './door-tree-node'
|
|
60
|
+
import { FenceTreeNode } from './fence-tree-node'
|
|
60
61
|
import { ItemTreeNode } from './item-tree-node'
|
|
61
62
|
import { LevelTreeNode } from './level-tree-node'
|
|
62
63
|
import { RoofTreeNode } from './roof-tree-node'
|
|
@@ -72,38 +73,40 @@ interface TreeNodeProps {
|
|
|
72
73
|
isLast?: boolean
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
|
76
|
-
const
|
|
76
|
+
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
|
77
|
+
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
|
|
77
78
|
|
|
78
|
-
if (!
|
|
79
|
+
if (!nodeType) return null
|
|
79
80
|
|
|
80
|
-
switch (
|
|
81
|
+
switch (nodeType) {
|
|
81
82
|
case 'building':
|
|
82
|
-
return <BuildingTreeNode depth={depth} isLast={isLast}
|
|
83
|
+
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
83
84
|
case 'ceiling':
|
|
84
|
-
return <CeilingTreeNode depth={depth} isLast={isLast}
|
|
85
|
+
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
85
86
|
case 'level':
|
|
86
|
-
return <LevelTreeNode depth={depth} isLast={isLast}
|
|
87
|
+
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
87
88
|
case 'slab':
|
|
88
|
-
return <SlabTreeNode depth={depth} isLast={isLast}
|
|
89
|
+
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
89
90
|
case 'wall':
|
|
90
|
-
return <WallTreeNode depth={depth} isLast={isLast}
|
|
91
|
+
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
92
|
+
case 'fence':
|
|
93
|
+
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
91
94
|
case 'roof':
|
|
92
|
-
return <RoofTreeNode depth={depth} isLast={isLast}
|
|
95
|
+
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
93
96
|
case 'stair':
|
|
94
|
-
return <StairTreeNode depth={depth} isLast={isLast}
|
|
97
|
+
return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
95
98
|
case 'item':
|
|
96
|
-
return <ItemTreeNode depth={depth} isLast={isLast}
|
|
99
|
+
return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
97
100
|
case 'door':
|
|
98
|
-
return <DoorTreeNode depth={depth} isLast={isLast}
|
|
101
|
+
return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
99
102
|
case 'window':
|
|
100
|
-
return <WindowTreeNode depth={depth} isLast={isLast}
|
|
103
|
+
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
101
104
|
case 'zone':
|
|
102
|
-
return <ZoneTreeNode depth={depth} isLast={isLast}
|
|
105
|
+
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
103
106
|
default:
|
|
104
107
|
return null
|
|
105
108
|
}
|
|
106
|
-
}
|
|
109
|
+
})
|
|
107
110
|
|
|
108
111
|
interface TreeNodeWrapperProps {
|
|
109
112
|
nodeId?: string
|
|
@@ -1,106 +1,114 @@
|
|
|
1
1
|
import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import Image from 'next/image'
|
|
4
|
-
import { useEffect, useState } from 'react'
|
|
4
|
+
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
5
6
|
import useEditor from './../../../../../store/use-editor'
|
|
6
7
|
import { InlineRenameInput } from './inline-rename-input'
|
|
7
8
|
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
8
9
|
import { TreeNodeActions } from './tree-node-actions'
|
|
9
10
|
|
|
10
11
|
interface WallTreeNodeProps {
|
|
11
|
-
|
|
12
|
+
nodeId: AnyNodeId
|
|
12
13
|
depth: number
|
|
13
14
|
isLast?: boolean
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export
|
|
17
|
+
export const WallTreeNode = memo(function WallTreeNode({
|
|
18
|
+
nodeId,
|
|
19
|
+
depth,
|
|
20
|
+
isLast,
|
|
21
|
+
}: WallTreeNodeProps) {
|
|
17
22
|
const [expanded, setExpanded] = useState(false)
|
|
18
23
|
const [isEditing, setIsEditing] = useState(false)
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
24
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
25
|
+
const children = useScene(
|
|
26
|
+
useShallow((s) => (s.nodes[nodeId as AnyNodeId] as WallNode | undefined)?.children ?? []),
|
|
27
|
+
)
|
|
28
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
29
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
22
30
|
const setSelection = useViewer((state) => state.setSelection)
|
|
23
31
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
24
32
|
|
|
33
|
+
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
|
25
34
|
useEffect(() => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
return useViewer.subscribe((state) => {
|
|
36
|
+
const { selectedIds } = state.selection
|
|
37
|
+
if (selectedIds.length === 0) return
|
|
38
|
+
const nodes = useScene.getState().nodes
|
|
39
|
+
for (const id of selectedIds) {
|
|
40
|
+
let current = nodes[id as AnyNodeId]
|
|
41
|
+
while (current?.parentId) {
|
|
42
|
+
if (current.parentId === nodeId) {
|
|
43
|
+
setExpanded(true)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
current = nodes[current.parentId as AnyNodeId]
|
|
35
47
|
}
|
|
36
|
-
current = nodes[current.parentId as AnyNodeId]
|
|
37
48
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (isDescendant) {
|
|
41
|
-
setExpanded(true)
|
|
42
|
-
}
|
|
43
|
-
}, [selectedIds, node.id])
|
|
44
|
-
|
|
45
|
-
const handleClick = (e: React.MouseEvent) => {
|
|
46
|
-
e.stopPropagation()
|
|
47
|
-
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
|
48
|
-
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
49
|
-
useEditor.getState().setPhase('structure')
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const handleDoubleClick = () => {
|
|
54
|
-
focusTreeNode(node.id)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const handleMouseEnter = () => {
|
|
58
|
-
setHoveredId(node.id)
|
|
59
|
-
}
|
|
49
|
+
})
|
|
50
|
+
}, [nodeId])
|
|
60
51
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
52
|
+
const handleClick = useCallback(
|
|
53
|
+
(e: React.MouseEvent) => {
|
|
54
|
+
e.stopPropagation()
|
|
55
|
+
const handled = handleTreeSelection(
|
|
56
|
+
e,
|
|
57
|
+
nodeId,
|
|
58
|
+
useViewer.getState().selection.selectedIds,
|
|
59
|
+
setSelection,
|
|
60
|
+
)
|
|
61
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
62
|
+
useEditor.getState().setPhase('structure')
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
[nodeId, setSelection],
|
|
66
|
+
)
|
|
64
67
|
|
|
65
|
-
const
|
|
68
|
+
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
|
|
69
|
+
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
|
70
|
+
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
|
71
|
+
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
|
|
72
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
73
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
66
74
|
|
|
67
75
|
return (
|
|
68
76
|
<TreeNodeWrapper
|
|
69
|
-
actions={<TreeNodeActions
|
|
77
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
70
78
|
depth={depth}
|
|
71
79
|
expanded={expanded}
|
|
72
|
-
hasChildren={
|
|
80
|
+
hasChildren={children.length > 0}
|
|
73
81
|
icon={
|
|
74
82
|
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
|
|
75
83
|
}
|
|
76
84
|
isHovered={isHovered}
|
|
77
85
|
isLast={isLast}
|
|
78
86
|
isSelected={isSelected}
|
|
79
|
-
isVisible={
|
|
87
|
+
isVisible={isVisible}
|
|
80
88
|
label={
|
|
81
89
|
<InlineRenameInput
|
|
82
|
-
defaultName=
|
|
90
|
+
defaultName="Wall"
|
|
83
91
|
isEditing={isEditing}
|
|
84
|
-
|
|
85
|
-
onStartEditing={
|
|
86
|
-
onStopEditing={
|
|
92
|
+
nodeId={nodeId as AnyNodeId}
|
|
93
|
+
onStartEditing={handleStartEditing}
|
|
94
|
+
onStopEditing={handleStopEditing}
|
|
87
95
|
/>
|
|
88
96
|
}
|
|
89
|
-
nodeId={
|
|
97
|
+
nodeId={nodeId}
|
|
90
98
|
onClick={handleClick}
|
|
91
99
|
onDoubleClick={handleDoubleClick}
|
|
92
100
|
onMouseEnter={handleMouseEnter}
|
|
93
101
|
onMouseLeave={handleMouseLeave}
|
|
94
|
-
onToggle={
|
|
102
|
+
onToggle={handleToggle}
|
|
95
103
|
>
|
|
96
|
-
{
|
|
104
|
+
{children.map((childId, index) => (
|
|
97
105
|
<TreeNode
|
|
98
106
|
depth={depth + 1}
|
|
99
|
-
isLast={index ===
|
|
107
|
+
isLast={index === children.length - 1}
|
|
100
108
|
key={childId}
|
|
101
109
|
nodeId={childId}
|
|
102
110
|
/>
|
|
103
111
|
))}
|
|
104
112
|
</TreeNodeWrapper>
|
|
105
113
|
)
|
|
106
|
-
}
|
|
114
|
+
})
|
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
5
|
import Image from 'next/image'
|
|
6
|
-
import { useState } from 'react'
|
|
6
|
+
import { memo, useCallback, useState } from 'react'
|
|
7
7
|
import useEditor from './../../../../../store/use-editor'
|
|
8
8
|
import { InlineRenameInput } from './inline-rename-input'
|
|
9
9
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
10
10
|
import { TreeNodeActions } from './tree-node-actions'
|
|
11
11
|
|
|
12
12
|
interface WindowTreeNodeProps {
|
|
13
|
-
|
|
13
|
+
nodeId: AnyNodeId
|
|
14
14
|
depth: number
|
|
15
15
|
isLast?: boolean
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export
|
|
18
|
+
export const WindowTreeNode = memo(function WindowTreeNode({
|
|
19
|
+
nodeId,
|
|
20
|
+
depth,
|
|
21
|
+
isLast,
|
|
22
|
+
}: WindowTreeNodeProps) {
|
|
19
23
|
const [isEditing, setIsEditing] = useState(false)
|
|
20
|
-
const
|
|
21
|
-
const isSelected = selectedIds.includes(
|
|
22
|
-
const isHovered = useViewer((state) => state.hoveredId ===
|
|
24
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
25
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
26
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
23
27
|
const setSelection = useViewer((state) => state.setSelection)
|
|
24
28
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
25
29
|
|
|
26
|
-
const
|
|
30
|
+
const handleClick = useCallback(
|
|
31
|
+
(e: React.MouseEvent) => {
|
|
32
|
+
e.stopPropagation()
|
|
33
|
+
const handled = handleTreeSelection(
|
|
34
|
+
e,
|
|
35
|
+
nodeId,
|
|
36
|
+
useViewer.getState().selection.selectedIds,
|
|
37
|
+
setSelection,
|
|
38
|
+
)
|
|
39
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
40
|
+
useEditor.getState().setPhase('structure')
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
[nodeId, setSelection],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
47
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
27
48
|
|
|
28
49
|
return (
|
|
29
50
|
<TreeNodeWrapper
|
|
30
|
-
actions={<TreeNodeActions
|
|
51
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
31
52
|
depth={depth}
|
|
32
53
|
expanded={false}
|
|
33
54
|
hasChildren={false}
|
|
@@ -37,28 +58,22 @@ export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
|
|
|
37
58
|
isHovered={isHovered}
|
|
38
59
|
isLast={isLast}
|
|
39
60
|
isSelected={isSelected}
|
|
40
|
-
isVisible={
|
|
61
|
+
isVisible={isVisible}
|
|
41
62
|
label={
|
|
42
63
|
<InlineRenameInput
|
|
43
|
-
defaultName=
|
|
64
|
+
defaultName="Window"
|
|
44
65
|
isEditing={isEditing}
|
|
45
|
-
|
|
46
|
-
onStartEditing={
|
|
47
|
-
onStopEditing={
|
|
66
|
+
nodeId={nodeId as AnyNodeId}
|
|
67
|
+
onStartEditing={handleStartEditing}
|
|
68
|
+
onStopEditing={handleStopEditing}
|
|
48
69
|
/>
|
|
49
70
|
}
|
|
50
|
-
nodeId={
|
|
51
|
-
onClick={
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
55
|
-
useEditor.getState().setPhase('structure')
|
|
56
|
-
}
|
|
57
|
-
}}
|
|
58
|
-
onDoubleClick={() => focusTreeNode(node.id)}
|
|
59
|
-
onMouseEnter={() => setHoveredId(node.id)}
|
|
71
|
+
nodeId={nodeId}
|
|
72
|
+
onClick={handleClick}
|
|
73
|
+
onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
|
|
74
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
60
75
|
onMouseLeave={() => setHoveredId(null)}
|
|
61
76
|
onToggle={() => {}}
|
|
62
77
|
/>
|
|
63
78
|
)
|
|
64
|
-
}
|
|
79
|
+
})
|