@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
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type InspectorCardMode,
4
+ resolveActiveExtension,
5
+ toggleCard,
6
+ toggleExtension,
7
+ } from './inspector-card-mode'
8
+
9
+ const collapsed: InspectorCardMode = { collapsed: true, activeExtensionId: null }
10
+ const regular: InspectorCardMode = { collapsed: false, activeExtensionId: null }
11
+ const engineering: InspectorCardMode = { collapsed: false, activeExtensionId: 'bones:eng' }
12
+
13
+ // EITHER/OR contract (replaces #667's appended-section behavior): the
14
+ // expanded card shows the regular controls OR one extension's content,
15
+ // never both. These gates encode every transition of the mode machine.
16
+
17
+ describe('toggleCard (chevron / header press)', () => {
18
+ test('folded card expands to the REGULAR controls — no extension', () => {
19
+ expect(toggleCard(collapsed)).toEqual(regular)
20
+ })
21
+
22
+ test('regular expanded card folds', () => {
23
+ expect(toggleCard(regular)).toEqual(collapsed)
24
+ })
25
+
26
+ test('extension mode returns to the regular controls, staying expanded', () => {
27
+ // The chevron exits extension mode first; it does NOT fold from there.
28
+ expect(toggleCard(engineering)).toEqual(regular)
29
+ })
30
+ })
31
+
32
+ describe('toggleExtension (header icon press)', () => {
33
+ test('folded card opens straight into extension-only mode', () => {
34
+ expect(toggleExtension(collapsed, 'bones:eng')).toEqual(engineering)
35
+ })
36
+
37
+ test('regular expanded card swaps to extension-only mode', () => {
38
+ expect(toggleExtension(regular, 'bones:eng')).toEqual(engineering)
39
+ })
40
+
41
+ test('pressing the ACTIVE extension icon again returns to regular mode', () => {
42
+ expect(toggleExtension(engineering, 'bones:eng')).toEqual(regular)
43
+ })
44
+
45
+ test('pressing another extension icon switches extension modes directly', () => {
46
+ expect(toggleExtension(engineering, 'other:ext')).toEqual({
47
+ collapsed: false,
48
+ activeExtensionId: 'other:ext',
49
+ })
50
+ })
51
+ })
52
+
53
+ describe('resolveActiveExtension', () => {
54
+ const bones = { id: 'bones:eng' }
55
+ const other = { id: 'other:ext' }
56
+ const extensions = [bones, other]
57
+
58
+ test('null id → regular mode (no extension)', () => {
59
+ expect(resolveActiveExtension(null, extensions)).toBeNull()
60
+ })
61
+
62
+ test('matching id → that extension fills the body', () => {
63
+ expect(resolveActiveExtension('bones:eng', extensions)).toBe(bones)
64
+ expect(resolveActiveExtension('other:ext', extensions)).toBe(other)
65
+ })
66
+
67
+ test('stale id (kind changed / plugin gated off) falls back to regular', () => {
68
+ expect(resolveActiveExtension('bones:eng', [])).toBeNull()
69
+ expect(resolveActiveExtension('gone:ext', extensions)).toBeNull()
70
+ })
71
+ })
72
+
73
+ describe('full user flows (QA script)', () => {
74
+ test('chevron → regular only; fold; icon → extension only; icon again → regular', () => {
75
+ let mode = collapsed
76
+ mode = toggleCard(mode) // chevron
77
+ expect(mode).toEqual(regular)
78
+ mode = toggleCard(mode) // fold
79
+ expect(mode).toEqual(collapsed)
80
+ mode = toggleExtension(mode, 'bones:eng') // bones icon
81
+ expect(mode).toEqual(engineering)
82
+ mode = toggleExtension(mode, 'bones:eng') // bones icon again
83
+ expect(mode).toEqual(regular)
84
+ })
85
+
86
+ test('extension mode exits via the chevron too', () => {
87
+ let mode = toggleExtension(collapsed, 'bones:eng')
88
+ expect(mode).toEqual(engineering)
89
+ mode = toggleCard(mode) // chevron
90
+ expect(mode).toEqual(regular)
91
+ })
92
+ })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Mode machine for the floating inspector card (`PanelWrapper`).
3
+ *
4
+ * The card is in exactly one of three modes — the two expanded modes are
5
+ * EITHER/OR, never combined:
6
+ *
7
+ * - collapsed: header only;
8
+ * - regular: the node kind's own controls (`children`) — no plugin
9
+ * inspector-extension sections appended;
10
+ * - extension: ONE plugin inspector-extension's content fills the body
11
+ * (its own section chrome), the regular controls are hidden.
12
+ *
13
+ * Transitions:
14
+ * - chevron / header press ({@link toggleCard}): collapsed → regular,
15
+ * regular → collapsed, extension → regular (exit extension mode first,
16
+ * stay expanded);
17
+ * - extension icon press ({@link toggleExtension}): enters that
18
+ * extension's mode from anywhere (expanding a folded card); pressing
19
+ * the ACTIVE extension's icon again returns to regular.
20
+ */
21
+
22
+ export interface InspectorCardMode {
23
+ /** Card folded to its header. */
24
+ collapsed: boolean
25
+ /** Extension whose content fills the body; null = regular controls. */
26
+ activeExtensionId: string | null
27
+ }
28
+
29
+ /** Chevron / header press. */
30
+ export function toggleCard(mode: InspectorCardMode): InspectorCardMode {
31
+ // Folded → expand to the regular controls.
32
+ if (mode.collapsed) return { collapsed: false, activeExtensionId: null }
33
+ // Extension mode → back to the regular controls (stay expanded).
34
+ if (mode.activeExtensionId !== null) return { collapsed: false, activeExtensionId: null }
35
+ // Regular expanded → fold.
36
+ return { collapsed: true, activeExtensionId: null }
37
+ }
38
+
39
+ /** Header extension-icon press. */
40
+ export function toggleExtension(mode: InspectorCardMode, extensionId: string): InspectorCardMode {
41
+ // The active extension's icon pressed again → back to the regular controls.
42
+ if (!mode.collapsed && mode.activeExtensionId === extensionId) {
43
+ return { collapsed: false, activeExtensionId: null }
44
+ }
45
+ // Anywhere else (folded, regular, another extension) → this extension only.
46
+ return { collapsed: false, activeExtensionId: extensionId }
47
+ }
48
+
49
+ /**
50
+ * The extension whose content should fill the card body, or null for the
51
+ * regular controls. A stale `activeExtensionId` (selection changed kind,
52
+ * plugin uninstalled, registry reset) safely falls back to regular mode.
53
+ */
54
+ export function resolveActiveExtension<E extends { id: string }>(
55
+ activeExtensionId: string | null,
56
+ extensions: readonly E[],
57
+ ): E | null {
58
+ if (activeExtensionId === null) return null
59
+ return extensions.find((extension) => extension.id === activeExtensionId) ?? null
60
+ }
@@ -1,3 +1,6 @@
1
+ // Must precede the lingo import: lingo clones its kind table with
2
+ // structuredClone while its module body evaluates. See the file's own comment.
3
+ import './structured-clone-fallback'
1
4
  import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
2
5
 
3
6
  /**
@@ -75,6 +75,10 @@ describe('linear measurements', () => {
75
75
  test('formats metric measurements in whole millimeters', () => {
76
76
  expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm')
77
77
  expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm')
78
+ expect(formatLinearMeasurement(0.2, 'metric', 'millimeters')).toBe('200mm')
79
+ expect(formatLinearMeasurement(18.4, 'metric', 'millimeters')).toBe('18400mm')
80
+ expect(formatLinearMeasurement(0.0005, 'metric', 'millimeters')).toBe('1mm')
81
+ expect(formatLinearMeasurement(-0.0005, 'metric', 'millimeters')).toBe('-1mm')
78
82
  })
79
83
 
80
84
  test('formats imperial measurements as feet and inches', () => {
@@ -81,8 +81,8 @@ describe('paintScopeLabel', () => {
81
81
  function item(id: string, assetId: string): ItemNode {
82
82
  return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode
83
83
  }
84
- function slab(id: string, polygon: Array<[number, number]>): SlabNode {
85
- return { id, type: 'slab', polygon } as unknown as SlabNode
84
+ function slab(id: string, polygon: Array<[number, number]>, levelId = 'l1'): SlabNode {
85
+ return { id, type: 'slab', polygon, parentId: levelId } as unknown as SlabNode
86
86
  }
87
87
  function wall(
88
88
  id: string,
@@ -451,4 +451,60 @@ describe('resolvePaintScopeTargets', () => {
451
451
  })
452
452
  expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface'])
453
453
  })
454
+
455
+ it('slab room stops at the level boundary', () => {
456
+ // Stacked storeys share a footprint, so the upper slab's centroid sits inside
457
+ // the ground floor's space polygon. Painting downstairs must not reach it.
458
+ const ground = slab(
459
+ 'ground',
460
+ [
461
+ [1, 1],
462
+ [3, 1],
463
+ [3, 3],
464
+ [1, 3],
465
+ ],
466
+ 'l1',
467
+ )
468
+ const upstairs = slab(
469
+ 'upstairs',
470
+ [
471
+ [1, 1],
472
+ [3, 1],
473
+ [3, 3],
474
+ [1, 3],
475
+ ],
476
+ 'l2',
477
+ )
478
+ const footprint: Array<[number, number]> = [
479
+ [0, 0],
480
+ [10, 0],
481
+ [10, 10],
482
+ [0, 10],
483
+ ]
484
+ const result = resolve({
485
+ node: ground,
486
+ role: 'surface',
487
+ scope: 'room',
488
+ nodes: [ground, upstairs],
489
+ spaces: [
490
+ {
491
+ id: 's1',
492
+ levelId: 'l1',
493
+ polygon: footprint,
494
+ wallIds: [],
495
+ boundaryFaces: [],
496
+ isExterior: false,
497
+ },
498
+ {
499
+ id: 's2',
500
+ levelId: 'l2',
501
+ polygon: footprint,
502
+ wallIds: [],
503
+ boundaryFaces: [],
504
+ isExterior: false,
505
+ },
506
+ ],
507
+ })
508
+ expect(keys(result)).toEqual(['ground:surface'])
509
+ })
454
510
  })
@@ -355,13 +355,19 @@ export function resolvePaintScopeTargets(args: {
355
355
  if (node.type === 'slab' && scope === 'room') {
356
356
  const centroid = polygonCentroid((node as SlabNode).polygon)
357
357
  if (!centroid) return single
358
- const space = Object.values(spaces).find((candidate) =>
359
- pointInPolygon2D(centroid, candidate.polygon),
358
+ // Space polygons are per-level footprints, and stacked storeys share a
359
+ // footprint — so the level has to gate both the space lookup and the fan-out
360
+ // or one click paints the floor above too.
361
+ const levelId = node.parentId ?? resolveLevelId(node, nodes)
362
+ if (!levelId) return single
363
+ const space = Object.values(spaces).find(
364
+ (candidate) => candidate.levelId === levelId && pointInPolygon2D(centroid, candidate.polygon),
360
365
  )
361
366
  if (!space) return single
362
367
  return Object.values(nodes)
363
368
  .filter((other) => {
364
369
  if (other.type !== 'slab') return false
370
+ if ((other.parentId ?? resolveLevelId(other, nodes)) !== levelId) return false
365
371
  const otherCentroid = polygonCentroid((other as SlabNode).polygon)
366
372
  return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
367
373
  })
@@ -30,7 +30,7 @@ describe('resolveSelectedIdsForNodeClick', () => {
30
30
  resolveSelectedIdsForNodeClick({
31
31
  baseSelectedIds: ['wall_1'],
32
32
  currentSelectedIds: [],
33
- modifierKeys: { meta: true, ctrl: false, shift: false },
33
+ modifierKeys: { meta: true, ctrl: false, shift: false, alt: false },
34
34
  nodeId: 'item_1',
35
35
  }),
36
36
  ).toEqual(['wall_1', 'item_1'])
@@ -41,11 +41,22 @@ describe('resolveSelectedIdsForNodeClick', () => {
41
41
  resolveSelectedIdsForNodeClick({
42
42
  baseSelectedIds: ['wall_1', 'item_1'],
43
43
  currentSelectedIds: [],
44
- modifierKeys: { meta: false, ctrl: false, shift: true },
44
+ modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
45
45
  nodeId: 'item_1',
46
46
  }),
47
47
  ).toEqual(['wall_1'])
48
48
  })
49
+
50
+ test('plain click expands session groups', () => {
51
+ expect(
52
+ resolveSelectedIdsForNodeClick({
53
+ currentSelectedIds: [],
54
+ modifierKeys: { meta: false, ctrl: false, shift: false, alt: false },
55
+ nodeId: 'item_2',
56
+ expandIdsForNode: () => ['item_2', 'item_1'],
57
+ }),
58
+ ).toEqual(['item_2', 'item_1'])
59
+ })
49
60
  })
50
61
 
51
62
  describe('emitCanvasNodeSelection', () => {
@@ -64,23 +75,27 @@ describe('emitCanvasNodeSelection', () => {
64
75
 
65
76
  describe('selectionModifiersFromEvent', () => {
66
77
  test('falls back to tracked modifier state when the click event omits keys', () => {
67
- expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({
78
+ expect(
79
+ selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false, alt: false }),
80
+ ).toEqual({
68
81
  meta: false,
69
82
  ctrl: true,
70
83
  shift: false,
84
+ alt: false,
71
85
  })
72
86
  })
73
87
 
74
88
  test('prefers explicit event key state over stale tracked modifiers', () => {
75
89
  expect(
76
90
  selectionModifiersFromEvent(
77
- { metaKey: false, ctrlKey: false, shiftKey: false },
78
- { meta: true, ctrl: true, shift: true },
91
+ { metaKey: false, ctrlKey: false, shiftKey: false, altKey: false },
92
+ { meta: true, ctrl: true, shift: true, alt: true },
79
93
  ),
80
94
  ).toEqual({
81
95
  meta: false,
82
96
  ctrl: false,
83
97
  shift: false,
98
+ alt: false,
84
99
  })
85
100
  })
86
101
  })
@@ -10,6 +10,8 @@ export type SelectionModifierKeys = {
10
10
  meta: boolean
11
11
  ctrl: boolean
12
12
  shift: boolean
13
+ /** Alt alone: select one session-group member without expanding. */
14
+ alt: boolean
13
15
  }
14
16
 
15
17
  export type NodeSelectionTarget = {
@@ -59,17 +61,19 @@ export function selectionModifiersFromEvent(
59
61
  metaKey?: boolean
60
62
  ctrlKey?: boolean
61
63
  shiftKey?: boolean
64
+ altKey?: boolean
62
65
  nativeEvent?: {
63
66
  metaKey?: boolean
64
67
  ctrlKey?: boolean
65
68
  shiftKey?: boolean
69
+ altKey?: boolean
66
70
  }
67
71
  } | null,
68
72
  fallback?: Partial<SelectionModifierKeys>,
69
73
  ): SelectionModifierKeys {
70
74
  const fromEvent = (
71
75
  key: keyof SelectionModifierKeys,
72
- eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey',
76
+ eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey',
73
77
  ) => {
74
78
  if (typeof event?.[eventKey] === 'boolean') return event[eventKey]
75
79
  if (typeof event?.nativeEvent?.[eventKey] === 'boolean') return event.nativeEvent[eventKey]
@@ -80,6 +84,7 @@ export function selectionModifiersFromEvent(
80
84
  meta: fromEvent('meta', 'metaKey'),
81
85
  ctrl: fromEvent('ctrl', 'ctrlKey'),
82
86
  shift: fromEvent('shift', 'shiftKey'),
87
+ alt: fromEvent('alt', 'altKey'),
83
88
  }
84
89
  }
85
90
 
@@ -88,11 +93,14 @@ export function resolveSelectedIdsForNodeClick({
88
93
  currentSelectedIds,
89
94
  modifierKeys,
90
95
  nodeId,
96
+ expandIdsForNode,
91
97
  }: {
92
98
  baseSelectedIds?: readonly string[]
93
99
  currentSelectedIds: readonly string[]
94
100
  modifierKeys: SelectionModifierKeys
95
101
  nodeId: string
102
+ /** Session-group expand on plain click (not on modifier/Alt). */
103
+ expandIdsForNode?: (nodeId: string) => string[] | null
96
104
  }): string[] {
97
105
  if (isSelectionModifierActive(modifierKeys)) {
98
106
  const selectedIds = baseSelectedIds ?? currentSelectedIds
@@ -102,6 +110,12 @@ export function resolveSelectedIdsForNodeClick({
102
110
  return [...selectedIds, nodeId]
103
111
  }
104
112
 
113
+ if (modifierKeys.alt) {
114
+ return [nodeId]
115
+ }
116
+
117
+ const expanded = expandIdsForNode?.(nodeId)
118
+ if (expanded && expanded.length > 1) return expanded
105
119
  return [nodeId]
106
120
  }
107
121
 
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ canCreateSessionGroup,
4
+ createSessionGroup,
5
+ expandSessionGroupMembers,
6
+ liveSessionGroups,
7
+ nextSessionGroupId,
8
+ resetSessionGroupIdSerial,
9
+ type SessionSelectionGroup,
10
+ selectionIntersectsSessionGroup,
11
+ selectionMatchesSessionGroup,
12
+ ungroupSessionSelection,
13
+ } from './session-groups'
14
+
15
+ function group(id: string, memberIds: string[], label = id): SessionSelectionGroup {
16
+ return { id, memberIds, label }
17
+ }
18
+
19
+ describe('session-groups', () => {
20
+ test('createSessionGroup requires two live members', () => {
21
+ resetSessionGroupIdSerial()
22
+ expect(
23
+ createSessionGroup([], ['a'], { idFactory: () => 'g1', labelFactory: () => 'G1' }).created,
24
+ ).toBeNull()
25
+ })
26
+
27
+ test('create and expand and ungroup', () => {
28
+ resetSessionGroupIdSerial(0)
29
+ const { groups, created } = createSessionGroup([], ['a', 'b', 'c'], {
30
+ idFactory: () => 'g1',
31
+ labelFactory: () => 'Group 1',
32
+ })
33
+ expect(created?.memberIds).toEqual(['a', 'b', 'c'])
34
+ expect(expandSessionGroupMembers(groups, 'b')).toEqual(['b', 'a', 'c'])
35
+ expect(selectionMatchesSessionGroup(groups, ['a', 'b', 'c'])?.label).toBe('Group 1')
36
+ expect(canCreateSessionGroup(groups, ['a', 'b', 'c'])).toBe(false)
37
+ expect(canCreateSessionGroup(groups, ['a', 'b'])).toBe(true)
38
+ expect(ungroupSessionSelection(groups, ['a']).groups).toEqual([])
39
+ })
40
+
41
+ test('liveSessionGroups hides groups that fall under two live members', () => {
42
+ resetSessionGroupIdSerial(0)
43
+ expect(nextSessionGroupId()).toBe('session-group-1')
44
+ expect(liveSessionGroups([group('g1', ['a', 'gone'])], new Set(['a']))).toEqual([])
45
+ expect(liveSessionGroups([group('g1', ['a', 'b', 'gone'])], new Set(['a', 'b']))).toEqual([
46
+ group('g1', ['a', 'b']),
47
+ ])
48
+ expect(selectionIntersectsSessionGroup([group('g1', ['a', 'b'])], ['b'])).toBe(true)
49
+ })
50
+
51
+ test('a deleted member is excluded from reads but kept in membership for undo', () => {
52
+ const groups = [group('g1', ['a', 'b', 'c'], 'Suite')]
53
+
54
+ // `a` deleted: reads narrow to the survivors...
55
+ expect(expandSessionGroupMembers(groups, 'b', new Set(['b', 'c']))).toEqual(['b', 'c'])
56
+ // ...and `a` and `b` deleted drops the group below the floor, so it goes inert.
57
+ expect(expandSessionGroupMembers(groups, 'c', new Set(['c']))).toBeNull()
58
+ expect(selectionIntersectsSessionGroup(groups, ['c'], new Set(['c']))).toBe(false)
59
+
60
+ // Undo restores the nodes, and membership was never rewritten — so the whole
61
+ // group comes back. A destructive prune would have lost `a` (or the group).
62
+ expect(expandSessionGroupMembers(groups, 'a', new Set(['a', 'b', 'c']))).toEqual([
63
+ 'a',
64
+ 'b',
65
+ 'c',
66
+ ])
67
+ })
68
+
69
+ test('ungroup leaves inert groups alone so undo can revive them', () => {
70
+ const groups = [group('g1', ['a', 'b'], 'Pair')]
71
+ // Only `a` is live, so the group is invisible to the user — Ctrl+Shift+G on
72
+ // `a` must not dissolve what it cannot see.
73
+ const result = ungroupSessionSelection(groups, ['a'], new Set(['a']))
74
+ expect(result.dissolved).toEqual([])
75
+ expect(result.groups).toEqual(groups)
76
+ })
77
+
78
+ test('grouping a member of an existing group dissolves that group whole', () => {
79
+ resetSessionGroupIdSerial(0)
80
+ const groups = [group('g1', ['a', 'b', 'c'], 'Suite')]
81
+ const { groups: next, created } = createSessionGroup(groups, ['c', 'd'], {
82
+ idFactory: () => 'g2',
83
+ labelFactory: () => 'Group 2',
84
+ })
85
+ expect(created?.memberIds).toEqual(['c', 'd'])
86
+ expect(next).toEqual([group('g2', ['c', 'd'], 'Group 2')])
87
+ })
88
+ })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Pure helpers for editor-only session selection groups.
3
+ * Not scene-graph nodes; not saved with the project.
4
+ * Ctrl/Cmd+G creates, Ctrl/Cmd+Shift+G dissolves; plain click expands.
5
+ *
6
+ * Stored membership is never rewritten to drop deleted nodes — `liveIds`
7
+ * filtering happens on every read instead. A destructive prune would make
8
+ * delete-then-undo lose the restored node's group, and would drop the group
9
+ * outright once it fell under the two-member floor.
10
+ */
11
+
12
+ export type SessionSelectionGroup = {
13
+ id: string
14
+ memberIds: readonly string[]
15
+ label: string
16
+ }
17
+
18
+ export type SessionGroupIdFactory = () => string
19
+
20
+ let sessionGroupSerial = 0
21
+
22
+ export function nextSessionGroupId(): string {
23
+ sessionGroupSerial += 1
24
+ return `session-group-${sessionGroupSerial}`
25
+ }
26
+
27
+ export function nextSessionGroupLabel(): string {
28
+ return `Group ${Math.max(1, sessionGroupSerial)}`
29
+ }
30
+
31
+ export function resetSessionGroupIdSerial(value = 0): void {
32
+ sessionGroupSerial = value
33
+ }
34
+
35
+ function uniquePreserveOrder(ids: readonly string[]): string[] {
36
+ const seen = new Set<string>()
37
+ const out: string[] = []
38
+ for (const id of ids) {
39
+ if (!id || seen.has(id)) continue
40
+ seen.add(id)
41
+ out.push(id)
42
+ }
43
+ return out
44
+ }
45
+
46
+ function isLive(id: string, liveIds?: ReadonlySet<string> | null): boolean {
47
+ if (!liveIds) return true
48
+ return liveIds.has(id)
49
+ }
50
+
51
+ function withLabel(
52
+ group: SessionSelectionGroup,
53
+ memberIds: readonly string[],
54
+ ): SessionSelectionGroup {
55
+ return {
56
+ id: group.id,
57
+ label: group.label || 'Group',
58
+ memberIds,
59
+ }
60
+ }
61
+
62
+ function liveMembers(group: SessionSelectionGroup, liveIds?: ReadonlySet<string> | null): string[] {
63
+ return uniquePreserveOrder(group.memberIds.filter((id) => isLive(id, liveIds)))
64
+ }
65
+
66
+ /**
67
+ * Read-time view: groups narrowed to their live members, dropping any that fall
68
+ * under two. A group below the floor is inert, not gone — deleting members never
69
+ * touches storage, so undo brings it back.
70
+ */
71
+ export function liveSessionGroups(
72
+ groups: readonly SessionSelectionGroup[],
73
+ liveIds?: ReadonlySet<string> | null,
74
+ ): SessionSelectionGroup[] {
75
+ const next: SessionSelectionGroup[] = []
76
+ for (const group of groups) {
77
+ const members = liveMembers(group, liveIds)
78
+ if (members.length < 2) continue
79
+ next.push(withLabel(group, members))
80
+ }
81
+ return next
82
+ }
83
+
84
+ export function createSessionGroup(
85
+ groups: readonly SessionSelectionGroup[],
86
+ memberIds: readonly string[],
87
+ options?: {
88
+ liveIds?: ReadonlySet<string> | null
89
+ idFactory?: SessionGroupIdFactory
90
+ labelFactory?: () => string
91
+ },
92
+ ): {
93
+ groups: SessionSelectionGroup[]
94
+ created: SessionSelectionGroup | null
95
+ alreadyGrouped: boolean
96
+ } {
97
+ const liveIds = options?.liveIds
98
+ const members = uniquePreserveOrder(memberIds.filter((id) => isLive(id, liveIds)))
99
+ if (members.length < 2) {
100
+ return { groups: [...groups], created: null, alreadyGrouped: false }
101
+ }
102
+
103
+ const memberSet = new Set(members)
104
+ const exact = liveSessionGroups(groups, liveIds).find(
105
+ (group) =>
106
+ group.memberIds.length === members.length && group.memberIds.every((id) => memberSet.has(id)),
107
+ )
108
+ if (exact) {
109
+ return { groups: [...groups], created: exact, alreadyGrouped: true }
110
+ }
111
+
112
+ // Grouping a selection takes its members out of whatever group they were in,
113
+ // dissolving that group whole rather than leaving a remnant.
114
+ const kept = groups.filter((group) => !group.memberIds.some((id) => memberSet.has(id)))
115
+ const id = (options?.idFactory ?? nextSessionGroupId)()
116
+ const label =
117
+ options?.labelFactory?.() ??
118
+ (options?.idFactory ? `Group ${kept.length + 1}` : nextSessionGroupLabel())
119
+ const created: SessionSelectionGroup = { id, label, memberIds: members }
120
+ return { groups: [...kept, created], created, alreadyGrouped: false }
121
+ }
122
+
123
+ export function ungroupSessionSelection(
124
+ groups: readonly SessionSelectionGroup[],
125
+ selectedIds: readonly string[],
126
+ liveIds?: ReadonlySet<string> | null,
127
+ ): { groups: SessionSelectionGroup[]; dissolved: SessionSelectionGroup[] } {
128
+ const selected = new Set(selectedIds)
129
+ if (selected.size === 0) {
130
+ return { groups: [...groups], dissolved: [] }
131
+ }
132
+
133
+ const kept: SessionSelectionGroup[] = []
134
+ const dissolved: SessionSelectionGroup[] = []
135
+ for (const group of groups) {
136
+ // Only a live-viable group is visible to the user, so only that can be
137
+ // dissolved. An inert one (members deleted) is left alone for undo.
138
+ const live = liveMembers(group, liveIds)
139
+ if (live.length >= 2 && live.some((id) => selected.has(id))) {
140
+ dissolved.push(withLabel(group, live))
141
+ } else {
142
+ kept.push(group)
143
+ }
144
+ }
145
+ return { groups: kept, dissolved }
146
+ }
147
+
148
+ export function expandSessionGroupMembers(
149
+ groups: readonly SessionSelectionGroup[],
150
+ nodeId: string,
151
+ liveIds?: ReadonlySet<string> | null,
152
+ ): string[] | null {
153
+ if (!nodeId) return null
154
+ for (const group of groups) {
155
+ if (!group.memberIds.includes(nodeId)) continue
156
+ const members = liveMembers(group, liveIds)
157
+ if (members.length < 2) return null
158
+ if (members[0] === nodeId) return members
159
+ return [nodeId, ...members.filter((id) => id !== nodeId)]
160
+ }
161
+ return null
162
+ }
163
+
164
+ export function selectionMatchesSessionGroup(
165
+ groups: readonly SessionSelectionGroup[],
166
+ selectedIds: readonly string[],
167
+ liveIds?: ReadonlySet<string> | null,
168
+ ): SessionSelectionGroup | null {
169
+ if (selectedIds.length < 2) return null
170
+ const selected = uniquePreserveOrder(selectedIds.filter((id) => isLive(id, liveIds)))
171
+ if (selected.length < 2) return null
172
+ const selectedSet = new Set(selected)
173
+ for (const group of liveSessionGroups(groups, liveIds)) {
174
+ if (group.memberIds.length !== selected.length) continue
175
+ if (group.memberIds.every((id) => selectedSet.has(id))) return group
176
+ }
177
+ return null
178
+ }
179
+
180
+ export function selectionIntersectsSessionGroup(
181
+ groups: readonly SessionSelectionGroup[],
182
+ selectedIds: readonly string[],
183
+ liveIds?: ReadonlySet<string> | null,
184
+ ): boolean {
185
+ if (selectedIds.length === 0) return false
186
+ const selected = new Set(selectedIds)
187
+ for (const group of liveSessionGroups(groups, liveIds)) {
188
+ if (group.memberIds.some((id) => selected.has(id))) return true
189
+ }
190
+ return false
191
+ }
192
+
193
+ export function canCreateSessionGroup(
194
+ groups: readonly SessionSelectionGroup[],
195
+ selectedIds: readonly string[],
196
+ liveIds?: ReadonlySet<string> | null,
197
+ ): boolean {
198
+ const live = uniquePreserveOrder(selectedIds.filter((id) => isLive(id, liveIds)))
199
+ if (live.length < 2) return false
200
+ return selectionMatchesSessionGroup(groups, live, liveIds) === null
201
+ }