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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.4",
|
"version": "0.8.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1877,12 +1877,27 @@ async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
|||||||
const trackMenu = ref<TrackMenuState | null>(null)
|
const trackMenu = ref<TrackMenuState | null>(null)
|
||||||
const trackMenuRef = ref<HTMLDivElement | null>(null)
|
const trackMenuRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||||||
|
if (isTrackEffectivelyLocked(track)) return
|
||||||
|
if (e.shiftKey && track.isParent) {
|
||||||
|
store.toggleLayerSelection(track.layerId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.selectLayer(track.layerId)
|
||||||
|
}
|
||||||
|
|
||||||
function onTrackContextMenu(e: MouseEvent, layerId: string) {
|
function onTrackContextMenu(e: MouseEvent, layerId: string) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||||
if (!layer) return
|
if (!layer) return
|
||||||
store.selectLayer(layerId)
|
// If the right-clicked layer isn't already in the multi-selection,
|
||||||
|
// treat the right-click as a single-select so the menu actions
|
||||||
|
// operate on what the user actually clicked. Multi-selected ids
|
||||||
|
// persist so "Group selected" has a real list to work with.
|
||||||
|
if (!store.selectedLayerIds.includes(layerId)) {
|
||||||
|
store.selectLayer(layerId)
|
||||||
|
}
|
||||||
trackMenu.value = {
|
trackMenu.value = {
|
||||||
kind: 'layer',
|
kind: 'layer',
|
||||||
trackId: layerId,
|
trackId: layerId,
|
||||||
@@ -1959,6 +1974,16 @@ function toggleSeparated() {
|
|||||||
trackMenu.value = null
|
trackMenu.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupSelectedFromMenu() {
|
||||||
|
store.groupSelectedLayers()
|
||||||
|
trackMenu.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertSelectedToClipFromMenu() {
|
||||||
|
store.convertSelectedLayersToClip()
|
||||||
|
trackMenu.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function deleteTrack() {
|
function deleteTrack() {
|
||||||
if (!trackMenu.value) return
|
if (!trackMenu.value) return
|
||||||
if (trackMenu.value.kind === 'layer') {
|
if (trackMenu.value.kind === 'layer') {
|
||||||
@@ -2424,12 +2449,13 @@ onBeforeUnmount(() => {
|
|||||||
<span
|
<span
|
||||||
class="track-name"
|
class="track-name"
|
||||||
:class="{
|
:class="{
|
||||||
selected: track.layerId === store.selectedLayerId && track.isParent,
|
selected:
|
||||||
|
track.isParent && store.selectedLayerIds.includes(track.layerId),
|
||||||
parent: track.isParent,
|
parent: track.isParent,
|
||||||
sub: !track.isParent,
|
sub: !track.isParent,
|
||||||
}"
|
}"
|
||||||
:draggable="track.isParent && !isTrackEffectivelyLocked(track)"
|
:draggable="track.isParent && !isTrackEffectivelyLocked(track)"
|
||||||
@click="!isTrackEffectivelyLocked(track) ? store.selectLayer(track.layerId) : null"
|
@click="onLayerTrackNameClick($event, track)"
|
||||||
@contextmenu="
|
@contextmenu="
|
||||||
track.isParent && !isTrackEffectivelyLocked(track)
|
track.isParent && !isTrackEffectivelyLocked(track)
|
||||||
? onTrackContextMenu($event, track.layerId)
|
? onTrackContextMenu($event, track.layerId)
|
||||||
@@ -2999,6 +3025,22 @@ onBeforeUnmount(() => {
|
|||||||
class="kf-menu-trailing-check"
|
class="kf-menu-trailing-check"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="store.selectedLayerIds.length >= 2"
|
||||||
|
class="kf-menu-item"
|
||||||
|
@click="groupSelectedFromMenu"
|
||||||
|
>
|
||||||
|
<PhFolderPlus :size="12" class="kf-menu-icon" />
|
||||||
|
{{ t('timeline.groupSelected', { n: store.selectedLayerIds.length }) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="store.selectedLayerIds.length >= 1"
|
||||||
|
class="kf-menu-item"
|
||||||
|
@click="convertSelectedToClipFromMenu"
|
||||||
|
>
|
||||||
|
<PhFilmStrip :size="12" class="kf-menu-icon" />
|
||||||
|
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
|
|||||||
@@ -241,6 +241,237 @@ export function registerAllTests(): void {
|
|||||||
await tick()
|
await tick()
|
||||||
expect(s.selectedLayerId).toBe(l.id)
|
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', () => {
|
suite('scene store — keyframes', () => {
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ export const en = {
|
|||||||
confirm: 'Apply',
|
confirm: 'Apply',
|
||||||
},
|
},
|
||||||
ungroup: 'Ungroup',
|
ungroup: 'Ungroup',
|
||||||
|
groupSelected: 'Group {n} selected layers',
|
||||||
|
convertToClip: 'Convert {n} selected to clip',
|
||||||
renameGroup: 'Rename group',
|
renameGroup: 'Rename group',
|
||||||
renameScriptTrack: 'Rename script track',
|
renameScriptTrack: 'Rename script track',
|
||||||
resizeTrackColumn: 'Drag to resize track name column',
|
resizeTrackColumn: 'Drag to resize track name column',
|
||||||
|
|||||||
+180
-1
@@ -1,4 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import { toRaw } from 'vue'
|
||||||
import type {
|
import type {
|
||||||
Action,
|
Action,
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
@@ -64,7 +65,14 @@ export type ButtonEditMode = 'states' | 'hit'
|
|||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
scene: Scene
|
scene: Scene
|
||||||
|
/** Primary (anchor) selection — drives the Properties panel and
|
||||||
|
* any code that expects a single focused layer. Always matches
|
||||||
|
* the last entry of `selectedLayerIds`. */
|
||||||
selectedLayerId: string | null
|
selectedLayerId: string | null
|
||||||
|
/** Multi-selection. Order is click order — the last entry is the
|
||||||
|
* anchor. Used by group-selected / delete-selected and any
|
||||||
|
* operation that needs the whole set. */
|
||||||
|
selectedLayerIds: string[]
|
||||||
playhead: number
|
playhead: number
|
||||||
playing: boolean
|
playing: boolean
|
||||||
activeClipId: string | null
|
activeClipId: string | null
|
||||||
@@ -259,6 +267,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
state: (): State => ({
|
state: (): State => ({
|
||||||
scene: createBlankScene(),
|
scene: createBlankScene(),
|
||||||
selectedLayerId: null,
|
selectedLayerId: null,
|
||||||
|
selectedLayerIds: [],
|
||||||
playhead: 0,
|
playhead: 0,
|
||||||
playing: false,
|
playing: false,
|
||||||
activeClipId: null,
|
activeClipId: null,
|
||||||
@@ -407,6 +416,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (!order.includes(layer.id)) order.unshift(layer.id)
|
if (!order.includes(layer.id)) order.unshift(layer.id)
|
||||||
}
|
}
|
||||||
this.selectedLayerId = layer.id
|
this.selectedLayerId = layer.id
|
||||||
|
this.selectedLayerIds = [layer.id]
|
||||||
return layer
|
return layer
|
||||||
},
|
},
|
||||||
removeLayer(id: string) {
|
removeLayer(id: string) {
|
||||||
@@ -416,7 +426,12 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (!this.activeClipId && this.scene.trackOrder) {
|
if (!this.activeClipId && this.scene.trackOrder) {
|
||||||
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
|
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
|
||||||
}
|
}
|
||||||
if (this.selectedLayerId === id) this.selectedLayerId = null
|
if (this.selectedLayerId === id) {
|
||||||
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
|
} else if (this.selectedLayerIds.includes(id)) {
|
||||||
|
this.selectedLayerIds = this.selectedLayerIds.filter((x) => x !== id)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
moveLayer(fromIndex: number, toIndex: number) {
|
moveLayer(fromIndex: number, toIndex: number) {
|
||||||
const arr = this.activeLayers
|
const arr = this.activeLayers
|
||||||
@@ -438,6 +453,29 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
},
|
},
|
||||||
selectLayer(id: string | null) {
|
selectLayer(id: string | null) {
|
||||||
this.selectedLayerId = id
|
this.selectedLayerId = id
|
||||||
|
this.selectedLayerIds = id ? [id] : []
|
||||||
|
},
|
||||||
|
/** Shift-click behaviour: add the layer to the selection if it's
|
||||||
|
* not already there; otherwise remove it. The anchor moves to
|
||||||
|
* the most-recently-clicked id so the Properties panel follows
|
||||||
|
* the user's last touch. Layers that can't be selected (locked
|
||||||
|
* directly or through a locked group) are skipped. */
|
||||||
|
toggleLayerSelection(id: string) {
|
||||||
|
const layer = this.activeLayers.find((l) => l.id === id)
|
||||||
|
if (!layer) return
|
||||||
|
if (layer.locked) return
|
||||||
|
for (const g of this.scene.groups ?? []) {
|
||||||
|
if (g.locked && g.childIds.includes(id)) return
|
||||||
|
}
|
||||||
|
const existing = this.selectedLayerIds.indexOf(id)
|
||||||
|
if (existing >= 0) {
|
||||||
|
this.selectedLayerIds.splice(existing, 1)
|
||||||
|
this.selectedLayerId =
|
||||||
|
this.selectedLayerIds[this.selectedLayerIds.length - 1] ?? null
|
||||||
|
} else {
|
||||||
|
this.selectedLayerIds.push(id)
|
||||||
|
this.selectedLayerId = id
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setPlayhead(t: number) {
|
setPlayhead(t: number) {
|
||||||
this.playhead = Math.max(0, Math.min(this.activeDuration, t))
|
this.playhead = Math.max(0, Math.min(this.activeDuration, t))
|
||||||
@@ -1269,15 +1307,18 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
const sel = this.activeLayers.find((l) => l.id === id)
|
const sel = this.activeLayers.find((l) => l.id === id)
|
||||||
if (!sel) {
|
if (!sel) {
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (sel.locked) {
|
if (sel.locked) {
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const g of this.scene.groups ?? []) {
|
for (const g of this.scene.groups ?? []) {
|
||||||
if (g.locked && g.childIds.includes(id)) {
|
if (g.locked && g.childIds.includes(id)) {
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1425,6 +1466,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
this.activeButtonState = 'normal'
|
this.activeButtonState = 'normal'
|
||||||
this.activeClipId = null
|
this.activeClipId = null
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
this.playhead = BUTTON_STATE_TIMES.normal
|
this.playhead = BUTTON_STATE_TIMES.normal
|
||||||
this.playing = false
|
this.playing = false
|
||||||
},
|
},
|
||||||
@@ -1432,6 +1474,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (!this.activeButtonAssetId) return
|
if (!this.activeButtonAssetId) return
|
||||||
this.activeButtonEditMode = mode
|
this.activeButtonEditMode = mode
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
if (mode === 'states') {
|
if (mode === 'states') {
|
||||||
// Landing back in states-mode: keep the last content state (or
|
// Landing back in states-mode: keep the last content state (or
|
||||||
// default to normal) and sync the playhead to its kf-time.
|
// default to normal) and sync the playhead to its kf-time.
|
||||||
@@ -1453,12 +1496,14 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
// state's fixed time so existing kf mutation paths target it.
|
// state's fixed time so existing kf mutation paths target it.
|
||||||
this.playhead = BUTTON_STATE_TIMES[state]
|
this.playhead = BUTTON_STATE_TIMES[state]
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
},
|
},
|
||||||
exitButtonAssetEdit() {
|
exitButtonAssetEdit() {
|
||||||
this.activeButtonAssetId = null
|
this.activeButtonAssetId = null
|
||||||
this.activeButtonEditMode = null
|
this.activeButtonEditMode = null
|
||||||
this.activeButtonState = null
|
this.activeButtonState = null
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
this.playhead = 0
|
this.playhead = 0
|
||||||
this.playing = false
|
this.playing = false
|
||||||
},
|
},
|
||||||
@@ -1571,6 +1616,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
loadScene(scene: Scene) {
|
loadScene(scene: Scene) {
|
||||||
this.scene = normalizeScene(scene)
|
this.scene = normalizeScene(scene)
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
this.playhead = 0
|
this.playhead = 0
|
||||||
this.playing = false
|
this.playing = false
|
||||||
this.activeClipId = null
|
this.activeClipId = null
|
||||||
@@ -1589,6 +1635,89 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (!this.scene.clipTracks?.length) this.addClipTrack()
|
if (!this.scene.clipTracks?.length) this.addClipTrack()
|
||||||
return clip
|
return clip
|
||||||
},
|
},
|
||||||
|
/** Pull the currently-selected (unlocked, top-level) layers out of
|
||||||
|
* `scene.layers`, pack them into a brand-new Clip, and drop a
|
||||||
|
* single ClipInstance on a fresh ClipTrack at the topmost
|
||||||
|
* selected track's stack position. The clip instance starts at
|
||||||
|
* `startTime = 0` and shares the scene duration — so layer kfs
|
||||||
|
* keep their absolute times and on-stage visuals are unchanged.
|
||||||
|
* Returns the new clip id, or null if the selection is empty. */
|
||||||
|
convertSelectedLayersToClip(name?: string): string | null {
|
||||||
|
const ids = this.selectedLayerIds.filter((id) => {
|
||||||
|
const layer = this.scene.layers.find((l) => l.id === id)
|
||||||
|
if (!layer) return false
|
||||||
|
if (layer.locked) return false
|
||||||
|
for (const g of this.scene.groups ?? []) {
|
||||||
|
if (g.locked && g.childIds.includes(id)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (ids.length === 0) return null
|
||||||
|
|
||||||
|
// Topmost index in trackOrder among the layers we're about to
|
||||||
|
// fold away. If a selected layer is already inside a group it
|
||||||
|
// doesn't appear in trackOrder directly, so it can't anchor
|
||||||
|
// the new clipTrack's position — we fall back to "top" if
|
||||||
|
// nothing anchors.
|
||||||
|
const order = (this.scene.trackOrder ||= [])
|
||||||
|
let insertAt = Infinity
|
||||||
|
for (const id of ids) {
|
||||||
|
const i = order.indexOf(id)
|
||||||
|
if (i >= 0 && i < insertAt) insertAt = i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep-clone the layers so the clip owns independent kf/base
|
||||||
|
// data. `toRaw` strips the Vue/Pinia reactive proxy first so
|
||||||
|
// `structuredClone` sees the underlying plain object graph
|
||||||
|
// (structuredClone refuses to walk a Vue proxy's invisible
|
||||||
|
// internals in some edge cases).
|
||||||
|
const clonedLayers = ids
|
||||||
|
.map((id) => this.scene.layers.find((l) => l.id === id))
|
||||||
|
.filter((l): l is Layer => !!l)
|
||||||
|
.map((l) => structuredClone(toRaw(l)) as Layer)
|
||||||
|
|
||||||
|
const clip: Clip = {
|
||||||
|
id: uid(),
|
||||||
|
name: name?.trim() || 'Clip',
|
||||||
|
duration: this.scene.duration,
|
||||||
|
layers: clonedLayers,
|
||||||
|
}
|
||||||
|
;(this.scene.clips ||= []).push(clip)
|
||||||
|
|
||||||
|
// Strip the originals out of scene.layers, trackOrder, and any
|
||||||
|
// group they were part of. Clearing in one pass keeps Pinia's
|
||||||
|
// reactivity from thrashing on intermediate states.
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
this.scene.layers = this.scene.layers.filter((l) => !idSet.has(l.id))
|
||||||
|
this.scene.trackOrder = order.filter((x) => !idSet.has(x))
|
||||||
|
for (const g of this.scene.groups ?? []) {
|
||||||
|
g.childIds = g.childIds.filter((x) => !idSet.has(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh clip track, sat at the anchor slot instead of
|
||||||
|
// appended to the end (addClipTrack's default).
|
||||||
|
const track: ClipTrack = { id: uid() }
|
||||||
|
;(this.scene.clipTracks ||= []).push(track)
|
||||||
|
const refreshedOrder = (this.scene.trackOrder ||= [])
|
||||||
|
if (insertAt === Infinity || insertAt > refreshedOrder.length) {
|
||||||
|
refreshedOrder.push(track.id)
|
||||||
|
} else {
|
||||||
|
refreshedOrder.splice(insertAt, 0, track.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance: ClipInstance = {
|
||||||
|
id: uid(),
|
||||||
|
clipId: clip.id,
|
||||||
|
clipTrackId: track.id,
|
||||||
|
startTime: 0,
|
||||||
|
}
|
||||||
|
;(this.scene.clipInstances ||= []).push(instance)
|
||||||
|
|
||||||
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
|
|
||||||
|
return clip.id
|
||||||
|
},
|
||||||
removeClip(id: string) {
|
removeClip(id: string) {
|
||||||
if (this.scene.clips) {
|
if (this.scene.clips) {
|
||||||
this.scene.clips = this.scene.clips.filter((c) => c.id !== id)
|
this.scene.clips = this.scene.clips.filter((c) => c.id !== id)
|
||||||
@@ -1599,6 +1728,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (this.activeClipId === id) {
|
if (this.activeClipId === id) {
|
||||||
this.activeClipId = null
|
this.activeClipId = null
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
this.playhead = 0
|
this.playhead = 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1689,6 +1819,53 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
;(this.scene.trackOrder ||= []).unshift(group.id)
|
;(this.scene.trackOrder ||= []).unshift(group.id)
|
||||||
return group
|
return group
|
||||||
},
|
},
|
||||||
|
/** Create a new group and move every currently-selected layer
|
||||||
|
* into it. Returns the new group's id, or null if no layers are
|
||||||
|
* selected. Skips layers that are locked (directly or via a
|
||||||
|
* parent group). The new group replaces the topmost selected
|
||||||
|
* track's position in the stack — so grouping a run of layers
|
||||||
|
* sitting in the middle of the timeline doesn't jump the whole
|
||||||
|
* group up to the top. */
|
||||||
|
groupSelectedLayers(name?: string): string | null {
|
||||||
|
const ids = this.selectedLayerIds.filter((id) => {
|
||||||
|
const layer = this.scene.layers.find((l) => l.id === id)
|
||||||
|
if (!layer) return false
|
||||||
|
if (layer.locked) return false
|
||||||
|
for (const g of this.scene.groups ?? []) {
|
||||||
|
if (g.locked && g.childIds.includes(id)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (ids.length === 0) return null
|
||||||
|
|
||||||
|
// Compute the topmost (smallest) trackOrder index among the
|
||||||
|
// selected layers BEFORE we touch anything. This is where the
|
||||||
|
// new group will land. Anything outside trackOrder (layer is
|
||||||
|
// already inside another group) doesn't constrain the anchor.
|
||||||
|
const order = (this.scene.trackOrder ||= [])
|
||||||
|
let insertAt = Infinity
|
||||||
|
for (const id of ids) {
|
||||||
|
const i = order.indexOf(id)
|
||||||
|
if (i >= 0 && i < insertAt) insertAt = i
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = this.addTrackGroup(name)
|
||||||
|
// addTrackGroup unshifted the group onto trackOrder[0] — pluck
|
||||||
|
// it back out and splice into the anchor slot. The layers sit
|
||||||
|
// at their original indices at this point; moveTrackIntoGroup
|
||||||
|
// below removes them from trackOrder one by one, collapsing
|
||||||
|
// the stack around the group we've just positioned.
|
||||||
|
if (insertAt !== Infinity) {
|
||||||
|
const gIdx = order.indexOf(group.id)
|
||||||
|
if (gIdx >= 0) order.splice(gIdx, 1)
|
||||||
|
order.splice(insertAt, 0, group.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
this.moveTrackIntoGroup(id, group.id)
|
||||||
|
}
|
||||||
|
return group.id
|
||||||
|
},
|
||||||
removeTrackGroup(id: string) {
|
removeTrackGroup(id: string) {
|
||||||
const group = this.scene.groups?.find((g) => g.id === id)
|
const group = this.scene.groups?.find((g) => g.id === id)
|
||||||
if (!group) return
|
if (!group) return
|
||||||
@@ -1754,12 +1931,14 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
this.activeButtonEditMode = null
|
this.activeButtonEditMode = null
|
||||||
this.activeButtonState = null
|
this.activeButtonState = null
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
this.playhead = 0
|
this.playhead = 0
|
||||||
this.playing = false
|
this.playing = false
|
||||||
},
|
},
|
||||||
exitClip() {
|
exitClip() {
|
||||||
this.activeClipId = null
|
this.activeClipId = null
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
this.playhead = 0
|
this.playhead = 0
|
||||||
this.playing = false
|
this.playing = false
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.8.4'
|
export const APP_VERSION = '0.8.5'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
@@ -157,12 +157,18 @@ kann man bei den button-states 200ms transitions einbauen?
|
|||||||
|
|
||||||
elemente in einer gelockten gruppe kann man bearbeiten. bearbeitungsmöglichkeit muss sofort entzogen werden wenn eine gruppe gesperrt ist
|
elemente in einer gelockten gruppe kann man bearbeiten. bearbeitungsmöglichkeit muss sofort entzogen werden wenn eine gruppe gesperrt ist
|
||||||
|
|
||||||
|
wir brauchen mehrfachselection von tracks (shift-click) und gruppiegen. also rechtsklick -> group
|
||||||
|
|
||||||
|
wir brauchen mehrfachselection von tracks und konvertierung in clip. also rechtsklick -> convert to clip
|
||||||
|
dann werden die tracks direkt als neuer clip an ort und stelle eingefügt
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
----
|
----
|
||||||
|
|
||||||
|
|
||||||
wir brauchen mehrfachselection von tracks (shift-click) und gruppiegen. also rechtsklick -> group
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user