From d03fbe4ecb1d0f8ee58eeb8ea50332132797aab7 Mon Sep 17 00:00:00 2001
From: th
Date: Sat, 25 Apr 2026 21:45:58 +0200
Subject: [PATCH] 1.2.0: SVG asset editor + end keyframe type + UX bundle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
package.json | 2 +-
plan-svg-draw.txt | 68 +++
src/App.vue | 76 +++-
src/components/AssetSidebar.vue | 132 +++++-
src/components/CanvasStage.vue | 554 +++++++++++++++++++++--
src/components/SvgLayerPanel.vue | 450 +++++++++++++++++++
src/components/Timeline.vue | 227 ++++++++--
src/devtools/tests.ts | 746 ++++++++++++++++++++++++++++---
src/i18n/messages.ts | 70 +++
src/lib/chromeClick.ts | 37 +-
src/lib/interpolate.ts | 58 +--
src/lib/render.ts | 37 +-
src/stores/scene.ts | 265 +++++++++--
src/types/scene.ts | 27 +-
src/version.ts | 2 +-
15 files changed, 2529 insertions(+), 222 deletions(-)
create mode 100644 plan-svg-draw.txt
create mode 100644 src/components/SvgLayerPanel.vue
diff --git a/package.json b/package.json
index 1313ff9..8bb1700 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "1.1.7",
+ "version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/plan-svg-draw.txt b/plan-svg-draw.txt
new file mode 100644
index 0000000..7099b79
--- /dev/null
+++ b/plan-svg-draw.txt
@@ -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).
diff --git a/src/App.vue b/src/App.vue
index cd3a57f..3c03b93 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -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(() => {
+
+