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:
1 parent
cd9cf91ef0
commit
647f09b287
9 files changed
+285
-19
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+5
-2
@@ -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`)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.0.1'
|
||||
export const APP_VERSION = '1.0.2'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user