@pascal-app/editor 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/components/editor/floating-action-menu.tsx +101 -29
- package/src/components/editor/floating-building-action-menu.tsx +69 -0
- package/src/components/editor/floorplan-panel.tsx +31 -13
- package/src/components/editor/index.tsx +219 -167
- package/src/components/editor/node-action-menu.tsx +26 -10
- package/src/components/editor/selection-manager.tsx +38 -2
- package/src/components/editor/thumbnail-generator.tsx +245 -64
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +19 -7
- package/src/components/tools/door/move-door-tool.tsx +17 -8
- package/src/components/tools/fence/fence-drafting.ts +125 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-tool.tsx +223 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +7 -0
- package/src/components/tools/item/placement-strategies.ts +15 -7
- package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
- package/src/components/tools/roof/move-roof-tool.tsx +5 -2
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +2 -2
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +29 -6
- package/src/components/tools/tool-manager.tsx +42 -14
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +17 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +19 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/panels/fence-panel.tsx +184 -0
- package/src/components/ui/panels/panel-manager.tsx +3 -0
- package/src/components/ui/panels/stair-panel.tsx +206 -33
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
- package/src/components/viewer-overlay.tsx +1 -0
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +10 -2
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +4 -0
- package/src/store/use-editor.tsx +7 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
|
1
|
+
import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { Building2, Plus } from 'lucide-react'
|
|
4
4
|
import { useState } from 'react'
|
|
5
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
5
6
|
import {
|
|
6
7
|
Tooltip,
|
|
7
8
|
TooltipContent,
|
|
@@ -11,37 +12,42 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
|
11
12
|
import { TreeNodeActions } from './tree-node-actions'
|
|
12
13
|
|
|
13
14
|
interface BuildingTreeNodeProps {
|
|
14
|
-
|
|
15
|
+
nodeId: AnyNodeId
|
|
15
16
|
depth: number
|
|
16
17
|
isLast?: boolean
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function BuildingTreeNode({
|
|
20
|
+
export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) {
|
|
20
21
|
const [expanded, setExpanded] = useState(true)
|
|
21
22
|
const createNode = useScene((state) => state.createNode)
|
|
22
|
-
const
|
|
23
|
-
const
|
|
23
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
24
|
+
const name = useScene((s) => s.nodes[nodeId]?.name)
|
|
25
|
+
const children = useScene(
|
|
26
|
+
useShallow((s) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []),
|
|
27
|
+
)
|
|
28
|
+
const isSelected = useViewer((state) => state.selection.buildingId === nodeId)
|
|
29
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
24
30
|
const setSelection = useViewer((state) => state.setSelection)
|
|
25
31
|
|
|
26
32
|
const handleClick = () => {
|
|
27
|
-
setSelection({ buildingId:
|
|
33
|
+
setSelection({ buildingId: nodeId })
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
const handleAddLevel = (e: React.MouseEvent) => {
|
|
31
37
|
e.stopPropagation()
|
|
32
38
|
const newLevel = LevelNode.parse({
|
|
33
|
-
level:
|
|
39
|
+
level: children.length,
|
|
34
40
|
children: [],
|
|
35
|
-
parentId:
|
|
41
|
+
parentId: nodeId,
|
|
36
42
|
})
|
|
37
|
-
createNode(newLevel,
|
|
43
|
+
createNode(newLevel, nodeId)
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
return (
|
|
41
47
|
<TreeNodeWrapper
|
|
42
48
|
actions={
|
|
43
49
|
<div className="flex items-center gap-0.5">
|
|
44
|
-
<TreeNodeActions
|
|
50
|
+
<TreeNodeActions nodeId={nodeId} />
|
|
45
51
|
<Tooltip>
|
|
46
52
|
<TooltipTrigger asChild>
|
|
47
53
|
<button
|
|
@@ -57,20 +63,21 @@ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps)
|
|
|
57
63
|
}
|
|
58
64
|
depth={depth}
|
|
59
65
|
expanded={expanded}
|
|
60
|
-
hasChildren={
|
|
66
|
+
hasChildren={children.length > 0}
|
|
61
67
|
icon={<Building2 className="h-3.5 w-3.5" />}
|
|
62
68
|
isHovered={isHovered}
|
|
63
69
|
isLast={isLast}
|
|
64
70
|
isSelected={isSelected}
|
|
65
|
-
|
|
71
|
+
isVisible={isVisible}
|
|
72
|
+
label={name || 'Building'}
|
|
66
73
|
onClick={handleClick}
|
|
67
|
-
onDoubleClick={() => focusTreeNode(
|
|
74
|
+
onDoubleClick={() => focusTreeNode(nodeId)}
|
|
68
75
|
onToggle={() => setExpanded(!expanded)}
|
|
69
76
|
>
|
|
70
|
-
{
|
|
77
|
+
{children.map((childId, index) => (
|
|
71
78
|
<TreeNode
|
|
72
79
|
depth={depth + 1}
|
|
73
|
-
isLast={index ===
|
|
80
|
+
isLast={index === children.length - 1}
|
|
74
81
|
key={childId}
|
|
75
82
|
nodeId={childId}
|
|
76
83
|
/>
|
|
@@ -1,104 +1,112 @@
|
|
|
1
1
|
import { type AnyNodeId, type CeilingNode, 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'
|
|
8
9
|
import { TreeNodeActions } from './tree-node-actions'
|
|
9
10
|
|
|
10
11
|
interface CeilingTreeNodeProps {
|
|
11
|
-
|
|
12
|
+
nodeId: AnyNodeId
|
|
12
13
|
depth: number
|
|
13
14
|
isLast?: boolean
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function CeilingTreeNode({
|
|
17
|
+
export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps) {
|
|
17
18
|
const [expanded, setExpanded] = useState(false)
|
|
18
19
|
const [isEditing, setIsEditing] = useState(false)
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
21
|
+
const children = useScene(
|
|
22
|
+
useShallow((s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.children ?? []),
|
|
23
|
+
)
|
|
24
|
+
const polygon = useScene(
|
|
25
|
+
(s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.polygon ?? [],
|
|
26
|
+
)
|
|
27
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
28
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
22
29
|
const setSelection = useViewer((state) => state.setSelection)
|
|
23
30
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
24
31
|
|
|
32
|
+
// Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
|
|
25
33
|
useEffect(() => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
return useViewer.subscribe((state) => {
|
|
35
|
+
const { selectedIds } = state.selection
|
|
36
|
+
if (selectedIds.length === 0) return
|
|
37
|
+
const nodes = useScene.getState().nodes
|
|
38
|
+
for (const id of selectedIds) {
|
|
39
|
+
let current = nodes[id as AnyNodeId]
|
|
40
|
+
while (current?.parentId) {
|
|
41
|
+
if (current.parentId === nodeId) {
|
|
42
|
+
setExpanded(true)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
current = nodes[current.parentId as AnyNodeId]
|
|
35
46
|
}
|
|
36
|
-
current = nodes[current.parentId as AnyNodeId]
|
|
37
47
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (isDescendant) {
|
|
41
|
-
setExpanded(true)
|
|
42
|
-
}
|
|
43
|
-
}, [selectedIds, node.id])
|
|
44
|
-
|
|
45
|
-
const handleClick = (e: React.MouseEvent) => {
|
|
46
|
-
e.stopPropagation()
|
|
47
|
-
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
|
48
|
-
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
49
|
-
useEditor.getState().setPhase('structure')
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const handleDoubleClick = () => {
|
|
54
|
-
focusTreeNode(node.id)
|
|
55
|
-
}
|
|
48
|
+
})
|
|
49
|
+
}, [nodeId])
|
|
56
50
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
const handleClick = useCallback(
|
|
52
|
+
(e: React.MouseEvent) => {
|
|
53
|
+
e.stopPropagation()
|
|
54
|
+
const handled = handleTreeSelection(
|
|
55
|
+
e,
|
|
56
|
+
nodeId,
|
|
57
|
+
useViewer.getState().selection.selectedIds,
|
|
58
|
+
setSelection,
|
|
59
|
+
)
|
|
60
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
61
|
+
useEditor.getState().setPhase('structure')
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[nodeId, setSelection],
|
|
65
|
+
)
|
|
60
66
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
|
|
68
|
+
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
|
69
|
+
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
|
70
|
+
const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
|
|
71
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
72
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
|
74
|
+
const area = calculatePolygonArea(polygon).toFixed(1)
|
|
67
75
|
const defaultName = `Ceiling (${area}m²)`
|
|
68
76
|
|
|
69
77
|
return (
|
|
70
78
|
<TreeNodeWrapper
|
|
71
|
-
actions={<TreeNodeActions
|
|
79
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
72
80
|
depth={depth}
|
|
73
81
|
expanded={expanded}
|
|
74
|
-
hasChildren={
|
|
82
|
+
hasChildren={children.length > 0}
|
|
75
83
|
icon={
|
|
76
84
|
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
|
|
77
85
|
}
|
|
78
86
|
isHovered={isHovered}
|
|
79
87
|
isLast={isLast}
|
|
80
88
|
isSelected={isSelected}
|
|
81
|
-
isVisible={
|
|
89
|
+
isVisible={isVisible}
|
|
82
90
|
label={
|
|
83
91
|
<InlineRenameInput
|
|
84
92
|
defaultName={defaultName}
|
|
85
93
|
isEditing={isEditing}
|
|
86
|
-
|
|
87
|
-
onStartEditing={
|
|
88
|
-
onStopEditing={
|
|
94
|
+
nodeId={nodeId as AnyNodeId}
|
|
95
|
+
onStartEditing={handleStartEditing}
|
|
96
|
+
onStopEditing={handleStopEditing}
|
|
89
97
|
/>
|
|
90
98
|
}
|
|
91
|
-
nodeId={
|
|
99
|
+
nodeId={nodeId}
|
|
92
100
|
onClick={handleClick}
|
|
93
101
|
onDoubleClick={handleDoubleClick}
|
|
94
102
|
onMouseEnter={handleMouseEnter}
|
|
95
103
|
onMouseLeave={handleMouseLeave}
|
|
96
|
-
onToggle={
|
|
104
|
+
onToggle={handleToggle}
|
|
97
105
|
>
|
|
98
|
-
{
|
|
106
|
+
{children.map((childId, index) => (
|
|
99
107
|
<TreeNode
|
|
100
108
|
depth={depth + 1}
|
|
101
|
-
isLast={index ===
|
|
109
|
+
isLast={index === children.length - 1}
|
|
102
110
|
key={childId}
|
|
103
111
|
nodeId={childId}
|
|
104
112
|
/>
|
|
@@ -1,33 +1,50 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
5
|
import Image from 'next/image'
|
|
6
|
-
import { useState } from 'react'
|
|
6
|
+
import { useCallback, useState } from 'react'
|
|
7
7
|
import useEditor from './../../../../../store/use-editor'
|
|
8
8
|
import { InlineRenameInput } from './inline-rename-input'
|
|
9
9
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
10
10
|
import { TreeNodeActions } from './tree-node-actions'
|
|
11
11
|
|
|
12
12
|
interface DoorTreeNodeProps {
|
|
13
|
-
|
|
13
|
+
nodeId: AnyNodeId
|
|
14
14
|
depth: number
|
|
15
15
|
isLast?: boolean
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function DoorTreeNode({
|
|
18
|
+
export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
|
|
19
19
|
const [isEditing, setIsEditing] = useState(false)
|
|
20
|
-
const
|
|
21
|
-
const isSelected = selectedIds.includes(
|
|
22
|
-
const isHovered = useViewer((state) => state.hoveredId ===
|
|
20
|
+
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
21
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
22
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
23
23
|
const setSelection = useViewer((state) => state.setSelection)
|
|
24
24
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
25
25
|
|
|
26
|
-
const
|
|
26
|
+
const handleClick = useCallback(
|
|
27
|
+
(e: React.MouseEvent) => {
|
|
28
|
+
e.stopPropagation()
|
|
29
|
+
const handled = handleTreeSelection(
|
|
30
|
+
e,
|
|
31
|
+
nodeId,
|
|
32
|
+
useViewer.getState().selection.selectedIds,
|
|
33
|
+
setSelection,
|
|
34
|
+
)
|
|
35
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
36
|
+
useEditor.getState().setPhase('structure')
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
[nodeId, setSelection],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
43
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
27
44
|
|
|
28
45
|
return (
|
|
29
46
|
<TreeNodeWrapper
|
|
30
|
-
actions={<TreeNodeActions
|
|
47
|
+
actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
|
|
31
48
|
depth={depth}
|
|
32
49
|
expanded={false}
|
|
33
50
|
hasChildren={false}
|
|
@@ -37,26 +54,20 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
|
|
|
37
54
|
isHovered={isHovered}
|
|
38
55
|
isLast={isLast}
|
|
39
56
|
isSelected={isSelected}
|
|
40
|
-
isVisible={
|
|
57
|
+
isVisible={isVisible}
|
|
41
58
|
label={
|
|
42
59
|
<InlineRenameInput
|
|
43
|
-
defaultName=
|
|
60
|
+
defaultName="Door"
|
|
44
61
|
isEditing={isEditing}
|
|
45
|
-
|
|
46
|
-
onStartEditing={
|
|
47
|
-
onStopEditing={
|
|
62
|
+
nodeId={nodeId as AnyNodeId}
|
|
63
|
+
onStartEditing={handleStartEditing}
|
|
64
|
+
onStopEditing={handleStopEditing}
|
|
48
65
|
/>
|
|
49
66
|
}
|
|
50
|
-
nodeId={
|
|
51
|
-
onClick={
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
55
|
-
useEditor.getState().setPhase('structure')
|
|
56
|
-
}
|
|
57
|
-
}}
|
|
58
|
-
onDoubleClick={() => focusTreeNode(node.id)}
|
|
59
|
-
onMouseEnter={() => setHoveredId(node.id)}
|
|
67
|
+
nodeId={nodeId}
|
|
68
|
+
onClick={handleClick}
|
|
69
|
+
onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
|
|
70
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
60
71
|
onMouseLeave={() => setHoveredId(null)}
|
|
61
72
|
onToggle={() => {}}
|
|
62
73
|
/>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import Image from 'next/image'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import useEditor from '../../../../../store/use-editor'
|
|
6
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
7
|
+
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
8
|
+
import { TreeNodeActions } from './tree-node-actions'
|
|
9
|
+
|
|
10
|
+
interface FenceTreeNodeProps {
|
|
11
|
+
nodeId: AnyNodeId
|
|
12
|
+
depth: number
|
|
13
|
+
isLast?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
|
|
17
|
+
const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined
|
|
18
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
19
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
20
|
+
const isSelected = selectedIds.includes(nodeId)
|
|
21
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
22
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
23
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
24
|
+
|
|
25
|
+
if (!node) return null
|
|
26
|
+
|
|
27
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
28
|
+
e.stopPropagation()
|
|
29
|
+
const handled = handleTreeSelection(e, nodeId, selectedIds, setSelection)
|
|
30
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
31
|
+
useEditor.getState().setPhase('structure')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<TreeNodeWrapper
|
|
37
|
+
actions={<TreeNodeActions node={node} />}
|
|
38
|
+
depth={depth}
|
|
39
|
+
expanded={false}
|
|
40
|
+
hasChildren={false}
|
|
41
|
+
icon={
|
|
42
|
+
<Image alt="" className="object-contain" height={14} src="/icons/fence.png" width={14} />
|
|
43
|
+
}
|
|
44
|
+
isHovered={isHovered}
|
|
45
|
+
isLast={isLast}
|
|
46
|
+
isSelected={isSelected}
|
|
47
|
+
isVisible={node.visible !== false}
|
|
48
|
+
label={
|
|
49
|
+
<InlineRenameInput
|
|
50
|
+
defaultName="Fence"
|
|
51
|
+
isEditing={isEditing}
|
|
52
|
+
node={node}
|
|
53
|
+
onStartEditing={() => setIsEditing(true)}
|
|
54
|
+
onStopEditing={() => setIsEditing(false)}
|
|
55
|
+
/>
|
|
56
|
+
}
|
|
57
|
+
nodeId={nodeId}
|
|
58
|
+
onClick={handleClick}
|
|
59
|
+
onDoubleClick={() => focusTreeNode(nodeId)}
|
|
60
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
61
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
62
|
+
onToggle={() => {}}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from 'lucide-react'
|
|
25
25
|
import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
|
|
26
26
|
import { useEffect, useRef, useState } from 'react'
|
|
27
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
27
28
|
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
|
28
29
|
import {
|
|
29
30
|
Popover,
|
|
@@ -393,8 +394,15 @@ function LevelReferences({
|
|
|
393
394
|
onUploadAsset,
|
|
394
395
|
onDeleteAsset,
|
|
395
396
|
}: LevelReferencesProps) {
|
|
396
|
-
const nodes = useScene((s) => s.nodes)
|
|
397
397
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
398
|
+
const references = useScene(
|
|
399
|
+
useShallow((s) =>
|
|
400
|
+
Object.values(s.nodes).filter(
|
|
401
|
+
(node): node is ScanNode | GuideNode =>
|
|
402
|
+
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
|
403
|
+
),
|
|
404
|
+
),
|
|
405
|
+
)
|
|
398
406
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
|
399
407
|
const uploadState = useUploadStore((s) => s.uploads[levelId])
|
|
400
408
|
const clearUpload = useUploadStore((s) => s.clearUpload)
|
|
@@ -409,11 +417,6 @@ function LevelReferences({
|
|
|
409
417
|
|
|
410
418
|
const scanInputRef = useRef<HTMLInputElement>(null)
|
|
411
419
|
|
|
412
|
-
const references = Object.values(nodes).filter(
|
|
413
|
-
(node): node is ScanNode | GuideNode =>
|
|
414
|
-
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
|
415
|
-
)
|
|
416
|
-
|
|
417
420
|
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
418
421
|
const file = e.target.files?.[0]
|
|
419
422
|
if (!file) return
|
|
@@ -457,7 +460,10 @@ function LevelReferences({
|
|
|
457
460
|
|
|
458
461
|
const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
|
|
459
462
|
e.stopPropagation()
|
|
460
|
-
const refNode = nodes[nodeId as AnyNodeId] as
|
|
463
|
+
const refNode = useScene.getState().nodes[nodeId as AnyNodeId] as
|
|
464
|
+
| ScanNode
|
|
465
|
+
| GuideNode
|
|
466
|
+
| undefined
|
|
461
467
|
|
|
462
468
|
if (
|
|
463
469
|
projectId &&
|
|
@@ -789,21 +795,28 @@ function LevelsSection({
|
|
|
789
795
|
onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
|
|
790
796
|
onDeleteAsset?: (projectId: string, url: string) => void
|
|
791
797
|
} = {}) {
|
|
792
|
-
const nodes = useScene((state) => state.nodes)
|
|
793
798
|
const createNode = useScene((state) => state.createNode)
|
|
794
799
|
const updateNode = useScene((state) => state.updateNode)
|
|
795
800
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId)
|
|
796
801
|
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
797
802
|
const setSelection = useViewer((state) => state.setSelection)
|
|
798
803
|
|
|
799
|
-
const building =
|
|
804
|
+
const building = useScene((s) =>
|
|
805
|
+
selectedBuildingId ? ((s.nodes[selectedBuildingId] as BuildingNode | undefined) ?? null) : null,
|
|
806
|
+
)
|
|
807
|
+
const levels = useScene(
|
|
808
|
+
useShallow((s) => {
|
|
809
|
+
if (!selectedBuildingId) return []
|
|
810
|
+
const bldg = s.nodes[selectedBuildingId] as BuildingNode | undefined
|
|
811
|
+
if (!bldg) return []
|
|
812
|
+
return bldg.children
|
|
813
|
+
.map((id) => s.nodes[id])
|
|
814
|
+
.filter((node): node is LevelNode => node?.type === 'level')
|
|
815
|
+
}),
|
|
816
|
+
)
|
|
800
817
|
|
|
801
818
|
if (!building) return null
|
|
802
819
|
|
|
803
|
-
const levels = building.children
|
|
804
|
-
.map((id) => nodes[id])
|
|
805
|
-
.filter((node): node is LevelNode => node?.type === 'level')
|
|
806
|
-
|
|
807
820
|
const handleAddLevel = () => {
|
|
808
821
|
const newLevel = LevelNode.parse({
|
|
809
822
|
level: levels.length,
|
|
@@ -1175,7 +1188,6 @@ function MultiSelectionBadge() {
|
|
|
1175
1188
|
}
|
|
1176
1189
|
|
|
1177
1190
|
function ContentSection() {
|
|
1178
|
-
const nodes = useScene((state) => state.nodes)
|
|
1179
1191
|
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
1180
1192
|
const structureLayer = useEditor((state) => state.structureLayer)
|
|
1181
1193
|
const phase = useEditor((state) => state.phase)
|
|
@@ -1183,7 +1195,25 @@ function ContentSection() {
|
|
|
1183
1195
|
const setMode = useEditor((state) => state.setMode)
|
|
1184
1196
|
const setTool = useEditor((state) => state.setTool)
|
|
1185
1197
|
|
|
1186
|
-
const level =
|
|
1198
|
+
const level = useScene((s) =>
|
|
1199
|
+
selectedLevelId ? ((s.nodes[selectedLevelId] as LevelNode | undefined) ?? null) : null,
|
|
1200
|
+
)
|
|
1201
|
+
const levelZones = useScene(
|
|
1202
|
+
useShallow((s) => {
|
|
1203
|
+
if (!selectedLevelId) return []
|
|
1204
|
+
return Object.values(s.nodes).filter(
|
|
1205
|
+
(node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,
|
|
1206
|
+
)
|
|
1207
|
+
}),
|
|
1208
|
+
)
|
|
1209
|
+
const elementChildren = useScene(
|
|
1210
|
+
useShallow((s) => {
|
|
1211
|
+
if (!selectedLevelId) return []
|
|
1212
|
+
const lvl = s.nodes[selectedLevelId] as LevelNode | undefined
|
|
1213
|
+
if (!lvl) return []
|
|
1214
|
+
return lvl.children.filter((childId) => s.nodes[childId]?.type !== 'zone')
|
|
1215
|
+
}),
|
|
1216
|
+
)
|
|
1187
1217
|
|
|
1188
1218
|
if (!level) {
|
|
1189
1219
|
return (
|
|
@@ -1192,11 +1222,6 @@ function ContentSection() {
|
|
|
1192
1222
|
}
|
|
1193
1223
|
|
|
1194
1224
|
if (structureLayer === 'zones') {
|
|
1195
|
-
// Show zones for this level
|
|
1196
|
-
const levelZones = Object.values(nodes).filter(
|
|
1197
|
-
(node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,
|
|
1198
|
-
)
|
|
1199
|
-
|
|
1200
1225
|
const handleAddZone = () => {
|
|
1201
1226
|
setPhase('structure')
|
|
1202
1227
|
setMode('build')
|
|
@@ -1223,21 +1248,9 @@ function ContentSection() {
|
|
|
1223
1248
|
)
|
|
1224
1249
|
}
|
|
1225
1250
|
|
|
1226
|
-
// Filter elements based on phase
|
|
1227
|
-
const elementChildren = level.children.filter((childId) => {
|
|
1228
|
-
const childNode = nodes[childId]
|
|
1229
|
-
if (!childNode || childNode.type === 'zone') return false
|
|
1230
|
-
|
|
1231
|
-
// We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode
|
|
1232
|
-
// This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible
|
|
1233
|
-
// and selectable in both modes, ensuring seamless transition in the tree view.
|
|
1234
|
-
return true
|
|
1235
|
-
})
|
|
1236
|
-
|
|
1237
1251
|
if (elementChildren.length === 0) {
|
|
1238
1252
|
return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div>
|
|
1239
1253
|
}
|
|
1240
|
-
|
|
1241
1254
|
return (
|
|
1242
1255
|
<TreeNodeDragProvider>
|
|
1243
1256
|
<div className="flex flex-col">
|
|
@@ -1431,7 +1444,6 @@ export interface SitePanelProps {
|
|
|
1431
1444
|
}
|
|
1432
1445
|
|
|
1433
1446
|
export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) {
|
|
1434
|
-
const nodes = useScene((state) => state.nodes)
|
|
1435
1447
|
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
|
1436
1448
|
const updateNode = useScene((state) => state.updateNode)
|
|
1437
1449
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId)
|
|
@@ -1442,13 +1454,20 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
|
|
1442
1454
|
const [siteCameraOpen, setSiteCameraOpen] = useState(false)
|
|
1443
1455
|
const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null)
|
|
1444
1456
|
|
|
1445
|
-
const siteNode =
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1457
|
+
const siteNode = useScene((s) =>
|
|
1458
|
+
rootNodeIds[0] ? ((s.nodes[rootNodeIds[0]] as SiteNode | undefined) ?? null) : null,
|
|
1459
|
+
)
|
|
1460
|
+
const buildings = useScene(
|
|
1461
|
+
useShallow((s) => {
|
|
1462
|
+
if (!siteNode) return []
|
|
1463
|
+
return siteNode.children
|
|
1464
|
+
.map((child) => {
|
|
1465
|
+
const id = typeof child === 'string' ? child : child.id
|
|
1466
|
+
return s.nodes[id] as BuildingNode | undefined
|
|
1467
|
+
})
|
|
1468
|
+
.filter((node): node is BuildingNode => node?.type === 'building')
|
|
1469
|
+
}),
|
|
1470
|
+
)
|
|
1452
1471
|
|
|
1453
1472
|
return (
|
|
1454
1473
|
<LayoutGroup>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
2
2
|
import { Pencil } from 'lucide-react'
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
|
4
4
|
import { cn } from './../../../../../lib/utils'
|
|
5
5
|
|
|
6
6
|
interface InlineRenameInputProps {
|
|
7
|
-
|
|
7
|
+
nodeId: AnyNodeId
|
|
8
8
|
isEditing: boolean
|
|
9
9
|
onStopEditing: () => void
|
|
10
10
|
defaultName: string
|
|
@@ -12,8 +12,8 @@ interface InlineRenameInputProps {
|
|
|
12
12
|
onStartEditing?: () => void
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function InlineRenameInput({
|
|
16
|
-
|
|
15
|
+
export const InlineRenameInput = memo(function InlineRenameInput({
|
|
16
|
+
nodeId,
|
|
17
17
|
isEditing,
|
|
18
18
|
onStopEditing,
|
|
19
19
|
defaultName,
|
|
@@ -21,13 +21,14 @@ export function InlineRenameInput({
|
|
|
21
21
|
onStartEditing,
|
|
22
22
|
}: InlineRenameInputProps) {
|
|
23
23
|
const updateNode = useScene((s) => s.updateNode)
|
|
24
|
-
const
|
|
24
|
+
const name = useScene((s) => s.nodes[nodeId]?.name)
|
|
25
|
+
const [value, setValue] = useState(name || '')
|
|
25
26
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
26
27
|
const inputSize = Math.max((value || defaultName).length, 1)
|
|
27
28
|
|
|
28
29
|
useEffect(() => {
|
|
29
30
|
if (isEditing) {
|
|
30
|
-
setValue(
|
|
31
|
+
setValue(name || '')
|
|
31
32
|
// Focus and select all text after a short delay
|
|
32
33
|
setTimeout(() => {
|
|
33
34
|
if (inputRef.current) {
|
|
@@ -36,15 +37,15 @@ export function InlineRenameInput({
|
|
|
36
37
|
}
|
|
37
38
|
}, 0)
|
|
38
39
|
}
|
|
39
|
-
}, [isEditing,
|
|
40
|
+
}, [isEditing, name])
|
|
40
41
|
|
|
41
42
|
const handleSave = useCallback(() => {
|
|
42
43
|
const trimmed = value.trim()
|
|
43
|
-
if (trimmed !==
|
|
44
|
-
updateNode(
|
|
44
|
+
if (trimmed !== name) {
|
|
45
|
+
updateNode(nodeId, { name: trimmed || undefined })
|
|
45
46
|
}
|
|
46
47
|
onStopEditing()
|
|
47
|
-
}, [value,
|
|
48
|
+
}, [value, nodeId, name, updateNode, onStopEditing])
|
|
48
49
|
|
|
49
50
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
50
51
|
if (e.key === 'Enter') {
|
|
@@ -60,7 +61,7 @@ export function InlineRenameInput({
|
|
|
60
61
|
return (
|
|
61
62
|
<div className="group/rename flex h-5 min-w-0 items-center gap-1">
|
|
62
63
|
<span className={cn('truncate border-transparent border-b', className)}>
|
|
63
|
-
{
|
|
64
|
+
{name || defaultName}
|
|
64
65
|
</span>
|
|
65
66
|
{onStartEditing && (
|
|
66
67
|
<button
|
|
@@ -95,4 +96,4 @@ export function InlineRenameInput({
|
|
|
95
96
|
value={value}
|
|
96
97
|
/>
|
|
97
98
|
)
|
|
98
|
-
}
|
|
99
|
+
})
|