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