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",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.16.1",
|
"version": "0.17.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
PhPencilSimpleLine,
|
PhPencilSimpleLine,
|
||||||
PhPlus,
|
PhPlus,
|
||||||
PhPolygon,
|
PhPolygon,
|
||||||
|
PhScissors,
|
||||||
PhSpeakerSlash,
|
PhSpeakerSlash,
|
||||||
PhSquare,
|
PhSquare,
|
||||||
PhTextT,
|
PhTextT,
|
||||||
@@ -2683,6 +2684,26 @@ function convertSelectedToClipFromMenu() {
|
|||||||
trackMenu.value = null
|
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() {
|
function deleteTrack() {
|
||||||
if (!trackMenu.value) return
|
if (!trackMenu.value) return
|
||||||
if (trackMenu.value.kind === 'layer') {
|
if (trackMenu.value.kind === 'layer') {
|
||||||
@@ -3976,6 +3997,15 @@ onBeforeUnmount(() => {
|
|||||||
<PhFilmStrip :size="12" class="kf-menu-icon" />
|
<PhFilmStrip :size="12" class="kf-menu-icon" />
|
||||||
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
|
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
|
||||||
</button>
|
</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>
|
</template>
|
||||||
<template v-if="trackMenu.kind !== 'group'">
|
<template v-if="trackMenu.kind !== 'group'">
|
||||||
|
|||||||
@@ -103,11 +103,18 @@ function onRecentKeyDown(e: KeyboardEvent, index: number) {
|
|||||||
} else if (e.key === 'End') {
|
} else if (e.key === 'End') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
focusRecent(recentRefs.value.length - 1)
|
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) {
|
/** Wrap the usual trash-icon flow so the keyboard path stays in lock-
|
||||||
e.stopPropagation()
|
* 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({
|
const ok = await confirmDialog({
|
||||||
title: t('welcome.deleteDialog.title'),
|
title: t('welcome.deleteDialog.title'),
|
||||||
message: t('welcome.deleteDialog.message', { name: p.name }),
|
message: t('welcome.deleteDialog.message', { name: p.name }),
|
||||||
@@ -117,6 +124,19 @@ async function removeProject(e: MouseEvent, p: StoredProject) {
|
|||||||
if (!ok) return
|
if (!ok) return
|
||||||
await deleteStoredProject(p.id)
|
await deleteStoredProject(p.id)
|
||||||
await reload()
|
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() {
|
function startFresh() {
|
||||||
@@ -234,10 +254,13 @@ watch(
|
|||||||
color: var(--text-dim);
|
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 {
|
.body {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
overflow: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const open = computed({
|
|||||||
|
|
||||||
const inputValue = ref('')
|
const inputValue = ref('')
|
||||||
const inputEl = ref<HTMLInputElement | null>(null)
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
const primaryBtn = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => current.value,
|
() => current.value,
|
||||||
@@ -28,6 +29,13 @@ watch(
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
inputEl.value?.focus()
|
inputEl.value?.focus()
|
||||||
inputEl.value?.select()
|
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 },
|
{ immediate: true },
|
||||||
@@ -74,6 +82,7 @@ function isDanger(): boolean {
|
|||||||
:model-value="open"
|
:model-value="open"
|
||||||
:title="title()"
|
:title="title()"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
topmost
|
||||||
@update:model-value="open = $event"
|
@update:model-value="open = $event"
|
||||||
>
|
>
|
||||||
<div v-if="current?.kind === 'confirm'" class="body confirm-body">
|
<div v-if="current?.kind === 'confirm'" class="body confirm-body">
|
||||||
@@ -98,6 +107,7 @@ function isDanger(): boolean {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="btn secondary" @click="onCancel">{{ cancelLabel() }}</button>
|
<button class="btn secondary" @click="onCancel">{{ cancelLabel() }}</button>
|
||||||
<button
|
<button
|
||||||
|
ref="primaryBtn"
|
||||||
class="btn primary"
|
class="btn primary"
|
||||||
:class="{ danger: isDanger() }"
|
:class="{ danger: isDanger() }"
|
||||||
@click="onConfirm"
|
@click="onConfirm"
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import type { StyleValue } from 'vue'
|
import type { StyleValue } from 'vue'
|
||||||
import { PhX } from '@phosphor-icons/vue'
|
import { PhX } from '@phosphor-icons/vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
type Size = 'sm' | 'md' | 'lg' | 'xl'
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -16,6 +22,10 @@ const props = withDefaults(
|
|||||||
dismissable?: boolean
|
dismissable?: boolean
|
||||||
dialogStyle?: StyleValue
|
dialogStyle?: StyleValue
|
||||||
bodyStyle?: 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',
|
size: 'md',
|
||||||
@@ -24,6 +34,7 @@ const props = withDefaults(
|
|||||||
dismissable: true,
|
dismissable: true,
|
||||||
dialogStyle: undefined,
|
dialogStyle: undefined,
|
||||||
bodyStyle: undefined,
|
bodyStyle: undefined,
|
||||||
|
topmost: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,24 +97,50 @@ function handleTab(e: KeyboardEvent) {
|
|||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (!props.modelValue) return
|
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()
|
e.preventDefault()
|
||||||
close()
|
close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab' && isTop) {
|
||||||
handleTab(e)
|
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))
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="modal-fade">
|
<Transition name="modal-fade">
|
||||||
<div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown">
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="backdrop"
|
||||||
|
:class="{ topmost }"
|
||||||
|
@mousedown="onBackdropMouseDown"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
class="dialog"
|
class="dialog"
|
||||||
@@ -150,6 +187,10 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backdrop.topmost {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
|
|||||||
+469
-73
@@ -1365,28 +1365,26 @@ export function registerAllTests(): void {
|
|||||||
// Silence actual file downloads during the test.
|
// Silence actual file downloads during the test.
|
||||||
const originalClick = HTMLAnchorElement.prototype.click
|
const originalClick = HTMLAnchorElement.prototype.click
|
||||||
HTMLAnchorElement.prototype.click = function () {}
|
HTMLAnchorElement.prototype.click = function () {}
|
||||||
|
|
||||||
const baseline = snapshotDialogs()
|
const baseline = snapshotDialogs()
|
||||||
const handle = mountComponent(ExportDialog, { modelValue: true })
|
const handle = mountComponent(ExportDialog, { modelValue: true })
|
||||||
await nextTick()
|
try {
|
||||||
const dialog = dialogsAddedSince(baseline)[0]
|
await nextTick()
|
||||||
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
|
const dialog = dialogsAddedSince(baseline)[0]
|
||||||
expect(btn).toBeTruthy()
|
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
|
||||||
btn!.click()
|
expect(btn).toBeTruthy()
|
||||||
await nextTick()
|
btn!.click()
|
||||||
// Progress view replaces the options list — `.rendering` appears,
|
await nextTick()
|
||||||
// `.opt` buttons vanish.
|
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
|
||||||
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
|
expect(rendering).toBeTruthy()
|
||||||
expect(rendering).toBeTruthy()
|
const label = dialog.querySelector('.r-text')?.textContent ?? ''
|
||||||
const label = dialog.querySelector('.r-text')?.textContent ?? ''
|
expect(label).toBe(i18nT('export.labelApngPreview'))
|
||||||
expect(label).toBe(i18nT('export.labelApngPreview'))
|
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
|
||||||
// Abort the pending render so the encoder doesn't burn CPU for
|
cancelBtn?.click()
|
||||||
// the rest of the test run.
|
await nextTick()
|
||||||
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
|
} finally {
|
||||||
cancelBtn?.click()
|
HTMLAnchorElement.prototype.click = originalClick
|
||||||
await nextTick()
|
handle.unmount()
|
||||||
HTMLAnchorElement.prototype.click = originalClick
|
}
|
||||||
handle.unmount()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2368,40 +2366,35 @@ export function registerAllTests(): void {
|
|||||||
await saveProjectToStore(s.project, { id: idA, name: 'A' })
|
await saveProjectToStore(s.project, { id: idA, name: 'A' })
|
||||||
await tick(5)
|
await tick(5)
|
||||||
await saveProjectToStore(s.project, { id: idB, name: 'B' })
|
await saveProjectToStore(s.project, { id: idB, name: 'B' })
|
||||||
|
|
||||||
const baseline = snapshotDialogs()
|
const baseline = snapshotDialogs()
|
||||||
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
||||||
// Wait for the dialog's async reload() to populate the list.
|
try {
|
||||||
await waitUntil(() => {
|
await waitUntil(() => {
|
||||||
const d = dialogsAddedSince(baseline)[0]
|
const d = dialogsAddedSince(baseline)[0]
|
||||||
return !!d && d.querySelectorAll('.recent').length >= 2
|
return !!d && d.querySelectorAll('.recent').length >= 2
|
||||||
})
|
})
|
||||||
const dialog = dialogsAddedSince(baseline)[0]
|
const dialog = dialogsAddedSince(baseline)[0]
|
||||||
expect(dialog).toBeTruthy()
|
expect(dialog).toBeTruthy()
|
||||||
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
|
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
|
||||||
expect(items.length >= 2).toBe(true)
|
expect(items.length >= 2).toBe(true)
|
||||||
|
|
||||||
// The initial focus should already be on the first recent.
|
items[0].focus()
|
||||||
items[0].focus()
|
expect(document.activeElement).toBe(items[0])
|
||||||
expect(document.activeElement).toBe(items[0])
|
items[0].dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||||
// ArrowDown moves to the next item.
|
)
|
||||||
items[0].dispatchEvent(
|
await nextTick()
|
||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
expect(document.activeElement).toBe(items[1])
|
||||||
)
|
items[1].dispatchEvent(
|
||||||
await nextTick()
|
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
|
||||||
expect(document.activeElement).toBe(items[1])
|
)
|
||||||
|
await nextTick()
|
||||||
// ArrowUp moves back.
|
expect(document.activeElement).toBe(items[0])
|
||||||
items[1].dispatchEvent(
|
} finally {
|
||||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
|
handle.unmount()
|
||||||
)
|
await deleteStoredProject(idA).catch(() => {})
|
||||||
await nextTick()
|
await deleteStoredProject(idB).catch(() => {})
|
||||||
expect(document.activeElement).toBe(items[0])
|
}
|
||||||
|
|
||||||
handle.unmount()
|
|
||||||
await deleteStoredProject(idA)
|
|
||||||
await deleteStoredProject(idB)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Home and End jump to the first / last recent', async () => {
|
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 saveProjectToStore(s.project, { id, name: id })
|
||||||
await tick(5)
|
await tick(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseline = snapshotDialogs()
|
const baseline = snapshotDialogs()
|
||||||
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
const handle = mountComponent(WelcomeDialog, { modelValue: true })
|
||||||
await waitUntil(() => {
|
try {
|
||||||
const d = dialogsAddedSince(baseline)[0]
|
await waitUntil(() => {
|
||||||
return !!d && d.querySelectorAll('.recent').length >= 3
|
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]
|
try {
|
||||||
const items = dialog.querySelectorAll('.recent') as NodeListOf<HTMLElement>
|
await waitUntil(() => {
|
||||||
expect(items.length >= 3).toBe(true)
|
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.
|
test('Enter while the delete-confirm is up triggers confirm (not openProject)', async () => {
|
||||||
items[1].focus()
|
// After Backspace pops the confirm, focus must move to the
|
||||||
items[1].dispatchEvent(
|
// confirm button so the next Enter fires its click instead of
|
||||||
new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
|
// bubbling back to the welcome row and opening the project.
|
||||||
)
|
const s = makeFreshStore()
|
||||||
await nextTick()
|
const idA = `test-enter-confirm-a-${Date.now()}`
|
||||||
expect(document.activeElement).toBe(items[items.length - 1])
|
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.
|
test('Backspace on a focused recent opens the confirm and removes it on OK', async () => {
|
||||||
items[items.length - 1].dispatchEvent(
|
// Seed two recents; mount welcome; focus the top one; press
|
||||||
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
|
// Backspace. A confirm dialog is expected to pop (topmost layer),
|
||||||
)
|
// and clicking OK should drop the entry from the welcome list.
|
||||||
await nextTick()
|
const s = makeFreshStore()
|
||||||
expect(document.activeElement).toBe(items[0])
|
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()
|
test('delete confirm floats above the welcome dialog and the list updates afterwards', async () => {
|
||||||
for (const id of ids) await deleteStoredProject(id)
|
// 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', () => {
|
suite('polygon — store actions', () => {
|
||||||
function makePolygonLayer() {
|
function makePolygonLayer() {
|
||||||
const s = makeFreshStore()
|
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', () => {
|
suite('ui — Timeline audio track', () => {
|
||||||
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export const en = {
|
|||||||
separate: 'Separate Position and Rotation',
|
separate: 'Separate Position and Rotation',
|
||||||
rotation: 'Rotation',
|
rotation: 'Rotation',
|
||||||
position: 'Position',
|
position: 'Position',
|
||||||
|
splitSvg: 'Split SVG into paths',
|
||||||
removeInstanceDialog: {
|
removeInstanceDialog: {
|
||||||
title: 'Remove clip instance?',
|
title: 'Remove clip instance?',
|
||||||
message: 'Remove "{name}" at {time}s from the timeline.',
|
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'
|
} from '@/types/scene'
|
||||||
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
|
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
|
import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit'
|
||||||
import {
|
import {
|
||||||
buildMarkerMap,
|
buildMarkerMap,
|
||||||
evalAction,
|
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
|
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
|
const KEYFRAME_EPSILON = 1e-3
|
||||||
|
|
||||||
/** Shared cascading push-aside helper used by the audio-clip and clip-
|
/** 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)
|
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) {
|
moveLayer(fromIndex: number, toIndex: number) {
|
||||||
const arr = this.activeLayers
|
const arr = this.activeLayers
|
||||||
if (fromIndex < 0 || fromIndex >= arr.length) return
|
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'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user