1.0.0: delete-group cascade + native-size APNG preview
First stable release. Highlights in this bump: - Group context menu gains a proper danger "Delete group" entry that tears down the group AND every track it contains (layers, clip tracks, script tracks, audio tracks) along with their dependent content. "Ungroup" keeps its old meaning as a separate non-danger item, so the two operations stop being confusable. New removeTrackGroupWithChildren store action clears any selection pointing inside the group before dispatching per-kind removes. - APNG preview page now renders the export at its native pixel size: the .stage + <img> use explicit width/height in pixels instead of a max-960 aspect-ratio box with object-fit: contain. A 4K export looks 4K; the body scrolls if the window is smaller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
56cecabaae
commit
c2b81b0fb4
7 files changed
+256
-12
No files matched your search
+175
-2
@@ -1068,8 +1068,11 @@ export function registerAllTests(): void {
|
||||
const html = generateApngPreviewHtml(dataUrl, 320, 180)
|
||||
// Payload is embedded so the exported HTML works as a single file.
|
||||
expect(html).toContain(dataUrl)
|
||||
// Aspect ratio drives the stage box.
|
||||
expect(html).toContain('aspect-ratio: 320 / 180')
|
||||
// Stage + image render at the canvas's original pixel size —
|
||||
// no auto-fit / aspect-ratio box so a 4K export looks 4K.
|
||||
expect(html).toContain('width: 320px')
|
||||
expect(html).toContain('height: 180px')
|
||||
expect(html.includes('aspect-ratio')).toBe(false)
|
||||
// Checkerboard + toggle buttons are present.
|
||||
expect(html).toContain('.stage.checker')
|
||||
expect(html).toContain('data-bg="checker"')
|
||||
@@ -6241,6 +6244,172 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — removeTrackGroupWithChildren', () => {
|
||||
test('deletes the group + every nested layer', () => {
|
||||
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)!
|
||||
s.selectLayer(l1.id)
|
||||
s.toggleLayerSelection(l2.id)
|
||||
const groupId = s.groupSelectedLayers('G')!
|
||||
expect(s.scene.layers.length).toBe(2)
|
||||
expect(s.scene.groups!.length).toBe(1)
|
||||
s.removeTrackGroupWithChildren(groupId)
|
||||
// Group and children are gone, trackOrder empty.
|
||||
expect(s.scene.groups!.find((g) => g.id === groupId)).toBe(undefined)
|
||||
expect(s.scene.layers.find((x) => x.id === l1.id)).toBe(undefined)
|
||||
expect(s.scene.layers.find((x) => x.id === l2.id)).toBe(undefined)
|
||||
expect(s.scene.trackOrder!.includes(groupId)).toBe(false)
|
||||
})
|
||||
|
||||
test('drops clip-track children and their instances', () => {
|
||||
const s = makeFreshStore()
|
||||
const clip = s.addClip('C')
|
||||
clip.duration = 2
|
||||
const track = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||
const g = s.addTrackGroup('G', track.id)
|
||||
g.childIds.push(track.id)
|
||||
const order = s.scene.trackOrder!
|
||||
const ti = order.indexOf(track.id)
|
||||
if (ti >= 0) order.splice(ti, 1)
|
||||
s.selectClipInstance(ci.id)
|
||||
s.removeTrackGroupWithChildren(g.id)
|
||||
// Track + instance gone, selection cleared, group gone.
|
||||
expect(s.scene.clipTracks!.find((t) => t.id === track.id)).toBe(undefined)
|
||||
expect(s.scene.clipInstances!.find((x) => x.id === ci.id)).toBe(undefined)
|
||||
expect(s.scene.groups!.find((gg) => gg.id === g.id)).toBe(undefined)
|
||||
expect(s.selectedClipInstanceId).toBe(null)
|
||||
})
|
||||
|
||||
test('clears layer selection that pointed inside the group', () => {
|
||||
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)
|
||||
const groupId = s.groupSelectedLayers('G')!
|
||||
s.selectLayer(l.id)
|
||||
expect(s.selectedLayerId).toBe(l.id)
|
||||
s.removeTrackGroupWithChildren(groupId)
|
||||
expect(s.selectedLayerId).toBe(null)
|
||||
expect(s.selectedLayerIds).toEqual([])
|
||||
})
|
||||
|
||||
test('leaves sibling tracks outside the group untouched', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const inside = s.addLayer(a.id)!
|
||||
s.selectLayer(inside.id)
|
||||
const groupId = s.groupSelectedLayers('G')!
|
||||
// Create another top-level layer AFTER grouping so it stays
|
||||
// outside the group.
|
||||
const outside = s.addLayer(a.id)!
|
||||
s.removeTrackGroupWithChildren(groupId)
|
||||
expect(s.scene.layers.find((x) => x.id === outside.id)).toBeTruthy()
|
||||
expect(s.scene.trackOrder!.includes(outside.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Timeline group delete menu', () => {
|
||||
test('right-click on a group shows a "Delete group" danger item', async () => {
|
||||
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)
|
||||
s.groupSelectedLayers('G1')!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const row = handle.host.querySelector(
|
||||
'.track.group-track .track-name',
|
||||
) as HTMLElement
|
||||
row.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
|
||||
}),
|
||||
)
|
||||
await nextTick()
|
||||
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||||
expect(menu).toBeTruthy()
|
||||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||||
const del = items.find((b) => b.classList.contains('danger'))
|
||||
expect(del).toBeTruthy()
|
||||
expect(del!.textContent?.includes(i18nT('timeline.deleteGroup'))).toBe(true)
|
||||
// Ungroup is still there as a separate (non-danger) option.
|
||||
const ungroup = items.find((b) => b.textContent?.includes(i18nT('timeline.ungroup')))
|
||||
expect(ungroup).toBeTruthy()
|
||||
expect(ungroup!.classList.contains('danger')).toBe(false)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking Delete group removes the group and its children', async () => {
|
||||
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)
|
||||
const groupId = s.groupSelectedLayers('G1')!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const row = handle.host.querySelector(
|
||||
'.track.group-track .track-name',
|
||||
) as HTMLElement
|
||||
row.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
|
||||
}),
|
||||
)
|
||||
await nextTick()
|
||||
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||||
const del = items.find((b) => b.classList.contains('danger'))!
|
||||
del.click()
|
||||
await nextTick()
|
||||
expect(s.scene.groups!.find((g) => g.id === groupId)).toBe(undefined)
|
||||
expect(s.scene.layers.find((x) => x.id === l.id)).toBe(undefined)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking Ungroup preserves the children but removes the wrapper', async () => {
|
||||
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)
|
||||
const groupId = s.groupSelectedLayers('G1')!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const row = handle.host.querySelector(
|
||||
'.track.group-track .track-name',
|
||||
) as HTMLElement
|
||||
row.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
|
||||
}),
|
||||
)
|
||||
await nextTick()
|
||||
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||||
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
|
||||
const ungroup = items.find(
|
||||
(b) => b.textContent?.includes(i18nT('timeline.ungroup')) && !b.classList.contains('danger'),
|
||||
)!
|
||||
ungroup.click()
|
||||
await nextTick()
|
||||
// Group gone, layer stays.
|
||||
expect(s.scene.groups!.find((g) => g.id === groupId)).toBe(undefined)
|
||||
expect(s.scene.layers.find((x) => x.id === l.id)).toBeTruthy()
|
||||
expect(s.scene.trackOrder!.includes(l.id)).toBe(true)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Timeline eye button stays clickable on locked tracks', () => {
|
||||
test('locked layer row keeps the eye-btn pointer-events auto', async () => {
|
||||
// Regression: `.track.locked .track-name { pointer-events: none }`
|
||||
@@ -11182,6 +11351,10 @@ export function registerAllTests(): void {
|
||||
expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false)
|
||||
})
|
||||
|
||||
test('deleteGroup is translated', () => {
|
||||
expect(i18nT('timeline.deleteGroup') === 'timeline.deleteGroup').toBe(false)
|
||||
})
|
||||
|
||||
test('renameLayer + renameClipTrack are translated', () => {
|
||||
expect(i18nT('timeline.renameLayer') === 'timeline.renameLayer').toBe(false)
|
||||
expect(i18nT('timeline.renameClipTrack') === 'timeline.renameClipTrack').toBe(false)
|
||||
|
||||
Reference in new issue
Block a user