0.17.0: split SVG layer into per-path layers

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

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

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

No files matched your search

+30
View File
@@ -24,6 +24,7 @@ import {
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhScissors,
PhSpeakerSlash,
PhSquare,
PhTextT,
@@ -2683,6 +2684,26 @@ function convertSelectedToClipFromMenu() {
trackMenu.value = null
}
/** True only for a context-menu tied to an SVG-backed image layer.
* Gates the "Split SVG" entry so the menu doesn't show a dead action
* on raster layers. */
const canSplitSvgFromMenu = computed(() => {
const m = trackMenu.value
if (!m || m.kind !== 'layer') return false
const layer = store.scene.layers.find((l) => l.id === m.trackId)
if (!layer) return false
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'image') return false
return asset.src.startsWith('data:image/svg+xml')
})
function splitSvgFromMenu() {
const m = trackMenu.value
if (!m || m.kind !== 'layer') return
store.splitLayerSvg(m.trackId)
trackMenu.value = null
}
function deleteTrack() {
if (!trackMenu.value) return
if (trackMenu.value.kind === 'layer') {
@@ -3976,6 +3997,15 @@ onBeforeUnmount(() => {
<PhFilmStrip :size="12" class="kf-menu-icon" />
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
</button>
<button
v-if="canSplitSvgFromMenu"
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="splitSvgFromMenu"
>
<PhScissors :size="12" class="kf-menu-icon" />
{{ t('timeline.splitSvg') }}
</button>
</template>
</template>
<template v-if="trackMenu.kind !== 'group'">
+27 -4
View File
@@ -103,11 +103,18 @@ function onRecentKeyDown(e: KeyboardEvent, index: number) {
} else if (e.key === 'End') {
e.preventDefault()
focusRecent(recentRefs.value.length - 1)
} else if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
const p = recents.value[index]
if (p) void deleteRecentWithConfirm(p, index)
}
}
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
/** Wrap the usual trash-icon flow so the keyboard path stays in lock-
* step with the mouse one: same confirm dialog, same reload after,
* and focus lands on a sensible neighbour instead of the now-empty
* slot. */
async function deleteRecentWithConfirm(p: StoredProject, index: number) {
const ok = await confirmDialog({
title: t('welcome.deleteDialog.title'),
message: t('welcome.deleteDialog.message', { name: p.name }),
@@ -117,6 +124,19 @@ async function removeProject(e: MouseEvent, p: StoredProject) {
if (!ok) return
await deleteStoredProject(p.id)
await reload()
// After reload the recentRefs have been rebuilt. Keep focus close
// to where the user was: the slot now occupied by the next entry,
// or the previous one if we deleted the tail.
await nextTick()
const remaining = recentRefs.value.length
if (remaining === 0) return
focusRecent(Math.min(index, remaining - 1))
}
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
const index = recents.value.findIndex((x) => x.id === p.id)
await deleteRecentWithConfirm(p, index >= 0 ? index : 0)
}
function startFresh() {
@@ -234,10 +254,13 @@ watch(
color: var(--text-dim);
}
/* Padding only — scrolling is delegated to Modal's own `.body`, which
carries the flex + overflow rules that actually cap the dialog at
viewport height. Adding an extra overflow layer here swallowed the
wheel events because this inner wrapper had no height constraint
and never became its own scroll container. */
.body {
padding: 8px 12px;
overflow: auto;
flex: 1;
}
.empty {
+10
View File
@@ -19,6 +19,7 @@ const open = computed({
const inputValue = ref('')
const inputEl = ref<HTMLInputElement | null>(null)
const primaryBtn = ref<HTMLButtonElement | null>(null)
watch(
() => current.value,
@@ -28,6 +29,13 @@ watch(
await nextTick()
inputEl.value?.focus()
inputEl.value?.select()
} else if (s?.kind === 'confirm') {
// Move focus to the confirm button so Enter triggers confirm
// natively. Without this, focus stays on whatever opened the
// dialog (e.g. a welcome-dialog recent row) and Enter bubbles
// back to its handler instead.
await nextTick()
primaryBtn.value?.focus()
}
},
{ immediate: true },
@@ -74,6 +82,7 @@ function isDanger(): boolean {
:model-value="open"
:title="title()"
size="sm"
topmost
@update:model-value="open = $event"
>
<div v-if="current?.kind === 'confirm'" class="body confirm-body">
@@ -98,6 +107,7 @@ function isDanger(): boolean {
<template #footer>
<button class="btn secondary" @click="onCancel">{{ cancelLabel() }}</button>
<button
ref="primaryBtn"
class="btn primary"
:class="{ danger: isDanger() }"
@click="onConfirm"
+46 -5
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { PhX } from '@phosphor-icons/vue'
import { t } from '@/i18n'
type Size = 'sm' | 'md' | 'lg' | 'xl'
// Module-level open-modal stack — LIFO. Only the most recently opened
// modal claims ESC; anything underneath (welcome while a confirm is
// up) ignores the key so it doesn't double-dismiss.
const modalStack: symbol[] = []
const modalId = Symbol('modal')
const props = withDefaults(
defineProps<{
modelValue: boolean
@@ -16,6 +22,10 @@ const props = withDefaults(
dismissable?: boolean
dialogStyle?: StyleValue
bodyStyle?: StyleValue
/** Raise the backdrop + dialog above any other Modal currently on
* screen. Used by confirm/prompt dialogs that pop ON TOP of other
* modals (welcome screen → delete confirm, etc.). */
topmost?: boolean
}>(),
{
size: 'md',
@@ -24,6 +34,7 @@ const props = withDefaults(
dismissable: true,
dialogStyle: undefined,
bodyStyle: undefined,
topmost: false,
},
)
@@ -86,24 +97,50 @@ function handleTab(e: KeyboardEvent) {
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (e.key === 'Escape' && props.closeOnEsc) {
// Only the topmost modal owns ESC. `modalStack[last]` is whichever
// Modal most recently flipped to open; non-topmost ones below it
// stay put.
const isTop = modalStack[modalStack.length - 1] === modalId
if (e.key === 'Escape' && props.closeOnEsc && isTop) {
e.preventDefault()
close()
return
}
if (e.key === 'Tab') {
if (e.key === 'Tab' && isTop) {
handleTab(e)
}
}
watch(
() => props.modelValue,
(v) => {
if (v) {
modalStack.push(modalId)
} else {
const i = modalStack.indexOf(modalId)
if (i >= 0) modalStack.splice(i, 1)
}
},
{ immediate: true },
)
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
const i = modalStack.indexOf(modalId)
if (i >= 0) modalStack.splice(i, 1)
})
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown">
<div
v-if="modelValue"
class="backdrop"
:class="{ topmost }"
@mousedown="onBackdropMouseDown"
>
<div
ref="dialogRef"
class="dialog"
@@ -150,6 +187,10 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
backdrop-filter: blur(2px);
}
.backdrop.topmost {
z-index: 1000;
}
.dialog {
background: var(--panel);
border: 1px solid var(--border-strong);