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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.1.7",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+68
View File
@@ -0,0 +1,68 @@
# Plan — SVG asset authoring view
## Concept
A new asset kind `svg` (sibling to `button`) with its own sub-editor scope. Created from a "+" button under the Images section. The sub-editor reuses the canvas + shape primitives but has no time axis: each sub-layer is a single static frame.
## 1. Data model — `src/types/scene.ts`
- Extend `AssetKind` union with `'svg'`.
- Add `SvgAsset { id; kind: 'svg'; name; canvas: { width; height; background? }; layers: Layer[] }`.
- Add to the `Asset` discriminated union.
- Layers reuse the existing `Layer` type — single keyframe at `t=0` is all the renderer/sampler needs.
## 2. Store scope — `src/stores/scene.ts`
- New state: `activeSvgAssetId: string | null`.
- Actions (mirroring the button pair):
- `addSvgAsset(name, canvas)` → creates asset, returns it.
- `enterSvgAssetEdit(assetId)` → sets `activeSvgAssetId`, clears layer / clip / button selections, parks playhead at 0.
- `exitSvgAssetEdit()` → nulls scope + selection.
- The existing `addLayer(assetId)` and `addAsset(...)` already write into the active scope (button / clip / main); extend the same scope-resolver so `activeSvgAssetId` is recognised. That means `addSampleRect / addSampleCircle / addSampleText / addSamplePolygon / startFreeformDrawing` all work unchanged inside the SVG scope.
- Block keyframe-creating paths inside SVG scope (`upsertKeyframe`, the "+ keyframe" button, arrow-nudge keyframe writes) — silently bake into the single frame at `t=0` instead of inserting a new one. Cheaper and cleaner than scrubbing them out post-hoc.
## 3. AssetSidebar — `src/components/AssetSidebar.vue`
- Add a `section-action` "+" button to the Images section header (same pattern as Clips/Buttons section).
- Handler: prompt for name (and maybe canvas size — default `300×300`) → `addSvgAsset(...)` → `enterSvgAssetEdit(asset.id)`.
- SVG assets show up in the Images section list as their own row (icon + name + click-to-edit + drag-to-stage to place a layer that references the asset). Pattern matches Buttons section.
## 4. Sub-editor wiring — `src/App.vue`
- Scope footer: add a third branch alongside `activeClipId` / `activeButtonAssetId`. SvgAsset name + back button → `exitSvgAssetEdit()`.
- **Layout swap** (the "left side replaces library" bit): when `activeSvgAssetId` is truthy:
- The `.sidebar` slot renders a new `SvgLayerPanel` instead of `AssetSidebar`.
- The `.timeline` footer area is collapsed / hidden (toggle a `panels-no-timeline` class on `.app` that flips `--timeline-height` to 0 and skips rendering Timeline). Same machinery the TAB-panels feature uses, so the visual transition is consistent.
- Header transport (play/pause/rewind/playhead) stays but should be disabled in SVG scope — there's nothing to play. Easiest: gate them on `!activeSvgAssetId`.
## 5. New component — `src/components/SvgLayerPanel.vue`
A purpose-built layer list. Cannot extract from Timeline — its track-name column is too coupled to the time axis. Surface area:
- One row per layer in `asset.layers`, top = front.
- Per row: type icon, inline-rename name, eye toggle (`editorHidden`), lock toggle, delete.
- Drag-to-reorder calling `moveLayer`.
- Top toolbar with the same shape-creation buttons currently in AssetSidebar (rect / circle / text / polygon / freeform). Reuse the existing `addSample*` handlers — they already obey active scope once step 2 lands.
- Selection state: existing `selectedLayerIds` works as-is.
## 6. CanvasStage — `src/components/CanvasStage.vue`
- Already drives off `activeButtonAssetId` for the button stage. Add a parallel branch for `activeSvgAssetId` that resolves the source layers + canvas size from the SVG asset.
- Skip the playhead animation: sample layers at `t=0` regardless of `store.playhead`. (Or: lock playhead to 0 in SVG scope so existing sampling Just Works.)
- Disable / hide UI not relevant to authoring statics: keyframe overlays, in/out brackets, scrub guidance.
## 7. Rendering the SVG asset back in the main scene — `src/lib/render.ts`
When a layer references an `SvgAsset`, draw its sub-layers via the existing layer/group draw path at `t=0`, scaled into the asset's `canvas` rect (same idea as `drawButtonFrame`). New helper `drawSvgFrame(ctx, asset, scene, images)` — small, reuses `drawMaskedGroup` like the button code does.
## 8. Persistence
No work. `SvgAsset.layers` is just `Layer[]`; goes through `projectForSave` → JSON → `.ruw` round-trip transparently. The only gate is the `AssetKind` union — once that's extended, save/load + zip/unzip + scene-clone paths all continue to work unchanged.
## 9. i18n
Strings: section-action button label/tooltip, scope footer label, layer-panel toolbar tooltips, the new-svg prompt copy. EN + DE.
## 10. Tests (devtools/tests.ts)
- Store: `addSvgAsset` shape, `enterSvgAssetEdit` flips scope + clears selection, `exitSvgAssetEdit` reverses it, `addLayer` inside SVG scope appends to `asset.layers`.
- Sidebar: "+" button under Images creates an asset and enters edit mode.
- App: while `activeSvgAssetId`, sidebar shows the layer panel and the timeline area is hidden.
- Render: a scene containing a layer referencing an SVG asset draws the asset's child shapes (snapshot via canvas pixel sample, like the existing button rendering tests).
- Round-trip: save + reload preserves `kind: 'svg'` and the nested `layers`.
## 11. Decisions (confirmed)
- **Canvas size on creation**: inherit from current scene; user can resize later via the existing resize button.
- **Background**: transparent.
- **Placement after creation**: drop user straight into the sub-editor.
- **Existing keyframe data**: strip on entry — SVG sub-layers carry a single t=0 keyframe.
Implementation order: 1–2 (types + store) → 3 + 4 (sidebar entry + scope swap) → 5 (layer panel) → 6 (canvas stage scope) → 7 (main-scene render) → 8 (i18n) → 9 (tests).
+73 -3
View File
@@ -4,12 +4,14 @@ import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhArrowLeft,
PhClipboard,
PhCursorClick,
PhExport,
PhFilmStrip,
PhFolderOpen,
PhFloppyDisk,
PhGearSix,
PhImageSquare,
PhPause,
PhPencilSimpleLine,
PhPlay,
@@ -29,6 +31,7 @@ import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import SvgLayerPanel from './components/SvgLayerPanel.vue'
import Timeline from './components/Timeline.vue'
import TooltipHost from './components/TooltipHost.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
@@ -43,6 +46,7 @@ import { useDocumentDrawer } from './composables/useDocumentDrawer'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile'
import {
@@ -99,7 +103,10 @@ const appStyle = computed(() => ({
'--timeline-height': `${timelineHeight.value}px`,
}))
const appClass = computed(() => ({ 'panels-hidden': panelsHidden.value }))
const appClass = computed(() => ({
'panels-hidden': panelsHidden.value,
'no-timeline': !!store.activeSvgAssetId,
}))
let resizeStartY = 0
let resizeStartHeight = 0
@@ -283,6 +290,42 @@ function showSavedToast(msg: string) {
}, 2500)
}
/** Copy the current frame to the system clipboard as a PNG so the
* user can paste it into another app without going through the
* full export dialog. Uses the same drawSceneFrame path as the
* still-image export so the copied bitmap matches what the editor
* shows. Errors surface as a brief toast — clipboard writes need
* a user gesture, which the button click satisfies. */
async function copyStillToClipboard() {
if (typeof navigator === 'undefined' || !navigator.clipboard?.write) {
loadError.value = t('app.copyStill.unsupported')
setTimeout(() => { loadError.value = null }, 2500)
return
}
try {
const scene = JSON.parse(JSON.stringify(store.scene))
const blob = await renderSceneToStillImage(scene, store.playhead, 'png')
// ClipboardItem is widely available in modern Chromium / Firefox
// / Safari but TS lib doesn't always include it.
const ClipboardItemCtor =
(window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
if (!ClipboardItemCtor) {
loadError.value = t('app.copyStill.unsupported')
setTimeout(() => { loadError.value = null }, 2500)
return
}
await navigator.clipboard.write([
new ClipboardItemCtor({ 'image/png': blob }),
])
showSavedToast(t('app.copyStill.copied'))
} catch (err) {
loadError.value = t('app.copyStill.failed', {
msg: (err as Error).message ?? String(err),
})
setTimeout(() => { loadError.value = null }, 2500)
}
}
/** Boot policy: always restore the last edited project so the user
* picks up where they left off, regardless of the show-welcome
* preference. The welcome dialog still opens on top when the user
@@ -595,6 +638,13 @@ onBeforeUnmount(() => {
<button class="icon-btn" v-tooltip="t('app.recentProjects')" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
</button>
<button
class="icon-btn"
v-tooltip="{ label: t('app.copyStill.label'), help: t('app.copyStill.help') }"
@click="copyStillToClipboard"
>
<PhClipboard :size="16" />
</button>
<button
class="icon-btn save-btn"
:class="{ dirty: isDirty }"
@@ -624,7 +674,10 @@ onBeforeUnmount(() => {
</div>
</header>
<aside class="sidebar">
<div class="sidebar-scroll"><AssetSidebar /></div>
<div class="sidebar-scroll">
<SvgLayerPanel v-if="store.activeSvgAssetId" />
<AssetSidebar v-else />
</div>
<div v-if="store.activeClipId" class="scope-footer">
<button class="scope-back" @click="store.exitClip">
<PhArrowLeft :size="12" weight="bold" />
@@ -645,10 +698,20 @@ onBeforeUnmount(() => {
{{ store.activeButtonAsset?.name ?? '' }}
</span>
</div>
<div v-else-if="store.activeSvgAssetId" class="scope-footer">
<button class="scope-back" @click="store.exitSvgAssetEdit">
<PhArrowLeft :size="12" weight="bold" />
{{ t('app.mainTimeline') }}
</button>
<span class="scope-current" v-tooltip="store.activeSvgAsset?.name ?? ''">
<PhImageSquare :size="12" />
{{ store.activeSvgAsset?.name ?? '' }}
</span>
</div>
</aside>
<main class="stage"><CanvasStage /></main>
<aside class="properties"><PropertiesPanel /></aside>
<footer class="timeline">
<footer v-if="!store.activeSvgAssetId" class="timeline">
<div
class="tl-resize"
:class="{ resizing }"
@@ -726,6 +789,13 @@ onBeforeUnmount(() => {
grid-template-columns: 0 1fr 0;
}
/* SVG-asset edit scope: the time axis is meaningless for static
shapes, so the timeline track collapses to 0 height and the
stage absorbs the freed vertical space. */
.app.no-timeline {
grid-template-rows: 48px 1fr 0;
}
.topbar {
grid-area: top;
display: flex;
+125 -7
View File
@@ -33,7 +33,7 @@ import {
} from '@/composables/useClipDragSource'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
import { loadSceneImages, renderButtonThumbnail, renderSvgThumbnail } from '@/lib/render'
import { t } from '@/i18n'
const store = useSceneStore()
@@ -145,6 +145,46 @@ async function addSampleText() {
store.addLayer(asset.id)
}
async function addSvg() {
// Inherit canvas size from the current scene; the user can resize
// later via the canvas-resize button. Background defaults to
// transparent so the asset composites cleanly.
const nameInput = await promptDialog({
title: t('sidebar.newSvgDialog.title'),
message: t('sidebar.newSvgDialog.nameMessage'),
defaultValue: t('sidebar.newSvgDialog.defaultName'),
confirmLabel: t('sidebar.newSvgDialog.confirm'),
})
if (nameInput === null) return
const asset = store.addSvgAsset(
nameInput || t('sidebar.newSvgDialog.defaultName'),
)
store.enterSvgAssetEdit(asset.id)
}
async function renameSvgAssetPrompt(id: string, current: string) {
const input = await promptDialog({
title: t('sidebar.renameSvgDialog.title'),
defaultValue: current,
confirmLabel: t('sidebar.renameSvgDialog.confirm'),
})
if (input === null) return
const asset = store.scene.assets.find((a) => a.id === id)
if (asset && asset.kind === 'svg') asset.name = input.trim() || asset.name
}
async function deleteSvgAsset(id: string) {
const ok = await confirmDialog({
title: t('sidebar.deleteSvgDialog.title'),
message: t('sidebar.deleteSvgDialog.message'),
confirmLabel: t('sidebar.deleteSvgDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeAsset(id)
if (store.activeSvgAssetId === id) store.exitSvgAssetEdit()
}
async function addButton() {
const nameInput = await promptDialog({
title: t('sidebar.newButtonDialog.title'),
@@ -173,6 +213,11 @@ const buttonAssets = computed(() =>
)
const buttonThumbs = ref<Record<string, string | null>>({})
const svgThumbs = ref<Record<string, string | null>>({})
const svgAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'svg' }> => a.kind === 'svg'),
)
const sceneImageCache = ref<Record<string, HTMLImageElement>>({})
// Keep scene image cache in sync with the current scene's image assets so
@@ -207,6 +252,33 @@ watchEffect(() => {
buttonThumbs.value = out
})
// Sidebar thumbnails for SVG assets. Mirrors the button thumbnail
// effect: deep-touch each child layer's reactive surface so any
// edit inside the SVG sub-editor (add / remove / move shapes, kf
// prop changes) re-renders the tile.
watchEffect(() => {
if (!showThumbs.value) { svgThumbs.value = {}; return }
const out: Record<string, string | null> = {}
for (const sa of svgAssets.value) {
void sa.canvas.width
void sa.canvas.height
for (const l of sa.layers) {
void l.visible
void l.editorHidden
void l.offsetX
void l.offsetY
// Touch every keyframe + its props so prop edits (color, size,
// points etc.) propagate to the thumb without us listing them.
for (const kf of l.keyframes) {
void kf.time
void Object.keys(kf.props).length
}
}
out[sa.id] = renderSvgThumbnail(sa, store.scene, 80, sceneImageCache.value)
}
svgThumbs.value = out
})
// Shared ghost-installer: appends the node offscreen, hands it to
// setDragImage for the snapshot frame, then removes it on next tick.
function installGhost(e: DragEvent, node: HTMLElement) {
@@ -459,10 +531,16 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
polygon: PhPolygon,
button: PhCursorClick,
audio: PhMusicNote,
svg: PhImageSquare,
}
const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
// Images section shows both raster image assets and authoring SVG
// assets — the "+" button on its header creates a new SVG.
type ImageOrSvgAsset = Extract<Asset, { kind: 'image' | 'svg' }>
const imageAssets = computed<ImageOrSvgAsset[]>(() =>
store.scene.assets.filter(
(a): a is ImageOrSvgAsset => a.kind === 'image' || a.kind === 'svg',
),
)
const audioAssets = computed(() =>
@@ -497,7 +575,7 @@ async function deleteAudioAsset(id: string) {
store.removeAsset(id)
}
function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>) {
function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', asset.id)
@@ -514,6 +592,9 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
e.dataTransfer.setDragImage(thumbImg, ox, oy)
return
}
// SVG assets have no `src` to ghost against. Skip the offscreen
// image and let the browser fall back to rendering the row itself.
if (asset.kind === 'svg') return
// Fallback — create an offscreen image of the asset for use as drag ghost.
const ghost = document.createElement('img')
ghost.src = asset.src
@@ -605,18 +686,30 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<component :is="collapsedSections.images ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
<span class="section-title">{{ t('sidebar.images') }}</span>
</button>
<button class="section-action" v-tooltip="t('sidebar.createSvgTooltip')" @click.stop="addSvg">
<PhPlus :size="12" weight="bold" />
</button>
<span class="section-count">{{ imageAssets.length }}</span>
</div>
<ul v-show="!collapsedSections.images" class="assets" :class="{ compact: !showThumbs }">
<li
v-for="asset in imageAssets"
:key="asset.id"
:class="{ 'svg-asset': asset.kind === 'svg', editing: store.activeSvgAssetId === asset.id }"
draggable="true"
@dragstart="onAssetDragStart($event, asset)"
@dblclick="asset.kind === 'svg' ? store.enterSvgAssetEdit(asset.id) : undefined"
>
<template v-if="showThumbs">
<div class="thumb">
<img :src="asset.src" :alt="asset.name" draggable="false" />
<img v-if="asset.kind === 'image'" :src="asset.src" :alt="asset.name" draggable="false" />
<img
v-else-if="asset.kind === 'svg' && svgThumbs[asset.id]"
:src="svgThumbs[asset.id]!"
:alt="asset.name"
draggable="false"
/>
<PhImageSquare v-else :size="22" class="svg-thumb-icon" />
</div>
</template>
<template v-else>
@@ -624,12 +717,37 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</template>
<div class="meta">
<span class="name" v-tooltip="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
<span v-if="showThumbs" class="sub">
{{ asset.kind === 'svg' ? t('sidebar.svgLabel') : t('sidebar.imageLabel') }}
</span>
</div>
<template v-if="asset.kind === 'svg'">
<button
class="clip-btn"
v-tooltip="t('sidebar.editSvgTooltip')"
@click.stop="store.enterSvgAssetEdit(asset.id)"
>
<PhPencilSimple :size="11" />
</button>
<button
class="clip-btn"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameSvgAssetPrompt(asset.id, asset.name)"
>
<PhPencilSimpleLine :size="11" />
</button>
<button
class="clip-btn danger"
v-tooltip="t('sidebar.deleteTooltip')"
@click.stop="deleteSvgAsset(asset.id)"
>
<PhTrash :size="11" />
</button>
</template>
<button
class="add-layer"
v-tooltip="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
@click.stop="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
</button>
+521 -33
View File
@@ -32,6 +32,7 @@ import type {
Keyframe,
Layer,
Point,
SvgAsset,
} from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
@@ -107,7 +108,7 @@ function clamp(v: number, lo: number, hi: number) {
function fitToContainer() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
const ec = effectiveCanvas.value
const lw = ec.width
const lh = ec.height
if (!cw || !ch) return
@@ -120,7 +121,7 @@ function fitToContainer() {
function zoomToOne() {
const cw = containerSize.value.width
const ch = containerSize.value.height
const ec = store.activeButtonAsset ? store.activeButtonAsset.canvas : store.scene.canvas
const ec = effectiveCanvas.value
const lw = ec.width
const lh = ec.height
if (!cw || !ch) return
@@ -130,6 +131,8 @@ function zoomToOne() {
}
const effectiveCanvas = computed(() => {
const sa = store.activeSvgAsset
if (sa) return sa.canvas
const ba = store.activeButtonAsset
if (ba) return ba.canvas
return store.scene.canvas
@@ -408,6 +411,10 @@ interface CanvasResizeHandle {
}
const canvasResizeHandles = computed<CanvasResizeHandle[]>(() => {
// Inline handles are a button-asset only affordance. SVG-asset
// resize goes through the existing 8-handle stage-resize mode
// (right-click → "Stage resize") so the canvas stays visually
// clean instead of looking permanently in-resize.
if (!store.activeButtonAssetId) return []
const w = effectiveCanvas.value.width
const h = effectiveCanvas.value.height
@@ -434,19 +441,22 @@ function canvasResizeHandleConfig(h: CanvasResizeHandle) {
}
function onCanvasResizeDrag(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
const sa = store.activeSvgAsset
const ba = store.activeButtonAsset
if (!ba) return
const target = sa ?? ba
if (!target) return
const node = e.target
const s = 10 / zoom.value
let w = ba.canvas.width
let h = ba.canvas.height
let w = target.canvas.width
let h = target.canvas.height
if (edge === 'right' || edge === 'corner') {
w = Math.max(10, Math.round(node.x() + s / 2))
}
if (edge === 'bottom' || edge === 'corner') {
h = Math.max(10, Math.round(node.y() + s / 2))
}
store.setButtonAssetCanvas(ba.id, w, h)
if (sa) store.setSvgAssetCanvas(sa.id, w, h)
else if (ba) store.setButtonAssetCanvas(ba.id, w, h)
}
function onCanvasResizeDragEnd(e: Konva.KonvaEventObject<DragEvent>, edge: CanvasResizeEdge) {
@@ -530,6 +540,23 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
/** Invisible Konva rect that covers the SVG asset's canvas rect.
* Children are non-listening (buttonChildConfig) so without a hit
* surface the layer would fall through to the stage and stay
* unselectable. The catcher routes mousedown to the stage handler
* via its `svg-hit:${id}` name, mirroring the button-hit pattern. */
function svgHitCatcherConfig(asset: SvgAsset, layer: Layer) {
return {
x: 0,
y: 0,
width: asset.canvas.width,
height: asset.canvas.height,
fill: 'rgba(0,0,0,0.001)',
listening: !isLayerEffectivelyLocked(layer),
name: `svg-hit:${layer.id}`,
}
}
function buttonStackForTime(
asset: ButtonAsset,
stateTime: number,
@@ -548,6 +575,28 @@ function buttonStackForTime(
return out
}
/** Static render stack for an SVG asset placed in the main scene.
* All child keyframes live at t=0 by construction, so there's no
* per-state lookup — just sample each visible layer once. */
function svgStackForAsset(
asset: SvgAsset,
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
const layers = asset.layers.slice().reverse()
for (const l of layers) {
if (!l.visible) continue
if (l.editorHidden) continue
const a = assetMap.value.get(l.assetId)
if (!a) continue
out.push({ layer: l, asset: a, props: sampleLayer(l, 0) })
}
return out
}
function svgAssetFor(asset: Asset): SvgAsset | null {
return asset.kind === 'svg' ? asset : null
}
function setStageCursor(cursor: string) {
const el = stageRef.value?.getStage().container()
if (el) el.style.cursor = cursor
@@ -662,6 +711,20 @@ const centerCrosshairConfigs = computed(() => {
})
const backgroundConfig = computed(() => {
// SVG-asset edit: same dark "outside the document" fill as the
// button editor so the user sees where the asset's canvas ends.
// The asset itself composites with alpha; the dark backdrop is
// visual chrome only.
if (store.activeSvgAssetId) {
return {
x: 0,
y: 0,
width: effectiveCanvas.value.width,
height: effectiveCanvas.value.height,
listening: false,
fill: '#1a1a1f',
}
}
// In button-asset edit: checkerboard-ish transparent background so the
// user sees where the button's canvas ends.
if (store.activeButtonAssetId) {
@@ -854,6 +917,29 @@ const stackItems = computed<StackItem[]>(() => {
}
return out
}
// Svg-asset edit scope: render the asset's child layers at t=0.
// No time-axis filtering — every visible layer always shows.
if (store.activeSvgAssetId) {
const out: StackItem[] = []
const sa = store.activeSvgAsset
if (!sa) return out
const layers = sa.layers.slice().reverse()
for (const layer of layers) {
if (!layer.visible) continue
if (layer.editorHidden) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
out.push({
kind: 'layer',
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, 0), layer.id),
},
})
}
return out
}
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
if (store.activeClipId) {
const out: StackItem[] = []
@@ -1591,6 +1677,9 @@ const pivotInfo = computed<PivotInfo | null>(() => {
})
const pivotHandleConfig = computed(() => {
// Per-layer pivot only makes sense for a single selection. Multi
// selection has its own temp-pivot block below (SVG scope only).
if (store.selectedLayerIds.length > 1) return null
const info = pivotInfo.value
if (!info) return null
return {
@@ -1779,6 +1868,11 @@ const vertexMenu = ref<VertexMenuState | null>(null)
function onVertexContextMenu(e: Konva.KonvaEventObject<PointerEvent>, index: number) {
e.evt.preventDefault()
// Stop the DOM-level contextmenu from bubbling to the
// .stage-container handler, which would otherwise pop the
// generic stage menu (Document settings / Stage resize) on top
// of our vertex menu.
e.evt.stopPropagation()
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
@@ -2115,15 +2209,15 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
}
const transformerConfig = computed(() => {
// Rotation on a multi-selection would pin every node to a single
// shared pivot (the transformer bbox centre), which isn't what
// users expect — each layer has its own offset / pivot and should
// only rotate around its own origin. Disable rotation whenever
// more than one node is wrapped so the handle disappears and no
// ambiguous combined rotate can happen.
const multi = store.selectedLayerIds.length > 1
// Single selection → rotation is handled by the pivot-anchored
// custom handle below (the user-set pivot). The Konva transformer
// anchor stays off so we don't show two rotation knobs.
// Multi selection → group rotate has been tried and didn't work
// (write-back per node fights with the bbox-centred transform),
// so it stays disabled. The user explicitly preferred no
// rotation in multi-select over a half-broken group rotate.
return {
rotateEnabled: !multi,
rotateEnabled: false,
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
rotationSnapTolerance: 4,
enabledAnchors: [
@@ -2612,6 +2706,14 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
// SVG-scope multi-rotation handles. Their inner Konva nodes have
// no id and a custom name, so without these bails the fall-through
// below clears the multi-selection on mousedown — making multi-
// rotation / pivot-drag dissolve the selection they operate on.
if (target.findAncestor?.('.multi-pivot-handle')) return
if (target.findAncestor?.('.multi-rotation-handle')) return
if (target.name?.() === 'multi-pivot-handle') return
if (target.name?.() === 'multi-rotation-handle') return
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target.name?.() === 'tangent-handle') return
if (target.name?.() === 'path-frame-dot') return
@@ -2630,6 +2732,23 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
return
}
if (name.startsWith('svg-hit:')) {
// Hit-catcher inside an SVG-asset layer's group. Route the
// mousedown to the wrapping layer the same way a regular
// shape click would — including shift-toggle for multi.
const layerId = name.slice('svg-hit:'.length)
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) {
store.selectLayer(null)
return
}
if ((e.evt as MouseEvent).shiftKey) {
store.toggleLayerSelection(layerId)
} else if (!store.selectedLayerIds.includes(layerId)) {
store.selectLayer(layerId)
}
return
}
// Bubbled mousedown from a Konva node we don't recognise as a
// layer. Use selectLayer(null) — NOT deselectAll — so that any
// clip-instance selection just set by an inner @mousedown
@@ -2714,13 +2833,21 @@ function onContainerDblClick(e: MouseEvent) {
return
}
// Not drawing: polygon-specific gestures on selected polygon
// Not drawing: scope-entry shortcut for stage-placed sub-asset
// layers. Double-click an SVG-asset layer → enter its sub-editor
// (mirroring the sidebar's double-click behaviour). Other asset
// kinds fall through to the polygon-specific gestures below.
const selId = store.selectedLayerId
if (!selId) return
const layer = store.activeLayers.find((l) => l.id === selId)
if (!layer || layer.locked) return
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return
if (!asset) return
if (asset.kind === 'svg') {
store.enterSvgAssetEdit(asset.id)
return
}
if (asset.kind !== 'polygon') return
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
if (pts.length < 1) return
@@ -2962,6 +3089,7 @@ const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
if (store.selectedLayerIds.length > 1) return null
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
@@ -2984,6 +3112,7 @@ const rotationHandleConfig = computed(() => {
})
const rotationArmConfig = computed(() => {
if (store.selectedLayerIds.length > 1) return null
const info = pivotInfo.value
if (!info) return null
if (draggingPivot.value) return null
@@ -3053,6 +3182,252 @@ function onRotationHandleDragEnd() {
clearLivePoses()
}
// -------- SVG-scope multi-select rotation -------------------------
// In SVG edit mode only: when the user has 2+ layers selected we
// expose a *temporary* group pivot — defaults to the centroid of the
// selection but draggable. The rotation handle anchored to it
// rotates every selected layer around that pivot. The temp pivot is
// scope-local: dropped on deselect / scope exit, re-init'd next time.
interface MultiRotState {
/** Per-selected-layer pose snapshotted at drag start. We rotate
* these around the pivot; the live pose is what Konva renders
* during drag. Persisting in a snapshot keeps the maths stable
* even when liveLayerPoses changes mid-drag. */
startPoses: {
layerId: string
x: number; y: number
scaleX: number; scaleY: number
rotation: number
}[]
/** Pivot anchor at drag start. Stays constant for the duration
* of the rotation gesture. */
pivotX: number
pivotY: number
/** Where the rotation handle started in world coords — used as
* the zero-angle reference. */
startHandleX: number
startHandleY: number
}
/** Active only in SVG scope with a multi-selection. `null` clears
* the pivot — auto-reseeded by the watcher below when the user
* multi-selects again. */
const multiRotPivot = ref<{ x: number; y: number } | null>(null)
const multiRotDrag = ref<MultiRotState | null>(null)
const multiRotDelta = ref(0)
const draggingMultiPivot = ref(false)
const isMultiSvg = computed(() =>
!!store.activeSvgAssetId && store.selectedLayerIds.length >= 2,
)
/** Sample-based positions for every currently-selected layer. Live
* poses (mid-rotation / mid-transform) are honoured so the centroid
* follows the rendered position during a drag. Includes scale so
* the multi-rotation drag preserves it instead of snapping the
* element to scale 1 mid-rotate. */
function selectedLayerPoses() {
const out: {
layerId: string
x: number; y: number
scaleX: number; scaleY: number
rotation: number
}[] = []
for (const id of store.selectedLayerIds) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) continue
const live = liveLayerPoses.value[id]
const sampled = sampleLayer(layer, store.playhead)
out.push({
layerId: id,
x: live?.x ?? sampled.x,
y: live?.y ?? sampled.y,
scaleX: live?.scaleX ?? sampled.scaleX,
scaleY: live?.scaleY ?? sampled.scaleY,
rotation: live?.rotation ?? sampled.rotation,
})
}
return out
}
/** Centroid of the current selection. Used to seed the temp pivot
* when the user enters multi-select. */
function selectionCentroid(): { x: number; y: number } | null {
const poses = selectedLayerPoses()
if (!poses.length) return null
let sx = 0, sy = 0
for (const p of poses) { sx += p.x; sy += p.y }
return { x: sx / poses.length, y: sy / poses.length }
}
// Re-seed / drop the temp pivot when the selection changes.
watch(isMultiSvg, (active) => {
if (active) {
if (!multiRotPivot.value) multiRotPivot.value = selectionCentroid()
} else {
multiRotPivot.value = null
multiRotDrag.value = null
multiRotDelta.value = 0
}
})
// When the selection's centroid drifts (because the user is scaling
// or moving the multi-selection via the Konva transformer / drag),
// shift the pivot by the same delta so it stays attached to the
// selection. Skipped while:
// - the user is mid-rotation (rotation around a non-centroid pivot
// naturally moves the centroid; we don't want the pivot to chase
// its own rotation);
// - the user is dragging the pivot itself (it's their gesture, not
// a selection drift).
const liveSelectionCentroid = computed<{ x: number; y: number } | null>(() => {
if (!isMultiSvg.value) return null
return selectionCentroid()
})
watch(liveSelectionCentroid, (newC, oldC) => {
if (!newC || !oldC) return
if (!multiRotPivot.value) return
if (multiRotDrag.value) return
if (draggingMultiPivot.value) return
const dx = newC.x - oldC.x
const dy = newC.y - oldC.y
if (Math.abs(dx) < 1e-3 && Math.abs(dy) < 1e-3) return
multiRotPivot.value = {
x: multiRotPivot.value.x + dx,
y: multiRotPivot.value.y + dy,
}
})
const MULTI_ROT_ARM_PX = 56
const multiPivotHandleConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
return { x: p.x, y: p.y, draggable: true, name: 'multi-pivot-handle' }
})
const multiRotArmConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
if (draggingMultiPivot.value) return null
const arm = MULTI_ROT_ARM_PX / zoom.value
const ang = multiRotDelta.value
const rad = (ang * Math.PI) / 180
const endX = p.x + arm * Math.sin(rad)
const endY = p.y - arm * Math.cos(rad)
return {
points: [p.x, p.y, endX, endY],
stroke: '#f59e0b',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
listening: false,
}
})
const multiRotHandleConfig = computed(() => {
if (!isMultiSvg.value) return null
const p = multiRotPivot.value
if (!p) return null
if (draggingMultiPivot.value) return null
const arm = MULTI_ROT_ARM_PX / zoom.value
const ang = multiRotDelta.value
const rad = (ang * Math.PI) / 180
return {
x: p.x + arm * Math.sin(rad),
y: p.y - arm * Math.cos(rad),
draggable: true,
name: 'multi-rotation-handle',
}
})
const multiRotKnobConfig = computed(() => ({
radius: 6 / zoom.value,
fill: '#ffffff',
stroke: '#f59e0b',
strokeWidth: 2 / zoom.value,
}))
function onMultiPivotDragMove(e: Konva.KonvaEventObject<DragEvent>) {
draggingMultiPivot.value = true
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
}
function onMultiPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
draggingMultiPivot.value = false
multiRotPivot.value = { x: e.target.x(), y: e.target.y() }
}
function onMultiRotHandleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
const p = multiRotPivot.value
if (!p) return
multiRotDrag.value = {
startPoses: selectedLayerPoses().map((q) => ({ ...q })),
pivotX: p.x,
pivotY: p.y,
startHandleX: e.target.x(),
startHandleY: e.target.y(),
}
multiRotDelta.value = 0
}
function onMultiRotHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const d = multiRotDrag.value
if (!d) return
const ang0 = Math.atan2(d.startHandleX - d.pivotX, -(d.startHandleY - d.pivotY))
const ang1 = Math.atan2(e.target.x() - d.pivotX, -(e.target.y() - d.pivotY))
let delta = ((ang1 - ang0) * 180) / Math.PI
// Wrap to nearest equivalent so a small drag across ±180° doesn't
// suddenly flip a full turn.
const prev = multiRotDelta.value
while (delta - prev > 180) delta -= 360
while (delta - prev < -180) delta += 360
multiRotDelta.value = delta
const rad = (delta * Math.PI) / 180
const c = Math.cos(rad)
const s = Math.sin(rad)
const next: Record<string, LiveLayerPose> = { ...liveLayerPoses.value }
for (const start of d.startPoses) {
const dx = start.x - d.pivotX
const dy = start.y - d.pivotY
next[start.layerId] = {
x: d.pivotX + dx * c - dy * s,
y: d.pivotY + dx * s + dy * c,
// Carry the layer's own scale through the rotation —
// rotation around a pivot doesn't change scale, but if we
// default to 1 here a scaled element snaps to "scale 1"
// mid-drag and only restores after dragend writes back.
scaleX: start.scaleX,
scaleY: start.scaleY,
rotation: start.rotation + delta,
}
}
liveLayerPoses.value = next
}
function onMultiRotHandleDragEnd() {
const d = multiRotDrag.value
if (!d) {
multiRotDelta.value = 0
return
}
for (const start of d.startPoses) {
const live = liveLayerPoses.value[start.layerId]
if (!live) continue
store.upsertKeyframe(start.layerId, store.playhead, {
x: live.x,
y: live.y,
rotation: live.rotation,
})
}
multiRotDrag.value = null
multiRotDelta.value = 0
clearLivePoses()
}
let ro: ResizeObserver | null = null
function onWheel(e: WheelEvent) {
@@ -3275,14 +3650,22 @@ watch(maskPreview, async (on) => {
drawPreview()
})
// Re-fit the stage whenever the user enters / exits a button-asset edit
// session — the button has its own canvas and the framing must update.
// Re-fit the stage whenever the user enters / exits a button- or
// svg-asset edit session — sub-assets have their own canvas, and
// the framing must update so the new canvas lands inside the
// viewport instead of leaving the user staring at an empty corner.
watch(
() => store.activeButtonAssetId,
() => {
nextTick(() => fitToContainer())
},
)
watch(
() => store.activeSvgAssetId,
() => {
nextTick(() => fitToContainer())
},
)
function zoomBy(factor: number) {
const cw = containerSize.value.width
@@ -3309,12 +3692,31 @@ if (
didInitialFit = true
}
// Last known viewport position of the stage container. Updated by
// the ResizeObserver below; used to compensate viewX/Y when the
// container's screen position shifts (e.g. when TAB collapses the
// asset / properties asides) so the canvas content stays anchored
// to the same viewport pixel instead of riding the layout shift.
let lastContainerRect: { left: number; top: number } | null = null
onMounted(() => {
if (!container.value) return
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect
containerSize.value = { width: rect.width, height: rect.height }
if (!didInitialFit && rect.width > 0 && rect.height > 0) {
ro = new ResizeObserver(() => {
const el = container.value
if (!el) return
const r = el.getBoundingClientRect()
containerSize.value = { width: r.width, height: r.height }
if (lastContainerRect) {
const dx = r.left - lastContainerRect.left
const dy = r.top - lastContainerRect.top
// viewX/Y are container-local: shift them inversely so the
// rendered content keeps its viewport position when the
// container moves under it.
if (dx !== 0) viewX.value -= dx
if (dy !== 0) viewY.value -= dy
}
lastContainerRect = { left: r.left, top: r.top }
if (!didInitialFit && r.width > 0 && r.height > 0) {
fitToContainer()
didInitialFit = true
}
@@ -3479,19 +3881,36 @@ watch(stageResizeMode, (v) => {
/** Screen-space rect for the canvas — matches how Konva renders it
* (view offset + canvas * zoom). Handles are anchored to these
* positions. */
* positions. Tracks the *active* canvas: SVG-asset / button-asset /
* main scene, depending on edit scope. */
const stageScreenRect = computed(() => {
const z = zoom.value
const w = store.project.canvas.width * z
const h = store.project.canvas.height * z
const c = effectiveCanvas.value
return {
left: viewX.value,
top: viewY.value,
width: w,
height: h,
width: c.width * z,
height: c.height * z,
}
})
/** Apply a canvas-size change to whichever canvas the user is
* currently editing. SVG-asset edit and button-asset edit both
* carry their own internal canvas; otherwise it's the main scene. */
function setActiveCanvasSize(width: number, height: number) {
const sa = store.activeSvgAsset
if (sa) {
store.setSvgAssetCanvas(sa.id, width, height)
return
}
const ba = store.activeButtonAsset
if (ba) {
store.setButtonAssetCanvas(ba.id, width, height)
return
}
store.setCanvasSize(width, height)
}
function handleStyle(dir: ResizeHandleDir): Record<string, string> {
const rect = stageScreenRect.value
const half = 6 // handle is 12×12, centre the chip on the edge
@@ -3561,8 +3980,8 @@ function onResizeHandlePointerDown(e: PointerEvent, dir: ResizeHandleDir) {
dir,
startClientX: e.clientX,
startClientY: e.clientY,
startWidth: store.project.canvas.width,
startHeight: store.project.canvas.height,
startWidth: effectiveCanvas.value.width,
startHeight: effectiveCanvas.value.height,
appliedShiftX: 0,
appliedShiftY: 0,
pointerId: e.pointerId,
@@ -3582,10 +4001,18 @@ function onResizeHandlePointerMove(e: PointerEvent) {
drag.startWidth,
drag.startHeight,
)
store.setCanvasSize(width, height)
setActiveCanvasSize(width, height)
const deltaShiftX = shiftX - drag.appliedShiftX
const deltaShiftY = shiftY - drag.appliedShiftY
if (deltaShiftX !== 0 || deltaShiftY !== 0) {
// Shift only applies to the main-scene canvas — sub-asset edit
// scopes have their own coord system and don't need a global
// shift to keep content visible. North/west drags inside an SVG
// / button asset just resize without re-anchoring.
if (
!store.activeSvgAssetId &&
!store.activeButtonAssetId &&
(deltaShiftX !== 0 || deltaShiftY !== 0)
) {
store.shiftSceneWorldCoords(deltaShiftX, deltaShiftY)
drag.appliedShiftX = shiftX
drag.appliedShiftY = shiftY
@@ -3639,7 +4066,44 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<template v-if="entry.kind === 'layer'">
<v-group
v-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
>
<v-rect
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
/>
<template
v-for="(sub, sli) in svgStackForAsset(svgAssetFor(entry.item.asset)!)"
:key="`${entry.item.layer.id}:${sub.layer.id}:${sli}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"
:config="buttonChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
/>
<v-circle
v-else-if="sub.asset.kind === 'circle'"
:config="buttonChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
/>
<v-text
v-else-if="sub.asset.kind === 'text'"
:config="buttonChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="buttonChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
:config="buttonChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
/>
</template>
</v-group>
<v-group
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
@mouseenter="onButtonEnter(entry.item.layer)"
@mouseleave="onButtonLeave(entry.item.layer)"
@@ -3839,6 +4303,29 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<v-circle :config="rotationKnobConfig" />
</v-group>
<!-- SVG-scope multi-rotation: temp pivot + rotation arm.
Active only when isMultiSvg is true; the per-layer
handles above are gated off in that case. -->
<v-line v-if="multiRotArmConfig" :config="multiRotArmConfig" />
<v-group
v-if="multiPivotHandleConfig"
:config="multiPivotHandleConfig"
@dragmove="onMultiPivotDragMove"
@dragend="onMultiPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
<v-group
v-if="multiRotHandleConfig"
:config="multiRotHandleConfig"
@dragstart="onMultiRotHandleDragStart"
@dragmove="onMultiRotHandleDragMove"
@dragend="onMultiRotHandleDragEnd"
>
<v-circle :config="multiRotKnobConfig" />
</v-group>
<v-circle
v-for="h in polygonVertexHandles"
:key="`vh-${h.layerId}-${h.index}`"
@@ -4050,6 +4537,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
@contextmenu.prevent.stop
>
<button
v-if="!store.activeSvgAssetId"
type="button"
class="stage-menu-item"
@pointerdown.stop
@@ -4067,7 +4555,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
@click.stop="onMenuStageResize"
>
<PhFrameCorners :size="13" class="stage-menu-icon" />
{{ t('canvas.menu.stageResize') }}
{{ store.activeSvgAssetId ? t('canvas.menu.editCanvasSize') : t('canvas.menu.stageResize') }}
</button>
</div>
</div>
+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>
+184 -43
View File
@@ -23,6 +23,7 @@ import {
PhMusicNote,
PhPauseCircle,
PhPencilSimpleLine,
PhFlagBanner,
PhPlus,
PhPolygon,
PhScissors,
@@ -638,6 +639,37 @@ const canCreateKf = computed(() => {
return false
})
function setEndForSelected() {
// Toggle: if every selected layer already has an end kf at the
// playhead, remove them all. Otherwise set/move each layer's end
// kf to the playhead. Multi-select fan-out matches addKeyframeForSelected.
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
: store.selectedLayerId
? [store.selectedLayerId]
: []
if (!ids.length) return
const time = snapTime(store.playhead, { ignorePlayhead: true })
const allHaveAtTime = ids.every((id) => {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return false
const end = layer.keyframes.find((k) => k.kind === 'end')
return end && Math.abs(end.time - time) < 1e-6
})
for (const id of ids) {
if (isLayerIdLocked(id)) continue
if (allHaveAtTime) store.clearLayerEnd(id)
else store.setLayerEnd(id, time)
}
}
const canSetEnd = computed(() => {
// End kfs only make sense in the main timeline / clip-edit scope;
// button + svg sub-editors have no time axis.
if (store.activeButtonAssetId || store.activeSvgAssetId) return false
return canCreateKf.value
})
function addKeyframeForSelected() {
// Fan out across the whole multi-selection so the user can pin a
// frame on every grouped object at once. Each layer gets its own
@@ -1295,9 +1327,30 @@ interface KfSegment {
tail?: boolean
}
/** Render-time filter for the kf v-for. Hides end kfs that share a
* time slot with a normal kf — common after the end-kf migration
* on legacy projects, where stacking a square (end) on top of a
* diamond (normal) reads as a four-point star. The end kf stays
* in the data so visibility still cuts correctly; the user can
* promote the diamond via the kf context menu's "Convert to end"
* to dedupe in one click. */
function displayKfs(kfs: Keyframe[]): Keyframe[] {
const E = 1e-3
const normalTimes: number[] = []
for (const k of kfs) if (k.kind !== 'end') normalTimes.push(k.time)
return kfs.filter((k) => {
if (k.kind !== 'end') return true
return !normalTimes.some((t) => Math.abs(t - k.time) < E)
})
}
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
if (kfs.length === 0) return []
const sorted = [...kfs].sort((a, b) => a.time - b.time)
// End kfs are visibility markers, not interpolation participants.
// Pull them out for tail computation; everything else flows through
// the normal-kf segment loop.
const endKf = !buttonMode ? kfs.find((k) => k.kind === 'end') ?? null : null
const sorted = kfs.filter((k) => k.kind !== 'end').sort((a, b) => a.time - b.time)
const out: KfSegment[] = []
// Button-state timelines have discrete state frames rather than a
@@ -1322,16 +1375,17 @@ function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSe
return out
}
// Single keyframe means the layer is always visible (no animation
// range), so the line should span the entire timeline as a solid
// dim "on-stage" indicator — same treatment as a still-frame tail.
// Single keyframe → layer is visible from kf.time onward (or up to
// an end kf if the user set one). Render the on-stage tail in the
// visible range.
if (sorted.length === 1) {
const only = sorted[0]
if (duration > 0) {
const tailEnd = endKf?.time ?? duration
if (tailEnd > only.time) {
out.push({
id: `${only.id}>full`,
fromTime: 0,
toTime: duration,
fromTime: only.time,
toTime: tailEnd,
easing: only.easing,
held: false,
tail: true,
@@ -1351,15 +1405,16 @@ function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSe
held: !!a.hold,
})
}
// Last-keyframe "still frame" tail: when the final keyframe is held,
// extend the on-stage indicator to the end of the timeline. Solid
// and dim — the value isn't interpolating, it's just idling.
// Visibility tail past the last normal kf. Runs to the end-kf time
// when one's set, otherwise to scene-end (layer has no explicit
// disappear point).
const last = sorted[sorted.length - 1]
if (last.hold && duration > last.time) {
const tailEnd = endKf?.time ?? duration
if (tailEnd > last.time) {
out.push({
id: `${last.id}>tail`,
fromTime: last.time,
toTime: duration,
toTime: tailEnd,
easing: last.easing,
held: false,
tail: true,
@@ -1434,6 +1489,12 @@ interface KfMenuState {
isFirst: boolean
isLast: boolean
isHeld: boolean
isEnd: boolean
/** Layer already carries an end kf (somewhere). Drives the
* "Convert to end" toggle's enabled state — converting a normal
* kf to end while one already exists would create two end markers,
* so we route through the toggle path instead. */
hasEnd: boolean
}
const menu = ref<KfMenuState | null>(null)
@@ -1451,7 +1512,12 @@ function onKeyframeContextMenu(
const layer = store.activeLayers.find((l) => l.id === layerId)
const kf = kfs.find((k) => k.id === keyframeId)
if (!layer || !kf) return
const sorted = [...kfs].sort((a, b) => a.time - b.time)
// Sort excluding end kfs so isFirst / isLast describe the
// animation envelope, not the layer's visibility cap.
const sortedNormal = kfs
.filter((k) => k.kind !== 'end')
.sort((a, b) => a.time - b.time)
const hasEnd = layer.keyframes.some((k) => k.kind === 'end')
menu.value = {
layerId,
keyframeId: kf.id,
@@ -1459,9 +1525,11 @@ function onKeyframeContextMenu(
x: e.clientX,
y: e.clientY,
currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isFirst: sortedNormal[0]?.id === kf.id,
isLast: sortedNormal[sortedNormal.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
isEnd: kf.kind === 'end',
hasEnd,
}
store.selectLayer(layerId)
}
@@ -1472,6 +1540,36 @@ function toggleHold() {
menu.value = null
}
/** Convert the menu's keyframe between normal and end. Normal → end
* drops the kf's animatable props (an end kf has none); end →
* normal turns it into a property-snapshot kf with no props (so
* the layer renders from `base` at that time). The single-end-kf
* invariant holds: if the layer already has an end kf elsewhere
* we delete the existing one before promoting the new one. */
function toggleEndKind() {
const m = menu.value
if (!m) return
const layer = store.activeLayers.find((l) => l.id === m.layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === m.keyframeId)
if (!kf) return
if (kf.kind === 'end') {
kf.kind = 'normal'
delete kf.kind // drop the explicit 'normal' so JSON stays clean
} else {
if (m.hasEnd) {
layer.keyframes = layer.keyframes.filter((k) => k.kind !== 'end' || k.id === kf.id)
}
kf.kind = 'end'
kf.props = {}
kf.hold = false
}
// Re-sort just in case (sortKfs runs on insert; conversion doesn't
// change time but we keep it explicit for safety).
layer.keyframes.sort((a, b) => a.time - b.time)
menu.value = null
}
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
@@ -3235,6 +3333,15 @@ onBeforeUnmount(() => {
>
<PhPlus :size="12" :weight="'bold'" />
</button>
<button
v-if="canSetEnd"
class="snap-btn set-end-btn"
v-tooltip="t('timeline.setEndTooltip')"
:disabled="!canSetEnd"
@click="setEndForSelected"
>
<PhFlagBanner :size="12" :weight="'bold'" />
</button>
</div>
<div
class="scrub-ruler"
@@ -3451,11 +3558,12 @@ onBeforeUnmount(() => {
:style="segmentStyle(seg)"
/>
<span
v-for="kf in track.kfs"
v-for="kf in displayKfs(track.kfs)"
:key="kf.id"
class="kf"
:class="{
hold: kf.hold,
'kf-end': kf.kind === 'end',
selected: isKfSelected(track.layerId, track.kind, kf.id),
}"
:style="{ left: pxFromTime(kf.time) + 'px' }"
@@ -3463,9 +3571,11 @@ onBeforeUnmount(() => {
:data-kind="track.kind"
:data-kf-id="kf.id"
v-tooltip="
kf.hold
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
kf.kind === 'end'
? t('timeline.endKfTooltip', { time: formatKfTime(kf.time) })
: kf.hold
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id, track.kind)"
@pointermove="onKeyframePointerMove"
@@ -4006,44 +4116,61 @@ onBeforeUnmount(() => {
class="kf-menu"
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="kf-menu-header">Easing</div>
<template v-if="!menu.isEnd">
<div class="kf-menu-section">
<div class="kf-menu-header">Easing</div>
<button
v-for="opt in EASING_OPTIONS"
:key="opt.value"
class="kf-menu-item"
:class="{ active: menu.currentEasing === opt.value }"
:disabled="menu.isFirst"
@click="applyEasing(opt.value)"
>
<span class="kf-menu-icon">
<PhCheck
v-if="menu.currentEasing === opt.value"
:size="12"
weight="bold"
/>
</span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
</div>
<div class="kf-menu-divider"></div>
<button
v-for="opt in EASING_OPTIONS"
:key="opt.value"
class="kf-menu-item"
:class="{ active: menu.currentEasing === opt.value }"
:disabled="menu.isFirst"
@click="applyEasing(opt.value)"
:class="{ active: menu.isHeld }"
@click="toggleHold"
>
<span class="kf-menu-icon">
<PhCheck
v-if="menu.currentEasing === opt.value"
:size="12"
weight="bold"
/>
</span>
{{ opt.label }}
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
<PhCheck
v-if="menu.isHeld"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
</div>
<div class="kf-menu-divider"></div>
<div class="kf-menu-divider"></div>
</template>
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
@click="toggleHold"
:class="{ active: menu.isEnd }"
@click="toggleEndKind"
>
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
<PhFlagBanner :size="12" class="kf-menu-icon" />
{{ menu.isEnd ? t('timeline.endKfBackToNormal') : t('timeline.endKfMakeEnd') }}
<PhCheck
v-if="menu.isHeld"
v-if="menu.isEnd"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<button v-if="!menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
@@ -5289,6 +5416,20 @@ onBeforeUnmount(() => {
box-shadow: none;
}
/* End-kf: visibility cap. Same shape and colour as the normal kf
diamond but axis-aligned (no 45° rotation). Kill the inherited
transform transition so converting a normal kf to end doesn't
visibly rotate-tween through intermediate angles — should always
read as a clean upright square. */
.kf.kf-end {
transform: none;
transition: background 120ms var(--ease);
}
.kf.kf-end:hover {
background: #fff;
}
/* Button-editor empty-slot placeholder. Outlined accent-coloured
circle that stays fully opaque (no fade) so it's obvious the slot
is interactive; on hover the fill solidifies into the accent so
+691 -55
View File
@@ -850,74 +850,150 @@ export function registerAllTests(): void {
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('single kf at any time is always visible (static layer)', () => {
test('single kf at t>0: layer appears at the kf time and stays visible forever', () => {
// New model: the kf time is the visibility start, not just a
// property snapshot. A static-looking 1-kf layer placed at
// t=3 is invisible before that — matches the kf-as-cue
// semantic the rest of the system uses (no implicit "always
// visible" rule).
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
l.keyframes[0].time = 3
expect(isLayerActiveAt(l, 0)).toBe(true)
expect(isLayerActiveAt(l, 0)).toBe(false)
expect(isLayerActiveAt(l, 2.99)).toBe(false)
expect(isLayerActiveAt(l, 3)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('multi-kf: visible only in [firstKf, lastKf]', () => {
test('multi-kf without end kf: visible from first kf forward (no auto end)', () => {
// New model: the last NORMAL kf no longer terminates visibility.
// Without an explicit end kf the layer stays on stage to scene
// end. Authors who want the old "disappear at last kf" behaviour
// place an explicit end kf there (see the migration test below).
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
// kf@0 is still there; firstKf = 0, lastKf = 5
expect(isLayerActiveAt(l, -0.1)).toBe(false)
expect(isLayerActiveAt(l, 0)).toBe(true)
expect(isLayerActiveAt(l, 3)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('explicit end kf cuts visibility at its time', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
s.setLayerEnd(l.id, 5)
expect(isLayerActiveAt(l, 4.99)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(false)
expect(isLayerActiveAt(l, 6)).toBe(false)
})
test('clearLayerEnd restores "visible to scene end"', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.setLayerEnd(l.id, 4)
expect(isLayerActiveAt(l, 5)).toBe(false)
s.clearLayerEnd(l.id)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
})
test('dragging first kf forward hides layer before it', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
// Drag the first kf from t=0 to t=2.
const first = l.keyframes.find((k) => k.time === 0)!
first.time = 2
expect(isLayerActiveAt(l, 1)).toBe(false)
expect(isLayerActiveAt(l, 2)).toBe(true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
// No end kf → still visible past last normal kf.
expect(isLayerActiveAt(l, 6)).toBe(true)
})
test('last kf held: layer stays visible past its last keyframe', () => {
test('end kf carries no props (no influence on interpolation)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 0, { scaleX: 1 })
s.upsertKeyframe(l.id, 4, { scaleX: 5 })
s.setLayerEnd(l.id, 4)
// Sampling at t=2 must still interpolate halfway between the
// two normal kfs, ignoring the end kf at t=4.
expect(sampleLayer(l, 2).scaleX).toBe(3)
})
test('migration: legacy multi-kf layers gain an end kf at last-kf time', () => {
// Old projects relied on "last kf = disappear". On load
// (normalizeProject) we synthesize an end kf at the time of
// the last normal kf so the visual stays identical.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
// Mark the t=5 kf as hold. It's the last keyframe — used to be a
// no-op; now it means "stay on stage past the end of the anim".
const last = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeHold(l.id, last.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
// sampleLayer already pins to last.props for time >= last.time —
// confirm the value is the last keyframe's, not interpolating out.
expect(sampleLayer(l, 10).scaleX).toBe(2)
// Strip any pre-existing end kfs to simulate a pre-migration
// project, then round-trip through loadProject.
l.keyframes = l.keyframes.filter((k) => k.kind !== 'end')
const snapshot = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snapshot)
const restored = s2.scene.layers.find((x) => x.id === l.id)!
const endKf = restored.keyframes.find((k) => k.kind === 'end')
expect(endKf).toBeTruthy()
expect(endKf!.time).toBe(5)
})
test('non-last held kf does NOT extend visibility past end', () => {
// Hold on a middle keyframe only changes how it interpolates TO
// the following kf; it should not keep the layer alive past the
// final keyframe when that final kf itself isn't held.
test('migration is idempotent (already-migrated projects stay clean)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
const middle = l.keyframes.find((k) => k.time === 2)!
s.setKeyframeHold(l.id, middle.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
s.setLayerEnd(l.id, 7)
// Round-trip twice — the existing end kf at t=7 must be
// preserved (NOT replaced with an end kf at t=5).
const snap1 = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snap1)
const snap2 = JSON.parse(JSON.stringify(s2.projectForSave))
const s3 = makeFreshStore()
s3.loadProject(snap2)
const restored = s3.scene.layers.find((x) => x.id === l.id)!
const ends = restored.keyframes.filter((k) => k.kind === 'end')
expect(ends.length).toBe(1)
expect(ends[0].time).toBe(7)
})
test('migration skips layers whose last kf had hold:true (visible to scene end)', () => {
// Legacy hold:true on the last kf was the way to keep a layer
// visible past the animation. The new "no end kf = visible
// forever" rule does the same job, so the migration must NOT
// add an end kf for these layers.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
const last = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeHold(l.id, last.id, true)
l.keyframes = l.keyframes.filter((k) => k.kind !== 'end')
const snap = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snap)
const restored = s2.scene.layers.find((x) => x.id === l.id)!
expect(restored.keyframes.find((k) => k.kind === 'end')).toBeFalsy()
// Layer stays alive forever (= visible to scene end).
expect(isLayerActiveAt(restored, 99)).toBe(true)
})
})
@@ -4598,6 +4674,16 @@ export function registerAllTests(): void {
expect(recentsBtn).toBeTruthy()
})
test('topbar has a copy-still-to-clipboard button', () => {
// Sits next to the recent-projects button. Renders the still-
// image export through the Clipboard API on click — the button
// itself just needs to be present and labelled correctly.
const btn = document.querySelector(
'button[aria-label="Copy current frame"]',
)
expect(btn).toBeTruthy()
})
test('canvas has crop toggle button', () => {
const cropBtn = document.querySelector('.crop-btn')
expect(cropBtn).toBeTruthy()
@@ -4837,6 +4923,289 @@ export function registerAllTests(): void {
})
})
suite('timeline — end keyframe (set / toggle / render)', () => {
test('+end button sets an end kf on each selected layer at the playhead', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const lA = s.addLayer(rect.id)!
const lB = s.addLayer(rect.id)!
s.setPlayhead(4)
s.selectedLayerIds = [lA.id, lB.id]
s.selectedLayerId = lB.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn') as HTMLButtonElement
expect(btn).toBeTruthy()
btn.click()
await nextTick()
const endA = lA.keyframes.find((k) => k.kind === 'end')
const endB = lB.keyframes.find((k) => k.kind === 'end')
expect(endA).toBeTruthy()
expect(endB).toBeTruthy()
expect(endA!.time).toBe(4)
expect(endB!.time).toBe(4)
} finally {
handle.unmount()
}
})
test('+end clicked again at the same playhead removes the end kfs (toggle)', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.setPlayhead(3)
s.selectedLayerId = l.id
s.selectedLayerIds = [l.id]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn') as HTMLButtonElement
btn.click()
await nextTick()
expect(l.keyframes.find((k) => k.kind === 'end')).toBeTruthy()
// Same playhead, second click — toggle off.
btn.click()
await nextTick()
expect(l.keyframes.find((k) => k.kind === 'end')).toBeFalsy()
} finally {
handle.unmount()
}
})
test('+end at a new playhead moves the existing end kf instead of duplicating it', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.selectedLayerId = l.id
s.selectedLayerIds = [l.id]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn') as HTMLButtonElement
s.setPlayhead(2)
btn.click()
await nextTick()
s.setPlayhead(6)
btn.click()
await nextTick()
const ends = l.keyframes.filter((k) => k.kind === 'end')
expect(ends.length).toBe(1)
expect(ends[0].time).toBe(6)
} finally {
handle.unmount()
}
})
test('+end button is hidden in SVG sub-editor scope', async () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 60, 60)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.selectedLayerId = l.id
s.selectedLayerIds = [l.id]
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn')
expect(btn).toBeNull()
} finally {
handle.unmount()
s.exitSvgAssetEdit()
}
})
test('+end button is hidden in button-asset edit scope', async () => {
const s = makeFreshStore()
const ba = s.addButtonAsset('Btn', 80, 30)
s.enterButtonAssetEdit(ba.id)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const btn = handle.host.querySelector('.set-end-btn')
expect(btn).toBeNull()
} finally {
handle.unmount()
s.exitButtonAssetEdit()
}
})
test('end kf renders with the .kf-end class on the timeline lane', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
s.setLayerEnd(l.id, 7)
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const endNode = handle.host.querySelector('.track-lane .kf.kf-end') as HTMLElement
expect(endNode).toBeTruthy()
// The end kf should sit at its own time, not on the lane edge.
const lane = handle.host.querySelector('.track-lane') as HTMLElement
const laneRect = lane.getBoundingClientRect()
const endRect = endNode.getBoundingClientRect()
// end kf at t=7 of 10s scene should land roughly 70% across.
const ratio = (endRect.left + endRect.width / 2 - laneRect.left) / laneRect.width
expect(ratio > 0.5 && ratio < 0.95).toBe(true)
} finally {
handle.unmount()
}
})
test('visibility tail extends to scene end when no end kf is set', async () => {
// Behaviour shift: with the new model, layers without an end
// kf stay visible past their last normal kf. The on-stage tail
// must therefore render and extend across the rest of the
// timeline, not stop at the last kf.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 4, { scaleX: 2 })
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const tail = handle.host.querySelector('.track-lane .kf-seg.tail') as HTMLElement
expect(tail).toBeTruthy()
} finally {
handle.unmount()
}
})
test('right-click context menu on a normal kf shows the "Convert to end" entry', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 4, { scaleX: 2 })
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const kfs = handle.host.querySelectorAll('.track-lane .kf')
expect(kfs.length).toBeGreaterThan(0)
kfs[kfs.length - 1].dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')).map(
(el) => el.textContent?.trim() ?? '',
)
expect(items.some((t) => t.includes(i18nT('timeline.endKfMakeEnd')))).toBe(true)
} finally {
handle.unmount()
}
})
test('clicking "Convert to end" turns the kf into an end kf and drops its props', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 4, { scaleX: 2 })
s.selectedLayerId = l.id
const target = l.keyframes.find((k) => Math.abs(k.time - 4) < 1e-3)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
// Open the context menu by querySelector → the kf at t=4 has
// a stable data-kf-id so we can pick it directly.
const node = handle.host.querySelector(
`.track-lane .kf[data-kf-id="${target.id}"]`,
) as HTMLElement
expect(node).toBeTruthy()
node.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
const convertBtn = Array.from(menu.querySelectorAll('.kf-menu-item')).find(
(el) => el.textContent?.includes(i18nT('timeline.endKfMakeEnd')),
) as HTMLButtonElement | undefined
expect(convertBtn).toBeTruthy()
convertBtn!.click()
await nextTick()
const updated = l.keyframes.find((k) => k.id === target.id)!
expect(updated.kind).toBe('end')
// End kf must carry no props.
expect(Object.keys(updated.props).length).toBe(0)
} finally {
handle.unmount()
}
})
test('end kf colliding with a normal kf at the same time is hidden from the lane', async () => {
// Migration on legacy projects places an end kf at the time
// of the last normal kf. Rendering both diamond + square at
// the same x reads as a four-point star — filter the end kf
// out of the v-for; visibility still cuts via the data layer.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
s.setLayerEnd(l.id, 5)
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
// Two kfs in the data, one rendered (the diamond).
expect(l.keyframes.filter((k) => Math.abs(k.time - 5) < 1e-3).length).toBe(2)
const lane = handle.host.querySelector('.track-lane') as HTMLElement
expect(lane).toBeTruthy()
const ends = lane.querySelectorAll('.kf.kf-end')
expect(ends.length).toBe(0)
// Visibility still cuts: the layer must be invisible past t=5.
const { isLayerActiveAt } = await import('@/lib/interpolate')
expect(isLayerActiveAt(l, 6)).toBe(false)
} finally {
handle.unmount()
}
})
test('right-click on an end kf shows "Back to normal" but no easing/duplicate', async () => {
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(rect.id)!
s.setLayerEnd(l.id, 6)
s.selectedLayerId = l.id
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const endNode = handle.host.querySelector('.track-lane .kf.kf-end') as HTMLElement
expect(endNode).toBeTruthy()
endNode.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const items = Array.from(menu.querySelectorAll('.kf-menu-item')).map(
(el) => el.textContent?.trim() ?? '',
)
// The convert label flips to "Back to normal".
expect(items.some((t) => t.includes(i18nT('timeline.endKfBackToNormal')))).toBe(true)
// Easing block + still-frame + duplicate are hidden for end kfs.
expect(menu.querySelector('.kf-menu-header')).toBeNull()
expect(items.some((t) => t.includes(i18nT('timeline.stillFrame')))).toBe(false)
expect(items.some((t) => t.includes(i18nT('timeline.duplicate')))).toBe(false)
} finally {
handle.unmount()
}
})
})
suite('timeline — backspace deletes selected layer', () => {
// Regression: Backspace / Delete used to only remove selected
// keyframes and markers. The selected layer track had no shortcut
@@ -6264,12 +6633,14 @@ export function registerAllTests(): void {
}
})
test('clicks on empty timeline tracks → deselect, on timeline elements → keep', async () => {
// Empty timeline space (gaps below tracks, the .tracks
// background) should clear the selection. Timeline elements
// that own their own selection semantics (.kf, .clip-block,
// .track-name) must NOT trigger deselect — letting them would
// immediately undo the click that just selected something.
test('clicks anywhere inside the timeline → keep selection', async () => {
// Regression: after a kf box-select drag, the synthetic click
// event still fires on the document and used to fall through
// to the global deselect, wiping the just-committed
// selection. Timeline owns its own selection model — bare
// empty-tracks clicks call deselectAll() from inside the
// component — so the chrome handler must bail on the whole
// .timeline subtree.
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
const tl = document.createElement('div')
tl.className = 'timeline'
@@ -6285,9 +6656,11 @@ export function registerAllTests(): void {
tl.appendChild(tracks)
document.body.appendChild(tl)
try {
// Background within the timeline → deselect.
expect(shouldChromeClickDeselect(tracks)).toBe(true)
// Selection-managing children → keep.
// Every subtree inside the timeline keeps the selection —
// including the .tracks background (synthetic-click target
// after a box-select drag).
expect(shouldChromeClickDeselect(tl)).toBe(false)
expect(shouldChromeClickDeselect(tracks)).toBe(false)
expect(shouldChromeClickDeselect(kf)).toBe(false)
expect(shouldChromeClickDeselect(trackName)).toBe(false)
expect(shouldChromeClickDeselect(trackLane)).toBe(false)
@@ -6326,6 +6699,31 @@ export function registerAllTests(): void {
expect(shouldChromeClickDeselect(null)).toBe(false)
})
test('synthetic click after a Timeline box-select must NOT wipe kf selection', async () => {
// Regression: after a successful kf box-select the browser
// fires a synthetic `click` on the .tracks background. If
// the global chrome-deselect handler treated that as inert
// chrome, deselectAll() would run AFTER the box-select had
// populated selectedKfs and erase it. The fix lives in the
// shouldChromeClickDeselect predicate — anything inside
// `.timeline` must bail, regardless of which inner element
// received the click.
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
const tl = document.createElement('div')
tl.className = 'timeline'
const tracks = document.createElement('div')
tracks.className = 'tracks'
tl.appendChild(tracks)
document.body.appendChild(tl)
try {
// Simulating "click target = .tracks background after a
// drag" — predicate returns false → deselectAll never fires.
expect(shouldChromeClickDeselect(tracks)).toBe(false)
} finally {
tl.remove()
}
})
test('useDeselectAll clears layers, clip instances and keyframes', async () => {
// Single helper used from every "empty click" surface — chrome
// (App.vue), empty stage (CanvasStage), empty tracks (Timeline)
@@ -9929,11 +10327,12 @@ export function registerAllTests(): void {
}
})
test('multi-select disables rotateEnabled on the transformer config', async () => {
// Rotation on a group pins every node to the transformer
// bbox centre, which isn't what users want per-layer — we
// turn the rotate handle off whenever more than one node is
// wrapped so the gesture is simply unavailable.
test('transformer rotateEnabled stays off in every selection state', async () => {
// Single selection delegates rotation to the custom pivot
// handle. Multi selection has no rotation at all — group
// rotate via Konva's built-in didn't work cleanly (write-back
// per node fights the bbox-centred transform), so it's
// explicitly disabled.
const s = makeFreshStore()
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
const rect = s.addAsset({ kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00' })
@@ -9950,23 +10349,20 @@ export function registerAllTests(): void {
const stage = await getKonvaStage() as unknown as {
find: (sel: string) => unknown[]
}
const transformers = stage.find('Transformer') as Array<{
nodes: () => unknown[]
rotateEnabled: () => boolean
}>
const main = transformers.find((tr) => tr.nodes().length > 1)
expect(main).toBeTruthy()
expect(main!.rotateEnabled()).toBe(false)
// Drop back to a single-layer selection → rotate is back.
type Tr = { nodes: () => unknown[]; rotateEnabled: () => boolean }
const multiTr = (stage.find('Transformer') as Tr[]).find(
(tr) => tr.nodes().length > 1,
)
expect(multiTr).toBeTruthy()
expect(multiTr!.rotateEnabled()).toBe(false)
s.selectLayer(lA.id)
await nextTick()
await tick(20)
const refreshed = (stage.find('Transformer') as Array<{
nodes: () => unknown[]
rotateEnabled: () => boolean
}>).find((tr) => tr.nodes().length === 1)
expect(refreshed).toBeTruthy()
expect(refreshed!.rotateEnabled()).toBe(true)
const singleTr = (stage.find('Transformer') as Tr[]).find(
(tr) => tr.nodes().length === 1,
)
expect(singleTr).toBeTruthy()
expect(singleTr!.rotateEnabled()).toBe(false)
} finally {
handle.unmount()
}
@@ -13379,4 +13775,244 @@ export function registerAllTests(): void {
expect(obscured(200, 0)).toBe(true)
})
})
suite('svg asset — store + edit mode', () => {
test('addSvgAsset inherits the current scene canvas', () => {
const s = makeFreshStore()
s.scene.canvas.width = 480
s.scene.canvas.height = 270
const asset = s.addSvgAsset('Logo')
expect(asset.kind).toBe('svg')
expect(asset.name).toBe('Logo')
expect(asset.canvas.width).toBe(480)
expect(asset.canvas.height).toBe(270)
expect(asset.layers).toEqual([])
})
test('enterSvgAssetEdit flips scope and clears competing scopes', () => {
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
s.activeClipId = 'fake-clip'
s.activeButtonAssetId = 'fake-btn'
s.enterSvgAssetEdit(asset.id)
expect(s.activeSvgAssetId).toBe(asset.id)
expect(s.activeClipId).toBeNull()
expect(s.activeButtonAssetId).toBeNull()
expect(s.playhead).toBe(0)
expect(s.activeSvgAsset?.id).toBe(asset.id)
})
test('addLayer in svg scope appends to asset.layers, not the scene', () => {
const s = makeFreshStore()
const before = s.scene.layers.length
const asset = s.addSvgAsset('Logo')
s.enterSvgAssetEdit(asset.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
const layer = s.addLayer(rect.id)!
expect(layer).toBeTruthy()
expect(asset.layers.length).toBe(1)
expect(asset.layers[0].id).toBe(layer.id)
// The main scene's layer list and trackOrder must be untouched.
expect(s.scene.layers.length).toBe(before)
expect((s.scene.trackOrder ?? []).includes(layer.id)).toBe(false)
})
test('upsertKeyframe in svg scope collapses onto t=0', () => {
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
s.enterSvgAssetEdit(asset.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
const layer = s.addLayer(rect.id)!
// Try to write at t=2; expected to merge into the existing
// single keyframe at t=0 instead of inserting a second one.
s.upsertKeyframe(layer.id, 2, { x: 99 })
expect(layer.keyframes.length).toBe(1)
expect(layer.keyframes[0].time).toBe(0)
expect(layer.keyframes[0].props.x).toBe(99)
})
test('exitSvgAssetEdit returns to the main timeline scope', () => {
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
s.enterSvgAssetEdit(asset.id)
s.exitSvgAssetEdit()
expect(s.activeSvgAssetId).toBeNull()
expect(s.selectedLayerId).toBeNull()
expect(s.activeLayers).toBe(s.scene.layers)
})
test('enterSvgAssetEdit strips multi-keyframe layers down to t=0', () => {
// Pasted / migrated layers might carry multiple keyframes. The
// sub-editor would silently hide them behind the static stage,
// so the entry sanitises down to a single t=0 entry per layer.
const s = makeFreshStore()
const asset = s.addSvgAsset('Logo')
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
// Author the layer through the normal scope, then hand-edit
// its keyframes to simulate a migrated multi-kf layer before
// re-entering the scope.
s.enterSvgAssetEdit(asset.id)
const layer = s.addLayer(rect.id)!
s.exitSvgAssetEdit()
layer.keyframes.push({ id: 'extra', time: 5, props: { x: 100 }, easing: 'linear' })
s.enterSvgAssetEdit(asset.id)
expect(asset.layers[0].keyframes.length).toBe(1)
expect(asset.layers[0].keyframes[0].time).toBe(0)
})
})
suite('svg asset — sidebar + scope wiring', () => {
test('+ button under Images creates an SVG and enters edit mode', async () => {
// Stub the prompt so the test runs unattended. The button's
// click handler awaits promptDialog; we resolve immediately
// and tick the awaited continuation.
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
try {
await nextTick()
const beforeAssets = s.scene.assets.length
const btn = handle.host.querySelector(
`button[aria-label="${i18nT('sidebar.createSvgTooltip')}"]`,
) as HTMLButtonElement
expect(btn).toBeTruthy()
btn.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Logo')
// Allow the awaited handler chain to settle: addSvgAsset +
// enterSvgAssetEdit both run on the next microtask.
await nextTick()
await nextTick()
expect(s.scene.assets.length).toBe(beforeAssets + 1)
const created = s.scene.assets[s.scene.assets.length - 1]
expect(created.kind).toBe('svg')
expect(s.activeSvgAssetId).toBe(created.id)
} finally {
handle.unmount()
// Reset scope so subsequent suites don't inherit the SVG mode.
s.exitSvgAssetEdit()
}
})
})
suite('svg asset — main-scene render', () => {
test('drawSceneFrame composites SVG child shapes into the main frame', async () => {
// End-to-end: an SVG asset with one bright child rect, placed
// as a layer in the scene, should leave non-transparent pixels
// in the rendered frame.
const { drawSceneFrame, loadSceneImages } = await import('@/lib/render')
const s = makeFreshStore()
s.setDuration(1)
s.scene.canvas.width = 60
s.scene.canvas.height = 60
s.scene.canvas.background = 'transparent'
const svg = s.addSvgAsset('Logo', 60, 60)
// Author one child rect via the sub-editor scope.
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'Square', width: 40, height: 40, fill: '#22cc88',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
// Place the SVG asset as a top-level scene layer.
s.addLayer(svg.id)
const canvas = document.createElement('canvas')
canvas.width = 60; canvas.height = 60
const ctx = canvas.getContext('2d')!
const imgs = await loadSceneImages(s.scene)
drawSceneFrame(ctx, s.scene, 0, imgs)
// Center pixel should carry the green fill bleed-through.
const px = ctx.getImageData(30, 30, 1, 1).data
expect(px[0] + px[1] + px[2] > 0).toBe(true)
expect(px[3] > 0).toBe(true)
})
test('SVG asset round-trips through projectForSave + loadProject', () => {
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 100, 80)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
const snapshot = JSON.parse(JSON.stringify(s.projectForSave))
const s2 = makeFreshStore()
s2.loadProject(snapshot)
const restored = s2.scene.assets.find((a) => a.kind === 'svg')
expect(restored).toBeTruthy()
expect(restored!.kind).toBe('svg')
// Asset list survived; nested layers carried through.
const restoredAsset = restored as Extract<typeof restored, { kind: 'svg' }>
expect(restoredAsset.canvas.width).toBe(100)
expect(restoredAsset.canvas.height).toBe(80)
expect(restoredAsset.layers.length).toBe(1)
})
})
suite('svg asset — thumbnail rendering', () => {
test('renderSvgThumbnail returns a data URL for non-empty assets', async () => {
const { renderSvgThumbnail } = await import('@/lib/render')
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 80, 80)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 60, height: 60, fill: '#22cc88',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
const url = renderSvgThumbnail(svg, s.scene, 64)
expect(url).toBeTruthy()
expect(url!.startsWith('data:image/png;base64,')).toBe(true)
})
test('renderSvgThumbnail returns null for empty SVGs', async () => {
const { renderSvgThumbnail } = await import('@/lib/render')
const s = makeFreshStore()
const svg = s.addSvgAsset('Empty', 80, 80)
expect(renderSvgThumbnail(svg, s.scene, 64)).toBeNull()
})
test('AssetSidebar renders an <img> thumb for an SVG asset with content', async () => {
// Regression for the inline placeholder: with showThumbs on
// and an SVG that has child layers, the row should render a
// real <img> (data-URL) instead of the icon fallback.
const s = makeFreshStore()
const svg = s.addSvgAsset('Logo', 80, 80)
s.enterSvgAssetEdit(svg.id)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 60, height: 60, fill: '#22cc88',
})
s.addLayer(rect.id)
s.exitSvgAssetEdit()
// Force showThumbs on so the sidebar takes the thumbnail branch.
const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
const { enabled } = useAssetThumbsPref()
const prior = enabled.value
enabled.value = true
const handle = mountComponent(AssetSidebar, {})
try {
// Two ticks: one for the watchEffect, one for the DOM
// render to settle after svgThumbs[id] populates.
await nextTick()
await nextTick()
const row = handle.host.querySelector('.assets li.svg-asset') as HTMLElement
expect(row).toBeTruthy()
const img = row.querySelector('.thumb img') as HTMLImageElement | null
expect(img).toBeTruthy()
expect(img!.src.startsWith('data:image/png;base64,')).toBe(true)
} finally {
handle.unmount()
enabled.value = prior
}
})
})
}
+70
View File
@@ -7,6 +7,13 @@ export const en = {
play: 'Play (Space)',
stop: 'Stop',
recentProjects: 'Recent projects',
copyStill: {
label: 'Copy current frame',
help: 'Copies the current frame as a PNG to the clipboard.',
copied: 'Frame copied to clipboard',
unsupported: 'Clipboard images are not supported in this browser.',
failed: 'Copy failed: {msg}',
},
saveProject: 'Save project (Ctrl/Cmd+S)',
saveProjectDirty: 'Save project (unsaved changes, Ctrl/Cmd+S)',
documentSettings: 'Document settings',
@@ -127,6 +134,24 @@ export const en = {
message: 'The button asset will be removed. Any layers that reference it will no longer render.',
confirm: 'Delete',
},
createSvgTooltip: 'Create new SVG asset',
editSvgTooltip: 'Open SVG editor',
svgLabel: 'SVG',
newSvgDialog: {
title: 'New SVG',
nameMessage: 'SVG name:',
defaultName: 'SVG',
confirm: 'Create',
},
renameSvgDialog: {
title: 'Rename SVG',
confirm: 'Apply',
},
deleteSvgDialog: {
title: 'Delete SVG?',
message: 'The SVG asset will be removed. Any layers that reference it will no longer render.',
confirm: 'Delete',
},
newTextDialog: {
title: 'New text object',
message: 'Text:',
@@ -239,6 +264,10 @@ export const en = {
hitAreaEmpty: 'No hit shapes yet — add a rect or circle from the canvas.',
hitTrackDelete: 'Delete hit shape',
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
setEndTooltip: 'Set / move end keyframe to the playhead — layer disappears here',
endKfTooltip: 'End keyframe {time} — layer disappears at this time',
endKfMakeEnd: 'Convert to end keyframe',
endKfBackToNormal: 'Back to normal keyframe',
groupChildCount: '{n} tracks',
groupEmpty: 'Drop tracks here',
renameGroupDialog: {
@@ -355,6 +384,7 @@ export const en = {
menu: {
documentSettings: 'Document settings',
stageResize: 'Resize stage',
editCanvasSize: 'Edit canvas size',
},
resize: {
banner: 'Resizing stage',
@@ -550,6 +580,13 @@ export const en = {
projectName: 'Project',
},
svgPanel: {
layers: 'Layers',
empty: 'No shapes yet — pick a tool above to add one.',
renameLayer: 'Rename layer',
renameConfirm: 'Apply',
},
scenes: {
title: 'Scenes',
open: 'Open scenes',
@@ -1048,6 +1085,13 @@ export const de: DeepPartial<Messages> = {
play: 'Abspielen (Leertaste)',
stop: 'Stop',
recentProjects: 'Letzte Projekte',
copyStill: {
label: 'Aktuellen Frame kopieren',
help: 'Kopiert den aktuellen Frame als PNG in die Zwischenablage.',
copied: 'Frame in Zwischenablage kopiert',
unsupported: 'Bilder in der Zwischenablage werden in diesem Browser nicht unterstützt.',
failed: 'Kopieren fehlgeschlagen: {msg}',
},
saveProject: 'Projekt speichern (Ctrl/Cmd+S)',
saveProjectDirty: 'Projekt speichern (ungespeicherte Änderungen, Ctrl/Cmd+S)',
documentSettings: 'Dokumenteinstellungen',
@@ -1154,6 +1198,25 @@ export const de: DeepPartial<Messages> = {
'Das Button-Asset wird entfernt. Ebenen, die darauf verweisen, werden danach nicht mehr gerendert.',
confirm: 'Löschen',
},
createSvgTooltip: 'Neues SVG-Asset anlegen',
editSvgTooltip: 'SVG-Editor öffnen',
svgLabel: 'SVG',
newSvgDialog: {
title: 'Neues SVG',
nameMessage: 'SVG-Name:',
defaultName: 'SVG',
confirm: 'Erstellen',
},
renameSvgDialog: {
title: 'SVG umbenennen',
confirm: 'Übernehmen',
},
deleteSvgDialog: {
title: 'SVG löschen?',
message:
'Das SVG-Asset wird entfernt. Ebenen, die darauf verweisen, werden danach nicht mehr gerendert.',
confirm: 'Löschen',
},
newTextDialog: {
title: 'Neues Textobjekt',
message: 'Text:',
@@ -1359,6 +1422,13 @@ export const de: DeepPartial<Messages> = {
projectName: 'Projekt',
},
svgPanel: {
layers: 'Ebenen',
empty: 'Noch keine Formen — wähle oben ein Werkzeug.',
renameLayer: 'Ebene umbenennen',
renameConfirm: 'Übernehmen',
},
scenes: {
title: 'Szenen',
open: 'Szenen öffnen',
+13 -24
View File
@@ -15,39 +15,28 @@ const SELECTOR_OVERLAYS =
'.scene-drawer, .clipart-drawer, .document-drawer, ' +
'.fs-root, .toast'
/** Properties panel is an editing surface — clicking around inputs
* while editing must not yank the selection out from under the user.
* The sidebar's interactive items are buttons (already excluded),
* but the panel as a whole is editing-adjacent so we treat it the
* same way. */
const SELECTOR_EDITING_PANELS = '.properties, .sidebar'
/** Timeline elements that own their own selection / scrub semantics.
* Click on a keyframe → that kf becomes selected; click on a clip →
* that clip becomes the active instance; click on a track-name →
* the layer is selected. Letting the global handler fire on top of
* any of these would immediately undo the selection the user just
* made. The timeline's own background (.tracks gaps, header gutter)
* is *not* in this list — those clicks SHOULD deselect. */
const SELECTOR_TIMELINE_ELEMENTS =
'.kf, .kf-add, .clip-block, .clip-handle, .track-name, .track-lane, ' +
'.script-frame, .script-lane, .timeline-marker, .scrub-handle, ' +
'.playhead, .add-clip-btn, .drag-handle, .in-bracket, .out-bracket'
/** Properties panel and sidebar are editing surfaces — clicking
* around inputs while editing must not yank the selection out from
* under the user. Timeline owns its own selection model: bare-click
* in `.tracks` calls deselectAll directly, drags become box-select
* whose `pointerup` commits the selection. A *synthetic* click
* event still fires on the document after that drag, so if we
* treated the timeline as inert chrome the global handler would
* fire AFTER box-select commit and immediately wipe it. Bail. */
const SELECTOR_EDITING_PANELS = '.properties, .sidebar, .timeline'
/** Should a click on `target` cause the global "deselect everything"
* behaviour to fire? Returns false for clicks on the Konva canvas
* (the stage handles its own logic), interactive elements, overlays,
* editing panels (sidebar / properties), and timeline elements with
* their own selection semantics. Anything else — topbar chrome,
* gaps around the canvas, empty timeline tracks, the app shell —
* counts as inert background and clicks there should clear the
* selection. */
* and editing panels (sidebar / properties / timeline — these own
* their own selection state). Anything else — topbar chrome, gaps
* around the canvas, the app shell — counts as inert background
* and clicks there clear the selection. */
export function shouldChromeClickDeselect(target: Element | null): boolean {
if (!target) return false
if (target.closest('canvas')) return false
if (target.closest(SELECTOR_INTERACTIVE)) return false
if (target.closest(SELECTOR_OVERLAYS)) return false
if (target.closest(SELECTOR_EDITING_PANELS)) return false
if (target.closest(SELECTOR_TIMELINE_ELEMENTS)) return false
return true
}
+33 -25
View File
@@ -99,7 +99,10 @@ function sampleKeyframeSet(
time: number,
): AnimatableProps {
if (!kfs || !kfs.length) return { ...base }
const sorted = [...kfs].sort((a, b) => a.time - b.time)
// End-kfs are visibility markers only — they carry no props and
// must not influence interpolation. Filter them out before sorting.
const sorted = kfs.filter((k) => k.kind !== 'end').sort((a, b) => a.time - b.time)
if (!sorted.length) return { ...base }
if (time <= sorted[0].time) return merge(base, sorted[0].props)
const last = sorted[sorted.length - 1]
if (time >= last.time) return merge(base, last.props)
@@ -128,18 +131,27 @@ function sampleKeyframeSet(
return { ...base }
}
/** Earliest end-kf time across the layer's main track, or null when
* no end kf exists (→ layer never auto-disappears). End kfs only
* live on `keyframes`, never on the separated position/rotation
* tracks — there's only one end-of-life per layer. */
export function layerEndTime(layer: Layer): number | null {
let end: number | null = null
for (const k of layer.keyframes ?? []) {
if (k.kind !== 'end') continue
if (end === null || k.time < end) end = k.time
}
return end
}
/**
* Union range of all keyframe times on a layer (main + position + rotation
* tracks). A layer is considered "alive" only within this range.
* Union range of all NORMAL keyframe times on a layer (main + position
* + rotation tracks). The layer is "alive" from `min` onward; the end
* is determined by `layerEndTime` or, if there is no end kf, runs out
* to Infinity.
*
* Returns null (→ always visible) when the layer has zero or one total
* keyframes — a single keyframe is a static layer with no animation range.
*
* Special case: when the keyframe with the latest time has `hold: true`,
* the max is extended to Infinity — the layer stays alive past its last
* keyframe, pinned to that keyframe's sampled value. That's how users
* turn the final keyframe of a short anim into a still frame so the
* layer stays on-screen for the remainder of the timeline.
* Returns null when the layer has zero normal keyframes — a static
* layer with no animation range, always visible.
*/
export function layerActiveRange(
layer: Layer,
@@ -147,31 +159,25 @@ export function layerActiveRange(
const main = layer.keyframes || []
const pos = layer.positionKeyframes || []
const rot = layer.rotationKeyframes || []
const total = main.length + pos.length + rot.length
if (total <= 1) return null
let min = Infinity
let max = -Infinity
let lastHold = false
let foundNormal = false
for (const arr of [main, pos, rot]) {
for (const k of arr) {
if (k.kind === 'end') continue
foundNormal = true
if (k.time < min) min = k.time
if (k.time > max) {
max = k.time
lastHold = !!k.hold
} else if (k.time === max && k.hold) {
lastHold = true
}
}
}
if (lastHold) max = Infinity
return { min, max }
if (!foundNormal) return null
const end = layerEndTime(layer)
return { min, max: end ?? Infinity }
}
export function isLayerActiveAt(layer: Layer, time: number): boolean {
const range = layerActiveRange(layer)
if (!range) return true
const EPSILON = 1e-6
return time >= range.min - EPSILON && time <= range.max + EPSILON
return time >= range.min - EPSILON && time < range.max - EPSILON
}
/**
@@ -192,7 +198,9 @@ export function isLayerActiveAt(layer: Layer, time: number): boolean {
export function isButtonLayerActiveAt(layer: Layer, time: number): boolean {
const EPSILON = 1e-6
const kfs: Keyframe[] = []
for (const k of layer.keyframes) kfs.push(k)
// End kfs aren't meaningful in button-asset edit (button states
// are discrete frame slots, not a timeline) — skip them.
for (const k of layer.keyframes) if (k.kind !== 'end') kfs.push(k)
if (layer.positionKeyframes) for (const k of layer.positionKeyframes) kfs.push(k)
if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) kfs.push(k)
if (kfs.length === 0) return true
+36 -1
View File
@@ -1,4 +1,4 @@
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene } from '@/types/scene'
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, SvgAsset } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
@@ -250,6 +250,14 @@ function drawLayers(
ctx.translate(-offX, -offY)
const bs = buttonStates?.get(layer.id) ?? 'normal'
drawMaskedGroup(ctx, asset.layers, assetMap, images, BUTTON_STATE_TIMES[bs], undefined, true)
} else if (asset.kind === 'svg') {
// SVG assets are static — child layers each carry a single
// keyframe at t=0. Composite that frame into the main scene
// exactly like a button-state frame (no per-state lookup).
const offX = layer.offsetX ?? asset.canvas.width / 2
const offY = layer.offsetY ?? asset.canvas.height / 2
ctx.translate(-offX, -offY)
drawMaskedGroup(ctx, asset.layers, assetMap, images, 0, undefined, true)
}
clearShadow(ctx)
@@ -646,6 +654,33 @@ export function renderButtonThumbnail(
return canvas.toDataURL('image/png')
}
/** Sidebar / preview thumbnail for an SVG asset. Mirrors
* `renderButtonThumbnail` but always samples at t=0 (the SVG sub-
* editor pins every child layer to a single keyframe at 0). Returns
* null when the asset is empty or its canvas is degenerate. */
export function renderSvgThumbnail(
svg: SvgAsset,
scene: Scene,
maxSize = 80,
images: Record<string, HTMLImageElement> = {},
): string | null {
const { width: w, height: h } = svg.canvas
if (w <= 0 || h <= 0 || !svg.layers.length) return null
const scale = Math.min(maxSize / w, maxSize / h, 1)
const cw = Math.round(w * scale)
const ch = Math.round(h * scale)
const canvas = document.createElement('canvas')
canvas.width = cw
canvas.height = ch
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.scale(scale, scale)
const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
drawMaskedGroup(ctx, svg.layers, assetMap, images, 0, undefined, true)
return canvas.toDataURL('image/png')
}
/**
* Draw a single button-asset frame into the supplied context. Used by the
* mask-preview overlay in button-edit mode so the preview reflects the
+237 -28
View File
@@ -25,6 +25,7 @@ import type {
ScenePage,
ScriptFrame,
ScriptTrack,
SvgAsset,
TrackGroup,
} from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
@@ -249,6 +250,10 @@ interface State {
/** Which of the 4 content states is currently active. Only meaningful while
* `activeButtonEditMode === 'states'`; preserved across mode toggles. */
activeButtonState: ButtonState | null
/** When set, the Stage is inside an SVG-asset edit session. The
* timeline is hidden in this mode and the asset's child layers
* are static (single keyframe at t=0). */
activeSvgAssetId: string | null
drawingMode: 'freeform' | null
drawingAssetId: string | null
drawingLayerId: string | null
@@ -288,7 +293,15 @@ function upsertInto(
props: Partial<AnimatableProps>,
snapshotProps: Partial<AnimatableProps> | null,
) {
const existing = arr.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
// Skip end-kfs when looking for a same-time match — they carry no
// props and must not be overwritten with property values. A normal
// kf and an end kf can legitimately share a time (e.g. "appear at
// t=2 and disappear at t=2" wouldn't make sense, but a normal kf
// at t=2 next to an end kf at t=5 is fine; same-time is the edge
// case during migration).
const existing = arr.find(
(k) => k.kind !== 'end' && Math.abs(k.time - time) < KEYFRAME_EPSILON,
)
if (existing) {
existing.props = { ...existing.props, ...props }
return
@@ -439,6 +452,59 @@ function normalizeScenePage(page: ScenePage): ScenePage {
return page
}
/**
* One-time migration: legacy projects coupled "last keyframe = layer
* disappears" together. With the new explicit end-kf model the user
* can decouple the two — layers without an `end` kf stay visible
* past their last normal kf. To keep existing projects looking
* identical after the model change, we synthesize an `end` kf at
* the time of the last normal kf for any layer that has 2+ normal
* kfs and *isn't* relying on the legacy `hold: true` to extend
* visibility past the end.
*
* Idempotent: layers that already carry an `end` kf are skipped, so
* re-running on an already-migrated project is a no-op.
*
* Applied to every layer collection in the project: scene-page
* layers, clip layers, button-asset layers (skip — state-based),
* and svg-asset layers (skip — single-kf static frame).
*/
function migrateLayerEndKfs(layers: Layer[]) {
for (const layer of layers) {
const main = layer.keyframes ?? []
if (main.some((k) => k.kind === 'end')) continue
const pos = layer.positionKeyframes ?? []
const rot = layer.rotationKeyframes ?? []
const normal: Keyframe[] = []
for (const arr of [main, pos, rot]) {
for (const k of arr) {
if (k.kind !== 'end') normal.push(k)
}
}
if (normal.length < 2) continue
let last = normal[0]
for (const k of normal) if (k.time > last.time) last = k
if (last.hold) continue
layer.keyframes = [
...main,
{
id: uid(),
time: last.time,
props: {},
easing: 'linear',
kind: 'end',
},
]
}
}
function applyEndKfMigration(project: Project) {
for (const page of project.scenes ?? []) migrateLayerEndKfs(page.layers ?? [])
for (const clip of project.clips ?? []) migrateLayerEndKfs(clip.layers ?? [])
// Button + SVG assets carry their own static / state-based timing
// and don't take part in this migration.
}
/** Project-level housekeeping. Ensures shared arrays exist and that
* `activeSceneId` actually references one of the pages — falls back to
* the first page (or a freshly minted one if none exist). */
@@ -454,6 +520,7 @@ function normalizeProject(project: Project): Project {
if (!project.scenes.find((s) => s.id === project.activeSceneId)) {
project.activeSceneId = project.scenes[0].id
}
applyEndKfMigration(project)
return project
}
@@ -561,6 +628,7 @@ export const useSceneStore = defineStore('scene', {
activeButtonAssetId: null,
activeButtonEditMode: null,
activeButtonState: null,
activeSvgAssetId: null,
drawingMode: null,
drawingAssetId: null,
drawingLayerId: null,
@@ -621,7 +689,14 @@ export const useSceneStore = defineStore('scene', {
const asset = this.project.assets.find((a) => a.id === this.activeButtonAssetId)
return asset && asset.kind === 'button' ? asset : null
},
activeSvgAsset(): SvgAsset | null {
if (!this.activeSvgAssetId) return null
const asset = this.project.assets.find((a) => a.id === this.activeSvgAssetId)
return asset && asset.kind === 'svg' ? asset : null
},
activeLayers(): Layer[] {
const sAsset = this.activeSvgAsset
if (sAsset) return sAsset.layers
const bAsset = this.activeButtonAsset
if (bAsset) {
// Hit mode shares the same layer list — the canvas switches to
@@ -696,9 +771,12 @@ export const useSceneStore = defineStore('scene', {
if (this.activeButtonEditMode === 'hit') return null
const asset = this.scene.assets.find((a) => a.id === assetId)
// Default position = center of the active canvas. In button-asset
// edit that's the button's smaller internal canvas, so the new
// layer lands inside the visible (and unclipped) area.
const canvas = this.activeButtonAsset?.canvas ?? this.scene.canvas
// / svg-asset edit that's the asset's own internal canvas, so the
// new layer lands inside the visible (and unclipped) area.
const canvas =
this.activeSvgAsset?.canvas ??
this.activeButtonAsset?.canvas ??
this.scene.canvas
const base: AnimatableProps = {
...DEFAULT_BASE_PROPS,
x: canvas.width / 2,
@@ -734,8 +812,9 @@ export const useSceneStore = defineStore('scene', {
} else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') {
layer.offsetX = 0
layer.offsetY = 0
} else if (asset.kind === 'button') {
// Place the button centered on (x, y) using Konva's offset mechanism.
} else if (asset.kind === 'button' || asset.kind === 'svg') {
// Place the button / svg centered on (x, y) using Konva's
// offset mechanism. Both carry an internal canvas size.
layer.offsetX = asset.canvas.width / 2
layer.offsetY = asset.canvas.height / 2
}
@@ -747,9 +826,10 @@ export const useSceneStore = defineStore('scene', {
}
}
this.activeLayers.unshift(layer)
// Only top-level scene layers appear in trackOrder. Clip-edit and
// button-asset-edit layers live inside their own containers.
if (!this.activeClipId && !this.activeButtonAssetId) {
// Only top-level scene layers appear in trackOrder. Clip-edit,
// button-asset-edit and svg-asset-edit layers live inside their
// own containers.
if (!this.activeClipId && !this.activeButtonAssetId && !this.activeSvgAssetId) {
const order = (this.scene.trackOrder ||= [])
if (!order.includes(layer.id)) order.unshift(layer.id)
}
@@ -761,7 +841,7 @@ export const useSceneStore = defineStore('scene', {
const layers = this.activeLayers
const idx = layers.findIndex((l) => l.id === id)
if (idx >= 0) layers.splice(idx, 1)
if (!this.activeClipId) {
if (!this.activeClipId && !this.activeButtonAssetId && !this.activeSvgAssetId) {
this.activeScene.trackOrder = this.activeScene.trackOrder.filter((x) => x !== id)
}
if (this.selectedLayerId === id) {
@@ -941,6 +1021,9 @@ export const useSceneStore = defineStore('scene', {
}
},
setPlayhead(t: number) {
// Svg-edit has no time axis — pin the playhead at 0 so any
// scrub-driven path (auto-key etc.) reads the static frame.
if (this.activeSvgAssetId) { this.playhead = 0; return }
this.playhead = Math.max(0, Math.min(this.activeDuration, t))
},
setDuration(seconds: number) {
@@ -1036,6 +1119,9 @@ export const useSceneStore = defineStore('scene', {
// with an existing keyframe at the same time, so collisions are
// handled automatically.
if (this.activeButtonAssetId) time = snapToButtonStateTime(time)
// Svg-edit: a single keyframe at t=0 represents the static
// frame. Any upsert collapses onto t=0.
if (this.activeSvgAssetId) time = 0
if (!layer.separated) {
const snapshot = sampleLayer(layer, time)
upsertInto(layer.keyframes, time, props, snapshot)
@@ -1073,6 +1159,40 @@ export const useSceneStore = defineStore('scene', {
const idx = found.arr.indexOf(found.kf)
if (idx >= 0) found.arr.splice(idx, 1)
},
/** Insert (or move the existing) "end" keyframe on a layer. End
* kfs live only on the main `keyframes` array; they carry no
* props and exist purely to mark when the layer disappears.
* Calling with the same time twice is a no-op. */
setLayerEnd(layerId: string, time: number): string | null {
// SVG / button scopes don't use end kfs.
if (this.activeSvgAssetId || this.activeButtonAssetId) return null
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const t = Math.max(0, Math.min(this.activeDuration, time))
const existing = layer.keyframes.find((k) => k.kind === 'end')
if (existing) {
existing.time = t
sortKfs(layer.keyframes)
return existing.id
}
const kf: Keyframe = {
id: uid(),
time: t,
props: {},
easing: 'linear',
kind: 'end',
}
layer.keyframes.push(kf)
sortKfs(layer.keyframes)
return kf.id
},
/** Remove the layer's end kf (if any). The layer becomes visible
* to scene end. */
clearLayerEnd(layerId: string) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
layer.keyframes = layer.keyframes.filter((k) => k.kind !== 'end')
},
setKeyframeControl(layerId: string, keyframeId: string, ctrlX: number, ctrlY: number) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
@@ -1143,6 +1263,8 @@ export const useSceneStore = defineStore('scene', {
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
// Svg-edit: keyframes are pinned at t=0. Refuse to move them.
if (this.activeSvgAssetId) return
let t = newTime
if (this.activeButtonAssetId) {
t = snapToButtonStateTime(t)
@@ -1192,6 +1314,8 @@ export const useSceneStore = defineStore('scene', {
keyframeId: string,
targetTime: number,
): string | null {
// Svg-edit: same reasoning as duplicateKeyframe — refuse.
if (this.activeSvgAssetId) return null
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const found = findKf(layer, keyframeId)
@@ -1214,6 +1338,9 @@ export const useSceneStore = defineStore('scene', {
return newKf.id
},
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
// Svg-edit: only a single keyframe per layer at t=0 — duplicating
// would either collide with itself or pollute the model.
if (this.activeSvgAssetId) return null
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const found = findKf(layer, keyframeId)
@@ -1472,9 +1599,13 @@ export const useSceneStore = defineStore('scene', {
kind: 'polygon',
name: 'Freeform',
points: [],
// Open paths need a visible stroke or they're invisible in
// the editor. Red picks up against the dark stage backdrop
// and against the lighter SVG-edit canvas without the user
// having to fish for a colour first.
fill: 'transparent',
closed: false,
stroke: '#000000',
stroke: '#e23b3b',
strokeWidth: 2,
}) as Extract<Asset, { kind: 'polygon' }>
const layer: Layer = {
@@ -1495,7 +1626,10 @@ export const useSceneStore = defineStore('scene', {
offsetY: 0,
}
this.activeLayers.unshift(layer)
if (!this.activeClipId) {
// Top-level scene layers go into trackOrder. Clip / button /
// svg sub-edit scopes live inside their own containers and
// must NOT touch the main scene's order.
if (!this.activeClipId && !this.activeButtonAssetId && !this.activeSvgAssetId) {
;(this.scene.trackOrder ||= []).unshift(layer.id)
}
this.drawingMode = 'freeform'
@@ -1511,24 +1645,28 @@ export const useSceneStore = defineStore('scene', {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
asset.points.push({ x, y })
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
kf.props = {
...kf.props,
points: [...kf.props.points, { x, y }],
}
// The drawing layer lives in whichever scope is active —
// main scene, clip, button, or svg asset. Look it up via
// activeLayers so the freeform path works inside all of them.
// (The previous version filtered `scene.layers` only, which
// skipped any layer that lives inside a button or svg asset.)
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
kf.props = {
...kf.props,
points: [...kf.props.points, { x, y }],
}
}
// Ensure the first keyframe of the drawing layer always has a points array
if (layer.id === layerId && layer.keyframes[0] && !layer.keyframes[0].props.points) {
layer.keyframes[0].props = {
...layer.keyframes[0].props,
points: asset.points.map((p) => ({ x: p.x, y: p.y })),
}
}
// Ensure the first keyframe of the drawing layer always has a points array
if (layer.keyframes[0] && !layer.keyframes[0].props.points) {
layer.keyframes[0].props = {
...layer.keyframes[0].props,
points: asset.points.map((p) => ({ x: p.x, y: p.y })),
}
}
},
@@ -2251,6 +2389,7 @@ export const useSceneStore = defineStore('scene', {
this.activeButtonEditMode = 'states'
this.activeButtonState = 'normal'
this.activeClipId = null
this.activeSvgAssetId = null
this.selectedLayerId = null
this.selectedLayerIds = []
this.playhead = BUTTON_STATE_TIMES.normal
@@ -2293,6 +2432,73 @@ export const useSceneStore = defineStore('scene', {
this.playhead = 0
this.playing = false
},
// -------- Svg assets ----------------------------------------------
/** Create a new authoring SVG asset. Inherits canvas size from
* the current scene; resizable later via the existing canvas
* resize button. */
addSvgAsset(name?: string, width?: number, height?: number): SvgAsset {
const w = Math.max(1, Math.round(width ?? this.scene.canvas.width))
const h = Math.max(1, Math.round(height ?? this.scene.canvas.height))
const asset: SvgAsset = {
id: uid(),
kind: 'svg',
name: name?.trim() || 'SVG',
canvas: { width: w, height: h },
layers: [],
}
this.scene.assets.push(asset)
return asset
},
setSvgAssetCanvas(assetId: string, width: number, height: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'svg') return
asset.canvas = {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
}
},
enterSvgAssetEdit(assetId: string) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'svg') return
// Strip any pre-existing per-layer keyframes back to a single
// entry at t=0 — the SVG sub-editor exposes no time axis, so
// multi-kf layers (e.g. from a paste / migration) would just
// hide their own animation behind the static stage view.
for (const layer of asset.layers) {
const first = layer.keyframes[0]
const props = first ? { ...first.props } : { ...layer.base }
layer.keyframes = [
{
id: uid(),
time: 0,
props,
easing: 'linear',
},
]
delete layer.positionKeyframes
delete layer.rotationKeyframes
layer.separated = false
}
this.activeSvgAssetId = assetId
this.activeClipId = null
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.selectedLayerId = null
this.selectedLayerIds = []
this.playhead = 0
this.playing = false
},
exitSvgAssetEdit() {
this.activeSvgAssetId = null
this.selectedLayerId = null
this.selectedLayerIds = []
this.playhead = 0
this.playing = false
},
addButtonHitShape(assetId: string, shape: HitShape): HitShape | null {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return null
@@ -2416,6 +2622,7 @@ export const useSceneStore = defineStore('scene', {
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.activeSvgAssetId = null
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
@@ -2490,6 +2697,7 @@ export const useSceneStore = defineStore('scene', {
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.activeSvgAssetId = null
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
@@ -3203,6 +3411,7 @@ export const useSceneStore = defineStore('scene', {
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.activeSvgAssetId = null
this.selectedLayerId = null
this.selectedLayerIds = []
this.playhead = 0
+26 -1
View File
@@ -37,13 +37,21 @@ export interface Keyframe {
props: Partial<AnimatableProps>
easing: Easing
hold?: boolean
/**
* Keyframe role. Default `'normal'` (omitted) is a property snapshot
* that participates in interpolation. `'end'` is a marker-only kf:
* it carries no animatable props, doesn't influence interpolation,
* and signals that the layer becomes invisible at this time. Lives
* only on the main `keyframes` track.
*/
kind?: 'normal' | 'end'
// Quadratic bezier control point for the position arc from the previous
// keyframe to this one. When set, x/y are curved along B(u)=(1-u)²P0+2(1-u)u·C+u²P1.
ctrlX?: number
ctrlY?: number
}
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio'
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio' | 'svg'
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
@@ -156,6 +164,22 @@ export interface AudioAsset {
duration: number
}
/**
* A user-authored SVG built from the same shape primitives as the main
* timeline (rect / circle / text / polygon / freeform). Each child layer
* carries exactly one keyframe at t=0 — the sub-editor exposes no time
* axis. Composited into the main scene as a single image-like layer.
*/
export interface SvgAsset {
id: string
kind: 'svg'
name: string
/** SVG-internal canvas size. Inherited from the current scene at
* creation time; resizable via the existing canvas-resize button. */
canvas: { width: number; height: number }
layers: Layer[]
}
export type Asset =
| ImageAsset
| RectAsset
@@ -164,6 +188,7 @@ export type Asset =
| PolygonAsset
| ButtonAsset
| AudioAsset
| SvgAsset
export type MaskMode = 'positive' | 'negative'
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.1.7'
export const APP_VERSION = '1.2.0'
export const APP_NAME = 'RUWIO'