mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors * test(sdk,ci): smoke test + explicit sdk-tests CI gate Smoke test covers the full public surface: openComposition → setStyle/setText/dispatch(moveElement) → serialize applyPatches + ORIGIN_APPLY_PATCHES tagging batch() coalescing + transactional rollback on throw undo/redo round-trip persist adapter write + persist:error surfacing T3 embedded mode: override-set apply on open + getOverrides round-trip Adds sdk-tests CI job so SDK coverage is explicitly named and required — prevents a repeat of the demo-next vitest-never-ran incident. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): export adapter types, awaitable flush(), never-coalesce mode - Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package root — callers can now write typed fakes without reaching into internals - Add flush(): Promise<void> to Composition interface + CompositionImpl — app-close handlers can await a clean drain of the persist queue - coalesceMs <= 0 disables coalescing entirely in createHistory — enables deterministic test scenarios without per-entry timestamp manipulation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke - setText on element with no prior text node (firstTextIdx=-1 path) - applyOverrideSet null removal on non-existent prop is a no-op (no throw) - smoke persist test uses comp.flush() instead of setTimeout - can() JSDoc clarifies Phase 3b false-return is intentional feature-detection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: trigger regression suite * fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy bun install --frozen-lockfile fails in the regression Docker build because the lockfile references the sdk workspace member but its package.json was not copied into the image before the install step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
/**
|
|
* Optional history module (F5 layering).
|
|
*
|
|
* Wires onto a Composition session via on('patch') and implements undo/redo.
|
|
* Coalesces same-op+same-targets bursts within coalesceMs into one undo entry.
|
|
*
|
|
* Usage (standalone / T1):
|
|
* const comp = await openComposition(html, { persist });
|
|
* // openComposition attaches this automatically in non-embedded mode.
|
|
*
|
|
* Usage (manual / custom undo timeline):
|
|
* const history = createHistory(comp, { coalesceMs: 500, trackedOrigins: ['local'] });
|
|
* // host calls history.undo() / history.redo() instead of comp.undo() / comp.redo()
|
|
*/
|
|
|
|
import type { Composition, JsonPatchOp, PatchEvent } from "./types.js";
|
|
import { ORIGIN_APPLY_PATCHES } from "./types.js";
|
|
|
|
export interface HistoryEntry {
|
|
readonly patches: readonly JsonPatchOp[];
|
|
readonly inversePatches: readonly JsonPatchOp[];
|
|
readonly opTypes: readonly string[];
|
|
readonly origin: unknown;
|
|
readonly timestamp: number;
|
|
}
|
|
|
|
export interface HistoryModule {
|
|
undo(): boolean;
|
|
redo(): boolean;
|
|
canUndo(): boolean;
|
|
canRedo(): boolean;
|
|
dispose(): void;
|
|
}
|
|
|
|
export interface HistoryOptions {
|
|
/** Only ops with these origins enter the undo stack. Default: all non-ORIGIN_APPLY_PATCHES. */
|
|
trackedOrigins?: unknown[];
|
|
/** Coalesce window in ms. Same opTypes + same origin within window → one entry. Default: 300. */
|
|
coalesceMs?: number;
|
|
/** Max undo stack depth. Default: 100. */
|
|
maxEntries?: number;
|
|
}
|
|
|
|
export function createHistory(session: Composition, opts: HistoryOptions = {}): HistoryModule {
|
|
const coalesceMs = opts.coalesceMs ?? 300;
|
|
const maxEntries = opts.maxEntries ?? 100;
|
|
const { trackedOrigins } = opts;
|
|
|
|
const undoStack: HistoryEntry[] = [];
|
|
let redoStack: HistoryEntry[] = [];
|
|
|
|
function isTracked(origin: unknown): boolean {
|
|
if (origin === ORIGIN_APPLY_PATCHES) return false;
|
|
if (!trackedOrigins) return true;
|
|
return trackedOrigins.includes(origin);
|
|
}
|
|
|
|
function pathsKey(patches: readonly JsonPatchOp[]): string {
|
|
return patches
|
|
.map((p) => p.path)
|
|
.sort()
|
|
.join("\n");
|
|
}
|
|
|
|
function opTypesKey(opTypes: readonly string[]): string {
|
|
// Sorted: the same op-type SET coalesces regardless of dispatch order.
|
|
return [...opTypes].sort().join(",");
|
|
}
|
|
|
|
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
|
|
if (coalesceMs <= 0) return false;
|
|
if (opTypesKey(entry.opTypes) !== opTypesKey(incoming.opTypes)) return false;
|
|
if (entry.origin !== incoming.origin) return false;
|
|
// Coalesce only when the SAME paths are touched (e.g. slider drag on one
|
|
// property). Without this, rapid edits to different elements would merge
|
|
// into one entry holding the second forward + first inverse — undo would
|
|
// then revert the wrong element.
|
|
if (pathsKey(entry.patches) !== pathsKey(incoming.patches)) return false;
|
|
const now = Date.now();
|
|
return now - entry.timestamp <= coalesceMs;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const unsubscribe = session.on("patch", (event: PatchEvent) => {
|
|
if (!isTracked(event.origin)) return;
|
|
|
|
const last = undoStack[undoStack.length - 1];
|
|
if (last && shouldCoalesce(last, event)) {
|
|
// Coalesce: keep first inverse (original prev), replace forward with latest value.
|
|
// Slide timestamp forward so rapid-fire edits keep coalescing.
|
|
const coalesced: HistoryEntry = {
|
|
patches: event.patches,
|
|
inversePatches: last.inversePatches,
|
|
opTypes: last.opTypes,
|
|
origin: last.origin,
|
|
timestamp: Date.now(),
|
|
};
|
|
undoStack[undoStack.length - 1] = coalesced;
|
|
} else {
|
|
undoStack.push({
|
|
patches: event.patches,
|
|
inversePatches: event.inversePatches,
|
|
opTypes: event.opTypes,
|
|
origin: event.origin,
|
|
timestamp: Date.now(),
|
|
});
|
|
if (undoStack.length > maxEntries) undoStack.shift();
|
|
}
|
|
|
|
// Any new op clears the redo stack.
|
|
redoStack = [];
|
|
});
|
|
|
|
return {
|
|
undo(): boolean {
|
|
const entry = undoStack.pop();
|
|
if (!entry) return false;
|
|
session.applyPatches(entry.inversePatches, { origin: ORIGIN_APPLY_PATCHES });
|
|
redoStack.push(entry);
|
|
return true;
|
|
},
|
|
|
|
redo(): boolean {
|
|
const entry = redoStack.pop();
|
|
if (!entry) return false;
|
|
session.applyPatches(entry.patches, { origin: ORIGIN_APPLY_PATCHES });
|
|
undoStack.push(entry);
|
|
return true;
|
|
},
|
|
|
|
canUndo(): boolean {
|
|
return undoStack.length > 0;
|
|
},
|
|
|
|
canRedo(): boolean {
|
|
return redoStack.length > 0;
|
|
},
|
|
|
|
dispose(): void {
|
|
unsubscribe();
|
|
undoStack.length = 0;
|
|
redoStack.length = 0;
|
|
},
|
|
};
|
|
}
|