@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.
Files changed (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,730 @@
1
+ 'use client'
2
+
3
+ import type { AnyNodeId, LevelNode } from '@pascal-app/core'
4
+ import { useScene } from '@pascal-app/core'
5
+ import { useViewer } from '@pascal-app/viewer'
6
+ import { Command, useCommandState } from 'cmdk'
7
+ import { ChevronRight, Search } from 'lucide-react'
8
+ import type { ReactNode } from 'react'
9
+ import { useEffect, useState } from 'react'
10
+ import { create } from 'zustand'
11
+ import { useShallow } from 'zustand/shallow'
12
+ import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'
13
+ import { useCommandRegistry } from '../../../store/use-command-registry'
14
+ import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Open + navigation state store
18
+ // ---------------------------------------------------------------------------
19
+ interface CommandPaletteStore {
20
+ open: boolean
21
+ setOpen: (open: boolean) => void
22
+ /** Current rendering mode. 'command' = normal palette; anything else = registered mode view. */
23
+ mode: string
24
+ setMode: (mode: string) => void
25
+ pages: string[]
26
+ inputValue: string
27
+ setInputValue: (value: string) => void
28
+ navigateTo: (page: string) => void
29
+ goBack: () => void
30
+ cameraScope: { nodeId: string; label: string } | null
31
+ setCameraScope: (scope: { nodeId: string; label: string } | null) => void
32
+ }
33
+
34
+ export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
35
+ open: false,
36
+ setOpen: (open) => {
37
+ set({ open })
38
+ if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' })
39
+ },
40
+ mode: 'command',
41
+ setMode: (mode) => set({ mode }),
42
+ pages: [],
43
+ inputValue: '',
44
+ setInputValue: (value) => set({ inputValue: value }),
45
+ navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),
46
+ goBack: () => {
47
+ const { pages } = get()
48
+ if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })
49
+ set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' }))
50
+ },
51
+ cameraScope: null,
52
+ setCameraScope: (scope) => set({ cameraScope: scope }),
53
+ }))
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+ function resolve(value: string | (() => string)): string {
59
+ return typeof value === 'function' ? value() : value
60
+ }
61
+
62
+ function Shortcut({ keys }: { keys: string[] }) {
63
+ return (
64
+ <span className="ml-auto flex shrink-0 items-center gap-0.5">
65
+ {keys.map((k) => (
66
+ <kbd
67
+ className="flex min-w-4.5 items-center justify-center rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground leading-none"
68
+ key={k}
69
+ >
70
+ {k}
71
+ </kbd>
72
+ ))}
73
+ </span>
74
+ )
75
+ }
76
+
77
+ function Item({
78
+ icon,
79
+ label,
80
+ onSelect,
81
+ shortcut,
82
+ disabled = false,
83
+ keywords = [],
84
+ badge,
85
+ navigate = false,
86
+ }: {
87
+ icon: React.ReactNode
88
+ label: string | (() => string)
89
+ onSelect: () => void
90
+ shortcut?: string[]
91
+ disabled?: boolean
92
+ keywords?: string[]
93
+ badge?: string | (() => string)
94
+ navigate?: boolean
95
+ }) {
96
+ const resolvedLabel = resolve(label)
97
+ const resolvedBadge = badge ? resolve(badge) : undefined
98
+
99
+ return (
100
+ <Command.Item
101
+ className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[disabled=true]:cursor-not-allowed data-[selected=true]:bg-accent data-[disabled=true]:opacity-40"
102
+ disabled={disabled}
103
+ keywords={keywords}
104
+ onSelect={onSelect}
105
+ value={resolvedLabel}
106
+ >
107
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
108
+ {icon}
109
+ </span>
110
+ <span className="flex-1 truncate">{resolvedLabel}</span>
111
+ {resolvedBadge && (
112
+ <span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
113
+ {resolvedBadge}
114
+ </span>
115
+ )}
116
+ {shortcut && <Shortcut keys={shortcut} />}
117
+ {(resolvedBadge || navigate) && (
118
+ <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
119
+ )}
120
+ </Command.Item>
121
+ )
122
+ }
123
+
124
+ function OptionItem({
125
+ label,
126
+ isActive = false,
127
+ onSelect,
128
+ icon,
129
+ disabled = false,
130
+ }: {
131
+ label: string
132
+ isActive?: boolean
133
+ onSelect: () => void
134
+ icon?: React.ReactNode
135
+ disabled?: boolean
136
+ }) {
137
+ return (
138
+ <Command.Item
139
+ className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[disabled=true]:cursor-not-allowed data-[selected=true]:bg-accent data-[disabled=true]:opacity-40"
140
+ disabled={disabled}
141
+ onSelect={onSelect}
142
+ value={label}
143
+ >
144
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
145
+ {isActive ? <div className="h-1.5 w-1.5 rounded-full bg-primary" /> : icon}
146
+ </span>
147
+ <span className="flex-1 truncate">{label}</span>
148
+ </Command.Item>
149
+ )
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Sub-page label map
154
+ // ---------------------------------------------------------------------------
155
+ const PAGE_LABEL: Record<string, string> = {
156
+ 'wall-mode': 'Wall Mode',
157
+ 'level-mode': 'Level Mode',
158
+ 'rename-level': 'Rename Level',
159
+ 'goto-level': 'Go to Level',
160
+ 'camera-view': 'Camera Snapshot',
161
+ 'camera-scope': '',
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Empty state fallback (force-mounted, visible only when no results)
166
+ // ---------------------------------------------------------------------------
167
+ export interface CommandPaletteEmptyAction {
168
+ icon: ReactNode
169
+ label: (query: string) => string
170
+ onSelect: (query: string) => void
171
+ }
172
+
173
+ function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
174
+ const count = useCommandState((s) => s.filtered.count)
175
+ const search = useCommandState((s) => s.search)
176
+ if (count > 0) return null
177
+ // No Command.Group wrapper — groups hide themselves when not in filtered.groups (which is
178
+ // empty when nothing matches), swallowing the force-mounted item even with forceMount on
179
+ // the item itself.
180
+ return (
181
+ <Command.Item
182
+ className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[selected=true]:bg-accent"
183
+ forceMount
184
+ onSelect={() => action.onSelect(search)}
185
+ value="__empty_action__"
186
+ >
187
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
188
+ {action.icon}
189
+ </span>
190
+ <span className="flex-1 truncate">{action.label(search)}</span>
191
+ </Command.Item>
192
+ )
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Main component
197
+ // ---------------------------------------------------------------------------
198
+ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
199
+ const {
200
+ open,
201
+ setOpen,
202
+ mode,
203
+ setMode,
204
+ pages,
205
+ inputValue,
206
+ setInputValue,
207
+ navigateTo,
208
+ goBack,
209
+ cameraScope,
210
+ setCameraScope,
211
+ } = useCommandPalette()
212
+
213
+ const [meta, setMeta] = useState('⌘')
214
+ const [isFullscreen, setIsFullscreen] = useState(false)
215
+
216
+ const page = pages[pages.length - 1]
217
+
218
+ const actions = useCommandRegistry((s) => s.actions)
219
+ const views = usePaletteViewRegistry((s) => s.views)
220
+
221
+ const activeLevelId = useViewer((s) => s.selection.levelId)
222
+ const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
223
+
224
+ const wallMode = useViewer((s) => s.wallMode)
225
+ const setWallMode = useViewer((s) => s.setWallMode)
226
+ const levelMode = useViewer((s) => s.levelMode)
227
+ const setLevelMode = useViewer((s) => s.setLevelMode)
228
+
229
+ const allLevels = useScene(
230
+ useShallow((s) =>
231
+ (Object.values(s.nodes).filter((n) => n.type === 'level') as LevelNode[]).sort(
232
+ (a, b) => a.level - b.level,
233
+ ),
234
+ ),
235
+ )
236
+
237
+ const cameraScopeNode = useScene((s) =>
238
+ cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,
239
+ )
240
+ const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera
241
+
242
+ // Platform detection
243
+ useEffect(() => {
244
+ setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl')
245
+ }, [])
246
+
247
+ // Fullscreen tracking
248
+ useEffect(() => {
249
+ const handler = () => setIsFullscreen(!!document.fullscreenElement)
250
+ document.addEventListener('fullscreenchange', handler)
251
+ return () => document.removeEventListener('fullscreenchange', handler)
252
+ }, [])
253
+
254
+ // Cmd/Ctrl+K global shortcut
255
+ useEffect(() => {
256
+ const handler = (e: KeyboardEvent) => {
257
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
258
+ e.preventDefault()
259
+ setOpen(true)
260
+ }
261
+ }
262
+ window.addEventListener('keydown', handler)
263
+ return () => window.removeEventListener('keydown', handler)
264
+ }, [setOpen])
265
+
266
+ const run = (fn: () => void) => {
267
+ fn()
268
+ setOpen(false)
269
+ }
270
+
271
+ const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = {
272
+ cutaway: 'Cutaway',
273
+ up: 'Up',
274
+ down: 'Down',
275
+ }
276
+ const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
277
+ manual: 'Manual',
278
+ stacked: 'Stacked',
279
+ exploded: 'Exploded',
280
+ solo: 'Solo',
281
+ }
282
+
283
+ // Camera snapshot helpers (used by sub-pages registered via EditorCommands)
284
+ const confirmRename = () => {
285
+ if (!(activeLevelId && inputValue.trim())) return
286
+ run(() => {
287
+ useScene.getState().updateNode(activeLevelId as AnyNodeId, { name: inputValue.trim() } as any)
288
+ })
289
+ }
290
+
291
+ const takeSnapshot = () => {
292
+ if (!cameraScope) return
293
+ import('@pascal-app/core').then(({ emitter }) => {
294
+ run(() =>
295
+ emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }),
296
+ )
297
+ })
298
+ }
299
+
300
+ const viewSnapshot = () => {
301
+ if (!(cameraScope && hasScopeSnapshot)) return
302
+ import('@pascal-app/core').then(({ emitter }) => {
303
+ run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))
304
+ })
305
+ }
306
+
307
+ const clearSnapshot = () => {
308
+ if (!(cameraScope && hasScopeSnapshot)) return
309
+ run(() => {
310
+ useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any)
311
+ })
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Group registered actions by group (preserving insertion order)
316
+ // ---------------------------------------------------------------------------
317
+ const grouped = actions.reduce<Map<string, typeof actions>>((acc, action) => {
318
+ const list = acc.get(action.group) ?? []
319
+ list.push(action)
320
+ acc.set(action.group, list)
321
+ return acc
322
+ }, new Map())
323
+
324
+ const onClose = () => setOpen(false)
325
+ const onBack = () => {
326
+ if (mode !== 'command') {
327
+ setMode('command')
328
+ } else {
329
+ goBack()
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Render
335
+ // ---------------------------------------------------------------------------
336
+
337
+ // Mode view: replaces the entire cmdk shell
338
+ const modeView = mode !== 'command' ? views.get(mode) : undefined
339
+
340
+ return (
341
+ <Dialog onOpenChange={setOpen} open={open}>
342
+ <DialogContent className="max-w-lg gap-0 overflow-hidden p-0" showCloseButton={false}>
343
+ <DialogTitle className="sr-only">Command Palette</DialogTitle>
344
+
345
+ {modeView && <modeView.Component onBack={onBack} onClose={onClose} />}
346
+
347
+ {!modeView && (
348
+ <Command
349
+ className="**:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pt-3 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:text-[10px] **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider"
350
+ onKeyDown={(e) => {
351
+ if (e.key === 'Backspace' && !inputValue && pages.length > 0) {
352
+ e.preventDefault()
353
+ goBack()
354
+ }
355
+ }}
356
+ shouldFilter={page !== 'rename-level'}
357
+ >
358
+ {/* Search bar */}
359
+ <div className="flex items-center border-border/50 border-b px-3">
360
+ <Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
361
+ {page && (
362
+ <button
363
+ className="mr-2 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted/70"
364
+ onClick={goBack}
365
+ type="button"
366
+ >
367
+ {page === 'camera-scope'
368
+ ? (cameraScope?.label ?? 'Snapshot')
369
+ : (PAGE_LABEL[page] ?? views.get(page)?.label ?? page)}
370
+ </button>
371
+ )}
372
+ <Command.Input
373
+ autoFocus
374
+ className="flex h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
375
+ onValueChange={setInputValue}
376
+ placeholder={
377
+ page === 'rename-level'
378
+ ? 'Type a new name…'
379
+ : page
380
+ ? 'Filter options…'
381
+ : 'Search actions…'
382
+ }
383
+ value={inputValue}
384
+ />
385
+ </div>
386
+
387
+ <Command.List className="max-h-100 overflow-y-auto p-1.5">
388
+ {(!emptyAction || page) && (
389
+ <Command.Empty className="py-8 text-center text-muted-foreground text-sm">
390
+ No commands found.
391
+ </Command.Empty>
392
+ )}
393
+ {emptyAction && !page && <EmptyActionItem action={emptyAction} />}
394
+
395
+ {/* ── Registered page view (e.g. 'ai') ─────────────────────── */}
396
+ {page &&
397
+ views.get(page)?.type === 'page' &&
398
+ (() => {
399
+ const pageView = views.get(page)
400
+ return pageView ? <pageView.Component onBack={onBack} onClose={onClose} /> : null
401
+ })()}
402
+
403
+ {/* ── Root view: render from registry ───────────────────────── */}
404
+ {!page &&
405
+ Array.from(grouped.entries()).map(([group, groupActions]) => (
406
+ <Command.Group heading={group} key={group}>
407
+ {groupActions.map((action) => (
408
+ <Item
409
+ badge={action.badge}
410
+ disabled={action.when ? !action.when() : false}
411
+ icon={action.icon}
412
+ key={action.id}
413
+ keywords={action.keywords}
414
+ label={action.label}
415
+ navigate={action.navigate}
416
+ onSelect={() => action.execute()}
417
+ shortcut={action.shortcut}
418
+ />
419
+ ))}
420
+ </Command.Group>
421
+ ))}
422
+
423
+ {/* ── Wall Mode sub-page ────────────────────────────────────── */}
424
+ {page === 'wall-mode' && (
425
+ <Command.Group heading="Wall Mode">
426
+ {(['cutaway', 'up', 'down'] as const).map((mode) => (
427
+ <OptionItem
428
+ isActive={wallMode === mode}
429
+ key={mode}
430
+ label={wallModeLabel[mode]}
431
+ onSelect={() => run(() => setWallMode(mode))}
432
+ />
433
+ ))}
434
+ </Command.Group>
435
+ )}
436
+
437
+ {/* ── Level Mode sub-page ───────────────────────────────────── */}
438
+ {page === 'level-mode' && (
439
+ <Command.Group heading="Level Mode">
440
+ {(['stacked', 'exploded', 'solo'] as const).map((mode) => (
441
+ <OptionItem
442
+ isActive={levelMode === mode}
443
+ key={mode}
444
+ label={levelModeLabel[mode]}
445
+ onSelect={() => run(() => setLevelMode(mode))}
446
+ />
447
+ ))}
448
+ </Command.Group>
449
+ )}
450
+
451
+ {/* ── Go to Level sub-page ──────────────────────────────────── */}
452
+ {page === 'goto-level' && (
453
+ <Command.Group heading="Go to Level">
454
+ {allLevels.map((level) => (
455
+ <OptionItem
456
+ isActive={level.id === activeLevelId}
457
+ key={level.id}
458
+ label={level.name ?? `Level ${level.level}`}
459
+ onSelect={() =>
460
+ run(() => useViewer.getState().setSelection({ levelId: level.id }))
461
+ }
462
+ />
463
+ ))}
464
+ </Command.Group>
465
+ )}
466
+
467
+ {/* ── Rename Level sub-page ─────────────────────────────────── */}
468
+ {page === 'rename-level' && (
469
+ <Command.Group heading="Rename Level">
470
+ <Command.Item
471
+ className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[disabled=true]:cursor-not-allowed data-[selected=true]:bg-accent data-[disabled=true]:opacity-40"
472
+ disabled={!inputValue.trim()}
473
+ onSelect={confirmRename}
474
+ value="confirm-rename"
475
+ >
476
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
477
+ <svg
478
+ className="h-4 w-4"
479
+ fill="none"
480
+ stroke="currentColor"
481
+ strokeWidth={2}
482
+ viewBox="0 0 24 24"
483
+ >
484
+ <path d="M12 20h9" strokeLinecap="round" strokeLinejoin="round" />
485
+ <path
486
+ d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"
487
+ strokeLinecap="round"
488
+ strokeLinejoin="round"
489
+ />
490
+ </svg>
491
+ </span>
492
+ <span className="flex-1 truncate">
493
+ {inputValue.trim() ? (
494
+ <>
495
+ Rename to <span className="font-medium">"{inputValue.trim()}"</span>
496
+ </>
497
+ ) : (
498
+ <span className="text-muted-foreground">Type a new name above…</span>
499
+ )}
500
+ </span>
501
+ </Command.Item>
502
+ </Command.Group>
503
+ )}
504
+
505
+ {/* ── Camera Snapshot: scope picker ─────────────────────────── */}
506
+ {page === 'camera-view' && (
507
+ <Command.Group heading="Camera Snapshot — Select Scope">
508
+ <OptionItem
509
+ icon={
510
+ <svg
511
+ className="h-4 w-4"
512
+ fill="none"
513
+ stroke="currentColor"
514
+ strokeWidth={2}
515
+ viewBox="0 0 24 24"
516
+ >
517
+ <path d="M3 3h18v18H3z" strokeLinecap="round" strokeLinejoin="round" />
518
+ <path d="M3 9h18M9 21V9" strokeLinecap="round" strokeLinejoin="round" />
519
+ </svg>
520
+ }
521
+ label="Site"
522
+ onSelect={() => {
523
+ const { rootNodeIds } = useScene.getState()
524
+ const siteId = rootNodeIds[0]
525
+ if (siteId) {
526
+ setCameraScope({ nodeId: siteId, label: 'Site' })
527
+ navigateTo('camera-scope')
528
+ }
529
+ }}
530
+ />
531
+ <OptionItem
532
+ icon={
533
+ <svg
534
+ className="h-4 w-4"
535
+ fill="none"
536
+ stroke="currentColor"
537
+ strokeWidth={2}
538
+ viewBox="0 0 24 24"
539
+ >
540
+ <path
541
+ d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
542
+ strokeLinecap="round"
543
+ strokeLinejoin="round"
544
+ />
545
+ <polyline
546
+ points="9 22 9 12 15 12 15 22"
547
+ strokeLinecap="round"
548
+ strokeLinejoin="round"
549
+ />
550
+ </svg>
551
+ }
552
+ label="Building"
553
+ onSelect={() => {
554
+ const building = Object.values(useScene.getState().nodes).find(
555
+ (n) => n.type === 'building',
556
+ )
557
+ if (building) {
558
+ setCameraScope({ nodeId: building.id, label: 'Building' })
559
+ navigateTo('camera-scope')
560
+ }
561
+ }}
562
+ />
563
+ <OptionItem
564
+ disabled={!activeLevelId}
565
+ icon={
566
+ <svg
567
+ className="h-4 w-4"
568
+ fill="none"
569
+ stroke="currentColor"
570
+ strokeWidth={2}
571
+ viewBox="0 0 24 24"
572
+ >
573
+ <path
574
+ d="M12 2L2 7l10 5 10-5-10-5z"
575
+ strokeLinecap="round"
576
+ strokeLinejoin="round"
577
+ />
578
+ <path
579
+ d="M2 17l10 5 10-5M2 12l10 5 10-5"
580
+ strokeLinecap="round"
581
+ strokeLinejoin="round"
582
+ />
583
+ </svg>
584
+ }
585
+ label="Level"
586
+ onSelect={() => {
587
+ if (activeLevelId) {
588
+ setCameraScope({ nodeId: activeLevelId, label: 'Level' })
589
+ navigateTo('camera-scope')
590
+ }
591
+ }}
592
+ />
593
+ <OptionItem
594
+ disabled={!useViewer.getState().selection.selectedIds.length}
595
+ icon={
596
+ <svg
597
+ className="h-4 w-4"
598
+ fill="none"
599
+ stroke="currentColor"
600
+ strokeWidth={2}
601
+ viewBox="0 0 24 24"
602
+ >
603
+ <path d="M5 3l14 9-14 9V3z" strokeLinecap="round" strokeLinejoin="round" />
604
+ </svg>
605
+ }
606
+ label="Selection"
607
+ onSelect={() => {
608
+ const firstId = useViewer.getState().selection.selectedIds[0]
609
+ if (firstId) {
610
+ setCameraScope({ nodeId: firstId, label: 'Selection' })
611
+ navigateTo('camera-scope')
612
+ }
613
+ }}
614
+ />
615
+ </Command.Group>
616
+ )}
617
+
618
+ {/* ── Camera Snapshot: actions for selected scope ───────────── */}
619
+ {page === 'camera-scope' && cameraScope && (
620
+ <Command.Group heading={`${cameraScope.label} Snapshot`}>
621
+ <OptionItem
622
+ icon={
623
+ <svg
624
+ className="h-4 w-4"
625
+ fill="none"
626
+ stroke="currentColor"
627
+ strokeWidth={2}
628
+ viewBox="0 0 24 24"
629
+ >
630
+ <path
631
+ d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
632
+ strokeLinecap="round"
633
+ strokeLinejoin="round"
634
+ />
635
+ <circle
636
+ cx="12"
637
+ cy="13"
638
+ r="4"
639
+ strokeLinecap="round"
640
+ strokeLinejoin="round"
641
+ />
642
+ </svg>
643
+ }
644
+ label={hasScopeSnapshot ? 'Update Snapshot' : 'Take Snapshot'}
645
+ onSelect={takeSnapshot}
646
+ />
647
+ {hasScopeSnapshot && (
648
+ <OptionItem
649
+ icon={
650
+ <svg
651
+ className="h-4 w-4"
652
+ fill="none"
653
+ stroke="currentColor"
654
+ strokeWidth={2}
655
+ viewBox="0 0 24 24"
656
+ >
657
+ <path
658
+ d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
659
+ strokeLinecap="round"
660
+ strokeLinejoin="round"
661
+ />
662
+ <circle
663
+ cx="12"
664
+ cy="12"
665
+ r="3"
666
+ strokeLinecap="round"
667
+ strokeLinejoin="round"
668
+ />
669
+ </svg>
670
+ }
671
+ label="View Snapshot"
672
+ onSelect={viewSnapshot}
673
+ />
674
+ )}
675
+ {hasScopeSnapshot && (
676
+ <OptionItem
677
+ icon={
678
+ <svg
679
+ className="h-4 w-4"
680
+ fill="none"
681
+ stroke="currentColor"
682
+ strokeWidth={2}
683
+ viewBox="0 0 24 24"
684
+ >
685
+ <polyline
686
+ points="3 6 5 6 21 6"
687
+ strokeLinecap="round"
688
+ strokeLinejoin="round"
689
+ />
690
+ <path
691
+ d="M19 6l-1 14H6L5 6"
692
+ strokeLinecap="round"
693
+ strokeLinejoin="round"
694
+ />
695
+ <path d="M10 11v6M14 11v6" strokeLinecap="round" strokeLinejoin="round" />
696
+ <path d="M9 6V4h6v2" strokeLinecap="round" strokeLinejoin="round" />
697
+ </svg>
698
+ }
699
+ label="Clear Snapshot"
700
+ onSelect={clearSnapshot}
701
+ />
702
+ )}
703
+ </Command.Group>
704
+ )}
705
+ </Command.List>
706
+
707
+ {/* Footer hint */}
708
+ <div className="flex items-center justify-between border-border/50 border-t px-3 py-2">
709
+ <span className="text-[11px] text-muted-foreground">
710
+ <Shortcut keys={['↑', '↓']} /> navigate
711
+ </span>
712
+ <span className="text-[11px] text-muted-foreground">
713
+ <Shortcut keys={['↵']} /> select
714
+ </span>
715
+ {page ? (
716
+ <span className="text-[11px] text-muted-foreground">
717
+ <Shortcut keys={['⌫']} /> back
718
+ </span>
719
+ ) : (
720
+ <span className="text-[11px] text-muted-foreground">
721
+ <Shortcut keys={['Esc']} /> close
722
+ </span>
723
+ )}
724
+ </div>
725
+ </Command>
726
+ )}
727
+ </DialogContent>
728
+ </Dialog>
729
+ )
730
+ }