@pascal-app/editor 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/editor-layout-v2.tsx +8 -3
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/first-person-controls.tsx +2 -45
- package/src/components/editor/floating-action-menu.tsx +13 -4
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +5 -45
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/index.tsx +112 -25
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +29 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +28 -5
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
- package/src/components/editor/thumbnail-generator.tsx +27 -11
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
- package/src/components/tools/item/placement-strategies.test.ts +167 -0
- package/src/components/tools/item/placement-strategies.ts +57 -22
- package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
- package/src/components/tools/shared/placement-box.tsx +9 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/wall/wall-drafting.test.ts +207 -2
- package/src/components/tools/wall/wall-drafting.ts +84 -400
- package/src/components/ui/command-palette/editor-commands.tsx +3 -1
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
- package/src/components/ui/sidebar/tab-bar.tsx +7 -1
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +218 -0
- package/src/components/viewer-overlay.tsx +12 -8
- package/src/hooks/use-auto-save.test.ts +50 -1
- package/src/hooks/use-auto-save.ts +44 -9
- package/src/hooks/use-keyboard.ts +35 -1
- package/src/index.tsx +27 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +84 -0
- package/src/lib/history.test.ts +75 -10
- package/src/lib/history.ts +58 -13
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/selection-routing.test.ts +20 -5
- package/src/lib/selection-routing.ts +15 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/store/use-session-groups.ts +103 -0
|
@@ -11,6 +11,40 @@ export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount
|
|
|
11
11
|
return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Tracks the node count of the graph we believe is stored, which is what the
|
|
16
|
+
* accidental-wipe guard measures every write against.
|
|
17
|
+
*
|
|
18
|
+
* The distinction that matters: a graph that came from storage is authoritative
|
|
19
|
+
* and has to become the new baseline, while an edited or previewed graph must
|
|
20
|
+
* not. Seeding the baseline once at mount is not enough — the hook mounts
|
|
21
|
+
* before the scene has loaded, so it would sit at ~0 for the whole session and
|
|
22
|
+
* `isSuspiciousNodeDrop` could never fire.
|
|
23
|
+
*/
|
|
24
|
+
export function createStoredNodeCountTracker(initialNodeCount: number) {
|
|
25
|
+
let count = initialNodeCount
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
get count() {
|
|
29
|
+
return count
|
|
30
|
+
},
|
|
31
|
+
/** A graph read from storage — it defines what "populated" means from here. */
|
|
32
|
+
trackLoadedGraph(nodeCount: number) {
|
|
33
|
+
count = nodeCount
|
|
34
|
+
},
|
|
35
|
+
/**
|
|
36
|
+
* `false` when the write would drop a populated scene to a bare scaffold,
|
|
37
|
+
* which is an accidental full deletion far more often than an intent. The
|
|
38
|
+
* caller reports the block; on `true` the write becomes the new baseline.
|
|
39
|
+
*/
|
|
40
|
+
allowWrite(nodeCount: number) {
|
|
41
|
+
if (isSuspiciousNodeDrop(count, nodeCount)) return false
|
|
42
|
+
count = nodeCount
|
|
43
|
+
return true
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
14
48
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
15
49
|
|
|
16
50
|
interface UseAutoSaveOptions {
|
|
@@ -65,7 +99,9 @@ export function useAutoSave({
|
|
|
65
99
|
// Stable subscription to scene changes
|
|
66
100
|
useEffect(() => {
|
|
67
101
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
68
|
-
|
|
102
|
+
const storedNodeCount = createStoredNodeCountTracker(
|
|
103
|
+
Object.keys(useScene.getState().nodes).length,
|
|
104
|
+
)
|
|
69
105
|
// Collections + scene materials are document-level state that persists with
|
|
70
106
|
// the graph but lives outside `nodes`. Track them by reference (zustand
|
|
71
107
|
// hands out a new object on every mutation) so a material edit or a
|
|
@@ -90,17 +126,15 @@ export function useAutoSave({
|
|
|
90
126
|
installedPlugins,
|
|
91
127
|
} as SceneGraph
|
|
92
128
|
|
|
93
|
-
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
|
94
|
-
// This catches accidental full deletions before they're persisted.
|
|
95
129
|
const currentNodeCount = Object.keys(nodes).length
|
|
96
|
-
|
|
130
|
+
const previousNodeCount = storedNodeCount.count
|
|
131
|
+
if (!storedNodeCount.allowWrite(currentNodeCount)) {
|
|
97
132
|
console.warn(
|
|
98
|
-
`[autosave] Blocked: scene dropped from ${
|
|
133
|
+
`[autosave] Blocked: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
99
134
|
)
|
|
100
135
|
setSaveStatus('error')
|
|
101
136
|
return
|
|
102
137
|
}
|
|
103
|
-
lastNodeCount = currentNodeCount
|
|
104
138
|
|
|
105
139
|
isSavingRef.current = true
|
|
106
140
|
pendingSaveRef.current = false
|
|
@@ -135,6 +169,7 @@ export function useAutoSave({
|
|
|
135
169
|
const unsubscribe = useScene.subscribe((state) => {
|
|
136
170
|
if (isLoadingSceneRef.current) {
|
|
137
171
|
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
172
|
+
storedNodeCount.trackLoadedGraph(Object.keys(state.nodes).length)
|
|
138
173
|
lastCollectionsRef = state.collections
|
|
139
174
|
lastMaterialsRef = state.materials
|
|
140
175
|
lastInstalledPluginsRef = state.installedPlugins
|
|
@@ -188,16 +223,16 @@ export function useAutoSave({
|
|
|
188
223
|
if (!hasDirtyChangesRef.current) return
|
|
189
224
|
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
|
|
190
225
|
const currentNodeCount = Object.keys(nodes).length
|
|
191
|
-
|
|
226
|
+
const previousNodeCount = storedNodeCount.count
|
|
227
|
+
if (!storedNodeCount.allowWrite(currentNodeCount)) {
|
|
192
228
|
console.warn(
|
|
193
|
-
`[autosave] Blocked unload flush: scene dropped from ${
|
|
229
|
+
`[autosave] Blocked unload flush: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
194
230
|
)
|
|
195
231
|
setSaveStatus('error')
|
|
196
232
|
return
|
|
197
233
|
}
|
|
198
234
|
|
|
199
235
|
hasDirtyChangesRef.current = false
|
|
200
|
-
lastNodeCount = currentNodeCount
|
|
201
236
|
const sceneGraph = {
|
|
202
237
|
nodes,
|
|
203
238
|
rootNodeIds,
|
|
@@ -36,6 +36,7 @@ import { toggleWindowOpenState } from '../lib/window-interaction'
|
|
|
36
36
|
import useDeleteConfirmation from '../store/use-delete-confirmation'
|
|
37
37
|
import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
|
|
38
38
|
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
|
|
39
|
+
import { groupCurrentSelection, ungroupCurrentSelection } from '../store/use-session-groups'
|
|
39
40
|
|
|
40
41
|
// References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
|
|
41
42
|
// the viewer selection, so selection-based key arms (R/T rotate) need this
|
|
@@ -210,7 +211,11 @@ export const useKeyboard = ({
|
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
// Don't handle shortcuts if user is typing in an input
|
|
213
|
-
if (
|
|
214
|
+
if (
|
|
215
|
+
e.target instanceof HTMLInputElement ||
|
|
216
|
+
e.target instanceof HTMLTextAreaElement ||
|
|
217
|
+
(e.target instanceof HTMLElement && e.target.isContentEditable)
|
|
218
|
+
) {
|
|
214
219
|
return
|
|
215
220
|
}
|
|
216
221
|
|
|
@@ -695,9 +700,38 @@ export const useKeyboard = ({
|
|
|
695
700
|
}
|
|
696
701
|
}
|
|
697
702
|
|
|
703
|
+
// Capture-phase Ctrl/Cmd+G so browser "Find next" cannot steal the shortcut
|
|
704
|
+
// before the editor bubble listener runs. `stopPropagation` here silences the
|
|
705
|
+
// chord for every other capture listener (floorplan hotkeys, group move,
|
|
706
|
+
// registry move overlay) — safe only because none of them claim Ctrl/Cmd+G.
|
|
707
|
+
// `e.code` keeps it on the physical G key across keyboard layouts.
|
|
708
|
+
const handleSessionGroupKeyDown = (e: KeyboardEvent) => {
|
|
709
|
+
if (
|
|
710
|
+
e.target instanceof HTMLInputElement ||
|
|
711
|
+
e.target instanceof HTMLTextAreaElement ||
|
|
712
|
+
(e.target instanceof HTMLElement && e.target.isContentEditable)
|
|
713
|
+
) {
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
if (useDeleteConfirmation.getState().request) return
|
|
717
|
+
if (isVersionPreviewMode) return
|
|
718
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey) return
|
|
719
|
+
if (e.code !== 'KeyG') return
|
|
720
|
+
|
|
721
|
+
e.preventDefault()
|
|
722
|
+
e.stopPropagation()
|
|
723
|
+
if (e.shiftKey) {
|
|
724
|
+
ungroupCurrentSelection()
|
|
725
|
+
} else {
|
|
726
|
+
groupCurrentSelection()
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
window.addEventListener('keydown', handleSessionGroupKeyDown, true)
|
|
698
731
|
window.addEventListener('keydown', handleKeyDown)
|
|
699
732
|
window.addEventListener('keyup', handleKeyUp)
|
|
700
733
|
return () => {
|
|
734
|
+
window.removeEventListener('keydown', handleSessionGroupKeyDown, true)
|
|
701
735
|
window.removeEventListener('keydown', handleKeyDown)
|
|
702
736
|
window.removeEventListener('keyup', handleKeyUp)
|
|
703
737
|
}
|
package/src/index.tsx
CHANGED
|
@@ -274,6 +274,16 @@ export {
|
|
|
274
274
|
SnapTargetBadge,
|
|
275
275
|
SnapTargetIcon,
|
|
276
276
|
} from './components/ui/snap-target-badge'
|
|
277
|
+
export {
|
|
278
|
+
FloorplanCompassButton,
|
|
279
|
+
type FloorplanCompassButtonProps,
|
|
280
|
+
} from './components/viewer/floorplan-compass-button'
|
|
281
|
+
export {
|
|
282
|
+
FloorplanPreview,
|
|
283
|
+
type FloorplanPreviewProps,
|
|
284
|
+
type FloorplanPreviewScene,
|
|
285
|
+
} from './components/viewer/floorplan-preview'
|
|
286
|
+
export { useViewerCameraNavigationSync } from './components/viewer/use-viewer-camera-navigation-sync'
|
|
277
287
|
export {
|
|
278
288
|
ViewerControlsBar,
|
|
279
289
|
type ViewerControlsBarProps,
|
|
@@ -282,6 +292,19 @@ export {
|
|
|
282
292
|
ViewerSceneHeader,
|
|
283
293
|
type ViewerSceneHeaderProps,
|
|
284
294
|
} from './components/viewer/viewer-scene-header'
|
|
295
|
+
export { ViewerStage, type ViewerStageProps } from './components/viewer/viewer-stage'
|
|
296
|
+
export {
|
|
297
|
+
normalizeViewerStageModes,
|
|
298
|
+
resolveMobileViewerStageMode,
|
|
299
|
+
resolveViewerStageMode,
|
|
300
|
+
VIEWER_STAGE_MODES,
|
|
301
|
+
viewerStageIncludes3D,
|
|
302
|
+
} from './components/viewer/viewer-stage-modes'
|
|
303
|
+
export {
|
|
304
|
+
type ViewerStageMode,
|
|
305
|
+
ViewerStageSwitcher,
|
|
306
|
+
type ViewerStageSwitcherProps,
|
|
307
|
+
} from './components/viewer/viewer-stage-switcher'
|
|
285
308
|
export {
|
|
286
309
|
WalkthroughHud,
|
|
287
310
|
type WalkthroughHudProps,
|
|
@@ -387,10 +410,14 @@ export {
|
|
|
387
410
|
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
|
388
411
|
export { exportSceneToGlb } from './lib/glb-export'
|
|
389
412
|
export {
|
|
413
|
+
getHistoryCommandState,
|
|
390
414
|
type HistoryCommandDelegate,
|
|
415
|
+
type HistoryCommandResult,
|
|
416
|
+
type HistoryCommandState,
|
|
391
417
|
installHistoryCommandDelegate,
|
|
392
418
|
runRedo,
|
|
393
419
|
runUndo,
|
|
420
|
+
subscribeHistoryCommandState,
|
|
394
421
|
} from './lib/history'
|
|
395
422
|
export {
|
|
396
423
|
boundaryReshapeScope,
|
|
@@ -73,6 +73,14 @@ describe('resolveSelectModeHelpHints', () => {
|
|
|
73
73
|
label: 'Click or drag the selection to move it as one',
|
|
74
74
|
},
|
|
75
75
|
{ keys: ['R / T'], label: 'Rotate the selection ±45°' },
|
|
76
|
+
{
|
|
77
|
+
keys: ['Cmd/Ctrl', 'G'],
|
|
78
|
+
label: 'Group selection (session only)',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
keys: ['Cmd/Ctrl', 'Shift', 'G'],
|
|
82
|
+
label: 'Ungroup session selection',
|
|
83
|
+
},
|
|
76
84
|
{
|
|
77
85
|
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
|
78
86
|
label: 'Add or remove objects from the selection',
|
|
@@ -93,6 +93,14 @@ export function resolveSelectModeHelpHints({
|
|
|
93
93
|
label: 'Click or drag the selection to move it as one',
|
|
94
94
|
})
|
|
95
95
|
hints.push({ keys: [ROTATE_KEYS], label: 'Rotate the selection ±45°' })
|
|
96
|
+
hints.push({
|
|
97
|
+
keys: [COMMAND_KEY, 'G'],
|
|
98
|
+
label: 'Group selection (session only)',
|
|
99
|
+
})
|
|
100
|
+
hints.push({
|
|
101
|
+
keys: [COMMAND_KEY, SHIFT_KEY, 'G'],
|
|
102
|
+
label: 'Ungroup session selection',
|
|
103
|
+
})
|
|
96
104
|
hints.push({
|
|
97
105
|
keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
|
|
98
106
|
label: 'Add or remove objects from the selection',
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { LevelNode, WallNode } from '@pascal-app/core/schema'
|
|
3
|
+
import { buildFloorplanContext } from './floorplan-readonly'
|
|
4
|
+
|
|
5
|
+
const viewState = {
|
|
6
|
+
selected: false,
|
|
7
|
+
unit: 'metric' as const,
|
|
8
|
+
highlighted: false,
|
|
9
|
+
hovered: false,
|
|
10
|
+
moving: false,
|
|
11
|
+
palette: undefined,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('read-only floorplan context', () => {
|
|
15
|
+
test('does not invent siblings for parentless nodes', () => {
|
|
16
|
+
const first = LevelNode.parse({ id: 'level_first', type: 'level' })
|
|
17
|
+
const second = LevelNode.parse({ id: 'level_second', type: 'level' })
|
|
18
|
+
const nodes = { [first.id]: first, [second.id]: second }
|
|
19
|
+
|
|
20
|
+
expect(buildFloorplanContext(first, nodes, viewState).siblings).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('resolves same-kind siblings from the parent child order', () => {
|
|
24
|
+
const first = WallNode.parse({
|
|
25
|
+
id: 'wall_first',
|
|
26
|
+
type: 'wall',
|
|
27
|
+
parentId: 'level_ground',
|
|
28
|
+
start: [0, 0],
|
|
29
|
+
end: [1, 0],
|
|
30
|
+
})
|
|
31
|
+
const second = WallNode.parse({
|
|
32
|
+
id: 'wall_second',
|
|
33
|
+
type: 'wall',
|
|
34
|
+
parentId: 'level_ground',
|
|
35
|
+
start: [1, 0],
|
|
36
|
+
end: [2, 0],
|
|
37
|
+
})
|
|
38
|
+
const level = LevelNode.parse({
|
|
39
|
+
id: 'level_ground',
|
|
40
|
+
type: 'level',
|
|
41
|
+
children: [first.id, second.id],
|
|
42
|
+
})
|
|
43
|
+
const nodes = { [level.id]: level, [first.id]: first, [second.id]: second }
|
|
44
|
+
|
|
45
|
+
expect(buildFloorplanContext(first, nodes, viewState).siblings).toEqual([second])
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId, FloorplanPalette, GeometryContext } from '@pascal-app/core'
|
|
2
|
+
import {
|
|
3
|
+
createFloorplanContextExtensions,
|
|
4
|
+
type FloorplanWallDimensionReference,
|
|
5
|
+
} from './floorplan-extension'
|
|
6
|
+
|
|
7
|
+
export type FloorplanViewState = {
|
|
8
|
+
automaticDimensions?: boolean
|
|
9
|
+
selected: boolean
|
|
10
|
+
unit: 'metric' | 'imperial'
|
|
11
|
+
metricNotation?: 'meters' | 'millimeters'
|
|
12
|
+
purpose?: 'edit' | 'document'
|
|
13
|
+
wallDimensionReference?: FloorplanWallDimensionReference
|
|
14
|
+
highlighted: boolean
|
|
15
|
+
hovered: boolean
|
|
16
|
+
moving: boolean
|
|
17
|
+
palette: FloorplanPalette | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildFloorplanContext(
|
|
21
|
+
node: AnyNode,
|
|
22
|
+
nodes: Record<string, AnyNode>,
|
|
23
|
+
viewState: FloorplanViewState,
|
|
24
|
+
levelData?: unknown,
|
|
25
|
+
): GeometryContext {
|
|
26
|
+
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
27
|
+
const childIds = (node as { children?: AnyNodeId[] }).children
|
|
28
|
+
const children = Array.isArray(childIds)
|
|
29
|
+
? childIds.map((id) => nodes[id]).filter((child): child is AnyNode => child !== undefined)
|
|
30
|
+
: []
|
|
31
|
+
const parentId = node.parentId as AnyNodeId | null
|
|
32
|
+
const parent = parentId ? (nodes[parentId] ?? null) : null
|
|
33
|
+
let siblings: AnyNode[] = []
|
|
34
|
+
if (parent) {
|
|
35
|
+
const parentChildIds = (parent as { children?: AnyNodeId[] }).children
|
|
36
|
+
siblings = Array.isArray(parentChildIds)
|
|
37
|
+
? parentChildIds
|
|
38
|
+
.filter((id) => id !== node.id)
|
|
39
|
+
.map((id) => nodes[id])
|
|
40
|
+
.filter((sibling): sibling is AnyNode => sibling?.type === node.type)
|
|
41
|
+
: Object.values(nodes).filter(
|
|
42
|
+
(candidate) =>
|
|
43
|
+
candidate.id !== node.id &&
|
|
44
|
+
candidate.type === node.type &&
|
|
45
|
+
candidate.parentId === node.parentId,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
resolve,
|
|
51
|
+
children,
|
|
52
|
+
siblings,
|
|
53
|
+
parent,
|
|
54
|
+
levelData,
|
|
55
|
+
extensions: createFloorplanContextExtensions({
|
|
56
|
+
automaticDimensions: viewState.automaticDimensions,
|
|
57
|
+
metricNotation: viewState.metricNotation ?? 'meters',
|
|
58
|
+
purpose: viewState.purpose ?? 'edit',
|
|
59
|
+
wallDimensionReference: viewState.wallDimensionReference,
|
|
60
|
+
}),
|
|
61
|
+
viewState: viewState.palette
|
|
62
|
+
? {
|
|
63
|
+
selected: viewState.selected,
|
|
64
|
+
unit: viewState.unit,
|
|
65
|
+
highlighted: viewState.highlighted,
|
|
66
|
+
hovered: viewState.hovered,
|
|
67
|
+
moving: viewState.moving,
|
|
68
|
+
palette: viewState.palette,
|
|
69
|
+
}
|
|
70
|
+
: undefined,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function floorplanLayerRank(type: string): number {
|
|
75
|
+
switch (type) {
|
|
76
|
+
case 'zone':
|
|
77
|
+
return 0
|
|
78
|
+
case 'slab':
|
|
79
|
+
case 'ceiling':
|
|
80
|
+
return 1
|
|
81
|
+
default:
|
|
82
|
+
return 2
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/lib/history.test.ts
CHANGED
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
LevelNode,
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getHistoryCommandState,
|
|
12
|
+
installHistoryCommandDelegate,
|
|
13
|
+
runRedo,
|
|
14
|
+
runUndo,
|
|
15
|
+
subscribeHistoryCommandState,
|
|
16
|
+
} from './history'
|
|
11
17
|
|
|
12
18
|
type RafFn = (cb: (time: number) => void) => number
|
|
13
19
|
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
|
|
@@ -58,12 +64,28 @@ describe('editor history controller', () => {
|
|
|
58
64
|
})
|
|
59
65
|
|
|
60
66
|
test('delegates undo and redo while a host delegate is installed', () => {
|
|
61
|
-
const undo = mock(() => {})
|
|
62
|
-
const redo = mock(() => {})
|
|
63
|
-
disposeController = installHistoryCommandDelegate({
|
|
67
|
+
const undo = mock(() => ({ kind: 'applied', persistence: 'queued' }) as const)
|
|
68
|
+
const redo = mock(() => ({ kind: 'empty' }) as const)
|
|
69
|
+
disposeController = installHistoryCommandDelegate({
|
|
70
|
+
getState: () => ({
|
|
71
|
+
canRedo: false,
|
|
72
|
+
canUndo: true,
|
|
73
|
+
mode: 'collaborative',
|
|
74
|
+
status: 'syncing',
|
|
75
|
+
}),
|
|
76
|
+
redo,
|
|
77
|
+
subscribe: () => () => {},
|
|
78
|
+
undo,
|
|
79
|
+
})
|
|
64
80
|
|
|
65
|
-
runUndo()
|
|
66
|
-
runRedo()
|
|
81
|
+
expect(runUndo()).toEqual({ kind: 'applied', persistence: 'queued' })
|
|
82
|
+
expect(runRedo()).toEqual({ kind: 'empty' })
|
|
83
|
+
expect(getHistoryCommandState()).toEqual({
|
|
84
|
+
canRedo: false,
|
|
85
|
+
canUndo: true,
|
|
86
|
+
mode: 'collaborative',
|
|
87
|
+
status: 'syncing',
|
|
88
|
+
})
|
|
67
89
|
|
|
68
90
|
expect(undo).toHaveBeenCalledTimes(1)
|
|
69
91
|
expect(redo).toHaveBeenCalledTimes(1)
|
|
@@ -72,20 +94,34 @@ describe('editor history controller', () => {
|
|
|
72
94
|
})
|
|
73
95
|
|
|
74
96
|
test('falls back to standalone Zundo undo and redo when no controller is installed', () => {
|
|
75
|
-
runUndo()
|
|
97
|
+
expect(runUndo()).toEqual({ kind: 'applied', persistence: 'local' })
|
|
76
98
|
expect(levelNumber()).toBe(0)
|
|
77
99
|
expect(useScene.temporal.getState().futureStates).toHaveLength(1)
|
|
78
100
|
|
|
79
|
-
runRedo()
|
|
101
|
+
expect(runRedo()).toEqual({ kind: 'applied', persistence: 'local' })
|
|
80
102
|
expect(levelNumber()).toBe(1)
|
|
81
103
|
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
82
104
|
})
|
|
83
105
|
|
|
84
106
|
test('an older cleanup cannot uninstall a newer controller', () => {
|
|
85
107
|
const firstUndo = mock(() => {})
|
|
86
|
-
const
|
|
108
|
+
const delegate = (undo: () => void) => ({
|
|
109
|
+
getState: () => ({
|
|
110
|
+
canRedo: false,
|
|
111
|
+
canUndo: true,
|
|
112
|
+
mode: 'collaborative' as const,
|
|
113
|
+
status: 'ready' as const,
|
|
114
|
+
}),
|
|
115
|
+
redo: () => ({ kind: 'empty' as const }),
|
|
116
|
+
subscribe: () => () => {},
|
|
117
|
+
undo: () => {
|
|
118
|
+
undo()
|
|
119
|
+
return { kind: 'applied' as const, persistence: 'queued' as const }
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
const stopFirst = installHistoryCommandDelegate(delegate(firstUndo))
|
|
87
123
|
const secondUndo = mock(() => {})
|
|
88
|
-
disposeController = installHistoryCommandDelegate(
|
|
124
|
+
disposeController = installHistoryCommandDelegate(delegate(secondUndo))
|
|
89
125
|
|
|
90
126
|
stopFirst()
|
|
91
127
|
runUndo()
|
|
@@ -93,4 +129,33 @@ describe('editor history controller', () => {
|
|
|
93
129
|
expect(firstUndo).toHaveBeenCalledTimes(0)
|
|
94
130
|
expect(secondUndo).toHaveBeenCalledTimes(1)
|
|
95
131
|
})
|
|
132
|
+
|
|
133
|
+
test('publishes delegate state changes and restores standalone availability on teardown', () => {
|
|
134
|
+
const listeners = new Set<() => void>()
|
|
135
|
+
const observed: string[] = []
|
|
136
|
+
const unsubscribe = subscribeHistoryCommandState(() => {
|
|
137
|
+
observed.push(getHistoryCommandState().mode)
|
|
138
|
+
})
|
|
139
|
+
disposeController = installHistoryCommandDelegate({
|
|
140
|
+
getState: () => ({
|
|
141
|
+
canRedo: false,
|
|
142
|
+
canUndo: true,
|
|
143
|
+
mode: 'collaborative',
|
|
144
|
+
status: 'offline',
|
|
145
|
+
}),
|
|
146
|
+
redo: () => ({ kind: 'empty' }),
|
|
147
|
+
subscribe: (listener) => {
|
|
148
|
+
listeners.add(listener)
|
|
149
|
+
return () => listeners.delete(listener)
|
|
150
|
+
},
|
|
151
|
+
undo: () => ({ kind: 'applied', persistence: 'queued' }),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
for (const listener of listeners) listener()
|
|
155
|
+
disposeController()
|
|
156
|
+
disposeController = () => {}
|
|
157
|
+
unsubscribe()
|
|
158
|
+
|
|
159
|
+
expect(observed).toEqual(['collaborative', 'collaborative', 'standalone'])
|
|
160
|
+
})
|
|
96
161
|
})
|
package/src/lib/history.ts
CHANGED
|
@@ -1,19 +1,66 @@
|
|
|
1
1
|
import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
|
|
2
2
|
|
|
3
|
+
export type HistoryCommandState = {
|
|
4
|
+
canRedo: boolean
|
|
5
|
+
canUndo: boolean
|
|
6
|
+
mode: 'collaborative' | 'standalone'
|
|
7
|
+
status: 'offline' | 'ready' | 'syncing' | 'unavailable'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type HistoryCommandResult =
|
|
11
|
+
| { kind: 'applied'; persistence: 'local' | 'queued' }
|
|
12
|
+
| { kind: 'empty' }
|
|
13
|
+
| { kind: 'unavailable' }
|
|
14
|
+
|
|
3
15
|
export type HistoryCommandDelegate = {
|
|
4
|
-
|
|
5
|
-
redo: () =>
|
|
16
|
+
getState: () => HistoryCommandState
|
|
17
|
+
redo: () => HistoryCommandResult
|
|
18
|
+
subscribe: (listener: () => void) => () => void
|
|
19
|
+
undo: () => HistoryCommandResult
|
|
6
20
|
}
|
|
7
21
|
|
|
8
22
|
let historyCommandDelegate: HistoryCommandDelegate | null = null
|
|
23
|
+
let historyCommandDelegateSubscription: (() => void) | null = null
|
|
24
|
+
const historyCommandListeners = new Set<() => void>()
|
|
9
25
|
|
|
10
26
|
export function installHistoryCommandDelegate(delegate: HistoryCommandDelegate): () => void {
|
|
27
|
+
historyCommandDelegateSubscription?.()
|
|
11
28
|
historyCommandDelegate = delegate
|
|
29
|
+
historyCommandDelegateSubscription = delegate.subscribe(notifyHistoryCommandListeners)
|
|
30
|
+
notifyHistoryCommandListeners()
|
|
31
|
+
return () => {
|
|
32
|
+
if (historyCommandDelegate !== delegate) return
|
|
33
|
+
historyCommandDelegateSubscription?.()
|
|
34
|
+
historyCommandDelegateSubscription = null
|
|
35
|
+
historyCommandDelegate = null
|
|
36
|
+
notifyHistoryCommandListeners()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getHistoryCommandState(): HistoryCommandState {
|
|
41
|
+
if (historyCommandDelegate) return historyCommandDelegate.getState()
|
|
42
|
+
const temporal = useScene.temporal.getState()
|
|
43
|
+
return {
|
|
44
|
+
canRedo: temporal.futureStates.length > 0,
|
|
45
|
+
canUndo: temporal.pastStates.length > 0,
|
|
46
|
+
mode: 'standalone',
|
|
47
|
+
status: 'ready',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function subscribeHistoryCommandState(listener: () => void): () => void {
|
|
52
|
+
historyCommandListeners.add(listener)
|
|
53
|
+
const unsubscribeTemporal = useScene.temporal.subscribe(listener)
|
|
12
54
|
return () => {
|
|
13
|
-
|
|
55
|
+
historyCommandListeners.delete(listener)
|
|
56
|
+
unsubscribeTemporal()
|
|
14
57
|
}
|
|
15
58
|
}
|
|
16
59
|
|
|
60
|
+
function notifyHistoryCommandListeners() {
|
|
61
|
+
for (const listener of [...historyCommandListeners]) listener()
|
|
62
|
+
}
|
|
63
|
+
|
|
17
64
|
function refreshSceneAfterHistoryJump() {
|
|
18
65
|
useLiveNodeOverrides.getState().clearAll()
|
|
19
66
|
useLiveTransforms.getState().clearAll()
|
|
@@ -24,22 +71,20 @@ function refreshSceneAfterHistoryJump() {
|
|
|
24
71
|
}
|
|
25
72
|
}
|
|
26
73
|
|
|
27
|
-
export function runUndo() {
|
|
28
|
-
if (historyCommandDelegate)
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
}
|
|
74
|
+
export function runUndo(): HistoryCommandResult {
|
|
75
|
+
if (historyCommandDelegate) return historyCommandDelegate.undo()
|
|
76
|
+
if (useScene.temporal.getState().pastStates.length === 0) return { kind: 'empty' }
|
|
32
77
|
useScene.temporal.getState().undo()
|
|
33
78
|
refreshSceneAfterHistoryJump()
|
|
79
|
+
return { kind: 'applied', persistence: 'local' }
|
|
34
80
|
}
|
|
35
81
|
|
|
36
|
-
export function runRedo() {
|
|
37
|
-
if (historyCommandDelegate)
|
|
38
|
-
|
|
39
|
-
return
|
|
40
|
-
}
|
|
82
|
+
export function runRedo(): HistoryCommandResult {
|
|
83
|
+
if (historyCommandDelegate) return historyCommandDelegate.redo()
|
|
84
|
+
if (useScene.temporal.getState().futureStates.length === 0) return { kind: 'empty' }
|
|
41
85
|
useScene.temporal.getState().redo()
|
|
42
86
|
refreshSceneAfterHistoryJump()
|
|
87
|
+
return { kind: 'applied', persistence: 'local' }
|
|
43
88
|
}
|
|
44
89
|
|
|
45
90
|
/**
|