From 647f09b287d2eb6850f206f4446ebebcc5d6fd09 Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 24 Apr 2026 19:47:44 +0200
Subject: [PATCH] 1.0.2: persist editor-session UI state with the project
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Canvas zoom + pan, crop / mask-preview / stage-preview / centre-
crosshair overlays, and timeline snap / show-locked / displayMode
/ px-per-sec / auto-fit now ride along in the saved project under
a new optional `editor` block. Reopening a project restores the
workbench exactly how the user left it — zoom level, pan offset,
HUD toggles, timeline density — instead of snapping back to
defaults.
Editor state lives on the store as a sibling of `this.project`
rather than inside it, so `JSON.stringify(store.project)` (the
undo/redo snapshot) still only captures content. The new
`projectForSave` getter stitches the two on the way to disk;
`loadProject` reads them back apart on the way in. Saves through
App.vue, the rename flow, and IDB recents all go through
`projectForSave`.
CanvasStage skips its first auto-fit when a non-default
canvas zoom/pan was restored so the user's exact view survives
mount. Frame-based export options pick up the same source of
truth for a consistent starting point.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
src/App.vue | 7 +-
src/components/CanvasStage.vue | 53 +++++++++++--
src/components/Timeline.vue | 24 +++++-
src/composables/useProjectName.ts | 5 +-
src/devtools/tests.ts | 119 ++++++++++++++++++++++++++++++
src/stores/scene.ts | 67 ++++++++++++++++-
src/types/scene.ts | 25 +++++++
src/version.ts | 2 +-
9 files changed, 285 insertions(+), 19 deletions(-)
diff --git a/package.json b/package.json
index fef36ce..bc4f2ba 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "1.0.1",
+ "version": "1.0.2",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.vue b/src/App.vue
index 7e6218b..b645b2b 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -174,7 +174,10 @@ function stop() {
}
async function saveProject() {
- const project = JSON.parse(JSON.stringify(store.project))
+ // projectForSave stitches the current editor-session UI state
+ // (zoom, pan, overlay toggles, timeline display) onto the
+ // project payload so reopening restores the full workbench.
+ const project = JSON.parse(JSON.stringify(store.projectForSave))
let name = currentProjectName.value
if (!name) {
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
@@ -207,7 +210,7 @@ function openProjectNameDrawer() {
}
function exportProjectFile() {
- const project = JSON.parse(JSON.stringify(store.project))
+ const project = JSON.parse(JSON.stringify(store.projectForSave))
const base = currentProjectName.value ?? 'ruwio'
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
downloadProject(project, `${safe}.json`)
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index 8c69ab8..3751334 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -52,19 +52,45 @@ const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
const bgVideoEl = ref(null)
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
-const zoom = ref(1)
-const viewX = ref(0)
-const viewY = ref(0)
-const cropObjects = ref(false)
-const maskPreview = ref(false)
+// Canvas zoom / pan / overlay toggles live on the store so the
+// project file can round-trip the workbench state. Thin
+// writableComputed shims keep the rest of this component's code
+// (which reads/writes `zoom.value`, `cropObjects.value = …`, etc.)
+// unchanged.
+const zoom = computed({
+ get: () => store.editor.canvasZoom,
+ set: (v) => { store.editor.canvasZoom = v },
+})
+const viewX = computed({
+ get: () => store.editor.canvasViewX,
+ set: (v) => { store.editor.canvasViewX = v },
+})
+const viewY = computed({
+ get: () => store.editor.canvasViewY,
+ set: (v) => { store.editor.canvasViewY = v },
+})
+const cropObjects = computed({
+ get: () => store.editor.cropObjects,
+ set: (v) => { store.editor.cropObjects = v },
+})
+const maskPreview = computed({
+ get: () => store.editor.maskPreview,
+ set: (v) => { store.editor.maskPreview = v },
+})
// Dashed horizontal + vertical lines through the center of the
// effective canvas — a composition guide the user can toggle on/off
-// from the HUD. Not persisted; local to this editor session.
-const centerCrosshair = ref(false)
+// from the HUD.
+const centerCrosshair = computed({
+ get: () => store.editor.centerCrosshair,
+ set: (v) => { store.editor.centerCrosshair = v },
+})
// Stage-preview mode: when false (default) buttons behave like normal
// assets — draggable, no hover/click interaction. When true the canvas
// turns into a live preview with buttons responding to input.
-const stagePreview = ref(false)
+const stagePreview = computed({
+ get: () => store.editor.stagePreview,
+ set: (v) => { store.editor.stagePreview = v },
+})
const previewCanvasEl = ref(null)
const previewImages = ref>({})
let didInitialFit = false
@@ -3039,6 +3065,17 @@ function zoomBy(factor: number) {
viewY.value = sy - logY * newZoom
}
+// Project-loaded zoom/pan? Treat a non-default editor state as an
+// explicit user choice and skip the first auto-fit so the workbench
+// restores exactly where the user left off.
+if (
+ store.editor.canvasZoom !== 1 ||
+ store.editor.canvasViewX !== 0 ||
+ store.editor.canvasViewY !== 0
+) {
+ didInitialFit = true
+}
+
onMounted(() => {
if (!container.value) return
ro = new ResizeObserver((entries) => {
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index c0972e5..09a9c49 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -289,8 +289,18 @@ const viewportWidth = ref(0)
// scrub-handle behind the sticky name-spacer when the playhead has
// scrolled off the left edge of the visible ruler area.
const scrollLeft = ref(0)
-const pxPerSec = ref(0)
-const autoFit = ref(true)
+// pxPerSec / autoFit mirror the store's editor.timeline* fields so
+// the timeline zoom / fit preference round-trips with the project
+// file. Thin writableComputed shims keep the rest of this
+// component's code (`pxPerSec.value = …`, `autoFit.value`) unchanged.
+const pxPerSec = computed({
+ get: () => store.editor.timelinePxPerSec,
+ set: (v) => { store.editor.timelinePxPerSec = v },
+})
+const autoFit = computed({
+ get: () => store.editor.timelineAutoFit,
+ set: (v) => { store.editor.timelineAutoFit = v },
+})
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v))
@@ -314,8 +324,14 @@ const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
const totalWidth = computed(() => trackNameWidth.value + laneWidth.value)
type DisplayMode = 'seconds' | 'frames'
-const displayMode = ref('seconds')
-const snapEnabled = ref(true)
+const displayMode = computed({
+ get: () => store.editor.timelineDisplayMode,
+ set: (v) => { store.editor.timelineDisplayMode = v },
+})
+const snapEnabled = computed({
+ get: () => store.editor.timelineSnap,
+ set: (v) => { store.editor.timelineSnap = v },
+})
const SNAP_PX = 8
diff --git a/src/composables/useProjectName.ts b/src/composables/useProjectName.ts
index 325a3f7..5cf0a08 100644
--- a/src/composables/useProjectName.ts
+++ b/src/composables/useProjectName.ts
@@ -21,7 +21,10 @@ async function renameProject(next: string): Promise {
projectName.value = trimmed
if (projectId.value) {
const store = useSceneStore()
- const project = JSON.parse(JSON.stringify(toRaw(store.project)))
+ // Bundle the editor-session UI state so renaming doesn't strip
+ // the user's zoom / pan / overlay toggles from the persisted
+ // record.
+ const project = JSON.parse(JSON.stringify(toRaw(store.projectForSave)))
const rec = await saveProjectToStore(project, {
id: projectId.value,
name: trimmed,
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 0eb70e1..6319899 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -1733,6 +1733,125 @@ export function registerAllTests(): void {
})
})
+ suite('editor state — save / load roundtrip', () => {
+ // The store's editor block carries canvas zoom/pan, overlay
+ // toggles and timeline display settings. It rides along with the
+ // project file via `projectForSave` and comes back via
+ // `loadProject`, so reopening restores the full workbench.
+
+ test('projectForSave embeds the current editor state', () => {
+ const s = makeFreshStore()
+ s.editor.canvasZoom = 2.5
+ s.editor.canvasViewX = 40
+ s.editor.canvasViewY = -80
+ s.editor.cropObjects = true
+ s.editor.maskPreview = true
+ s.editor.centerCrosshair = true
+ s.editor.timelineSnap = false
+ s.editor.timelineDisplayMode = 'frames'
+ const payload = s.projectForSave
+ expect(payload.editor).toBeTruthy()
+ expect(payload.editor!.canvasZoom).toBe(2.5)
+ expect(payload.editor!.canvasViewX).toBe(40)
+ expect(payload.editor!.canvasViewY).toBe(-80)
+ expect(payload.editor!.cropObjects).toBe(true)
+ expect(payload.editor!.maskPreview).toBe(true)
+ expect(payload.editor!.centerCrosshair).toBe(true)
+ expect(payload.editor!.timelineSnap).toBe(false)
+ expect(payload.editor!.timelineDisplayMode).toBe('frames')
+ })
+
+ test('loadProject restores editor state from the payload', () => {
+ const s = makeFreshStore()
+ s.editor.canvasZoom = 3
+ const payload = JSON.parse(JSON.stringify(s.projectForSave))
+ // Mutate live state so the load is observable.
+ s.editor.canvasZoom = 1
+ s.editor.cropObjects = false
+ // Pre-bake a specific editor block into the payload.
+ payload.editor = {
+ canvasZoom: 1.75,
+ canvasViewX: 12,
+ canvasViewY: 34,
+ cropObjects: true,
+ maskPreview: false,
+ stagePreview: true,
+ centerCrosshair: true,
+ timelineSnap: false,
+ timelineShowLocked: true,
+ timelineDisplayMode: 'frames',
+ timelinePxPerSec: 80,
+ timelineAutoFit: false,
+ }
+ s.loadProject(payload)
+ expect(s.editor.canvasZoom).toBe(1.75)
+ expect(s.editor.canvasViewX).toBe(12)
+ expect(s.editor.canvasViewY).toBe(34)
+ expect(s.editor.cropObjects).toBe(true)
+ expect(s.editor.stagePreview).toBe(true)
+ expect(s.editor.centerCrosshair).toBe(true)
+ expect(s.editor.timelineSnap).toBe(false)
+ expect(s.editor.timelineShowLocked).toBe(true)
+ expect(s.editor.timelineDisplayMode).toBe('frames')
+ expect(s.editor.timelinePxPerSec).toBe(80)
+ expect(s.editor.timelineAutoFit).toBe(false)
+ })
+
+ test('loadProject without an editor block falls back to defaults', () => {
+ const s = makeFreshStore()
+ // Pretend the user tweaked everything before loading an older
+ // file that has no editor metadata.
+ s.editor.canvasZoom = 4
+ s.editor.cropObjects = true
+ s.editor.timelineSnap = false
+ const payload = JSON.parse(JSON.stringify(s.projectForSave))
+ delete payload.editor
+ s.loadProject(payload)
+ // Back to defaults regardless of what was set before the load.
+ expect(s.editor.canvasZoom).toBe(1)
+ expect(s.editor.cropObjects).toBe(false)
+ expect(s.editor.timelineSnap).toBe(true)
+ })
+
+ test('applying a partial editor patch leaves untouched fields at default', () => {
+ const s = makeFreshStore()
+ s.applyEditorState({ canvasZoom: 2, cropObjects: true })
+ expect(s.editor.canvasZoom).toBe(2)
+ expect(s.editor.cropObjects).toBe(true)
+ // Not in the patch → should be back to the default value (the
+ // action rebuilds from scratch so stale flags don't leak).
+ expect(s.editor.maskPreview).toBe(false)
+ expect(s.editor.timelineSnap).toBe(true)
+ })
+
+ test('undo/redo snapshots do not include editor state', async () => {
+ // History only snapshots `store.project`; UI toggles must not
+ // show up in the undo stack or a ctrl-Z would revert a pan,
+ // zoom or snap-toggle — explicitly unwanted.
+ const s = makeFreshStore()
+ const history = createHistory(s)
+ history.start()
+ try {
+ s.addAsset({ kind: 'rect', name: 'Before', width: 10, height: 10, fill: '#fff' })
+ history.commitNow()
+ // Now tweak editor state, then make a content change and
+ // commit. Undo should revert the asset but leave the zoom
+ // the user set in between intact.
+ s.editor.canvasZoom = 3
+ s.addAsset({ kind: 'rect', name: 'After', width: 10, height: 10, fill: '#000' })
+ history.commitNow()
+ history.undo()
+ // Asset 'After' is gone, asset 'Before' is back — but the
+ // canvas zoom stays at 3 because editor isn't snapshotted.
+ expect(s.scene.assets.some((a) => a.name === 'After')).toBe(false)
+ expect(s.scene.assets.some((a) => a.name === 'Before')).toBe(true)
+ expect(s.editor.canvasZoom).toBe(3)
+ } finally {
+ history.stop()
+ }
+ })
+ })
+
suite('scene store — project & scenes', () => {
test('merged `scene` getter mirrors the active page\'s arrays by reference', () => {
// Reads through store.scene must share the SAME array references
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index dc548bb..2d1ffe0 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -14,6 +14,7 @@ import type {
ClipInstance,
ClipTrack,
Easing,
+ EditorState,
HitShape,
Keyframe,
Layer,
@@ -39,6 +40,23 @@ import { useButtonRuntime } from '@/composables/useButtonRuntime'
const uid = () => Math.random().toString(36).slice(2, 10)
+function defaultEditorState(): Required {
+ return {
+ canvasZoom: 1,
+ canvasViewX: 0,
+ canvasViewY: 0,
+ cropObjects: false,
+ maskPreview: false,
+ stagePreview: false,
+ centerCrosshair: false,
+ timelineSnap: true,
+ timelineShowLocked: false,
+ timelineDisplayMode: 'seconds',
+ timelinePxPerSec: 0,
+ timelineAutoFit: true,
+ }
+}
+
type AssetInit = Asset extends infer A ? (A extends Asset ? Omit : never) : never
/** A source URL is considered SVG if it's a `data:image/svg+xml` payload.
@@ -234,6 +252,11 @@ interface State {
drawingMode: 'freeform' | null
drawingAssetId: string | null
drawingLayerId: string | null
+ /** Canvas zoom, pan, overlay toggles + timeline display settings.
+ * Persisted with the project so reopening restores the workbench
+ * exactly how the user left it. Deliberately NOT tracked by the
+ * undo history — ctrl-Z should never undo a pan or a snap-toggle. */
+ editor: Required
}
function allKeyframes(layer: Layer): Keyframe[] {
@@ -541,6 +564,7 @@ export const useSceneStore = defineStore('scene', {
drawingMode: null,
drawingAssetId: null,
drawingLayerId: null,
+ editor: defaultEditorState(),
}),
getters: {
/** The active page from `project.scenes`. Use this when you need to
@@ -549,6 +573,14 @@ export const useSceneStore = defineStore('scene', {
const p = state.project
return p.scenes.find((s) => s.id === p.activeSceneId) ?? p.scenes[0]
},
+ /** Project data PLUS the current editor-session UI state — what
+ * callers should serialize when saving. Keeping the editor
+ * block out of `this.project` directly avoids polluting the
+ * undo history; this getter stitches them together only at
+ * save time. */
+ projectForSave(state): Project {
+ return { ...state.project, editor: { ...state.editor } }
+ },
/** Merged Scene-shaped view: shared project fields combined with the
* active page's arrays. Read-only by convention — array references
* are live (so `store.scene.layers.push(x)` still hits the active
@@ -2367,8 +2399,12 @@ export const useSceneStore = defineStore('scene', {
layer.separated = false
}
},
- /** Replace the entire project. Resets editor context (selection,
- * playback, clip / button-edit drill-down). */
+ /** Replace the entire project. Resets transient editor context
+ * (selection, playback, clip / button-edit drill-down) and
+ * rehydrates the persisted editor UI state (zoom, pan, overlay
+ * toggles, timeline display) from `project.editor`. Missing
+ * editor block falls back to defaults so older files open the
+ * same as before. */
loadProject(project: Project) {
this.project = normalizeProject(project)
this.selectedLayerId = null
@@ -2383,6 +2419,33 @@ export const useSceneStore = defineStore('scene', {
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
+ this.applyEditorState(project.editor)
+ },
+ /** Merge a saved editor UI snapshot into the live state. Missing
+ * fields fall back to defaults so partial saves (e.g. older
+ * files with no editor block) still get a clean starting
+ * point. Called by `loadProject` and usable standalone by
+ * anything that wants to reset the workbench to defaults. */
+ applyEditorState(patch: EditorState | undefined) {
+ const defaults = defaultEditorState()
+ if (!patch) {
+ this.editor = defaults
+ return
+ }
+ this.editor = {
+ canvasZoom: patch.canvasZoom ?? defaults.canvasZoom,
+ canvasViewX: patch.canvasViewX ?? defaults.canvasViewX,
+ canvasViewY: patch.canvasViewY ?? defaults.canvasViewY,
+ cropObjects: patch.cropObjects ?? defaults.cropObjects,
+ maskPreview: patch.maskPreview ?? defaults.maskPreview,
+ stagePreview: patch.stagePreview ?? defaults.stagePreview,
+ centerCrosshair: patch.centerCrosshair ?? defaults.centerCrosshair,
+ timelineSnap: patch.timelineSnap ?? defaults.timelineSnap,
+ timelineShowLocked: patch.timelineShowLocked ?? defaults.timelineShowLocked,
+ timelineDisplayMode: patch.timelineDisplayMode ?? defaults.timelineDisplayMode,
+ timelinePxPerSec: patch.timelinePxPerSec ?? defaults.timelinePxPerSec,
+ timelineAutoFit: patch.timelineAutoFit ?? defaults.timelineAutoFit,
+ }
},
/** Backward-compat shim: legacy callers (older project files, recents
* stored as Scene, tests) still pass a Scene. Wrap into a one-page
diff --git a/src/types/scene.ts b/src/types/scene.ts
index c3a3089..52d7e7c 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -400,6 +400,28 @@ export interface ScenePage {
* page + project-wide fields, so legacy read sites (`store.scene.canvas`,
* `store.scene.layers`, …) keep working unchanged.
*/
+/**
+ * Editor-session state preserved across save/load so the user comes
+ * back to the same working setup: canvas zoom/pan, overlay toggles,
+ * timeline display settings. Distinct from the content-undo history,
+ * which ONLY tracks document data (layers, keyframes, …) so toggling
+ * an overlay or panning the stage never pollutes Ctrl-Z.
+ */
+export interface EditorState {
+ canvasZoom?: number
+ canvasViewX?: number
+ canvasViewY?: number
+ cropObjects?: boolean
+ maskPreview?: boolean
+ stagePreview?: boolean
+ centerCrosshair?: boolean
+ timelineSnap?: boolean
+ timelineShowLocked?: boolean
+ timelineDisplayMode?: 'seconds' | 'frames'
+ timelinePxPerSec?: number
+ timelineAutoFit?: boolean
+}
+
export interface Project {
version: 1
name?: string
@@ -416,6 +438,9 @@ export interface Project {
clips: Clip[]
scenes: ScenePage[]
activeSceneId: string
+ /** Editor-session UI state. Optional so files saved before this
+ * feature load fine; a missing value means "use defaults". */
+ editor?: EditorState
}
export const DEFAULT_BASE_PROPS: AnimatableProps = {
diff --git a/src/version.ts b/src/version.ts
index c5e67dc..5d1531c 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '1.0.1'
+export const APP_VERSION = '1.0.2'
export const APP_NAME = 'RUWIO'
|