fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate (#1875)

* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate

A committed moveElement wrote data-x/data-y but nothing rendered them:
hosts shimmed CSS translate, which GSAP folds into the cached transform
at first parse and then discards on the animated axis at every seek —
dragging an animated element kept only the un-animated axis.

Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is
never read, folded, or cleared across seeks and composes natively with
the animated transform. So:

- moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y)
- the runtime (new core runtime/positionEdits.ts, applied at timeline
  bind — after GSAP parse) renders translate = (data-x − base), a pure
  delta that composes with GSAP tweens, tl.set positions, and CSS alike
- applyDraft now drives the drag preview through the same translate
  channel (the --hf-studio-dx/dy vars had no consumer outside authored
  Studio bridges), and commitPreview mirrors the committed move onto
  the live element so it holds without an srcdoc reload

Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts
(real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated,
Y-animated, and static elements hold both edited axes across the full
seek range. New subpath export @hyperframes/core/runtime/position-edits.

Known limitation (documented): a tween created lazily at runtime that
first-parses a marked element after apply folds the edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): harden position-edit rendering and the drag draft channel

Fixes six issues from adversarial review of the moveElement stack:

- Runtime: apply position edits at init as well as at timeline bind, so
  committed moves render in compositions with no usable GSAP timeline
  (CSS/WAAPI-animated or fully static) — previously the apply was
  unreachable outside the boundDuration > 0 bind branch and the edit
  silently vanished from reloads and renders.
- Runtime: guard bind-path re-apply against post-fold double-apply — if
  the previously written translate was consumed externally (a lazily
  created tween folding it into GSAP's cached transform), skip instead
  of re-setting it on top ({force} escape hatch for editor commits).
- Adapter: stop writing the --hf-studio-dx/dy custom properties during
  drags — compositions with the documented var-consuming drag-bridge
  CSS moved by twice the pointer delta (var transform + new inline
  translate). The inline translate is now the only draft channel;
  deltas accumulate in adapter fields. Docs updated to match.
- Adapter: switching applyDraft to a new id reverts the abandoned
  element's draft translate instead of leaving it displaced with no op.
- Adapter: cancelPreview restores the raw inline translate (removing it
  when there was none), so a stylesheet-authored translate is never
  promoted to a permanent inline style.
- Adapter: commitPreview reverts the draft and clears state when
  dispatch throws, instead of leaving the element shifted by an
  uncommitted draft.

Cleanups: reuse readCurrentTranslate from the core module (was a
verbatim copy), drop the dead __hfApplyPositionEdits window hook.
Browser acceptance test now also covers the GSAP-free composition path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry

Addresses PR #1875 review feedback (Rames, Miga):

- Prime the element's GSAP transform parse (gsap.getProperty) before the
  first translate apply — positioned tl.set()s and tweens that first
  RENDER after the apply now reuse the cache instead of folding the edit.
  This closes the lazy-first-parse fold-loss for any page where GSAP is
  loaded at apply time; the residual limitation is GSAP itself loading
  after the apply. Proven by the extended browser acceptance test.
- Emit position_edit_fold_skipped analytics at the fold-guard skip site
  so the residual degradation is observable instead of silent.
- Browser acceptance test: add a both-axis-animated element (the shape
  that originated the per-axis loss) and a positioned tl.set() element,
  asserted across the full seek range.
- Simplify the num() null guard (review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 20:44:27 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent a2677ca730
commit 74faa4b2a4
13 changed files with 929 additions and 64 deletions
+1 -1
View File
@@ -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. 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 ```typescript
let dragging = false; let dragging = false;
+3 -3
View File
@@ -100,15 +100,15 @@ interface PreviewAdapter {
</ParamField> </ParamField>
<ParamField path="applyDraft" type="(id: string, props: DraftProps) => void"> <ParamField path="applyDraft" type="(id: string, props: DraftProps) => void">
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.
</ParamField> </ParamField>
<ParamField path="commitPreview" type="() => void"> <ParamField path="commitPreview" type="() => void">
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.
</ParamField> </ParamField>
<ParamField path="cancelPreview" type="() => void"> <ParamField path="cancelPreview" type="() => void">
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.
</ParamField> </ParamField>
<ParamField path="select" type="(ids: string[], opts?: { additive?: boolean }) => void"> <ParamField path="select" type="(ids: string[], opts?: { additive?: boolean }) => void">
+10
View File
@@ -95,6 +95,12 @@
"import": "./src/runtime/clipTree.ts", "import": "./src/runtime/clipTree.ts",
"types": "./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": { "./runtime/lottie-readiness": {
"bun": "./src/lottieReadiness.ts", "bun": "./src/lottieReadiness.ts",
"node": "./dist/lottieReadiness.js", "node": "./dist/lottieReadiness.js",
@@ -269,6 +275,10 @@
"import": "./dist/runtime/clipTree.js", "import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts" "types": "./dist/runtime/clipTree.d.ts"
}, },
"./runtime/position-edits": {
"import": "./dist/runtime/positionEdits.js",
"types": "./dist/runtime/positionEdits.d.ts"
},
"./runtime/lottie-readiness": { "./runtime/lottie-readiness": {
"import": "./dist/lottieReadiness.js", "import": "./dist/lottieReadiness.js",
"types": "./dist/lottieReadiness.d.ts" "types": "./dist/lottieReadiness.d.ts"
+2 -1
View File
@@ -12,7 +12,8 @@ export type RuntimeAnalyticsEvent =
| "composition_paused" | "composition_paused"
| "composition_seeked" | "composition_seeked"
| "composition_ended" | "composition_ended"
| "element_picked"; | "element_picked"
| "position_edit_fold_skipped";
export type RuntimeAnalyticsProperties = Record<string, string | number | boolean | null>; export type RuntimeAnalyticsProperties = Record<string, string | number | boolean | null>;
+13
View File
@@ -28,6 +28,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver";
import { createClipTree } from "./clipTree"; import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader"; import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides"; import { applyCaptionOverrides } from "./captionOverrides";
import { applyPositionEdits } from "./positionEdits";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock"; import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport"; import { WebAudioTransport } from "./webAudioTransport";
@@ -72,6 +73,12 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {
export function initSandboxRuntimeModular(): void { export function initSandboxRuntimeModular(): void {
const state = createRuntimeState(); 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(); const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps; state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) { if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
@@ -1198,6 +1205,12 @@ export function initSandboxRuntimeModular(): void {
// during initial rebind (timing race on first load / soft reload). // during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply; const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn(); 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) { if (resolution.diagnostics) {
postRuntimeMessage({ postRuntimeMessage({
@@ -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<string, string>, 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();
});
});
+167
View File
@@ -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<HTMLElement, string>();
/**
* 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;
}
+5 -1
View File
@@ -13,7 +13,11 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "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/**/*"], "include": ["src/**/*"],
"exclude": [ "exclude": [
"node_modules", "node_modules",
@@ -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<void> {
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 = `<!DOCTYPE html>
<html><head><style>
body { margin: 0; }
.el { position: absolute; left: 0; top: 0; width: 40px; height: 40px; }
</style></head><body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="4">
<div id="ax" class="clip el" data-hf-id="hf-ax" data-start="0" data-duration="4"
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
<div id="ay" class="clip el" data-hf-id="hf-ay" data-start="0" data-duration="4"
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
<div id="axy" class="clip el" data-hf-id="hf-axy" data-start="0" data-duration="4"
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
<div id="ts" class="clip el" data-hf-id="hf-ts" data-start="0" data-duration="4"
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
<div id="st" class="clip el" data-hf-id="hf-st" data-start="0" data-duration="4"
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
</div>
<script>${gsapSource}</script>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
tl.fromTo("#ax", { x: 0 }, { x: 400, duration: 4, ease: "none" }, 0);
tl.fromTo("#ay", { y: 0 }, { y: 300, duration: 4, ease: "none" }, 0);
tl.fromTo("#axy", { x: 0, y: 0 }, { x: 400, y: 300, duration: 4, ease: "none" }, 0);
tl.set("#ts", { x: 200, y: 100 }, 1.0);
// #st is never targeted by GSAP.
window.__timelines.main = tl;
</script>
<script>${runtimeSource}</script>
</body></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 = `<!DOCTYPE html>
<html><head><style>
body { margin: 0; }
.el { position: absolute; left: 0; top: 0; width: 40px; height: 40px; }
</style></head><body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="2">
<div id="st" class="clip el" data-hf-id="hf-st" data-start="0" data-duration="2"
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
</div>
<script>${runtimeSource}</script>
</body></html>`;
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);
});
+120 -26
View File
@@ -218,12 +218,12 @@ interface FakeStyle {
} }
interface FakeDomEl { interface FakeDomEl {
"data-hf-id": string; _attrs: Record<string, string>;
"data-x": string | null;
"data-y": string | null;
style: FakeStyle; style: FakeStyle;
isConnected: boolean; isConnected: boolean;
getAttribute(name: string): string | null; getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
hasAttribute(name: string): boolean;
querySelector(sel: string): FakeDomEl | null; querySelector(sel: string): FakeDomEl | null;
} }
@@ -240,17 +240,21 @@ function fakeDomEl(id: string, dataX: string | null, dataY: string | null): Fake
delete this._props[name]; delete this._props[name];
}, },
}; };
const attrs: Record<string, string> = { "data-hf-id": id };
if (dataX !== null) attrs["data-x"] = dataX;
if (dataY !== null) attrs["data-y"] = dataY;
const el: FakeDomEl = { const el: FakeDomEl = {
"data-hf-id": id, _attrs: attrs,
"data-x": dataX,
"data-y": dataY,
style, style,
isConnected: true, isConnected: true,
getAttribute(name) { getAttribute(name) {
if (name === "data-x") return this["data-x"]; return this._attrs[name] ?? null;
if (name === "data-y") return this["data-y"]; },
if (name === "data-hf-id") return this["data-hf-id"]; setAttribute(name, value) {
return null; this._attrs[name] = value;
},
hasAttribute(name) {
return name in this._attrs;
}, },
querySelector(_sel: string) { querySelector(_sel: string) {
return null; 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)", () => { it("applyDraft reuses the cached element across repeated calls (no re-query)", () => {
const el = fakeDomEl("hf-abc", "0", "0"); const el = fakeDomEl("hf-abc", "0", "0");
let queryCount = 0; let queryCount = 0;
@@ -344,30 +397,17 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
adapter.commitPreview(); adapter.commitPreview();
}); });
it("cancelPreview clears draft vars without dispatching", () => { it("cancelPreview reverts the draft translate without dispatching", () => {
const dispatch = vi.fn(); const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "100", "200"); const el = fakeDomEl("hf-abc", "100", "200");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch); const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 }); adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
adapter.cancelPreview(); adapter.cancelPreview();
expect(dispatch).not.toHaveBeenCalled(); expect(dispatch).not.toHaveBeenCalled();
// CSS vars cleared expect(el.style.getPropertyValue("translate")).toBe("");
expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
});
it("commitPreview clears draft vars after dispatching", () => {
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();
expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
}); });
it("second commitPreview after first is a no-op (draft cleared)", () => { it("second commitPreview after first is a no-op (draft cleared)", () => {
@@ -381,6 +421,60 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1);
}); });
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: 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();
});
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("");
});
}); });
// ─── WS-G: alphaIsOpaque ────────────────────────────────────────────────────── // ─── WS-G: alphaIsOpaque ──────────────────────────────────────────────────────
+132 -28
View File
@@ -28,14 +28,17 @@
* here gated on a perf spike. * 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 { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
import type { EditOp } 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) ─────────────────────────────── // ─── Pure resolver (testable without a browser) ───────────────────────────────
/** /**
@@ -492,6 +495,21 @@ class IframePreviewAdapter implements PreviewAdapter {
/** Tracked id and element for the in-progress drag. */ /** Tracked id and element for the in-progress drag. */
private _draftId: string | null = null; private _draftId: string | null = null;
private _draftEl: HTMLElement | 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) { constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
this.iframe = iframe; this.iframe = iframe;
@@ -543,73 +561,159 @@ class IframePreviewAdapter implements PreviewAdapter {
} }
/** /**
* Write draft CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto * Visually translate the target element inside the iframe at 60fps without
* the target element inside the iframe at 60fps. The composition's CSS uses * touching the model: sets the element's `translate` to its pre-drag value
* these vars to visually translate the element without touching the model. * 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 * Calling applyDraft with a new id switches the tracked element, reverting
* cancel the prior draft call cancelPreview first if switching targets). * the previous element's draft translate first.
* *
* width/height in DraftProps are not yet wired (resize setStyle, future op). * width/height in DraftProps are not yet wired (resize setStyle, future op).
*/ */
applyDraft(id: string, props: DraftProps): void { 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; 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 cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null;
const el = const el =
cached ?? cached ??
doc.querySelector<HTMLElement>( doc.querySelector<HTMLElement>(
`[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`, `[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._draftId = id;
this._draftEl = el; this._draftEl = el;
return 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));
} }
/** /**
* Read the accumulated draft deltas, derive a moveElement op, dispatch it, * 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 applyDraft was called (nothing to commit)
* - No dispatch callback was provided at construction * - 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 { commitPreview(): void {
if (!this._draftId || !this._draftEl || !this._dispatch) { if (!this._draftId || !this._draftEl || !this._dispatch) {
this._revertDraftTranslate();
this._clearDraft(); this._clearDraft();
return; return;
} }
const el = this._draftEl; 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 dataX = el.getAttribute("data-x");
const dataY = el.getAttribute("data-y"); 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(); 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 { cancelPreview(): void {
this._revertDraftTranslate();
this._clearDraft(); this._clearDraft();
} }
/**
* 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 { private _clearDraft(): void {
if (this._draftEl) {
this._draftEl.style.removeProperty(VAR_DX);
this._draftEl.style.removeProperty(VAR_DY);
}
this._draftId = null; this._draftId = null;
this._draftEl = null; this._draftEl = null;
this._draftDx = 0;
this._draftDy = 0;
this._draftPrevTranslate = null;
this._draftPrevInlineTranslate = null;
} }
// Selection ----------------------------------------------------------------- // Selection -----------------------------------------------------------------
+27
View File
@@ -974,6 +974,33 @@ describe("moveElement", () => {
expect(el.getAttribute("data-x")).toBe("50"); expect(el.getAttribute("data-x")).toBe("50");
expect(el.getAttribute("data-y")).toBe("75"); 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()) ─────────────────────────────────────────────────────── // ─── validateOp (can()) ───────────────────────────────────────────────────────
+28 -4
View File
@@ -51,6 +51,7 @@ import {
} from "./patches.js"; } from "./patches.js";
import { upsertCssRule } from "./cssWriter.js"; import { upsertCssRule } from "./cssWriter.js";
import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids"; 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 { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { import {
@@ -342,11 +343,34 @@ function handleMoveElement(
): MutationResult { ): MutationResult {
// HF elements are positioned via data-x / data-y (parsed by htmlParser.ts, // HF elements are positioned via data-x / data-y (parsed by htmlParser.ts,
// emitted by hyperframes generator). CSS left/top is not the convention. // 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 { return {
forward: [...rx.forward, ...ry.forward], forward: parts.flatMap((p) => p.forward),
inverse: [...ry.inverse, ...rx.inverse], inverse: parts
.slice()
.reverse()
.flatMap((p) => p.inverse),
}; };
} }