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:
1 parent
492f25f629
commit
d03fbe4ecb
15 files changed
+2529
-222
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user