fix(studio): start every drag from one position reader and draft what it commits

This commit is contained in:
Miguel Ángel
2026-09-30 09:02:38 -04:00
parent 2bd1c5a8f1
commit fe04bb8f76
15 changed files with 324 additions and 168 deletions
@@ -1714,6 +1714,35 @@ const tl = gsap.timeline({ paused: true });
expect(html.indexOf('tl.set("#card"')).toBeGreaterThan(declaration);
});
it("a first write into a script-less sub-composition lands inside its template", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
writeHtml(
projectDir,
"sub.html",
'<template id="sub-template"><div data-composition-id="sub"><div id="card"></div></div></template>',
);
const res = await app.request("http://localhost/projects/demo/gsap-mutations/sub.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add",
targetSelector: "#card",
method: "set",
position: 0,
properties: { x: 40, y: 30 },
global: true,
}),
});
expect(res.status).toBe(200);
const html = readFileSync(join(projectDir, "sub.html"), "utf-8");
expect(html.indexOf('gsap.set("#card"')).toBeGreaterThan(html.indexOf("<template"));
expect(html.indexOf("</template>")).toBeGreaterThan(html.lastIndexOf("</script>"));
});
it("consolidate-position-writes leaves exactly one position write per selector", async () => {
const projectDir = createProjectDir();
const CORRUPTED = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
+9 -1
View File
@@ -1274,6 +1274,14 @@ function containsRawGsapExpression(value: unknown): boolean {
return Object.values(value).some(containsRawGsapExpression);
}
function insertGsapBootstrap(html: string, bootstrap: string): string {
const templateClose = html.indexOf("</template>", html.search(/<template[\s>]/));
if (templateClose > 0) {
return `${html.slice(0, templateClose)}${bootstrap}\n${html.slice(templateClose)}`;
}
return insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ?? `${html}\n${bootstrap}`;
}
async function prepareGsapMutationScript(
c: RouteContext,
res: ResolvedGsapFile,
@@ -1300,7 +1308,7 @@ async function prepareGsapMutationScript(
`window.__timelines["${compId}"] = tl;`,
"</script>",
].join("\n");
html = insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ?? `${html}\n${bootstrap}`;
html = insertGsapBootstrap(html, bootstrap);
block = extractGsapScriptBlock(html);
}
if (
@@ -17,19 +17,23 @@ const ORIGIN_CENTER = {
// Consistent geometry stub: model the physical truth the real DOM would report.
// A CSS width/height change grows the box from its top-left, so the rendered
// center drifts by half the size delta; the manual offset the gesture applies
// (read back from the element's studio vars) pulls it back. `elementCornerOverlayPoints`
// (read back from the drafted `translate`) pulls it back. `elementCornerOverlayPoints`
// returns the four corners of that drifted box; `overlayCornersCentroid` (kept
// real) averages them so the anchor loop can measure the true center each frame.
vi.mock("./domEditOverlayGeometry", async () => {
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
"./domEditOverlayGeometry",
);
const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
const { readStudioBoxSize } = await import("./manualEditsDom");
const draftedOffset = (element: HTMLElement) => {
const px = element.style.getPropertyValue("translate").match(/-?[\d.]+(?=px)/g) ?? [];
return { x: Number(px[0] ?? 0), y: Number(px[1] ?? 0) };
};
const physicalCenter = (element: HTMLElement) => {
const size = readStudioBoxSize(element);
const width = size.width > 0 ? size.width : ORIGIN.width;
const height = size.height > 0 ? size.height : ORIGIN.height;
const offset = readStudioPathOffset(element);
const offset = draftedOffset(element);
return {
x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
@@ -70,20 +70,6 @@ export interface GestureState {
// follows the cursor instead of overshooting by the live scale.
contentScaleX: number;
contentScaleY: number;
// Resize anchor pinning: with a live scale transform, growing the CSS box
// shifts the rendered box (scaling happens around the element center), so the
// un-dragged corner creeps during the draft. The move handler measures the
// gesture-start top-left drift each frame and counters it through the GSAP
// position channel; the pin accumulates so the correction converges.
// Present only on resize gestures.
resizeAnchor?: {
anchorX: number;
anchorY: number;
baseGsapX: number;
baseGsapY: number;
pinX: number;
pinY: number;
};
manualEditDragToken?: string;
snapContext?: SnapContext;
lastSnappedDx?: number;
@@ -2,7 +2,6 @@
* Gesture-begin functions: startGroupDrag and startGesture.
* These are pure "start a new gesture" operations — no draft rect updates.
*/
import { readElementGsapNumber } from "../../utils/elementGsap";
import { type DomEditSelection } from "./domEditing";
import {
createManualOffsetDragMember,
@@ -174,18 +173,6 @@ export function startGesture(
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
const contentScaleY =
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
let resizeAnchor: GestureState["resizeAnchor"];
if (kind === "resize") {
const startBcr = sel.element.getBoundingClientRect();
resizeAnchor = {
anchorX: startBcr.x,
anchorY: startBcr.y,
baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0,
baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0,
pinX: 0,
pinY: 0,
};
}
let initialPathOffset = captureStudioPathOffset(sel.element);
let manualEditDragToken: string | undefined;
let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -283,7 +270,6 @@ export function startGesture(
editScaleY: rect.editScaleY,
contentScaleX,
contentScaleY,
resizeAnchor,
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
@@ -511,7 +511,7 @@ describe("a stylesheet translate after a committed GSAP drag", () => {
it("is not masked when GSAP does not own the position", () => {
const element = draggedTitle("#title { translate: 0 -200px; }", true, false);
expect(element.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
expect(element.style.getPropertyValue("translate")).toMatch(/^calc\(0% \+ -?[\d.]+px\)/);
});
it("is not left behind when nothing sits under the draft's mask", () => {
@@ -566,3 +566,119 @@ describe("resumeGsapTimelines", () => {
expect(() => resumeGsapTimelines(element)).not.toThrow();
});
});
describe("drag in a composition without GSAP", () => {
// The commit writes `gsap.set(x, y)`; once GSAP loads it folds the CSS translate and the
// transform's translation into x/y, so the drag base must already count them.
function committedPosition(css: string, dx: number, dy: number, setup?: (w: Window) => void) {
const window = new Window();
setup?.(window);
window.document.head.innerHTML = `<style>#title { ${css} }</style>`;
const element = window.document.createElement("h1");
element.id = "title";
window.document.body.append(element);
Object.defineProperty(element, "offsetWidth", { value: 146 });
Object.defineProperty(element, "offsetHeight", { value: 82 });
element.getBoundingClientRect = () => {
const x = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
const y = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
return new window.DOMRect(100 + x, 300 + y, 146, 82);
};
const result = createManualOffsetDragMember({
key: "title",
selection: { element } as never,
element,
rect: { left: 100, top: 300, width: 146, height: 82, editScaleX: 1, editScaleY: 1 },
});
if (!result.ok) throw new Error(result.reason);
const offset = applyManualOffsetDragCommit(result.member, dx, dy);
return computeDraggedGsapPosition(element, offset, { x: 0, y: 0 });
}
it("keeps a stylesheet translate, so a 30px drag moves the element 30px", () => {
const { newX, newY } = committedPosition("translate: 0 -200px;", 40, 30);
expect({ x: newX, y: newY }).toEqual({ x: 40, y: -170 });
});
it("keeps a stylesheet transform translation the same way", () => {
const { newX, newY } = committedPosition("transform: translate(-60px, 10px);", 40, 30);
expect({ x: newX, y: newY }).toEqual({ x: -20, y: 40 });
});
it("turns the transform's translation by a CSS rotate, as GSAP folds it", () => {
const { newX, newY } = committedPosition(
"rotate: 90deg; transform: translate(100px, 0);",
40,
30,
);
expect(newX).toBeCloseTo(40);
expect(newY).toBeCloseTo(130);
});
it("scales the transform's translation by a CSS scale", () => {
const { newX, newY } = committedPosition("scale: 2; transform: translate(10px, 0);", 40, 30);
expect({ x: newX, y: newY }).toEqual({ x: 60, y: 30 });
});
it("falls back to the plain transform, as GSAP does, when the fold does not parse", () => {
// Chrome's DOMMatrix rejects an axis rotate inside a transform list; happy-dom's does not.
const rejectAxisRotate = (w: Window) => {
const Parsed = w.DOMMatrix;
class ChromeLike extends Parsed {
constructor(init?: string) {
if (init?.includes("rotate(x ")) throw new SyntaxError(`Failed to parse '${init}'`);
super(init);
}
}
Object.assign(w, { DOMMatrix: ChromeLike });
};
const { newX, newY } = committedPosition(
"rotate: x 30deg; translate: 0 -200px; transform: translate(-60px, 10px);",
40,
30,
rejectAxisRotate,
);
expect({ x: newX, y: newY }).toEqual({ x: -20, y: 40 });
});
it("reads the base from GSAP itself once the page has it", () => {
const base: Record<string, number> = { x: 7, y: 5 };
const gsap = {
set: () => undefined,
getProperty: (_el: Element, prop: string) => base[prop] ?? 0,
};
const { newX, newY } = committedPosition("translate: 0 -200px;", 40, 30, (w) =>
Object.assign(w, { gsap }),
);
expect({ x: newX, y: newY }).toEqual({ x: 47, y: 35 });
});
it("drafts the committed x/y: a percent translate no longer grows with a resize", () => {
const window = new Window();
window.document.head.innerHTML = "<style>#title { translate: 25% -50%; }</style>";
const element = window.document.createElement("h1");
element.id = "title";
window.document.body.append(element);
let width = 240;
Object.defineProperty(element, "offsetWidth", { get: () => width });
Object.defineProperty(element, "offsetHeight", { value: 160 });
element.getBoundingClientRect = () => new window.DOMRect(100, 300, width, 160);
const result = createManualOffsetDragMember({
key: "title",
selection: { element } as never,
element,
rect: { left: 100, top: 300, width: 240, height: 160, editScaleX: 1, editScaleY: 1 },
});
if (!result.ok) throw new Error(result.reason);
width = 340;
const offset = applyManualOffsetDragDraft(result.member, 10, 5);
const { newX, newY } = computeDraggedGsapPosition(element, offset, result.member.baseGsap);
expect({ x: newX, y: newY }).toEqual({ x: 70, y: 5 });
expect(element.style.getPropertyValue("translate")).toBe("calc(0% + 70px) calc(-50% + 5px)");
});
it("leaves a -50% centering out of x/y, since GSAP keeps it as xPercent/yPercent", () => {
const { newX, newY } = committedPosition("translate: -50% -50%;", 40, 30);
expect({ x: newX, y: newY }).toEqual({ x: 40, y: 30 });
});
});
@@ -1,6 +1,5 @@
import type { DomEditSelection } from "./domEditing";
import {
applyStudioPathOffset,
applyStudioPathOffsetDraft,
beginStudioManualEditGesture,
captureStudioPathOffset,
@@ -10,7 +9,11 @@ import {
restoreStudioPathOffset,
type StudioPathOffsetSnapshot,
} from "./manualEdits";
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
import {
computeDraggedGsapPosition,
cssTranslateForGsapPosition,
readGsapPosition,
} from "../../hooks/draggedGsapPosition";
interface OffsetDragGsap {
set: (el: Element, vars: Record<string, number | string>) => void;
@@ -25,35 +28,27 @@ function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null;
}
/**
* Live drag preview through the GSAP channel — the SAME channel the commit
* lands in (a `tl.set`/keyframe on the timeline), so what the user sees while
* dragging equals what gets written (plan R3/R4). Reuses the commit's
* base+delta+rotation math so preview and commit agree by construction. Returns
* true when handled via gsap; false when gsap is unavailable (caller falls back
* to the CSS draft).
*/
function applyOffsetDragDraftViaGsap(
element: HTMLElement,
offset: { x: number; y: number },
baseGsap: { x: number; y: number },
): boolean {
// The draft shows the x/y the commit writes, from the stable gesture-start base (a live read would
// integrate frame over frame).
function applyOffsetDragDraft(member: ManualOffsetDragMember, offset: { x: number; y: number }) {
const { element } = member;
const { newX, newY } = computeDraggedGsapPosition(element, offset, member.baseGsap);
const gsap = getOffsetDragGsap(element);
if (!gsap) return false;
// GSAP owns the transform; neutralize the CSS translate longhand so the two
// channels can't compose into a doubled position.
if (member.cssTranslate || !gsap) {
element.style.setProperty(
"translate",
member.cssTranslate?.(newX, newY) ?? `${newX}px ${newY}px`,
);
return;
}
// GSAP owns the transform; neutralize the CSS translate so the two can't compose.
element.style.setProperty("translate", "none");
// Use the STABLE gesture-start base (captured in JS), NOT `gsap.getProperty`.
// After `translate: none`, getProperty reads the transform we set last frame,
// so `base + delta` would integrate frame-over-frame and fling the element.
const { newX, newY } = computeDraggedGsapPosition(element, offset, baseGsap);
gsap.set(element, { x: newX, y: newY });
return true;
}
/**
* Live rotation preview through the GSAP channel — the SAME channel the commit
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraft`.
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
* the two channels from composing. `angle` is the absolute target rotation. Returns
* false when gsap is unavailable (caller falls back to the CSS draft).
@@ -102,9 +97,11 @@ export interface ManualOffsetDragMember {
* re-render (which reverts inline style + wipes the `data-hf-drag-gsap-base-*`
* attrs) can't drop the base. Without this the draft falls back to the LIVE
* transform — i.e. the value it set last frame — and `base + delta` integrates,
* making the element accelerate away ("flies"). See applyOffsetDragDraftViaGsap.
* making the element accelerate away ("flies"). See applyOffsetDragDraft.
*/
baseGsap: { x: number; y: number };
/** Without GSAP in the page, how the draft shows x/y (see cssTranslateForGsapPosition). */
cssTranslate: ((x: number, y: number) => string) | null;
initialPathOffset: StudioPathOffsetSnapshot;
gestureToken: string;
screenToOffset: ManualOffsetDragMatrix;
@@ -342,12 +339,10 @@ export function createManualOffsetDragMember(input: {
const win = input.element.ownerDocument.defaultView as
| (Window & {
gsap?: { getProperty?: (el: Element, prop: string) => number };
__timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
})
| null;
const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
const { x: gsapX, y: gsapY } = readGsapPosition(input.element);
input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
@@ -375,44 +370,27 @@ export function createManualOffsetDragMember(input: {
scaleY: input.rect.editScaleY,
});
const baseGsap = { x: gsapX, y: gsapY };
if (!measured.ok) {
// Fallback: when GSAP transforms interfere with probe measurement, use
// the preview scale as an approximation. The commit path reads the actual
// GSAP position from the iframe runtime, so visual imprecision during
// drag is acceptable — the final committed position is always exact.
const scaleX = input.rect.editScaleX || 1;
const scaleY = input.rect.editScaleY || 1;
const w = readTransformWDivisor(input.element);
return {
ok: true,
member: {
key: input.key,
selection: input.selection,
element: input.element,
initialOffset,
baseGsap,
initialPathOffset,
gestureToken,
screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY },
originRect: input.rect,
},
};
}
return {
ok: true,
member: {
key: input.key,
selection: input.selection,
element: input.element,
initialOffset,
baseGsap,
initialPathOffset,
gestureToken,
screenToOffset: measured.matrix,
originRect: input.rect,
},
const cssTranslate = cssTranslateForGsapPosition(input.element);
// Fallback: when GSAP transforms interfere with probe measurement, use the preview scale.
const w = readTransformWDivisor(input.element);
const screenToOffset = measured.ok
? measured.matrix
: { a: w / (input.rect.editScaleX || 1), b: 0, c: 0, d: w / (input.rect.editScaleY || 1) };
const member: ManualOffsetDragMember = {
key: input.key,
selection: input.selection,
element: input.element,
initialOffset,
baseGsap,
cssTranslate,
initialPathOffset,
gestureToken,
screenToOffset,
originRect: input.rect,
};
// Swap the authored translate for the drafted one before a resize can grow its percentages.
if (cssTranslate) applyOffsetDragDraft(member, initialOffset);
return { ok: true, member };
}
function resolveManualOffsetDragMemberOffset(
@@ -434,12 +412,7 @@ export function applyManualOffsetDragDraft(
dy: number,
): { x: number; y: number } {
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
// Position is single-sourced on the GSAP timeline; preview through gsap.set so
// the live draft matches the committed `tl.set`/keyframe. CSS draft only when
// gsap is unavailable (no preview iframe runtime).
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
applyStudioPathOffsetDraft(member.element, offset);
}
applyOffsetDragDraft(member, offset);
return offset;
}
@@ -463,13 +436,8 @@ function applyManualOffsetCommitValue(
offset: { x: number; y: number },
): { x: number; y: number } {
restampManualOffsetDragGestureBase(member);
// Optimistic visual through the GSAP channel (same as the live draft and the
// committed `tl.set`), so the element holds its dropped position until the
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
// CSS apply only when gsap is unavailable.
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
applyStudioPathOffset(member.element, offset);
}
// The element holds its dropped position until the source mutation reloads.
applyOffsetDragDraft(member, offset);
return offset;
}
@@ -496,9 +464,7 @@ export function applyManualOffsetNudgeDraft(
x: member.initialOffset.x + delta.x,
y: member.initialOffset.y + delta.y,
};
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
applyStudioPathOffsetDraft(member.element, offset);
}
applyOffsetDragDraft(member, offset);
return offset;
}
@@ -1,5 +1,3 @@
import { roundTo3 } from "../utils/rounding";
/**
* Drag → GSAP position math, shared by the commit path
* (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and
@@ -8,6 +6,79 @@ import { roundTo3 } from "../utils/rounding";
* it without pulling the GSAP commit graph into its module scope.
*/
import { roundTo3 } from "../utils/rounding";
const cssValue = (style: CSSStyleDeclaration, prop: string) => {
const value = style.getPropertyValue(prop).trim();
return value === "none" ? "" : value;
};
function transformTranslation(view: Window & typeof globalThis, list: string) {
const matrix = list ? new view.DOMMatrix(list) : null;
return { x: matrix?.m41 ?? 0, y: matrix?.m42 ?? 0 };
}
// GSAP writes `translate rotate scale transform` as one inline transform; a value the browser rejects
// (e.g. `rotate: x 30deg`) drops the whole string, leaving GSAP only the plain transform.
function foldedTranslation(view: Window & typeof globalThis, style: CSSStyleDeclaration) {
const [tx = "", ty = ""] = cssValue(style, "translate").split(/\s+/);
const rotate = cssValue(style, "rotate");
const scale = cssValue(style, "scale");
const transform = cssValue(style, "transform");
const folded = [
rotate && `rotate(${rotate})`,
scale && `scale(${scale.split(/\s+/).join(",")})`,
transform,
];
try {
return { tx, ty, ...transformTranslation(view, folded.join(" ").trim()) };
} catch {
return { tx: "", ty: "", ...transformTranslation(view, transform) };
}
}
function foldedAxis(translate: string, fromTransform: number, size: number) {
const raw = Number.parseFloat(translate) || 0;
const t = (translate.endsWith("%") ? (raw * size) / 100 : raw) + fromTransform;
const centered = t !== 0 && Math.round(size / 2) === Math.round(-t);
return { value: centered ? t + size / 2 : t, percent: centered ? -50 : 0, fromTransform };
}
function readCssFold(element: HTMLElement, view: Window & typeof globalThis) {
const moved = foldedTranslation(view, view.getComputedStyle(element));
return {
x: foldedAxis(moved.tx, moved.x, element.offsetWidth),
y: foldedAxis(moved.ty, moved.y, element.offsetHeight),
};
}
type GsapView = Window &
typeof globalThis & { gsap?: { getProperty?: (el: Element, p: string) => unknown } };
// The GSAP x/y every position gesture and commit starts from. Without GSAP, what its CSSPlugin will
// parse once the commit loads it: `translate`, `rotate`, `scale`, `transform`, less a -50% centring.
export function readGsapPosition(element: HTMLElement): { x: number; y: number } {
const view = element.ownerDocument.defaultView as GsapView | null;
if (!view) return { x: 0, y: 0 };
const getProperty = view.gsap?.getProperty;
if (getProperty) {
return { x: Number(getProperty(element, "x")), y: Number(getProperty(element, "y")) };
}
const fold = readCssFold(element, view);
return { x: fold.x.value, y: fold.y.value };
}
// Without GSAP, the inline `translate` that shows x/y where the committed `gsap.set` will. Null with GSAP.
export function cssTranslateForGsapPosition(
element: HTMLElement,
): ((x: number, y: number) => string) | null {
const view = element.ownerDocument.defaultView as GsapView | null;
if (!view || typeof view.gsap?.getProperty === "function") return null;
const { x: ax, y: ay } = readCssFold(element, view);
return (x, y) =>
`calc(${ax.percent}% + ${x - ax.fromTransform}px) calc(${ay.percent}% + ${y - ay.fromTransform}px)`;
}
/**
* Translate a studio drag offset into absolute GSAP x/y, accounting for the
* element's rotation and its drag-start base pose. Reads the drag-start
@@ -7,25 +7,8 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { getIframeGsap, queryIframeElement } from "./gsapShared";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
// fallow-ignore-next-line complexity
export function readGsapPositionFromIframe(
iframe: HTMLIFrameElement | null,
elementSelector: string,
): { x: number; y: number } | null {
const gsap = getIframeGsap(iframe);
if (!gsap) return null;
const element = queryIframeElement(iframe, elementSelector);
if (!element) return null;
const x = Number(gsap.getProperty(element, "x")) || 0;
const y = Number(gsap.getProperty(element, "y")) || 0;
return { x, y };
}
// fallow-ignore-next-line complexity
function animHasPosition(anim: GsapAnimation): boolean {
if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
@@ -25,8 +25,8 @@ import {
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
import { computeDraggedGsapPosition, readGsapPosition } from "./draggedGsapPosition";
import { pickClosestToPlayhead } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
@@ -337,7 +337,7 @@ export async function tryGsapResizeIntercept(
// composed that residual onto the pre-gesture base (it reads the gesture's
// own base attributes, not the live value), so the element landed a full
// drag away from the drop point on every scale resize.
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
const gsapPos = readGsapPosition(selection.element);
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
selection.element,
{ x: 0, y: 0 },
@@ -33,6 +33,7 @@ function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement {
// so without the guard the drag would reach commitMutation (resurrecting the tween).
const fakeElement = {
id: "puck-b",
ownerDocument: { defaultView: { gsap: { getProperty: () => 0 } } },
style: { getPropertyValue: () => "" },
getAttribute: () => null,
getBoundingClientRect: () => ({ top: 100, left: 100, width: 50, height: 50 }),
@@ -461,6 +462,32 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
expect(types).not.toContain("add-keyframe");
});
it("keeps an xPercent/yPercent set: it does not write x/y, so it is no duplicate", async () => {
usePlayerStore.setState({ currentTime: 1 });
const centering = {
id: "#puck-b-set-position",
targetSelector: "#puck-b",
propertyGroup: "position",
method: "set",
properties: { xPercent: -50, yPercent: -50 },
position: 0,
duration: 0,
} as unknown as GsapAnimation;
const animations = [centering, keyframedPositionAnim];
const commitMutation = vi.fn();
await tryGsapDragIntercept(
selection,
{ x: -50, y: 0 },
animations,
fakeIframe("puck-b", []),
commitMutation,
async () => animations,
);
const types = commitMutation.mock.calls.map(([, mutation]) => mutation.type);
expect(types).not.toContain("consolidate-position-writes");
expect(types).toContain("add-keyframe");
});
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
const { handled, types } = await runAutoKeyframeDrag(true);
expect(handled).toEqual({ status: "persisted" });
+10 -15
View File
@@ -30,11 +30,8 @@ import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
import {
findGsapPositionAnimation,
pickClosestToPlayhead,
readGsapPositionFromIframe,
} from "./gsapPositionDetection";
import { findGsapPositionAnimation, pickClosestToPlayhead } from "./gsapPositionDetection";
import { readGsapPosition } from "./draggedGsapPosition";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
import { getAnimationsForElement } from "./gsapElementMatch";
import {
@@ -60,6 +57,8 @@ export const POSITION_CHANNELS: string[] = [
"translateY",
];
const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
const XY_CHANNEL_SET = new Set(["x", "y"]);
const writesXY = (a: GsapAnimation) => animationWritesAnyProperty(a, XY_CHANNEL_SET);
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
@@ -259,8 +258,7 @@ export async function tryGsapDragIntercept(
// the live keyframed/real tween if present (else any), strip the rest, so the
// commit below updates ONE write instead of fighting duplicates.
let workingAnimations = animations;
const isPosWrite = (a: GsapAnimation) =>
a.targetSelector === selector && a.propertyGroup === "position";
const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && writesXY(a);
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const dupes = fresh.filter(isPosWrite);
@@ -301,7 +299,7 @@ export async function tryGsapDragIntercept(
}
}
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
const gsapPos = readGsapPosition(selection.element);
// STATIC case (single source of truth = GSAP timeline): the element has no LIVE
// keyframed/tweened position motion. Use the strict non-hold check — a leftover
@@ -343,13 +341,10 @@ export async function tryGsapDragIntercept(
// current ID and avoid a stale-ID remove that creates duplicate tweens.
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const freshMatch = fresh.find(
(a) =>
a.targetSelector === posAnim!.targetSelector && a.propertyGroup === posAnim!.propertyGroup,
);
if (freshMatch && freshMatch.id !== posAnim.id) {
posAnim = freshMatch;
}
const freshMatch = fresh.some((a) => a.id === posAnim!.id)
? undefined
: fresh.find((a) => a.targetSelector === posAnim!.targetSelector && writesXY(a));
if (freshMatch) posAnim = freshMatch;
}
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
@@ -12,7 +12,6 @@ import { mountReactHarness } from "./domSelectionTestHarness";
const mocks = vi.hoisted(() => ({
resize: vi.fn(),
drag: vi.fn(),
readPosition: vi.fn(),
setPosition: vi.fn(),
commitAnimatedProperty: vi.fn(),
commitAnimatedProperties: vi.fn(),
@@ -24,9 +23,6 @@ vi.mock("./gsapRuntimeBridge", () => ({
tryGsapDragIntercept: mocks.drag,
tryGsapRotationIntercept: vi.fn(),
}));
vi.mock("./gsapPositionDetection", () => ({
readGsapPositionFromIframe: mocks.readPosition,
}));
vi.mock("../utils/elementGsap", () => ({ setElementGsapPosition: mocks.setPosition }));
vi.mock("./useAnimatedPropertyCommit", () => ({
useAnimatedPropertyCommit: () => ({
@@ -276,7 +272,6 @@ describe("useGsapAwareEditing anchored resize", () => {
});
mocks.resize.mockReturnValue(pendingResize);
mocks.drag.mockResolvedValue({ status: "persisted" });
mocks.readPosition.mockReturnValue({ x: 120.4, y: 80.2 });
const h = mountResizeHandler([]);
h.selection.element.setAttribute("data-hf-drag-gsap-base-x", "120.4");
h.selection.element.setAttribute("data-hf-drag-gsap-base-y", "80.2");
@@ -16,8 +16,7 @@ import {
tryGsapRotationIntercept,
} from "./gsapRuntimeBridge";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import { readGsapPositionFromIframe } from "./gsapPositionDetection";
import { computeDraggedGsapPosition, readGsapPosition } from "./draggedGsapPosition";
import { selectorFromSelection } from "./gsapShared";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
import {
@@ -360,10 +359,7 @@ export function useGsapAwareEditing({
// Scale resize settles its center-scale residual after the scale commit
// renders. Width/height can settle its anchored position immediately.
if (!offset || scaleRoute || !selector) return;
const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
x: 0,
y: 0,
};
const gsapPos = readGsapPosition(selection.element);
const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
logResize("sync-settle", { gsapPos, offset, newX, newY });
setElementGsapPosition(selection.element, newX, newY);
-6
View File
@@ -37,9 +37,3 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
gsap.set(element, { scaleX: x, scaleY: y });
return true;
}
/** The element's GSAP numeric property, or null when unreadable. */
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
return Number.isFinite(value) ? value : null;
}