@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
@@ -1,7 +1,8 @@
1
1
  import { type AnyNodeId, type ItemNode, useScene } 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, 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'
@@ -18,67 +19,73 @@ const CATEGORY_ICONS: Record<string, string> = {
18
19
  }
19
20
 
20
21
  interface ItemTreeNodeProps {
21
- node: ItemNode
22
+ nodeId: AnyNodeId
22
23
  depth: number
23
24
  isLast?: boolean
24
25
  }
25
26
 
26
- export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
27
+ export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
27
28
  const [isEditing, setIsEditing] = useState(false)
28
29
  const [expanded, setExpanded] = useState(true)
29
- const iconSrc = CATEGORY_ICONS[node.asset.category] || '/icons/couch.png'
30
- const selectedIds = useViewer((state) => state.selection.selectedIds)
31
- const isSelected = selectedIds.includes(node.id)
32
- const isHovered = useViewer((state) => state.hoveredId === node.id)
30
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
31
+ const children = useScene(
32
+ useShallow((s) => (s.nodes[nodeId] as ItemNode | undefined)?.children ?? []),
33
+ )
34
+ const asset = useScene((s) => (s.nodes[nodeId] as ItemNode | undefined)?.asset)
35
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
36
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
33
37
  const setSelection = useViewer((state) => state.setSelection)
34
38
  const setHoveredId = useViewer((state) => state.setHoveredId)
35
39
 
40
+ // Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
36
41
  useEffect(() => {
37
- if (selectedIds.length === 0) return
38
- const nodes = useScene.getState().nodes
39
- let isDescendant = false
40
- for (const id of selectedIds) {
41
- let current = nodes[id as AnyNodeId]
42
- while (current?.parentId) {
43
- if (current.parentId === node.id) {
44
- isDescendant = true
45
- break
42
+ return useViewer.subscribe((state) => {
43
+ const { selectedIds } = state.selection
44
+ if (selectedIds.length === 0) return
45
+ const nodes = useScene.getState().nodes
46
+ for (const id of selectedIds) {
47
+ let current = nodes[id as AnyNodeId]
48
+ while (current?.parentId) {
49
+ if (current.parentId === nodeId) {
50
+ setExpanded(true)
51
+ return
52
+ }
53
+ current = nodes[current.parentId as AnyNodeId]
46
54
  }
47
- current = nodes[current.parentId as AnyNodeId]
48
55
  }
49
- if (isDescendant) break
50
- }
51
- if (isDescendant) {
52
- setExpanded(true)
53
- }
54
- }, [selectedIds, node.id])
55
-
56
- const handleClick = (e: React.MouseEvent) => {
57
- e.stopPropagation()
58
- const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
59
- if (!handled && useEditor.getState().phase === 'structure') {
60
- useEditor.getState().setPhase('furnish')
61
- }
62
- }
63
-
64
- const handleDoubleClick = () => {
65
- focusTreeNode(node.id)
66
- }
56
+ })
57
+ }, [nodeId])
67
58
 
68
- const handleMouseEnter = () => {
69
- setHoveredId(node.id)
70
- }
59
+ const handleClick = useCallback(
60
+ (e: React.MouseEvent) => {
61
+ e.stopPropagation()
62
+ const handled = handleTreeSelection(
63
+ e,
64
+ nodeId,
65
+ useViewer.getState().selection.selectedIds,
66
+ setSelection,
67
+ )
68
+ if (!handled && useEditor.getState().phase === 'structure') {
69
+ useEditor.getState().setPhase('furnish')
70
+ }
71
+ },
72
+ [nodeId, setSelection],
73
+ )
71
74
 
72
- const handleMouseLeave = () => {
73
- setHoveredId(null)
74
- }
75
+ const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
76
+ const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
77
+ const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
78
+ const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
79
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
80
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
75
81
 
76
- const defaultName = node.asset.name || 'Item'
77
- const hasChildren = node.children && node.children.length > 0
82
+ const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
83
+ const defaultName = asset?.name || 'Item'
84
+ const hasChildren = children.length > 0
78
85
 
79
86
  return (
80
87
  <TreeNodeWrapper
81
- actions={<TreeNodeActions node={node} />}
88
+ actions={<TreeNodeActions nodeId={nodeId} />}
82
89
  depth={depth}
83
90
  expanded={expanded}
84
91
  hasChildren={hasChildren}
@@ -86,28 +93,28 @@ export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
86
93
  isHovered={isHovered}
87
94
  isLast={isLast}
88
95
  isSelected={isSelected}
89
- isVisible={node.visible !== false}
96
+ isVisible={isVisible}
90
97
  label={
91
98
  <InlineRenameInput
92
99
  defaultName={defaultName}
93
100
  isEditing={isEditing}
94
- node={node}
95
- onStartEditing={() => setIsEditing(true)}
96
- onStopEditing={() => setIsEditing(false)}
101
+ nodeId={nodeId}
102
+ onStartEditing={handleStartEditing}
103
+ onStopEditing={handleStopEditing}
97
104
  />
98
105
  }
99
- nodeId={node.id}
106
+ nodeId={nodeId}
100
107
  onClick={handleClick}
101
108
  onDoubleClick={handleDoubleClick}
102
109
  onMouseEnter={handleMouseEnter}
103
110
  onMouseLeave={handleMouseLeave}
104
- onToggle={() => setExpanded(!expanded)}
111
+ onToggle={handleToggle}
105
112
  >
106
113
  {hasChildren &&
107
- node.children.map((childId, index) => (
114
+ children.map((childId, index) => (
108
115
  <TreeNode
109
116
  depth={depth + 1}
110
- isLast={index === node.children.length - 1}
117
+ isLast={index === children.length - 1}
111
118
  key={childId}
112
119
  nodeId={childId}
113
120
  />
@@ -1,61 +1,66 @@
1
- import type { LevelNode } from '@pascal-app/core'
1
+ import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { Layers } from 'lucide-react'
4
- import { useState } from 'react'
4
+ import { useCallback, useState } from 'react'
5
+ import { useShallow } from 'zustand/react/shallow'
5
6
  import { InlineRenameInput } from './inline-rename-input'
6
7
  import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
7
8
  import { TreeNodeActions } from './tree-node-actions'
8
9
 
9
10
  interface LevelTreeNodeProps {
10
- node: LevelNode
11
+ nodeId: AnyNodeId
11
12
  depth: number
12
13
  isLast?: boolean
13
14
  }
14
15
 
15
- export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {
16
+ export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
16
17
  const [expanded, setExpanded] = useState(true)
17
18
  const [isEditing, setIsEditing] = useState(false)
18
- const isSelected = useViewer((state) => state.selection.levelId === node.id)
19
- const isHovered = useViewer((state) => state.hoveredId === node.id)
19
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
20
+ const children = useScene(
21
+ useShallow((s) => (s.nodes[nodeId] as LevelNode | undefined)?.children ?? []),
22
+ )
23
+ const level = useScene((s) => (s.nodes[nodeId] as LevelNode | undefined)?.level ?? 0)
24
+ const isSelected = useViewer((state) => state.selection.levelId === nodeId)
25
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
20
26
  const setSelection = useViewer((state) => state.setSelection)
21
27
 
22
- const handleClick = () => {
23
- setSelection({ levelId: node.id })
24
- }
25
-
26
- const handleDoubleClick = () => {
27
- focusTreeNode(node.id)
28
- }
28
+ const handleClick = useCallback(() => setSelection({ levelId: nodeId }), [nodeId, setSelection])
29
+ const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
30
+ const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
31
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
32
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
29
33
 
30
- const defaultName = `Level ${node.level}`
34
+ const defaultName = `Level ${level}`
31
35
 
32
36
  return (
33
37
  <TreeNodeWrapper
34
- actions={<TreeNodeActions node={node} />}
38
+ actions={<TreeNodeActions nodeId={nodeId} />}
35
39
  depth={depth}
36
40
  expanded={expanded}
37
- hasChildren={node.children.length > 0}
41
+ hasChildren={children.length > 0}
38
42
  icon={<Layers className="h-3.5 w-3.5" />}
39
43
  isHovered={isHovered}
40
44
  isLast={isLast}
41
45
  isSelected={isSelected}
46
+ isVisible={isVisible}
42
47
  label={
43
48
  <InlineRenameInput
44
49
  defaultName={defaultName}
45
50
  isEditing={isEditing}
46
- node={node}
47
- onStartEditing={() => setIsEditing(true)}
48
- onStopEditing={() => setIsEditing(false)}
51
+ nodeId={nodeId}
52
+ onStartEditing={handleStartEditing}
53
+ onStopEditing={handleStopEditing}
49
54
  />
50
55
  }
51
56
  onClick={handleClick}
52
57
  onDoubleClick={handleDoubleClick}
53
- onToggle={() => setExpanded(!expanded)}
58
+ onToggle={handleToggle}
54
59
  >
55
- {node.children.map((childId, index) => (
60
+ {children.map((childId, index) => (
56
61
  <TreeNode
57
62
  depth={depth + 1}
58
- isLast={index === node.children.length - 1}
63
+ isLast={index === children.length - 1}
59
64
  key={childId}
60
65
  nodeId={childId}
61
66
  />
@@ -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 RoofTreeNodeProps {
13
- node: RoofNode
14
+ nodeId: AnyNodeId
14
15
  depth: number
15
16
  isLast?: boolean
16
17
  }
17
18
 
18
- export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
19
+ export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
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 RoofNode | undefined
32
+ if (!n) return [] as RoofSegmentNode[]
33
+ return (n.children ?? [])
34
+ .map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
35
+ .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
36
+ }),
37
+ )
44
38
 
45
- const handleMouseLeave = () => {
46
- setHoveredId(null)
47
- }
39
+ // Targeted selector only re-renders when a segment of THIS roof 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 RoofSegmentNode | undefined)
51
- .filter((n): n is RoofSegmentNode => n?.type === 'roof-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 RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
59
71
  }, [isSelected, hasSelectedChild])
60
72
 
61
73
  // Auto-expand when a segment is being dragged over this roof
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 RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
72
84
  // Hide the dragged segment from every roof 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 RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
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 RoofSegmentTreeNode({
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) => {
@@ -169,12 +183,15 @@ function RoofSegmentTreeNode({
169
183
  [node.id, node.type, node.parentId, node.roofType, node.width, node.depth, startDrag],
170
184
  )
171
185
 
186
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
187
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
188
+
172
189
  const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`
173
190
 
174
191
  return (
175
192
  <div data-drop-child={node.id}>
176
193
  <TreeNodeWrapper
177
- actions={<TreeNodeActions node={node} />}
194
+ actions={<TreeNodeActions nodeId={node.id} />}
178
195
  depth={depth}
179
196
  expanded={false}
180
197
  hasChildren={false}
@@ -196,9 +213,9 @@ function RoofSegmentTreeNode({
196
213
  <InlineRenameInput
197
214
  defaultName={defaultName}
198
215
  isEditing={isEditing}
199
- node={node}
200
- onStartEditing={() => setIsEditing(true)}
201
- onStopEditing={() => setIsEditing(false)}
216
+ nodeId={node.id}
217
+ onStartEditing={handleStartEditing}
218
+ onStopEditing={handleStopEditing}
202
219
  />
203
220
  }
204
221
  nodeId={node.id}
@@ -1,53 +1,52 @@
1
- import type { SlabNode } from '@pascal-app/core'
1
+ import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
- import { useState } from 'react'
4
+ import { useCallback, useState } from 'react'
5
5
  import useEditor from './../../../../../store/use-editor'
6
6
  import { InlineRenameInput } from './inline-rename-input'
7
7
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
8
8
  import { TreeNodeActions } from './tree-node-actions'
9
9
 
10
10
  interface SlabTreeNodeProps {
11
- node: SlabNode
11
+ nodeId: AnyNodeId
12
12
  depth: number
13
13
  isLast?: boolean
14
14
  }
15
15
 
16
- export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
16
+ export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
17
17
  const [isEditing, setIsEditing] = useState(false)
18
- const selectedIds = useViewer((state) => state.selection.selectedIds)
19
- const isSelected = selectedIds.includes(node.id)
20
- const isHovered = useViewer((state) => state.hoveredId === node.id)
18
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
19
+ const polygon = useScene((s) => (s.nodes[nodeId] as SlabNode | undefined)?.polygon ?? [])
20
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
21
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
21
22
  const setSelection = useViewer((state) => state.setSelection)
22
23
  const setHoveredId = useViewer((state) => state.setHoveredId)
23
24
 
24
- const handleClick = (e: React.MouseEvent) => {
25
- e.stopPropagation()
26
- const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
27
- if (!handled && useEditor.getState().phase === 'furnish') {
28
- useEditor.getState().setPhase('structure')
29
- }
30
- }
31
-
32
- const handleDoubleClick = () => {
33
- focusTreeNode(node.id)
34
- }
35
-
36
- const handleMouseEnter = () => {
37
- setHoveredId(node.id)
38
- }
25
+ const handleClick = useCallback(
26
+ (e: React.MouseEvent) => {
27
+ e.stopPropagation()
28
+ const handled = handleTreeSelection(
29
+ e,
30
+ nodeId,
31
+ useViewer.getState().selection.selectedIds,
32
+ setSelection,
33
+ )
34
+ if (!handled && useEditor.getState().phase === 'furnish') {
35
+ useEditor.getState().setPhase('structure')
36
+ }
37
+ },
38
+ [nodeId, setSelection],
39
+ )
39
40
 
40
- const handleMouseLeave = () => {
41
- setHoveredId(null)
42
- }
41
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
42
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
43
43
 
44
- // Calculate approximate area from polygon
45
- const area = calculatePolygonArea(node.polygon).toFixed(1)
44
+ const area = calculatePolygonArea(polygon).toFixed(1)
46
45
  const defaultName = `Slab (${area}m²)`
47
46
 
48
47
  return (
49
48
  <TreeNodeWrapper
50
- actions={<TreeNodeActions node={node} />}
49
+ actions={<TreeNodeActions nodeId={nodeId} />}
51
50
  depth={depth}
52
51
  expanded={false}
53
52
  hasChildren={false}
@@ -57,21 +56,21 @@ export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
57
56
  isHovered={isHovered}
58
57
  isLast={isLast}
59
58
  isSelected={isSelected}
60
- isVisible={node.visible !== false}
59
+ isVisible={isVisible}
61
60
  label={
62
61
  <InlineRenameInput
63
62
  defaultName={defaultName}
64
63
  isEditing={isEditing}
65
- node={node}
66
- onStartEditing={() => setIsEditing(true)}
67
- onStopEditing={() => setIsEditing(false)}
64
+ nodeId={nodeId}
65
+ onStartEditing={handleStartEditing}
66
+ onStopEditing={handleStopEditing}
68
67
  />
69
68
  }
70
- nodeId={node.id}
69
+ nodeId={nodeId}
71
70
  onClick={handleClick}
72
- onDoubleClick={handleDoubleClick}
73
- onMouseEnter={handleMouseEnter}
74
- onMouseLeave={handleMouseLeave}
71
+ onDoubleClick={() => focusTreeNode(nodeId)}
72
+ onMouseEnter={() => setHoveredId(nodeId)}
73
+ onMouseLeave={() => setHoveredId(null)}
75
74
  onToggle={() => {}}
76
75
  />
77
76
  )