/** * 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 { CanResult, Composition, EditOp, ElementSnapshot, ElementTimingSnapshot, FindQuery, FontValue, GsapTweenSpec, ElasticHold, KeyframeSpec, HfId, ImageValue, JsonPatchOp, OverrideSet, PatchEvent, PersistErrorEvent, SelectionProxy, ElementHandle, VariableUsageReport, } from "./types.js"; import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js"; import { buildRoots, flatElements, parsedAnimationIds } 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, type MutationResult } from "./engine/mutate.js"; import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; import { readDeclaredDefaults, validateVariables, scanVariableUsage, } from "@hyperframes/core/variables"; import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables"; import { readVariableDeclarations } from "./engine/variableModel.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; /** Adapter path the persist queue writes to. Default: "composition.html". Immutable for the session lifetime. */ persistPath?: string; 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; /** * Pass `false` to skip attaching the history module (undo/redo). * Default: history is attached in standalone (non-embedded) mode. * Use when the host owns the undo stack and SDK undo is dead weight. */ history?: false; } // ─── Implementation ─────────────────────────────────────────────────────────── /** Escape a string for literal use inside a RegExp. */ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } 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; /** Lazily-built root snapshot (getRootElements), invalidated alongside elementsCache. */ private rootsCache: 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> = []; private previewSelectionUnsubscribe: (() => void) | null = null; /** 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 ?? {}) }; this.previewSelectionUnsubscribe = this.preview?.on("selection", (ids) => this.updateSelection(ids)) ?? null; } attachHistory(module: HistoryModule): void { this.historyModule = module; } attachPersistQueue(module: PersistQueueModule): void { this.persistQueueModule = module; } _fireError(e: PersistErrorEvent): void { this.errorHandlers.forEach((h) => h(e)); } // ── Typed methods (F10 layer 1) ───────────────────────────────────────────── setStyle(id: HfId, styles: Record): 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 }); } addElement(parent: HfId | null, index: number, html: string): HfId { const result = this._dispatch({ type: "addElement", parent, index, html }, ORIGIN_LOCAL); return result.meta?.newId ?? ""; } setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void { this.dispatch({ type: "setVariableValue", id, value }); } // ── #2098 CRUD conveniences — thin aliases over the canonical surface below. // They delegate so the per-file declaration-element scope (template/fragment // sub-comps included) is resolved in exactly one place. getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined { return this.getVariableValues()[id] as | string | number | boolean | FontValue | ImageValue | undefined; } listVariables(): CompositionVariable[] { return this.getVariableDeclarations(); } removeVariable(id: string): void { this.dispatch({ type: "removeVariable", id }); } // ── Canonical declaration edit ops (this stack) ── // declareVariable unified onto the {declaration} op payload (#2098 used // {decl}); the method signature is identical, so #2098 callers are unaffected. declareVariable(declaration: CompositionVariable): void { this.dispatch({ type: "declareVariable", declaration }); } updateVariableDeclaration(id: string, declaration: CompositionVariable): void { this.dispatch({ type: "updateVariableDeclaration", id, declaration }); } removeVariableDeclaration(id: string): void { this.dispatch({ type: "removeVariableDeclaration", id }); } getVariableDeclarations(): CompositionVariable[] { return readVariableDeclarations(declarationElement(this.parsed.document, this.parsed.wrapped)); } getVariableValues(overrides?: Record): Record { // THIS composition's own declared defaults (loose extraction: any entry with // a string id + a `default` key, even ones the strict declaration parser // drops) spread under the overrides. Scope note: this reads the composition's // single declaration element only — NOT a union of every `[data-composition- // variables]` in the document. The runtime's getVariables() // (core/runtime/getVariables.ts) additionally walks inlined sub-composition // declarers because it operates on the bundled multi-composition document; // the SDK models one composition file, so per-file scope is intended. const defaults = readDeclaredDefaults( declarationElement(this.parsed.document, this.parsed.wrapped), ); return { ...defaults, ...(overrides ?? {}) }; } validateVariableValues(values: Record): VariableValidationIssue[] { return validateVariables(values, this.getVariableDeclarations()); } /** * Script scans are content-keyed (same rationale as _gsapLabelCache): the * panel recomputes usage on every preview reload, and unchanged script text * is the common case — never pay a second acorn parse for identical input. */ private _variableUsageScanCache = new Map>(); // Scan/merge dispatcher — same complexity class as the suppressed // variableUsage.ts classifiers it drives. // fallow-ignore-next-line complexity getVariableUsage(): VariableUsageReport { const usedIds: string[] = []; const seen = new Set(); let scanIncomplete = false; const freshCache = new Map>(); // Inline scripts only — external src scripts aren't part of the document model. for (const script of Array.from(this.parsed.document.querySelectorAll("script"))) { if (script.getAttribute("src")) continue; const text = script.textContent ?? ""; // Direct global reads (window.__hfVariables / __hfVariablesByComp) are // invisible to the getVariables() scanner — the report must degrade to // a lower bound instead of confidently claiming declarations unused. if (text.includes("__hfVariables")) scanIncomplete = true; if (!text.includes("getVariables")) continue; // cheap pre-filter before an acorn parse const scan = this._variableUsageScanCache.get(text) ?? scanVariableUsage(text); freshCache.set(text, scan); scanIncomplete = scanIncomplete || scan.scanIncomplete; for (const id of scan.usedIds) { if (!seen.has(id)) { seen.add(id); usedIds.push(id); } } } // Declarative bindings (data-var-src / data-var-text) are direct reads. for (const el of Array.from( this.parsed.document.querySelectorAll("[data-var-src], [data-var-text]"), )) { for (const attr of ["data-var-src", "data-var-text"]) { const id = el.getAttribute(attr)?.trim(); if (id && !seen.has(id)) { seen.add(id); usedIds.push(id); } } } this._variableUsageScanCache = freshCache; const declaredIds = this.getVariableDeclarations().map((d) => d.id); // The CSS compat channel counts as usage: a variable consumed only via // var(--id) in stylesheets or inline styles must not be badged unused // (removing it also removes the --{id} root prop and breaks the binding). const cssText = this._collectCssText(); // Match var(--id) only at a custom-property-name boundary: the id must be // followed by whitespace, a comma (fallback), or the closing paren — so id // "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are // regex-escaped because a value read from disk may predate can()'s // /^[A-Za-z_][A-Za-z0-9_-]*$/ enforcement and carry metacharacters. const cssUsed = (id: string) => new RegExp(`var\\(\\s*--${escapeRegExp(id)}[\\s,)]`).test(cssText); const declaredSet = new Set(declaredIds); return { usedIds, unusedDeclarations: declaredIds.filter((id) => !seen.has(id) && !cssUsed(id)), undeclaredReads: usedIds.filter((id) => !declaredSet.has(id)), scanIncomplete, }; } /** All stylesheet + inline-style text, for var(--id) consumption checks. */ private _collectCssText(): string { const cssParts: string[] = []; for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) { cssParts.push(styleEl.textContent ?? ""); } for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) { cssParts.push(el.getAttribute("style") ?? ""); } return cssParts.join("\n"); } setPreviewVariables(values: Record | null): boolean { if (!this.preview?.setPreviewVariables) return false; this.preview.setPreviewVariables(values); return true; } // ── WS-C: timing accessors + typed setHold ─────────────────────────────────── /** * Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts. * extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated * getElementTimings reads when the scripts are unchanged. */ private _gsapLabelCache: { scripts: string[]; labels: ReturnType; } | null = null; // fallow-ignore-next-line complexity getElementTimings(): Record { const scripts = getGsapScripts(this.parsed.document); // Extract all addLabel("name", position) calls from every GSAP script. let allLabels: ReturnType; const cachedScripts = this._gsapLabelCache?.scripts; const cacheMatches = cachedScripts?.length === scripts.length && cachedScripts.every((script, index) => script === scripts[index]); if (cacheMatches) { allLabels = this._gsapLabelCache?.labels ?? []; } else { allLabels = scripts.flatMap((script) => extractGsapLabels(script)); this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null; } // Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" — // parseStartExpression's grammar) into an absolute second, recursively against the // referenced element's own resolved start + duration. A plain numeric data-start keeps // the old parseFloat path unchanged — this only touches the case that used to silently // resolve to 0 (parseFloat("intro + 2") is NaN). Node-safe static counterpart of the // runtime's own resolver (runtime/startResolver.ts): no live GSAP timeline to fall back // on, so an unauthored sub-composition duration still resolves to 0, same as before. // // refId is always a BARE id (the reference grammar has no scope syntax), resolved via // resolveScoped's bare-id rule: prefer the canonical top-level match, else document // order. An element inside a sub-composition referencing a bare id that also exists at // the top level resolves to the TOP-LEVEL one, not a same-scope sibling — this matches // the runtime's own resolver (also a global, not scope-aware, lookup), so the two stay // consistent, but it means a bare-id collision across scopes is a real footgun for // authored content. const startCache = new Map(); const visiting = new Set(); // Split out of resolveStart so its own branching stays low — this is the ONE // path that recurses + calls resolveDuration, kept here so that's visible at a // glance rather than buried inside resolveStart's try block. const resolveReferenceStart = (refId: string, offset: number): number => { const target = resolveScoped(this.parsed.document, refId); if (!target) return 0; return Math.max(0, resolveStart(target) + (resolveDuration(target) ?? 0) + offset); }; const resolveStart = (el: Element): number => { const cached = startCache.get(el); if (cached !== undefined) return cached; if (visiting.has(el)) return 0; // reference cycle — fail safe, don't loop visiting.add(el); let resolved: number; try { const startStr = el.getAttribute("data-start"); const expr = parseStartExpression(startStr); if (expr?.kind === "reference") { resolved = resolveReferenceStart(expr.refId, expr.offset); } else if (expr?.kind === "absolute") { resolved = expr.value; } else { resolved = startStr !== null ? parseFloat(startStr) : 0; } } finally { visiting.delete(el); } const finite = Number.isFinite(resolved) ? resolved : 0; startCache.set(el, finite); return finite; }; // Same preference as handleSetTiming: prefer data-duration, fall back to end - start. const resolveDuration = (el: Element): number | null => { const durationStr = el.getAttribute("data-duration"); const durationAttr = durationStr !== null ? parseFloat(durationStr) : null; if (durationAttr !== null && Number.isFinite(durationAttr)) return durationAttr; const endStr = el.getAttribute("data-end"); const endAttr = endStr !== null ? parseFloat(endStr) : null; if (endAttr !== null && Number.isFinite(endAttr)) return endAttr - resolveStart(el); return null; }; const result: Record = {}; const elements = this.getElements(); for (const el of elements) { const domEl = resolveScoped(this.parsed.document, el.scopedId); if (!domEl) continue; const enterAt = resolveStart(domEl); const duration = resolveDuration(domEl); if (duration === null) continue; // no timing info — skip non-timed elements const exitAt = enterAt + duration; // Labels whose position falls within [enterAt, exitAt] (end-inclusive: a // label exactly at exitAt is treated as within the element's window). const labels = allLabels .filter(({ position }) => position >= enterAt && position <= exitAt) .map(({ name }) => name); result[el.scopedId] = { enterAt, exitAt, labels }; } return result; } setElementTiming( map: Record, ): void { const entries = Object.entries(map); if (entries.length === 0) return; this.batch(() => { for (const [id, timing] of entries) { this.dispatch({ type: "setTiming", target: id, ...timing }); } }); } setHold(id: HfId, hold: ElasticHold): void { this.dispatch({ type: "setHold", target: id, hold }); } addGsapTween(target: HfId, tween: GsapTweenSpec): string { const result = this._dispatch({ type: "addGsapTween", target, tween }, ORIGIN_LOCAL); return result.meta?.animationId ?? ""; } setGsapTween(animationId: string, properties: Partial): void { this.dispatch({ type: "setGsapTween", animationId, properties }); } removeGsapTween(animationId: string): void { this.dispatch({ type: "removeGsapTween", animationId }); } addWithKeyframes( targetSelector: string, position: number, duration: number, keyframes: KeyframeSpec[], ease?: string, ): string { const result = this._dispatch( { type: "addWithKeyframes", targetSelector, position, duration, keyframes, ease }, ORIGIN_LOCAL, ); return result.meta?.animationId ?? ""; } replaceWithKeyframes( animationId: string, targetSelector: string, position: number, duration: number, keyframes: KeyframeSpec[], ease?: string, ): string { const result = this._dispatch( { type: "replaceWithKeyframes", animationId, targetSelector, position, duration, keyframes, ease, }, ORIGIN_LOCAL, ); // Position-derived IDs renumber after the remove — this is the NEW id, which // may differ from the input animationId. return result.meta?.animationId ?? ""; } undo(): void { this.historyModule?.undo(); } redo(): void { this.historyModule?.redo(); } canUndo(): boolean { return this.historyModule?.canUndo() ?? false; } canRedo(): boolean { return this.historyModule?.canRedo() ?? false; } // ── 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]; } /** * Top-level elements only (each still carrying its full descendant subtree via * `.children`) — unlike `getElements()`, no element appears twice. Consumers building a * tree view (a layer panel) want this, not `getElements()`: that method's flat list * includes every descendant a second time as its own top-level entry, since each * snapshot in it still carries its children. `buildRoots` already computes true roots * internally for `getElements()` to flatten — this just returns them unflattened. * Cached like elementsCache — a layer panel calling this every render tick shouldn't * repay the DOM walk each time. */ getRootElements(): ElementSnapshot[] { this.rootsCache ??= buildRoots(this.parsed.document); return [...this.rootsCache]; } getElement(id: HfId): ElementSnapshot | null { // Accept both bare ids (top-level) and scoped ids (sub-composition elements). // Match by scopedId first (canonical); bare-id fallback keeps top-level compat // for callers that don't yet use scoped ids. return ( this.getElements().find((el) => el.scopedId === id) ?? this.getElements().find((el) => el.id === id && el.scopedId === el.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; if (query.composition && !el.scopedId.startsWith(`${query.composition}/`)) return false; return true; }) .map((el) => el.scopedId) ); } getAllAnimationIds(): Set { const ids = new Set(); for (const script of getGsapScripts(this.parsed.document)) { for (const id of parsedAnimationIds(script)) ids.add(id); } return ids; } // ── 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]; } setSelection(ids: string[]): void { const deduped = Array.from(new Set(ids)); if ( deduped.length === this.currentSelection.length && deduped.every((id, i) => id === this.currentSelection[i]) ) { return; } this.updateSelection(deduped); } private updateSelection(ids: readonly string[]): void { this.currentSelection = [...ids]; for (const handler of this.selectionHandlers) { handler([...this.currentSelection]); } } // ── Dispatch / batch ───────────────────────────────────────────────────────── // fallow-ignore-next-line complexity private _dispatch(op: EditOp, origin: unknown): MutationResult { const result = applyOp(this.parsed, op); const { forward, inverse } = result; if (forward.length === 0 && inverse.length === 0) { if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h()); return result; } this.elementsCache = null; this.rootsCache = 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 | Record | null); } } // Purge orphan property keys for removed elements so the override-set stays // compact and a future T3 session doesn't replay stale properties onto a // non-existent element. Override-set keys use decoded scoped ids ("hf-host/hf-leaf") // while path segments use RFC 6902 encoding ("hf-host~1hf-leaf") — decode before compare. for (const p of forward) { const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path); if (p.op === "remove" && elemMatch) { // Decode RFC 6902 escaping: ~1 → /, ~0 → ~ const id = elemMatch[1]!.replace(/~1/g, "/").replace(/~0/g, "~"); for (const key of Object.keys(this.overrides)) { // Purge property sub-keys (e.g. "hf-x.style.color") but preserve // the removal marker itself (key === id, set to null in the loop above). if (key.startsWith(`${id}.`) || key.startsWith(`${id}/`)) { delete this.overrides[key]; } } } } if (this.batchDepth > 0) { this.batchForward.push(...forward); this.batchInverse.push(...inverse); if (!this.batchOpTypes.includes(op.type)) this.batchOpTypes.push(op.type); } else { // Reverse the inverse list (parity with batch() below): an op that emits // multiple patches whose undo order matters — same path (reorderElements // with a duplicate target), an aliased multi-target, or a nested // parent+child removeElement — must undo in reverse application order, or // undo lands on an intermediate value / drops a subtree. Harmless for the // common single-patch / independent-path case. const event = buildPatchEvent(forward, [...inverse].reverse(), origin, [op.type]); this.patchHandlers.forEach((h) => h(event)); this.changeHandlers.forEach((h) => h()); } return result; } dispatch(op: EditOp, opts?: { origin?: unknown }): void { this._dispatch(op, opts?.origin ?? ORIGIN_LOCAL); } /** * 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, ); // Fire handlers before resetting batch state so that if a handler // throws the patch data (batchForward/batchInverse) is still intact // for callers that inspect it on error. The event was already built // from a snapshot so handler re-entrancy does not corrupt the event. this.patchHandlers.forEach((h) => h(event)); this.changeHandlers.forEach((h) => h()); this.resetBatchState(); } 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.rootsCache = 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): CanResult { 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(opts?: { stripRuntime?: boolean }): string { const html = serializeDocument(this.parsed); // Newer agent-generated compositions embed hyperframe.runtime.iife.js in their own // HTML. Any host driving its own clock (not just an editing iframe — anything that // owns seeking/playback itself) must not let that runtime self-init: it races the // host's first seek and resets the timeline to t=0. Opt-in (default false) since a // host playing the composition normally wants the runtime. return opts?.stripRuntime ? stripEmbeddedRuntimeScripts(html) : html; } // ── 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; this.rootsCache = 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 | Record | null); } } const opTypes = ["applyPatches"]; const event = buildPatchEvent(patches, [], origin, opTypes); this.patchHandlers.forEach((h) => h(event)); this.changeHandlers.forEach((h) => h()); } // ── Lifecycle ──────────────────────────────────────────────────────────────── async flush(): Promise { await this.persistQueueModule?.flush(); } dispose(): void { this.previewSelectionUnsubscribe?.(); this.previewSelectionUnsubscribe = null; 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 { // 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) { // history:false opts out of the SDK undo stack ONLY. Persist (auto-save) is // independent — gating it on the history flag too would silently drop every // disk write for a caller that just wanted to disable undo (data loss). if (opts?.history !== false) { const history = createHistory(session, { coalesceMs: opts?.coalesceMs ?? 300, trackedOrigins: opts?.trackedOrigins, }); session.attachHistory(history); } if (opts?.persist) { const pq = createPersistQueue(session, opts.persist, { path: opts.persistPath, onError: (e) => session._fireError(e), }); session.attachPersistQueue(pq); } } return session; }