@pascal-app/editor 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/components/editor/floating-action-menu.tsx +101 -29
- package/src/components/editor/floating-building-action-menu.tsx +69 -0
- package/src/components/editor/floorplan-panel.tsx +31 -13
- package/src/components/editor/index.tsx +219 -167
- package/src/components/editor/node-action-menu.tsx +26 -10
- package/src/components/editor/selection-manager.tsx +38 -2
- package/src/components/editor/thumbnail-generator.tsx +245 -64
- 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/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +19 -7
- package/src/components/tools/door/move-door-tool.tsx +17 -8
- package/src/components/tools/fence/fence-drafting.ts +125 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-tool.tsx +223 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +7 -0
- package/src/components/tools/item/placement-strategies.ts +15 -7
- package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
- package/src/components/tools/roof/move-roof-tool.tsx +5 -2
- 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 +2 -2
- 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 +29 -6
- package/src/components/tools/tool-manager.tsx +42 -14
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +17 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +19 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/panels/fence-panel.tsx +184 -0
- package/src/components/ui/panels/panel-manager.tsx +3 -0
- package/src/components/ui/panels/stair-panel.tsx +206 -33
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
- 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 +59 -52
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
- package/src/components/viewer-overlay.tsx +1 -0
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +10 -2
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +4 -0
- package/src/store/use-editor.tsx +7 -0
|
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
3
3
|
import { AnimatePresence } from 'motion/react'
|
|
4
4
|
import Image from 'next/image'
|
|
5
5
|
import { 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,58 @@ 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 function StairTreeNode({
|
|
19
|
+
export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
|
|
19
20
|
const [isEditing, setIsEditing] = useState(false)
|
|
20
21
|
const [expanded, setExpanded] = useState(false)
|
|
21
|
-
const
|
|
22
|
-
const isSelected = selectedIds.includes(
|
|
23
|
-
const isHovered = useViewer((state) => state.hoveredId ===
|
|
22
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
23
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
24
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
24
25
|
const setSelection = useViewer((state) => state.setSelection)
|
|
25
26
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
26
|
-
const nodes = useScene((state) => state.nodes)
|
|
27
27
|
const { drag, dropTarget } = useTreeNodeDrag()
|
|
28
28
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
focusTreeNode(node.id)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const handleMouseEnter = () => {
|
|
42
|
-
setHoveredId(node.id)
|
|
43
|
-
}
|
|
29
|
+
const segments = useScene(
|
|
30
|
+
useShallow((s) => {
|
|
31
|
+
const n = s.nodes[nodeId] as StairNode | undefined
|
|
32
|
+
if (!n) return [] as StairSegmentNode[]
|
|
33
|
+
return (n.children ?? [])
|
|
34
|
+
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
|
35
|
+
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
// Targeted selector — only re-renders when a segment of THIS stair is selected/deselected
|
|
40
|
+
const hasSelectedChild = useViewer((state) =>
|
|
41
|
+
segments.some((seg) => state.selection.selectedIds.includes(seg.id)),
|
|
42
|
+
)
|
|
48
43
|
|
|
49
|
-
const
|
|
50
|
-
.
|
|
51
|
-
|
|
44
|
+
const handleClick = useCallback(
|
|
45
|
+
(e: React.MouseEvent) => {
|
|
46
|
+
e.stopPropagation()
|
|
47
|
+
const handled = handleTreeSelection(
|
|
48
|
+
e,
|
|
49
|
+
nodeId,
|
|
50
|
+
useViewer.getState().selection.selectedIds,
|
|
51
|
+
setSelection,
|
|
52
|
+
)
|
|
53
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
54
|
+
useEditor.getState().setPhase('structure')
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
[nodeId, setSelection],
|
|
58
|
+
)
|
|
52
59
|
|
|
53
|
-
const
|
|
60
|
+
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
|
61
|
+
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
|
62
|
+
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
|
63
|
+
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
|
|
64
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
65
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
54
66
|
|
|
55
67
|
useEffect(() => {
|
|
56
68
|
if (isSelected || hasSelectedChild) {
|
|
@@ -59,7 +71,7 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
|
59
71
|
}, [isSelected, hasSelectedChild])
|
|
60
72
|
|
|
61
73
|
// Auto-expand when a segment is being dragged over this stair
|
|
62
|
-
const isDropTarget = drag !== null && dropTarget?.parentId ===
|
|
74
|
+
const isDropTarget = drag !== null && dropTarget?.parentId === nodeId
|
|
63
75
|
useEffect(() => {
|
|
64
76
|
if (isDropTarget && !expanded) {
|
|
65
77
|
setExpanded(true)
|
|
@@ -72,12 +84,12 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
|
72
84
|
// Hide the dragged segment from every stair while dragging
|
|
73
85
|
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
|
|
74
86
|
|
|
75
|
-
const isValidDropTarget = drag !== null && drag.nodeId !==
|
|
87
|
+
const isValidDropTarget = drag !== null && drag.nodeId !== nodeId
|
|
76
88
|
|
|
77
89
|
return (
|
|
78
|
-
<div data-drop-target={
|
|
90
|
+
<div data-drop-target={nodeId}>
|
|
79
91
|
<TreeNodeWrapper
|
|
80
|
-
actions={<TreeNodeActions
|
|
92
|
+
actions={<TreeNodeActions nodeId={nodeId} />}
|
|
81
93
|
depth={depth}
|
|
82
94
|
expanded={expanded}
|
|
83
95
|
hasChildren={segments.length > 0}
|
|
@@ -88,22 +100,22 @@ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
|
|
88
100
|
isHovered={isHovered || isDropTarget}
|
|
89
101
|
isLast={isLast && !expanded}
|
|
90
102
|
isSelected={isSelected}
|
|
91
|
-
isVisible={
|
|
103
|
+
isVisible={isVisible}
|
|
92
104
|
label={
|
|
93
105
|
<InlineRenameInput
|
|
94
106
|
defaultName={defaultName}
|
|
95
107
|
isEditing={isEditing}
|
|
96
|
-
|
|
97
|
-
onStartEditing={
|
|
98
|
-
onStopEditing={
|
|
108
|
+
nodeId={nodeId}
|
|
109
|
+
onStartEditing={handleStartEditing}
|
|
110
|
+
onStopEditing={handleStopEditing}
|
|
99
111
|
/>
|
|
100
112
|
}
|
|
101
|
-
nodeId={
|
|
113
|
+
nodeId={nodeId}
|
|
102
114
|
onClick={handleClick}
|
|
103
115
|
onDoubleClick={handleDoubleClick}
|
|
104
116
|
onMouseEnter={handleMouseEnter}
|
|
105
117
|
onMouseLeave={handleMouseLeave}
|
|
106
|
-
onToggle={
|
|
118
|
+
onToggle={handleToggle}
|
|
107
119
|
>
|
|
108
120
|
{visibleSegments.map((seg, i) => {
|
|
109
121
|
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
|
|
@@ -147,18 +159,20 @@ function StairSegmentTreeNode({
|
|
|
147
159
|
isLast?: boolean
|
|
148
160
|
}) {
|
|
149
161
|
const [isEditing, setIsEditing] = useState(false)
|
|
150
|
-
const
|
|
151
|
-
const isSelected = selectedIds.includes(node.id)
|
|
162
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
|
152
163
|
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
|
153
164
|
const setSelection = useViewer((state) => state.setSelection)
|
|
154
165
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
155
166
|
const { startDrag, isDragging } = useTreeNodeDrag()
|
|
156
167
|
|
|
157
|
-
const handleClick = (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
const handleClick = useCallback(
|
|
169
|
+
(e: React.MouseEvent) => {
|
|
170
|
+
if (isDragging) return
|
|
171
|
+
e.stopPropagation()
|
|
172
|
+
handleTreeSelection(e, node.id, useViewer.getState().selection.selectedIds, setSelection)
|
|
173
|
+
},
|
|
174
|
+
[node.id, isDragging, setSelection],
|
|
175
|
+
)
|
|
162
176
|
|
|
163
177
|
const handlePointerDown = useCallback(
|
|
164
178
|
(e: React.PointerEvent) => {
|
|
@@ -170,13 +184,16 @@ function StairSegmentTreeNode({
|
|
|
170
184
|
[node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],
|
|
171
185
|
)
|
|
172
186
|
|
|
187
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
188
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
189
|
+
|
|
173
190
|
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
|
|
174
191
|
const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
|
|
175
192
|
|
|
176
193
|
return (
|
|
177
194
|
<div data-drop-child={node.id}>
|
|
178
195
|
<TreeNodeWrapper
|
|
179
|
-
actions={<TreeNodeActions
|
|
196
|
+
actions={<TreeNodeActions nodeId={node.id} />}
|
|
180
197
|
depth={depth}
|
|
181
198
|
expanded={false}
|
|
182
199
|
hasChildren={false}
|
|
@@ -198,9 +215,9 @@ function StairSegmentTreeNode({
|
|
|
198
215
|
<InlineRenameInput
|
|
199
216
|
defaultName={defaultName}
|
|
200
217
|
isEditing={isEditing}
|
|
201
|
-
|
|
202
|
-
onStartEditing={
|
|
203
|
-
onStopEditing={
|
|
218
|
+
nodeId={node.id}
|
|
219
|
+
onStartEditing={handleStartEditing}
|
|
220
|
+
onStopEditing={handleStopEditing}
|
|
204
221
|
/>
|
|
205
222
|
}
|
|
206
223
|
nodeId={node.id}
|
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
import { useState } from 'react'
|
|
@@ -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 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
|
|
|
@@ -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'
|
|
@@ -73,33 +74,35 @@ interface TreeNodeProps {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
|
76
|
-
const
|
|
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
|
}
|
|
@@ -1,102 +1,106 @@
|
|
|
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 { 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 function WallTreeNode({
|
|
17
|
+
export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
|
|
17
18
|
const [expanded, setExpanded] = useState(false)
|
|
18
19
|
const [isEditing, setIsEditing] = useState(false)
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
21
|
+
const children = useScene(
|
|
22
|
+
useShallow((s) => (s.nodes[nodeId as AnyNodeId] as WallNode | undefined)?.children ?? []),
|
|
23
|
+
)
|
|
24
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
25
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
22
26
|
const setSelection = useViewer((state) => state.setSelection)
|
|
23
27
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
24
28
|
|
|
29
|
+
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
|
25
30
|
useEffect(() => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
return useViewer.subscribe((state) => {
|
|
32
|
+
const { selectedIds } = state.selection
|
|
33
|
+
if (selectedIds.length === 0) return
|
|
34
|
+
const nodes = useScene.getState().nodes
|
|
35
|
+
for (const id of selectedIds) {
|
|
36
|
+
let current = nodes[id as AnyNodeId]
|
|
37
|
+
while (current?.parentId) {
|
|
38
|
+
if (current.parentId === nodeId) {
|
|
39
|
+
setExpanded(true)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
current = nodes[current.parentId as AnyNodeId]
|
|
35
43
|
}
|
|
36
|
-
current = nodes[current.parentId as AnyNodeId]
|
|
37
44
|
}
|
|
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
|
-
}
|
|
45
|
+
})
|
|
46
|
+
}, [nodeId])
|
|
56
47
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
)
|
|
64
63
|
|
|
65
|
-
const
|
|
64
|
+
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [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), [])
|
|
66
70
|
|
|
67
71
|
return (
|
|
68
72
|
<TreeNodeWrapper
|
|
69
|
-
actions={<TreeNodeActions
|
|
73
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
70
74
|
depth={depth}
|
|
71
75
|
expanded={expanded}
|
|
72
|
-
hasChildren={
|
|
76
|
+
hasChildren={children.length > 0}
|
|
73
77
|
icon={
|
|
74
78
|
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
|
|
75
79
|
}
|
|
76
80
|
isHovered={isHovered}
|
|
77
81
|
isLast={isLast}
|
|
78
82
|
isSelected={isSelected}
|
|
79
|
-
isVisible={
|
|
83
|
+
isVisible={isVisible}
|
|
80
84
|
label={
|
|
81
85
|
<InlineRenameInput
|
|
82
|
-
defaultName=
|
|
86
|
+
defaultName="Wall"
|
|
83
87
|
isEditing={isEditing}
|
|
84
|
-
|
|
85
|
-
onStartEditing={
|
|
86
|
-
onStopEditing={
|
|
88
|
+
nodeId={nodeId as AnyNodeId}
|
|
89
|
+
onStartEditing={handleStartEditing}
|
|
90
|
+
onStopEditing={handleStopEditing}
|
|
87
91
|
/>
|
|
88
92
|
}
|
|
89
|
-
nodeId={
|
|
93
|
+
nodeId={nodeId}
|
|
90
94
|
onClick={handleClick}
|
|
91
95
|
onDoubleClick={handleDoubleClick}
|
|
92
96
|
onMouseEnter={handleMouseEnter}
|
|
93
97
|
onMouseLeave={handleMouseLeave}
|
|
94
|
-
onToggle={
|
|
98
|
+
onToggle={handleToggle}
|
|
95
99
|
>
|
|
96
|
-
{
|
|
100
|
+
{children.map((childId, index) => (
|
|
97
101
|
<TreeNode
|
|
98
102
|
depth={depth + 1}
|
|
99
|
-
isLast={index ===
|
|
103
|
+
isLast={index === children.length - 1}
|
|
100
104
|
key={childId}
|
|
101
105
|
nodeId={childId}
|
|
102
106
|
/>
|
|
@@ -1,33 +1,50 @@
|
|
|
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 { 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 function WindowTreeNode({
|
|
18
|
+
export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
|
|
19
19
|
const [isEditing, setIsEditing] = useState(false)
|
|
20
|
-
const
|
|
21
|
-
const isSelected = selectedIds.includes(
|
|
22
|
-
const isHovered = useViewer((state) => state.hoveredId ===
|
|
20
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
21
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
22
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
23
23
|
const setSelection = useViewer((state) => state.setSelection)
|
|
24
24
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
25
25
|
|
|
26
|
-
const
|
|
26
|
+
const handleClick = useCallback(
|
|
27
|
+
(e: React.MouseEvent) => {
|
|
28
|
+
e.stopPropagation()
|
|
29
|
+
const handled = handleTreeSelection(
|
|
30
|
+
e,
|
|
31
|
+
nodeId,
|
|
32
|
+
useViewer.getState().selection.selectedIds,
|
|
33
|
+
setSelection,
|
|
34
|
+
)
|
|
35
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
36
|
+
useEditor.getState().setPhase('structure')
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
[nodeId, setSelection],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
43
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
27
44
|
|
|
28
45
|
return (
|
|
29
46
|
<TreeNodeWrapper
|
|
30
|
-
actions={<TreeNodeActions
|
|
47
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
31
48
|
depth={depth}
|
|
32
49
|
expanded={false}
|
|
33
50
|
hasChildren={false}
|
|
@@ -37,26 +54,20 @@ export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
|
|
|
37
54
|
isHovered={isHovered}
|
|
38
55
|
isLast={isLast}
|
|
39
56
|
isSelected={isSelected}
|
|
40
|
-
isVisible={
|
|
57
|
+
isVisible={isVisible}
|
|
41
58
|
label={
|
|
42
59
|
<InlineRenameInput
|
|
43
|
-
defaultName=
|
|
60
|
+
defaultName="Window"
|
|
44
61
|
isEditing={isEditing}
|
|
45
|
-
|
|
46
|
-
onStartEditing={
|
|
47
|
-
onStopEditing={
|
|
62
|
+
nodeId={nodeId as AnyNodeId}
|
|
63
|
+
onStartEditing={handleStartEditing}
|
|
64
|
+
onStopEditing={handleStopEditing}
|
|
48
65
|
/>
|
|
49
66
|
}
|
|
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)}
|
|
67
|
+
nodeId={nodeId}
|
|
68
|
+
onClick={handleClick}
|
|
69
|
+
onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
|
|
70
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
60
71
|
onMouseLeave={() => setHoveredId(null)}
|
|
61
72
|
onToggle={() => {}}
|
|
62
73
|
/>
|