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
@@ -218,12 +218,12 @@ interface FakeStyle {
|
||||
}
|
||||
|
||||
interface FakeDomEl {
|
||||
"data-hf-id": string;
|
||||
"data-x": string | null;
|
||||
"data-y": string | null;
|
||||
_attrs: Record<string, string>;
|
||||
style: FakeStyle;
|
||||
isConnected: boolean;
|
||||
getAttribute(name: string): string | null;
|
||||
setAttribute(name: string, value: string): void;
|
||||
hasAttribute(name: string): boolean;
|
||||
querySelector(sel: string): FakeDomEl | null;
|
||||
}
|
||||
|
||||
@@ -240,17 +240,21 @@ function fakeDomEl(id: string, dataX: string | null, dataY: string | null): Fake
|
||||
delete this._props[name];
|
||||
},
|
||||
};
|
||||
const attrs: Record<string, string> = { "data-hf-id": id };
|
||||
if (dataX !== null) attrs["data-x"] = dataX;
|
||||
if (dataY !== null) attrs["data-y"] = dataY;
|
||||
const el: FakeDomEl = {
|
||||
"data-hf-id": id,
|
||||
"data-x": dataX,
|
||||
"data-y": dataY,
|
||||
_attrs: attrs,
|
||||
style,
|
||||
isConnected: true,
|
||||
getAttribute(name) {
|
||||
if (name === "data-x") return this["data-x"];
|
||||
if (name === "data-y") return this["data-y"];
|
||||
if (name === "data-hf-id") return this["data-hf-id"];
|
||||
return null;
|
||||
return this._attrs[name] ?? null;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this._attrs[name] = value;
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return name in this._attrs;
|
||||
},
|
||||
querySelector(_sel: string) {
|
||||
return null;
|
||||
@@ -316,6 +320,55 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("commitPreview mirrors the move onto the live element and applies the translate", () => {
|
||||
const el = fakeDomEl("hf-abc", "100", "200");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
|
||||
adapter.commitPreview();
|
||||
|
||||
expect(el.getAttribute("data-x")).toBe("130");
|
||||
expect(el.getAttribute("data-y")).toBe("180");
|
||||
// Baseline captured from the pre-drag values.
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
|
||||
expect(el.getAttribute("data-hf-edit-base-y")).toBe("200");
|
||||
// Final translate = delta from the baseline, held without a reload.
|
||||
expect(el.getAttribute("data-hf-edit-original-translate")).toBe("");
|
||||
expect(el.style.getPropertyValue("translate")).toBe("30px -20px");
|
||||
|
||||
// A second drag composes from the committed state and keeps the baseline.
|
||||
adapter.applyDraft("hf-abc", { dx: 10, dy: 10 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
|
||||
adapter.commitPreview();
|
||||
expect(el.getAttribute("data-x")).toBe("140");
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBe("100");
|
||||
expect(el.style.getPropertyValue("translate")).toBe("40px -10px");
|
||||
});
|
||||
|
||||
it("applyDraft translates the element live and cancelPreview restores it", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
el.style.setProperty("translate", "5px 6px");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("35px -14px");
|
||||
|
||||
adapter.cancelPreview();
|
||||
expect(el.style.getPropertyValue("translate")).toBe("5px 6px");
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancelPreview removes a draft translate when there was none before", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), vi.fn());
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("30px 0px");
|
||||
|
||||
adapter.cancelPreview();
|
||||
expect(el.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("applyDraft reuses the cached element across repeated calls (no re-query)", () => {
|
||||
const el = fakeDomEl("hf-abc", "0", "0");
|
||||
let queryCount = 0;
|
||||
@@ -344,30 +397,17 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
|
||||
adapter.commitPreview();
|
||||
});
|
||||
|
||||
it("cancelPreview clears draft vars without dispatching", () => {
|
||||
it("cancelPreview reverts the draft translate without dispatching", () => {
|
||||
const dispatch = vi.fn();
|
||||
const el = fakeDomEl("hf-abc", "100", "200");
|
||||
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
|
||||
|
||||
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
|
||||
adapter.cancelPreview();
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
// CSS vars cleared
|
||||
expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
|
||||
expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
|
||||
});
|
||||
|
||||
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("");
|
||||
expect(el.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("second commitPreview after first is a no-op (draft cleared)", () => {
|
||||
@@ -381,6 +421,60 @@ describe("IframePreviewAdapter draft / commit / cancel", () => {
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,14 +28,17 @@
|
||||
* here — gated on a perf spike.
|
||||
*/
|
||||
|
||||
import {
|
||||
EDIT_BASE_X_ATTR,
|
||||
EDIT_BASE_Y_ATTR,
|
||||
EDIT_ORIGINAL_TRANSLATE_ATTR,
|
||||
applyPositionEditToElement,
|
||||
composeTranslate,
|
||||
readCurrentTranslate,
|
||||
} from "@hyperframes/core/runtime/position-edits";
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
import type { EditOp } from "../types.js";
|
||||
|
||||
// ─── CSS var names written onto elements during drag ─────────────────────────
|
||||
|
||||
const VAR_DX = "--hf-studio-dx";
|
||||
const VAR_DY = "--hf-studio-dy";
|
||||
|
||||
// ─── Pure resolver (testable without a browser) ───────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -492,6 +495,21 @@ class IframePreviewAdapter implements PreviewAdapter {
|
||||
/** Tracked id and element for the in-progress drag. */
|
||||
private _draftId: string | null = null;
|
||||
private _draftEl: HTMLElement | null = null;
|
||||
/** Accumulated drag deltas from applyDraft calls. */
|
||||
private _draftDx = 0;
|
||||
private _draftDy = 0;
|
||||
/**
|
||||
* The element's effective `translate` when the drag started (inline value,
|
||||
* or computed when no inline one was set; "" = none). Drafts compose onto
|
||||
* this.
|
||||
*/
|
||||
private _draftPrevTranslate: string | null = null;
|
||||
/**
|
||||
* The element's raw INLINE `translate` when the drag started ("" = not
|
||||
* inline). Reverts restore exactly this, so a stylesheet-authored translate
|
||||
* is never promoted to a permanent inline style.
|
||||
*/
|
||||
private _draftPrevInlineTranslate: string | null = null;
|
||||
|
||||
constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
|
||||
this.iframe = iframe;
|
||||
@@ -543,73 +561,159 @@ class IframePreviewAdapter implements PreviewAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write draft CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto
|
||||
* the target element inside the iframe at 60fps. The composition's CSS uses
|
||||
* these vars to visually translate the element without touching the model.
|
||||
* Visually translate the target element inside the iframe at 60fps without
|
||||
* touching the model: sets the element's `translate` to its pre-drag value
|
||||
* composed with the accumulated delta. `translate` set after GSAP's first
|
||||
* parse is untouched by seeks, so this renders correctly on animated
|
||||
* elements too. (The `--hf-studio-dx/dy` custom properties are no longer
|
||||
* written — compositions with the authored Studio drag-bridge CSS would
|
||||
* move by twice the delta if both channels applied.)
|
||||
*
|
||||
* Calling applyDraft with a new id replaces the tracked element (does not
|
||||
* cancel the prior draft — call cancelPreview first if switching targets).
|
||||
* Calling applyDraft with a new id switches the tracked element, reverting
|
||||
* the previous element's draft translate first.
|
||||
*
|
||||
* width/height in DraftProps are not yet wired (resize → setStyle, future op).
|
||||
*/
|
||||
applyDraft(id: string, props: DraftProps): void {
|
||||
const el = this._resolveDraftElement(id);
|
||||
if (!el) return;
|
||||
|
||||
if (props.dx !== undefined) this._draftDx = props.dx;
|
||||
if (props.dy !== undefined) this._draftDy = props.dy;
|
||||
|
||||
el.style.setProperty(
|
||||
"translate",
|
||||
composeTranslate(this._draftPrevTranslate ?? "", `${this._draftDx}px`, `${this._draftDy}px`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and track the drag target. Reuses the tracked element across the
|
||||
* 60fps drag; only re-queries when the id changes or the cached node
|
||||
* detached (e.g. an iframe reload mid-drag). Switching to a different
|
||||
* element reverts the previous one's draft first, then captures the new
|
||||
* element's pre-drag translate.
|
||||
*/
|
||||
private _resolveDraftElement(id: string): HTMLElement | null {
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
if (!doc) return null;
|
||||
|
||||
// Reuse the tracked element across the 60fps drag; only re-query when the id
|
||||
// changes or the cached node detached (e.g. an iframe reload mid-drag).
|
||||
const cached = id === this._draftId && this._draftEl?.isConnected ? this._draftEl : null;
|
||||
const el =
|
||||
cached ??
|
||||
doc.querySelector<HTMLElement>(
|
||||
`[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`,
|
||||
);
|
||||
if (!el) return;
|
||||
if (!el) return null;
|
||||
|
||||
if (el !== this._draftEl) {
|
||||
// Abandoning a prior target mid-drag must not leave it displaced.
|
||||
this._revertDraftTranslate();
|
||||
this._draftDx = 0;
|
||||
this._draftDy = 0;
|
||||
this._draftPrevTranslate = readCurrentTranslate(el);
|
||||
const inline = el.style.getPropertyValue("translate").trim();
|
||||
this._draftPrevInlineTranslate = inline === "none" ? "" : inline;
|
||||
}
|
||||
this._draftId = id;
|
||||
this._draftEl = el;
|
||||
|
||||
if (props.dx !== undefined) el.style.setProperty(VAR_DX, String(props.dx));
|
||||
if (props.dy !== undefined) el.style.setProperty(VAR_DY, String(props.dy));
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the accumulated draft deltas, derive a moveElement op, dispatch it,
|
||||
* then clear the CSS vars and draft state.
|
||||
* then clear the draft state.
|
||||
*
|
||||
* No-ops when:
|
||||
* No-ops (reverting any draft translate) when:
|
||||
* - No applyDraft was called (nothing to commit)
|
||||
* - No dispatch callback was provided at construction
|
||||
*
|
||||
* If dispatch throws (e.g. the model no longer has the element), the draft
|
||||
* translate is reverted and the error propagates — the element is never
|
||||
* left displaced by an uncommitted draft.
|
||||
*/
|
||||
commitPreview(): void {
|
||||
if (!this._draftId || !this._draftEl || !this._dispatch) {
|
||||
this._revertDraftTranslate();
|
||||
this._clearDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this._draftEl;
|
||||
const dx = parseFloat(el.style.getPropertyValue(VAR_DX) || "0") || 0;
|
||||
const dy = parseFloat(el.style.getPropertyValue(VAR_DY) || "0") || 0;
|
||||
const dataX = el.getAttribute("data-x");
|
||||
const dataY = el.getAttribute("data-y");
|
||||
const { x, y } = computeDraftPosition(dataX, dataY, dx, dy);
|
||||
const { x, y } = computeDraftPosition(dataX, dataY, this._draftDx, this._draftDy);
|
||||
|
||||
this._dispatch({ type: "moveElement", target: this._draftId, x, y });
|
||||
try {
|
||||
this._dispatch({ type: "moveElement", target: this._draftId, x, y });
|
||||
} catch (err) {
|
||||
this._revertDraftTranslate();
|
||||
this._clearDraft();
|
||||
throw err;
|
||||
}
|
||||
this._mirrorCommittedMove(el, dataX, dataY, x, y);
|
||||
this._clearDraft();
|
||||
}
|
||||
|
||||
/** Revert draft CSS vars without dispatching any op. */
|
||||
/**
|
||||
* Mirror a committed move onto the live element so the position holds
|
||||
* without a document reload — same attributes handleMoveElement writes
|
||||
* into the model, rendered by the runtime's position-edit translate.
|
||||
*
|
||||
* The pre-edit translate is stamped from the value captured at drag start
|
||||
* (the element's current inline translate is the draft-composed one, which
|
||||
* must not be mistaken for the original), then the final translate is
|
||||
* recomputed the same way the runtime does at bind time.
|
||||
*/
|
||||
private _mirrorCommittedMove(
|
||||
el: HTMLElement,
|
||||
dataX: string | null,
|
||||
dataY: string | null,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
if (el.getAttribute(EDIT_BASE_X_ATTR) === null) {
|
||||
el.setAttribute(EDIT_BASE_X_ATTR, dataX ?? "0");
|
||||
}
|
||||
if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) {
|
||||
el.setAttribute(EDIT_BASE_Y_ATTR, dataY ?? "0");
|
||||
}
|
||||
if (el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) === null) {
|
||||
el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, this._draftPrevTranslate ?? "");
|
||||
}
|
||||
el.setAttribute("data-x", String(x));
|
||||
el.setAttribute("data-y", String(y));
|
||||
applyPositionEditToElement(el, { force: true });
|
||||
}
|
||||
|
||||
/** Revert the draft translate without dispatching any op. */
|
||||
cancelPreview(): void {
|
||||
this._revertDraftTranslate();
|
||||
this._clearDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (this._draftEl) {
|
||||
this._draftEl.style.removeProperty(VAR_DX);
|
||||
this._draftEl.style.removeProperty(VAR_DY);
|
||||
}
|
||||
this._draftId = null;
|
||||
this._draftEl = null;
|
||||
this._draftDx = 0;
|
||||
this._draftDy = 0;
|
||||
this._draftPrevTranslate = null;
|
||||
this._draftPrevInlineTranslate = null;
|
||||
}
|
||||
|
||||
// Selection -----------------------------------------------------------------
|
||||
|
||||
@@ -974,6 +974,33 @@ describe("moveElement", () => {
|
||||
expect(el.getAttribute("data-x")).toBe("50");
|
||||
expect(el.getAttribute("data-y")).toBe("75");
|
||||
});
|
||||
|
||||
it("captures the pre-edit baseline on first move only", () => {
|
||||
const parsed = fresh();
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element;
|
||||
el.setAttribute("data-x", "50");
|
||||
applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 });
|
||||
// Baseline = the values before the first edit (absent data-y → "0").
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBe("50");
|
||||
expect(el.getAttribute("data-hf-edit-base-y")).toBe("0");
|
||||
// A second move keeps the original baseline.
|
||||
applyOp(parsed, { type: "moveElement", target: "hf-title", x: 300, y: 400 });
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBe("50");
|
||||
expect(el.getAttribute("data-hf-edit-base-y")).toBe("0");
|
||||
expect(el.getAttribute("data-x")).toBe("300");
|
||||
expect(el.getAttribute("data-y")).toBe("400");
|
||||
});
|
||||
|
||||
it("inverse of the first move removes the baseline attributes", () => {
|
||||
const parsed = fresh();
|
||||
const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element;
|
||||
const result = applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 });
|
||||
applyPatchesToDocument(parsed, result.inverse);
|
||||
expect(el.getAttribute("data-hf-edit-base-x")).toBeNull();
|
||||
expect(el.getAttribute("data-hf-edit-base-y")).toBeNull();
|
||||
expect(el.getAttribute("data-x")).toBeNull();
|
||||
expect(el.getAttribute("data-y")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateOp (can()) ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "./patches.js";
|
||||
import { upsertCssRule } from "./cssWriter.js";
|
||||
import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids";
|
||||
import { EDIT_BASE_X_ATTR, EDIT_BASE_Y_ATTR } from "@hyperframes/core/runtime/position-edits";
|
||||
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import {
|
||||
@@ -342,11 +343,34 @@ function handleMoveElement(
|
||||
): MutationResult {
|
||||
// HF elements are positioned via data-x / data-y (parsed by htmlParser.ts,
|
||||
// emitted by hyperframes generator). CSS left/top is not the convention.
|
||||
const rx = handleSetAttribute(parsed, ids, "data-x", String(x));
|
||||
const ry = handleSetAttribute(parsed, ids, "data-y", String(y));
|
||||
//
|
||||
// The pre-edit values are captured once per element into
|
||||
// data-hf-edit-base-x/y. The runtime (core runtime/positionEdits.ts) renders
|
||||
// the edit as translate(data-x − base, data-y − base), which composes with
|
||||
// GSAP-animated transforms instead of being overwritten per-axis.
|
||||
const parts: MutationResult[] = [];
|
||||
for (const id of ids) {
|
||||
const el = resolveScoped(parsed.document, id);
|
||||
if (!el) continue;
|
||||
if (el.getAttribute(EDIT_BASE_X_ATTR) === null) {
|
||||
parts.push(
|
||||
handleSetAttribute(parsed, [id], EDIT_BASE_X_ATTR, el.getAttribute("data-x") ?? "0"),
|
||||
);
|
||||
}
|
||||
if (el.getAttribute(EDIT_BASE_Y_ATTR) === null) {
|
||||
parts.push(
|
||||
handleSetAttribute(parsed, [id], EDIT_BASE_Y_ATTR, el.getAttribute("data-y") ?? "0"),
|
||||
);
|
||||
}
|
||||
}
|
||||
parts.push(handleSetAttribute(parsed, ids, "data-x", String(x)));
|
||||
parts.push(handleSetAttribute(parsed, ids, "data-y", String(y)));
|
||||
return {
|
||||
forward: [...rx.forward, ...ry.forward],
|
||||
inverse: [...ry.inverse, ...rx.inverse],
|
||||
forward: parts.flatMap((p) => p.forward),
|
||||
inverse: parts
|
||||
.slice()
|
||||
.reverse()
|
||||
.flatMap((p) => p.inverse),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user