124 lines
3.4 KiB
TypeScript
124 lines
3.4 KiB
TypeScript
import { signal, type Signal } from "@wrnexus/reactive";
|
|
import {
|
|
applyCommand,
|
|
type BlockCommand,
|
|
type BlockDocument,
|
|
type CommandResult,
|
|
type DocumentKindDefinition,
|
|
type ReadonlyBlockRegistry,
|
|
type Result,
|
|
type SelectionState,
|
|
} from "@wrnexus/blocks";
|
|
|
|
export interface EditorSnapshot {
|
|
document: BlockDocument;
|
|
revision: number;
|
|
selection: SelectionState;
|
|
dirty: boolean;
|
|
status: "idle" | "saving" | "saved" | "conflict" | "error";
|
|
}
|
|
|
|
interface HistoryEntry {
|
|
forward: BlockCommand;
|
|
inverse: BlockCommand;
|
|
}
|
|
|
|
export interface BlockEditorState extends Signal<EditorSnapshot> {
|
|
execute(command: BlockCommand): Result<CommandResult>;
|
|
undo(): boolean;
|
|
redo(): boolean;
|
|
select(ids: string[], primary?: string | null): void;
|
|
markSaving(): void;
|
|
markSaved(revision: number): void;
|
|
markConflict(): void;
|
|
replaceFromServer(document: BlockDocument, revision: number): void;
|
|
canUndo(): boolean;
|
|
canRedo(): boolean;
|
|
}
|
|
|
|
export function createBlockEditorState(options: {
|
|
document: BlockDocument;
|
|
revision: number;
|
|
kind: DocumentKindDefinition;
|
|
registry: ReadonlyBlockRegistry;
|
|
historyLimit?: number;
|
|
}): BlockEditorState {
|
|
const current = signal<EditorSnapshot>({
|
|
document: options.document,
|
|
revision: options.revision,
|
|
selection: { primary: null, ordered: [] },
|
|
dirty: false,
|
|
status: "idle",
|
|
});
|
|
const past: HistoryEntry[] = [];
|
|
const future: HistoryEntry[] = [];
|
|
const limit = Math.max(1, options.historyLimit ?? 100);
|
|
const apply = (command: BlockCommand, remember: boolean): Result<CommandResult> => {
|
|
const result = applyCommand(current.get().document, command, options.kind, options.registry);
|
|
if (!result.ok) return result;
|
|
if (remember) {
|
|
past.push({ forward: command, inverse: result.value.inverse });
|
|
if (past.length > limit) past.shift();
|
|
future.length = 0;
|
|
}
|
|
current.set({
|
|
...current.get(),
|
|
document: result.value.document,
|
|
selection: result.value.selection,
|
|
dirty: true,
|
|
status: "idle",
|
|
});
|
|
return result;
|
|
};
|
|
return Object.assign(current, {
|
|
execute: (command: BlockCommand) => apply(command, true),
|
|
undo() {
|
|
const entry = past.pop();
|
|
if (!entry) return false;
|
|
const result = apply(entry.inverse, false);
|
|
if (!result.ok) {
|
|
past.push(entry);
|
|
return false;
|
|
}
|
|
future.push(entry);
|
|
return true;
|
|
},
|
|
redo() {
|
|
const entry = future.pop();
|
|
if (!entry) return false;
|
|
const result = apply(entry.forward, false);
|
|
if (!result.ok) {
|
|
future.push(entry);
|
|
return false;
|
|
}
|
|
past.push(entry);
|
|
return true;
|
|
},
|
|
select(ids: string[], primary: string | null = ids[0] ?? null) {
|
|
current.set({ ...current.get(), selection: { primary, ordered: [...new Set(ids)] } });
|
|
},
|
|
markSaving() {
|
|
current.set({ ...current.get(), status: "saving" });
|
|
},
|
|
markSaved(revision: number) {
|
|
current.set({ ...current.get(), revision, dirty: false, status: "saved" });
|
|
},
|
|
markConflict() {
|
|
current.set({ ...current.get(), status: "conflict" });
|
|
},
|
|
replaceFromServer(document: BlockDocument, revision: number) {
|
|
past.length = 0;
|
|
future.length = 0;
|
|
current.set({
|
|
document,
|
|
revision,
|
|
selection: { primary: null, ordered: [] },
|
|
dirty: false,
|
|
status: "idle",
|
|
});
|
|
},
|
|
canUndo: () => past.length > 0,
|
|
canRedo: () => future.length > 0,
|
|
});
|
|
}
|