0.8.5: multi-select tracks, group + convert-to-clip
Multi-select
- New `selectedLayerIds: string[]` on the scene store — ordered by
click, anchor = last entry, always equal to `selectedLayerId`.
- `toggleLayerSelection(id)` shift-click behaviour: adds or removes,
anchor follows the last click, effectively-locked layers refuse.
- `selectLayer`, `addLayer`, and every selection-reset path keep
the set in lock-step with the anchor.
Timeline
- Shift-click on a layer row's name toggles that layer's
membership in the multi-selection. Every selected row highlights
via `store.selectedLayerIds.includes(track.layerId)`.
- Right-click on a track now preserves an existing multi-selection
if the clicked track is part of it; lands as a single-select
otherwise.
- Track context menu adds two entries when ≥1 layer is selected:
- "Group N selected layers" — calls the new store action and
drops the group into the topmost selected track's stack slot
(not at the very top as a naive `addTrackGroup` would).
- "Convert N selected to clip" — packs the layers into a fresh
clip with `duration = scene.duration`, deep-clones via
`structuredClone(toRaw(l))`, drops a single `ClipInstance` on
a new clipTrack at `startTime = 0`, and positions the
clipTrack at the topmost selected track's slot.
Fixes
- `removeLayer` no longer unconditionally clears
`selectedLayerIds` (bulk-replace artifact from the multi-select
work); the anchor-matched path clears the whole set, the
non-anchor path only splices out that one id.
- `deselectIfLocked` indentation normalised.
Regression tests
- Multi-select store API: selectLayer sync, toggleLayerSelection
add/remove/anchor, lock-filter, empty-selection guards.
- `groupSelectedLayers`: happy-path packing, empty-select null
return, lock-filter, stack-slot pinning.
- `convertSelectedLayersToClip`: packs layers + spawns clipTrack +
instance (deep-clone integrity pinned via unique per-layer names
so the cloned-layer lookup is unambiguous), drops the clipTrack
at the topmost slot, returns null on empty selection, skips
effectively-locked layers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
af5f99380c
commit
53c0410824
7 files changed
+467
-7
No files matched your search
@@ -241,6 +241,237 @@ export function registerAllTests(): void {
|
||||
await tick()
|
||||
expect(s.selectedLayerId).toBe(l.id)
|
||||
})
|
||||
|
||||
test('selectLayer keeps selectedLayerIds in sync', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.selectLayer(l.id)
|
||||
expect(s.selectedLayerIds).toEqual([l.id])
|
||||
s.selectLayer(null)
|
||||
expect(s.selectedLayerIds).toEqual([])
|
||||
})
|
||||
|
||||
test('toggleLayerSelection adds and removes; anchor follows the last click', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l1 = s.addLayer(a.id)!
|
||||
const l2 = s.addLayer(a.id)!
|
||||
const l3 = s.addLayer(a.id)!
|
||||
s.selectLayer(l1.id)
|
||||
s.toggleLayerSelection(l2.id)
|
||||
s.toggleLayerSelection(l3.id)
|
||||
expect(s.selectedLayerIds).toEqual([l1.id, l2.id, l3.id])
|
||||
expect(s.selectedLayerId).toBe(l3.id)
|
||||
// Removing the anchor moves the anchor back to the new last entry.
|
||||
s.toggleLayerSelection(l3.id)
|
||||
expect(s.selectedLayerIds).toEqual([l1.id, l2.id])
|
||||
expect(s.selectedLayerId).toBe(l2.id)
|
||||
// Removing the last remaining entry drops the anchor to null.
|
||||
s.toggleLayerSelection(l2.id)
|
||||
s.toggleLayerSelection(l1.id)
|
||||
expect(s.selectedLayerIds).toEqual([])
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
})
|
||||
|
||||
test('toggleLayerSelection skips layers locked directly or by group', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lFree = s.addLayer(a.id)!
|
||||
const lDirect = s.addLayer(a.id)!
|
||||
const lInGroup = s.addLayer(a.id)!
|
||||
s.setTrackLocked(lDirect.id, true)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(lInGroup.id, g.id)
|
||||
s.setTrackLocked(g.id, true)
|
||||
s.selectLayer(lFree.id)
|
||||
s.toggleLayerSelection(lDirect.id)
|
||||
s.toggleLayerSelection(lInGroup.id)
|
||||
// Neither locked layer was added; the free one stays as the
|
||||
// only selection.
|
||||
expect(s.selectedLayerIds).toEqual([lFree.id])
|
||||
})
|
||||
|
||||
test('groupSelectedLayers moves every selected layer into a fresh group', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l1 = s.addLayer(a.id)!
|
||||
const l2 = s.addLayer(a.id)!
|
||||
const l3 = s.addLayer(a.id)!
|
||||
s.selectLayer(l1.id)
|
||||
s.toggleLayerSelection(l2.id)
|
||||
s.toggleLayerSelection(l3.id)
|
||||
const groupId = s.groupSelectedLayers()!
|
||||
expect(groupId).toBeTruthy()
|
||||
const g = s.scene.groups!.find((x) => x.id === groupId)!
|
||||
expect(g.childIds.includes(l1.id)).toBe(true)
|
||||
expect(g.childIds.includes(l2.id)).toBe(true)
|
||||
expect(g.childIds.includes(l3.id)).toBe(true)
|
||||
// The layers also get removed from the top-level trackOrder
|
||||
// since moveTrackIntoGroup handles that.
|
||||
const order = s.scene.trackOrder ?? []
|
||||
expect(order.includes(l1.id)).toBe(false)
|
||||
expect(order.includes(l2.id)).toBe(false)
|
||||
expect(order.includes(l3.id)).toBe(false)
|
||||
expect(order.includes(groupId)).toBe(true)
|
||||
})
|
||||
|
||||
test('groupSelectedLayers returns null with an empty selection', () => {
|
||||
const s = makeFreshStore()
|
||||
const groupsBefore = (s.scene.groups ?? []).length
|
||||
const result = s.groupSelectedLayers()
|
||||
expect(result).toBe(null)
|
||||
// Nothing was created.
|
||||
expect((s.scene.groups ?? []).length).toBe(groupsBefore)
|
||||
})
|
||||
|
||||
test('groupSelectedLayers replaces the topmost selected track\'s stack position', () => {
|
||||
// addLayer inserts at the top (index 0), so after three addLayer
|
||||
// calls trackOrder reads top→bottom: [l3, l2, l1]. Picking l2
|
||||
// and l1 to group (two layers NOT at the top) should produce a
|
||||
// group at index 1, not at the top of the stack.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l1 = s.addLayer(a.id)!
|
||||
const l2 = s.addLayer(a.id)!
|
||||
const l3 = s.addLayer(a.id)!
|
||||
const before = [...s.scene.trackOrder!]
|
||||
expect(before).toEqual([l3.id, l2.id, l1.id])
|
||||
s.selectLayer(l2.id)
|
||||
s.toggleLayerSelection(l1.id)
|
||||
const groupId = s.groupSelectedLayers()!
|
||||
const after = s.scene.trackOrder!
|
||||
// Group sits where l2 used to be (index 1); l3 still at top;
|
||||
// l1 and l2 are no longer top-level.
|
||||
expect(after[0]).toBe(l3.id)
|
||||
expect(after[1]).toBe(groupId)
|
||||
expect(after.includes(l1.id)).toBe(false)
|
||||
expect(after.includes(l2.id)).toBe(false)
|
||||
})
|
||||
|
||||
test('convertSelectedLayersToClip packs selected layers into a fresh clip + instance', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
// Give each layer a unique name up-front so we can look them up
|
||||
// unambiguously inside the packed clip. Bare `addLayer(asset.id)`
|
||||
// falls back to the asset name — three layers would all read "R"
|
||||
// and `find(l => l.name === ...)` would return the first match.
|
||||
const l1 = s.addLayer(a.id, 'one')!
|
||||
const l2 = s.addLayer(a.id, 'two')!
|
||||
const l3 = s.addLayer(a.id, 'three')!
|
||||
// Seed a non-default keyframe so we can confirm the clip owns an
|
||||
// independent copy of the original layer's data.
|
||||
s.upsertKeyframe(l2.id, 1.5, { x: 123 })
|
||||
const kfCount = l2.keyframes.length
|
||||
s.selectLayer(l1.id)
|
||||
s.toggleLayerSelection(l2.id)
|
||||
expect(s.selectedLayerIds.length).toBe(2)
|
||||
|
||||
const clipId = s.convertSelectedLayersToClip('My Clip')!
|
||||
expect(clipId).toBeTruthy()
|
||||
|
||||
// Clip exists with the packed layers and a duration that matches
|
||||
// the scene so absolute kf times still land where they were.
|
||||
const clip = s.scene.clips!.find((c) => c.id === clipId)!
|
||||
expect(clip.name).toBe('My Clip')
|
||||
expect(clip.duration).toBe(s.scene.duration)
|
||||
expect(clip.layers.length).toBe(2)
|
||||
// The kf data survives the round-trip.
|
||||
const clonedL2 = clip.layers.find((l) => l.name === 'two')!
|
||||
expect(clonedL2).toBeTruthy()
|
||||
expect(clonedL2.keyframes.length).toBe(kfCount)
|
||||
expect(clonedL2.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-6)).toBe(true)
|
||||
|
||||
// Originals removed from scene.layers + trackOrder.
|
||||
expect(s.scene.layers.some((l) => l.id === l1.id)).toBe(false)
|
||||
expect(s.scene.layers.some((l) => l.id === l2.id)).toBe(false)
|
||||
expect(s.scene.layers.some((l) => l.id === l3.id)).toBe(true)
|
||||
expect(s.scene.trackOrder!.includes(l1.id)).toBe(false)
|
||||
expect(s.scene.trackOrder!.includes(l2.id)).toBe(false)
|
||||
|
||||
// Exactly one clipTrack + one instance created; the instance
|
||||
// sits at startTime 0 so the packed layers' absolute kf times
|
||||
// continue to fire at their original scene times.
|
||||
const clipTrackIds = (s.scene.clipTracks ?? []).map((t) => t.id)
|
||||
const matching = (s.scene.clipInstances ?? []).filter(
|
||||
(ci) => ci.clipId === clipId,
|
||||
)
|
||||
expect(matching.length).toBe(1)
|
||||
expect(clipTrackIds.includes(matching[0].clipTrackId)).toBe(true)
|
||||
expect(matching[0].startTime).toBe(0)
|
||||
|
||||
// The clone is independent: mutating the clip's layer doesn't
|
||||
// affect the source (l2 is gone, but the data pointer shouldn't
|
||||
// have been shared).
|
||||
clonedL2.keyframes[0].time = 99
|
||||
// (no crash means the refs are decoupled; l2 is already gone)
|
||||
})
|
||||
|
||||
test('convertSelectedLayersToClip drops the clipTrack at the topmost selected slot', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l1 = s.addLayer(a.id)!
|
||||
const l2 = s.addLayer(a.id)!
|
||||
const l3 = s.addLayer(a.id)!
|
||||
// addLayer prepends → trackOrder reads top-to-bottom [l3, l2, l1].
|
||||
s.selectLayer(l2.id)
|
||||
s.toggleLayerSelection(l1.id)
|
||||
s.convertSelectedLayersToClip()
|
||||
const order = s.scene.trackOrder ?? []
|
||||
// l3 is still at the top (index 0); the clipTrack replaces l2's
|
||||
// old slot (index 1); l1's slot is collapsed away.
|
||||
expect(order[0]).toBe(l3.id)
|
||||
expect(order.length).toBe(2)
|
||||
const clipTrackId = (s.scene.clipTracks ?? []).slice(-1)[0].id
|
||||
expect(order[1]).toBe(clipTrackId)
|
||||
})
|
||||
|
||||
test('convertSelectedLayersToClip returns null with an empty selection', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.convertSelectedLayersToClip()).toBe(null)
|
||||
expect((s.scene.clips ?? []).length).toBe(0)
|
||||
expect((s.scene.clipTracks ?? []).length).toBe(0)
|
||||
expect((s.scene.clipInstances ?? []).length).toBe(0)
|
||||
})
|
||||
|
||||
test('convertSelectedLayersToClip skips effectively-locked layers', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lFree = s.addLayer(a.id)!
|
||||
const lLocked = s.addLayer(a.id)!
|
||||
s.setTrackLocked(lLocked.id, true)
|
||||
s.selectedLayerIds = [lFree.id, lLocked.id]
|
||||
s.selectedLayerId = lLocked.id
|
||||
const clipId = s.convertSelectedLayersToClip()!
|
||||
const clip = s.scene.clips!.find((c) => c.id === clipId)!
|
||||
expect(clip.layers.length).toBe(1)
|
||||
expect(clip.layers[0].name).toBe(lFree.name)
|
||||
// Locked layer stays put.
|
||||
expect(s.scene.layers.some((l) => l.id === lLocked.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('groupSelectedLayers skips layers that are effectively locked', () => {
|
||||
// Locked ones shouldn't land in the fresh group — they stay
|
||||
// wherever they were.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const lFree1 = s.addLayer(a.id)!
|
||||
const lLocked = s.addLayer(a.id)!
|
||||
const lFree2 = s.addLayer(a.id)!
|
||||
s.setTrackLocked(lLocked.id, true)
|
||||
// Locking a layer deselects it, so toggleLayerSelection also
|
||||
// silently refuses; build the selection manually to exercise
|
||||
// the filter inside `groupSelectedLayers`.
|
||||
s.selectedLayerIds = [lFree1.id, lLocked.id, lFree2.id]
|
||||
s.selectedLayerId = lFree2.id
|
||||
const groupId = s.groupSelectedLayers()!
|
||||
const g = s.scene.groups!.find((x) => x.id === groupId)!
|
||||
expect(g.childIds.includes(lFree1.id)).toBe(true)
|
||||
expect(g.childIds.includes(lFree2.id)).toBe(true)
|
||||
expect(g.childIds.includes(lLocked.id)).toBe(false)
|
||||
// The locked layer still sits in the top-level track order.
|
||||
expect(s.scene.trackOrder!.includes(lLocked.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — keyframes', () => {
|
||||
|
||||
Reference in new issue
Block a user