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:
thandClaude Opus 4.7 committed 2026-04-22 05:19:09 +02:00
1 parent af5f99380c
commit 53c0410824
7 files changed
+467 -7

No files matched your search

+231
View File
@@ -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', () => {