diff --git a/docs/sdk/guides/canvas-integration.mdx b/docs/sdk/guides/canvas-integration.mdx index a60a912cdf..feea098842 100644 --- a/docs/sdk/guides/canvas-integration.mdx +++ b/docs/sdk/guides/canvas-integration.mdx @@ -86,7 +86,7 @@ iframeDoc.addEventListener("click", (e) => { The draft loop keeps the model clean during a drag. The SDK is **not** in the 60fps path — you call `preview.applyDraft` on every `pointermove` and `preview.commitPreview` once on `pointerup`. The model sees exactly one `moveElement` op per drag, rather than hundreds. -`applyDraft` writes CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) directly onto the target element inside the iframe. The composition's CSS uses these vars to translate the element visually. Nothing in the SDK model changes. +`applyDraft` sets the element's CSS `translate` directly inside the iframe — the pre-drag value composed with the accumulated delta — so the drag is visible in any composition without composition-side CSS, and works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform instead of being overwritten). Nothing in the SDK model changes. `cancelPreview` restores the pre-drag translate; `commitPreview` derives one `moveElement` op and mirrors the committed position onto the live element. ```typescript let dragging = false; diff --git a/docs/sdk/reference/adapters.mdx b/docs/sdk/reference/adapters.mdx index 8a8666dc8c..87e149e71c 100644 --- a/docs/sdk/reference/adapters.mdx +++ b/docs/sdk/reference/adapters.mdx @@ -100,15 +100,15 @@ interface PreviewAdapter { - Applies draft CSS markers to the preview element at 60fps during a drag. Writes CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto the element so the composition's CSS can visually translate it without touching the model. The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler. + Visually translates the preview element at 60fps during a drag: sets the element's CSS `translate` to its pre-drag value composed with the accumulated delta. Works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform). The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler. Switching `id` mid-drag reverts the previous element's draft first. - Called once on pointer-up. Reads the accumulated draft markers, derives a `moveElement` op from them, dispatches it into the SDK, emits a patch event, and clears the markers. This is the only moment the SDK becomes aware of a drag. + Called once on pointer-up. Reads the accumulated draft delta, derives a `moveElement` op from it, dispatches it into the SDK, emits a patch event, and mirrors the committed position onto the live element (so it holds without a reload). This is the only moment the SDK becomes aware of a drag. If dispatch throws, the draft translate is reverted and the error propagates. - Reverts the draft CSS markers without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted. + Restores the element's pre-drag `translate` without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted. diff --git a/packages/core/package.json b/packages/core/package.json index 3893ed6139..0cebfd5274 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,12 @@ "import": "./src/runtime/clipTree.ts", "types": "./src/runtime/clipTree.ts" }, + "./runtime/position-edits": { + "bun": "./src/runtime/positionEdits.ts", + "node": "./dist/runtime/positionEdits.js", + "import": "./src/runtime/positionEdits.ts", + "types": "./src/runtime/positionEdits.ts" + }, "./runtime/lottie-readiness": { "bun": "./src/lottieReadiness.ts", "node": "./dist/lottieReadiness.js", @@ -259,6 +265,10 @@ "import": "./dist/runtime/clipTree.js", "types": "./dist/runtime/clipTree.d.ts" }, + "./runtime/position-edits": { + "import": "./dist/runtime/positionEdits.js", + "types": "./dist/runtime/positionEdits.d.ts" + }, "./runtime/lottie-readiness": { "import": "./dist/lottieReadiness.js", "types": "./dist/lottieReadiness.d.ts" diff --git a/packages/core/src/runtime/analytics.ts b/packages/core/src/runtime/analytics.ts index 119aa2aeef..795c20e1f5 100644 --- a/packages/core/src/runtime/analytics.ts +++ b/packages/core/src/runtime/analytics.ts @@ -12,7 +12,8 @@ export type RuntimeAnalyticsEvent = | "composition_paused" | "composition_seeked" | "composition_ended" - | "element_picked"; + | "element_picked" + | "position_edit_fold_skipped"; export type RuntimeAnalyticsProperties = Record; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c045ada4d8..3211a069c5 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -28,6 +28,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver"; import { createClipTree } from "./clipTree"; import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader"; import { applyCaptionOverrides } from "./captionOverrides"; +import { applyPositionEdits } from "./positionEdits"; import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; import { TransportClock } from "./clock"; import { WebAudioTransport } from "./webAudioTransport"; @@ -72,6 +73,12 @@ function resolveExportRenderFps(): ExportRenderFpsResolution { export function initSandboxRuntimeModular(): void { const state = createRuntimeState(); + // SDK moveElement edits must render even when no usable GSAP timeline ever + // binds (CSS/WAAPI-animated or fully static compositions) — apply at init. + // This runs at DOMContentLoaded, after inline composition scripts have + // parsed their tweens, so GSAP (when present) won't fold the translate. + // Re-applied on every timeline bind for the rebind/soft-reload paths. + applyPositionEdits(document); const exportRenderFps = resolveExportRenderFps(); state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps; if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) { @@ -1189,6 +1196,12 @@ export function initSandboxRuntimeModular(): void { // during initial rebind (timing race on first load / soft reload). const applyFn = (window as Record).__hfStudioManualEditsApply; if (typeof applyFn === "function") applyFn(); + + // SDK moveElement edits (data-hf-edit-base-x/y markers) render as a + // CSS translate delta. Must run after the timeline is bound so GSAP has + // already parsed the elements — a translate present at first parse gets + // folded into the cached transform and lost per-axis on seek. + applyPositionEdits(document); } if (resolution.diagnostics) { postRuntimeMessage({ diff --git a/packages/core/src/runtime/positionEdits.test.ts b/packages/core/src/runtime/positionEdits.test.ts new file mode 100644 index 0000000000..d7eae7343c --- /dev/null +++ b/packages/core/src/runtime/positionEdits.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + EDIT_BASE_X_ATTR, + EDIT_BASE_Y_ATTR, + EDIT_ORIGINAL_TRANSLATE_ATTR, + applyPositionEditToElement, + applyPositionEdits, + composeTranslate, +} from "./positionEdits"; + +function makeElement(attrs: Record, style = ""): HTMLElement { + const el = document.createElement("div"); + for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value); + if (style) el.setAttribute("style", style); + document.body.appendChild(el); + return el; +} + +describe("composeTranslate", () => { + it("returns the delta alone when there is no original", () => { + expect(composeTranslate("", "10px", "20px")).toBe("10px 20px"); + expect(composeTranslate("none", "10px", "20px")).toBe("10px 20px"); + }); + + it("adds px values numerically", () => { + expect(composeTranslate("5px 6px", "10px", "20px")).toBe("15px 26px"); + expect(composeTranslate("-5.5px 6px", "10px", "-20px")).toBe("4.5px -14px"); + }); + + it("treats a single-part original as x-only", () => { + expect(composeTranslate("5px", "10px", "20px")).toBe("15px 20px"); + }); + + it("falls back to calc() for non-px units and preserves z", () => { + expect(composeTranslate("10% 6px", "10px", "20px")).toBe("calc(10% + 10px) 26px"); + expect(composeTranslate("1px 2px 3px", "10px", "20px")).toBe("11px 22px 3px"); + }); +}); + +describe("applyPositionEdits", () => { + it("ignores unmarked elements", () => { + const el = makeElement({ "data-x": "100", "data-y": "50" }); + expect(applyPositionEdits(document)).toBe(0); + expect(el.style.getPropertyValue("translate")).toBe(""); + el.remove(); + }); + + it("applies the delta between data-x/y and the captured baseline", () => { + const el = makeElement({ + "data-x": "150", + "data-y": "-30", + [EDIT_BASE_X_ATTR]: "100", + [EDIT_BASE_Y_ATTR]: "20", + }); + expect(applyPositionEdits(document)).toBe(1); + expect(el.style.getPropertyValue("translate")).toBe("50px -50px"); + expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe(""); + el.remove(); + }); + + it("treats missing data-x/y or baseline attributes as 0", () => { + const el = makeElement({ "data-x": "40", [EDIT_BASE_X_ATTR]: "0" }); + applyPositionEdits(document); + expect(el.style.getPropertyValue("translate")).toBe("40px 0px"); + el.remove(); + }); + + it("composes with a pre-existing inline translate and stays idempotent", () => { + const el = makeElement( + { "data-x": "10", "data-y": "20", [EDIT_BASE_X_ATTR]: "0", [EDIT_BASE_Y_ATTR]: "0" }, + "translate: 5px 6px", + ); + applyPositionEdits(document); + expect(el.style.getPropertyValue("translate")).toBe("15px 26px"); + expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("5px 6px"); + // Second application must not compound. + applyPositionEdits(document); + expect(el.style.getPropertyValue("translate")).toBe("15px 26px"); + el.remove(); + }); + + it("recomputes from the same baseline after data-x changes", () => { + const el = makeElement({ + "data-x": "10", + "data-y": "0", + [EDIT_BASE_X_ATTR]: "0", + [EDIT_BASE_Y_ATTR]: "0", + }); + applyPositionEdits(document); + expect(el.style.getPropertyValue("translate")).toBe("10px 0px"); + el.setAttribute("data-x", "70"); + applyPositionEdits(document); + expect(el.style.getPropertyValue("translate")).toBe("70px 0px"); + el.remove(); + }); + + it("never re-captures the original translate once set", () => { + const el = makeElement({ + "data-x": "10", + "data-y": "0", + [EDIT_BASE_X_ATTR]: "0", + [EDIT_BASE_Y_ATTR]: "0", + [EDIT_ORIGINAL_TRANSLATE_ATTR]: "3px 4px", + }); + applyPositionEdits(document); + expect(el.style.getPropertyValue("translate")).toBe("13px 4px"); + el.remove(); + }); + + it("counts and applies multiple marked elements", () => { + const a = makeElement({ "data-x": "1", [EDIT_BASE_X_ATTR]: "0" }); + const b = makeElement({ "data-y": "2", [EDIT_BASE_Y_ATTR]: "0" }); + expect(applyPositionEdits(document)).toBe(2); + a.remove(); + b.remove(); + }); + + it("skips re-apply when the written translate was consumed externally (GSAP fold)", () => { + const el = makeElement({ + "data-x": "10", + "data-y": "20", + [EDIT_BASE_X_ATTR]: "0", + [EDIT_BASE_Y_ATTR]: "0", + }); + applyPositionEdits(document); + expect(el.style.getPropertyValue("translate")).toBe("10px 20px"); + // GSAP folding the translate into its cached transform writes "none". + el.style.setProperty("translate", "none"); + applyPositionEdits(document); + // Re-setting would double the offset on non-animated axes — must skip. + expect(el.style.getPropertyValue("translate")).toBe("none"); + el.remove(); + }); + + it("force re-applies over a clobbered translate (editor commit path)", () => { + const el = makeElement({ + "data-x": "10", + "data-y": "20", + [EDIT_BASE_X_ATTR]: "0", + [EDIT_BASE_Y_ATTR]: "0", + }); + applyPositionEditToElement(el); + // A drag draft overwrites the translate; the commit must recompute. + el.style.setProperty("translate", "999px 999px"); + el.setAttribute("data-x", "30"); + applyPositionEditToElement(el, { force: true }); + expect(el.style.getPropertyValue("translate")).toBe("30px 20px"); + el.remove(); + }); +}); diff --git a/packages/core/src/runtime/positionEdits.ts b/packages/core/src/runtime/positionEdits.ts new file mode 100644 index 0000000000..0fabec7635 --- /dev/null +++ b/packages/core/src/runtime/positionEdits.ts @@ -0,0 +1,167 @@ +// 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}]`); + let applied = 0; + for (let i = 0; i < marked.length; i++) { + const el = marked[i]; + if (!(el instanceof HTMLElement)) continue; + applyPositionEditToElement(el); + applied += 1; + } + return applied; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index e400fc1acc..bf6eb101e7 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -13,7 +13,11 @@ "outDir": "./dist", "rootDir": "./src" }, - "files": ["src/runtime/clipTree.ts", "src/runtime/mediaVolumeEnvelope.ts"], + "files": [ + "src/runtime/clipTree.ts", + "src/runtime/mediaVolumeEnvelope.ts", + "src/runtime/positionEdits.ts" + ], "include": ["src/**/*"], "exclude": [ "node_modules", diff --git a/packages/engine/scripts/test-runtime-position-edits-browser.ts b/packages/engine/scripts/test-runtime-position-edits-browser.ts new file mode 100644 index 0000000000..048d5243ef --- /dev/null +++ b/packages/engine/scripts/test-runtime-position-edits-browser.ts @@ -0,0 +1,271 @@ +// fallow-ignore-file unused-file code-duplication complexity +/** + * Browser acceptance test: SDK moveElement edits survive GSAP animation + * per-axis (the AI Studio embedded-editor per-axis loss bug). + * + * Launches headless Chrome with real GSAP + the real runtime IIFE, loads a + * fixture whose elements carry committed moveElement state (data-x/data-y + + * data-hf-edit-base-x/y), seeks the timeline across its range, and asserts + * the rendered position reflects the edit on BOTH axes at every sample: + * - an X-animated element keeps its edited offset while X animates + * - a Y-animated element keeps its edited offset while Y animates + * - a static element keeps both + * + * Runs in the plain embedded runtime — no Studio shell, no manual-edits + * render script — matching what third-party SDK consumers load. + * + * Requires: puppeteer + gsap (monorepo deps, dynamically resolved; skips + * with a notice when unavailable). + * Run: cd packages/engine && npx tsx scripts/test-runtime-position-edits-browser.ts + */ + +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { resolve as resolvePath, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildHyperframesRuntimeScript } from "../../core/src/inline-scripts/hyperframesRuntime.engine"; + +const thisDir = dirname(fileURLToPath(import.meta.url)); + +function assert(condition: unknown, message: string): void { + if (!condition) { + throw new Error(`FAIL: ${message}`); + } +} + +function loadGsapSource(): string | null { + const req = createRequire(import.meta.url); + // gsap is a dep of studio / player / sdk-playground, hoisted in the + // workspace store — resolve through whichever package has it. + for (const pkg of ["studio", "player", "sdk-playground"]) { + try { + const path = req.resolve("gsap/dist/gsap.min.js", { + paths: [resolvePath(thisDir, `../../${pkg}`)], + }); + return readFileSync(path, "utf8"); + } catch { + // try the next package + } + } + return null; +} + +interface Sample { + t: number; + ax: { x: number; y: number }; + ay: { x: number; y: number }; + axy: { x: number; y: number }; + ts: { x: number; y: number }; + st: { x: number; y: number }; +} + +async function main(): Promise { + let puppeteer; + try { + puppeteer = (await import("puppeteer")).default; + } catch { + console.log( + JSON.stringify({ + event: "runtime_position_edits_browser_test_skipped", + reason: "puppeteer not available", + }), + ); + return; + } + + const gsapSource = loadGsapSource(); + if (gsapSource === null) { + console.log( + JSON.stringify({ + event: "runtime_position_edits_browser_test_skipped", + reason: "gsap not available", + }), + ); + return; + } + + const runtimeSource = buildHyperframesRuntimeScript({ minify: false }); + assert( + runtimeSource !== null, + "buildHyperframesRuntimeScript returned null — entry.ts not found", + ); + + // Committed moveElement state: every element moved by (50, -70). data-x/y + // hold the post-edit values; data-hf-edit-base-x/y hold the pre-edit ones + // (absent → "0"), exactly as handleMoveElement serializes them. + const html = ` + +
+
+
+
+
+
+
+ + + +`; + + const browser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + + try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: "networkidle0", timeout: 10000 }); + + // The runtime applies position edits after it binds the timeline — wait + // for the translate to land on a marked element. String form: tsx/esbuild + // injects a __name helper into serialized closures that the page lacks. + await page.waitForFunction( + `(function () { + var el = document.getElementById("ax"); + return el !== null && getComputedStyle(el).translate !== "none"; + })()`, + { timeout: 10000 }, + ); + + const sampleScript = `(function (time) { + if (window.__player && typeof window.__player.renderSeek === "function") { + window.__player.renderSeek(time); + } else if (window.__hf && typeof window.__hf.seek === "function") { + window.__hf.seek(time); + } else { + throw new Error("no runtime seek surface (__player.renderSeek / __hf.seek)"); + } + function read(id) { + var el = document.getElementById(id); + if (!el) throw new Error("missing #" + id); + var cs = getComputedStyle(el); + var m = new DOMMatrix(cs.transform === "none" ? "" : cs.transform); + var parts = cs.translate === "none" ? [] : cs.translate.split(" "); + var tx = parts.length > 0 ? parseFloat(parts[0]) : 0; + var ty = parts.length > 1 ? parseFloat(parts[1]) : 0; + return { x: m.m41 + tx, y: m.m42 + ty }; + } + return { + t: time, + ax: read("ax"), + ay: read("ay"), + axy: read("axy"), + ts: read("ts"), + st: read("st"), + }; + })`; + + const samples: Sample[] = []; + for (const t of [0, 1, 2.5, 4]) { + const sample = (await page.evaluate(`(${sampleScript})(${t})`)) as Sample; + samples.push(sample); + } + + const close = (actual: number, expected: number): boolean => Math.abs(actual - expected) <= 0.5; + + for (const s of samples) { + // X-animated: x = animation (100·t) + edit (50); y = edit (−70). + assert( + close(s.ax.x, 100 * s.t + 50), + `t=${s.t}: X-animated element x should be ${100 * s.t + 50}, got ${s.ax.x}`, + ); + assert(close(s.ax.y, -70), `t=${s.t}: X-animated element y should keep -70, got ${s.ax.y}`); + // Y-animated: y = animation (75·t) + edit (−70); x = edit (50). + assert(close(s.ay.x, 50), `t=${s.t}: Y-animated element x should keep 50, got ${s.ay.x}`); + assert( + close(s.ay.y, 75 * s.t - 70), + `t=${s.t}: Y-animated element y should be ${75 * s.t - 70}, got ${s.ay.y}`, + ); + // Both-axis-animated: both axes = animation + edit (the shape that + // originated the per-axis loss bug). + assert( + close(s.axy.x, 100 * s.t + 50), + `t=${s.t}: XY-animated element x should be ${100 * s.t + 50}, got ${s.axy.x}`, + ); + assert( + close(s.axy.y, 75 * s.t - 70), + `t=${s.t}: XY-animated element y should be ${75 * s.t - 70}, got ${s.axy.y}`, + ); + // tl.set at t=1.0: before it fires, position = edit only; after, set + edit. + const setX = s.t >= 1.0 ? 200 : 0; + const setY = s.t >= 1.0 ? 100 : 0; + assert( + close(s.ts.x, setX + 50), + `t=${s.t}: tl.set element x should be ${setX + 50}, got ${s.ts.x}`, + ); + assert( + close(s.ts.y, setY - 70), + `t=${s.t}: tl.set element y should be ${setY - 70}, got ${s.ts.y}`, + ); + // Static: both axes hold the edit. + assert(close(s.st.x, 50), `t=${s.t}: static element x should be 50, got ${s.st.x}`); + assert(close(s.st.y, -70), `t=${s.t}: static element y should be -70, got ${s.st.y}`); + } + + // GSAP-free composition: no window.gsap, no timelines — the edit must + // still render (applied at runtime init, not only at timeline bind). + const gsapFreeHtml = ` + +
+
+
+ +`; + + const page2 = await browser.newPage(); + await page2.setContent(gsapFreeHtml, { waitUntil: "networkidle0", timeout: 10000 }); + await page2.waitForFunction( + `(function () { + var el = document.getElementById("st"); + return el !== null && getComputedStyle(el).translate !== "none"; + })()`, + { timeout: 10000 }, + ); + const gsapFree = (await page2.evaluate( + `(function () { + var cs = getComputedStyle(document.getElementById("st")); + return { translate: cs.translate }; + })()`, + )) as { translate: string }; + assert( + gsapFree.translate === "50px -70px", + `GSAP-free composition should render the edit as translate 50px -70px, got ${gsapFree.translate}`, + ); + + console.log( + JSON.stringify({ + event: "runtime_position_edits_browser_test_passed", + samples, + gsapFree, + }), + ); + } finally { + await browser.close(); + } +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/packages/sdk/src/adapters/iframe.test.ts b/packages/sdk/src/adapters/iframe.test.ts index e274800ae5..2d7c1afb7a 100644 --- a/packages/sdk/src/adapters/iframe.test.ts +++ b/packages/sdk/src/adapters/iframe.test.ts @@ -218,12 +218,12 @@ interface FakeStyle { } interface FakeDomEl { - "data-hf-id": string; - "data-x": string | null; - "data-y": string | null; + _attrs: Record; style: FakeStyle; isConnected: boolean; getAttribute(name: string): string | null; + setAttribute(name: string, value: string): void; + hasAttribute(name: string): boolean; querySelector(sel: string): FakeDomEl | null; } @@ -240,17 +240,21 @@ function fakeDomEl(id: string, dataX: string | null, dataY: string | null): Fake delete this._props[name]; }, }; + const attrs: Record = { "data-hf-id": id }; + if (dataX !== null) attrs["data-x"] = dataX; + if (dataY !== null) attrs["data-y"] = dataY; const el: FakeDomEl = { - "data-hf-id": id, - "data-x": dataX, - "data-y": dataY, + _attrs: attrs, style, isConnected: true, getAttribute(name) { - if (name === "data-x") return this["data-x"]; - if (name === "data-y") return this["data-y"]; - if (name === "data-hf-id") return this["data-hf-id"]; - return null; + return this._attrs[name] ?? null; + }, + setAttribute(name, value) { + this._attrs[name] = value; + }, + hasAttribute(name) { + return name in this._attrs; }, querySelector(_sel: string) { return null; @@ -316,6 +320,55 @@ describe("IframePreviewAdapter draft / commit / cancel", () => { }); }); + it("commitPreview mirrors the move onto the live element and applies the translate", () => { + const el = fakeDomEl("hf-abc", "100", "200"); + const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn()); + + adapter.applyDraft("hf-abc", { dx: 30, dy: -20 }); + adapter.commitPreview(); + + expect(el.getAttribute("data-x")).toBe("130"); + expect(el.getAttribute("data-y")).toBe("180"); + // Baseline captured from the pre-drag values. + expect(el.getAttribute("data-hf-edit-base-x")).toBe("100"); + expect(el.getAttribute("data-hf-edit-base-y")).toBe("200"); + // Final translate = delta from the baseline, held without a reload. + expect(el.getAttribute("data-hf-edit-original-translate")).toBe(""); + expect(el.style.getPropertyValue("translate")).toBe("30px -20px"); + + // A second drag composes from the committed state and keeps the baseline. + adapter.applyDraft("hf-abc", { dx: 10, dy: 10 }); + expect(el.style.getPropertyValue("translate")).toBe("40px -10px"); + adapter.commitPreview(); + expect(el.getAttribute("data-x")).toBe("140"); + expect(el.getAttribute("data-hf-edit-base-x")).toBe("100"); + expect(el.style.getPropertyValue("translate")).toBe("40px -10px"); + }); + + it("applyDraft translates the element live and cancelPreview restores it", () => { + const el = fakeDomEl("hf-abc", "0", "0"); + el.style.setProperty("translate", "5px 6px"); + const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn()); + + adapter.applyDraft("hf-abc", { dx: 30, dy: -20 }); + expect(el.style.getPropertyValue("translate")).toBe("35px -14px"); + + adapter.cancelPreview(); + expect(el.style.getPropertyValue("translate")).toBe("5px 6px"); + expect(el.getAttribute("data-hf-edit-base-x")).toBeNull(); + }); + + it("cancelPreview removes a draft translate when there was none before", () => { + const el = fakeDomEl("hf-abc", "0", "0"); + const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn()); + + adapter.applyDraft("hf-abc", { dx: 30 }); + expect(el.style.getPropertyValue("translate")).toBe("30px 0px"); + + adapter.cancelPreview(); + expect(el.style.getPropertyValue("translate")).toBe(""); + }); + it("applyDraft reuses the cached element across repeated calls (no re-query)", () => { const el = fakeDomEl("hf-abc", "0", "0"); let queryCount = 0; @@ -344,42 +397,83 @@ describe("IframePreviewAdapter draft / commit / cancel", () => { adapter.commitPreview(); }); - it("cancelPreview clears draft vars without dispatching", () => { + it("cancelPreview reverts the draft translate without dispatching", () => { const dispatch = vi.fn(); const el = fakeDomEl("hf-abc", "100", "200"); const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); adapter.applyDraft("hf-abc", { dx: 30, dy: 20 }); + expect(el.style.getPropertyValue("translate")).toBe("30px 20px"); adapter.cancelPreview(); expect(dispatch).not.toHaveBeenCalled(); - // CSS vars cleared - expect(el.style.getPropertyValue("--hf-studio-dx")).toBe(""); - expect(el.style.getPropertyValue("--hf-studio-dy")).toBe(""); + expect(el.style.getPropertyValue("translate")).toBe(""); }); - it("commitPreview clears draft vars after dispatching", () => { + it("second commitPreview after first is a no-op (draft cleared)", () => { const dispatch = vi.fn(); const el = fakeDomEl("hf-abc", "0", "0"); const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); adapter.applyDraft("hf-abc", { dx: 10, dy: 5 }); adapter.commitPreview(); + adapter.commitPreview(); - expect(el.style.getPropertyValue("--hf-studio-dx")).toBe(""); - expect(el.style.getPropertyValue("--hf-studio-dy")).toBe(""); + expect(dispatch).toHaveBeenCalledTimes(1); }); - it("second commitPreview after first is a no-op (draft cleared)", () => { - const dispatch = vi.fn(); + it("switching applyDraft to a new id reverts the abandoned element", () => { + const elA = fakeDomEl("hf-a", "0", "0"); + const elB = fakeDomEl("hf-b", "0", "0"); + const iframe = { + contentDocument: { + querySelector(sel: string) { + return sel.includes("hf-a") ? elA : elB; + }, + }, + } as unknown as HTMLIFrameElement; + const adapter = createIframePreviewAdapter(iframe, vi.fn()); + + adapter.applyDraft("hf-a", { dx: 80, dy: 0 }); + expect(elA.style.getPropertyValue("translate")).toBe("80px 0px"); + + adapter.applyDraft("hf-b", { dx: 10, dy: 10 }); + // The abandoned element is restored; the delta does not carry over. + expect(elA.style.getPropertyValue("translate")).toBe(""); + expect(elB.style.getPropertyValue("translate")).toBe("10px 10px"); + }); + + it("commitPreview reverts the draft translate when dispatch throws", () => { const el = fakeDomEl("hf-abc", "0", "0"); + el.style.setProperty("translate", "5px 6px"); + const dispatch = vi.fn(() => { + throw new Error("element_not_found"); + }); const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); - adapter.applyDraft("hf-abc", { dx: 10, dy: 5 }); - adapter.commitPreview(); - adapter.commitPreview(); + adapter.applyDraft("hf-abc", { dx: 30, dy: 20 }); + expect(() => adapter.commitPreview()).toThrow("element_not_found"); + expect(el.style.getPropertyValue("translate")).toBe("5px 6px"); + expect(el.getAttribute("data-hf-edit-base-x")).toBeNull(); + }); - expect(dispatch).toHaveBeenCalledTimes(1); + it("cancelPreview does not promote a computed (stylesheet) translate to inline", () => { + const el = fakeDomEl("hf-abc", "0", "0"); + // Simulate a stylesheet-authored translate visible only via computed style. + (el as unknown as { ownerDocument: unknown }).ownerDocument = { + defaultView: { + getComputedStyle: () => ({ getPropertyValue: () => "-50% -50%" }), + }, + }; + const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn()); + + adapter.applyDraft("hf-abc", { dx: 30, dy: 20 }); + // Draft composes onto the computed baseline (calc for non-px units). + expect(el.style.getPropertyValue("translate")).toBe("calc(-50% + 30px) calc(-50% + 20px)"); + + adapter.cancelPreview(); + // Inline translate removed — the stylesheet value stays authoritative. + expect(el.style.getPropertyValue("translate")).toBe(""); }); }); diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts index e45c7acd90..9143e17461 100644 --- a/packages/sdk/src/adapters/iframe.ts +++ b/packages/sdk/src/adapters/iframe.ts @@ -28,14 +28,17 @@ * here — gated on a perf spike. */ +import { + EDIT_BASE_X_ATTR, + EDIT_BASE_Y_ATTR, + EDIT_ORIGINAL_TRANSLATE_ATTR, + applyPositionEditToElement, + composeTranslate, + readCurrentTranslate, +} from "@hyperframes/core/runtime/position-edits"; import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js"; import type { EditOp } from "../types.js"; -// ─── CSS var names written onto elements during drag ───────────────────────── - -const VAR_DX = "--hf-studio-dx"; -const VAR_DY = "--hf-studio-dy"; - // ─── Pure resolver (testable without a browser) ─────────────────────────────── /** @@ -492,6 +495,21 @@ class IframePreviewAdapter implements PreviewAdapter { /** Tracked id and element for the in-progress drag. */ private _draftId: string | null = null; private _draftEl: HTMLElement | null = null; + /** Accumulated drag deltas from applyDraft calls. */ + private _draftDx = 0; + private _draftDy = 0; + /** + * The element's effective `translate` when the drag started (inline value, + * or computed when no inline one was set; "" = none). Drafts compose onto + * this. + */ + private _draftPrevTranslate: string | null = null; + /** + * The element's raw INLINE `translate` when the drag started ("" = not + * inline). Reverts restore exactly this, so a stylesheet-authored translate + * is never promoted to a permanent inline style. + */ + private _draftPrevInlineTranslate: string | null = null; constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) { this.iframe = iframe; @@ -543,73 +561,159 @@ class IframePreviewAdapter implements PreviewAdapter { } /** - * Write draft CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto - * the target element inside the iframe at 60fps. The composition's CSS uses - * these vars to visually translate the element without touching the model. + * Visually translate the target element inside the iframe at 60fps without + * touching the model: sets the element's `translate` to its pre-drag value + * composed with the accumulated delta. `translate` set after GSAP's first + * parse is untouched by seeks, so this renders correctly on animated + * elements too. (The `--hf-studio-dx/dy` custom properties are no longer + * written — compositions with the authored Studio drag-bridge CSS would + * move by twice the delta if both channels applied.) * - * Calling applyDraft with a new id replaces the tracked element (does not - * cancel the prior draft — call cancelPreview first if switching targets). + * Calling applyDraft with a new id switches the tracked element, reverting + * the previous element's draft translate first. * * width/height in DraftProps are not yet wired (resize → setStyle, future op). */ applyDraft(id: string, props: DraftProps): void { + const el = this._resolveDraftElement(id); + if (!el) return; + + if (props.dx !== undefined) this._draftDx = props.dx; + if (props.dy !== undefined) this._draftDy = props.dy; + + el.style.setProperty( + "translate", + composeTranslate(this._draftPrevTranslate ?? "", `${this._draftDx}px`, `${this._draftDy}px`), + ); + } + + /** + * Resolve and track the drag target. Reuses the tracked element across the + * 60fps drag; only re-queries when the id changes or the cached node + * detached (e.g. an iframe reload mid-drag). Switching to a different + * element reverts the previous one's draft first, then captures the new + * element's pre-drag translate. + */ + private _resolveDraftElement(id: string): HTMLElement | null { const doc = this.iframe.contentDocument; - if (!doc) return; + if (!doc) return null; - // Reuse the tracked element across the 60fps drag; only re-query when the id - // changes or the cached node detached (e.g. an iframe reload mid-drag). const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null; const el = cached ?? doc.querySelector( `[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`, ); - if (!el) return; - + if (!el) return null; + + if (el !== this._draftEl) { + // Abandoning a prior target mid-drag must not leave it displaced. + this._revertDraftTranslate(); + this._draftDx = 0; + this._draftDy = 0; + this._draftPrevTranslate = readCurrentTranslate(el); + const inline = el.style.getPropertyValue("translate").trim(); + this._draftPrevInlineTranslate = inline === "none" ? "" : inline; + } this._draftId = id; this._draftEl = el; - - if (props.dx !== undefined) el.style.setProperty(VAR_DX, String(props.dx)); - if (props.dy !== undefined) el.style.setProperty(VAR_DY, String(props.dy)); + return el; } /** * Read the accumulated draft deltas, derive a moveElement op, dispatch it, - * then clear the CSS vars and draft state. + * then clear the draft state. * - * No-ops when: + * No-ops (reverting any draft translate) when: * - No applyDraft was called (nothing to commit) * - No dispatch callback was provided at construction + * + * If dispatch throws (e.g. the model no longer has the element), the draft + * translate is reverted and the error propagates — the element is never + * left displaced by an uncommitted draft. */ commitPreview(): void { if (!this._draftId || !this._draftEl || !this._dispatch) { + this._revertDraftTranslate(); this._clearDraft(); return; } const el = this._draftEl; - const dx = parseFloat(el.style.getPropertyValue(VAR_DX) || "0") || 0; - const dy = parseFloat(el.style.getPropertyValue(VAR_DY) || "0") || 0; const dataX = el.getAttribute("data-x"); const dataY = el.getAttribute("data-y"); - const { x, y } = computeDraftPosition(dataX, dataY, dx, dy); + const { x, y } = computeDraftPosition(dataX, dataY, this._draftDx, this._draftDy); - this._dispatch({ type: "moveElement", target: this._draftId, x, y }); + try { + this._dispatch({ type: "moveElement", target: this._draftId, x, y }); + } catch (err) { + this._revertDraftTranslate(); + this._clearDraft(); + throw err; + } + this._mirrorCommittedMove(el, dataX, dataY, x, y); this._clearDraft(); } - /** Revert draft CSS vars without dispatching any op. */ + /** + * Mirror a committed move onto the live element so the position holds + * without a document reload — same attributes handleMoveElement writes + * into the model, rendered by the runtime's position-edit translate. + * + * The pre-edit translate is stamped from the value captured at drag start + * (the element's current inline translate is the draft-composed one, which + * must not be mistaken for the original), then the final translate is + * recomputed the same way the runtime does at bind time. + */ + private _mirrorCommittedMove( + el: HTMLElement, + dataX: string | null, + dataY: string | null, + x: number, + y: number, + ): void { + if (el.getAttribute(EDIT_BASE_X_ATTR) === null) { + el.setAttribute(EDIT_BASE_X_ATTR, dataX ?? "0"); + } + if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) { + el.setAttribute(EDIT_BASE_Y_ATTR, dataY ?? "0"); + } + if (el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) === null) { + el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, this._draftPrevTranslate ?? ""); + } + el.setAttribute("data-x", String(x)); + el.setAttribute("data-y", String(y)); + applyPositionEditToElement(el, { force: true }); + } + + /** Revert the draft translate without dispatching any op. */ cancelPreview(): void { + this._revertDraftTranslate(); this._clearDraft(); } - private _clearDraft(): void { - if (this._draftEl) { - this._draftEl.style.removeProperty(VAR_DX); - this._draftEl.style.removeProperty(VAR_DY); + /** + * Restore the element's pre-drag INLINE `translate` (removing it when there + * was none, so a stylesheet-authored translate is never promoted to inline). + * NOT called on a successful commit — the committed position-edit translate + * is recomputed onto the element by _mirrorCommittedMove. + */ + private _revertDraftTranslate(): void { + if (!this._draftEl || this._draftPrevInlineTranslate === null) return; + if (this._draftPrevInlineTranslate === "") { + this._draftEl.style.removeProperty("translate"); + } else { + this._draftEl.style.setProperty("translate", this._draftPrevInlineTranslate); } + } + + private _clearDraft(): void { this._draftId = null; this._draftEl = null; + this._draftDx = 0; + this._draftDy = 0; + this._draftPrevTranslate = null; + this._draftPrevInlineTranslate = null; } // Selection ----------------------------------------------------------------- diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 82fa26aa4f..1e814d5dab 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -974,6 +974,33 @@ describe("moveElement", () => { expect(el.getAttribute("data-x")).toBe("50"); expect(el.getAttribute("data-y")).toBe("75"); }); + + it("captures the pre-edit baseline on first move only", () => { + const parsed = fresh(); + const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element; + el.setAttribute("data-x", "50"); + applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 }); + // Baseline = the values before the first edit (absent data-y → "0"). + expect(el.getAttribute("data-hf-edit-base-x")).toBe("50"); + expect(el.getAttribute("data-hf-edit-base-y")).toBe("0"); + // A second move keeps the original baseline. + applyOp(parsed, { type: "moveElement", target: "hf-title", x: 300, y: 400 }); + expect(el.getAttribute("data-hf-edit-base-x")).toBe("50"); + expect(el.getAttribute("data-hf-edit-base-y")).toBe("0"); + expect(el.getAttribute("data-x")).toBe("300"); + expect(el.getAttribute("data-y")).toBe("400"); + }); + + it("inverse of the first move removes the baseline attributes", () => { + const parsed = fresh(); + const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element; + const result = applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 }); + applyPatchesToDocument(parsed, result.inverse); + expect(el.getAttribute("data-hf-edit-base-x")).toBeNull(); + expect(el.getAttribute("data-hf-edit-base-y")).toBeNull(); + expect(el.getAttribute("data-x")).toBeNull(); + expect(el.getAttribute("data-y")).toBeNull(); + }); }); // ─── validateOp (can()) ─────────────────────────────────────────────────────── diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 6580506dd1..3f2c5c69f0 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -51,6 +51,7 @@ import { } 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 { @@ -342,11 +343,34 @@ function handleMoveElement( ): 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. - const rx = handleSetAttribute(parsed, ids, "data-x", String(x)); - const ry = handleSetAttribute(parsed, ids, "data-y", String(y)); + // + // 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: [...rx.forward, ...ry.forward], - inverse: [...ry.inverse, ...rx.inverse], + forward: parts.flatMap((p) => p.forward), + inverse: parts + .slice() + .reverse() + .flatMap((p) => p.inverse), }; }