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
+835 -32

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "0.16.1",
"version": "0.17.0",
"type": "module",
"scripts": {
"dev": "vite",
+30
View File
@@ -24,6 +24,7 @@ import {
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhScissors,
PhSpeakerSlash,
PhSquare,
PhTextT,
@@ -2683,6 +2684,26 @@ function convertSelectedToClipFromMenu() {
trackMenu.value = null
}
/** True only for a context-menu tied to an SVG-backed image layer.
* Gates the "Split SVG" entry so the menu doesn't show a dead action
* on raster layers. */
const canSplitSvgFromMenu = computed(() => {
const m = trackMenu.value
if (!m || m.kind !== 'layer') return false
const layer = store.scene.layers.find((l) => l.id === m.trackId)
if (!layer) return false
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'image') return false
return asset.src.startsWith('data:image/svg+xml')
})
function splitSvgFromMenu() {
const m = trackMenu.value
if (!m || m.kind !== 'layer') return
store.splitLayerSvg(m.trackId)
trackMenu.value = null
}
function deleteTrack() {
if (!trackMenu.value) return
if (trackMenu.value.kind === 'layer') {
@@ -3976,6 +3997,15 @@ onBeforeUnmount(() => {
<PhFilmStrip :size="12" class="kf-menu-icon" />
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
</button>
<button
v-if="canSplitSvgFromMenu"
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="splitSvgFromMenu"
>
<PhScissors :size="12" class="kf-menu-icon" />
{{ t('timeline.splitSvg') }}
</button>
</template>
</template>
<template v-if="trackMenu.kind !== 'group'">
+27 -4
View File
@@ -103,11 +103,18 @@ function onRecentKeyDown(e: KeyboardEvent, index: number) {
} else if (e.key === 'End') {
e.preventDefault()
focusRecent(recentRefs.value.length - 1)
} else if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
const p = recents.value[index]
if (p) void deleteRecentWithConfirm(p, index)
}
}
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
/** Wrap the usual trash-icon flow so the keyboard path stays in lock-
* step with the mouse one: same confirm dialog, same reload after,
* and focus lands on a sensible neighbour instead of the now-empty
* slot. */
async function deleteRecentWithConfirm(p: StoredProject, index: number) {
const ok = await confirmDialog({
title: t('welcome.deleteDialog.title'),
message: t('welcome.deleteDialog.message', { name: p.name }),
@@ -117,6 +124,19 @@ async function removeProject(e: MouseEvent, p: StoredProject) {
if (!ok) return
await deleteStoredProject(p.id)
await reload()
// After reload the recentRefs have been rebuilt. Keep focus close
// to where the user was: the slot now occupied by the next entry,
// or the previous one if we deleted the tail.
await nextTick()
const remaining = recentRefs.value.length
if (remaining === 0) return
focusRecent(Math.min(index, remaining - 1))
}
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
const index = recents.value.findIndex((x) => x.id === p.id)
await deleteRecentWithConfirm(p, index >= 0 ? index : 0)
}
function startFresh() {
@@ -234,10 +254,13 @@ watch(
color: var(--text-dim);
}
/* Padding only — scrolling is delegated to Modal's own `.body`, which
carries the flex + overflow rules that actually cap the dialog at
viewport height. Adding an extra overflow layer here swallowed the
wheel events because this inner wrapper had no height constraint
and never became its own scroll container. */
.body {
padding: 8px 12px;
overflow: auto;
flex: 1;
}
.empty {
+10
View File
@@ -19,6 +19,7 @@ const open = computed({
const inputValue = ref('')
const inputEl = ref<HTMLInputElement | null>(null)
const primaryBtn = ref<HTMLButtonElement | null>(null)
watch(
() => current.value,
@@ -28,6 +29,13 @@ watch(
await nextTick()
inputEl.value?.focus()
inputEl.value?.select()
} else if (s?.kind === 'confirm') {
// Move focus to the confirm button so Enter triggers confirm
// natively. Without this, focus stays on whatever opened the
// dialog (e.g. a welcome-dialog recent row) and Enter bubbles
// back to its handler instead.
await nextTick()
primaryBtn.value?.focus()
}
},
{ immediate: true },
@@ -74,6 +82,7 @@ function isDanger(): boolean {
:model-value="open"
:title="title()"
size="sm"
topmost
@update:model-value="open = $event"
>
<div v-if="current?.kind === 'confirm'" class="body confirm-body">
@@ -98,6 +107,7 @@ function isDanger(): boolean {
<template #footer>
<button class="btn secondary" @click="onCancel">{{ cancelLabel() }}</button>
<button
ref="primaryBtn"
class="btn primary"
:class="{ danger: isDanger() }"
@click="onConfirm"
+46 -5
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { PhX } from '@phosphor-icons/vue'
import { t } from '@/i18n'
type Size = 'sm' | 'md' | 'lg' | 'xl'
// Module-level open-modal stack — LIFO. Only the most recently opened
// modal claims ESC; anything underneath (welcome while a confirm is
// up) ignores the key so it doesn't double-dismiss.
const modalStack: symbol[] = []
const modalId = Symbol('modal')
const props = withDefaults(
defineProps<{
modelValue: boolean
@@ -16,6 +22,10 @@ const props = withDefaults(
dismissable?: boolean
dialogStyle?: StyleValue
bodyStyle?: StyleValue
/** Raise the backdrop + dialog above any other Modal currently on
* screen. Used by confirm/prompt dialogs that pop ON TOP of other
* modals (welcome screen → delete confirm, etc.). */
topmost?: boolean
}>(),
{
size: 'md',
@@ -24,6 +34,7 @@ const props = withDefaults(
dismissable: true,
dialogStyle: undefined,
bodyStyle: undefined,
topmost: false,
},
)
@@ -86,24 +97,50 @@ function handleTab(e: KeyboardEvent) {
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (e.key === 'Escape' && props.closeOnEsc) {
// Only the topmost modal owns ESC. `modalStack[last]` is whichever
// Modal most recently flipped to open; non-topmost ones below it
// stay put.
const isTop = modalStack[modalStack.length - 1] === modalId
if (e.key === 'Escape' && props.closeOnEsc && isTop) {
e.preventDefault()
close()
return
}
if (e.key === 'Tab') {
if (e.key === 'Tab' && isTop) {
handleTab(e)
}
}
watch(
() => props.modelValue,
(v) => {
if (v) {
modalStack.push(modalId)
} else {
const i = modalStack.indexOf(modalId)
if (i >= 0) modalStack.splice(i, 1)
}
},
{ immediate: true },
)
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
const i = modalStack.indexOf(modalId)
if (i >= 0) modalStack.splice(i, 1)
})
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown">
<div
v-if="modelValue"
class="backdrop"
:class="{ topmost }"
@mousedown="onBackdropMouseDown"
>
<div
ref="dialogRef"
class="dialog"
@@ -150,6 +187,10 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
backdrop-filter: blur(2px);
}
.backdrop.topmost {
z-index: 1000;
}
.dialog {
background: var(--panel);
border: 1px solid var(--border-strong);
+417 -21
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 })
try {
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()
} finally {
HTMLAnchorElement.prototype.click = originalClick
handle.unmount()
}
})
})
@@ -2368,10 +2366,9 @@ 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.
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 2
@@ -2381,27 +2378,23 @@ export function registerAllTests(): void {
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])
} finally {
handle.unmount()
await deleteStoredProject(idA)
await deleteStoredProject(idB)
await deleteStoredProject(idA).catch(() => {})
await deleteStoredProject(idB).catch(() => {})
}
})
test('Home and End jump to the first / last recent', async () => {
@@ -2415,9 +2408,9 @@ export function registerAllTests(): void {
await saveProjectToStore(s.project, { id, name: id })
await tick(5)
}
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.recent').length >= 3
@@ -2426,23 +2419,225 @@ export function registerAllTests(): void {
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
expect(items.length >= 3).toBe(true)
// 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])
// 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])
} finally {
handle.unmount()
for (const id of ids) await deleteStoredProject(id)
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
},
})
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(() => {})
}
})
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(() => {})
}
})
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(() => {})
}
})
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()
+1
View File
@@ -294,6 +294,7 @@ export const en = {
separate: 'Separate Position and Rotation',
rotation: 'Rotation',
position: 'Position',
splitSvg: 'Split SVG into paths',
removeInstanceDialog: {
title: 'Remove clip instance?',
message: 'Remove "{name}" at {time}s from the timeline.',
+150
View File
@@ -0,0 +1,150 @@
/**
* Split an SVG document into its top-level drawable children. Each
* returned part carries its own standalone SVG (viewBox shrunk to the
* part's bounding box) + a pre-computed data URL suitable for use as
* an image asset. Intended for the timeline's "Split SVG" action,
* which replaces a single SVG-backed layer with one layer per part.
*
* Top-level = a direct child of the root <svg>. We do not recurse into
* <g>s; a whole group stays intact as one part. `<defs>`, `<style>`,
* metadata and plain text nodes are skipped (they don't render
* independently).
*/
const SVG_NS = 'http://www.w3.org/2000/svg'
export interface SvgPart {
/** Suggested layer/asset name. Derived from `id`/`aria-label` if the
* element carries one, else the tag name with a 1-based index. */
name: string
/** Standalone SVG text. Same size + viewBox as the ORIGINAL SVG —
* only the body differs (a single drawable per part). That way the
* store can keep the new layer's position/scale identical to the
* original and each part renders exactly where it used to, without
* any geometry math that could go wrong on complex SVGs. */
svgText: string
/** Ready-to-use `data:image/svg+xml;...` URL for the new image asset. */
dataUrl: string
}
export interface SvgSplitResult {
parts: SvgPart[]
}
/** Quote an attribute value for embedding in a built SVG string. */
function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** Serialise an SVG string into a percent-encoded data URL. `#` and
* `?` MUST stay encoded — browsers treat them as fragment / query
* separators in data URLs, and a single unencoded `#` (common in
* fill="#rrggbb" attributes) truncates the SVG at its first colour
* value. A few safe characters get decoded for compactness. */
function toDataUrl(svgText: string): string {
const encoded = encodeURIComponent(svgText).replace(
/%(20|22|3D)/g,
(_, c) => ({ '20': ' ', '22': '"', '3D': '=' })[c as '20' | '22' | '3D'] ?? _,
)
return `data:image/svg+xml,${encoded}`
}
/** Element tags whose bbox we skip when looking for splittable
* children. Everything else goes through getBBox. */
const NON_DRAWABLE = new Set([
'defs',
'style',
'metadata',
'title',
'desc',
'clipPath',
'mask',
'filter',
'symbol',
'marker',
'script',
])
export function splitSvg(svgText: string): SvgSplitResult | null {
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml')
if (doc.getElementsByTagName('parsererror').length > 0) return null
const root = doc.documentElement
if (!root || root.localName !== 'svg') return null
// Collect drawable top-level children in document order.
const drawables: Element[] = []
for (const child of Array.from(root.children)) {
if (NON_DRAWABLE.has(child.localName)) continue
drawables.push(child)
}
if (drawables.length < 2) return null
// Root presentation attributes to carry forward to each part's new
// <svg> root so inherited fill/stroke/color/etc. behave the same
// way they did inside the original document.
const INHERITABLE_ATTRS = [
'fill',
'stroke',
'stroke-width',
'stroke-linecap',
'stroke-linejoin',
'stroke-miterlimit',
'stroke-dasharray',
'stroke-dashoffset',
'stroke-opacity',
'fill-opacity',
'fill-rule',
'color',
'opacity',
'font-family',
'font-size',
'font-weight',
'font-style',
]
// Build a fresh attribute list for the new root: same width/height/
// viewBox as the original, plus any inherited presentation attrs the
// original had on its root.
const rootAttrParts: string[] = [`xmlns="${SVG_NS}"`]
const vb = root.getAttribute('viewBox')
if (vb) rootAttrParts.push(`viewBox="${escapeAttr(vb)}"`)
const rw = root.getAttribute('width')
if (rw) rootAttrParts.push(`width="${escapeAttr(rw)}"`)
const rh = root.getAttribute('height')
if (rh) rootAttrParts.push(`height="${escapeAttr(rh)}"`)
for (const a of INHERITABLE_ATTRS) {
const v = root.getAttribute(a)
if (v != null) rootAttrParts.push(`${a}="${escapeAttr(v)}"`)
}
const rootAttrs = rootAttrParts.join(' ')
// <defs>, <style> and other non-drawable children are preserved
// verbatim in every part so referenced ids (gradients, clipPaths,
// filters) still resolve standalone.
const serializer = new XMLSerializer()
const defsXml = Array.from(root.children)
.filter((c) => NON_DRAWABLE.has(c.localName))
.map((d) => serializer.serializeToString(d))
.join('')
const parts: SvgPart[] = []
for (let i = 0; i < drawables.length; i++) {
const el = drawables[i]
// Keep the full original canvas — only the BODY differs. Each
// part is a same-sized SVG with one drawable at its original
// coordinates, so the store can reuse the source layer's
// position/scale without any geometry math.
const childXml = serializer.serializeToString(el)
const svgText = `<svg ${rootAttrs}>${defsXml}${childXml}</svg>`
const name =
el.getAttribute('id') ||
el.getAttribute('aria-label') ||
`${el.localName} ${i + 1}`
parts.push({ name, svgText, dataUrl: toDataUrl(svgText) })
}
return { parts }
}
+152
View File
@@ -28,6 +28,7 @@ import type {
} from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit'
import {
buildMarkerMap,
evalAction,
@@ -40,6 +41,40 @@ const uid = () => Math.random().toString(36).slice(2, 10)
type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : never) : never
/** A source URL is considered SVG if it's a `data:image/svg+xml` payload.
* External URLs are rare in this app (imports data-URL everything) and
* splitting them would need a network fetch we don't want to run
* synchronously inside an action. */
function isSvgSource(src: string | undefined): boolean {
if (!src) return false
return src.startsWith('data:image/svg+xml')
}
/** Pull the SVG text out of a data URL. Supports both the URL-encoded
* (`data:image/svg+xml,`...) and base64 (`data:image/svg+xml;base64,`...)
* forms produced by the importer. Returns null when the payload is
* malformed so callers can silently bail out. */
function decodeSvgDataUrl(src: string): string | null {
const comma = src.indexOf(',')
if (comma < 0) return null
const header = src.slice(0, comma)
const payload = src.slice(comma + 1)
try {
if (header.includes(';base64')) {
// atob produces a binary string; re-decode as UTF-8 so high-
// byte chars (non-ASCII attribute values, embedded CSS, etc.)
// come through intact instead of mojibake.
const binary = atob(payload)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new TextDecoder('utf-8').decode(bytes)
}
return decodeURIComponent(payload)
} catch {
return null
}
}
const KEYFRAME_EPSILON = 1e-3
/** Shared cascading push-aside helper used by the audio-clip and clip-
@@ -704,6 +739,123 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerIds = this.selectedLayerIds.filter((x) => x !== id)
}
},
/** Replace a single SVG-backed layer with one layer per top-level
* drawable in the SVG. Each new layer lands in the stack slot of
* the original and preserves every animatable field — base,
* keyframes, scale, rotation, opacity. Positions are recomputed
* so the parts stay visually pinned to their pre-split spots on
* the stage. Returns the new layer ids, or `null` if the layer
* isn't SVG-backed or the SVG has fewer than 2 splittable parts. */
splitLayerSvg(layerId: string): string[] | null {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const asset = this.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'image') return null
if (!isSvgSource(asset.src)) return null
const svgText = decodeSvgDataUrl(asset.src)
if (!svgText) return null
const split = splitSvgRuntime(svgText)
if (!split || split.parts.length < 2) return null
const parts = split.parts
// Insert location: wherever the original sat inside trackOrder
// or inside its parent group's childIds.
const order = this.scene.trackOrder ?? []
const topIdx = order.indexOf(layerId)
let parentGroup: TrackGroup | null = null
let childIdx = -1
if (topIdx < 0) {
for (const g of this.scene.groups ?? []) {
const i = g.childIds.indexOf(layerId)
if (i >= 0) {
parentGroup = g
childIdx = i
break
}
}
}
const newLayerIds: string[] = []
const newAssets: Asset[] = []
const newLayers: Layer[] = []
// Each part is a full-sized copy of the original SVG with only
// one drawable in its body. That means every new layer inherits
// the original's position/scale/keyframes verbatim — the part
// renders exactly where it was because its SVG canvas is the
// same size and coordinate space as before.
const cloneKeyframes = (kfs: Keyframe[] | undefined): Keyframe[] | undefined => {
if (!kfs) return kfs
return kfs.map((kf) => ({
...kf,
id: uid(),
props: { ...kf.props },
}))
}
for (const part of parts) {
const partAsset: Asset = {
id: uid(),
kind: 'image',
name: `${asset.name} · ${part.name}`,
src: part.dataUrl,
width: asset.width,
height: asset.height,
}
newAssets.push(partAsset)
const partLayer: Layer = {
id: uid(),
name: `${layer.name} · ${part.name}`,
assetId: partAsset.id,
visible: layer.visible,
base: { ...layer.base },
keyframes: cloneKeyframes(layer.keyframes) ?? [],
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
}
if (layer.positionKeyframes) {
partLayer.positionKeyframes = cloneKeyframes(layer.positionKeyframes)
}
if (layer.rotationKeyframes) {
partLayer.rotationKeyframes = cloneKeyframes(layer.rotationKeyframes)
}
if (layer.separated) partLayer.separated = true
if (layer.color !== undefined) partLayer.color = layer.color
if (layer.colorOverridden !== undefined) partLayer.colorOverridden = layer.colorOverridden
if (layer.locked) partLayer.locked = layer.locked
if (layer.editorHidden) partLayer.editorHidden = layer.editorHidden
newLayers.push(partLayer)
newLayerIds.push(partLayer.id)
}
// Commit: push new assets, insert new layers at the original
// slot, then remove the original.
this.scene.assets.push(...newAssets)
const layers = this.activeLayers
const origIdx = layers.findIndex((l) => l.id === layerId)
if (origIdx >= 0) layers.splice(origIdx, 1, ...newLayers)
else layers.push(...newLayers)
if (parentGroup && childIdx >= 0) {
parentGroup.childIds.splice(
childIdx,
1,
...newLayers.map((l) => l.id),
)
} else if (topIdx >= 0) {
this.scene.trackOrder!.splice(topIdx, 1, ...newLayers.map((l) => l.id))
} else {
;(this.scene.trackOrder ||= []).unshift(...newLayers.map((l) => l.id))
}
if (this.selectedLayerId === layerId) {
this.selectedLayerId = newLayerIds[0] ?? null
this.selectedLayerIds = newLayerIds
}
return newLayerIds
},
moveLayer(fromIndex: number, toIndex: number) {
const arr = this.activeLayers
if (fromIndex < 0 || fromIndex >= arr.length) return
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.16.1'
export const APP_VERSION = '0.17.0'
export const APP_NAME = 'RUWIO'