187 lines
7.2 KiB
TypeScript
187 lines
7.2 KiB
TypeScript
/**
|
|
* Module-level slide-edit session — pure in-memory undo/redo for the editor
|
|
* canvas.
|
|
*
|
|
* EditShell invokes a surface's `useSurfaceState()` and renders its
|
|
* `SurfaceComponent` as siblings (state hook on the shell, canvas as a child
|
|
* of the frame). They must share one `SlideEditHistory`, so it lives in a
|
|
* store rather than component state — the same idiom the rest of the
|
|
* renderer uses (useCanvasStore / useStageStore).
|
|
*
|
|
* Edits are written through to the canonical stage store by the canvas
|
|
* controller (`useSlideCanvasController` in `use-slide-surface.ts`); this
|
|
* store only tracks the undo/redo timeline of an in-progress editing
|
|
* session. It deliberately does NOT persist to localStorage: the canonical
|
|
* stage store is the source of truth and already auto-persists via Dexie,
|
|
* so there is nothing "unsaved" to recover on reload — no "restore unsaved
|
|
* changes" UX, by design.
|
|
*/
|
|
|
|
import { create } from 'zustand';
|
|
import {
|
|
applyEditorTransaction,
|
|
createEditorHistory,
|
|
redoEditorTransaction,
|
|
undoEditorTransaction,
|
|
type EditorHistory,
|
|
type EditorOperation,
|
|
type EditorTransaction,
|
|
} from '@openmaic/editor/core';
|
|
import { commitSlideEdit } from '@/lib/edit/scene-edit-bridge';
|
|
import { migrateSlideContent } from '@/lib/edit/slide-schema';
|
|
import type { SlideEditHistory } from '@/lib/edit/slide-ops';
|
|
import { useStageStore } from '@/lib/store/stage';
|
|
import type { SlideContent } from '@/lib/types/stage';
|
|
|
|
interface SlideEditSessionState {
|
|
sceneId: string | null;
|
|
history: EditorHistory | null;
|
|
/** True while the legacy canvas holds an uncommitted pointer gesture locally. */
|
|
gestureActive: boolean;
|
|
|
|
/** Establish a fresh in-memory baseline for a scene. */
|
|
seed: (sceneId: string, content: SlideContent) => void;
|
|
/** Apply one canonical op (numeric inspectors, future affordances). */
|
|
applyOp: (op: EditorOperation) => void;
|
|
/** Apply a fully described editor transaction to the canonical document. */
|
|
applyTransaction: (transaction: EditorTransaction) => void;
|
|
/** Apply only while the transaction still belongs to the captured scene. */
|
|
applyTransactionForScene: (sceneId: string, transaction: EditorTransaction) => void;
|
|
/**
|
|
* Fold a renderer-committed snapshot in. `isUserEdit` is the causal
|
|
* discriminator: a real gesture commits synchronously inside a pointer
|
|
* interaction, whereas the renderer's ResizeObserver normalization (text
|
|
* auto-height) commits with no pointer gesture in flight. Non-user
|
|
* commits update `present` only — no new undo step, so `past` is left
|
|
* untouched (the reflow can chase a user resize and wiping the undo
|
|
* stack would silently break undo). `future` IS cleared, though: once
|
|
* `present` is replaced by the normalized content it has diverged from
|
|
* whatever the redo branch pointed at, so those stale entries are no
|
|
* longer valid continuations.
|
|
*/
|
|
commitContent: (next: SlideContent, isUserEdit: boolean) => void;
|
|
setGestureActive: (active: boolean) => void;
|
|
undo: () => void;
|
|
redo: () => void;
|
|
/** Tear the session down on exit from edit mode. */
|
|
end: () => void;
|
|
}
|
|
|
|
export const useSlideEditSession = create<SlideEditSessionState>((set, get) => {
|
|
/**
|
|
* Write the new canonical content through to the stage store (auto-save).
|
|
* Single point of write-through so undo, redo, applyOp, user
|
|
* commitContent, and ResizeObserver normalization all stay in lockstep
|
|
* with `useStageStore`. Stage updates fire first so renderer subscribers
|
|
* (SceneProvider reads via `currentSlideContent`) see the new content as
|
|
* soon as React processes the next batch.
|
|
*/
|
|
const writeThrough = (next: SlideContent) => {
|
|
const { sceneId } = get();
|
|
if (!sceneId) return;
|
|
useStageStore.getState().updateScene(sceneId, { content: next });
|
|
};
|
|
|
|
const replace = (history: EditorHistory) => {
|
|
const { history: prev } = get();
|
|
if (history === prev) return;
|
|
writeThrough(history.present);
|
|
set({ history });
|
|
};
|
|
|
|
return {
|
|
sceneId: null,
|
|
history: null,
|
|
gestureActive: false,
|
|
|
|
seed: (sceneId, content) => {
|
|
// Adopt the live scene content as the in-memory baseline. We do NOT
|
|
// write-through here: if the user makes no edits, the stage store
|
|
// shouldn't receive a redundant write. Any schema migration the
|
|
// first user edit triggers will naturally flow back through
|
|
// commitContent's writeThrough.
|
|
set({
|
|
sceneId,
|
|
history: createEditorHistory(migrateSlideContent(content)),
|
|
gestureActive: false,
|
|
});
|
|
},
|
|
|
|
applyOp: (op) => {
|
|
const { history } = get();
|
|
if (!history) return;
|
|
// Legacy toolbar actions may arrive after their selected element was
|
|
// deleted. Keep that one-operation UI path a silent no-op as before;
|
|
// explicit transactions remain strict so callers cannot hide invalid
|
|
// batch operations behind this compatibility behavior.
|
|
if (
|
|
'elementId' in op &&
|
|
!history.present.canvas.elements.some((element) => element.id === op.elementId)
|
|
) {
|
|
return;
|
|
}
|
|
replace(
|
|
applyEditorTransaction(history, {
|
|
origin: 'toolbar',
|
|
history: 'record',
|
|
operations: [op],
|
|
}),
|
|
);
|
|
},
|
|
|
|
applyTransaction: (transaction) => {
|
|
const { history } = get();
|
|
if (!history) return;
|
|
replace(applyEditorTransaction(history, transaction));
|
|
},
|
|
|
|
applyTransactionForScene: (sceneId, transaction) => {
|
|
const { history, sceneId: currentSceneId } = get();
|
|
if (!history || currentSceneId !== sceneId) return;
|
|
replace(applyEditorTransaction(history, transaction));
|
|
},
|
|
|
|
commitContent: (next, isUserEdit) => {
|
|
const { history } = get();
|
|
if (!history) return;
|
|
if (!isUserEdit) {
|
|
// ResizeObserver / auto-height normalization: don't push an undo
|
|
// step (the reflow can chase a user resize and wiping `past` would
|
|
// silently break undo), but DO write through — the auto-fit height
|
|
// IS the new canonical state. Clear `future`, though: `present` now
|
|
// holds the normalized content, which has diverged from whatever
|
|
// the redo branch pointed at, so replaying those stale entries
|
|
// would discard this normalization. Leaving them would let a later
|
|
// redo silently revert to pre-undo content (canvas/store divergence).
|
|
writeThrough(next);
|
|
set({ history: { ...history, present: next, future: [] } });
|
|
return;
|
|
}
|
|
// The legacy Canvas still emits complete snapshots while the renderer
|
|
// editor is feature-flagged. Keep this compatibility bridge isolated
|
|
// to that fallback until its React surface moves into @openmaic/editor.
|
|
replace(
|
|
commitSlideEdit(history as unknown as SlideEditHistory, next) as unknown as EditorHistory,
|
|
);
|
|
},
|
|
|
|
setGestureActive: (gestureActive) => set({ gestureActive }),
|
|
|
|
undo: () => {
|
|
const { history } = get();
|
|
if (!history) return;
|
|
replace(undoEditorTransaction(history));
|
|
},
|
|
|
|
redo: () => {
|
|
const { history } = get();
|
|
if (!history) return;
|
|
replace(redoEditorTransaction(history));
|
|
},
|
|
|
|
end: () => {
|
|
set({ sceneId: null, history: null, gestureActive: false });
|
|
},
|
|
};
|
|
});
|