mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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";
|
import type { PersistErrorEvent } from "../types.js";
|
||||||
|
|
||||||
// ─── PersistAdapter ───────────────────────────────────────────────────────────
|
// ─── 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.
|
* 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 type { ParsedDocument } from "./model.js";
|
||||||
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
|
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
|
||||||
|
import { keyToPath } from "./patches.js";
|
||||||
|
|
||||||
// ─── Path parser ────────────────────────────────────────────────────────────
|
// ─── Path parser ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -56,6 +57,25 @@ function parsePath(path: string): ParsedPath | null {
|
|||||||
|
|
||||||
// ─── Patch application ───────────────────────────────────────────────────────
|
// ─── 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(
|
export function applyPatchesToDocument(
|
||||||
parsed: ParsedDocument,
|
parsed: ParsedDocument,
|
||||||
patches: readonly JsonPatchOp[],
|
patches: readonly JsonPatchOp[],
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ const PHASE3B_OPS = new Set([
|
|||||||
"removeLabel",
|
"removeLabel",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Re-exported from the package entry in the next stacked PR (#1325).
|
|
||||||
// fallow-ignore-next-line unused-export
|
|
||||||
export class UnsupportedOpError extends Error {
|
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";
|
readonly code = "E_UNSUPPORTED_OP";
|
||||||
constructor(opType: string) {
|
constructor(opType: string) {
|
||||||
super(
|
super(
|
||||||
|
|||||||
@@ -103,10 +103,40 @@ export function pathToKey(path: string): string | null {
|
|||||||
return 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 ──────────────────────────────────────────────────────
|
// ─── Patch event builder ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Consumed by session.ts dispatch/batch in the next stacked PR (#1325).
|
|
||||||
// fallow-ignore-next-line unused-export
|
|
||||||
export function buildPatchEvent(
|
export function buildPatchEvent(
|
||||||
forward: readonly JsonPatchOp[],
|
forward: readonly JsonPatchOp[],
|
||||||
inverse: 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";
|
} from "./types.js";
|
||||||
|
|
||||||
export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } 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