@pascal-app/editor 1.0.0-beta.5 → 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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,244 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BlockNode,
6
+ clearSceneHistory,
7
+ DuctFittingNode,
8
+ DuctSegmentNode,
9
+ emitter,
10
+ nodeRegistry,
11
+ PipeFittingNode,
12
+ PipeSegmentNode,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { runRedo, runUndo } from '../lib/history'
16
+ import { meshEditScope } from '../lib/interaction/scope'
17
+ import useEditor from '../store/use-editor'
18
+ import useInteractionScope from '../store/use-interaction-scope'
19
+ import {
20
+ blocksSnappingShortcut,
21
+ canCycleSnappingModeShortcut,
22
+ canRunGlobalRotationShortcut,
23
+ isToolOwnedCanopyForm,
24
+ isToolOwnedRotation,
25
+ markToolCancelConsumed,
26
+ runHistoryShortcut,
27
+ } from './use-keyboard'
28
+
29
+ type RafFn = (callback: (time: number) => void) => number
30
+ ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (
31
+ callback,
32
+ ) => {
33
+ callback(0)
34
+ return 0
35
+ }
36
+ ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
37
+ () => {}
38
+
39
+ const NODE_ID = 'block_history' as AnyNodeId
40
+
41
+ describe('snapping shortcuts while entering a run length', () => {
42
+ test('allows snap mode and spacing shortcuts in the duct and pipe length field', () => {
43
+ expect(
44
+ blocksSnappingShortcut({
45
+ tagName: 'INPUT',
46
+ isContentEditable: false,
47
+ hasAttribute: (name) => name === 'data-run-length-input',
48
+ }),
49
+ ).toBe(false)
50
+ })
51
+
52
+ test('continues to protect ordinary text fields and editable content', () => {
53
+ for (const tagName of ['INPUT', 'TEXTAREA', 'DIV']) {
54
+ expect(
55
+ blocksSnappingShortcut({
56
+ tagName,
57
+ isContentEditable: tagName === 'DIV',
58
+ hasAttribute: () => false,
59
+ }),
60
+ ).toBe(true)
61
+ }
62
+ expect(blocksSnappingShortcut(null)).toBe(false)
63
+ })
64
+ })
65
+
66
+ beforeEach(() => {
67
+ const node = BlockNode.parse({ id: NODE_ID, position: [0, 0, 0] })
68
+ useScene.setState({
69
+ nodes: { [NODE_ID]: node },
70
+ rootNodeIds: [NODE_ID],
71
+ dirtyNodes: new Set<AnyNodeId>(),
72
+ collections: {},
73
+ materials: {},
74
+ readOnly: false,
75
+ } as never)
76
+ clearSceneHistory()
77
+ useScene.getState().updateNode(NODE_ID, { position: [1, 2, 3] } as Partial<AnyNode>)
78
+ })
79
+
80
+ afterEach(() => {
81
+ useInteractionScope.getState().end()
82
+ useEditor.getState().armToolMode({ mode: 'select' })
83
+ useEditor.setState({ selectedItem: null })
84
+ clearSceneHistory()
85
+ })
86
+
87
+ describe('rotation shortcut ownership', () => {
88
+ test('does not reserve R and T for an armed item tool without a placement item', () => {
89
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
90
+
91
+ expect(isToolOwnedRotation()).toBe(false)
92
+ })
93
+
94
+ test('leaves R and T to the active item placement tool', () => {
95
+ useEditor.getState().setSelectedItem({
96
+ asset: { id: 'asset:test' },
97
+ } as never)
98
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
99
+
100
+ expect(isToolOwnedRotation()).toBe(true)
101
+ })
102
+
103
+ test('leaves R and T to the active lean-to placement tool', () => {
104
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'lean-to-extension' })
105
+
106
+ expect(isToolOwnedRotation()).toBe(true)
107
+ })
108
+
109
+ test('leaves R and T to a moving lean-to extension', () => {
110
+ const leanTo = { id: 'lean_to_moving', type: 'lean-to-extension' } as unknown as AnyNode
111
+ useInteractionScope.getState().begin({
112
+ kind: 'moving',
113
+ node: leanTo,
114
+ nodeId: leanTo.id,
115
+ nodeType: leanTo.type,
116
+ view: '3d',
117
+ })
118
+
119
+ expect(isToolOwnedRotation()).toBe(true)
120
+ })
121
+
122
+ test('leaves F to the active lean-to placement tool', () => {
123
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'lean-to-extension' })
124
+
125
+ expect(isToolOwnedCanopyForm()).toBe(true)
126
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
127
+ expect(isToolOwnedCanopyForm()).toBe(false)
128
+ })
129
+ })
130
+
131
+ describe('history shortcuts during block editing', () => {
132
+ test('reserves global rotation shortcuts for the active mesh editor', () => {
133
+ expect(canRunGlobalRotationShortcut()).toBe(true)
134
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
135
+ expect(canRunGlobalRotationShortcut()).toBe(false)
136
+ })
137
+
138
+ test('keeps Shift available to cycle snapping while a mesh operation is active', () => {
139
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
140
+ expect(canCycleSnappingModeShortcut(true)).toBe(true)
141
+
142
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID, 'operating', 'translate'))
143
+ expect(canCycleSnappingModeShortcut(true)).toBe(true)
144
+ })
145
+
146
+ test('undoes and redoes mesh changes without leaving component selection mode', () => {
147
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
148
+
149
+ expect(runHistoryShortcut('undo')).toBe(true)
150
+ expect((useScene.getState().nodes[NODE_ID] as BlockNode).position).toEqual([0, 0, 0])
151
+ expect(useInteractionScope.getState().scope).toEqual({
152
+ kind: 'mesh-editing',
153
+ nodeId: NODE_ID,
154
+ phase: 'selecting',
155
+ })
156
+
157
+ expect(runHistoryShortcut('redo')).toBe(true)
158
+ expect((useScene.getState().nodes[NODE_ID] as BlockNode).position).toEqual([1, 2, 3])
159
+ expect(useInteractionScope.getState().scope).toEqual({
160
+ kind: 'mesh-editing',
161
+ nodeId: NODE_ID,
162
+ phase: 'selecting',
163
+ })
164
+ })
165
+ })
166
+
167
+ describe('history while drawing distribution runs', () => {
168
+ let restoreRegistry = () => {}
169
+
170
+ beforeEach(() => {
171
+ restoreRegistry = nodeRegistry._snapshot()
172
+ nodeRegistry._reset()
173
+ for (const kind of ['duct-segment', 'pipe-segment']) {
174
+ nodeRegistry._register({
175
+ kind,
176
+ schemaVersion: 1,
177
+ drafting: { cancelOnHistoryJump: true },
178
+ } as never)
179
+ }
180
+ })
181
+
182
+ afterEach(() => {
183
+ restoreRegistry()
184
+ restoreRegistry = () => {}
185
+ })
186
+
187
+ for (const [kind, schema, fittingSchema] of [
188
+ ['duct', DuctSegmentNode, DuctFittingNode],
189
+ ['pipe', PipeSegmentNode, PipeFittingNode],
190
+ ] as const) {
191
+ test(`undoes and redoes individual ${kind} commits while drafting`, () => {
192
+ clearSceneHistory()
193
+ const first = schema.parse({
194
+ path: [
195
+ [0, 1, 0],
196
+ [1, 1, 0],
197
+ ],
198
+ })
199
+ const second = schema.parse({
200
+ path: [
201
+ [1, 1, 0],
202
+ [2, 1, 0],
203
+ ],
204
+ })
205
+ useScene.getState().applyNodeChanges({ create: [{ node: first }] })
206
+ const fitting = fittingSchema.parse({ position: [1, 1, 0] })
207
+ const trimmedPath: [number, number, number][] = [
208
+ [0, 1, 0],
209
+ [0.8, 1, 0],
210
+ ]
211
+ useScene.getState().applyNodeChanges({
212
+ create: [{ node: second }, { node: fitting }],
213
+ update: [{ id: first.id, data: { path: trimmedPath } }],
214
+ })
215
+ expect(useScene.temporal.getState().pastStates).toHaveLength(2)
216
+ useInteractionScope.getState().begin({ kind: 'drafting', tool: first.type })
217
+ let cancellations = 0
218
+ const cancel = () => {
219
+ cancellations += 1
220
+ markToolCancelConsumed()
221
+ }
222
+ emitter.on('tool:cancel', cancel)
223
+ try {
224
+ expect(runHistoryShortcut('undo')).toBe(true)
225
+ expect(useScene.getState().nodes[second.id]).toBeUndefined()
226
+ expect(useScene.getState().nodes[fitting.id]).toBeUndefined()
227
+ expect(useScene.getState().nodes[first.id]).toMatchObject({ path: first.path })
228
+ expect(runUndo().kind).toBe('applied')
229
+ expect(useScene.getState().nodes[first.id]).toBeUndefined()
230
+ expect(runRedo().kind).toBe('applied')
231
+ expect(useScene.getState().nodes[first.id]).toBeDefined()
232
+ expect(useScene.getState().nodes[second.id]).toBeUndefined()
233
+ expect(runHistoryShortcut('redo')).toBe(true)
234
+ expect(useScene.getState().nodes[second.id]).toBeDefined()
235
+ expect(useScene.getState().nodes[fitting.id]).toBeDefined()
236
+ expect(useScene.getState().nodes[first.id]).toMatchObject({ path: trimmedPath })
237
+ expect(cancellations).toBe(4)
238
+ expect(useInteractionScope.getState().scope).toEqual({ kind: 'drafting', tool: first.type })
239
+ } finally {
240
+ emitter.off('tool:cancel', cancel)
241
+ }
242
+ })
243
+ }
244
+ })
@@ -7,7 +7,7 @@ import {
7
7
  resumeSpaceDetection,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
- import { useViewer } from '@pascal-app/viewer'
10
+ import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer'
11
11
  import { useEffect } from 'react'
12
12
  import { Vector3 } from 'three'
13
13
  import {
@@ -27,7 +27,7 @@ import { steppedRotation } from '../components/tools/item/placement-math'
27
27
  import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
28
28
  import { toggleDoorOpenState } from '../lib/door-interaction'
29
29
  import { guideEmitter } from '../lib/guide-events'
30
- import { runRedo, runUndo } from '../lib/history'
30
+ import { isHistoryShortcut, runRedo, runUndo, shouldCancelDraftOnHistoryJump } from '../lib/history'
31
31
  import { isActive } from '../lib/interaction/scope'
32
32
  import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
33
33
  import { sfxEmitter } from '../lib/sfx-bus'
@@ -102,6 +102,9 @@ function rotateGroupSelection(direction: 1 | -1): boolean {
102
102
  let _toolCancelConsumed = false
103
103
  export const markToolCancelConsumed = () => {
104
104
  _toolCancelConsumed = true
105
+ // A consumed cancel means the active gesture reverted — the perf ledger must
106
+ // not measure the restore as a committed action's settle.
107
+ cancelPerfAction()
105
108
  }
106
109
 
107
110
  // Escape's fall-through when no tool consumed the cancel: drop back to the
@@ -117,10 +120,10 @@ const exitToSelectAfterUnconsumedCancel = () => {
117
120
  // From zone mode, return to structure select
118
121
  if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
119
122
  useEditor.getState().setStructureLayer('elements')
120
- useEditor.getState().setMode('select')
123
+ useEditor.getState().armToolMode({ mode: 'select' })
121
124
  } else {
122
125
  // Return to the default select tool while keeping the active building/level context.
123
- useEditor.getState().setMode('select')
126
+ useEditor.getState().armToolMode({ mode: 'select' })
124
127
  }
125
128
 
126
129
  useEditor.getState().setFloorplanSelectionTool('click')
@@ -141,6 +144,9 @@ const cancelInteractionForHistoryShortcut = () => {
141
144
  guideEmitter.emit('guide:cancel-reference-scale')
142
145
  return true
143
146
  }
147
+ const activeScope = useInteractionScope.getState().scope
148
+ if (shouldCancelDraftOnHistoryJump()) return false
149
+ if (activeScope.kind === 'mesh-editing' && activeScope.phase === 'selecting') return false
144
150
  _toolCancelConsumed = false
145
151
  emitter.emit('tool:cancel')
146
152
  if (_toolCancelConsumed) return true
@@ -162,6 +168,56 @@ const cancelInteractionForHistoryShortcut = () => {
162
168
  return false
163
169
  }
164
170
 
171
+ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
172
+ if (cancelInteractionForHistoryShortcut()) return false
173
+ if (direction === 'redo') runRedo()
174
+ else runUndo()
175
+ return true
176
+ }
177
+
178
+ export const isToolOwnedRotation = () => {
179
+ const editor = useEditor.getState()
180
+ const moving = getMovingNode()
181
+ if (
182
+ moving?.type === 'door' ||
183
+ moving?.type === 'window' ||
184
+ moving?.type === 'item' ||
185
+ moving?.type === 'lean-to-extension'
186
+ )
187
+ return true
188
+ return (
189
+ editor.mode === 'build' &&
190
+ (editor.tool === 'door' ||
191
+ editor.tool === 'window' ||
192
+ editor.tool === 'roof' ||
193
+ // The item tool is mounted for the build mode, but it only owns R/T
194
+ // when a catalog item is actually selected and a placement draft can
195
+ // exist. Without this check, selecting an existing item in the 2D plan
196
+ // while the item tool is armed silently drops the global rotate key.
197
+ (editor.tool === 'item' && editor.selectedItem !== null) ||
198
+ editor.tool === 'lean-to-extension')
199
+ )
200
+ }
201
+
202
+ export const isToolOwnedCanopyForm = () => {
203
+ const editor = useEditor.getState()
204
+ return editor.mode === 'build' && editor.tool === 'lean-to-extension'
205
+ }
206
+
207
+ export const canRunGlobalRotationShortcut = () =>
208
+ useInteractionScope.getState().scope.kind !== 'mesh-editing'
209
+
210
+ export const canCycleSnappingModeShortcut = (hasActiveContext = getActiveSnapContext() != null) =>
211
+ hasActiveContext
212
+
213
+ export function blocksSnappingShortcut(
214
+ target: Pick<HTMLElement, 'tagName' | 'isContentEditable' | 'hasAttribute'> | null,
215
+ ): boolean {
216
+ if (!target) return false
217
+ if (target.tagName === 'INPUT' && target.hasAttribute('data-run-length-input')) return false
218
+ return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
219
+ }
220
+
165
221
  export const useKeyboard = ({
166
222
  isVersionPreviewMode = false,
167
223
  disabled = false,
@@ -174,32 +230,30 @@ export const useKeyboard = ({
174
230
  return
175
231
  }
176
232
 
177
- // True while a door/window is being placed: either a fresh clone is moving
178
- // (preset / duplicate path) or a door/window build tool is armed. The
179
- // placement tool owns R/T then (flip the draft before commit), so the
180
- // global selection-based R/T handler must stand down to avoid double-firing.
181
- const isPlacingOpening = () => {
182
- const ed = useEditor.getState()
183
- const moving = getMovingNode()
184
- if (moving?.type === 'door' || moving?.type === 'window') return true
185
- return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
186
- }
187
-
233
+ // True while an active placement tool owns R/T. Door/window tools flip the
234
+ // draft, item / lean-to placement rotates its draft, and the roof tool turns
235
+ // its draft axes. The global selection handler must stand down to avoid double-firing.
188
236
  // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
189
237
  // whenever there's an active snapping context — i.e. exactly when the HUD
190
238
  // shows a snapping chip. That single source covers wall/fence/item drafting,
191
239
  // every node move (including wall-hosted items + door/window openings, which
192
240
  // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
193
241
  // never silently stop working. Force-place lives on Alt where a tool supports it.
194
- const isSnappingCycleContext = () => getActiveSnapContext() != null
195
242
  // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
196
243
  // between) cycles the grid step — same context as the Shift snapping-mode
197
244
  // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
198
245
  // and is cleared the instant any other key fires, so chords like Ctrl+Z /
199
246
  // Ctrl+C never cycle.
200
247
  let ctrlTapClean = false
248
+ let shiftTapClean = false
201
249
 
202
250
  const handleKeyDown = (e: KeyboardEvent) => {
251
+ if (e.key === 'Shift') {
252
+ shiftTapClean = !e.repeat && !e.metaKey && !e.ctrlKey && !e.altKey
253
+ } else {
254
+ shiftTapClean = false
255
+ }
256
+
203
257
  if (e.key === 'Control' || e.key === 'Meta') {
204
258
  // Only a fresh, modifier-free press starts a clean-tap candidate;
205
259
  // ignore key-repeat and presses already part of a combo.
@@ -210,6 +264,18 @@ export const useKeyboard = ({
210
264
  ctrlTapClean = false
211
265
  }
212
266
 
267
+ if (
268
+ shouldCancelDraftOnHistoryJump() &&
269
+ isHistoryShortcut(e) &&
270
+ e.target instanceof HTMLInputElement &&
271
+ e.target.hasAttribute('data-run-length-input')
272
+ ) {
273
+ if (isVersionPreviewMode || useDeleteConfirmation.getState().request) return
274
+ e.preventDefault()
275
+ runHistoryShortcut(e.shiftKey ? 'redo' : 'undo')
276
+ return
277
+ }
278
+
213
279
  // Don't handle shortcuts if user is typing in an input
214
280
  if (
215
281
  e.target instanceof HTMLInputElement ||
@@ -261,15 +327,6 @@ export const useKeyboard = ({
261
327
  return
262
328
  }
263
329
 
264
- if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
265
- // Cycle the global snapping mode (grid → lines → angles → off).
266
- // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
267
- e.preventDefault()
268
- useEditor.getState().cycleSnappingMode()
269
- sfxEmitter.emit('sfx:grid-snap')
270
- return
271
- }
272
-
273
330
  if (
274
331
  (e.key === 't' || e.key === 'T') &&
275
332
  !e.repeat &&
@@ -338,32 +395,28 @@ export const useKeyboard = ({
338
395
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
339
396
  e.preventDefault()
340
397
  useEditor.getState().setPhase('site')
341
- useEditor.getState().setMode('select')
398
+ useEditor.getState().armToolMode({ mode: 'select' })
342
399
  } else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
343
400
  e.preventDefault()
344
401
  useEditor.getState().setPhase('structure')
345
- useEditor.getState().setMode('select')
402
+ useEditor.getState().armToolMode({ mode: 'select' })
346
403
  } else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
347
404
  e.preventDefault()
348
405
  useEditor.getState().setPhase('furnish')
349
- useEditor.getState().setMode('select')
406
+ useEditor.getState().armToolMode({ mode: 'select' })
350
407
  } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
351
408
  if (isVersionPreviewMode) return
409
+ if (isToolOwnedCanopyForm()) return
352
410
  e.preventDefault()
353
411
  useEditor.getState().setPhase('furnish')
354
- useEditor.getState().setMode('build')
355
- // Set the item tool explicitly so the active tool never inherits a
356
- // stale tool from a prior build session.
357
- useEditor.getState().setTool('item')
412
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
358
413
  useEditor.getState().setActiveSidebarPanel('items')
359
414
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
360
415
  if (isVersionPreviewMode) return
361
416
  e.preventDefault()
362
417
  useEditor.getState().setPhase('structure')
363
418
  useEditor.getState().setStructureLayer('zones')
364
- useEditor.getState().setMode('build')
365
- // Set the zone tool explicitly so it never inherits a stale tool.
366
- useEditor.getState().setTool('zone')
419
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'zone' })
367
420
  } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
368
421
  if (isVersionPreviewMode) return
369
422
  e.preventDefault()
@@ -371,39 +424,33 @@ export const useKeyboard = ({
371
424
  editor.setPhase('structure')
372
425
  editor.setStructureLayer('elements')
373
426
  editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
374
- editor.setMode('build')
375
- editor.setTool('measurement')
427
+ editor.armToolMode({ mode: 'build', tool: 'measurement' })
376
428
  }
377
429
  if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
378
430
  e.preventDefault()
379
- useEditor.getState().setMode('select')
431
+ useEditor.getState().armToolMode({ mode: 'select' })
380
432
  useEditor.getState().setFloorplanSelectionTool('click')
381
433
  } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
382
434
  if (isVersionPreviewMode) return
383
435
  e.preventDefault()
384
436
  useEditor.getState().setPhase('structure')
385
437
  useEditor.getState().setStructureLayer('elements')
386
- useEditor.getState().setMode('build')
387
- // Set the wall tool explicitly so B never inherits a stale tool
388
- // (e.g. fence) left over from a prior build session.
389
- useEditor.getState().setTool('wall')
438
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
390
439
  } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
391
440
  if (isVersionPreviewMode) return
392
441
  e.preventDefault()
393
- useEditor.getState().setMode('delete')
442
+ useEditor.getState().armToolMode({ mode: 'delete' })
394
443
  } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
395
444
  if (isVersionPreviewMode) return
396
445
  e.preventDefault()
397
- useEditor.getState().primeMaterialPaintFromSelection()
398
446
  useEditor.getState().setPhase('structure')
399
447
  useEditor.getState().setStructureLayer('elements')
400
- useEditor.getState().setMode('material-paint')
448
+ useEditor.getState().armMaterialPaint()
401
449
  } else if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
402
450
  if (isVersionPreviewMode) return
403
451
  e.preventDefault()
404
- // G for ground. No `setPhase` `setMode` moves to the site phase itself,
405
- // and doing it here would set the phase twice with a mode reset between.
406
- useEditor.getState().setMode('terrain-sculpt')
452
+ // G for ground. The ToolMode transition moves to the site phase itself.
453
+ useEditor.getState().armToolMode({ mode: 'terrain-sculpt' })
407
454
  } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
408
455
  if (isVersionPreviewMode) return
409
456
  e.preventDefault()
@@ -419,13 +466,11 @@ export const useKeyboard = ({
419
466
  } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
420
467
  if (isVersionPreviewMode) return
421
468
  e.preventDefault()
422
- if (cancelInteractionForHistoryShortcut()) return
423
- runRedo()
469
+ runHistoryShortcut('redo')
424
470
  } else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
425
471
  if (isVersionPreviewMode) return
426
472
  e.preventDefault()
427
- if (cancelInteractionForHistoryShortcut()) return
428
- runUndo()
473
+ runHistoryShortcut('undo')
429
474
  } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
430
475
  e.preventDefault()
431
476
  const { buildingId, levelId } = useViewer.getState().selection
@@ -441,8 +486,10 @@ export const useKeyboard = ({
441
486
  const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
442
487
  const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
443
488
  if (nextIdx !== -1 && nextIdx !== currentIdx) {
489
+ markPerfAction('level-switch', levels[nextIdx] as string)
444
490
  useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
445
491
  } else if (currentIdx === -1) {
492
+ markPerfAction('level-switch', levels[0] as string)
446
493
  useViewer.getState().setSelection({ levelId: levels[0] as any })
447
494
  }
448
495
  }
@@ -462,8 +509,10 @@ export const useKeyboard = ({
462
509
  const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
463
510
  const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
464
511
  if (prevIdx !== -1 && prevIdx !== currentIdx) {
512
+ markPerfAction('level-switch', levels[prevIdx] as string)
465
513
  useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
466
514
  } else if (currentIdx === -1) {
515
+ markPerfAction('level-switch', levels[levels.length - 1] as string)
467
516
  useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
468
517
  }
469
518
  }
@@ -473,7 +522,8 @@ export const useKeyboard = ({
473
522
  !e.metaKey &&
474
523
  !e.ctrlKey &&
475
524
  !isVersionPreviewMode &&
476
- !isPlacingOpening()
525
+ !isToolOwnedRotation() &&
526
+ canRunGlobalRotationShortcut()
477
527
  ) {
478
528
  // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead
479
529
  // of rotating/flipping the selected node.
@@ -482,10 +532,9 @@ export const useKeyboard = ({
482
532
  // open/close toggle lives on E. Windows still use R to toggle
483
533
  // their open/closed state.
484
534
  //
485
- // Skipped entirely while a door/window placement is active
486
- // (`isPlacingOpening`): the placement tool owns R then (flip the draft
487
- // before commit), and the user can have a node selected at the same
488
- // time — without this guard both would fire (double flip + sfx).
535
+ // Skipped while an item, door, window, or roof placement owns rotation.
536
+ // The user can still have a node selected during placement; without this
537
+ // guard both the draft and the selection would rotate.
489
538
  //
490
539
  // References (guide/scan) live in `selectedReferenceId`, not the viewer
491
540
  // selection — check them first, like the Delete arm below.
@@ -565,7 +614,12 @@ export const useKeyboard = ({
565
614
  sfxEmitter.emit('sfx:item-rotate')
566
615
  }
567
616
  }
568
- } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
617
+ } else if (
618
+ (e.key === 't' || e.key === 'T') &&
619
+ !isVersionPreviewMode &&
620
+ !isToolOwnedRotation() &&
621
+ canRunGlobalRotationShortcut()
622
+ ) {
569
623
  // Rotate selected node counter-clockwise
570
624
  // Multi-selection → group rotate, mirroring the R arm above.
571
625
  if (rotateGroupSelection(-1)) {
@@ -683,16 +737,27 @@ export const useKeyboard = ({
683
737
  }
684
738
  }
685
739
  const handleKeyUp = (e: KeyboardEvent) => {
740
+ if (e.key === 'Shift') {
741
+ const wasClean = shiftTapClean
742
+ shiftTapClean = false
743
+ if (!wasClean) return
744
+ if (blocksSnappingShortcut(e.target instanceof HTMLElement ? e.target : null)) {
745
+ return
746
+ }
747
+ if (!canCycleSnappingModeShortcut()) return
748
+ e.preventDefault()
749
+ useEditor.getState().cycleSnappingMode()
750
+ sfxEmitter.emit('sfx:grid-snap')
751
+ return
752
+ }
686
753
  if (e.key === 'Control' || e.key === 'Meta') {
687
754
  const wasClean = ctrlTapClean
688
755
  ctrlTapClean = false
689
756
  if (!wasClean) return
690
- // Same scope as the Shift snapping-mode cycle, and never while typing
691
- // in an input.
692
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
757
+ if (blocksSnappingShortcut(e.target instanceof HTMLElement ? e.target : null)) {
693
758
  return
694
759
  }
695
- if (!isSnappingCycleContext()) return
760
+ if (!canCycleSnappingModeShortcut()) return
696
761
  // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
697
762
  useEditor.getState().cycleGridSnapStep()
698
763
  sfxEmitter.emit('sfx:grid-snap')
@@ -706,6 +771,8 @@ export const useKeyboard = ({
706
771
  // registry move overlay) — safe only because none of them claim Ctrl/Cmd+G.
707
772
  // `e.code` keeps it on the physical G key across keyboard layouts.
708
773
  const handleSessionGroupKeyDown = (e: KeyboardEvent) => {
774
+ if (e.key !== 'Shift') shiftTapClean = false
775
+ if (e.key !== 'Control' && e.key !== 'Meta') ctrlTapClean = false
709
776
  if (
710
777
  e.target instanceof HTMLInputElement ||
711
778
  e.target instanceof HTMLTextAreaElement ||
@@ -0,0 +1,20 @@
1
+ 'use client'
2
+
3
+ import { isNodeKindEnabled, useScene } from '@pascal-app/core'
4
+ import { useEffect } from 'react'
5
+ import useEditor, { type Tool } from '../store/use-editor'
6
+
7
+ export function useRegisteredToolEnabled(tool: Tool | null): boolean {
8
+ const installedPlugins = useScene((state) => state.installedPlugins)
9
+ const enabled = tool === null || isNodeKindEnabled(tool, installedPlugins)
10
+
11
+ useEffect(() => {
12
+ if (enabled || tool === null) return
13
+
14
+ // The render gate has already unmounted the tool, so its own cleanup cancels
15
+ // any draft before clearing the stale selection prevents reinstall remounts.
16
+ if (useEditor.getState().tool === tool) useEditor.getState().setTool(null)
17
+ }, [enabled, tool])
18
+
19
+ return enabled
20
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ /**
6
+ * Claims Cmd/Ctrl+S for the app's save.
7
+ *
8
+ * Capture phase and ungated on purpose: the browser's "Save page" dialog must
9
+ * never appear anywhere in the editor — including first-person, studio mode and
10
+ * while focus sits in an input, where people still expect the chord to save.
11
+ * `e.code` keeps it on the physical S key across keyboard layouts.
12
+ */
13
+ export function useSaveShortcut(onSave: () => void) {
14
+ const onSaveRef = useRef(onSave)
15
+
16
+ useEffect(() => {
17
+ onSaveRef.current = onSave
18
+ }, [onSave])
19
+
20
+ useEffect(() => {
21
+ const handleKeyDown = (e: KeyboardEvent) => {
22
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return
23
+ if (e.code !== 'KeyS') return
24
+
25
+ e.preventDefault()
26
+ e.stopPropagation()
27
+ onSaveRef.current()
28
+ }
29
+
30
+ window.addEventListener('keydown', handleKeyDown, true)
31
+ return () => window.removeEventListener('keydown', handleKeyDown, true)
32
+ }, [])
33
+ }