@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.
Files changed (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. 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
- let lastNodeCount = Object.keys(useScene.getState().nodes).length
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
- if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
130
+ const previousNodeCount = storedNodeCount.count
131
+ if (!storedNodeCount.allowWrite(currentNodeCount)) {
97
132
  console.warn(
98
- `[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
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
- if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
226
+ const previousNodeCount = storedNodeCount.count
227
+ if (!storedNodeCount.allowWrite(currentNodeCount)) {
192
228
  console.warn(
193
- `[autosave] Blocked unload flush: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
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 (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
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
+ }
@@ -7,7 +7,13 @@ import {
7
7
  LevelNode,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
- import { installHistoryCommandDelegate, runRedo, runUndo } from './history'
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({ undo, redo })
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 stopFirst = installHistoryCommandDelegate({ undo: firstUndo, redo: () => {} })
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({ undo: secondUndo, redo: () => {} })
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
  })
@@ -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
- undo: () => void
5
- redo: () => void
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
- if (historyCommandDelegate === delegate) historyCommandDelegate = null
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
- historyCommandDelegate.undo()
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
- historyCommandDelegate.redo()
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
  /**