@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.
Files changed (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. 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
- 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 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 selectedIds = useViewer((state) => state.selection.selectedIds)
22
- const isSelected = selectedIds.includes(node.id)
23
- const isHovered = useViewer((state) => state.hoveredId === node.id)
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 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
- }
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
- const handleMouseLeave = () => {
46
- setHoveredId(null)
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 segments = (node.children ?? [])
50
- .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
51
- .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
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 hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
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 === node.id
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 !== node.id
91
+ const isValidDropTarget = drag !== null && drag.nodeId !== nodeId
76
92
 
77
93
  return (
78
- <div data-drop-target={node.id}>
94
+ <div data-drop-target={nodeId}>
79
95
  <TreeNodeWrapper
80
- actions={<TreeNodeActions node={node} />}
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={node.visible !== false}
107
+ isVisible={isVisible}
92
108
  label={
93
109
  <InlineRenameInput
94
110
  defaultName={defaultName}
95
111
  isEditing={isEditing}
96
- node={node}
97
- onStartEditing={() => setIsEditing(true)}
98
- onStopEditing={() => setIsEditing(false)}
112
+ nodeId={nodeId}
113
+ onStartEditing={handleStartEditing}
114
+ onStopEditing={handleStopEditing}
99
115
  />
100
116
  }
101
- nodeId={node.id}
117
+ nodeId={nodeId}
102
118
  onClick={handleClick}
103
119
  onDoubleClick={handleDoubleClick}
104
120
  onMouseEnter={handleMouseEnter}
105
121
  onMouseLeave={handleMouseLeave}
106
- onToggle={() => setExpanded(!expanded)}
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 selectedIds = useViewer((state) => state.selection.selectedIds)
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 = (e: React.MouseEvent) => {
158
- if (isDragging) return
159
- e.stopPropagation()
160
- handleTreeSelection(e, node.id, selectedIds, setSelection)
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 node={node} />}
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
- node={node}
202
- onStartEditing={() => setIsEditing(true)}
203
- onStopEditing={() => setIsEditing(false)}
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 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
- 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
- node: AnyNode
12
+ nodeId: AnyNodeId
13
13
  }
14
14
 
15
- export function TreeNodeActions({ node }: TreeNodeActionsProps) {
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 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
 
@@ -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 node = useScene((state) => state.nodes[nodeId])
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 (!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
  }
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
- 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 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 selectedIds = useViewer((state) => state.selection.selectedIds)
20
- const isSelected = selectedIds.includes(node.id)
21
- const isHovered = useViewer((state) => state.hoveredId === node.id)
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
- 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
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
- 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
- }
56
-
57
- const handleMouseEnter = () => {
58
- setHoveredId(node.id)
59
- }
49
+ })
50
+ }, [nodeId])
60
51
 
61
- const handleMouseLeave = () => {
62
- setHoveredId(null)
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 defaultName = 'Wall'
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 node={node} />}
77
+ actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
70
78
  depth={depth}
71
79
  expanded={expanded}
72
- hasChildren={node.children.length > 0}
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={node.visible !== false}
87
+ isVisible={isVisible}
80
88
  label={
81
89
  <InlineRenameInput
82
- defaultName={defaultName}
90
+ defaultName="Wall"
83
91
  isEditing={isEditing}
84
- node={node}
85
- onStartEditing={() => setIsEditing(true)}
86
- onStopEditing={() => setIsEditing(false)}
92
+ nodeId={nodeId as AnyNodeId}
93
+ onStartEditing={handleStartEditing}
94
+ onStopEditing={handleStopEditing}
87
95
  />
88
96
  }
89
- nodeId={node.id}
97
+ nodeId={nodeId}
90
98
  onClick={handleClick}
91
99
  onDoubleClick={handleDoubleClick}
92
100
  onMouseEnter={handleMouseEnter}
93
101
  onMouseLeave={handleMouseLeave}
94
- onToggle={() => setExpanded(!expanded)}
102
+ onToggle={handleToggle}
95
103
  >
96
- {node.children.map((childId, index) => (
104
+ {children.map((childId, index) => (
97
105
  <TreeNode
98
106
  depth={depth + 1}
99
- isLast={index === node.children.length - 1}
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 { 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 { 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
- 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 const WindowTreeNode = memo(function WindowTreeNode({
19
+ nodeId,
20
+ depth,
21
+ isLast,
22
+ }: WindowTreeNodeProps) {
19
23
  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)
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 defaultName = 'Window'
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 node={node} />}
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={node.visible !== false}
61
+ isVisible={isVisible}
41
62
  label={
42
63
  <InlineRenameInput
43
- defaultName={defaultName}
64
+ defaultName="Window"
44
65
  isEditing={isEditing}
45
- node={node}
46
- onStartEditing={() => setIsEditing(true)}
47
- onStopEditing={() => setIsEditing(false)}
66
+ nodeId={nodeId as AnyNodeId}
67
+ onStartEditing={handleStartEditing}
68
+ onStopEditing={handleStopEditing}
48
69
  />
49
70
  }
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)}
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
+ })