@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
|
@@ -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
|
+
}
|
|
@@ -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]
|
|
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
|
})
|
package/src/lib/paint-scope.ts
CHANGED
|
@@ -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
|
-
|
|
359
|
-
|
|
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(
|
|
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
|
+
}
|