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
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.16.1",
|
||||
"version": "0.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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()
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.16.1'
|
||||
export const APP_VERSION = '0.17.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user