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(() => { + +