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

+1 -1
View File
@@ -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",
+45 -3
View File
@@ -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>
+231
View File
@@ -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', () => {
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+7 -1
View File
@@ -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