mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Claude Fable 5
parent
a2677ca730
commit
74faa4b2a4
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 -----------------------------------------------------------------
|
||||||
|
|||||||
@@ -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()) ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user