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
+180
-1
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { toRaw } from 'vue'
|
||||
import type {
|
||||
Action,
|
||||
AnimatableProps,
|
||||
@@ -64,7 +65,14 @@ export type ButtonEditMode = 'states' | 'hit'
|
||||
|
||||
interface State {
|
||||
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
|
||||
/** 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
|
||||
playing: boolean
|
||||
activeClipId: string | null
|
||||
@@ -259,6 +267,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
state: (): State => ({
|
||||
scene: createBlankScene(),
|
||||
selectedLayerId: null,
|
||||
selectedLayerIds: [],
|
||||
playhead: 0,
|
||||
playing: false,
|
||||
activeClipId: null,
|
||||
@@ -407,6 +416,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (!order.includes(layer.id)) order.unshift(layer.id)
|
||||
}
|
||||
this.selectedLayerId = layer.id
|
||||
this.selectedLayerIds = [layer.id]
|
||||
return layer
|
||||
},
|
||||
removeLayer(id: string) {
|
||||
@@ -416,7 +426,12 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (!this.activeClipId && this.scene.trackOrder) {
|
||||
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) {
|
||||
const arr = this.activeLayers
|
||||
@@ -438,6 +453,29 @@ export const useSceneStore = defineStore('scene', {
|
||||
},
|
||||
selectLayer(id: string | null) {
|
||||
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) {
|
||||
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)
|
||||
if (!sel) {
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
return
|
||||
}
|
||||
if (sel.locked) {
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
return
|
||||
}
|
||||
for (const g of this.scene.groups ?? []) {
|
||||
if (g.locked && g.childIds.includes(id)) {
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1425,6 +1466,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.activeButtonState = 'normal'
|
||||
this.activeClipId = null
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
this.playhead = BUTTON_STATE_TIMES.normal
|
||||
this.playing = false
|
||||
},
|
||||
@@ -1432,6 +1474,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (!this.activeButtonAssetId) return
|
||||
this.activeButtonEditMode = mode
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
if (mode === 'states') {
|
||||
// Landing back in states-mode: keep the last content state (or
|
||||
// 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.
|
||||
this.playhead = BUTTON_STATE_TIMES[state]
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
},
|
||||
exitButtonAssetEdit() {
|
||||
this.activeButtonAssetId = null
|
||||
this.activeButtonEditMode = null
|
||||
this.activeButtonState = null
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
},
|
||||
@@ -1571,6 +1616,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
loadScene(scene: Scene) {
|
||||
this.scene = normalizeScene(scene)
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
this.activeClipId = null
|
||||
@@ -1589,6 +1635,89 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (!this.scene.clipTracks?.length) this.addClipTrack()
|
||||
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) {
|
||||
if (this.scene.clips) {
|
||||
this.scene.clips = this.scene.clips.filter((c) => c.id !== id)
|
||||
@@ -1599,6 +1728,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (this.activeClipId === id) {
|
||||
this.activeClipId = null
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
this.playhead = 0
|
||||
}
|
||||
},
|
||||
@@ -1689,6 +1819,53 @@ export const useSceneStore = defineStore('scene', {
|
||||
;(this.scene.trackOrder ||= []).unshift(group.id)
|
||||
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) {
|
||||
const group = this.scene.groups?.find((g) => g.id === id)
|
||||
if (!group) return
|
||||
@@ -1754,12 +1931,14 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.activeButtonEditMode = null
|
||||
this.activeButtonState = null
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
},
|
||||
exitClip() {
|
||||
this.activeClipId = null
|
||||
this.selectedLayerId = null
|
||||
this.selectedLayerIds = []
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user