mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete (#1325)
* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete * fix(sdk): address review — live-DOM query cache, single parse, style parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): restore full public exports now session/document modules exist index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): coalesce history by patch paths; replay override-set on open Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22bb6737c5
commit
7010edac85
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Archetype (c) — Headless agent script
|
||||
*
|
||||
* Shows: no browser, no persist adapter, no preview — pure editing engine.
|
||||
* Agents: batch restyling, localization, A/B variants, programmatic animation.
|
||||
* Explicit-id ops via query API — no selection, no mouse events.
|
||||
*
|
||||
* F1 payoff: headless is possible BECAUSE ops have explicit targets.
|
||||
* Selection-implicit ops (old R0) would break here — no UI → no selection.
|
||||
*/
|
||||
|
||||
import { openComposition } from "../src/index.js";
|
||||
import type { ElementSnapshot } from "../src/index.js";
|
||||
|
||||
// ── Localization agent ────────────────────────────────────────────────────────
|
||||
// Rewrites all text elements to a new locale. No browser, no preview.
|
||||
|
||||
export async function localize(html: string, translations: Map<string, string>): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const textElements = comp.find({ tag: "div" });
|
||||
|
||||
comp.batch(() => {
|
||||
for (const id of textElements) {
|
||||
const el = comp.getElement(id);
|
||||
if (!el?.text) continue;
|
||||
const translated = translations.get(el.text);
|
||||
if (translated) comp.setText(id, translated);
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Brand restyle agent ───────────────────────────────────────────────────────
|
||||
// Apply brand colors to all elements with a matching class name.
|
||||
|
||||
export async function applyBrandColors(
|
||||
html: string,
|
||||
brandPrimary: string,
|
||||
brandSecondary: string,
|
||||
): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
// Query: find elements by attribute pattern
|
||||
const brandColorEls = comp
|
||||
.getElements()
|
||||
.filter((el) => el.attributes["data-brand-role"] === "primary");
|
||||
const brandSecondaryEls = comp
|
||||
.getElements()
|
||||
.filter((el) => el.attributes["data-brand-role"] === "secondary");
|
||||
|
||||
comp.batch(() => {
|
||||
for (const el of brandColorEls) {
|
||||
comp.setStyle(el.id, { color: brandPrimary });
|
||||
}
|
||||
for (const el of brandSecondaryEls) {
|
||||
comp.setStyle(el.id, { color: brandSecondary });
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── A/B variant agent ─────────────────────────────────────────────────────────
|
||||
// Produce two HTML variants from one template.
|
||||
|
||||
export async function createABVariants(
|
||||
html: string,
|
||||
variantB: { headlineId: string; text: string; color: string },
|
||||
): Promise<{ variantA: string; variantB: string }> {
|
||||
const compA = await openComposition(html);
|
||||
const variantAHtml = compA.serialize();
|
||||
compA.dispose();
|
||||
|
||||
const compB = await openComposition(html);
|
||||
compB.setText(variantB.headlineId, variantB.text);
|
||||
compB.setStyle(variantB.headlineId, { color: variantB.color });
|
||||
const variantBHtml = compB.serialize();
|
||||
compB.dispose();
|
||||
|
||||
return { variantA: variantAHtml, variantB: variantBHtml };
|
||||
}
|
||||
|
||||
// ── Asset swap agent ──────────────────────────────────────────────────────────
|
||||
// F3: setAttribute handles img src, href, alt — the full attribute space.
|
||||
|
||||
export async function swapAssets(
|
||||
html: string,
|
||||
swaps: Array<{ id: string; src: string; alt?: string }>,
|
||||
): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
comp.batch(() => {
|
||||
for (const swap of swaps) {
|
||||
comp.setAttribute(swap.id, "src", swap.src);
|
||||
if (swap.alt !== undefined) {
|
||||
comp.setAttribute(swap.id, "alt", swap.alt);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Batch GSAP animation agent ────────────────────────────────────────────────
|
||||
// Add staggered entrance animations to all text elements.
|
||||
|
||||
export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const textEls = comp.find({ tag: "div" });
|
||||
|
||||
// Phase 3b feature-detect: addGsapTween throws UnsupportedOpError until the
|
||||
// parser-backed engine lands — skip animation rather than crash the job.
|
||||
const probeTween = {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.5,
|
||||
ease: "power3.out",
|
||||
fromProperties: { opacity: 0, y: 30 },
|
||||
} as const;
|
||||
const first = textEls[0];
|
||||
if (
|
||||
!first ||
|
||||
!comp.can({ type: "addGsapTween", target: first, id: "preflight", tween: probeTween })
|
||||
) {
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
comp.batch(() => {
|
||||
textEls.forEach((id, i) => {
|
||||
comp.addGsapTween(id, { ...probeTween, position: i * staggerDelay });
|
||||
});
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Composition metadata normalization ────────────────────────────────────────
|
||||
|
||||
export async function normalizeToPortrait(html: string): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
comp.dispatch({ type: "setCompositionMetadata", width: 1080, height: 1920 });
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Variable override agent ───────────────────────────────────────────────────
|
||||
// Apply a brand kit as composition variable overrides.
|
||||
|
||||
export async function applyVariableKit(
|
||||
html: string,
|
||||
kit: Record<string, string | number | boolean>,
|
||||
): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
comp.batch(() => {
|
||||
for (const [id, value] of Object.entries(kit)) {
|
||||
comp.setVariableValue(id, value);
|
||||
}
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Inspection utility ────────────────────────────────────────────────────────
|
||||
// Agents need to discover what's in a composition before editing.
|
||||
|
||||
export async function inspectComposition(html: string): Promise<{
|
||||
elementCount: number;
|
||||
textElements: ElementSnapshot[];
|
||||
imageElements: ElementSnapshot[];
|
||||
ids: string[];
|
||||
}> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const all = comp.getElements();
|
||||
const textElements = all.filter((el) => ["div", "p", "h1", "h2", "h3", "span"].includes(el.tag));
|
||||
const imageElements = all.filter((el) => el.tag === "img");
|
||||
|
||||
comp.dispose();
|
||||
|
||||
return {
|
||||
elementCount: all.length,
|
||||
textElements,
|
||||
imageElements,
|
||||
ids: all.map((el) => el.id),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Timing normalization agent ────────────────────────────────────────────────
|
||||
|
||||
export async function normalizeTiming(html: string, totalDuration: number): Promise<string> {
|
||||
const comp = await openComposition(html);
|
||||
|
||||
const timedEls = comp.getElements().filter((el) => el.start !== null && el.duration !== null);
|
||||
|
||||
const lastEnd = timedEls.reduce(
|
||||
(max, el) => Math.max(max, (el.start ?? 0) + (el.duration ?? 0)),
|
||||
0,
|
||||
);
|
||||
if (lastEnd === 0) return comp.serialize();
|
||||
|
||||
const scale = totalDuration / lastEnd;
|
||||
|
||||
comp.batch(() => {
|
||||
for (const el of timedEls) {
|
||||
comp.setTiming(el.id, {
|
||||
start: Math.round((el.start ?? 0) * scale * 100) / 100,
|
||||
duration: Math.round((el.duration ?? 0) * scale * 100) / 100,
|
||||
});
|
||||
}
|
||||
comp.dispatch({ type: "setCompositionMetadata", duration: totalDuration });
|
||||
});
|
||||
|
||||
return comp.serialize();
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Archetype (a) — React app embedding the SDK (T1 standalone)
|
||||
*
|
||||
* Shows: openComposition, event subscription, typed methods, selection sugar,
|
||||
* batch + brand kit, useSyncExternalStore pattern, undo/redo, export.
|
||||
*
|
||||
* Note: JSX/React not imported here to keep this file framework-agnostic .ts.
|
||||
* In a real React app: wrap createEditorSession in useEffect, subscribe with
|
||||
* useSyncExternalStore (see comment blocks below).
|
||||
*/
|
||||
|
||||
import { openComposition, ORIGIN_APPLY_PATCHES } from "../src/index.js";
|
||||
import { createMemoryAdapter } from "../src/adapters/memory.js";
|
||||
import type { Composition, ElementSnapshot } from "../src/index.js";
|
||||
|
||||
// ── Session factory ───────────────────────────────────────────────────────────
|
||||
// Typically called once in useEffect(() => { createEditorSession(html).then(setComp) }, [])
|
||||
|
||||
export async function createEditorSession(html: string): Promise<Composition> {
|
||||
const persist = createMemoryAdapter();
|
||||
|
||||
const comp = await openComposition(html, { persist });
|
||||
|
||||
// Persist failures surface as events, never fatal exceptions.
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
console.error(`Auto-save failed: ${error.message}`);
|
||||
// In a real app: show a toast notification
|
||||
});
|
||||
|
||||
return comp;
|
||||
}
|
||||
|
||||
// ── useSyncExternalStore integration ─────────────────────────────────────────
|
||||
// React 18+ pattern:
|
||||
//
|
||||
// const selection = useSyncExternalStore(
|
||||
// (cb) => comp.on('selectionchange', cb),
|
||||
// () => comp.getSelection(),
|
||||
// )
|
||||
//
|
||||
// Imperative equivalent for non-React consumers:
|
||||
|
||||
export function subscribeToSelection(
|
||||
comp: Composition,
|
||||
onChange: (ids: string[]) => void,
|
||||
): () => void {
|
||||
return comp.on("selectionchange", onChange);
|
||||
}
|
||||
|
||||
// ── Property panel bindings ───────────────────────────────────────────────────
|
||||
|
||||
export function applyStyle(comp: Composition, id: string, prop: string, value: string): void {
|
||||
// F1: explicit target — panel holds the id when rendering the current element
|
||||
comp.setStyle(id, { [prop]: value });
|
||||
}
|
||||
|
||||
export function applyFontSize(comp: Composition, id: string, px: number): void {
|
||||
comp.setStyle(id, { fontSize: `${px}px` });
|
||||
}
|
||||
|
||||
export function applyTextContent(comp: Composition, id: string, value: string): void {
|
||||
comp.setText(id, value);
|
||||
}
|
||||
|
||||
// Selection sugar — resolves getSelection() → explicit ops at call time.
|
||||
// Equivalent to: ids = comp.getSelection(); comp.setStyle(ids, {...})
|
||||
export function applyColorToSelection(comp: Composition, color: string): void {
|
||||
comp.selection().setStyle({ color });
|
||||
}
|
||||
|
||||
// ── Brand kit (batch) ─────────────────────────────────────────────────────────
|
||||
// One undo entry, one persist write, one change event.
|
||||
|
||||
export function applyBrandKit(comp: Composition, kit: Record<string, string>): void {
|
||||
comp.batch(() => {
|
||||
for (const [variableId, value] of Object.entries(kit)) {
|
||||
comp.setVariableValue(variableId, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Timeline drag ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function onClipDrag(comp: Composition, id: string, start: number, duration: number): void {
|
||||
comp.setTiming(id, { start, duration });
|
||||
}
|
||||
|
||||
// ── GSAP animation panel ──────────────────────────────────────────────────────
|
||||
|
||||
// Phase 3b: GSAP ops throw UnsupportedOpError until the parser-backed engine
|
||||
// lands — feature-detect with can() and disable the panel control if false.
|
||||
|
||||
export function addBounceIn(comp: Composition, targetId: string): string | null {
|
||||
const tween = {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.5,
|
||||
ease: "bounce.out",
|
||||
fromProperties: { y: 40, opacity: 0 },
|
||||
} as const;
|
||||
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
|
||||
return comp.addGsapTween(targetId, tween);
|
||||
}
|
||||
|
||||
export function updateEase(comp: Composition, animationId: string, ease: string): void {
|
||||
if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } })) return;
|
||||
comp.setGsapTween(animationId, { ease });
|
||||
}
|
||||
|
||||
// ── Undo / redo ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function undo(comp: Composition): void {
|
||||
comp.undo();
|
||||
}
|
||||
|
||||
export function redo(comp: Composition): void {
|
||||
comp.redo();
|
||||
}
|
||||
|
||||
// ── T3 host undo integration (embedded mode) ─────────────────────────────────
|
||||
// When the SDK is embedded in a host with its own undo timeline:
|
||||
|
||||
export type HostHistoryEntry =
|
||||
| { kind: "sdk"; patches: ReturnType<Composition["getOverrides"]>; inversePatches: unknown[] }
|
||||
| { kind: "native"; data: unknown };
|
||||
|
||||
export function setupHostUndo(
|
||||
comp: Composition,
|
||||
pushToHostHistory: (entry: HostHistoryEntry) => void,
|
||||
): () => void {
|
||||
return comp.on("patch", ({ patches, inversePatches, origin }) => {
|
||||
// Origin guard: skip re-emissions from applyPatches to avoid undo loops (F4)
|
||||
if (origin === ORIGIN_APPLY_PATCHES) return;
|
||||
|
||||
pushToHostHistory({
|
||||
kind: "sdk",
|
||||
patches: patches as unknown as ReturnType<Composition["getOverrides"]>,
|
||||
inversePatches: [...inversePatches],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportHtml(comp: Composition): string {
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Query API usage ───────────────────────────────────────────────────────────
|
||||
|
||||
export function findTextElements(comp: Composition): ElementSnapshot[] {
|
||||
const ids = comp.find({ tag: "div" });
|
||||
return ids.map((id) => comp.getElement(id)).filter((el): el is ElementSnapshot => el !== null);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Archetype (b) — Vanilla standalone editor (T1)
|
||||
*
|
||||
* Shows: openComposition with fs adapter pattern, typed methods (the docs page one surface),
|
||||
* element handle, batch, dispatch (advanced layer), slider-burst coalescing intent,
|
||||
* sub-composition editing intent, timeline label ops.
|
||||
*
|
||||
* This is the "zero-framework" path: plain TypeScript, no React, no Vue.
|
||||
* Target: a tools developer building a custom editor UI from scratch.
|
||||
*/
|
||||
|
||||
import { openComposition } from "../src/index.js";
|
||||
import { createMemoryAdapter } from "../src/adapters/memory.js";
|
||||
import type { Composition, GsapTweenSpec } from "../src/index.js";
|
||||
|
||||
// ── Initialize ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initEditor(html: string): Promise<Composition> {
|
||||
// Use createFsAdapter({ root: projectDir }) in production:
|
||||
// import { createFsAdapter } from '@hyperframes/sdk/adapters/fs'
|
||||
const persist = createMemoryAdapter();
|
||||
|
||||
const comp = await openComposition(html, {
|
||||
persist,
|
||||
coalesceMs: 300,
|
||||
});
|
||||
|
||||
comp.on("persist:error", ({ error }) => {
|
||||
showError(`Auto-save failed: ${error.message}${error.hint ? ` — ${error.hint}` : ""}`);
|
||||
});
|
||||
|
||||
return comp;
|
||||
}
|
||||
|
||||
// ── Property panel — typed method layer (F10 docs page one) ──────────────────
|
||||
|
||||
export function setColor(comp: Composition, id: string, color: string): void {
|
||||
comp.setStyle(id, { color });
|
||||
}
|
||||
|
||||
export function setFontFamily(comp: Composition, id: string, family: string): void {
|
||||
comp.setStyle(id, { fontFamily: family });
|
||||
}
|
||||
|
||||
export function swapImage(comp: Composition, id: string, src: string): void {
|
||||
// F3: setAttribute closes the attribute space — handles img src, href, alt, data-*, ARIA
|
||||
comp.setAttribute(id, "src", src);
|
||||
}
|
||||
|
||||
export function setAltText(comp: Composition, id: string, alt: string): void {
|
||||
comp.setAttribute(id, "alt", alt);
|
||||
}
|
||||
|
||||
export function removeElement(comp: Composition, id: string): void {
|
||||
comp.removeElement(id);
|
||||
// Inverse patch carries full serialized subtree — undo restores it.
|
||||
}
|
||||
|
||||
// ── Element handle pattern ────────────────────────────────────────────────────
|
||||
// comp.element(id) — curried handle, no stale-ref hazard
|
||||
|
||||
export function editHeadline(comp: Composition, headlineId: string): void {
|
||||
const h = comp.element(headlineId);
|
||||
h.setText("New headline");
|
||||
h.setStyle({ color: "#FFD60A", fontSize: "96px" });
|
||||
h.setTiming({ start: 0.5, duration: 3 });
|
||||
}
|
||||
|
||||
// ── Slider burst (rapid dispatch — coalesced into one undo entry) ─────────────
|
||||
|
||||
export function onFontSizeSlider(comp: Composition, id: string, px: number): void {
|
||||
// Each input event dispatches setStyle. History coalesces: same op + same target
|
||||
// within coalesceMs → one undo entry (forward keeps latest, inverse keeps first prev).
|
||||
// Persist queue writes once when the burst settles.
|
||||
comp.setStyle(id, { fontSize: `${px}px` });
|
||||
}
|
||||
|
||||
// ── Batch ─────────────────────────────────────────────────────────────────────
|
||||
// One undo entry, one persist write, one subscriber notification.
|
||||
|
||||
export function applyTextPreset(
|
||||
comp: Composition,
|
||||
id: string,
|
||||
preset: { fontSize: string; color: string; fontFamily: string },
|
||||
): void {
|
||||
comp.batch(() => {
|
||||
comp.setStyle(id, {
|
||||
fontSize: preset.fontSize,
|
||||
color: preset.color,
|
||||
fontFamily: preset.fontFamily,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── dispatch() — advanced layer for agents / automation ──────────────────────
|
||||
// Typed methods are sugar; dispatch() remains public for data-shaped op emission.
|
||||
|
||||
export function applyOpFromJson(comp: Composition, opJson: unknown): void {
|
||||
// Agents or automation scripts that emit JSON op objects use dispatch directly.
|
||||
comp.dispatch(opJson as Parameters<Composition["dispatch"]>[0]);
|
||||
}
|
||||
|
||||
// ── GSAP operations ───────────────────────────────────────────────────────────
|
||||
|
||||
// NOTE (Phase 3b): GSAP ops require the parser-backed engine and throw
|
||||
// UnsupportedOpError until it lands. Feature-detect with can() first.
|
||||
|
||||
export function addFadeIn(comp: Composition, targetId: string, delay = 0): string | null {
|
||||
const tween: GsapTweenSpec = {
|
||||
method: "from",
|
||||
position: delay,
|
||||
duration: 0.4,
|
||||
ease: "power2.out",
|
||||
fromProperties: { opacity: 0 },
|
||||
};
|
||||
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
|
||||
return comp.addGsapTween(targetId, tween);
|
||||
}
|
||||
|
||||
export function addBounce(
|
||||
comp: Composition,
|
||||
targetId: string,
|
||||
overrides?: Partial<GsapTweenSpec>,
|
||||
): string | null {
|
||||
const tween: GsapTweenSpec = {
|
||||
method: "from",
|
||||
position: 0,
|
||||
duration: 0.6,
|
||||
ease: "bounce.out",
|
||||
fromProperties: { y: 60, opacity: 0 },
|
||||
...overrides,
|
||||
};
|
||||
if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null;
|
||||
return comp.addGsapTween(targetId, tween);
|
||||
}
|
||||
|
||||
// Keyframe editing (addGsapKeyframe / removeGsapKeyframe — v1, promoted 2026-06-09):
|
||||
export function insertKeyframe(comp: Composition, animationId: string, position: number): void {
|
||||
comp.dispatch({
|
||||
type: "addGsapKeyframe",
|
||||
animationId,
|
||||
position,
|
||||
value: { opacity: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
// Timeline labels
|
||||
export function addLabel(comp: Composition, name: string, position: number): void {
|
||||
comp.dispatch({ type: "addLabel", name, position });
|
||||
}
|
||||
|
||||
// ── Composition metadata ──────────────────────────────────────────────────────
|
||||
|
||||
export function resizeComposition(
|
||||
comp: Composition,
|
||||
width: number,
|
||||
height: number,
|
||||
duration: number,
|
||||
): void {
|
||||
comp.dispatch({ type: "setCompositionMetadata", width, height, duration });
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportComposition(comp: Composition): string {
|
||||
return comp.serialize();
|
||||
}
|
||||
|
||||
// ── Query API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listAllElementIds(comp: Composition): string[] {
|
||||
return comp.getElements().map((el) => el.id);
|
||||
}
|
||||
|
||||
export function findByText(comp: Composition, text: string): string[] {
|
||||
return comp.find({ text });
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function cleanup(comp: Composition): void {
|
||||
comp.dispose();
|
||||
}
|
||||
|
||||
function showError(msg: string): void {
|
||||
console.error(msg);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
export interface FsAdapterOptions {
|
||||
/** Root directory for composition files */
|
||||
root: string;
|
||||
}
|
||||
|
||||
// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage).
|
||||
// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers).
|
||||
|
||||
class FsAdapter implements PersistAdapter {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(opts: FsAdapterOptions) {
|
||||
this.root = opts.root;
|
||||
}
|
||||
|
||||
async read(_path: string): Promise<string | undefined> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async write(_path: string, _content: string): Promise<void> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
}
|
||||
|
||||
on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter {
|
||||
return new FsAdapter(opts);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
|
||||
/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
|
||||
class HeadlessPreviewAdapter implements PreviewAdapter {
|
||||
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
applyDraft(_id: string, _props: DraftProps): void {}
|
||||
|
||||
commitPreview(): void {}
|
||||
|
||||
cancelPreview(): void {}
|
||||
|
||||
select(_ids: string[], _opts?: { additive?: boolean }): void {}
|
||||
|
||||
on(_event: "selection", _handler: (ids: string[]) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeadlessAdapter(): PreviewAdapter {
|
||||
return new HeadlessPreviewAdapter();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
class MemoryAdapter implements PersistAdapter {
|
||||
private readonly store = new Map<string, string>();
|
||||
private readonly history = new Map<string, PersistVersionEntry[]>();
|
||||
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
|
||||
private versionCounter = 0;
|
||||
private faultMessage: string | null = null;
|
||||
|
||||
async read(path: string): Promise<string | undefined> {
|
||||
return this.store.get(path);
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
if (this.faultMessage !== null) {
|
||||
const msg = this.faultMessage;
|
||||
this.faultMessage = null;
|
||||
this.errorListeners.forEach((l) => l({ error: { message: msg } }));
|
||||
return;
|
||||
}
|
||||
this.store.set(path, content);
|
||||
const hist = this.history.get(path) ?? [];
|
||||
const entry: PersistVersionEntry = {
|
||||
key: `v${++this.versionCounter}`,
|
||||
content,
|
||||
};
|
||||
hist.unshift(entry);
|
||||
this.history.set(path, hist);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Memory adapter writes are synchronous — nothing to drain.
|
||||
}
|
||||
|
||||
async listVersions(path: string): Promise<PersistVersionEntry[]> {
|
||||
return [...(this.history.get(path) ?? [])];
|
||||
}
|
||||
|
||||
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
|
||||
const hist = this.history.get(path) ?? [];
|
||||
return hist.find((v) => v.key === versionKey)?.content;
|
||||
}
|
||||
|
||||
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
|
||||
if (event !== "persist:error") return () => {};
|
||||
this.errorListeners.push(handler);
|
||||
return () => {
|
||||
const idx = this.errorListeners.indexOf(handler);
|
||||
if (idx !== -1) this.errorListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test helper — next write fires persist:error instead of committing */
|
||||
injectFault(message: string): void {
|
||||
this.faultMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void } {
|
||||
return new MemoryAdapter();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* T13 — PersistAdapter contract suite
|
||||
*
|
||||
* Parameterized over adapter implementations. Every adapter (memory, fs, S3, HTTP)
|
||||
* runs the same suite automatically — write once, protect all.
|
||||
*
|
||||
* Run against the memory adapter immediately; future implementations:
|
||||
* runPersistAdapterContract("fs", () => createFsAdapter({ root: tmpDir }))
|
||||
* runPersistAdapterContract("s3", () => createS3Adapter({ bucket, prefix }))
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createMemoryAdapter } from "./memory.js";
|
||||
import type { PersistAdapter } from "./types.js";
|
||||
|
||||
export function runPersistAdapterContract(
|
||||
label: string,
|
||||
createAdapter: () => PersistAdapter,
|
||||
): void {
|
||||
describe(`PersistAdapter contract — ${label}`, () => {
|
||||
it("read returns undefined for a path never written", async () => {
|
||||
const adapter = createAdapter();
|
||||
expect(await adapter.read("missing.html")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("write then read returns the written content", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "<html></html>");
|
||||
expect(await adapter.read("comp.html")).toBe("<html></html>");
|
||||
});
|
||||
|
||||
it("second write overwrites the first", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
await adapter.write("comp.html", "v2");
|
||||
expect(await adapter.read("comp.html")).toBe("v2");
|
||||
});
|
||||
|
||||
it("flush() returns after any pending writes are committed", async () => {
|
||||
const adapter = createAdapter();
|
||||
// Write without awaiting to exercise the queue path
|
||||
void adapter.write("comp.html", "queued");
|
||||
await adapter.flush();
|
||||
expect(await adapter.read("comp.html")).toBe("queued");
|
||||
});
|
||||
|
||||
it("listVersions returns entries in reverse-chronological order", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
await adapter.write("comp.html", "v2");
|
||||
await adapter.write("comp.html", "v3");
|
||||
const versions = await adapter.listVersions("comp.html");
|
||||
expect(versions.length).toBeGreaterThanOrEqual(3);
|
||||
// Newest first
|
||||
expect(versions[0]?.content).toBe("v3");
|
||||
expect(versions[versions.length - 1]?.content).toBe("v1");
|
||||
});
|
||||
|
||||
it("loadFrom restores the model to that version's content", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "v1");
|
||||
const versions = await adapter.listVersions("comp.html");
|
||||
const firstKey = versions[versions.length - 1]?.key;
|
||||
expect(firstKey).toBeDefined();
|
||||
await adapter.write("comp.html", "v2");
|
||||
const restored = await adapter.loadFrom("comp.html", firstKey!);
|
||||
expect(restored).toBe("v1");
|
||||
});
|
||||
|
||||
it("listVersions returns empty array for a path never written", async () => {
|
||||
const adapter = createAdapter();
|
||||
expect(await adapter.listVersions("missing.html")).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadFrom returns undefined for an unknown version key", async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.write("comp.html", "content");
|
||||
expect(await adapter.loadFrom("comp.html", "nonexistent-key")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("on('persist:error') fires when a write fails; error is not thrown", async () => {
|
||||
// This test uses the injectFault() test helper if available.
|
||||
// For adapters without fault injection, skip with a note.
|
||||
const adapter = createAdapter();
|
||||
const hasInjectFault =
|
||||
"injectFault" in adapter &&
|
||||
typeof (adapter as { injectFault: unknown }).injectFault === "function";
|
||||
|
||||
if (!hasInjectFault) {
|
||||
// Adapter does not expose fault injection — skip execution
|
||||
// (test still runs to document the contract; real adapters must implement this)
|
||||
return;
|
||||
}
|
||||
|
||||
const onError = vi.fn();
|
||||
adapter.on("persist:error", onError);
|
||||
(adapter as { injectFault(m: string): void }).injectFault("network error");
|
||||
|
||||
await adapter.write("comp.html", "content");
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ message: "network error" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsubscribe returned by on() removes the listener", async () => {
|
||||
const adapter = createAdapter();
|
||||
const onError = vi.fn();
|
||||
const unsub = adapter.on("persist:error", onError);
|
||||
unsub();
|
||||
|
||||
// Fire an error if possible
|
||||
if (
|
||||
"injectFault" in adapter &&
|
||||
typeof (adapter as { injectFault: unknown }).injectFault === "function"
|
||||
) {
|
||||
(adapter as { injectFault(m: string): void }).injectFault("err");
|
||||
await adapter.write("comp.html", "x");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run the suite against the memory adapter immediately
|
||||
runPersistAdapterContract("memory", createMemoryAdapter);
|
||||
@@ -1,5 +1,3 @@
|
||||
// Consumed by session.ts + adapter implementations in the next stacked PR (#1325).
|
||||
// fallow-ignore-file unused-file
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
// ─── PersistAdapter ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* SDK document model — adaptation layer on top of @hyperframes/core.
|
||||
*
|
||||
* F6 decision: SDK builds ON core, no parser duplication.
|
||||
* - ensureHfIds (from core) is the parse entry point: all construction starts here.
|
||||
* - DOMParser is NOT used (browser-only). linkedom is the node-safe primitive.
|
||||
* - ParsedHtml (core) is the Studio timeline view (timed elements only).
|
||||
* HyperFramesElement is the editing view (ALL editable elements, with raw attrs).
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
||||
import { findRoot, getElementStyles } from "./engine/model.js";
|
||||
import type { HyperFramesElement, SdkDocument } from "./types.js";
|
||||
|
||||
// Tags that carry no editable content and must not enter the element tree.
|
||||
const EXCLUDED_TAGS = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"template",
|
||||
"meta",
|
||||
"link",
|
||||
"noscript",
|
||||
"base",
|
||||
"head",
|
||||
]);
|
||||
|
||||
// Snapshot text is TRIMMED for display (markup indentation produces noisy
|
||||
// whitespace text nodes). setText writes verbatim — engine getOwnText/setOwnText
|
||||
// operate on raw text. el.text is a display value, not a round-trip identity.
|
||||
function ownText(el: Element): string | null {
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
});
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function buildElement(el: Element): HyperFramesElement | null {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (EXCLUDED_TAGS.has(tag)) return null;
|
||||
|
||||
const id = el.getAttribute("data-hf-id") ?? "";
|
||||
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
|
||||
|
||||
const inlineStyles = getElementStyles(el);
|
||||
|
||||
const classAttr = el.getAttribute("class") ?? "";
|
||||
const classNames = classAttr
|
||||
.split(/\s+/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name === "style" || attr.name === "class" || attr.name.startsWith("data-hf-")) {
|
||||
continue;
|
||||
}
|
||||
attributes[attr.name] = attr.value;
|
||||
}
|
||||
|
||||
const startAttr = el.getAttribute("data-start");
|
||||
const endAttr = el.getAttribute("data-end");
|
||||
const trackAttr = el.getAttribute("data-track-index");
|
||||
|
||||
const start = startAttr !== null ? parseFloat(startAttr) : null;
|
||||
const duration =
|
||||
start !== null && endAttr !== null ? Math.max(0, parseFloat(endAttr) - start) : null;
|
||||
const trackIndex = trackAttr !== null ? parseInt(trackAttr, 10) : null;
|
||||
|
||||
const children: HyperFramesElement[] = [];
|
||||
for (const child of Array.from(el.children)) {
|
||||
const built = buildElement(child);
|
||||
if (built) children.push(built);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
tag,
|
||||
children,
|
||||
inlineStyles,
|
||||
classNames,
|
||||
attributes,
|
||||
text: ownText(el),
|
||||
start,
|
||||
duration,
|
||||
trackIndex,
|
||||
animationIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractGsapScript(doc: Document): string | null {
|
||||
// GSAP script is the first <script> tag whose text references gsap
|
||||
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
||||
const text = script.textContent ?? "";
|
||||
if (text.includes("gsap") || text.includes("ScrollTrigger")) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStyles(doc: Document): string | null {
|
||||
const styleEl = doc.querySelector("style");
|
||||
return styleEl ? styleEl.textContent : null;
|
||||
}
|
||||
|
||||
// Root resolution delegates to the engine's findRoot so dimension extraction
|
||||
// and mutations agree on which element is the composition root.
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractDimensions(doc: Document): { width: number | null; height: number | null } {
|
||||
const stage = findRoot(doc);
|
||||
if (!stage) return { width: null, height: null };
|
||||
// data-width/data-height are the runtime's forced override — prefer them.
|
||||
const wAttr = stage.getAttribute("data-width");
|
||||
const hAttr = stage.getAttribute("data-height");
|
||||
const style = (stage as HTMLElement).getAttribute?.("style") ?? "";
|
||||
const wm = /width:\s*(\d+)px/.exec(style);
|
||||
const hm = /height:\s*(\d+)px/.exec(style);
|
||||
return {
|
||||
width: wAttr !== null ? parseInt(wAttr, 10) : wm ? parseInt(wm[1] ?? "", 10) : null,
|
||||
height: hAttr !== null ? parseInt(hAttr, 10) : hm ? parseInt(hm[1] ?? "", 10) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractDuration(doc: Document): number | null {
|
||||
const root = findRoot(doc) ?? doc.body;
|
||||
const dur = root?.getAttribute("data-duration");
|
||||
return dur ? parseFloat(dur) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the element tree from an already-parsed (hf-id-stamped) linkedom Document.
|
||||
* Walks the live DOM directly — no serialize/re-parse round trip. This is what
|
||||
* the session's query API uses against its mutable document.
|
||||
*/
|
||||
export function buildRoots(document: Document): HyperFramesElement[] {
|
||||
const body = document.body;
|
||||
const roots: HyperFramesElement[] = [];
|
||||
if (body) {
|
||||
for (const child of Array.from(body.children)) {
|
||||
const built = buildElement(child);
|
||||
if (built) roots.push(built);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an HTML string into the SDK document model.
|
||||
* Calls ensureHfIds first so every element has a stable data-hf-id.
|
||||
* Uses linkedom — node-safe (works in agents, CI, server-side).
|
||||
*/
|
||||
export function buildDocument(html: string): SdkDocument {
|
||||
const stamped = ensureHfIds(html);
|
||||
|
||||
const hasShell = /<!doctype|<html[\s>]/i.test(stamped);
|
||||
const wrapped = !hasShell;
|
||||
const { document } = wrapped
|
||||
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
|
||||
: parseHTML(stamped);
|
||||
|
||||
const dims = extractDimensions(document);
|
||||
|
||||
return {
|
||||
roots: buildRoots(document),
|
||||
gsapScript: extractGsapScript(document),
|
||||
styles: extractStyles(document),
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
compositionDuration: extractDuration(document),
|
||||
html: stamped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flat walk of the element tree — returns every element in document order */
|
||||
export function flatElements(roots: readonly HyperFramesElement[]): HyperFramesElement[] {
|
||||
const result: HyperFramesElement[] = [];
|
||||
function walk(el: HyperFramesElement) {
|
||||
result.push(el);
|
||||
for (const child of el.children) walk(child);
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
return result;
|
||||
}
|
||||
@@ -8,9 +8,10 @@
|
||||
* unknown paths are silently ignored, matching the JsonPatchOp contract.
|
||||
*/
|
||||
|
||||
import type { JsonPatchOp } from "../types.js";
|
||||
import type { JsonPatchOp, OverrideSet } from "../types.js";
|
||||
import type { ParsedDocument } from "./model.js";
|
||||
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
|
||||
import { keyToPath } from "./patches.js";
|
||||
|
||||
// ─── Path parser ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +57,25 @@ function parsePath(path: string): ParsedPath | null {
|
||||
|
||||
// ─── Patch application ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
|
||||
* Property keys with null mean "restore base value" — a no-op on a fresh base.
|
||||
* Bare element keys with null are removal markers — the element is removed.
|
||||
*/
|
||||
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
|
||||
const patches: JsonPatchOp[] = [];
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
const path = keyToPath(key);
|
||||
if (!path) continue;
|
||||
if (value === null) {
|
||||
if (!key.includes(".")) patches.push({ op: "remove", path });
|
||||
continue;
|
||||
}
|
||||
patches.push({ op: "replace", path, value });
|
||||
}
|
||||
applyPatchesToDocument(parsed, patches);
|
||||
}
|
||||
|
||||
export function applyPatchesToDocument(
|
||||
parsed: ParsedDocument,
|
||||
patches: readonly JsonPatchOp[],
|
||||
|
||||
@@ -53,9 +53,10 @@ const PHASE3B_OPS = new Set([
|
||||
"removeLabel",
|
||||
]);
|
||||
|
||||
// Re-exported from the package entry in the next stacked PR (#1325).
|
||||
// fallow-ignore-next-line unused-export
|
||||
export class UnsupportedOpError extends Error {
|
||||
// Stable error code — part of the public API contract (F7); hosts switch on
|
||||
// err.code rather than the message.
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
readonly code = "E_UNSUPPORTED_OP";
|
||||
constructor(opType: string) {
|
||||
super(
|
||||
|
||||
@@ -103,10 +103,40 @@ export function pathToKey(path: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
|
||||
* Used to replay a stored override-set onto a fresh base document (T3 init).
|
||||
*/
|
||||
export function keyToPath(key: string): string | null {
|
||||
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
|
||||
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
|
||||
|
||||
const text = /^([^.]+)\.text$/.exec(key);
|
||||
if (text?.[1]) return textPath(text[1]);
|
||||
|
||||
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
|
||||
if (attr?.[1] && attr[2]) return attrPath(attr[1], attr[2]);
|
||||
|
||||
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
|
||||
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");
|
||||
|
||||
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
|
||||
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
|
||||
|
||||
const variable = /^var\.(.+)$/.exec(key);
|
||||
if (variable?.[1]) return variablePath(variable[1]);
|
||||
|
||||
const meta = /^meta\.(width|height|duration)$/.exec(key);
|
||||
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
|
||||
|
||||
// Bare element id — removal marker key.
|
||||
if (!key.includes(".")) return elementPath(key);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Patch event builder ──────────────────────────────────────────────────────
|
||||
|
||||
// Consumed by session.ts dispatch/batch in the next stacked PR (#1325).
|
||||
// fallow-ignore-next-line unused-export
|
||||
export function buildPatchEvent(
|
||||
forward: readonly JsonPatchOp[],
|
||||
inverse: readonly JsonPatchOp[],
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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 (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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,3 +17,16 @@ export type {
|
||||
} from "./types.js";
|
||||
|
||||
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
|
||||
export { UnsupportedOpError } from "./engine/mutate.js";
|
||||
|
||||
export { buildDocument, buildRoots, flatElements } from "./document.js";
|
||||
|
||||
export { openComposition } from "./session.js";
|
||||
export type { OpenCompositionOptions } from "./session.js";
|
||||
|
||||
export { createHistory } from "./history.js";
|
||||
export type { HistoryModule, HistoryOptions, HistoryEntry } from "./history.js";
|
||||
|
||||
export { createPersistQueue } from "./persist-queue.js";
|
||||
export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Optional persist queue module (F5 layering).
|
||||
*
|
||||
* Subscribes to 'change' events and schedules async writes via a PersistAdapter.
|
||||
* One in-flight write at a time; latest state always wins (last-write-wins coalescing).
|
||||
*
|
||||
* Wired automatically by openComposition() in standalone (T1/T2) mode.
|
||||
* T3 (embedded) hosts own persistence — do not use this module.
|
||||
*/
|
||||
|
||||
import type { Composition } from "./types.js";
|
||||
import type { PersistAdapter } from "./adapters/types.js";
|
||||
|
||||
export interface PersistQueueModule {
|
||||
/** Force an immediate write (e.g. before app close). */
|
||||
flush(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface PersistQueueOptions {
|
||||
/** Adapter path to write to. Default: "composition.html" */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export function createPersistQueue(
|
||||
session: Composition,
|
||||
adapter: PersistAdapter,
|
||||
opts: PersistQueueOptions = {},
|
||||
): PersistQueueModule {
|
||||
const path = opts.path ?? "composition.html";
|
||||
let pendingWrite: ReturnType<typeof setTimeout> | null = null;
|
||||
// Promise-chain mutex: each write chains onto the prior, preventing concurrent writes.
|
||||
let writeChain: Promise<void> = Promise.resolve();
|
||||
let disposed = false;
|
||||
|
||||
function scheduleWrite(): void {
|
||||
if (pendingWrite !== null) clearTimeout(pendingWrite);
|
||||
pendingWrite = setTimeout(() => {
|
||||
pendingWrite = null;
|
||||
void doWrite();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function doWrite(): Promise<void> {
|
||||
if (disposed) return Promise.resolve();
|
||||
const content = session.serialize();
|
||||
writeChain = writeChain.then(async () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
await adapter.write(path, content);
|
||||
} catch {
|
||||
// error already surfaced via persist:error on the adapter
|
||||
}
|
||||
});
|
||||
return writeChain;
|
||||
}
|
||||
|
||||
const unsubscribe = session.on("change", () => {
|
||||
scheduleWrite();
|
||||
});
|
||||
|
||||
return {
|
||||
async flush(): Promise<void> {
|
||||
if (pendingWrite !== null) {
|
||||
clearTimeout(pendingWrite);
|
||||
pendingWrite = null;
|
||||
}
|
||||
await doWrite();
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
if (pendingWrite !== null) {
|
||||
clearTimeout(pendingWrite);
|
||||
pendingWrite = null;
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Session-level behavior: history coalescing invariants and T3 override replay.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
|
||||
const BASE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" data-start="0" data-end="3" style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
<p data-hf-id="hf-sub" style="opacity: 0.5">subtitle</p>
|
||||
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// ─── History coalescing ───────────────────────────────────────────────────────
|
||||
|
||||
describe("history coalescing", () => {
|
||||
it("rapid edits to the SAME property coalesce into one undo entry", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-title", { color: "#222" });
|
||||
comp.setStyle("hf-title", { color: "#333" });
|
||||
|
||||
comp.undo();
|
||||
const el = comp.getElement("hf-title");
|
||||
expect(el?.inlineStyles["color"]).toBe("#fff"); // back to original in ONE step
|
||||
});
|
||||
|
||||
it("rapid edits to DIFFERENT elements do NOT coalesce — undo reverts only the last edit", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-sub", { opacity: "1" });
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-sub")?.inlineStyles["opacity"]).toBe("0.5"); // last edit reverted
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111"); // first edit intact
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
|
||||
it("rapid edits to different properties of the same element do not coalesce", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#111" });
|
||||
comp.setStyle("hf-title", { fontSize: "96px" });
|
||||
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["fontSize"]).toBe("64px");
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#111");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T3 override replay ───────────────────────────────────────────────────────
|
||||
|
||||
describe("override-set replay on open", () => {
|
||||
it("applies style, text, and attribute overrides to the base document", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: {
|
||||
"hf-title.style.color": "#e63946",
|
||||
"hf-title.text": "Edited headline",
|
||||
"hf-logo.attr.src": "/new-logo.png",
|
||||
},
|
||||
});
|
||||
|
||||
const title = comp.getElement("hf-title");
|
||||
expect(title?.inlineStyles["color"]).toBe("#e63946");
|
||||
expect(title?.text).toBe("Edited headline");
|
||||
expect(comp.getElement("hf-logo")?.attributes["src"]).toBe("/new-logo.png");
|
||||
|
||||
const html = comp.serialize();
|
||||
expect(html).toContain("Edited headline");
|
||||
expect(html).toContain("/new-logo.png");
|
||||
expect(html).toContain("#e63946");
|
||||
});
|
||||
|
||||
it("applies timing overrides (computed absolute end)", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.timing.end": 4.5 },
|
||||
});
|
||||
expect(comp.serialize()).toContain('data-end="4.5"');
|
||||
});
|
||||
|
||||
it("removes elements marked with the null removal marker", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-sub": null },
|
||||
});
|
||||
expect(comp.getElement("hf-sub")).toBeNull();
|
||||
expect(comp.serialize()).not.toContain("subtitle");
|
||||
});
|
||||
|
||||
it("treats property-level null as restore-base (no-op on fresh base)", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: { "hf-title.style.color": null },
|
||||
});
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
|
||||
it("getOverrides returns the set the session was opened with", async () => {
|
||||
const overrides = { "hf-title.style.color": "#e63946" };
|
||||
const comp = await openComposition(BASE_HTML, { overrides });
|
||||
expect(comp.getOverrides()).toEqual(overrides);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batch() transactional rollback ───────────────────────────────────────────
|
||||
|
||||
describe("batch rollback on throw", () => {
|
||||
it("reverts DOM mutations and override-set when the callback throws", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const htmlBefore = comp.serialize();
|
||||
|
||||
expect(() =>
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
comp.setText("hf-sub", "changed");
|
||||
throw new Error("user cancelled");
|
||||
}),
|
||||
).toThrowError("user cancelled");
|
||||
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
expect(comp.getElement("hf-sub")?.text).toBe("subtitle");
|
||||
expect(comp.serialize()).toBe(htmlBefore);
|
||||
expect(comp.getOverrides()).toEqual({});
|
||||
});
|
||||
|
||||
it("a throwing batch leaves no history entry — undo is a no-op", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
try {
|
||||
comp.batch(() => {
|
||||
comp.setStyle("hf-title", { color: "#e63946" });
|
||||
throw new Error("boom");
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
comp.undo();
|
||||
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Phase 3a — real editing session.
|
||||
*
|
||||
* CompositionImpl: live linkedom document, real dispatch, RFC 6902 patch emission,
|
||||
* override-set accumulation, batch, can(), serialize(), applyPatches().
|
||||
*
|
||||
* openComposition() wires history + persist queue for standalone (T1/T2) mode.
|
||||
* T3 (embedded) callers supply overrides; SDK emits patches only — host owns state.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Composition,
|
||||
EditOp,
|
||||
ElementSnapshot,
|
||||
FindQuery,
|
||||
GsapTweenSpec,
|
||||
HfId,
|
||||
JsonPatchOp,
|
||||
OverrideSet,
|
||||
PatchEvent,
|
||||
PersistErrorEvent,
|
||||
SelectionProxy,
|
||||
ElementHandle,
|
||||
} from "./types.js";
|
||||
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
import { buildRoots, flatElements } from "./document.js";
|
||||
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
|
||||
import { parseMutable } from "./engine/model.js";
|
||||
import type { ParsedDocument } from "./engine/model.js";
|
||||
import { applyOp, validateOp } from "./engine/mutate.js";
|
||||
import { serializeDocument } from "./engine/serialize.js";
|
||||
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
|
||||
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
|
||||
import { createHistory } from "./history.js";
|
||||
import type { HistoryModule } from "./history.js";
|
||||
import { createPersistQueue } from "./persist-queue.js";
|
||||
import type { PersistQueueModule } from "./persist-queue.js";
|
||||
|
||||
export interface OpenCompositionOptions {
|
||||
persist?: PersistAdapter;
|
||||
preview?: PreviewAdapter;
|
||||
/** T3 embedded mode: override-set applied on top of the base template. */
|
||||
overrides?: OverrideSet;
|
||||
/** Origins whose mutations enter the undo stack. Default: all non-applyPatches. */
|
||||
trackedOrigins?: unknown[];
|
||||
/** Auto-coalesce window for history entries (ms). Default: 300. */
|
||||
coalesceMs?: number;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
class CompositionImpl implements Composition {
|
||||
private readonly parsed: ParsedDocument;
|
||||
private readonly persist: PersistAdapter | undefined;
|
||||
readonly preview: PreviewAdapter | undefined;
|
||||
|
||||
/** Accumulated override-set — T3 embedded mode fold contract. */
|
||||
private overrides: OverrideSet;
|
||||
|
||||
/** Lazily-built element snapshot, invalidated on every mutation. */
|
||||
private elementsCache: ElementSnapshot[] | null = null;
|
||||
|
||||
private currentSelection: string[] = [];
|
||||
|
||||
private changeHandlers: Array<() => void> = [];
|
||||
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
||||
private patchHandlers: Array<(e: PatchEvent) => void> = [];
|
||||
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
|
||||
|
||||
/** Attached by openComposition() for standalone mode. */
|
||||
private historyModule: HistoryModule | null = null;
|
||||
private persistQueueModule: PersistQueueModule | null = null;
|
||||
|
||||
/** Batching state: accumulates patches from multiple dispatches. */
|
||||
private batchDepth = 0;
|
||||
private batchForward: JsonPatchOp[] = [];
|
||||
private batchInverse: JsonPatchOp[] = [];
|
||||
private batchOpTypes: string[] = [];
|
||||
private batchOrigin: unknown = ORIGIN_LOCAL;
|
||||
/** Override-set state at outermost batch entry — restored if the batch throws. */
|
||||
private batchOverridesSnapshot: OverrideSet = {};
|
||||
|
||||
constructor(parsed: ParsedDocument, opts: OpenCompositionOptions) {
|
||||
this.parsed = parsed;
|
||||
this.persist = opts.persist;
|
||||
this.preview = opts.preview;
|
||||
this.overrides = { ...(opts.overrides ?? {}) };
|
||||
}
|
||||
|
||||
attachHistory(module: HistoryModule): void {
|
||||
this.historyModule = module;
|
||||
}
|
||||
|
||||
attachPersistQueue(module: PersistQueueModule): void {
|
||||
this.persistQueueModule = module;
|
||||
}
|
||||
|
||||
// ── Typed methods (F10 layer 1) ─────────────────────────────────────────────
|
||||
|
||||
setStyle(id: HfId, styles: Record<string, string | null>): void {
|
||||
this.dispatch({ type: "setStyle", target: id, styles });
|
||||
}
|
||||
|
||||
setText(id: HfId, value: string): void {
|
||||
this.dispatch({ type: "setText", target: id, value });
|
||||
}
|
||||
|
||||
setAttribute(id: HfId, name: string, value: string | null): void {
|
||||
this.dispatch({ type: "setAttribute", target: id, name, value });
|
||||
}
|
||||
|
||||
setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void {
|
||||
this.dispatch({ type: "setTiming", target: id, ...timing });
|
||||
}
|
||||
|
||||
removeElement(id: HfId): void {
|
||||
this.dispatch({ type: "removeElement", target: id });
|
||||
}
|
||||
|
||||
setVariableValue(id: string, value: string | number | boolean): void {
|
||||
this.dispatch({ type: "setVariableValue", id, value });
|
||||
}
|
||||
|
||||
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
|
||||
// Phase 3b: AST splice. For now, mint id and pass through.
|
||||
const tweenId = `tw-${crypto.randomUUID().slice(0, 8)}`;
|
||||
this.dispatch({ type: "addGsapTween", target, id: tweenId, tween });
|
||||
return tweenId;
|
||||
}
|
||||
|
||||
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void {
|
||||
this.dispatch({ type: "setGsapTween", animationId, properties });
|
||||
}
|
||||
|
||||
removeGsapTween(animationId: string): void {
|
||||
this.dispatch({ type: "removeGsapTween", animationId });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.historyModule?.undo();
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.historyModule?.redo();
|
||||
}
|
||||
|
||||
// ── Query API (F1) ───────────────────────────────────────────────────────────
|
||||
|
||||
getElements(): ElementSnapshot[] {
|
||||
// Walk the live linkedom DOM directly — no serialize/re-parse round trip.
|
||||
this.elementsCache ??= flatElements(buildRoots(this.parsed.document));
|
||||
return [...this.elementsCache];
|
||||
}
|
||||
|
||||
getElement(id: HfId): ElementSnapshot | null {
|
||||
return this.getElements().find((el) => el.id === id) ?? null;
|
||||
}
|
||||
|
||||
find(query: FindQuery): string[] {
|
||||
return (
|
||||
this.getElements()
|
||||
// fallow-ignore-next-line complexity
|
||||
.filter((el) => {
|
||||
if (query.tag && el.tag !== query.tag) return false;
|
||||
if (query.text && !el.text?.includes(query.text)) return false;
|
||||
if (query.name && el.attributes["data-name"] !== query.name) return false;
|
||||
if (query.track !== undefined && el.trackIndex !== query.track) return false;
|
||||
return true;
|
||||
})
|
||||
.map((el) => el.id)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Selection API ────────────────────────────────────────────────────────────
|
||||
|
||||
selection(): SelectionProxy {
|
||||
const ids = [...this.currentSelection];
|
||||
return {
|
||||
ids,
|
||||
setStyle: (styles) => this.dispatch({ type: "setStyle", target: ids, styles }),
|
||||
setText: (value) => this.dispatch({ type: "setText", target: ids, value }),
|
||||
setAttribute: (name, value) =>
|
||||
this.dispatch({ type: "setAttribute", target: ids, name, value }),
|
||||
setTiming: (timing) => this.dispatch({ type: "setTiming", target: ids, ...timing }),
|
||||
removeElement: () => this.dispatch({ type: "removeElement", target: ids }),
|
||||
};
|
||||
}
|
||||
|
||||
element(id: HfId): ElementHandle {
|
||||
return {
|
||||
id,
|
||||
setStyle: (styles) => this.dispatch({ type: "setStyle", target: id, styles }),
|
||||
setText: (value) => this.dispatch({ type: "setText", target: id, value }),
|
||||
setAttribute: (name, value) =>
|
||||
this.dispatch({ type: "setAttribute", target: id, name, value }),
|
||||
setTiming: (timing) => this.dispatch({ type: "setTiming", target: id, ...timing }),
|
||||
removeElement: () => this.dispatch({ type: "removeElement", target: id }),
|
||||
};
|
||||
}
|
||||
|
||||
getSelection(): string[] {
|
||||
return [...this.currentSelection];
|
||||
}
|
||||
|
||||
// ── Dispatch / batch ─────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
|
||||
const origin = opts?.origin ?? ORIGIN_LOCAL;
|
||||
const { forward, inverse } = applyOp(this.parsed, op);
|
||||
|
||||
if (forward.length === 0 && inverse.length === 0) {
|
||||
// No-op (e.g. Phase 3b op with no implementation yet): still fire change
|
||||
if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h());
|
||||
return;
|
||||
}
|
||||
|
||||
this.elementsCache = null;
|
||||
|
||||
// Update override-set from forward patches
|
||||
for (const p of forward) {
|
||||
const key = pathToKey(p.path);
|
||||
if (key !== null) {
|
||||
this.overrides[key] =
|
||||
p.op === "remove" ? null : (p.value as string | number | boolean | null);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.batchDepth > 0) {
|
||||
this.batchForward.push(...forward);
|
||||
this.batchInverse.push(...inverse);
|
||||
if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type);
|
||||
} else {
|
||||
const event = buildPatchEvent(forward, inverse, origin, [op.type]);
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce multiple dispatches into one undo entry / one patch event.
|
||||
*
|
||||
* Transactional: if the callback throws, all DOM mutations applied so far
|
||||
* are reverted (accumulated inverse patches replayed in reverse) and the
|
||||
* override-set is restored — the model is exactly as it was at batch entry.
|
||||
*
|
||||
* Note: a batch that produces no effective mutations still fires 'change'
|
||||
* handlers (parity with no-op dispatch) — subscribers must not assume
|
||||
* silence when wrapping speculative operations.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
batch(fn: () => void, opts?: { origin?: unknown }): void {
|
||||
const origin = opts?.origin ?? ORIGIN_LOCAL;
|
||||
this.batchDepth++;
|
||||
if (this.batchDepth === 1) {
|
||||
this.batchOrigin = origin; // only set on outermost entry
|
||||
this.batchOverridesSnapshot = { ...this.overrides };
|
||||
}
|
||||
let threw = false;
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
throw err;
|
||||
} finally {
|
||||
this.batchDepth--;
|
||||
if (this.batchDepth === 0) {
|
||||
if (!threw && this.batchForward.length > 0) {
|
||||
const event = buildPatchEvent(
|
||||
this.batchForward,
|
||||
[...this.batchInverse].reverse(),
|
||||
this.batchOrigin,
|
||||
this.batchOpTypes,
|
||||
);
|
||||
this.resetBatchState();
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
} else {
|
||||
if (threw && this.batchInverse.length > 0) {
|
||||
// Roll back: the dispatches inside the batch already mutated the
|
||||
// DOM. Without this, a throwing batch would leave the model in a
|
||||
// partial state with no patch trail to undo it.
|
||||
applyPatchesToDocument(this.parsed, [...this.batchInverse].reverse());
|
||||
this.overrides = { ...this.batchOverridesSnapshot };
|
||||
this.elementsCache = null;
|
||||
}
|
||||
this.resetBatchState();
|
||||
// Empty no-op batch: fire changeHandlers (parity with dispatch)
|
||||
if (!threw) this.changeHandlers.forEach((h) => h());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetBatchState(): void {
|
||||
this.batchForward = [];
|
||||
this.batchInverse = [];
|
||||
this.batchOpTypes = [];
|
||||
this.batchOrigin = ORIGIN_LOCAL;
|
||||
this.batchOverridesSnapshot = {};
|
||||
}
|
||||
|
||||
can(op: EditOp): boolean {
|
||||
return validateOp(this.parsed, op);
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
on(event: "change", handler: () => void): () => void;
|
||||
on(event: "selectionchange", handler: (ids: string[]) => void): () => void;
|
||||
on(event: "patch", handler: (event: PatchEvent) => void): () => void;
|
||||
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
|
||||
// fallow-ignore-next-line complexity
|
||||
on(event: string, handler: unknown): () => void {
|
||||
const h = handler as (...args: unknown[]) => void;
|
||||
if (event === "change") {
|
||||
this.changeHandlers.push(h as () => void);
|
||||
return () => {
|
||||
this.changeHandlers = this.changeHandlers.filter((x) => x !== h);
|
||||
};
|
||||
}
|
||||
if (event === "selectionchange") {
|
||||
this.selectionHandlers.push(h as (ids: string[]) => void);
|
||||
return () => {
|
||||
this.selectionHandlers = this.selectionHandlers.filter((x) => x !== h);
|
||||
};
|
||||
}
|
||||
if (event === "patch") {
|
||||
this.patchHandlers.push(h as (e: PatchEvent) => void);
|
||||
return () => {
|
||||
this.patchHandlers = this.patchHandlers.filter((x) => x !== h);
|
||||
};
|
||||
}
|
||||
if (event === "persist:error") {
|
||||
const typedH = h as (e: PersistErrorEvent) => void;
|
||||
this.errorHandlers.push(typedH);
|
||||
const offPersist = this.persist?.on("persist:error", typedH);
|
||||
return () => {
|
||||
this.errorHandlers = this.errorHandlers.filter((x) => x !== typedH);
|
||||
offPersist?.();
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// ── Serialization ────────────────────────────────────────────────────────────
|
||||
|
||||
serialize(): string {
|
||||
return serializeDocument(this.parsed);
|
||||
}
|
||||
|
||||
// ── T3 embedded-mode extras ──────────────────────────────────────────────────
|
||||
|
||||
getOverrides(): OverrideSet {
|
||||
return { ...this.overrides };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
applyPatches(patches: readonly JsonPatchOp[], opts?: { origin?: unknown }): void {
|
||||
const origin = opts?.origin ?? ORIGIN_APPLY_PATCHES;
|
||||
|
||||
// The emitted PatchEvent carries an EMPTY inversePatches array — hosts
|
||||
// maintaining an external inverse log must compute inverses from their own
|
||||
// state; applyPatches events never enter history (origin-guarded).
|
||||
// Emit a patch event so subscribers stay in sync.
|
||||
applyPatchesToDocument(this.parsed, patches);
|
||||
this.elementsCache = null;
|
||||
|
||||
// Update override-set
|
||||
for (const p of patches) {
|
||||
const key = pathToKey(p.path);
|
||||
if (key !== null) {
|
||||
this.overrides[key] =
|
||||
p.op === "remove" ? null : (p.value as string | number | boolean | null);
|
||||
}
|
||||
}
|
||||
|
||||
const opTypes = ["applyPatches"];
|
||||
const event = buildPatchEvent(patches, [], origin, opTypes);
|
||||
this.patchHandlers.forEach((h) => h(event));
|
||||
this.changeHandlers.forEach((h) => h());
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
dispose(): void {
|
||||
this.persistQueueModule?.dispose();
|
||||
this.historyModule?.dispose();
|
||||
this.changeHandlers = [];
|
||||
this.selectionHandlers = [];
|
||||
this.patchHandlers = [];
|
||||
this.errorHandlers = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public factory ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a composition for editing.
|
||||
*
|
||||
* Standalone (T1/T2): supply persist adapter — SDK owns history + auto-save.
|
||||
* Embedded (T3): supply overrides — SDK emits patches; host owns history + persistence.
|
||||
* Headless (agents): omit both — SDK is a stateless transform + serializer.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function openComposition(
|
||||
html: string,
|
||||
opts?: OpenCompositionOptions,
|
||||
): Promise<Composition> {
|
||||
// Single parse: parseMutable stamps hf-ids + builds the live linkedom DOM;
|
||||
// the query API derives element snapshots from it lazily.
|
||||
const parsed = parseMutable(html);
|
||||
|
||||
// T3 embedded: replay the stored override-set onto the base in one pass,
|
||||
// so the session exposes the user's exact edited state — not the template.
|
||||
if (opts?.overrides) applyOverrideSet(parsed, opts.overrides);
|
||||
|
||||
const session = new CompositionImpl(parsed, opts ?? {});
|
||||
|
||||
const isEmbedded = opts?.overrides !== undefined;
|
||||
|
||||
if (!isEmbedded) {
|
||||
const history = createHistory(session, {
|
||||
coalesceMs: opts?.coalesceMs ?? 300,
|
||||
trackedOrigins: opts?.trackedOrigins,
|
||||
});
|
||||
session.attachHistory(history);
|
||||
|
||||
if (opts?.persist) {
|
||||
const pq = createPersistQueue(session, opts.persist);
|
||||
session.attachPersistQueue(pq);
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
Reference in New Issue
Block a user