@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.
- package/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +13 -7
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +42 -51
- package/src/components/editor/floorplan-panel.tsx +76 -12
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +140 -21
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/selection-manager.tsx +104 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +284 -237
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +135 -2
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +9 -12
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +22 -5
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +129 -62
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +82 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +1 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- 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().
|
|
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().
|
|
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
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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.
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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.
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
|
486
|
-
//
|
|
487
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
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
|
+
}
|