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:
1 parent
baf775c8de
commit
a858f94bb7
10 files changed
+887
-84
No files matched your search
+469
-73
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user