1.2.0: SVG asset editor + end keyframe type + UX bundle
This release bundles three independently-useful pieces of work that
overlap heavily in shared files (types, store, i18n, App, CanvasStage,
tests), so they ship together.
SVG asset editor
- New asset kind 'svg' (SvgAsset { canvas, layers }), authored from
inside the existing shape primitives (rect / circle / text / polygon /
freeform). "+" button under the Images sidebar header; opens a
sub-editor that swaps the library sidebar for a dedicated layer
panel, hides the timeline, and uses the same canvas-resize flow
("Edit canvas size" via right-click → no Document settings entry
while in scope). Children carry a single t=0 keyframe each.
- Multi-select rotation in SVG scope only: a temporary group pivot
appears at the selection centroid (draggable), rotation around it
shifts each layer's x/y/rotation. Pivot follows the centroid drift
on Konva-transformer scale/move. Outside SVG, transformer
rotateEnabled stays off (group-rotate write-back is unreliable
per prior feedback).
- Authored SVGs are placed as layers in the main scene and rendered
via drawMaskedGroup at t=0; thumbnails surface in the sidebar via
renderSvgThumbnail. Drop / click / drag / dbl-click-to-edit work
via a transparent svg-hit catcher rect routed in the stage handler.
- Freeform default stroke is red (#e23b3b) so the open path is
visible immediately. addDrawingPoint resolves the layer through
activeLayers so freeform draws correctly inside SVG and clip
scopes too.
End keyframe type
- Keyframe.kind: 'normal' | 'end'. End kfs carry no props, mark the
visibility cap. layerActiveRange / isLayerActiveAt / sampleKeyframeSet
ignore them for interpolation and use them only as the visible-from
/ visible-until bounds.
- One-time migration in normalizeProject synthesizes an end kf at the
time of the last normal kf for layers that relied on the legacy
"last kf = disappears" semantics; layers using hold:true to extend
visibility past the end skip the migration. Idempotent.
- Timeline UI: new "+end" toolbar button (multi-select fan-out, toggle),
"Convert to end" / "Back to normal" entries in the kf context menu
(easing / still-frame / duplicate hidden for end kfs), end kfs
render as a flat axis-aligned square (vs. the diamond-rotated normal
kf). Render filter drops end kfs that collide with a normal kf at
the same time so legacy migrated projects don't read as four-point
stars.
UX polish
- TAB-collapse stage anchoring: the canvas content stays put when the
asset / properties asides toggle (CanvasStage's ResizeObserver
compensates viewX/Y by the container's bounding-rect drift).
- Topbar clipboard button copies the current frame to the system
clipboard as a PNG via navigator.clipboard.write([ClipboardItem]).
- chromeClick predicate adds .timeline to the editing-panels bail so
the synthetic click after a Timeline box-select drag no longer
wipes the just-committed kf selection.
- Right-click on a polygon vertex stops propagation so the vertex
context menu wins instead of the generic stage menu opening on top.
This commit is contained in:
1 parent
492f25f629
commit
d03fbe4ecb
15 files changed
+2492
-185
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.1.7",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+159
-18
@@ -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,7 +3571,9 @@ onBeforeUnmount(() => {
|
||||
:data-kind="track.kind"
|
||||
:data-kf-id="kf.id"
|
||||
v-tooltip="
|
||||
kf.hold
|
||||
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) })
|
||||
"
|
||||
@@ -4006,6 +4116,7 @@ onBeforeUnmount(() => {
|
||||
class="kf-menu"
|
||||
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
|
||||
>
|
||||
<template v-if="!menu.isEnd">
|
||||
<div class="kf-menu-section">
|
||||
<div class="kf-menu-header">Easing</div>
|
||||
<button
|
||||
@@ -4043,7 +4154,23 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||||
</template>
|
||||
<button
|
||||
class="kf-menu-item"
|
||||
:class="{ active: menu.isEnd }"
|
||||
@click="toggleEndKind"
|
||||
>
|
||||
<PhFlagBanner :size="12" class="kf-menu-icon" />
|
||||
{{ menu.isEnd ? t('timeline.endKfBackToNormal') : t('timeline.endKfMakeEnd') }}
|
||||
<PhCheck
|
||||
v-if="menu.isEnd"
|
||||
:size="12"
|
||||
weight="bold"
|
||||
class="kf-menu-trailing-check"
|
||||
/>
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<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
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+225
-16
@@ -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,8 +1645,13 @@ 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) {
|
||||
// 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) {
|
||||
@@ -1524,13 +1663,12 @@ export const useSceneStore = defineStore('scene', {
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
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 })),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
finishDrawing() {
|
||||
const layerId = this.drawingLayerId
|
||||
@@ -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
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.1.7'
|
||||
export const APP_VERSION = '1.2.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user