@pascal-app/editor 0.4.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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
2
|
+
import { ChevronRight } from 'lucide-react'
|
|
3
|
+
import { AnimatePresence, motion } from 'motion/react'
|
|
4
|
+
import { forwardRef, useEffect, useRef } from 'react'
|
|
5
|
+
|
|
6
|
+
export function handleTreeSelection(
|
|
7
|
+
e: React.MouseEvent,
|
|
8
|
+
nodeId: string,
|
|
9
|
+
selectedIds: string[],
|
|
10
|
+
setSelection: (s: any) => void,
|
|
11
|
+
) {
|
|
12
|
+
if (e.metaKey || e.ctrlKey) {
|
|
13
|
+
if (selectedIds.includes(nodeId)) {
|
|
14
|
+
setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) })
|
|
15
|
+
} else {
|
|
16
|
+
setSelection({ selectedIds: [...selectedIds, nodeId] })
|
|
17
|
+
}
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (e.shiftKey && selectedIds.length > 0) {
|
|
22
|
+
const lastSelectedId = selectedIds[selectedIds.length - 1]
|
|
23
|
+
if (lastSelectedId) {
|
|
24
|
+
const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'))
|
|
25
|
+
const nodeIds = nodes.map((n) => n.getAttribute('data-treenode-id') as string)
|
|
26
|
+
|
|
27
|
+
const startIndex = nodeIds.indexOf(lastSelectedId)
|
|
28
|
+
const endIndex = nodeIds.indexOf(nodeId)
|
|
29
|
+
|
|
30
|
+
if (startIndex !== -1 && endIndex !== -1) {
|
|
31
|
+
const start = Math.min(startIndex, endIndex)
|
|
32
|
+
const end = Math.max(startIndex, endIndex)
|
|
33
|
+
const range = nodeIds.slice(start, end + 1)
|
|
34
|
+
|
|
35
|
+
// We can keep the previous selections that were outside the range if we want,
|
|
36
|
+
// but standard file system shift-click replaces the selection with the range.
|
|
37
|
+
setSelection({ selectedIds: range })
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Fallback: if range selection fails (e.g. node not visible in tree), just add to selection
|
|
42
|
+
if (!selectedIds.includes(nodeId)) {
|
|
43
|
+
setSelection({ selectedIds: [...selectedIds, nodeId] })
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setSelection({ selectedIds: [nodeId] })
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function focusTreeNode(nodeId: AnyNodeId) {
|
|
53
|
+
emitter.emit('camera-controls:focus', { nodeId })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
import { cn } from '../../../../../lib/utils'
|
|
57
|
+
import { BuildingTreeNode } from './building-tree-node'
|
|
58
|
+
import { CeilingTreeNode } from './ceiling-tree-node'
|
|
59
|
+
import { DoorTreeNode } from './door-tree-node'
|
|
60
|
+
import { ItemTreeNode } from './item-tree-node'
|
|
61
|
+
import { LevelTreeNode } from './level-tree-node'
|
|
62
|
+
import { RoofTreeNode } from './roof-tree-node'
|
|
63
|
+
import { SlabTreeNode } from './slab-tree-node'
|
|
64
|
+
import { StairTreeNode } from './stair-tree-node'
|
|
65
|
+
import { WallTreeNode } from './wall-tree-node'
|
|
66
|
+
import { WindowTreeNode } from './window-tree-node'
|
|
67
|
+
import { ZoneTreeNode } from './zone-tree-node'
|
|
68
|
+
|
|
69
|
+
interface TreeNodeProps {
|
|
70
|
+
nodeId: AnyNodeId
|
|
71
|
+
depth?: number
|
|
72
|
+
isLast?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
|
76
|
+
const node = useScene((state) => state.nodes[nodeId])
|
|
77
|
+
|
|
78
|
+
if (!node) return null
|
|
79
|
+
|
|
80
|
+
switch (node.type) {
|
|
81
|
+
case 'building':
|
|
82
|
+
return <BuildingTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
83
|
+
case 'ceiling':
|
|
84
|
+
return <CeilingTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
85
|
+
case 'level':
|
|
86
|
+
return <LevelTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
87
|
+
case 'slab':
|
|
88
|
+
return <SlabTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
89
|
+
case 'wall':
|
|
90
|
+
return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
91
|
+
case 'roof':
|
|
92
|
+
return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
93
|
+
case 'stair':
|
|
94
|
+
return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
95
|
+
case 'item':
|
|
96
|
+
return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
97
|
+
case 'door':
|
|
98
|
+
return <DoorTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
99
|
+
case 'window':
|
|
100
|
+
return <WindowTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
101
|
+
case 'zone':
|
|
102
|
+
return <ZoneTreeNode depth={depth} isLast={isLast} node={node as any} />
|
|
103
|
+
default:
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface TreeNodeWrapperProps {
|
|
109
|
+
nodeId?: string
|
|
110
|
+
icon: React.ReactNode
|
|
111
|
+
label: React.ReactNode
|
|
112
|
+
depth: number
|
|
113
|
+
hasChildren: boolean
|
|
114
|
+
expanded: boolean
|
|
115
|
+
onToggle: () => void
|
|
116
|
+
onClick: (e: React.MouseEvent) => void
|
|
117
|
+
onDoubleClick?: () => void
|
|
118
|
+
onMouseEnter?: () => void
|
|
119
|
+
onMouseLeave?: () => void
|
|
120
|
+
onPointerDown?: (e: React.PointerEvent) => void
|
|
121
|
+
actions?: React.ReactNode
|
|
122
|
+
children?: React.ReactNode
|
|
123
|
+
isSelected?: boolean
|
|
124
|
+
isHovered?: boolean
|
|
125
|
+
isVisible?: boolean
|
|
126
|
+
isLast?: boolean
|
|
127
|
+
isDraggable?: boolean
|
|
128
|
+
isDropTarget?: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|
132
|
+
function TreeNodeWrapper(
|
|
133
|
+
{
|
|
134
|
+
nodeId,
|
|
135
|
+
icon,
|
|
136
|
+
label,
|
|
137
|
+
depth,
|
|
138
|
+
hasChildren,
|
|
139
|
+
expanded,
|
|
140
|
+
onToggle,
|
|
141
|
+
onClick,
|
|
142
|
+
onDoubleClick,
|
|
143
|
+
onMouseEnter,
|
|
144
|
+
onMouseLeave,
|
|
145
|
+
onPointerDown,
|
|
146
|
+
actions,
|
|
147
|
+
children,
|
|
148
|
+
isSelected,
|
|
149
|
+
isHovered,
|
|
150
|
+
isVisible = true,
|
|
151
|
+
isLast,
|
|
152
|
+
isDraggable,
|
|
153
|
+
isDropTarget,
|
|
154
|
+
},
|
|
155
|
+
ref,
|
|
156
|
+
) {
|
|
157
|
+
const rowRef = useRef<HTMLDivElement>(null)
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (isSelected && rowRef.current) {
|
|
161
|
+
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
162
|
+
}
|
|
163
|
+
}, [isSelected])
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div data-treenode-id={nodeId} ref={ref}>
|
|
167
|
+
<div
|
|
168
|
+
className={cn(
|
|
169
|
+
'group/row relative flex h-8 cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b text-sm transition-all duration-200',
|
|
170
|
+
isSelected
|
|
171
|
+
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
|
|
172
|
+
: isDropTarget
|
|
173
|
+
? 'bg-blue-500/15 text-foreground ring-1 ring-blue-500/40 ring-inset'
|
|
174
|
+
: isHovered
|
|
175
|
+
? 'bg-accent/30 text-foreground'
|
|
176
|
+
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
|
177
|
+
!isVisible && 'opacity-50',
|
|
178
|
+
isDraggable && 'cursor-grab active:cursor-grabbing',
|
|
179
|
+
)}
|
|
180
|
+
onClick={onClick}
|
|
181
|
+
onDoubleClick={onDoubleClick}
|
|
182
|
+
onMouseEnter={onMouseEnter}
|
|
183
|
+
onMouseLeave={onMouseLeave}
|
|
184
|
+
onPointerDown={onPointerDown}
|
|
185
|
+
ref={rowRef}
|
|
186
|
+
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
|
187
|
+
>
|
|
188
|
+
{/* Vertical tree line */}
|
|
189
|
+
<div
|
|
190
|
+
className={cn(
|
|
191
|
+
'pointer-events-none absolute w-px bg-border/50',
|
|
192
|
+
isLast ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
|
|
193
|
+
)}
|
|
194
|
+
style={{ left: (depth - 1) * 12 + 20 }}
|
|
195
|
+
/>
|
|
196
|
+
{/* Horizontal branch line */}
|
|
197
|
+
<div
|
|
198
|
+
className="pointer-events-none absolute top-1/2 h-px bg-border/50"
|
|
199
|
+
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
|
|
200
|
+
/>
|
|
201
|
+
{/* Line down to children */}
|
|
202
|
+
{hasChildren && expanded && (
|
|
203
|
+
<div
|
|
204
|
+
className="pointer-events-none absolute top-1/2 bottom-0 w-px bg-border/50"
|
|
205
|
+
style={{ left: depth * 12 + 20 }}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
<button
|
|
210
|
+
className="z-10 flex h-4 w-4 shrink-0 items-center justify-center bg-inherit"
|
|
211
|
+
onClick={(e) => {
|
|
212
|
+
e.stopPropagation()
|
|
213
|
+
onToggle()
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
{hasChildren ? (
|
|
217
|
+
<motion.div
|
|
218
|
+
animate={{ rotate: expanded ? 90 : 0 }}
|
|
219
|
+
initial={false}
|
|
220
|
+
transition={{ duration: 0.2 }}
|
|
221
|
+
>
|
|
222
|
+
<ChevronRight className="h-3 w-3" />
|
|
223
|
+
</motion.div>
|
|
224
|
+
) : null}
|
|
225
|
+
</button>
|
|
226
|
+
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
227
|
+
<span
|
|
228
|
+
className={cn(
|
|
229
|
+
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
|
|
230
|
+
!isSelected && 'opacity-60 grayscale',
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
{icon}
|
|
234
|
+
</span>
|
|
235
|
+
<div
|
|
236
|
+
className={cn(
|
|
237
|
+
'min-w-0 flex-1 truncate',
|
|
238
|
+
!isVisible && 'text-muted-foreground line-through',
|
|
239
|
+
)}
|
|
240
|
+
>
|
|
241
|
+
{label}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
{actions && (
|
|
245
|
+
<div
|
|
246
|
+
className={cn(
|
|
247
|
+
'pr-1 opacity-0 transition-opacity duration-200 group-hover/row:opacity-100',
|
|
248
|
+
!isVisible && 'opacity-100',
|
|
249
|
+
)}
|
|
250
|
+
>
|
|
251
|
+
{actions}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
<AnimatePresence initial={false}>
|
|
256
|
+
{expanded && children && (
|
|
257
|
+
<motion.div
|
|
258
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
259
|
+
className="overflow-hidden"
|
|
260
|
+
exit={{ height: 0, opacity: 0 }}
|
|
261
|
+
initial={{ height: 0, opacity: 0 }}
|
|
262
|
+
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
|
|
263
|
+
>
|
|
264
|
+
{children}
|
|
265
|
+
</motion.div>
|
|
266
|
+
)}
|
|
267
|
+
</AnimatePresence>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
},
|
|
271
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import Image from 'next/image'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import useEditor from './../../../../../store/use-editor'
|
|
6
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
7
|
+
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
8
|
+
import { TreeNodeActions } from './tree-node-actions'
|
|
9
|
+
|
|
10
|
+
interface WallTreeNodeProps {
|
|
11
|
+
node: WallNode
|
|
12
|
+
depth: number
|
|
13
|
+
isLast?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
|
17
|
+
const [expanded, setExpanded] = useState(false)
|
|
18
|
+
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)
|
|
22
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
23
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
24
|
+
|
|
25
|
+
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
|
+
}
|
|
36
|
+
current = nodes[current.parentId as AnyNodeId]
|
|
37
|
+
}
|
|
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
|
+
}
|
|
60
|
+
|
|
61
|
+
const handleMouseLeave = () => {
|
|
62
|
+
setHoveredId(null)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const defaultName = 'Wall'
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<TreeNodeWrapper
|
|
69
|
+
actions={<TreeNodeActions node={node} />}
|
|
70
|
+
depth={depth}
|
|
71
|
+
expanded={expanded}
|
|
72
|
+
hasChildren={node.children.length > 0}
|
|
73
|
+
icon={
|
|
74
|
+
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
|
|
75
|
+
}
|
|
76
|
+
isHovered={isHovered}
|
|
77
|
+
isLast={isLast}
|
|
78
|
+
isSelected={isSelected}
|
|
79
|
+
isVisible={node.visible !== false}
|
|
80
|
+
label={
|
|
81
|
+
<InlineRenameInput
|
|
82
|
+
defaultName={defaultName}
|
|
83
|
+
isEditing={isEditing}
|
|
84
|
+
node={node}
|
|
85
|
+
onStartEditing={() => setIsEditing(true)}
|
|
86
|
+
onStopEditing={() => setIsEditing(false)}
|
|
87
|
+
/>
|
|
88
|
+
}
|
|
89
|
+
nodeId={node.id}
|
|
90
|
+
onClick={handleClick}
|
|
91
|
+
onDoubleClick={handleDoubleClick}
|
|
92
|
+
onMouseEnter={handleMouseEnter}
|
|
93
|
+
onMouseLeave={handleMouseLeave}
|
|
94
|
+
onToggle={() => setExpanded(!expanded)}
|
|
95
|
+
>
|
|
96
|
+
{node.children.map((childId, index) => (
|
|
97
|
+
<TreeNode
|
|
98
|
+
depth={depth + 1}
|
|
99
|
+
isLast={index === node.children.length - 1}
|
|
100
|
+
key={childId}
|
|
101
|
+
nodeId={childId}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</TreeNodeWrapper>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { WindowNode } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import Image from 'next/image'
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import useEditor from './../../../../../store/use-editor'
|
|
8
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
9
|
+
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
10
|
+
import { TreeNodeActions } from './tree-node-actions'
|
|
11
|
+
|
|
12
|
+
interface WindowTreeNodeProps {
|
|
13
|
+
node: WindowNode
|
|
14
|
+
depth: number
|
|
15
|
+
isLast?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
|
|
19
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
20
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
21
|
+
const isSelected = selectedIds.includes(node.id)
|
|
22
|
+
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
|
23
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
24
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
25
|
+
|
|
26
|
+
const defaultName = 'Window'
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<TreeNodeWrapper
|
|
30
|
+
actions={<TreeNodeActions node={node} />}
|
|
31
|
+
depth={depth}
|
|
32
|
+
expanded={false}
|
|
33
|
+
hasChildren={false}
|
|
34
|
+
icon={
|
|
35
|
+
<Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
|
|
36
|
+
}
|
|
37
|
+
isHovered={isHovered}
|
|
38
|
+
isLast={isLast}
|
|
39
|
+
isSelected={isSelected}
|
|
40
|
+
isVisible={node.visible !== false}
|
|
41
|
+
label={
|
|
42
|
+
<InlineRenameInput
|
|
43
|
+
defaultName={defaultName}
|
|
44
|
+
isEditing={isEditing}
|
|
45
|
+
node={node}
|
|
46
|
+
onStartEditing={() => setIsEditing(true)}
|
|
47
|
+
onStopEditing={() => setIsEditing(false)}
|
|
48
|
+
/>
|
|
49
|
+
}
|
|
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)}
|
|
60
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
61
|
+
onToggle={() => {}}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useScene, type ZoneNode } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
|
5
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
6
|
+
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
|
7
|
+
import { TreeNodeActions } from './tree-node-actions'
|
|
8
|
+
|
|
9
|
+
interface ZoneTreeNodeProps {
|
|
10
|
+
node: ZoneNode
|
|
11
|
+
depth: number
|
|
12
|
+
isLast?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
|
16
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
17
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
18
|
+
const isSelected = useViewer((state) => state.selection.zoneId === node.id)
|
|
19
|
+
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
|
20
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
21
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
22
|
+
|
|
23
|
+
const handleClick = () => {
|
|
24
|
+
setSelection({ zoneId: node.id })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const handleDoubleClick = () => {
|
|
28
|
+
focusTreeNode(node.id)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const handleMouseEnter = () => {
|
|
32
|
+
setHoveredId(node.id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const handleMouseLeave = () => {
|
|
36
|
+
setHoveredId(null)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Calculate approximate area from polygon
|
|
40
|
+
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
|
41
|
+
const defaultName = `Zone (${area}m²)`
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<TreeNodeWrapper
|
|
45
|
+
actions={<TreeNodeActions node={node} />}
|
|
46
|
+
depth={depth}
|
|
47
|
+
expanded={false}
|
|
48
|
+
hasChildren={false}
|
|
49
|
+
icon={<ColorDot color={node.color} onChange={(color) => updateNode(node.id, { color })} />}
|
|
50
|
+
isHovered={isHovered}
|
|
51
|
+
isLast={isLast}
|
|
52
|
+
isSelected={isSelected}
|
|
53
|
+
label={
|
|
54
|
+
<InlineRenameInput
|
|
55
|
+
defaultName={defaultName}
|
|
56
|
+
isEditing={isEditing}
|
|
57
|
+
node={node}
|
|
58
|
+
onStartEditing={() => setIsEditing(true)}
|
|
59
|
+
onStopEditing={() => setIsEditing(false)}
|
|
60
|
+
/>
|
|
61
|
+
}
|
|
62
|
+
onClick={handleClick}
|
|
63
|
+
onDoubleClick={handleDoubleClick}
|
|
64
|
+
onMouseEnter={handleMouseEnter}
|
|
65
|
+
onMouseLeave={handleMouseLeave}
|
|
66
|
+
onToggle={() => {}}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Calculate the area of a polygon using the shoelace formula
|
|
73
|
+
*/
|
|
74
|
+
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|
75
|
+
if (polygon.length < 3) return 0
|
|
76
|
+
|
|
77
|
+
let area = 0
|
|
78
|
+
const n = polygon.length
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < n; i++) {
|
|
81
|
+
const j = (i + 1) % n
|
|
82
|
+
area += polygon[i]?.[0] * polygon[j]?.[1]
|
|
83
|
+
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Math.abs(area) / 2
|
|
87
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { emitter, useScene, type ZoneNode } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { Camera, Hexagon, Trash2 } from 'lucide-react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
|
6
|
+
import {
|
|
7
|
+
Popover,
|
|
8
|
+
PopoverContent,
|
|
9
|
+
PopoverTrigger,
|
|
10
|
+
} from './../../../../../components/ui/primitives/popover'
|
|
11
|
+
import { cn } from './../../../../../lib/utils'
|
|
12
|
+
import useEditor from './../../../../../store/use-editor'
|
|
13
|
+
|
|
14
|
+
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|
15
|
+
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
|
16
|
+
const deleteNode = useScene((state) => state.deleteNode)
|
|
17
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
18
|
+
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
19
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
20
|
+
|
|
21
|
+
const isSelected = selectedZoneId === zone.id
|
|
22
|
+
|
|
23
|
+
const handleClick = () => {
|
|
24
|
+
setSelection({ zoneId: zone.id })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const handleDelete = (e: React.MouseEvent) => {
|
|
28
|
+
e.stopPropagation()
|
|
29
|
+
deleteNode(zone.id)
|
|
30
|
+
if (isSelected) {
|
|
31
|
+
setSelection({ zoneId: null })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const handleColorChange = (color: string) => {
|
|
36
|
+
updateNode(zone.id, { color })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
'group/row mx-1 mb-0.5 flex h-8 cursor-pointer select-none items-center rounded-lg border px-2 text-sm transition-all duration-200',
|
|
43
|
+
isSelected
|
|
44
|
+
? 'border-neutral-200/60 bg-white text-foreground shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
|
|
45
|
+
: 'border-transparent text-muted-foreground hover:border-neutral-200/50 hover:bg-white/40 hover:text-foreground dark:hover:border-border/40 dark:hover:bg-accent/30',
|
|
46
|
+
)}
|
|
47
|
+
onClick={handleClick}
|
|
48
|
+
>
|
|
49
|
+
<span className="mr-2">
|
|
50
|
+
<ColorDot color={zone.color} onChange={handleColorChange} />
|
|
51
|
+
</span>
|
|
52
|
+
<Hexagon className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
|
53
|
+
<span className="flex-1 truncate">{zone.name}</span>
|
|
54
|
+
{/* Camera snapshot button */}
|
|
55
|
+
<Popover onOpenChange={setCameraPopoverOpen} open={cameraPopoverOpen}>
|
|
56
|
+
<PopoverTrigger asChild>
|
|
57
|
+
<button
|
|
58
|
+
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10"
|
|
59
|
+
onClick={(e) => e.stopPropagation()}
|
|
60
|
+
title="Camera snapshot"
|
|
61
|
+
>
|
|
62
|
+
<Camera className="h-3 w-3" />
|
|
63
|
+
{zone.camera && (
|
|
64
|
+
<span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
|
65
|
+
)}
|
|
66
|
+
</button>
|
|
67
|
+
</PopoverTrigger>
|
|
68
|
+
<PopoverContent
|
|
69
|
+
align="start"
|
|
70
|
+
className="w-auto p-1"
|
|
71
|
+
onClick={(e) => e.stopPropagation()}
|
|
72
|
+
side="right"
|
|
73
|
+
>
|
|
74
|
+
<div className="flex flex-col gap-0.5">
|
|
75
|
+
{zone.camera && (
|
|
76
|
+
<button
|
|
77
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
|
|
78
|
+
onClick={(e) => {
|
|
79
|
+
e.stopPropagation()
|
|
80
|
+
emitter.emit('camera-controls:view', { nodeId: zone.id })
|
|
81
|
+
setCameraPopoverOpen(false)
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<Camera className="h-3.5 w-3.5" />
|
|
85
|
+
View snapshot
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
<button
|
|
89
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
|
|
90
|
+
onClick={(e) => {
|
|
91
|
+
e.stopPropagation()
|
|
92
|
+
emitter.emit('camera-controls:capture', { nodeId: zone.id })
|
|
93
|
+
setCameraPopoverOpen(false)
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<Camera className="h-3.5 w-3.5" />
|
|
97
|
+
{zone.camera ? 'Update snapshot' : 'Take snapshot'}
|
|
98
|
+
</button>
|
|
99
|
+
{zone.camera && (
|
|
100
|
+
<button
|
|
101
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
e.stopPropagation()
|
|
104
|
+
updateNode(zone.id, { camera: undefined })
|
|
105
|
+
setCameraPopoverOpen(false)
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
109
|
+
Clear snapshot
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</PopoverContent>
|
|
114
|
+
</Popover>
|
|
115
|
+
<button
|
|
116
|
+
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10"
|
|
117
|
+
onClick={handleDelete}
|
|
118
|
+
>
|
|
119
|
+
<Trash2 className="h-3 w-3" />
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function ZonePanel() {
|
|
126
|
+
const nodes = useScene((state) => state.nodes)
|
|
127
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
128
|
+
const setPhase = useEditor((state) => state.setPhase)
|
|
129
|
+
const setMode = useEditor((state) => state.setMode)
|
|
130
|
+
const setTool = useEditor((state) => state.setTool)
|
|
131
|
+
|
|
132
|
+
// Filter nodes to get zones for the current level
|
|
133
|
+
const levelZones = Object.values(nodes).filter(
|
|
134
|
+
(node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const handleAddZone = () => {
|
|
138
|
+
if (currentLevelId) {
|
|
139
|
+
setPhase('structure')
|
|
140
|
+
setMode('build')
|
|
141
|
+
setTool('zone')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!currentLevelId) {
|
|
146
|
+
return (
|
|
147
|
+
<div className="px-3 py-4 text-muted-foreground text-sm">
|
|
148
|
+
Select a level to view and create zones
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="py-1">
|
|
155
|
+
{levelZones.length === 0 ? (
|
|
156
|
+
<div className="px-3 py-4 text-muted-foreground text-sm">
|
|
157
|
+
No zones on this level.{' '}
|
|
158
|
+
<button className="cursor-pointer text-primary hover:underline" onClick={handleAddZone}>
|
|
159
|
+
Add one
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|