@pascal-app/editor 1.0.0-beta.4 → 1.0.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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,21 +1,41 @@
1
1
  import {
2
2
  clearSceneHistory,
3
3
  emitter,
4
+ isNodeKindEnabled,
5
+ nodeRegistry,
6
+ useRegistryVersion,
4
7
  useScene,
8
+ type ParsedBuildJson,
5
9
  validateBuildJson,
6
10
  } from '@pascal-app/core'
7
- import { useViewer } from '@pascal-app/viewer'
11
+ import { useViewer, viewerPresentationRegistry } from '@pascal-app/viewer'
8
12
  import { TreeView, VisualJson } from '@visual-json/react'
9
- import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
13
+ import {
14
+ Camera,
15
+ Check,
16
+ ChevronDown,
17
+ Copy,
18
+ Download,
19
+ Map as MapIcon,
20
+ Save,
21
+ Trash2,
22
+ Upload,
23
+ } from 'lucide-react'
10
24
  import {
11
25
  type KeyboardEvent,
12
26
  type SyntheticEvent,
13
27
  useCallback,
28
+ useEffect,
29
+ useId,
14
30
  useMemo,
15
31
  useRef,
16
32
  useState,
33
+ useSyncExternalStore,
17
34
  } from 'react'
18
- import { exportFloorplanPdf } from '../../../../../lib/floorplan/floorplan-export'
35
+ import {
36
+ exportFloorplanPdf,
37
+ type FloorplanExportScope,
38
+ } from '../../../../../lib/floorplan/floorplan-export'
19
39
  import { Button } from './../../../../../components/ui/primitives/button'
20
40
  import {
21
41
  Dialog,
@@ -23,12 +43,14 @@ import {
23
43
  DialogTitle,
24
44
  DialogTrigger,
25
45
  } from './../../../../../components/ui/primitives/dialog'
46
+ import { Input } from './../../../../../components/ui/primitives/input'
26
47
  import { Switch } from './../../../../../components/ui/primitives/switch'
27
48
  import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
28
49
  import useFloorplanMode from './../../../../../store/use-floorplan-mode'
29
50
  import { AudioSettingsDialog } from './audio-settings-dialog'
30
51
  import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
31
52
  import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
53
+ import { PrintExportButton } from './print-export-button'
32
54
 
33
55
  type SceneNode = Record<string, unknown> & {
34
56
  id?: unknown
@@ -53,6 +75,21 @@ type SceneGraphValue = {
53
75
  detachedNodes?: SceneGraphNode[]
54
76
  }
55
77
 
78
+ const MODEL_EXPORT_FORMATS = [
79
+ { format: 'glb', label: 'GLB' },
80
+ { format: 'usdz', label: 'USDZ' },
81
+ { format: 'stl', label: 'STL' },
82
+ { format: 'obj', label: 'OBJ' },
83
+ ] as const
84
+
85
+ type ModelExportFormat = (typeof MODEL_EXPORT_FORMATS)[number]['format']
86
+
87
+ type ExportableNodeType = {
88
+ type: string
89
+ label: string
90
+ supportsGeometryOnly: boolean
91
+ }
92
+
56
93
  const isSceneNode = (value: unknown): value is SceneNode => {
57
94
  return (
58
95
  typeof value === 'object' &&
@@ -184,18 +221,78 @@ export function SettingsPanel({
184
221
  onVisibilityChange,
185
222
  }: SettingsPanelProps = {}) {
186
223
  const fileInputRef = useRef<HTMLInputElement>(null)
224
+ const copyResetTimeoutRef = useRef<number | null>(null)
187
225
  const nodes = useScene((state) => state.nodes)
188
226
  const rootNodeIds = useScene((state) => state.rootNodeIds)
189
227
  const installedPlugins = useScene((state) => state.installedPlugins)
228
+ const materials = useScene((state) => state.materials)
190
229
  const setScene = useScene((state) => state.setScene)
191
230
  const clearScene = useScene((state) => state.clearScene)
192
231
  const resetSelection = useViewer((state) => state.resetSelection)
193
- const exportScene = useViewer((state) => state.exportScene)
232
+ const modelExport = useEditor((state) => state.modelExport)
194
233
  const shadows = useViewer((state) => state.shadows)
195
234
  const setPhase = useEditor((state) => state.setPhase)
196
235
  const floorplanMode = useFloorplanMode((state) => state.mode)
236
+ const registryVersion = useRegistryVersion()
237
+ const visibleOnlySwitchId = useId()
238
+ const includeNodeTypeIdPrefix = useId()
239
+ const includePresentationIdPrefix = useId()
197
240
  const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
241
+ const [exportOnlyVisible, setExportOnlyVisible] = useState(true)
242
+ const [excludedNodeTypes, setExcludedNodeTypes] = useState<string[]>([])
243
+ const [includedPresentationIds, setIncludedPresentationIds] = useState<string[]>([])
244
+ const [activeModelExport, setActiveModelExport] = useState<ModelExportFormat | null>(null)
245
+ const [modelExportError, setModelExportError] = useState<string | null>(null)
246
+ const [modelExportWarning, setModelExportWarning] = useState<string | null>(null)
247
+ const [activeFloorplanExport, setActiveFloorplanExport] = useState<FloorplanExportScope | null>(
248
+ null,
249
+ )
250
+ const [floorplanExportError, setFloorplanExportError] = useState<string | null>(null)
198
251
  const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
252
+ const [projectIdCopyState, setProjectIdCopyState] = useState<'idle' | 'copied' | 'error'>('idle')
253
+ const exportableNodeTypes = useMemo(() => {
254
+ void registryVersion
255
+ const uniqueTypes = new Set(Object.values(nodes).map((node) => node.type))
256
+ const options: ExportableNodeType[] = []
257
+
258
+ for (const type of uniqueTypes) {
259
+ const definition = nodeRegistry.get(type)
260
+ if (
261
+ !(
262
+ definition?.bakeGeometry ||
263
+ definition?.bakeGeometryAsync ||
264
+ definition?.bake === 'replace'
265
+ ) ||
266
+ !isNodeKindEnabled(type, installedPlugins)
267
+ ) {
268
+ continue
269
+ }
270
+ options.push({
271
+ type,
272
+ label: definition.presentation?.label ?? type,
273
+ supportsGeometryOnly: !definition.bakeGeometryAsync || Boolean(definition.bakeGeometry),
274
+ })
275
+ }
276
+
277
+ return options.sort((a, b) => {
278
+ const labelOrder = a.label.localeCompare(b.label)
279
+ return labelOrder === 0 ? a.type.localeCompare(b.type) : labelOrder
280
+ })
281
+ }, [installedPlugins, nodes, registryVersion])
282
+ const registeredPresentations = useSyncExternalStore(
283
+ viewerPresentationRegistry.subscribe,
284
+ viewerPresentationRegistry.getSnapshot,
285
+ viewerPresentationRegistry.getSnapshot,
286
+ )
287
+ const exportablePresentations = useMemo(
288
+ () =>
289
+ registeredPresentations.filter(
290
+ (contribution) =>
291
+ contribution.staticExport &&
292
+ (!contribution.pluginId || installedPlugins.includes(contribution.pluginId)),
293
+ ),
294
+ [installedPlugins, registeredPresentations],
295
+ )
199
296
  const sceneGraphValue = useMemo(
200
297
  () => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
201
298
  [nodes, rootNodeIds],
@@ -211,10 +308,22 @@ export function SettingsPanel({
211
308
  }
212
309
  }, [])
213
310
 
311
+ useEffect(
312
+ () => () => {
313
+ if (copyResetTimeoutRef.current !== null) {
314
+ window.clearTimeout(copyResetTimeoutRef.current)
315
+ }
316
+ },
317
+ [],
318
+ )
319
+
214
320
  const isLocalProject = false // Props-based; only show cloud sections when projectId provided
215
321
 
216
322
  const handleSaveBuild = () => {
217
- const sceneData = { nodes, rootNodeIds, installedPlugins }
323
+ // Materials ride along: nodes reference them by `scene:<id>` slot
324
+ // refs, so a save without the table produces a file whose custom
325
+ // finishes revert to defaults on the very Load Build path below.
326
+ const sceneData = { nodes, rootNodeIds, installedPlugins, materials }
218
327
  const json = JSON.stringify(sceneData, null, 2)
219
328
  const blob = new Blob([json], { type: 'application/json' })
220
329
  const url = URL.createObjectURL(blob)
@@ -270,16 +379,16 @@ export function SettingsPanel({
270
379
  e.target.value = ''
271
380
  }
272
381
 
273
- const handleConfirmImport = (parsed: {
274
- nodes: Record<string, unknown>
275
- rootNodeIds: string[]
276
- installedPlugins?: string[]
277
- }) => {
382
+ const handleConfirmImport = (parsed: ParsedBuildJson) => {
278
383
  const currentScene = useScene.getState()
279
384
  setScene(
280
385
  parsed.nodes as Parameters<typeof setScene>[0],
281
386
  parsed.rootNodeIds as Parameters<typeof setScene>[1],
282
387
  {
388
+ // Without this, every `scene:<id>` slot ref in the imported file
389
+ // pointed at a material that no longer existed — custom finishes
390
+ // silently reverted to defaults on import.
391
+ materials: parsed.materials,
283
392
  installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
284
393
  hasExplicitPluginInstallState:
285
394
  parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
@@ -310,6 +419,25 @@ export function SettingsPanel({
310
419
  setTimeout(() => setIsGeneratingThumbnail(false), 3000)
311
420
  }
312
421
 
422
+ const handleCopyProjectId = async () => {
423
+ if (!projectId) return
424
+ if (copyResetTimeoutRef.current !== null) {
425
+ window.clearTimeout(copyResetTimeoutRef.current)
426
+ }
427
+
428
+ try {
429
+ await navigator.clipboard.writeText(projectId)
430
+ setProjectIdCopyState('copied')
431
+ } catch {
432
+ setProjectIdCopyState('error')
433
+ }
434
+
435
+ copyResetTimeoutRef.current = window.setTimeout(() => {
436
+ setProjectIdCopyState('idle')
437
+ copyResetTimeoutRef.current = null
438
+ }, 2000)
439
+ }
440
+
313
441
  const handleVisibilityChange = async (
314
442
  field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
315
443
  value: boolean,
@@ -317,8 +445,105 @@ export function SettingsPanel({
317
445
  await onVisibilityChange?.(field, value)
318
446
  }
319
447
 
448
+ const handleNodeTypeInclusion = useCallback((type: string, included: boolean) => {
449
+ setExcludedNodeTypes((current) => {
450
+ const isExcluded = current.includes(type)
451
+ if (included) {
452
+ return isExcluded ? current.filter((excludedType) => excludedType !== type) : current
453
+ }
454
+ return isExcluded ? current : [...current, type]
455
+ })
456
+ }, [])
457
+ const handlePresentationInclusion = useCallback((id: string, included: boolean) => {
458
+ setIncludedPresentationIds((current) => {
459
+ const isIncluded = current.includes(id)
460
+ if (included) return isIncluded ? current : [...current, id]
461
+ return isIncluded ? current.filter((includedId) => includedId !== id) : current
462
+ })
463
+ }, [])
464
+
465
+ const handleModelExport = async (format: ModelExportFormat, label: string) => {
466
+ if (!modelExport || activeModelExport) return
467
+
468
+ setActiveModelExport(format)
469
+ setModelExportError(null)
470
+ setModelExportWarning(null)
471
+ try {
472
+ const artifact = await modelExport(format, {
473
+ onlyVisible: exportOnlyVisible,
474
+ excludedNodeTypes,
475
+ includedPresentationIds:
476
+ format === 'glb' || format === 'usdz'
477
+ ? includedPresentationIds.filter((id) =>
478
+ exportablePresentations.some((contribution) => contribution.id === id),
479
+ )
480
+ : [],
481
+ })
482
+ if (!artifact) {
483
+ throw new Error('Model export did not produce a file')
484
+ }
485
+ if (artifact.warnings?.length) setModelExportWarning(artifact.warnings.join(' '))
486
+ } catch (error) {
487
+ setModelExportError(
488
+ error instanceof Error ? error.message : `Couldn’t export ${label}. Try again.`,
489
+ )
490
+ } finally {
491
+ setActiveModelExport(null)
492
+ }
493
+ }
494
+
495
+ const handleFloorplanExport = async (scope: FloorplanExportScope) => {
496
+ if (activeFloorplanExport) return
497
+
498
+ setActiveFloorplanExport(scope)
499
+ setFloorplanExportError(null)
500
+ try {
501
+ await exportFloorplanPdf(scope)
502
+ } catch (error) {
503
+ setFloorplanExportError(
504
+ `Couldn’t export the floor plan. ${error instanceof Error ? error.message : 'Try again.'}`,
505
+ )
506
+ } finally {
507
+ setActiveFloorplanExport(null)
508
+ }
509
+ }
510
+
320
511
  return (
321
- <div className="flex flex-col gap-6 p-3">
512
+ <div className="subtle-scrollbar min-h-0 flex-1 space-y-6 overflow-x-hidden overflow-y-auto overscroll-contain p-3">
513
+ {projectId && (
514
+ <div className="space-y-2">
515
+ <label className="font-medium text-muted-foreground text-xs uppercase">Project</label>
516
+ <div className="font-medium text-sm">Project ID</div>
517
+ <div className="flex items-center gap-2">
518
+ <Input
519
+ aria-label="Project ID"
520
+ className="font-mono text-xs"
521
+ readOnly
522
+ value={projectId}
523
+ />
524
+ <Button
525
+ aria-label={projectIdCopyState === 'copied' ? 'Project ID copied' : 'Copy project ID'}
526
+ className="rounded-full"
527
+ onClick={() => void handleCopyProjectId()}
528
+ size="sm"
529
+ type="button"
530
+ variant="outline"
531
+ >
532
+ {projectIdCopyState === 'copied' ? (
533
+ <Check className="size-3.5" />
534
+ ) : (
535
+ <Copy className="size-3.5" />
536
+ )}
537
+ {projectIdCopyState === 'copied'
538
+ ? 'Copied'
539
+ : projectIdCopyState === 'error'
540
+ ? 'Try again'
541
+ : 'Copy'}
542
+ </Button>
543
+ </div>
544
+ </div>
545
+ )}
546
+
322
547
  {/* Visibility Section (only for cloud projects) */}
323
548
  {projectId && !isLocalProject && (
324
549
  <div className="space-y-3">
@@ -331,6 +556,7 @@ export function SettingsPanel({
331
556
  </div>
332
557
  </div>
333
558
  <Switch
559
+ aria-label="Make project public"
334
560
  checked={!(projectVisibility?.isPrivate ?? false)}
335
561
  onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)}
336
562
  />
@@ -341,6 +567,7 @@ export function SettingsPanel({
341
567
  <div className="text-muted-foreground text-xs">Visible to public viewers</div>
342
568
  </div>
343
569
  <Switch
570
+ aria-label="Show 3D scans to public viewers"
344
571
  checked={projectVisibility?.showScansPublic ?? true}
345
572
  onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)}
346
573
  />
@@ -351,6 +578,7 @@ export function SettingsPanel({
351
578
  <div className="text-muted-foreground text-xs">Visible to public viewers</div>
352
579
  </div>
353
580
  <Switch
581
+ aria-label="Show floorplans to public viewers"
354
582
  checked={projectVisibility?.showGuidesPublic ?? true}
355
583
  onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
356
584
  />
@@ -361,6 +589,7 @@ export function SettingsPanel({
361
589
  <div className="text-muted-foreground text-xs">Cast shadows from lights</div>
362
590
  </div>
363
591
  <Switch
592
+ aria-label="Enable shadows"
364
593
  checked={shadows}
365
594
  onCheckedChange={(checked) => useViewer.getState().setShadows(checked)}
366
595
  />
@@ -374,30 +603,141 @@ export function SettingsPanel({
374
603
 
375
604
  <div className="space-y-2">
376
605
  <div className="font-medium text-muted-foreground text-xs">3D model</div>
377
- <Button
378
- className="w-full justify-start gap-2"
379
- onClick={() => exportScene?.('glb')}
380
- variant="outline"
381
- >
382
- <Download className="size-4" />
383
- Export GLB
384
- </Button>
385
- <Button
386
- className="w-full justify-start gap-2"
387
- onClick={() => exportScene?.('stl')}
388
- variant="outline"
606
+ <details
607
+ className="group"
608
+ onKeyDownCapture={(event) => {
609
+ // Keep Space available to the disclosure and switches, not canvas panning.
610
+ if (event.code === 'Space') event.stopPropagation()
611
+ }}
389
612
  >
390
- <Download className="size-4" />
391
- Export STL
392
- </Button>
393
- <Button
394
- className="w-full justify-start gap-2"
395
- onClick={() => exportScene?.('obj')}
396
- variant="outline"
397
- >
398
- <Download className="size-4" />
399
- Export OBJ
400
- </Button>
613
+ <summary className="flex min-h-10 cursor-pointer list-none items-center justify-between rounded-md border px-3 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring">
614
+ Export options
615
+ <ChevronDown aria-hidden="true" className="size-4 group-open:rotate-180" />
616
+ </summary>
617
+ <div className="space-y-3 pt-3">
618
+ <div className="flex items-center justify-between gap-4 rounded-md border p-3">
619
+ <div className="min-w-0">
620
+ <label className="font-medium text-sm" htmlFor={visibleOnlySwitchId}>
621
+ Visible nodes only
622
+ </label>
623
+ <div className="text-muted-foreground text-xs">
624
+ Exclude hidden furniture and other hidden scene nodes
625
+ </div>
626
+ </div>
627
+ <Switch
628
+ aria-label="Export visible nodes only"
629
+ checked={exportOnlyVisible}
630
+ id={visibleOnlySwitchId}
631
+ onCheckedChange={setExportOnlyVisible}
632
+ />
633
+ </div>
634
+
635
+ <fieldset className="space-y-2 rounded-md border p-3">
636
+ <legend className="px-1 font-medium text-sm">Include in file</legend>
637
+ <p className="text-muted-foreground text-xs">
638
+ Choose which procedural content is baked into model files. GLB and USDZ use the
639
+ textured portable path; STL and OBJ remain geometry-only.
640
+ </p>
641
+ {exportableNodeTypes.length > 0 ? (
642
+ <div className="space-y-2 pt-1">
643
+ {exportableNodeTypes.map(({ type, label, supportsGeometryOnly }, index) => {
644
+ const switchId = `${includeNodeTypeIdPrefix}-${index}`
645
+ return (
646
+ <div className="flex items-center justify-between gap-4" key={type}>
647
+ <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
648
+ {label}
649
+ {!supportsGeometryOnly && (
650
+ <span className="text-muted-foreground text-xs"> (GLB/USDZ only)</span>
651
+ )}
652
+ </label>
653
+ <Switch
654
+ aria-label={`Include ${label} in model files`}
655
+ checked={!excludedNodeTypes.includes(type)}
656
+ id={switchId}
657
+ onCheckedChange={(included) => handleNodeTypeInclusion(type, included)}
658
+ />
659
+ </div>
660
+ )
661
+ })}
662
+ </div>
663
+ ) : (
664
+ <p className="text-muted-foreground text-xs">
665
+ No optional procedural content is present.
666
+ </p>
667
+ )}
668
+ <p className="text-muted-foreground text-xs">
669
+ Viewer surroundings are excluded unless selected separately below.
670
+ </p>
671
+ </fieldset>
672
+ {exportablePresentations.length > 0 ? (
673
+ <fieldset className="space-y-2 rounded-md border p-3">
674
+ <legend className="px-1 font-medium text-sm">Viewer surroundings</legend>
675
+ <p className="text-muted-foreground text-xs">
676
+ Optional static surroundings are included only in GLB and USDZ.
677
+ </p>
678
+ <div className="space-y-2 pt-1">
679
+ {exportablePresentations.map((contribution, index) => {
680
+ const switchId = `${includePresentationIdPrefix}-${index}`
681
+ const label = contribution.staticExport!.label
682
+ return (
683
+ <div
684
+ className="flex items-center justify-between gap-4"
685
+ key={contribution.id}
686
+ >
687
+ <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
688
+ {label}
689
+ </label>
690
+ <Switch
691
+ aria-label={`Include ${label} in GLB and USDZ`}
692
+ checked={includedPresentationIds.includes(contribution.id)}
693
+ id={switchId}
694
+ onCheckedChange={(included) =>
695
+ handlePresentationInclusion(contribution.id, included)
696
+ }
697
+ />
698
+ </div>
699
+ )
700
+ })}
701
+ </div>
702
+ </fieldset>
703
+ ) : null}
704
+ </div>
705
+ </details>
706
+
707
+ {MODEL_EXPORT_FORMATS.map(({ format, label }) => {
708
+ const isActive = activeModelExport === format
709
+ return (
710
+ <Button
711
+ aria-busy={isActive}
712
+ className="w-full justify-start gap-2"
713
+ disabled={activeModelExport !== null || !modelExport}
714
+ key={format}
715
+ onClick={() => void handleModelExport(format, label)}
716
+ variant="outline"
717
+ >
718
+ <Download aria-hidden="true" className="size-4" />
719
+ {isActive ? `Exporting ${label}…` : `Export ${label}`}
720
+ </Button>
721
+ )
722
+ })}
723
+
724
+ {activeModelExport ? (
725
+ <p className="text-muted-foreground text-xs" role="status">
726
+ Preparing {activeModelExport.toUpperCase()} file…
727
+ </p>
728
+ ) : null}
729
+ {modelExportError ? (
730
+ <p className="text-destructive text-xs" role="alert">
731
+ {modelExportError}
732
+ </p>
733
+ ) : null}
734
+ {modelExportWarning ? (
735
+ <p className="text-foreground text-xs" role="status">
736
+ Warning: {modelExportWarning}
737
+ </p>
738
+ ) : null}
739
+
740
+ <PrintExportButton onlyVisible={exportOnlyVisible} />
401
741
  </div>
402
742
 
403
743
  <div className="space-y-2">
@@ -406,21 +746,35 @@ export function SettingsPanel({
406
746
  <span>{floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}</span>
407
747
  </div>
408
748
  <Button
749
+ aria-busy={activeFloorplanExport === 'full'}
409
750
  className="w-full justify-start gap-2"
410
- onClick={() => exportFloorplanPdf('full')}
751
+ disabled={activeFloorplanExport !== null}
752
+ onClick={() => void handleFloorplanExport('full')}
411
753
  variant="outline"
412
754
  >
413
755
  <MapIcon className="size-4" />
414
756
  Full floor plan
415
757
  </Button>
416
758
  <Button
759
+ aria-busy={activeFloorplanExport === 'structure'}
417
760
  className="w-full justify-start gap-2"
418
- onClick={() => exportFloorplanPdf('structure')}
761
+ disabled={activeFloorplanExport !== null}
762
+ onClick={() => void handleFloorplanExport('structure')}
419
763
  variant="outline"
420
764
  >
421
765
  <MapIcon className="size-4" />
422
766
  Structure only
423
767
  </Button>
768
+ {activeFloorplanExport ? (
769
+ <p className="text-muted-foreground text-xs" role="status">
770
+ Preparing floor-plan PDF…
771
+ </p>
772
+ ) : null}
773
+ {floorplanExportError ? (
774
+ <p className="break-words text-destructive text-xs" role="alert">
775
+ {floorplanExportError}
776
+ </p>
777
+ ) : null}
424
778
  </div>
425
779
  </div>
426
780
 
@@ -1,5 +1,4 @@
1
1
  import { Keyboard } from 'lucide-react'
2
- import { useEffect, useState } from 'react'
3
2
  import { Button } from './../../../../../components/ui/primitives/button'
4
3
  import {
5
4
  Dialog,
@@ -9,7 +8,10 @@ import {
9
8
  DialogTitle,
10
9
  DialogTrigger,
11
10
  } from './../../../../../components/ui/primitives/dialog'
12
- import { ShortcutToken } from './../../../../../components/ui/primitives/shortcut-token'
11
+ import {
12
+ ShortcutToken,
13
+ shortcutDisplayValue,
14
+ } from './../../../../../components/ui/primitives/shortcut-token'
13
15
 
14
16
  type Shortcut = {
15
17
  keys: string[]
@@ -22,14 +24,6 @@ type ShortcutCategory = {
22
24
  shortcuts: Shortcut[]
23
25
  }
24
26
 
25
- const KEY_DISPLAY_MAP: Record<string, string> = {
26
- 'Arrow Up': '↑',
27
- 'Arrow Down': '↓',
28
- Esc: '⎋',
29
- Shift: '⇧',
30
- Space: '␣',
31
- }
32
-
33
27
  const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
34
28
  {
35
29
  title: 'Editor Navigation',
@@ -60,10 +54,12 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
60
54
  {
61
55
  keys: ['Esc'],
62
56
  action: 'Cancel the active tool and return to Select mode',
57
+ note: 'Mid-draw it cancels only the chain in progress and keeps the tool armed; press it again to leave the tool.',
63
58
  },
64
59
  { keys: ['Delete / Backspace'], action: 'Delete selected objects' },
65
60
  { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' },
66
61
  { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' },
62
+ { keys: ['Cmd/Ctrl', 'S'], action: 'Save' },
67
63
  ],
68
64
  },
69
65
  {
@@ -106,6 +102,17 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
106
102
  action: 'Rotate a multi-selection ±45° around its center',
107
103
  note: 'Also works mid-move while carrying the selection.',
108
104
  },
105
+ {
106
+ keys: ['Cmd/Ctrl', 'G'],
107
+ action: 'Group the multi-selection (session only)',
108
+ note:
109
+ 'Editor-only. Plain click a member later to reselect the whole group. Not saved with the project.',
110
+ },
111
+ {
112
+ keys: ['Cmd/Ctrl', 'Shift', 'G'],
113
+ action: 'Ungroup the session selection',
114
+ note: 'Keeps the current selection; only dissolves the session group.',
115
+ },
109
116
  {
110
117
  keys: ['Esc'],
111
118
  action: 'Clear the selection',
@@ -136,6 +143,19 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
136
143
  {
137
144
  title: 'Drawing Tools',
138
145
  shortcuts: [
146
+ // Shift and Ctrl each mean one thing held and another tapped, and only
147
+ // the hold was documented — which read as the taps not existing. Both
148
+ // taps are listed first because they are the ones nobody discovers.
149
+ {
150
+ keys: ['Shift'],
151
+ action: 'Cycle the snapping mode',
152
+ note: 'Tap and release without pressing anything else, while a drawing or move gesture is available.',
153
+ },
154
+ {
155
+ keys: ['Cmd/Ctrl'],
156
+ action: 'Cycle the grid step: 0.5 m → 0.25 m → 0.1 m → 0.05 m',
157
+ note: 'Tap and release on its own. Use it when the default half-metre grid is too coarse — placing a window, for instance.',
158
+ },
139
159
  {
140
160
  keys: ['Shift'],
141
161
  action: 'Bypass guided snapping and angle constraints',
@@ -188,25 +208,13 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
188
208
  },
189
209
  ]
190
210
 
191
- function getDisplayKey(key: string, isMac: boolean): string {
192
- if (key === 'Cmd/Ctrl') return isMac ? '⌘' : 'Ctrl'
193
- if (key === 'Delete / Backspace') return isMac ? '⌫' : 'Backspace'
194
- return KEY_DISPLAY_MAP[key] ?? key
195
- }
196
-
197
211
  function ShortcutKeys({ keys }: { keys: string[] }) {
198
- const [isMac, setIsMac] = useState(true)
199
-
200
- useEffect(() => {
201
- setIsMac(navigator.platform.toUpperCase().indexOf('MAC') >= 0)
202
- }, [])
203
-
204
212
  return (
205
213
  <div className="flex flex-wrap items-center gap-1">
206
214
  {keys.map((key, index) => (
207
215
  <div className="flex items-center gap-1" key={`${key}-${index}`}>
208
216
  {index > 0 ? <span className="text-[10px] text-muted-foreground">+</span> : null}
209
- <ShortcutToken displayValue={getDisplayKey(key, isMac)} value={key} />
217
+ <ShortcutToken displayValue={shortcutDisplayValue(key)} value={key} />
210
218
  </div>
211
219
  ))}
212
220
  </div>