import type { MutableRefObject } from "react"; import type { Composition, GsapTweenSpec } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { EditHistoryKind } from "./editHistory"; import type { PatchOperation } from "./sourcePatcher"; import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; import { trackStudioEvent } from "./studioTelemetry"; import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; import { patchOpsToSdkEditOps } from "./sdkOpMapping"; import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow"; import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety"; const CUTOVER_OP_TYPES = new Set([ "inline-style", "text-content", "attribute", "html-attribute", ]); // Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is // force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK // rejects with a throw. Detect that up front and decline the whole batch so it // takes the server path cleanly, instead of throwing inside the dispatch and // silently falling back per op. // ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new // op for it just reverts to the (working) throw→fallback path until synced. const RESERVED_CUTOVER_ATTRS = new Set([ "data-hf-id", "data-composition-id", "data-width", "data-height", "data-start", "data-end", "data-track-index", "data-hold-start", "data-hold-end", "data-hold-fill", ]); function sdkAttrName(op: PatchOperation): string | null { if (op.type === "attribute") { return op.property.startsWith("data-") ? op.property : `data-${op.property}`; } if (op.type === "html-attribute") return op.property; return null; } function mapsToReservedAttr(op: PatchOperation): boolean { const name = sdkAttrName(op); // Lowercase to match the SDK's validateSetAttribute (it lowercases before the // reserved check), so "DATA-START" is declined up front too; covers both // `attribute` (prefixed) and `html-attribute` (raw) ops. return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase()); } // ─── html-attribute safety ─────────────────────────────────────────────────── function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean { return ops.some( (op) => op.type === "html-attribute" && (!isAllowedHtmlAttribute(op.property) || (op.value !== null && !isSafeAttributeValue(op.property, op.value))), ); } function hasTextContentOp(ops: PatchOperation[]): boolean { return ops.some((op) => op.type === "text-content"); } function targetChildren(target: unknown): unknown[] | null { if (!target || typeof target !== "object" || !("children" in target)) return null; const children = (target as { children?: unknown }).children; return Array.isArray(children) ? children : null; } function elementTag(element: unknown): string | null { if (!element || typeof element !== "object" || !("tag" in element)) return null; const tag = (element as { tag?: unknown }).tag; return typeof tag === "string" ? tag.toLowerCase() : null; } // Tags that are non-HTML namespace elements in a linkedom-parsed HTML body. // Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof // HTMLElement` — that runtime check catches the same set, but we can't use it // here because `target` is a plain SDK object, not a DOM Element. If linkedom // (or a future parser) surfaces additional foreign-content elements as // non-HTMLElement, add them here. const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]); function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean { if (!hasTextContentOp(ops)) return false; const children = targetChildren(target); if (!children) return false; // Legacy patch-element replaces the whole element for multi-child targets and // for single non-HTML children. The SDK text patch stream stores a scalar // inverse, so those shapes cannot be made both byte-identical and undo-safe // here. Let the server path remain authoritative for them. if (children.length > 1) return true; const tag = elementTag(children[0]); return tag !== null && NON_HTML_CHILD_TAGS.has(tag); } export function shouldUseSdkCutover( flagEnabled: boolean, hasSession: boolean, hfId: string | null | undefined, ops: PatchOperation[], ): boolean { return ( flagEnabled && hasSession && !!hfId && ops.length > 0 && ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) && !ops.some(mapsToReservedAttr) && !hasUnsafeHtmlAttributeOp(ops) ); } export interface CutoverDeps { editHistory: { recordEdit: (entry: { label: string; kind: EditHistoryKind; coalesceKey?: string; files: Record; }) => Promise; }; writeProjectFile: (path: string, content: string) => Promise; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; /** * Optional post-write refresh. When provided, it REPLACES the default * reloadPreview() — the GSAP path passes one that soft-reloads (preserving * the playhead) and invalidates the keyframe/gsap panel cache. Receives the * serialized document just written. */ refresh?: (after: string) => void; /** * Path of the composition the SDK session was opened for. The session models * ONLY this file (serialize() emits the whole active composition), so any edit * whose targetPath differs (a sub-composition file) must take the server path * — otherwise we'd write the full active-comp serialization into that file. */ compositionPath?: string | null; /** * Optional per-key task serializer (the same `gsap-file:${file}` serializer the * legacy `commitMutation` uses). When provided, every GSAP-op persist routes its * read-serialize → dispatch → serialize → write through it so two concurrent * same-file flushes can't interleave their read-modify-write and lose an edit. * Absent (e.g. in unit tests) → ops run unserialized as before. */ serialize?: (key: string, task: () => Promise) => Promise; /** * Optional reader for the on-disk content of targetPath. Timing/GSAP persists * use it to capture the EXACT prior bytes as the undo-history `before`, so undo * restores the file verbatim instead of a normalized SDK re-emit (which would * reformat the whole file). The style/delete paths already thread originalContent * in explicitly; this gives timing/GSAP parity without touching every call site. * Absent → falls back to the SDK's pre-edit serialize() (the prior behavior). */ readProjectFile?: (path: string) => Promise; } /** * Capture the undo-history `before` baseline for timing/GSAP persists: the exact * on-disk bytes when a reader is available (so undo restores them verbatim), * falling back to the SDK's pre-edit serialization when it isn't. Never throws — * a failed read degrades to the serialized fallback rather than aborting the edit. */ async function captureOnDiskBefore( deps: CutoverDeps, targetPath: string, serializedFallback: string, ): Promise { if (!deps.readProjectFile) return serializedFallback; try { return await deps.readProjectFile(targetPath); } catch { return serializedFallback; } } /** True when targetPath isn't the composition the SDK session models. */ function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean { return deps.compositionPath != null && targetPath !== deps.compositionPath; } interface CutoverOptions { label?: string; coalesceKey?: string; /** Skip the preview reload (mirrors the server path's skipRefresh). */ skipRefresh?: boolean; } // ponytail: exported for setSlideshowManifest (third caller — island write bypasses // the SDK dispatch path since