@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.
Files changed (61) hide show
  1. package/package.json +5 -5
  2. package/src/components/editor/floating-action-menu.tsx +101 -29
  3. package/src/components/editor/floating-building-action-menu.tsx +69 -0
  4. package/src/components/editor/floorplan-panel.tsx +31 -13
  5. package/src/components/editor/index.tsx +219 -167
  6. package/src/components/editor/node-action-menu.tsx +26 -10
  7. package/src/components/editor/selection-manager.tsx +38 -2
  8. package/src/components/editor/thumbnail-generator.tsx +245 -64
  9. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  10. package/src/components/tools/building/move-building-tool.tsx +157 -0
  11. package/src/components/tools/door/door-math.ts +1 -1
  12. package/src/components/tools/door/door-tool.tsx +19 -7
  13. package/src/components/tools/door/move-door-tool.tsx +17 -8
  14. package/src/components/tools/fence/fence-drafting.ts +125 -0
  15. package/src/components/tools/fence/fence-tool.tsx +190 -0
  16. package/src/components/tools/fence/move-fence-tool.tsx +223 -0
  17. package/src/components/tools/item/item-tool.tsx +3 -3
  18. package/src/components/tools/item/move-tool.tsx +7 -0
  19. package/src/components/tools/item/placement-strategies.ts +15 -7
  20. package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
  21. package/src/components/tools/roof/move-roof-tool.tsx +5 -2
  22. package/src/components/tools/roof/roof-tool.tsx +6 -6
  23. package/src/components/tools/select/box-select-tool.tsx +2 -2
  24. package/src/components/tools/shared/polygon-editor.tsx +2 -2
  25. package/src/components/tools/slab/slab-tool.tsx +4 -4
  26. package/src/components/tools/stair/stair-defaults.ts +10 -0
  27. package/src/components/tools/stair/stair-tool.tsx +29 -6
  28. package/src/components/tools/tool-manager.tsx +42 -14
  29. package/src/components/tools/wall/wall-tool.tsx +19 -29
  30. package/src/components/tools/window/move-window-tool.tsx +17 -8
  31. package/src/components/tools/window/window-math.ts +1 -1
  32. package/src/components/tools/window/window-tool.tsx +19 -7
  33. package/src/components/tools/zone/zone-tool.tsx +7 -7
  34. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  35. package/src/components/ui/helpers/building-helper.tsx +32 -0
  36. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  37. package/src/components/ui/panels/fence-panel.tsx +184 -0
  38. package/src/components/ui/panels/panel-manager.tsx +3 -0
  39. package/src/components/ui/panels/stair-panel.tsx +206 -33
  40. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
  41. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
  42. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
  43. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
  44. package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
  45. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  46. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
  47. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
  48. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
  49. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
  50. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
  51. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
  52. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
  53. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
  54. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
  55. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
  56. package/src/components/viewer-overlay.tsx +1 -0
  57. package/src/hooks/use-auto-save.ts +3 -6
  58. package/src/hooks/use-contextual-tools.ts +10 -2
  59. package/src/hooks/use-grid-events.ts +13 -1
  60. package/src/hooks/use-keyboard.ts +4 -0
  61. 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
- node: StairNode
14
+ nodeId: AnyNodeId
14
15
  depth: number
15
16
  isLast?: boolean
16
17
  }
17
18
 
18
- export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
19
+ export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
19
20
  const [isEditing, setIsEditing] = useState(false)
20
21
  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)
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 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
- }
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
- const handleMouseLeave = () => {
46
- setHoveredId(null)
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 segments = (node.children ?? [])
50
- .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
51
- .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
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 hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
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 === node.id
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 !== node.id
87
+ const isValidDropTarget = drag !== null && drag.nodeId !== nodeId
76
88
 
77
89
  return (
78
- <div data-drop-target={node.id}>
90
+ <div data-drop-target={nodeId}>
79
91
  <TreeNodeWrapper
80
- actions={<TreeNodeActions node={node} />}
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={node.visible !== false}
103
+ isVisible={isVisible}
92
104
  label={
93
105
  <InlineRenameInput
94
106
  defaultName={defaultName}
95
107
  isEditing={isEditing}
96
- node={node}
97
- onStartEditing={() => setIsEditing(true)}
98
- onStopEditing={() => setIsEditing(false)}
108
+ nodeId={nodeId}
109
+ onStartEditing={handleStartEditing}
110
+ onStopEditing={handleStopEditing}
99
111
  />
100
112
  }
101
- nodeId={node.id}
113
+ nodeId={nodeId}
102
114
  onClick={handleClick}
103
115
  onDoubleClick={handleDoubleClick}
104
116
  onMouseEnter={handleMouseEnter}
105
117
  onMouseLeave={handleMouseLeave}
106
- onToggle={() => setExpanded(!expanded)}
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 selectedIds = useViewer((state) => state.selection.selectedIds)
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 = (e: React.MouseEvent) => {
158
- if (isDragging) return
159
- e.stopPropagation()
160
- handleTreeSelection(e, node.id, selectedIds, setSelection)
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 node={node} />}
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
- node={node}
202
- onStartEditing={() => setIsEditing(true)}
203
- onStopEditing={() => setIsEditing(false)}
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 AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'
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
- node: AnyNode
12
+ nodeId: AnyNodeId
13
13
  }
14
14
 
15
- export function TreeNodeActions({ node }: TreeNodeActionsProps) {
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 selectedIds = useViewer((state) => state.selection.selectedIds)
20
- const hasCamera = !!node.camera
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
- if (selectedIds?.includes(node.id)) {
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(node.id, { visible: newVisibility })
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: node.id })
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: node.id })
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(node.id, { camera: undefined })
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 node = useScene((state) => state.nodes[nodeId])
77
+ const nodeType = useScene((state) => state.nodes[nodeId]?.type)
77
78
 
78
- if (!node) return null
79
+ if (!nodeType) return null
79
80
 
80
- switch (node.type) {
81
+ switch (nodeType) {
81
82
  case 'building':
82
- return <BuildingTreeNode depth={depth} isLast={isLast} node={node as any} />
83
+ return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
83
84
  case 'ceiling':
84
- return <CeilingTreeNode depth={depth} isLast={isLast} node={node as any} />
85
+ return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
85
86
  case 'level':
86
- return <LevelTreeNode depth={depth} isLast={isLast} node={node as any} />
87
+ return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
87
88
  case 'slab':
88
- return <SlabTreeNode depth={depth} isLast={isLast} node={node as any} />
89
+ return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
89
90
  case 'wall':
90
- return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
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} node={node as any} />
95
+ return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
93
96
  case 'stair':
94
- return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />
97
+ return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
95
98
  case 'item':
96
- return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
99
+ return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
97
100
  case 'door':
98
- return <DoorTreeNode depth={depth} isLast={isLast} node={node as any} />
101
+ return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
99
102
  case 'window':
100
- return <WindowTreeNode depth={depth} isLast={isLast} node={node as any} />
103
+ return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
101
104
  case 'zone':
102
- return <ZoneTreeNode depth={depth} isLast={isLast} node={node as any} />
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
- node: WallNode
12
+ nodeId: AnyNodeId
12
13
  depth: number
13
14
  isLast?: boolean
14
15
  }
15
16
 
16
- export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
17
+ export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
17
18
  const [expanded, setExpanded] = useState(false)
18
19
  const [isEditing, setIsEditing] = useState(false)
19
- const selectedIds = useViewer((state) => state.selection.selectedIds)
20
- const isSelected = selectedIds.includes(node.id)
21
- const isHovered = useViewer((state) => state.hoveredId === node.id)
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
- if (selectedIds.length === 0) return
27
- const nodes = useScene.getState().nodes
28
- let isDescendant = false
29
- for (const id of selectedIds) {
30
- let current = nodes[id as AnyNodeId]
31
- while (current?.parentId) {
32
- if (current.parentId === node.id) {
33
- isDescendant = true
34
- break
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
- if (isDescendant) break
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 handleMouseEnter = () => {
58
- setHoveredId(node.id)
59
- }
60
-
61
- const handleMouseLeave = () => {
62
- setHoveredId(null)
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 defaultName = 'Wall'
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 node={node} />}
73
+ actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
70
74
  depth={depth}
71
75
  expanded={expanded}
72
- hasChildren={node.children.length > 0}
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={node.visible !== false}
83
+ isVisible={isVisible}
80
84
  label={
81
85
  <InlineRenameInput
82
- defaultName={defaultName}
86
+ defaultName="Wall"
83
87
  isEditing={isEditing}
84
- node={node}
85
- onStartEditing={() => setIsEditing(true)}
86
- onStopEditing={() => setIsEditing(false)}
88
+ nodeId={nodeId as AnyNodeId}
89
+ onStartEditing={handleStartEditing}
90
+ onStopEditing={handleStopEditing}
87
91
  />
88
92
  }
89
- nodeId={node.id}
93
+ nodeId={nodeId}
90
94
  onClick={handleClick}
91
95
  onDoubleClick={handleDoubleClick}
92
96
  onMouseEnter={handleMouseEnter}
93
97
  onMouseLeave={handleMouseLeave}
94
- onToggle={() => setExpanded(!expanded)}
98
+ onToggle={handleToggle}
95
99
  >
96
- {node.children.map((childId, index) => (
100
+ {children.map((childId, index) => (
97
101
  <TreeNode
98
102
  depth={depth + 1}
99
- isLast={index === node.children.length - 1}
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 { WindowNode } from '@pascal-app/core'
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
- node: WindowNode
13
+ nodeId: AnyNodeId
14
14
  depth: number
15
15
  isLast?: boolean
16
16
  }
17
17
 
18
- export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
18
+ export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
19
19
  const [isEditing, setIsEditing] = useState(false)
20
- const selectedIds = useViewer((state) => state.selection.selectedIds)
21
- const isSelected = selectedIds.includes(node.id)
22
- const isHovered = useViewer((state) => state.hoveredId === node.id)
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 defaultName = 'Window'
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 node={node} />}
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={node.visible !== false}
57
+ isVisible={isVisible}
41
58
  label={
42
59
  <InlineRenameInput
43
- defaultName={defaultName}
60
+ defaultName="Window"
44
61
  isEditing={isEditing}
45
- node={node}
46
- onStartEditing={() => setIsEditing(true)}
47
- onStopEditing={() => setIsEditing(false)}
62
+ nodeId={nodeId as AnyNodeId}
63
+ onStartEditing={handleStartEditing}
64
+ onStopEditing={handleStopEditing}
48
65
  />
49
66
  }
50
- nodeId={node.id}
51
- onClick={(e: React.MouseEvent) => {
52
- e.stopPropagation()
53
- const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
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
  />