0.8.7: duplicate track via right-click (layer / clip / script)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d60684edd1
commit
517705bf55
4 files changed
+254
-1
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.6",
|
"version": "0.8.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1998,6 +1998,17 @@ function deleteTrack() {
|
|||||||
trackMenu.value = null
|
trackMenu.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateTrackFromMenu() {
|
||||||
|
if (!trackMenu.value) return
|
||||||
|
const newId = store.duplicateTrack(trackMenu.value.trackId)
|
||||||
|
trackMenu.value = null
|
||||||
|
if (newId && store.scene.layers.some((l) => l.id === newId)) {
|
||||||
|
// Only layers have a meaningful per-track selection model; clip /
|
||||||
|
// script tracks aren't selected in the same sense.
|
||||||
|
store.selectLayer(newId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renameFromMenu() {
|
async function renameFromMenu() {
|
||||||
if (!trackMenu.value) return
|
if (!trackMenu.value) return
|
||||||
if (trackMenu.value.kind === 'group') {
|
if (trackMenu.value.kind === 'group') {
|
||||||
@@ -3043,6 +3054,17 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="trackMenu.kind !== 'group'">
|
||||||
|
<div class="kf-menu-divider"></div>
|
||||||
|
<button
|
||||||
|
class="kf-menu-item"
|
||||||
|
:disabled="trackMenu.locked"
|
||||||
|
@click="duplicateTrackFromMenu"
|
||||||
|
>
|
||||||
|
<PhCopy :size="12" class="kf-menu-icon" />
|
||||||
|
{{ t('timeline.duplicate') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
<button class="kf-menu-item danger" @click="deleteTrack">
|
<button class="kf-menu-item danger" @click="deleteTrack">
|
||||||
<PhTrash :size="12" class="kf-menu-icon" />
|
<PhTrash :size="12" class="kf-menu-icon" />
|
||||||
|
|||||||
@@ -2345,6 +2345,143 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('scene store — duplicateTrack', () => {
|
||||||
|
test('duplicates a layer with fresh keyframe ids right below the original', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'D1', width: 10, height: 10, fill: '#abc' })
|
||||||
|
const l = s.addLayer(a.id, 'Orig')!
|
||||||
|
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||||||
|
s.upsertKeyframe(l.id, 2, { x: 20 })
|
||||||
|
const origKfIds = new Set(l.keyframes.map((k) => k.id))
|
||||||
|
|
||||||
|
const newId = s.duplicateTrack(l.id)
|
||||||
|
expect(newId).toBeTruthy()
|
||||||
|
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||||||
|
expect(dup).toBeTruthy()
|
||||||
|
expect(dup.id === l.id).toBe(false)
|
||||||
|
expect(dup.name).toBe('Orig')
|
||||||
|
expect(dup.assetId).toBe(a.id)
|
||||||
|
expect(dup.keyframes.length).toBe(l.keyframes.length)
|
||||||
|
// Fresh ids for every keyframe so history/undo never aliases.
|
||||||
|
for (const k of dup.keyframes) expect(origKfIds.has(k.id)).toBe(false)
|
||||||
|
// Clone sits immediately after the original in trackOrder.
|
||||||
|
const order = s.scene.trackOrder!
|
||||||
|
expect(order.indexOf(newId!)).toBe(order.indexOf(l.id) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layer duplicate is deeply independent of the original', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'D2', width: 10, height: 10, fill: '#bcd' })
|
||||||
|
const l = s.addLayer(a.id)!
|
||||||
|
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||||||
|
|
||||||
|
const newId = s.duplicateTrack(l.id)!
|
||||||
|
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||||||
|
// Mutate the clone — the original must not see the change.
|
||||||
|
dup.base.x = 999
|
||||||
|
dup.keyframes[0].props.x = 999
|
||||||
|
const origAfter = s.scene.layers.find((x) => x.id === l.id)!
|
||||||
|
expect(origAfter.base.x === 999).toBe(false)
|
||||||
|
expect(origAfter.keyframes[0].props.x === 999).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layer duplicate copies separated position/rotation kf arrays with fresh ids', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'D3', width: 10, height: 10, fill: '#cde' })
|
||||||
|
const l = s.addLayer(a.id)!
|
||||||
|
s.upsertKeyframe(l.id, 1, { x: 5, y: 5, rotation: 45 })
|
||||||
|
s.setLayerSeparated(l.id, true)
|
||||||
|
const before = s.scene.layers.find((x) => x.id === l.id)!
|
||||||
|
const origPosIds = new Set((before.positionKeyframes ?? []).map((k) => k.id))
|
||||||
|
const origRotIds = new Set((before.rotationKeyframes ?? []).map((k) => k.id))
|
||||||
|
|
||||||
|
const newId = s.duplicateTrack(l.id)!
|
||||||
|
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||||||
|
expect(dup.separated).toBe(true)
|
||||||
|
expect((dup.positionKeyframes ?? []).length).toBe((before.positionKeyframes ?? []).length)
|
||||||
|
expect((dup.rotationKeyframes ?? []).length).toBe((before.rotationKeyframes ?? []).length)
|
||||||
|
for (const k of dup.positionKeyframes ?? []) expect(origPosIds.has(k.id)).toBe(false)
|
||||||
|
for (const k of dup.rotationKeyframes ?? []) expect(origRotIds.has(k.id)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicating a grouped layer keeps the clone in the same group', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'D4', width: 10, height: 10, fill: '#def' })
|
||||||
|
const l = s.addLayer(a.id)!
|
||||||
|
const g = s.addTrackGroup('G')
|
||||||
|
s.moveTrackIntoGroup(l.id, g.id)
|
||||||
|
const newId = s.duplicateTrack(l.id)!
|
||||||
|
const group = s.scene.groups!.find((x) => x.id === g.id)!
|
||||||
|
// Duplicate lands in the group, directly after the original.
|
||||||
|
expect(group.childIds.indexOf(newId)).toBe(group.childIds.indexOf(l.id) + 1)
|
||||||
|
// Grouped children never leak into trackOrder.
|
||||||
|
expect((s.scene.trackOrder ?? []).includes(newId)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicates a clip track along with its clip instances', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const clip = s.addClip('C')
|
||||||
|
clip.duration = 5
|
||||||
|
const origTrack = s.addClipTrack('track-A')
|
||||||
|
const ci1 = s.addClipInstance(clip.id, 0, origTrack.id)!
|
||||||
|
const ci2 = s.addClipInstance(clip.id, 2, origTrack.id)!
|
||||||
|
|
||||||
|
const newId = s.duplicateTrack(origTrack.id)
|
||||||
|
expect(newId).toBeTruthy()
|
||||||
|
expect(newId === origTrack.id).toBe(false)
|
||||||
|
const newTrack = s.scene.clipTracks!.find((t) => t.id === newId)!
|
||||||
|
expect(newTrack.name).toBe('track-A')
|
||||||
|
// Original instances untouched.
|
||||||
|
expect(s.scene.clipInstances!.find((ci) => ci.id === ci1.id)!.clipTrackId).toBe(origTrack.id)
|
||||||
|
expect(s.scene.clipInstances!.find((ci) => ci.id === ci2.id)!.clipTrackId).toBe(origTrack.id)
|
||||||
|
// Clone track carries copies with fresh ids and same clipId/startTime.
|
||||||
|
const cloneInstances = s.scene.clipInstances!.filter((ci) => ci.clipTrackId === newId)
|
||||||
|
expect(cloneInstances).toHaveLength(2)
|
||||||
|
const startTimes = cloneInstances.map((ci) => ci.startTime).sort((a, b) => a - b)
|
||||||
|
expect(startTimes).toEqual([0, 2])
|
||||||
|
for (const ci of cloneInstances) {
|
||||||
|
expect(ci.clipId).toBe(clip.id)
|
||||||
|
expect(ci.id === ci1.id).toBe(false)
|
||||||
|
expect(ci.id === ci2.id).toBe(false)
|
||||||
|
}
|
||||||
|
// Slotted right after the original in trackOrder.
|
||||||
|
const order = s.scene.trackOrder!
|
||||||
|
expect(order.indexOf(newId!)).toBe(order.indexOf(origTrack.id) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicates a script track along with its frames', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(10)
|
||||||
|
const origTrack = s.addScriptTrack('triggers')
|
||||||
|
const f1 = s.addScriptFrame(origTrack.id, 1, [{ type: 'pause' }])!
|
||||||
|
const f2 = s.addScriptFrame(origTrack.id, 3, [{ type: 'play' }])!
|
||||||
|
|
||||||
|
const newId = s.duplicateTrack(origTrack.id)!
|
||||||
|
expect(newId === origTrack.id).toBe(false)
|
||||||
|
const newTrack = s.scene.scriptTracks!.find((t) => t.id === newId)!
|
||||||
|
expect(newTrack.name).toBe('triggers')
|
||||||
|
// Original frames untouched.
|
||||||
|
expect(s.scene.scriptFrames!.find((f) => f.id === f1.id)!.trackId).toBe(origTrack.id)
|
||||||
|
expect(s.scene.scriptFrames!.find((f) => f.id === f2.id)!.trackId).toBe(origTrack.id)
|
||||||
|
// Clone has its own frames at the same times with matching actions.
|
||||||
|
const cloneFrames = s.scene.scriptFrames!.filter((f) => f.trackId === newId)
|
||||||
|
expect(cloneFrames).toHaveLength(2)
|
||||||
|
const times = cloneFrames.map((f) => f.time).sort((a, b) => a - b)
|
||||||
|
expect(times).toEqual([1, 3])
|
||||||
|
for (const f of cloneFrames) {
|
||||||
|
expect(f.id === f1.id).toBe(false)
|
||||||
|
expect(f.id === f2.id).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for unknown id and for group ids', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
expect(s.duplicateTrack('nope-no-such-track')).toBe(null)
|
||||||
|
const g = s.addTrackGroup('G')
|
||||||
|
expect(s.duplicateTrack(g.id)).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('app smoke — live DOM', () => {
|
suite('app smoke — live DOM', () => {
|
||||||
test('app root, topbar, timeline, stage are mounted', () => {
|
test('app root, topbar, timeline, stage are mounted', () => {
|
||||||
expect(document.querySelector('.app')).toBeTruthy()
|
expect(document.querySelector('.app')).toBeTruthy()
|
||||||
|
|||||||
@@ -1756,6 +1756,100 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
;(this.scene.trackOrder ||= []).push(track.id)
|
;(this.scene.trackOrder ||= []).push(track.id)
|
||||||
return track
|
return track
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Duplicate a top-level track by id. Handles layer / clipTrack /
|
||||||
|
* scriptTrack — clip instances and script frames belonging to the
|
||||||
|
* original are cloned too. The duplicate lands directly after the
|
||||||
|
* original in trackOrder (or in the same group's childIds if the
|
||||||
|
* original lived inside one). Groups themselves are not supported
|
||||||
|
* here; returns null for unknown ids, clip-edit / button-edit
|
||||||
|
* contexts, and groups.
|
||||||
|
*/
|
||||||
|
duplicateTrack(id: string): string | null {
|
||||||
|
if (this.activeClipId || this.activeButtonAssetId) return null
|
||||||
|
const scene = this.scene
|
||||||
|
|
||||||
|
// Grouped tracks live in TrackGroup.childIds and are absent from
|
||||||
|
// scene.trackOrder entirely (only the group's own id appears at
|
||||||
|
// top level). Duplicate stays inside whichever container held the
|
||||||
|
// original.
|
||||||
|
const placeAfter = (afterId: string, newId: string) => {
|
||||||
|
const order = (scene.trackOrder ||= [])
|
||||||
|
const idx = order.indexOf(afterId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
order.splice(idx + 1, 0, newId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const g of scene.groups ?? []) {
|
||||||
|
const gi = g.childIds.indexOf(afterId)
|
||||||
|
if (gi >= 0) {
|
||||||
|
g.childIds.splice(gi + 1, 0, newId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order.push(newId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = scene.layers.find((l) => l.id === id)
|
||||||
|
if (layer) {
|
||||||
|
const cloned = structuredClone(toRaw(layer)) as Layer
|
||||||
|
cloned.id = uid()
|
||||||
|
cloned.keyframes = cloned.keyframes.map((k) => ({ ...k, id: uid() }))
|
||||||
|
if (cloned.positionKeyframes) {
|
||||||
|
cloned.positionKeyframes = cloned.positionKeyframes.map((k) => ({ ...k, id: uid() }))
|
||||||
|
}
|
||||||
|
if (cloned.rotationKeyframes) {
|
||||||
|
cloned.rotationKeyframes = cloned.rotationKeyframes.map((k) => ({ ...k, id: uid() }))
|
||||||
|
}
|
||||||
|
const origIdx = scene.layers.findIndex((l) => l.id === id)
|
||||||
|
scene.layers.splice(origIdx + 1, 0, cloned)
|
||||||
|
placeAfter(id, cloned.id)
|
||||||
|
return cloned.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipTrack = scene.clipTracks?.find((t) => t.id === id)
|
||||||
|
if (clipTrack) {
|
||||||
|
const newTrack: ClipTrack = {
|
||||||
|
...(structuredClone(toRaw(clipTrack)) as ClipTrack),
|
||||||
|
id: uid(),
|
||||||
|
}
|
||||||
|
;(scene.clipTracks ||= []).push(newTrack)
|
||||||
|
const instances = scene.clipInstances?.filter((ci) => ci.clipTrackId === id) ?? []
|
||||||
|
for (const ci of instances) {
|
||||||
|
const clonedInst: ClipInstance = {
|
||||||
|
...(structuredClone(toRaw(ci)) as ClipInstance),
|
||||||
|
id: uid(),
|
||||||
|
clipTrackId: newTrack.id,
|
||||||
|
}
|
||||||
|
;(scene.clipInstances ||= []).push(clonedInst)
|
||||||
|
}
|
||||||
|
placeAfter(id, newTrack.id)
|
||||||
|
return newTrack.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptTrack = scene.scriptTracks?.find((t) => t.id === id)
|
||||||
|
if (scriptTrack) {
|
||||||
|
const newTrack: ScriptTrack = {
|
||||||
|
...(structuredClone(toRaw(scriptTrack)) as ScriptTrack),
|
||||||
|
id: uid(),
|
||||||
|
}
|
||||||
|
;(scene.scriptTracks ||= []).push(newTrack)
|
||||||
|
const frames = scene.scriptFrames?.filter((f) => f.trackId === id) ?? []
|
||||||
|
for (const f of frames) {
|
||||||
|
const clonedFrame: ScriptFrame = {
|
||||||
|
...(structuredClone(toRaw(f)) as ScriptFrame),
|
||||||
|
id: uid(),
|
||||||
|
trackId: newTrack.id,
|
||||||
|
}
|
||||||
|
;(scene.scriptFrames ||= []).push(clonedFrame)
|
||||||
|
}
|
||||||
|
if (scene.scriptFrames) scene.scriptFrames.sort((a, b) => a.time - b.time)
|
||||||
|
placeAfter(id, newTrack.id)
|
||||||
|
return newTrack.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
removeClipTrack(id: string) {
|
removeClipTrack(id: string) {
|
||||||
if (this.scene.clipTracks) {
|
if (this.scene.clipTracks) {
|
||||||
this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id)
|
this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id)
|
||||||
|
|||||||
Reference in new issue
Block a user