1.2.0: SVG asset editor + end keyframe type + UX bundle

This release bundles three independently-useful pieces of work that
overlap heavily in shared files (types, store, i18n, App, CanvasStage,
tests), so they ship together.

SVG asset editor
- New asset kind 'svg' (SvgAsset { canvas, layers }), authored from
  inside the existing shape primitives (rect / circle / text / polygon /
  freeform). "+" button under the Images sidebar header; opens a
  sub-editor that swaps the library sidebar for a dedicated layer
  panel, hides the timeline, and uses the same canvas-resize flow
  ("Edit canvas size" via right-click → no Document settings entry
  while in scope). Children carry a single t=0 keyframe each.
- Multi-select rotation in SVG scope only: a temporary group pivot
  appears at the selection centroid (draggable), rotation around it
  shifts each layer's x/y/rotation. Pivot follows the centroid drift
  on Konva-transformer scale/move. Outside SVG, transformer
  rotateEnabled stays off (group-rotate write-back is unreliable
  per prior feedback).
- Authored SVGs are placed as layers in the main scene and rendered
  via drawMaskedGroup at t=0; thumbnails surface in the sidebar via
  renderSvgThumbnail. Drop / click / drag / dbl-click-to-edit work
  via a transparent svg-hit catcher rect routed in the stage handler.
- Freeform default stroke is red (#e23b3b) so the open path is
  visible immediately. addDrawingPoint resolves the layer through
  activeLayers so freeform draws correctly inside SVG and clip
  scopes too.

End keyframe type
- Keyframe.kind: 'normal' | 'end'. End kfs carry no props, mark the
  visibility cap. layerActiveRange / isLayerActiveAt / sampleKeyframeSet
  ignore them for interpolation and use them only as the visible-from
  / visible-until bounds.
- One-time migration in normalizeProject synthesizes an end kf at the
  time of the last normal kf for layers that relied on the legacy
  "last kf = disappears" semantics; layers using hold:true to extend
  visibility past the end skip the migration. Idempotent.
- Timeline UI: new "+end" toolbar button (multi-select fan-out, toggle),
  "Convert to end" / "Back to normal" entries in the kf context menu
  (easing / still-frame / duplicate hidden for end kfs), end kfs
  render as a flat axis-aligned square (vs. the diamond-rotated normal
  kf). Render filter drops end kfs that collide with a normal kf at
  the same time so legacy migrated projects don't read as four-point
  stars.

UX polish
- TAB-collapse stage anchoring: the canvas content stays put when the
  asset / properties asides toggle (CanvasStage's ResizeObserver
  compensates viewX/Y by the container's bounding-rect drift).
- Topbar clipboard button copies the current frame to the system
  clipboard as a PNG via navigator.clipboard.write([ClipboardItem]).
- chromeClick predicate adds .timeline to the editing-panels bail so
  the synthetic click after a Timeline box-select drag no longer
  wipes the just-committed kf selection.
- Right-click on a polygon vertex stops propagation so the vertex
  context menu wins instead of the generic stage menu opening on top.
This commit is contained in:
th committed 2026-04-25 21:45:58 +02:00
1 parent 492f25f629
commit d03fbe4ecb
15 files changed
+2529 -222

No files matched your search

+450
View File
@@ -0,0 +1,450 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
PhCircle,
PhDotsSixVertical,
PhEye,
PhEyeSlash,
PhImage,
PhLock,
PhLockOpen,
PhPencilSimple,
PhPolygon,
PhSquare,
PhTextT,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { promptDialog } from '@/components/ui'
import { t } from '@/i18n'
import type { Asset, Layer } from '@/types/scene'
const store = useSceneStore()
// The active SVG asset is the source of truth. When the user exits
// the scope this panel unmounts — App.vue swaps to AssetSidebar.
const layers = computed<Layer[]>(() => store.activeSvgAsset?.layers ?? [])
// Find the asset this layer renders. Used for the row icon and to
// keep the layer-list visually aligned with the main asset library.
const assetById = computed(() => {
const map = new Map<string, Asset>()
for (const a of store.scene.assets) map.set(a.id, a)
return map
})
function iconFor(layer: Layer) {
const asset = assetById.value.get(layer.assetId)
if (!asset) return PhSquare
switch (asset.kind) {
case 'rect': return PhSquare
case 'circle': return PhCircle
case 'text': return PhTextT
case 'polygon': return PhPolygon
case 'image': return PhImage
default: return PhSquare
}
}
// -- Shape creators (mirror AssetSidebar; the active scope routes
// them into the SVG asset's layer list automatically). --------
function addRect() {
const asset = store.addAsset({
kind: 'rect', name: 'Rect', width: 160, height: 100, fill: '#6ea8ff',
})
store.addLayer(asset.id)
}
function addCircle() {
const asset = store.addAsset({
kind: 'circle', name: 'Circle', radius: 60, fill: '#ff7a59',
})
store.addLayer(asset.id)
}
async function addText() {
const input = await promptDialog({
title: t('sidebar.newTextDialog.title'),
message: t('sidebar.newTextDialog.message'),
defaultValue: t('sidebar.newTextDialog.default'),
confirmLabel: t('sidebar.newTextDialog.confirm'),
})
if (input === null) return
const text = input.trim() || t('sidebar.newTextDialog.fallback')
const asset = store.addAsset({
kind: 'text',
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text,
fontSize: 48,
fill: '#f5d76e',
})
store.addLayer(asset.id)
}
async function addPolygon() {
const input = await promptDialog({
title: t('sidebar.newPolygonDialog.title'),
message: t('sidebar.newPolygonDialog.message'),
defaultValue: '6',
confirmLabel: t('sidebar.newPolygonDialog.confirm'),
})
if (input === null) return
const parsed = parseInt(input.trim(), 10)
const sides = Number.isFinite(parsed) ? Math.max(3, Math.min(24, parsed)) : 6
const radius = 60
const points: { x: number; y: number }[] = []
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
points.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius })
}
const asset = store.addAsset({
kind: 'polygon', name: `Polygon ${sides}`, points, fill: '#9b7ede',
})
store.addLayer(asset.id)
}
function startFreeform() {
store.startFreeformDrawing()
}
// -- Per-layer interactions ---------------------------------------
function selectLayer(layer: Layer, e: MouseEvent) {
if (e.shiftKey) {
store.toggleLayerSelection(layer.id)
} else {
store.selectLayer(layer.id)
}
}
function isSelected(layer: Layer): boolean {
return store.selectedLayerIds.includes(layer.id)
}
function toggleVisible(layer: Layer) {
store.setLayerEditorHidden(layer.id, !layer.editorHidden)
}
function toggleLocked(layer: Layer) {
store.setTrackLocked(layer.id, !layer.locked)
}
async function renameLayer(layer: Layer) {
const input = await promptDialog({
title: t('svgPanel.renameLayer'),
defaultValue: layer.name,
confirmLabel: t('svgPanel.renameConfirm'),
})
if (input === null) return
const trimmed = input.trim()
if (trimmed) store.setLayerName(layer.id, trimmed)
}
function removeLayer(layer: Layer) {
store.removeLayer(layer.id)
}
// -- Drag-to-reorder. Top of list = front of stack (same as the
// timeline's top-down semantics). -------------------------------
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(e: DragEvent, index: number) {
dragIndex.value = index
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
}
function onDragOver(e: DragEvent, index: number) {
if (dragIndex.value === null) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
dragOverIndex.value = index
}
function onDrop(e: DragEvent, index: number) {
e.preventDefault()
const from = dragIndex.value
dragIndex.value = null
dragOverIndex.value = null
if (from === null || from === index) return
store.moveLayer(from, index)
}
function onDragEnd() {
dragIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div class="svg-panel">
<div class="toolbar">
<button
class="tool-btn"
v-tooltip="t('sidebar.shape.rect')"
@click="addRect"
>
<PhSquare :size="16" />
</button>
<button
class="tool-btn"
v-tooltip="t('sidebar.shape.circle')"
@click="addCircle"
>
<PhCircle :size="16" />
</button>
<button
class="tool-btn"
v-tooltip="t('sidebar.shape.text')"
@click="addText"
>
<PhTextT :size="16" />
</button>
<button
class="tool-btn"
v-tooltip="t('sidebar.shape.polygon')"
@click="addPolygon"
>
<PhPolygon :size="16" />
</button>
<button
class="tool-btn"
:class="{ active: store.drawingMode === 'freeform' }"
v-tooltip="t('sidebar.shape.freeform')"
@click="startFreeform"
>
<PhPencilSimple :size="16" />
</button>
</div>
<div class="section-head-row">
<span class="section-title">{{ t('svgPanel.layers') }}</span>
<span class="section-count">{{ layers.length }}</span>
</div>
<ul class="layer-list">
<li
v-for="(layer, idx) in layers"
:key="layer.id"
class="layer-row"
:class="{
selected: isSelected(layer),
'drop-target': dragOverIndex === idx,
dragging: dragIndex === idx,
}"
draggable="true"
@click="selectLayer(layer, $event)"
@dragstart="onDragStart($event, idx)"
@dragover="onDragOver($event, idx)"
@drop="onDrop($event, idx)"
@dragend="onDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-grip" />
<component :is="iconFor(layer)" :size="13" class="layer-icon" />
<span class="layer-name" v-tooltip="layer.name">{{ layer.name }}</span>
<button
class="row-btn"
:class="{ off: layer.editorHidden }"
v-tooltip="layer.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="toggleVisible(layer)"
>
<component :is="layer.editorHidden ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
class="row-btn"
:class="{ on: layer.locked }"
v-tooltip="layer.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="toggleLocked(layer)"
>
<component :is="layer.locked ? PhLock : PhLockOpen" :size="12" />
</button>
<button
class="row-btn"
v-tooltip="t('svgPanel.renameLayer')"
@click.stop="renameLayer(layer)"
>
<PhPencilSimple :size="12" />
</button>
<button
class="row-btn danger"
v-tooltip="t('sidebar.deleteTooltip')"
@click.stop="removeLayer(layer)"
>
<PhTrash :size="12" />
</button>
</li>
<li v-if="!layers.length" class="empty">{{ t('svgPanel.empty') }}</li>
</ul>
</div>
</template>
<style scoped>
.svg-panel {
display: flex;
flex-direction: column;
padding: 10px 8px;
gap: 8px;
font-size: 12px;
}
.toolbar {
display: flex;
gap: 4px;
padding: 6px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.tool-btn {
flex: 1 1 0;
min-width: 0;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
}
.tool-btn:hover {
background: var(--panel);
color: var(--text);
}
.tool-btn.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.section-head-row {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px;
margin-top: 4px;
}
.section-title {
flex: 1 1 auto;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.section-count {
font-size: 10px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.layer-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.layer-row {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.layer-row:hover {
border-color: var(--border-strong);
}
.layer-row.selected {
background: var(--accent-soft);
border-color: var(--accent);
}
.layer-row.dragging {
opacity: 0.5;
}
.layer-row.drop-target {
border-color: var(--accent);
outline: 1px dashed var(--accent);
outline-offset: -2px;
}
.drag-grip {
color: var(--text-dim);
cursor: grab;
flex-shrink: 0;
}
.layer-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.layer-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.row-btn {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
flex-shrink: 0;
}
.row-btn:hover {
background: var(--panel);
color: var(--text);
border-color: var(--border);
}
.row-btn.off {
color: var(--text-dim);
opacity: 0.5;
}
.row-btn.on {
color: var(--accent);
}
.row-btn.danger:hover {
color: var(--danger);
border-color: var(--danger);
}
.empty {
padding: 16px 8px;
text-align: center;
color: var(--text-dim);
font-style: italic;
font-size: 11px;
}
</style>