// fallow-ignore-file code-duplication // (splitTopLevelWhitespace intentionally mirrors the studio-side copies in // manualEditsDom.ts / manualEditsRenderScript.ts — this module ships inside // the self-contained runtime bundle and cannot import studio code.) /** * Editor position edits (SDK `moveElement`) applied at render time. * * The SDK's `moveElement` writes `data-x` / `data-y` plus a captured baseline * (`data-hf-edit-base-x` / `data-hf-edit-base-y` — the values before the first * edit). The runtime renders the edit as the DELTA between the two, via the * independent CSS `translate` longhand, so it composes additively with any * position the composition itself produces (GSAP tweens, `tl.set`, CSS). * * Why `translate` and why after timeline bind: GSAP folds a `translate` that * is present when it FIRST parses an element into its cached transform (and * an absolute tween then discards it on the animated axis — the per-axis loss * bug). A `translate` set AFTER that parse is never read, cleared, or baked * by GSAP 3.x on subsequent seeks, so a single application at bind time holds * for the whole timeline. Before the first apply, the element's transform * parse is primed (gsap.getProperty) so tweens and positioned set()s that * first RENDER later reuse the cache instead of folding the edit. Known * limitation: if GSAP itself loads only after the apply ran, a later tween's * first parse still folds the edit (the fold guard then skips re-apply and * emits position_edit_fold_skipped instead of double-applying). */ import { emitAnalyticsEvent } from "./analytics"; export const EDIT_BASE_X_ATTR = "data-hf-edit-base-x"; export const EDIT_BASE_Y_ATTR = "data-hf-edit-base-y"; export const EDIT_ORIGINAL_TRANSLATE_ATTR = "data-hf-edit-original-translate"; const num = (value: string | null): number => { const n = parseFloat(value ?? ""); return Number.isFinite(n) ? n : 0; }; /** Split "10px 20px" / "calc(1px + 2px) 3px" on top-level whitespace only. */ const splitTopLevelWhitespace = (value: string): string[] => { const parts: string[] = []; let depth = 0; let current = ""; for (const char of value.trim()) { if (char === "(") depth += 1; if (char === ")") depth = Math.max(0, depth - 1); if (/\s/.test(char) && depth === 0) { if (current) parts.push(current); current = ""; } else { current += char; } } if (current) parts.push(current); return parts; }; const PX_VALUE = /^-?(?:\d+(?:\.\d+)?|\.\d+)px$/; /** Sum two lengths — numerically when both are plain px, via calc() otherwise. */ const addLengths = (a: string, b: string): string => { if (PX_VALUE.test(a) && PX_VALUE.test(b)) return `${parseFloat(a) + parseFloat(b)}px`; return `calc(${a} + ${b})`; }; /** Compose the edit delta with the element's pre-edit translate value. */ export const composeTranslate = (original: string, x: string, y: string): string => { if (!original || original === "none") return `${x} ${y}`; const [ox, oy, oz] = splitTopLevelWhitespace(original); if (ox === undefined) return `${x} ${y}`; if (oy === undefined) return `${addLengths(ox, x)} ${y}`; const z = oz === undefined ? "" : ` ${oz}`; return `${addLengths(ox, x)} ${addLengths(oy, y)}${z}`; }; /** * Force GSAP (when present) to parse and cache the element's transform BEFORE * the edit translate is written. GSAP folds a CSS `translate` it sees at an * element's first parse into its cached transform (losing it per-axis on * absolute tweens); once the cache exists, later tweens and positioned set()s * reuse it and never read the translate again. gsap.getProperty parses * without mutating the element. Best-effort — absent or failing GSAP is fine. */ const primeGsapTransformCache = (el: HTMLElement): void => { try { const view = el.ownerDocument.defaultView as | (Window & { gsap?: { getProperty?: (t: Element, p: string) => unknown } }) | null; view?.gsap?.getProperty?.(el, "x"); } catch { // parse priming is an optimization, never a requirement } }; /** The element's effective translate: inline if set, computed otherwise ("" = none). */ export const readCurrentTranslate = (el: HTMLElement): string => { const inline = el.style.getPropertyValue("translate").trim(); if (inline) return inline === "none" ? "" : inline; try { const view = el.ownerDocument.defaultView; const computed = view ? view.getComputedStyle(el).getPropertyValue("translate").trim() : ""; return computed === "none" ? "" : computed; } catch { return ""; } }; /** * The translate value this module last wrote per element. When a re-apply * (timeline rebind) finds the element's inline translate no longer matching, * something else consumed it — in practice GSAP folding it into the cached * transform when a lazily-created tween first-parsed the element. Re-setting * it then would DOUBLE the offset on every axis the tween doesn't animate, so * the non-forced path skips instead (degrading to the documented fold-loss). */ const lastAppliedTranslate = new WeakMap(); /** * Apply one element's position edit. Idempotent — the pre-edit translate is * captured exactly once (into EDIT_ORIGINAL_TRANSLATE_ATTR, empty string * meaning "none") on first application, and every application recomputes from * that baseline. * * `force` re-applies even when the previously written translate was clobbered * externally — used by editor commits, where the current inline translate is * the draft-composed one and must be overwritten. */ export function applyPositionEditToElement(el: HTMLElement, opts?: { force?: boolean }): void { const previous = lastAppliedTranslate.get(el); if ( !opts?.force && previous !== undefined && el.style.getPropertyValue("translate") !== previous ) { // Observable signal for the documented degradation — without it, a // fold-loss surfaces to users only as "my edit didn't stick". emitAnalyticsEvent("position_edit_fold_skipped", { hfId: el.getAttribute("data-hf-id"), }); return; } const dx = num(el.getAttribute("data-x")) - num(el.getAttribute(EDIT_BASE_X_ATTR)); const dy = num(el.getAttribute("data-y")) - num(el.getAttribute(EDIT_BASE_Y_ATTR)); if (!el.hasAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)) { el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, readCurrentTranslate(el)); } if (previous === undefined) primeGsapTransformCache(el); const original = el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) ?? ""; const value = composeTranslate(original, `${dx}px`, `${dy}px`); el.style.setProperty("translate", value); lastAppliedTranslate.set(el, el.style.getPropertyValue("translate")); } /** * Apply all pending position edits in the document. Returns the number of * elements updated. */ export function applyPositionEdits(doc: Document): number { const marked = doc.querySelectorAll(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`); // Not `instanceof HTMLElement`: `doc` is frequently an iframe's document (the // SDK's edit preview, a host embedding a composition), and its elements are // HTMLElement instances of THAT frame's realm — never this module's. A // module-scope `instanceof HTMLElement` check silently no-ops on every element // cross-realm. Use the document's own realm's constructor; duck-type on // `.style` when defaultView is unavailable (a detached/synthetic document). const RealmHTMLElement = doc.defaultView?.HTMLElement; let applied = 0; for (let i = 0; i < marked.length; i++) { const el = marked[i]; const isStylable = RealmHTMLElement ? el instanceof RealmHTMLElement : typeof (el as HTMLElement).style?.setProperty === "function"; if (!isStylable) continue; applyPositionEditToElement(el as HTMLElement); applied += 1; } return applied; } const SEEK_REAPPLY_WRAPPED = "__hfPositionEditsSeekReapplyWrapped"; type SeekFunction = (...args: unknown[]) => unknown; const wrappedSeekFunctions = new WeakSet(); const observedSeekProperties = new WeakMap>(); const observedGlobalProperties = new WeakMap>(); type SeekWindow = Window & typeof globalThis & { __hf?: { seek?: (...args: unknown[]) => unknown }; __player?: { renderSeek?: (...args: unknown[]) => unknown }; }; /** Reapply SDK position edits after every render seek, including late-bound seeks. */ export function installPositionEditsSeekReapply(win: Window & typeof globalThis): void { const target = win as SeekWindow; const reapply = (): void => { try { applyPositionEdits(target.document); } catch { // A position edit must never break the render seek path. } }; const isWrapped = (fn: unknown): fn is SeekFunction => typeof fn === "function" && (wrappedSeekFunctions.has(fn as SeekFunction) || Boolean((fn as { [SEEK_REAPPLY_WRAPPED]?: boolean })[SEEK_REAPPLY_WRAPPED])); const markWrapped = (fn: SeekFunction): void => { wrappedSeekFunctions.add(fn); try { Object.defineProperty(fn, SEEK_REAPPLY_WRAPPED, { value: true }); } catch { // The WeakSet keeps frozen functions from being wrapped repeatedly. } }; const wrapFunction = (fn: unknown): unknown => { if (typeof fn !== "function" || isWrapped(fn)) return fn; const wrapped: SeekFunction = function (this: unknown, ...args: unknown[]): unknown { const result = fn.apply(this, args); reapply(); return result; }; markWrapped(wrapped); return wrapped; }; const observeSeekProperty = (container: object, property: string): boolean => { let observed = observedSeekProperties.get(container); if (observed?.has(property)) return true; const descriptor = Object.getOwnPropertyDescriptor(container, property); if (descriptor?.configurable === false) { const current = (container as Record)[property]; if (typeof current === "function") { (container as Record)[property] = wrapFunction(current); reapply(); } return false; } let current = (container as Record)[property]; const originalSetter = descriptor?.set; Object.defineProperty(container, property, { configurable: true, enumerable: descriptor?.enumerable ?? true, get: () => current, set: (value: unknown) => { current = wrapFunction(value); originalSetter?.call(container, value); }, }); current = wrapFunction(current); observed ??= new Set(); observed.add(property); observedSeekProperties.set(container, observed); reapply(); return true; }; const observeGlobalContainer = ( name: "__hf" | "__player", property: "seek" | "renderSeek", ): boolean => { let globals = observedGlobalProperties.get(target); const descriptor = Object.getOwnPropertyDescriptor(target, name); if (!globals?.has(name)) { if (descriptor?.configurable === false) { const current = target[name]; return current ? observeSeekProperty(current, property) : false; } let value = target[name]; Object.defineProperty(target, name, { configurable: true, enumerable: descriptor?.enumerable ?? true, get: () => value, set: (next: unknown) => { value = next as typeof value; if (value) observeSeekProperty(value, property); }, }); globals ??= new Set(); globals.add(name); observedGlobalProperties.set(target, globals); } const current = target[name]; return current ? observeSeekProperty(current, property) : false; }; const wrapAll = (): boolean => { const hfObserved = observeGlobalContainer("__hf", "seek"); const playerObserved = observeGlobalContainer("__player", "renderSeek"); return hfObserved && playerObserved; }; if (wrapAll()) return; let remaining = 120; const interval = target.setInterval(() => { if (wrapAll()) { target.clearInterval(interval); return; } remaining -= 1; if (remaining <= 0) target.clearInterval(interval); }, 50); }