Files
hyperframes/packages/studio/src/hooks/gsapResizeDropPoint.test.ts
T

395 lines
14 KiB
TypeScript

// @vitest-environment happy-dom
/**
* The scale-route resize invariant, swept over the shapes a composition
* actually produces: WHERE THE USER DROPS THE BOX IS WHERE IT LANDS.
*
* Each case builds the geometry the browser reports — a CSS box at a layout
* position, transformed by GSAP's translate/rotate/scale about the element
* centre — drives one resize through the real intercept, then re-renders the
* committed scale and the PERSISTED position and checks the box is still on
* the drop point. Asserting on the persisted values rather than the live ones
* is the point: every bug in this class showed as a correct-looking drop frame
* followed by the element sliding to whatever got written to disk.
*
* The model is calibrated against real `hf-resize-debug` output: the same
* inputs reproduce the drop rect, the post-commit rect and the rect width the
* browser reported to three decimal places.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
afterEach(() => {
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
document.body.innerHTML = "";
});
interface Pose {
/** Untransformed CSS box, in px. */
box: { w: number; h: number };
/** GSAP translate. */
pos: { x: number; y: number };
/** GSAP scale, per axis. */
scale: { x: number; y: number };
}
/** Layout position of the untransformed box, shared by every pose in a case. */
const LAYOUT = { left: 120, top: 520 };
/**
* The AABB a browser reports for `translate() rotate() scale()` about the
* element centre — the same rect `getBoundingClientRect` returns, which is why
* a rotated element's anchor is approximate rather than corner-exact.
*/
function renderRect(
pose: Pose,
rotationDeg: number,
): { x: number; y: number; w: number; h: number } {
const rad = (rotationDeg * Math.PI) / 180;
const [cos, sin] = [Math.abs(Math.cos(rad)), Math.abs(Math.sin(rad))];
const [sw, sh] = [pose.box.w * pose.scale.x, pose.box.h * pose.scale.y];
const w = sw * cos + sh * sin;
const h = sw * sin + sh * cos;
const cx = LAYOUT.left + pose.box.w / 2 + pose.pos.x;
const cy = LAYOUT.top + pose.box.h / 2 + pose.pos.y;
return { x: cx - w / 2, y: cy - h / 2, w, h };
}
interface ResizeCase {
name: string;
/** Untransformed box the stylesheet (or an inline style) gives the element. */
box: { w: number; h: number };
/** Where the element sat before the gesture. */
base: { x: number; y: number };
/** Scale already on the element — 1 for a never-resized one. */
liveScale: { x: number; y: number };
rotation?: number;
/** The box the user dragged to, and where the draft put it. */
drop: { w: number; h: number; x: number; y: number };
/** Sized inline rather than by a stylesheet. */
inlineSized?: boolean;
/** The tween states scaleX/scaleY rather than the shorthand. */
longhandTween?: boolean;
/** What already writes this element's position. */
positionWrite?: "static-set" | "keyframed-tween" | "none";
}
function createResizeElement(testCase: ResizeCase): HTMLElement {
const el = document.createElement("div");
el.id = "clip";
if (testCase.inlineSized) {
el.setAttribute("data-hf-studio-original-width", `${testCase.box.w}px`);
el.setAttribute("data-hf-studio-original-height", `${testCase.box.h}px`);
} else {
el.setAttribute("data-hf-studio-original-box-width", `${testCase.box.w}`);
el.setAttribute("data-hf-studio-original-box-height", `${testCase.box.h}`);
el.setAttribute("data-hf-studio-original-width", "");
el.setAttribute("data-hf-studio-original-height", "");
}
return el;
}
function scaleTween(longhand: boolean): GsapAnimation {
const at = (v: number) => (longhand ? { scaleX: v, scaleY: v } : { scale: v });
return {
id: "#clip-to-0-scale",
targetSelector: "#clip",
propertyGroup: "scale",
method: "to",
properties: at(1),
position: 0,
resolvedStart: 0,
duration: 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: at(1) },
{ percentage: 100, properties: at(1.08) },
],
},
} as unknown as GsapAnimation;
}
function positionAnimation(
kind: NonNullable<ResizeCase["positionWrite"]>,
base: { x: number; y: number },
) {
if (kind === "none") return null;
if (kind === "static-set") {
return {
id: "#clip-set-0-position",
targetSelector: "#clip",
propertyGroup: "position",
method: "set",
properties: { x: base.x, y: base.y },
position: 0,
resolvedStart: 0,
duration: 0,
global: true,
} as unknown as GsapAnimation;
}
return {
id: "#clip-to-0-position",
targetSelector: "#clip",
propertyGroup: "position",
method: "to",
properties: { x: base.x, y: base.y },
position: 0,
resolvedStart: 0,
duration: 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: base.x, y: base.y } },
{ percentage: 100, properties: { x: base.x + 40, y: base.y + 40 } },
],
},
} as unknown as GsapAnimation;
}
/** Every x/y a run wrote, in commit order. */
function persistedPositions(calls: unknown[][]): Array<{ x?: number; y?: number }> {
const written: Array<{ x?: number; y?: number }> = [];
for (const call of calls) {
const mutation = call[1] as {
properties?: Record<string, number>;
keyframes?: Array<{ properties: Record<string, number> }>;
x?: number;
y?: number;
};
const sources = [mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)];
if (mutation.x != null || mutation.y != null) written.push({ x: mutation.x, y: mutation.y });
for (const source of sources) {
if (source && (source.x != null || source.y != null))
written.push({ x: source.x, y: source.y });
}
}
return written;
}
/** The scale the run committed AT THE PLAYHEAD (percentage 0 here). */
function persistedScale(calls: unknown[][]): { x: number; y: number } | null {
let found: { x: number; y: number } | null = null;
const take = (source: Record<string, number> | undefined) => {
if (!source) return;
const x = source.scaleX ?? source.scale;
const y = source.scaleY ?? source.scale;
if (x != null && y != null) found = { x, y };
};
for (const call of calls) {
const mutation = call[1] as {
properties?: Record<string, number>;
percentage?: number;
keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
};
if (mutation.keyframes) {
for (const frame of mutation.keyframes) if (frame.percentage === 0) take(frame.properties);
continue;
}
if (mutation.percentage != null && mutation.percentage !== 0) continue;
take(mutation.properties);
}
return found;
}
async function runCase(testCase: ResizeCase) {
const rotation = testCase.rotation ?? 0;
const el = createResizeElement(testCase);
// What the gesture stamps at drag start, and the draft it leaves applied.
el.setAttribute("data-hf-drag-gsap-base-x", `${testCase.base.x}`);
el.setAttribute("data-hf-drag-gsap-base-y", `${testCase.base.y}`);
el.setAttribute("data-hf-studio-box-size", "true");
el.style.width = `${testCase.drop.w}px`;
el.style.height = `${testCase.drop.h}px`;
document.body.append(el);
// The live pose the intercept reads and mutates.
const live: Pose = {
box: { ...testCase.box },
pos: { x: testCase.drop.x, y: testCase.drop.y },
scale: { ...testCase.liveScale },
};
el.getBoundingClientRect = () => {
// The draft's inline box while it is applied, the real one once cleared.
const w = Number.parseFloat(el.style.width) || live.box.w;
const h = Number.parseFloat(el.style.height) || live.box.h;
const rect = renderRect({ ...live, box: { w, h } }, rotation);
return { ...rect, width: rect.w, height: rect.h } as unknown as DOMRect;
};
const gsapStub = {
set: (_target: Element, vars: Record<string, number>) => {
if (vars.x != null) live.pos.x = vars.x;
if (vars.y != null) live.pos.y = vars.y;
if (vars.scaleX != null) live.scale.x = vars.scaleX;
if (vars.scaleY != null) live.scale.y = vars.scaleY;
},
getProperty: (_target: Element, prop: string) =>
({
scaleX: live.scale.x,
scaleY: live.scale.y,
x: live.pos.x,
y: live.pos.y,
rotation,
})[prop] ?? 0,
};
Object.assign(window, { gsap: gsapStub });
const iframe = {
contentWindow: { gsap: gsapStub, __timelines: { main: { getChildren: () => [] } } },
contentDocument: document,
} as unknown as HTMLIFrameElement;
const dropPoint = el.getBoundingClientRect();
const position = positionAnimation(testCase.positionWrite ?? "static-set", testCase.base);
const animations = [scaleTween(!!testCase.longhandTween), ...(position ? [position] : [])];
const commitMutation = vi.fn();
usePlayerStore.setState({ currentTime: 0 });
await tryGsapResizeIntercept(
{ id: "clip", selector: "#clip", element: el } as DomEditSelection,
{ width: testCase.drop.w, height: testCase.drop.h },
animations,
iframe,
commitMutation,
async () => animations,
);
// Re-render what the file now says: the committed scale, the persisted
// position (or the pre-gesture one when nothing was written), the real box.
const scale = persistedScale(commitMutation.mock.calls) ?? testCase.liveScale;
const writes = persistedPositions(commitMutation.mock.calls);
const last = writes.at(-1);
const settled: Pose = {
box: testCase.box,
pos: { x: last?.x ?? testCase.base.x, y: last?.y ?? testCase.base.y },
scale,
};
return {
dropPoint: { x: dropPoint.x, y: dropPoint.y, w: dropPoint.width, h: dropPoint.height },
settled: renderRect(settled, rotation),
writes,
};
}
const CASES: ResizeCase[] = [
{
name: "shrinks a stylesheet-sized element",
box: { w: 630, h: 252 },
base: { x: 489, y: 195 },
liveScale: { x: 1.648, y: 1.648 },
drop: { w: 320, h: 128, x: 587, y: 235 },
},
{
name: "grows a stylesheet-sized element",
box: { w: 630, h: 252 },
base: { x: 489, y: 195 },
liveScale: { x: 0.837, y: 0.837 },
drop: { w: 1319, h: 527, x: -67, y: -26 },
},
{
name: "resizes an element for the first time (no scale of its own yet)",
box: { w: 630, h: 252 },
base: { x: 489, y: 195 },
liveScale: { x: 1, y: 1 },
drop: { w: 900, h: 360, x: 300, y: 100 },
},
{
name: "resizes a rotated element",
box: { w: 630, h: 200 },
base: { x: 329, y: 129 },
liveScale: { x: 0.94, y: 0.939 },
rotation: -8,
longhandTween: true,
drop: { w: 350, h: 111, x: 609, y: 219 },
},
{
name: "resizes a steeply rotated element",
box: { w: 630, h: 252 },
base: { x: 17, y: -69 },
liveScale: { x: 1.192, y: 1.2 },
rotation: -47,
longhandTween: true,
drop: { w: 491, h: 196, x: 120, y: 40 },
},
{
name: "takes a non-uniform drag",
box: { w: 630, h: 252 },
base: { x: 489, y: 195 },
liveScale: { x: 1, y: 1 },
longhandTween: true,
drop: { w: 1200, h: 300, x: -80, y: 60 },
},
{
name: "shrinks almost to nothing",
box: { w: 630, h: 252 },
base: { x: 489, y: 195 },
liveScale: { x: 1, y: 1 },
drop: { w: 13, h: 5, x: 800, y: 320 },
},
{
name: "resizes an inline-sized element",
box: { w: 500, h: 252 },
base: { x: 100, y: 100 },
liveScale: { x: 0.5, y: 0.5 },
inlineSized: true,
drop: { w: 700, h: 353, x: -40, y: -30 },
},
{
name: "resizes an element with no position write at all",
box: { w: 630, h: 252 },
base: { x: 0, y: 0 },
liveScale: { x: 1, y: 1 },
positionWrite: "none",
drop: { w: 900, h: 360, x: 140, y: 55 },
},
{
name: "resizes an element whose position is animated",
box: { w: 630, h: 252 },
base: { x: 489, y: 195 },
liveScale: { x: 1.648, y: 1.648 },
positionWrite: "keyframed-tween",
drop: { w: 320, h: 128, x: 587, y: 235 },
},
];
describe("a scale resize lands the element on the drop point", () => {
for (const testCase of CASES) {
it(testCase.name, async () => {
const { dropPoint, settled } = await runCase(testCase);
// 1px: the commit rounds position to whole pixels and scale to 3dp.
expect(settled.x).toBeCloseTo(dropPoint.x, 0);
expect(settled.y).toBeCloseTo(dropPoint.y, 0);
expect(settled.w).toBeCloseTo(dropPoint.w, 0);
expect(settled.h).toBeCloseTo(dropPoint.h, 0);
});
}
});
/**
* Two drags in a row on the same element. The first bug in this class only
* showed on the second one, because the wrong value the first wrote then
* counted as the element's live pose.
*/
it("holds the drop point across a second drag", async () => {
const first = await runCase({
name: "first",
box: { w: 630, h: 252 },
base: { x: 489, y: 195 },
liveScale: { x: 1, y: 1 },
drop: { w: 900, h: 360, x: 300, y: 100 },
});
const settledPos = first.writes.at(-1);
document.body.innerHTML = "";
const second = await runCase({
name: "second",
box: { w: 630, h: 252 },
base: { x: settledPos?.x ?? 489, y: settledPos?.y ?? 195 },
// What the first drag committed: 900/630.
liveScale: { x: 1.429, y: 1.429 },
drop: { w: 420, h: 168, x: 640, y: 260 },
});
expect(second.settled.x).toBeCloseTo(second.dropPoint.x, 0);
expect(second.settled.y).toBeCloseTo(second.dropPoint.y, 0);
expect(second.settled.w).toBeCloseTo(second.dropPoint.w, 0);
});