0.17.0: split SVG layer into per-path layers

Right-click an SVG-backed layer to split it into one layer per
top-level drawable. Each part keeps the original stage position
and ends up as a same-size standalone SVG.

Also: delete confirm now floats above the welcome dialog (topmost
z-index + scoped ESC/Tab via modal stack) and autofocuses its
primary button so Enter triggers confirm instead of bubbling back
to the underlying row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 12:26:57 +02:00
1 parent baf775c8de
commit a858f94bb7
10 files changed
+887 -84

No files matched your search

+469 -73
View File
@@ -1365,28 +1365,26 @@ export function registerAllTests(): void {
// Silence actual file downloads during the test.
const originalClick = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = function () {}
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
expect(btn).toBeTruthy()
btn!.click()
await nextTick()
// Progress view replaces the options list — `.rendering` appears,
// `.opt` buttons vanish.
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
expect(rendering).toBeTruthy()
const label = dialog.querySelector('.r-text')?.textContent ?? ''
expect(label).toBe(i18nT('export.labelApngPreview'))
// Abort the pending render so the encoder doesn't burn CPU for
// the rest of the test run.
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
cancelBtn?.click()
await nextTick()
HTMLAnchorElement.prototype.click = originalClick
handle.unmount()
try {
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
expect(btn).toBeTruthy()
btn!.click()
await nextTick()
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
expect(rendering).toBeTruthy()
const label = dialog.querySelector('.r-text')?.textContent ?? ''
expect(label).toBe(i18nT('export.labelApngPreview'))
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
cancelBtn?.click()
await nextTick()
} finally {
HTMLAnchorElement.prototype.click = originalClick
handle.unmount()
}
})
})
@@ -2368,40 +2366,35 @@ export function registerAllTests(): void {
await saveProjectToStore(s.project, { id: idA, name: 'A' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'B' })
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
// Wait for the dialog's async reload() to populate the list.
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const dialog = dialogsAddedSince(baseline)[0]
expect(dialog).toBeTruthy()
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
expect(items.length >= 2).toBe(true)
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const dialog = dialogsAddedSince(baseline)[0]
expect(dialog).toBeTruthy()
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
expect(items.length >= 2).toBe(true)
// The initial focus should already be on the first recent.
items[0].focus()
expect(document.activeElement).toBe(items[0])
// ArrowDown moves to the next item.
items[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[1])
// ArrowUp moves back.
items[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[0])
handle.unmount()
await deleteStoredProject(idA)
await deleteStoredProject(idB)
items[0].focus()
expect(document.activeElement).toBe(items[0])
items[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[1])
items[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[0])
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
test('Home and End jump to the first / last recent', async () => {
@@ -2415,34 +2408,236 @@ export function registerAllTests(): void {
await saveProjectToStore(s.project, { id, name: id })
await tick(5)
}
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 3
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 3
})
const dialog = dialogsAddedSince(baseline)[0]
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
expect(items.length >= 3).toBe(true)
items[1].focus()
items[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[items.length - 1])
items[items.length - 1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[0])
} finally {
handle.unmount()
for (const id of ids) await deleteStoredProject(id).catch(() => {})
}
})
test('Enter on a focused recent opens the project (when no confirm is up)', async () => {
// Regression guard: Enter must open the focused project while
// the welcome is the active modal. Delete-confirm (next test)
// verifies that Enter doesn't leak through to this handler while
// the confirm is on top.
const s = makeFreshStore()
const idA = `test-enter-a-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Open me' })
const baseline = snapshotDialogs()
let opened: { name: string } | null = null
const handle = mountComponent(WelcomeDialog, {
modelValue: true,
'onOpen': (p: { name: string }) => {
opened = p
},
})
const dialog = dialogsAddedSince(baseline)[0]
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
expect(items.length >= 3).toBe(true)
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 1
})
const welcome = dialogsAddedSince(baseline)[0]
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
top.focus()
top.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await nextTick()
expect(opened).toBeTruthy()
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
}
})
// Start somewhere in the middle, then End jumps to the last.
items[1].focus()
items[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[items.length - 1])
test('Enter while the delete-confirm is up triggers confirm (not openProject)', async () => {
// After Backspace pops the confirm, focus must move to the
// confirm button so the next Enter fires its click instead of
// bubbling back to the welcome row and opening the project.
const s = makeFreshStore()
const idA = `test-enter-confirm-a-${Date.now()}`
const idB = `test-enter-confirm-b-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
const baseline = snapshotDialogs()
let openCalls = 0
const handle = mountComponent(WelcomeDialog, {
modelValue: true,
'onOpen': () => {
openCalls += 1
},
})
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const welcome = dialogsAddedSince(baseline)[0]
// The newest seed ('DropMe' / idB) is at the top of the list.
// We assert by name rather than by count because the browser's
// IDB may already hold MAX_RECENT real projects; in that case
// deleting the top entry just unmasks a hidden record and the
// count stays the same even though the seed is gone.
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
top.focus()
top.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
)
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
// Confirm dialog is up; focus should now be on its primary
// button so Enter fires the confirm instead of bubbling back
// to the welcome row's Enter handler (which would openProject).
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
const primary = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
await waitUntil(() => document.activeElement === primary)
expect(document.activeElement).toBe(primary)
primary.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
// Native browser behaviour: Enter on a focused button synthesises
// a click. Simulate that click directly to keep jsdom/Chrome
// behaviour consistent inside the test.
primary.click()
await waitUntil(() => !welcome.textContent?.includes('DropMe'))
expect(welcome.textContent?.includes('DropMe')).toBe(false)
// The welcome's openProject handler must NOT have fired.
expect(openCalls).toBe(0)
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
// Home returns to the first.
items[items.length - 1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
)
await nextTick()
expect(document.activeElement).toBe(items[0])
test('Backspace on a focused recent opens the confirm and removes it on OK', async () => {
// Seed two recents; mount welcome; focus the top one; press
// Backspace. A confirm dialog is expected to pop (topmost layer),
// and clicking OK should drop the entry from the welcome list.
const s = makeFreshStore()
const idA = `test-backspace-a-${Date.now()}`
const idB = `test-backspace-b-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const welcome = dialogsAddedSince(baseline)[0]
// Assert by seed name rather than count — MAX_RECENT caps the
// visible list at 8, so if the browser's IDB already has enough
// real projects, deleting the top seed just unmasks a hidden
// record and the count does not drop.
const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
top.focus()
expect(document.activeElement).toBe(top)
top.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
)
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
const confirmBtn = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
expect(confirmBtn).toBeTruthy()
confirmBtn.click()
// reload() briefly flips loading=true, which hides .recents
// behind the .empty "loading…" placeholder and also unmounts
// the <li>s (so 'DropMe' is transiently absent from textContent
// too). Wait for the list to fully settle before asserting.
await waitUntil(
() =>
!welcome.textContent?.includes('DropMe') &&
!!welcome.querySelector('.recents'),
)
expect(welcome.textContent?.includes('DropMe')).toBe(false)
// Focus is placed by deleteRecentWithConfirm AFTER reload
// returns, so poll until it lands inside welcome.
await waitUntil(() => welcome.contains(document.activeElement))
expect(welcome.contains(document.activeElement)).toBe(true)
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
handle.unmount()
for (const id of ids) await deleteStoredProject(id)
test('delete confirm floats above the welcome dialog and the list updates afterwards', async () => {
// Reproduces: clicking the trash icon used to pop the confirm
// behind welcome (both z-index 900); pressing ESC then dismissed
// welcome as well because both modals ate the key. The topmost
// flag fixes stacking and the stack-based ESC filter keeps the
// confirm from dragging welcome down with it.
const s = makeFreshStore()
const idA = `test-del-a-${Date.now()}`
const idB = `test-del-b-${Date.now()}`
await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
})
const welcome = dialogsAddedSince(baseline)[0]
expect(welcome).toBeTruthy()
expect(welcome.querySelectorAll('.recent').length >= 2).toBe(true)
const target = welcome.querySelectorAll('.recent')[0] as HTMLElement
const delBtn = target.querySelector('.del') as HTMLButtonElement
expect(delBtn).toBeTruthy()
delBtn.click()
await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
expect(topmost).toBeTruthy()
const confirmBtn = topmost.querySelector(
'footer .btn.primary',
) as HTMLButtonElement
expect(confirmBtn).toBeTruthy()
confirmBtn.click()
// Assert by seed name rather than count — MAX_RECENT=8 caps the
// visible list, so deleting the top seed may just unmask a
// hidden record instead of dropping the total. Also wait for
// the list to rerender: reload() briefly flips loading=true,
// which hides .recents behind the "loading…" placeholder.
await waitUntil(
() =>
!welcome.textContent?.includes('DropMe') &&
!!welcome.querySelector('.recents'),
)
expect(welcome.textContent?.includes('DropMe')).toBe(false)
expect(welcome.isConnected).toBe(true)
expect(welcome.querySelector('.recents')).toBeTruthy()
} finally {
handle.unmount()
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
})
@@ -4106,6 +4301,129 @@ export function registerAllTests(): void {
})
})
suite('svg split', () => {
// A minimal SVG with two non-overlapping paths. Absolute
// coordinates so getBBox returns predictable values under jsdom/
// Chrome. The split helper should emit two standalone SVG parts.
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="60" viewBox="0 0 100 60">
<rect id="left" x="0" y="0" width="40" height="40" fill="#f00" />
<rect id="right" x="60" y="20" width="40" height="40" fill="#0f0" />
</svg>`
function toDataUrl(svg: string): string {
return 'data:image/svg+xml,' + encodeURIComponent(svg)
}
test('splitSvg returns one part per top-level drawable', async () => {
const { splitSvg } = await import('@/lib/svgSplit')
const out = splitSvg(sampleSvg)
expect(out).toBeTruthy()
expect(out!.parts.length).toBe(2)
expect(out!.parts[0].svgText).toContain('<rect')
expect(out!.parts[0].svgText).toContain('id="left"')
// Each part keeps the ORIGINAL width/height/viewBox so the store
// can reuse the layer's position without any geometry math.
expect(out!.parts[0].svgText).toContain('width="100"')
expect(out!.parts[0].svgText).toContain('viewBox="0 0 100 60"')
expect(out!.parts[0].dataUrl.startsWith('data:image/svg+xml')).toBe(true)
})
test('splitSvg returns null for a single-element SVG (nothing to split)', async () => {
const { splitSvg } = await import('@/lib/svgSplit')
const one = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" fill="#333"/></svg>`
expect(splitSvg(one)).toBeNull()
})
test('splitSvg data URLs keep # encoded so hex colours survive (regression)', async () => {
// A raw `#` in the data URL is parsed as a fragment separator
// by every browser, which truncates the SVG at the first colour
// value. The encoder has to leave `#` percent-encoded even
// though it makes the URL a bit longer.
const { splitSvg } = await import('@/lib/svgSplit')
const out = splitSvg(sampleSvg)
expect(out).toBeTruthy()
for (const p of out!.parts) {
// The payload portion (after the comma) must NOT contain a
// literal `#` — otherwise browsers load a truncated SVG.
const payload = p.dataUrl.slice(p.dataUrl.indexOf(',') + 1)
expect(payload.includes('#')).toBe(false)
// It should still encode the hex colour:
expect(payload.includes('%23')).toBe(true)
}
})
test('splitLayerSvg replaces the original layer with one layer per part at the same stage position', () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'SvgSrc',
src: toDataUrl(sampleSvg),
width: 100,
height: 60,
})
const l = s.addLayer(asset.id)!
const origX = l.base.x
const origY = l.base.y
const result = s.splitLayerSvg(l.id)
expect(result).toBeTruthy()
expect(result!.length).toBe(2)
// Original layer is gone.
expect(s.scene.layers.find((x) => x.id === l.id)).toBe(undefined)
// Both new layers exist and sit on top of the old layer's stage
// position — each part's SVG canvas is the same size as the
// original so no delta is needed.
const [a, b] = result!.map((id) => s.scene.layers.find((x) => x.id === id)!)
expect(a.base.x).toBe(origX)
expect(a.base.y).toBe(origY)
expect(b.base.x).toBe(origX)
expect(b.base.y).toBe(origY)
// New assets carry the full original dimensions.
const assetA = s.scene.assets.find(
(x) => x.id === a.assetId,
) as Extract<typeof s.scene.assets[number], { kind: 'image' }>
expect(assetA.width).toBe(100)
expect(assetA.height).toBe(60)
// New parts replace the original in trackOrder at its original slot.
const order = s.scene.trackOrder ?? []
expect(order.includes(l.id)).toBe(false)
for (const id of result!) expect(order.includes(id)).toBe(true)
})
test('splitLayerSvg clones keyframes verbatim (no x/y drift)', () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Anim',
src: toDataUrl(sampleSvg),
width: 100,
height: 60,
})
const l = s.addLayer(asset.id)!
s.upsertKeyframe(l.id, 1, { x: 200, y: 120 })
const result = s.splitLayerSvg(l.id)
expect(result).toBeTruthy()
const first = s.scene.layers.find((x) => x.id === result![0])!
const kf = first.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)
expect(kf).toBeTruthy()
// Same x/y as the source keyframe; no shift math to go wrong.
expect(kf!.props.x).toBe(200)
expect(kf!.props.y).toBe(120)
})
test('splitLayerSvg returns null for a non-SVG image layer', () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Raster',
src: 'data:image/png;base64,iVBORw0K==',
width: 40,
height: 40,
})
const l = s.addLayer(asset.id)!
expect(s.splitLayerSvg(l.id)).toBeNull()
})
})
suite('polygon — store actions', () => {
function makePolygonLayer() {
const s = makeFreshStore()
@@ -5761,6 +6079,84 @@ export function registerAllTests(): void {
})
})
suite('ui — Timeline split-svg menu entry', () => {
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect x="0" y="0" width="20" height="20" fill="#f00"/>
<rect x="20" y="20" width="20" height="20" fill="#0f0"/>
</svg>`
function toDataUrl(svg: string): string {
return 'data:image/svg+xml,' + encodeURIComponent(svg)
}
test('Split SVG entry appears in the track menu for an SVG-backed layer', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Parts',
src: toDataUrl(sampleSvg),
width: 40,
height: 40,
})
s.addLayer(asset.id)
const handle = mountComponent(Timeline, {})
await nextTick()
// Layer tracks render as `.track` without a kind-specific suffix;
// the first track-name in the host is the layer we just added.
const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
expect(name).toBeTruthy()
name!.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 20,
clientY: 20,
}),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
expect(hasSplit).toBe(true)
handle.unmount()
})
test('Split SVG entry is absent for a raster (PNG) layer', async () => {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'image',
name: 'Raster',
src: 'data:image/png;base64,iVBORw0K==',
width: 40,
height: 40,
})
s.addLayer(asset.id)
const handle = mountComponent(Timeline, {})
await nextTick()
// Layer tracks render as `.track` without a kind-specific suffix;
// the first track-name in the host is the layer we just added.
const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
expect(name).toBeTruthy()
name!.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 20,
clientY: 20,
}),
)
await nextTick()
const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
const menu = menus[menus.length - 1]
const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
expect(hasSplit).toBe(false)
handle.unmount()
})
})
suite('ui — Timeline audio track', () => {
test('audio track renders as an .audio-track lane with one block per clip', async () => {
const s = makeFreshStore()