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'