1.0.2: persist editor-session UI state with the project

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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 19:47:44 +02:00
1 parent cd9cf91ef0
commit 647f09b287
9 files changed
+285 -19

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.0.1",
"version": "1.0.2",
"type": "module",
"scripts": {
"dev": "vite",
+5 -2
View File
@@ -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`)
+45 -8
View File
@@ -52,19 +52,45 @@ const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(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<number>({
get: () => store.editor.canvasZoom,
set: (v) => { store.editor.canvasZoom = v },
})
const viewX = computed<number>({
get: () => store.editor.canvasViewX,
set: (v) => { store.editor.canvasViewX = v },
})
const viewY = computed<number>({
get: () => store.editor.canvasViewY,
set: (v) => { store.editor.canvasViewY = v },
})
const cropObjects = computed<boolean>({
get: () => store.editor.cropObjects,
set: (v) => { store.editor.cropObjects = v },
})
const maskPreview = computed<boolean>({
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<boolean>({
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<boolean>({
get: () => store.editor.stagePreview,
set: (v) => { store.editor.stagePreview = v },
})
const previewCanvasEl = ref<HTMLCanvasElement | null>(null)
const previewImages = ref<Record<string, HTMLImageElement>>({})
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) => {
+20 -4
View File
@@ -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<number>({
get: () => store.editor.timelinePxPerSec,
set: (v) => { store.editor.timelinePxPerSec = v },
})
const autoFit = computed<boolean>({
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<DisplayMode>('seconds')
const snapEnabled = ref(true)
const displayMode = computed<DisplayMode>({
get: () => store.editor.timelineDisplayMode,
set: (v) => { store.editor.timelineDisplayMode = v },
})
const snapEnabled = computed<boolean>({
get: () => store.editor.timelineSnap,
set: (v) => { store.editor.timelineSnap = v },
})
const SNAP_PX = 8
+4 -1
View File
@@ -21,7 +21,10 @@ async function renameProject(next: string): Promise<void> {
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,
+119
View File
@@ -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
+65 -2
View File
@@ -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<EditorState> {
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<A, 'id'> : 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<EditorState>
}
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
+25
View File
@@ -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 = {
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.0.1'
export const APP_VERSION = '1.0.2'
export const APP_NAME = 'RUWIO'