/** * Op handlers for Phase 3a (non-parser ops). * * Each handler: mutates the linkedom Document, returns {forward, inverse} RFC 6902 patches. * Pure with respect to events — callers emit events from the patches. * * Phase 3b (parser-backed) will add setClassStyle + 7 GSAP ops as additional handlers. */ import type { CanResult, EditOp, FontValue, GsapTweenSpec, HfId, ImageValue, JsonPatchOp, } from "../types.js"; import type { ParsedDocument } from "./model.js"; import { resolveScoped, escapeHfId, findRoot, getElementStyles, setElementStyles, toCamel, getOwnText, setOwnText, getSiblingIndex, getGsapScript, setGsapScript, getStyleSheet, setStyleSheet, } from "./model.js"; import { stylePath, textPath, attrPath, timingPath, holdPath, elementPath, variablePath, metaPath, gsapScriptPath, styleSheetPath, scalarChange, scalarDelete, valueChange, patchAdd, patchRemove, } from "./patches.js"; import { upsertCssRule } from "./cssWriter.js"; import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids"; import { EDIT_BASE_X_ATTR, EDIT_BASE_Y_ATTR } from "@hyperframes/core/runtime/position-edits"; import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { addAnimationToScript, addAnimationWithKeyframesToScript, updateAnimationInScript, removeAnimationFromScript, removePropertyFromAnimation, addKeyframeToScript, removeKeyframeFromScript, removeAllKeyframesFromScript, convertToKeyframesFromScript, materializeKeyframesFromScript, splitIntoPropertyGroupsFromScript, splitAnimationsInScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, setArcPathInScript, updateArcSegmentInScript, removeArcPathFromScript, unrollDynamicAnimations, } from "@hyperframes/core/gsap-writer-acorn"; import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js"; import { readVariableDefault, writeVariableDefault } from "./variableModel.js"; import { URI_BEARING_ATTRS, DANGEROUS_URI_SCHEMES, DANGEROUS_DATA_URI, } from "@hyperframes/core/html-attr-safety"; export interface MutationResult { forward: JsonPatchOp[]; inverse: JsonPatchOp[]; meta?: { animationId?: string; newId?: string }; } const EMPTY: MutationResult = { forward: [], inverse: [] }; // ─── setAttribute safety ──────────────────────────────────────────────────── // Composition-reserved attributes — changing these breaks element identity or // the core/studio data model. Reject before mutating. const RESERVED_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 validateSetAttribute(name: string, value: string | null): void { const lower = name.toLowerCase(); if (RESERVED_ATTRS.has(lower)) { throw new Error( `setAttribute: "${name}" is a reserved composition attribute and cannot be reassigned. ` + `Use the appropriate typed method (setTiming, setHold, etc.) instead.`, ); } if (lower.startsWith("on")) { throw new Error( `setAttribute: event-handler attributes ("${name}") are not permitted — ` + `they produce executable HTML that cannot be safely serialized.`, ); } if (value !== null && URI_BEARING_ATTRS.has(lower)) { const trimmed = value.trim(); if (DANGEROUS_URI_SCHEMES.test(trimmed) || DANGEROUS_DATA_URI.test(trimmed)) { throw new Error(`setAttribute: unsafe URI value for "${name}".`); } } } export class UnsupportedOpError extends Error { // Stable error code — part of the public API contract (F7); hosts switch on // err.code rather than the message. // fallow-ignore-next-line unused-class-member readonly code = "E_UNSUPPORTED_OP"; constructor(opType: string) { super( `Op '${opType}' requires the Phase 3b parser-backed engine and is not available yet. ` + `Use can(op) to feature-detect before dispatching.`, ); this.name = "UnsupportedOpError"; } } // ─── Target normalization ──────────────────────────────────────────────────── function targets(target: HfId | HfId[]): HfId[] { return Array.isArray(target) ? target : [target]; } // ─── Op dispatch ──────────────────────────────────────────────────────────── function dispatchRemoveGsapKeyframe( parsed: ParsedDocument, op: Extract, ): MutationResult { return handleRemoveGsapKeyframeByPercentage(parsed, op.animationId, op.percentage); } function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { switch (op.type) { case "setGsapKeyframe": return handleSetGsapKeyframe( parsed, op.animationId, op.keyframeIndex, op.position, op.value, op.ease, ); case "addGsapKeyframe": return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value); case "removeGsapKeyframe": return dispatchRemoveGsapKeyframe(parsed, op); case "removeAllKeyframes": return handleRemoveAllKeyframes(parsed, op.animationId); case "convertToKeyframes": return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues); case "materializeKeyframes": return handleMaterializeKeyframes( parsed, op.animationId, op.keyframes, op.easeEach, op.resolvedSelector, ); case "splitIntoPropertyGroups": return handleSplitIntoPropertyGroups(parsed, op.animationId); case "splitAnimations": return handleSplitAnimations(parsed, op); default: return undefined; } } function applyArcPathOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { const s = getGsapScript(parsed.document) ?? ""; switch (op.type) { case "setArcPath": { const cfg = { ...op.config, segments: op.config.segments.map((seg) => ({ ...seg, curviness: seg.curviness ?? 1 })), }; return handleArcPathScript(parsed, s, setArcPathInScript(s, op.animationId, cfg)); } case "updateArcSegment": return handleArcPathScript( parsed, s, updateArcSegmentInScript(s, op.animationId, op.segmentIndex, op.update), ); case "removeArcPath": return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId)); case "unrollDynamicAnimations": return handleArcPathScript( parsed, s, unrollDynamicAnimations(s, op.animationId, op.elements), ); default: return undefined; } } function applyGsapWithKeyframesOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { switch (op.type) { case "addWithKeyframes": return handleAddWithKeyframes(parsed, op); case "replaceWithKeyframes": return handleReplaceWithKeyframes(parsed, op); default: return undefined; } } function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined { const kf = applyGsapKeyframeOp(parsed, op); if (kf !== undefined) return kf; const arc = applyArcPathOp(parsed, op); if (arc !== undefined) return arc; const wkf = applyGsapWithKeyframesOp(parsed, op); if (wkf !== undefined) return wkf; switch (op.type) { case "addGsapTween": return handleAddGsapTween(parsed, op.target, op.tween); case "setGsapTween": return handleSetGsapTween(parsed, op.animationId, op.properties); case "removeGsapProperty": return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from); case "removeGsapTween": return handleRemoveGsapTween(parsed, op.animationId); case "deleteAllForSelector": return handleDeleteAllForSelector(parsed, op.selector); default: return undefined; } } export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { const gsap = applyGsapOp(parsed, op); if (gsap !== undefined) return gsap; switch (op.type) { case "setStyle": return handleSetStyle(parsed, targets(op.target), op.styles); case "setText": return handleSetText(parsed, targets(op.target), op.value); case "setAttribute": return handleSetAttribute(parsed, targets(op.target), op.name, op.value); case "setTiming": return handleSetTiming(parsed, targets(op.target), { start: op.start, duration: op.duration, trackIndex: op.trackIndex, }); case "setHold": return handleSetHold(parsed, targets(op.target), op.hold); case "moveElement": return handleMoveElement(parsed, targets(op.target), op.x, op.y); case "removeElement": return handleRemoveElement(parsed, targets(op.target)); case "addElement": return handleAddElement(parsed, op.parent, op.index, op.html); case "reorderElements": return handleReorderElements(parsed, op.entries); case "setCompositionMetadata": return handleSetCompositionMetadata(parsed, op); case "setVariableValue": return handleSetVariableValue(parsed, op.id, op.value); case "setClassStyle": return handleSetClassStyle(parsed, op.selector, op.styles); case "addLabel": return handleAddLabel(parsed, op.name, op.position); case "removeLabel": return handleRemoveLabel(parsed, op.name); default: throw new UnsupportedOpError((op as EditOp).type); } } // ─── Op handlers ──────────────────────────────────────────────────────────── function handleSetStyle( parsed: ParsedDocument, ids: HfId[], styles: Record, ): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { const el = resolveScoped(parsed.document, id); if (!el) continue; const old = getElementStyles(el); setElementStyles(el, styles); for (const [prop, value] of Object.entries(styles)) { // Normalize to the camelCase key the style map + patch grammar use. A // hyphenated op key ("transform-origin") otherwise misses the camelCase // store, so oldValue is always null → undo deletes/loses the prior value, // a removal skips its inverse patch entirely (DOM/patch-log desync), and // the patch path/override-set key diverge from the camelCase grammar. const key = toCamel(prop); const path = stylePath(id, key); const oldValue = old[key] ?? null; if (value !== null) { const p = scalarChange(path, oldValue, value); result.forward.push(p.forward); result.inverse.push(p.inverse); } else if (oldValue !== null) { const p = scalarDelete(path, oldValue); result.forward.push(p.forward); result.inverse.push(p.inverse); } } } return result; } function handleMoveElement( parsed: ParsedDocument, ids: HfId[], x: number, y: number, ): MutationResult { // HF elements are positioned via data-x / data-y (parsed by htmlParser.ts, // emitted by hyperframes generator). CSS left/top is not the convention. // // The pre-edit values are captured once per element into // data-hf-edit-base-x/y. The runtime (core runtime/positionEdits.ts) renders // the edit as translate(data-x − base, data-y − base), which composes with // GSAP-animated transforms instead of being overwritten per-axis. const parts: MutationResult[] = []; for (const id of ids) { const el = resolveScoped(parsed.document, id); if (!el) continue; if (el.getAttribute(EDIT_BASE_X_ATTR) === null) { parts.push( handleSetAttribute(parsed, [id], EDIT_BASE_X_ATTR, el.getAttribute("data-x") ?? "0"), ); } if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) { parts.push( handleSetAttribute(parsed, [id], EDIT_BASE_Y_ATTR, el.getAttribute("data-y") ?? "0"), ); } } parts.push(handleSetAttribute(parsed, ids, "data-x", String(x))); parts.push(handleSetAttribute(parsed, ids, "data-y", String(y))); return { forward: parts.flatMap((p) => p.forward), inverse: parts .slice() .reverse() .flatMap((p) => p.inverse), }; } function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { const el = resolveScoped(parsed.document, id); if (!el) continue; const oldText = getOwnText(el); setOwnText(el, value); const path = textPath(id); // getOwnText always returns string ("" for empty) — use it directly so // the forward patch is always op:'replace', not op:'add'. An op:'add' on // a text path is semantically wrong for external JSON-patch consumers // (the path already exists; add would fail on strict appliers). const p = scalarChange(path, oldText, value); result.forward.push(p.forward); result.inverse.push(p.inverse); } return result; } function handleSetAttribute( parsed: ParsedDocument, ids: HfId[], name: string, value: string | null, ): MutationResult { validateSetAttribute(name, value); const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { const el = resolveScoped(parsed.document, id); if (!el) continue; const oldValue = el.getAttribute(name); const path = attrPath(id, name); if (value !== null) { el.setAttribute(name, value); const p = scalarChange(path, oldValue, value); result.forward.push(p.forward); result.inverse.push(p.inverse); } else if (oldValue !== null) { el.removeAttribute(name); const p = scalarDelete(path, oldValue); result.forward.push(p.forward); result.inverse.push(p.inverse); } } return result; } // fallow-ignore-next-line complexity function handleSetTiming( parsed: ParsedDocument, ids: HfId[], timing: { start?: number; duration?: number; trackIndex?: number }, ): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; // Parse GSAP script once; updateAnimationInScript re-parses internally per call but // we avoid re-fetching the script element on every iteration. const origScript = getGsapScript(parsed.document); const parsedGsap = origScript ? parseGsapScriptAcornForWrite(origScript) : null; let currentScript = origScript; for (const id of ids) { const el = resolveScoped(parsed.document, id); if (!el) continue; const oldStartStr = el.getAttribute("data-start"); const oldEndStr = el.getAttribute("data-end"); const oldDurationStr = el.getAttribute("data-duration"); const oldTrackStr = el.getAttribute("data-track-index"); const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null; const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null; const oldDurationAttr = oldDurationStr !== null ? parseFloat(oldDurationStr) : null; // Prefer an explicit data-duration — the attribute clips are authored with and // the runtime reads — falling back to data-end − data-start. Reading only // data-end left oldDuration null for duration-authored clips, collapsing the // GSAP duration-scale ratio to 1 and scaling nothing. const oldDuration = oldDurationAttr !== null ? oldDurationAttr : oldStart !== null && oldEnd !== null ? oldEnd - oldStart : null; const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null; const newStart = timing.start ?? oldStart; const newDuration = timing.duration ?? oldDuration; if (timing.start !== undefined && newStart !== null) { const path = timingPath(id, "start"); const p = scalarChange(path, oldStart, newStart); result.forward.push(p.forward); result.inverse.push(p.inverse); el.setAttribute("data-start", String(newStart)); } // Write to whichever timing attribute the clip actually uses. A data-duration // clip updates data-duration only on a real resize (duration is invariant // under a move); a data-end clip updates data-end whenever start or duration // changes (end = start + duration). Writing a fresh data-end beside a stale // data-duration had no playback effect. if (oldDurationStr !== null) { if (timing.duration !== undefined && newDuration !== null) { const path = timingPath(id, "duration"); const p = scalarChange(path, oldDurationAttr, newDuration); result.forward.push(p.forward); result.inverse.push(p.inverse); el.setAttribute("data-duration", String(newDuration)); } // A clip carrying BOTH data-duration and data-end must keep data-end in // sync (end = start + duration) on any start/duration change, else the // stale data-end inverts the clip (end < start) for runtimes that read it. if (oldEndStr !== null && newStart !== null && newDuration !== null) { const newEnd = newStart + newDuration; const endPath = timingPath(id, "end"); const ep = scalarChange(endPath, oldEnd, newEnd); result.forward.push(ep.forward); result.inverse.push(ep.inverse); el.setAttribute("data-end", String(newEnd)); } } else if ( (timing.duration !== undefined || timing.start !== undefined) && newStart !== null && newDuration !== null ) { const newEnd = newStart + newDuration; // Store the computed end value directly (not the logical duration) so the inverse // patch is self-contained and doesn't require data-start to be restored first. const path = timingPath(id, "end"); const p = scalarChange(path, oldEnd, newEnd); result.forward.push(p.forward); result.inverse.push(p.inverse); el.setAttribute("data-end", String(newEnd)); } if (timing.trackIndex !== undefined) { const newTrack = timing.trackIndex; const path = timingPath(id, "trackIndex"); const p = scalarChange(path, oldTrack, newTrack); result.forward.push(p.forward); result.inverse.push(p.inverse); el.setAttribute("data-track-index", String(newTrack)); } // Sync GSAP tween positions: the GSAP script is the source of truth at play time — // the timeline rebuilds from it on every seek. Without this, DOM attribute edits // have zero playback effect; the script's position/duration silently overrides them. // Match against BOTH the element's data-hf-id (the canonical form) AND its DOM // id: the Studio GSAP panel / ensureElementAddressable author tweens as // `#domId`, which selectorMatchesId(hfId) never matched — so moving/resizing // those clips left their tweens unsynced. const matchHfId = el.getAttribute("data-hf-id") ?? id; const matchDomId = el.getAttribute("id"); if (parsedGsap && currentScript) { // A missing data-start means an implicit start of 0 (matching the server // shiftGsapPositions path); a malformed attr parses to NaN. Sanitize to a // finite number so a start-less/blank clip still shifts and never feeds // NaN into the tween positions. const oldStartNum = oldStart !== null && Number.isFinite(oldStart) ? oldStart : 0; // Per-tween shift/scale (mirrors shiftGsapPositions/scaleGsapPositions): a // multi-tween stagger maps each tween's own intra-clip position by the // start DELTA and scales its duration by the clip-duration RATIO. Writing // the absolute newStart/newDuration onto every tween would collapse the // stagger onto one point and blow each tween's duration to the full clip. const startChanged = timing.start !== undefined && newStart !== null; const durChanged = timing.duration !== undefined && newDuration !== null; const ratio = durChanged && oldDuration !== null && oldDuration > 0 && newDuration !== null ? newDuration / oldDuration : 1; const remapStart = startChanged && newStart !== null ? newStart : oldStartNum; for (const { id: animId, animation } of parsedGsap.located) { const matches = selectorMatchesId(animation.targetSelector, matchHfId) || (matchDomId !== null && selectorMatchesId(animation.targetSelector, matchDomId)); if (!matches) continue; // Skip tweens whose position is a label or relative string ("+=0.5", // "<", ">"): relative positions already track their neighbours, and a // string position can't be safely shifted by the clip delta here. // ponytail: known ceiling — string positions are not re-synced on // move/resize; numeric positions only. if (typeof animation.position !== "number") continue; const updates: Partial = {}; // Don't write an absolute position onto an auto-sequenced tween (no // explicit position arg → parsed as implicitPosition): the writer would // APPEND a position arg, collapsing the stagger onto one point. Duration // still scales below. if ((startChanged || durChanged) && animation.implicitPosition !== true) { const shifted = remapStart + (animation.position - oldStartNum) * ratio; updates.position = Math.max(0, Math.round(shifted * 1000) / 1000); } if (durChanged && typeof animation.duration === "number" && animation.duration > 0) { updates.duration = Math.max(0.001, Math.round(animation.duration * ratio * 1000) / 1000); } if (Object.keys(updates).length === 0) continue; currentScript = updateAnimationInScript(currentScript, animId, updates); } } } // Flush accumulated GSAP script changes as a single patch pair. if (origScript && currentScript && currentScript !== origScript) { setGsapScript(parsed.document, currentScript); const gsapResult = gsapScriptChange(origScript, currentScript); result.forward.push(...gsapResult.forward); result.inverse.push(...gsapResult.inverse); } return result; } function handleSetHold( parsed: ParsedDocument, ids: HfId[], hold: { start: number; end: number; fill: "freeze" | "loop" }, ): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { const el = resolveScoped(parsed.document, id); if (!el) continue; const fields: Array<["start" | "end" | "fill", string]> = [ ["start", String(hold.start)], ["end", String(hold.end)], ["fill", hold.fill], ]; for (const [field, newVal] of fields) { const attrName = `data-hold-${field}`; const oldVal = el.getAttribute(attrName); const path = holdPath(id, field); el.setAttribute(attrName, newVal); const p = scalarChange(path, oldVal, newVal); result.forward.push(p.forward); result.inverse.push(p.inverse); } } return result; } function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; const origScript = getGsapScript(parsed.document); let currentScript = origScript; for (const id of ids) { const el = resolveScoped(parsed.document, id); if (!el) continue; const parentEl = el.parentElement; const parentId = parentEl?.getAttribute("data-hf-id") ?? null; const siblingIndex = getSiblingIndex(el); const html = el.outerHTML; // Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade // removes animations targeting any sub-composition element, not just the host. const subtreeIds = collectSubtreeHfIds(el); el.remove(); const path = elementPath(id); result.forward.push(patchRemove(path)); result.inverse.push(patchAdd(path, { html, parentId, siblingIndex })); if (currentScript) { for (const subtreeId of subtreeIds) { currentScript = cascadeRemoveAnimations(currentScript, subtreeId); } } } if (origScript && currentScript && currentScript !== origScript) { setGsapScript(parsed.document, currentScript); const gsapResult = gsapScriptChange(origScript, currentScript); result.forward.push(...gsapResult.forward); result.inverse.push(...gsapResult.inverse); } return result; } // ─── addElement handler ─────────────────────────────────────────────────────── /** * Resolve all existing hf-ids in the document into `assigned` so that * mintHfId cannot issue an id that already exists in the composition. */ function collectDocumentHfIds(document: Document): Set { const assigned = new Set(); for (const el of Array.from(document.querySelectorAll("[data-hf-id]"))) { const id = el.getAttribute("data-hf-id"); if (id) assigned.add(id); } return assigned; } /** * Stamp data-hf-id onto every un-stamped element in `root` and its * descendants, minting ids against `assigned` (the live document's id set). * Returns the minted id of `root` (or its existing id if already stamped). */ function mintFragmentIds(root: Element, assigned: Set): string { if (!root.getAttribute("data-hf-id") && !EXCLUDED_TAGS.has(root.tagName.toLowerCase())) { root.setAttribute("data-hf-id", mintHfId(root, assigned)); } for (const el of Array.from(root.querySelectorAll("*"))) { if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) continue; if (el.getAttribute("data-hf-id")) continue; // pinned el.setAttribute("data-hf-id", mintHfId(el, assigned)); } return root.getAttribute("data-hf-id") ?? ""; } /** * Insert an HTML fragment (single-root) as a child of `parent` at `index`. * Mints ids against the LIVE document's existing id set so new ids can never * collide with elements already in the composition. Returns the minted root id * via result.meta.newId — mirrors the `animationId` pattern in addGsapTween. * * Inverse = patchRemove of the new element's path; mirrors handleRemoveElement's * inverse = patchAdd. Forward/inverse are thus symmetric with that handler. */ /** * Parse an HTML fragment in the target document and return its single root * element, or null when it is empty, multi-root, or contains a