mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 05:13:47 +00:00
fix(studio): start every drag from one position reader and draft what it commits
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user