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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user