Merge pull request #2230 from heygen-com/fix/studio-graded-element-editing

fix(studio): graded elements survive manual editing (disappear/resize/rotate/crop/panel)
This commit is contained in:
Miguel Ángel
2026-07-11 15:57:42 -04:00
committed by GitHub
37 changed files with 2225 additions and 441 deletions
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import type { DomEditSelection } from "./domEditing";
import type { OverlayRect } from "./domEditOverlayGeometry";
import { DomEditCropHandles } from "./DomEditCropHandles";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
const overlayRect: OverlayRect = {
left: 0,
top: 0,
width: 200,
height: 100,
editScaleX: 1,
editScaleY: 1,
};
function selectionFor(el: HTMLElement): DomEditSelection {
return { element: el, id: el.id, selector: `#${el.id}` } as unknown as DomEditSelection;
}
function makeEl(id: string, clip: string): HTMLElement {
const el = document.createElement("div");
el.id = id;
if (clip) el.style.setProperty("clip-path", clip);
document.body.append(el);
return el;
}
function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) => void } {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const draw = (target: HTMLElement) =>
act(() => {
root.render(
<DomEditCropHandles
selection={selectionFor(target)}
overlayRect={overlayRect}
onStyleCommit={() => undefined}
/>,
);
});
draw(el);
return { root, rerender: draw };
}
// Regression: the deselect restore used a ref recomputed from RENDER state — on
// a direct A→B selection switch, state re-syncs to B before A's effect cleanup
// runs, so A used to get B's crop string (or lose its crop entirely). The
// restore value must be owned by A's own lift effect / crop gesture.
describe("DomEditCropHandles clip lift/restore", () => {
it("lifts on select and restores the inline clip verbatim on unmount", () => {
const a = makeEl("a", "inset(16px round 12px)");
const { root } = render(a);
expect(a.style.getPropertyValue("clip-path")).toBe("none");
act(() => root.unmount());
expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)");
});
it("restores A's own clip when switching directly to B", () => {
const a = makeEl("a", "inset(16px)");
const b = makeEl("b", "inset(40px 8px 4px 2px)");
const { root, rerender } = render(a);
rerender(b);
// A got ITS clip back, not B's (and not removed); B is now lifted.
expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px)");
expect(b.style.getPropertyValue("clip-path")).toBe("none");
act(() => root.unmount());
expect(b.style.getPropertyValue("clip-path")).toBe("inset(40px 8px 4px 2px)");
});
it("never lifts an uneditable clip and leaves it untouched across select/deselect", () => {
const a = makeEl("a", "circle(50% at 50% 50%)");
const { root } = render(a);
expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
act(() => root.unmount());
expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
});
});
@@ -4,9 +4,11 @@ import type { OverlayRect } from "./domEditOverlayGeometry";
import {
type CropEdge,
cropRectFromInsets,
readElementCropFrame,
readElementCropInsets,
resolveCropInsetFromEdgeDrag,
resolveCropInsetFromMoveDrag,
rotateDeltaIntoFrame,
} from "./domEditOverlayCrop";
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
@@ -17,6 +19,10 @@ interface CropGestureState {
startY: number;
startInsets: ClipPathInsetSides;
didMove: boolean;
/** Element frame captured at gesture start: pointer deltas rotate into it. */
angleDeg: number;
scaleX: number;
scaleY: number;
}
interface DomEditCropHandlesProps {
@@ -77,36 +83,23 @@ export function DomEditCropHandles({
}: DomEditCropHandlesProps) {
const gestureRef = useRef<CropGestureState | null>(null);
const [dragging, setDragging] = useState(false);
const [state, setState] = useState(() => {
const parsed = readElementCropInsets(selection.element);
return {
element: selection.element,
insets: {
top: parsed.top,
right: parsed.right,
bottom: parsed.bottom,
left: parsed.left,
} as ClipPathInsetSides,
radius: parsed.radius,
};
});
// readElementCropInsets returns null for a clip this tool can't represent
// (circle/polygon/non-px inset): the crop UI must fully stand down for that
// element — no lift, no handles — or select+deselect replaces the authored
// clip with an inset (or deletes it).
const cropStateFor = (element: HTMLElement) => {
const parsed = readElementCropInsets(element);
const { radius, ...insets } = parsed ?? { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
return { element, croppable: parsed !== null, insets, radius };
};
const [state, setState] = useState(() => cropStateFor(selection.element));
// Re-sync when the selection targets a different element (reselect, or an
// undo/redo that re-keys the node): read its committed crop before the lift
// effect runs. Read inside the guard so a drag's per-frame setState doesn't
// re-run getComputedStyle every frame.
if (state.element !== selection.element) {
const liveInsets = readElementCropInsets(selection.element);
setState({
element: selection.element,
insets: {
top: liveInsets.top,
right: liveInsets.right,
bottom: liveInsets.bottom,
left: liveInsets.left,
},
radius: liveInsets.radius,
});
setState(cropStateFor(selection.element));
}
const hasCrop =
@@ -115,31 +108,51 @@ export function DomEditCropHandles({
state.insets.bottom > 0 ||
state.insets.left > 0;
// Latest committed crop — re-applied to the element when the selection drops.
const committedRef = useRef<string | null>(null);
committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null;
// Lift the clip while the element is selected so the full content shows and the
// cropped-away area can be dimmed; restore the committed crop on deselect. Keyed
// on the element so switching selections restores the previous one. Runs after
// render, so the state re-sync above still reads the element's real committed clip.
// cropped-away area can be dimmed; restore on deselect. Keyed on the element so
// switching selections restores the previous one. Runs after render, so the
// state re-sync above still reads the element's real committed clip. Restore
// prefers the pre-lift inline value VERBATIM — the rebuilt inset only replaces
// it after a crop gesture actually commits, so a mere select+deselect can
// never reformat (or drop) what the author wrote. Both refs are written only
// by THIS element's lift effect and crop gestures — never derived from render
// state, which by cleanup time already describes the NEXT selection (a direct
// A→B switch re-syncs state to B before A's cleanup runs).
const liftedRef = useRef(false);
const preLiftInlineClipRef = useRef("");
// null = no crop gesture committed this selection; "" = committed a crop
// removal; anything else = the exact committed clip-path value.
const committedClipRef = useRef<string | null>(null);
useEffect(() => {
const el = selection.element;
if (readElementCropInsets(el) === null) return;
preLiftInlineClipRef.current = el.style.getPropertyValue("clip-path");
committedClipRef.current = null;
el.style.setProperty("clip-path", "none");
liftedRef.current = true;
return () => {
liftedRef.current = false;
if (committedRef.current) el.style.setProperty("clip-path", committedRef.current);
const committed = committedClipRef.current;
const restore = committed !== null ? committed || null : preLiftInlineClipRef.current || null;
if (restore) el.style.setProperty("clip-path", restore);
else el.style.removeProperty("clip-path");
};
}, [selection.element]);
const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
const width = overlayRect.width / scaleX;
const height = overlayRect.height / scaleY;
const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY);
// The crop applies in the element's LOCAL frame (clip-path precedes the
// transform), so all crop UI is drawn inside a container rotated with the
// element — on a rotated element an axis-aligned dim visually "straightens"
// it by masking the rotated corners.
const frame = readElementCropFrame(selection.element, overlayRect);
const width = frame.width / frame.scaleX; // element CSS px
const height = frame.height / frame.scaleY;
// Crop rect in FRAME-LOCAL coordinates (origin = frame top-left).
const cropRect = cropRectFromInsets(
{ left: 0, top: 0, width: frame.width, height: frame.height },
state.insets,
frame.scaleX,
frame.scaleY,
);
const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
if (!onStyleCommit) return;
@@ -153,6 +166,9 @@ export function DomEditCropHandles({
startY: event.clientY,
startInsets: state.insets,
didMove: false,
angleDeg: frame.angleDeg,
scaleX: frame.scaleX,
scaleY: frame.scaleY,
};
// Clip is already lifted by the selection effect; just flag the drag so the
// rule-of-thirds grid shows.
@@ -164,12 +180,17 @@ export function DomEditCropHandles({
if (!gesture || gesture.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const local = rotateDeltaIntoFrame(
event.clientX - gesture.startX,
event.clientY - gesture.startY,
gesture.angleDeg,
);
const drag = {
startInsets: gesture.startInsets,
deltaX: event.clientX - gesture.startX,
deltaY: event.clientY - gesture.startY,
scaleX,
scaleY,
deltaX: local.deltaX,
deltaY: local.deltaY,
scaleX: gesture.scaleX,
scaleY: gesture.scaleY,
};
const nextInsets =
gesture.edge === "move"
@@ -196,9 +217,19 @@ export function DomEditCropHandles({
const reLift = () => {
if (liftedRef.current) el.style.setProperty("clip-path", "none");
};
void Promise.resolve(
onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)),
).then(reLift, reLift);
const committedValue = buildInsetClipPathSides(state.insets, state.radius);
const cropped =
state.insets.top > 0 ||
state.insets.right > 0 ||
state.insets.bottom > 0 ||
state.insets.left > 0;
void Promise.resolve(onStyleCommit?.("clip-path", committedValue)).then(() => {
// Only a landed commit makes the rebuilt inset the restore value; a
// failed one keeps restoring the pre-lift clip. Store the value itself —
// by deselect time, render state describes the next selection.
committedClipRef.current = cropped ? committedValue : "";
reLift();
}, reLift);
};
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
@@ -212,25 +243,32 @@ export function DomEditCropHandles({
setState((prev) => ({ ...prev, insets: gesture.startInsets }));
};
// Uneditable clip (circle/polygon/non-px inset): the element renders exactly
// as authored and the crop tool shows nothing. All hooks above stay mounted.
if (!state.croppable) return null;
return (
<>
<div
data-dom-edit-crop-frame="true"
className="pointer-events-none absolute"
style={{
left: frame.left,
top: frame.top,
width: frame.width,
height: frame.height,
transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
}}
>
{/* Dim the cropped-away area whenever the element is cropped and selected,
so the hidden content is visible (ghosted) without dragging. */}
so the hidden content is visible (ghosted) without dragging. Clipped to
the element's own (rotated) box. */}
{hasCrop && (
<div
className="pointer-events-none absolute overflow-hidden"
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
}}
>
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute"
style={{
left: cropRect.left - overlayRect.left,
top: cropRect.top - overlayRect.top,
left: cropRect.left,
top: cropRect.top,
width: cropRect.width,
height: cropRect.height,
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
@@ -312,6 +350,6 @@ export function DomEditCropHandles({
/>
);
})}
</>
</div>
);
}
@@ -535,6 +535,47 @@ describe("resolveDomEditResizeGesture", () => {
});
});
it("divides the cursor delta by the element's content scale (rescaled element)", () => {
// Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the
// CSS box by only 15px so the RENDERED box tracks the pointer 1:1.
const next = resolveDomEditResizeGesture({
originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1)
originHeight: 240,
actualWidth: 240,
actualHeight: 120,
scaleX: 1,
scaleY: 1,
contentScaleX: 2,
contentScaleY: 2,
dx: 30,
dy: 12,
uniform: false,
});
expect(next.width).toBe(255);
expect(next.height).toBe(126);
// The overlay box keeps tracking the raw cursor.
expect(next.overlayWidth).toBe(510);
expect(next.overlayHeight).toBe(252);
});
it("treats a missing/invalid content scale as 1 (unscaled element)", () => {
const next = resolveDomEditResizeGesture({
originWidth: 240,
originHeight: 120,
actualWidth: 240,
actualHeight: 120,
scaleX: 1,
scaleY: 1,
contentScaleX: 0,
contentScaleY: Number.NaN,
dx: 30,
dy: 12,
uniform: false,
});
expect(next.width).toBe(270);
expect(next.height).toBe(132);
});
it("snaps width and height to the same value when Shift is held", () => {
expect(
resolveDomEditResizeGesture({
@@ -1,4 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
import { type DomEditSelection } from "./domEditing";
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
import { useMarqueeGestures } from "./marqueeCommit";
@@ -305,8 +306,23 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
// Start marquee if clicking on empty canvas (no element under pointer)
// Start marquee if clicking on empty canvas (no element under pointer).
// The hover selection is an ASYNC cache: on a fast click (or when the
// pointer was already resting over an element) it can still be empty while
// an element IS under the pointer — starting a marquee here would swallow
// the selection mousedown and the click would silently select nothing.
// Confirm emptiness with a fresh SYNCHRONOUS hit-test before committing.
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
const iframe = iframeRef.current;
const freshTarget = iframe
? getPreviewTargetFromPointer(
iframe,
event.clientX,
event.clientY,
activeCompositionPathRef.current,
)
: null;
if (freshTarget) return;
const overlayEl = overlayRef.current;
if (overlayEl) {
const oRect = overlayEl.getBoundingClientRect();
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import {
cropRectFromInsets,
hugRectForElement,
readElementCropFrame,
readElementCropInsets,
resolveCropInsetFromEdgeDrag,
resolveCropInsetFromMoveDrag,
rotateDeltaIntoFrame,
} from "./domEditOverlayCrop";
describe("resolveCropInsetFromEdgeDrag", () => {
@@ -104,3 +108,136 @@ describe("cropRectFromInsets", () => {
expect(r.height).toBe(0);
});
});
describe("readElementCropInsets tri-state", () => {
// Regression: a clip-path the crop tool can't represent (circle/polygon/
// non-px inset) used to parse to ZEROS — indistinguishable from "no crop" —
// so selecting lifted the clip and deselecting removed/replaced it: the
// authored circle clip was silently destroyed by a mere select+deselect.
const fakeEl = (inlineClip: string) =>
({
style: { getPropertyValue: (p: string) => (p === "clip-path" ? inlineClip : "") },
ownerDocument: { defaultView: { getComputedStyle: () => ({ clipPath: "none" }) } },
}) as unknown as HTMLElement;
it("zeros for no clip", () => {
expect(readElementCropInsets(fakeEl(""))).toEqual({
top: 0,
right: 0,
bottom: 0,
left: 0,
radius: 0,
});
});
it("parses a px inset", () => {
expect(readElementCropInsets(fakeEl("inset(16px round 12px)"))).toEqual({
top: 16,
right: 16,
bottom: 16,
left: 16,
radius: 12,
});
});
it("null for a circle clip (uneditable, must not be lifted)", () => {
expect(readElementCropInsets(fakeEl("circle(50% at 50% 50%)"))).toBeNull();
});
it("null for a non-px inset (uneditable, must not be lifted)", () => {
expect(readElementCropInsets(fakeEl("inset(10%)"))).toBeNull();
});
it("hugRectForElement passes the rect through for uneditable clips", () => {
const rect = { left: 1, top: 2, width: 30, height: 40, editScaleX: 1, editScaleY: 1 };
expect(hugRectForElement(rect, fakeEl("circle(50%)"))).toEqual(rect);
});
});
// Regression: crop UI drawn on the axis-aligned bounding box visually
// "straightens" a rotated element — the dim masks the rotated corners. The
// frame gives the element's own box + rotation so the UI rotates with it.
describe("readElementCropFrame", () => {
const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
const fakeEl = (transform: string, offsetWidth = 200, offsetHeight = 100) =>
({
offsetWidth,
offsetHeight,
ownerDocument: { defaultView: { getComputedStyle: () => ({ transform }) } },
}) as unknown as HTMLElement;
it("identity transform → the axis-aligned overlay rect", () => {
expect(readElementCropFrame(fakeEl("none"), overlayRect)).toEqual({
angleDeg: 0,
left: 100,
top: 50,
width: 220,
height: 130,
scaleX: 1,
scaleY: 1,
});
});
it("rotated element → its own box, centered on the AABB, with the angle", () => {
// rotate(30deg): matrix(cos, sin, -sin, cos, tx, ty)
const cos = Math.cos(Math.PI / 6);
const sin = Math.sin(Math.PI / 6);
const frame = readElementCropFrame(
fakeEl(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 10, 20)`),
overlayRect,
);
expect(frame.angleDeg).toBeCloseTo(30, 3);
expect(frame.width).toBeCloseTo(200, 3);
expect(frame.height).toBeCloseTo(100, 3);
// centered on the AABB center (210, 115)
expect(frame.left + frame.width / 2).toBeCloseTo(210, 3);
expect(frame.top + frame.height / 2).toBeCloseTo(115, 3);
expect(frame.scaleX).toBeCloseTo(1, 3);
});
it("scaled element → scale factored into px-per-element-px", () => {
const frame = readElementCropFrame(fakeEl("matrix(1.5, 0, 0, 2, 0, 0)"), overlayRect);
expect(frame.angleDeg).toBe(0);
expect(frame.scaleX).toBeCloseTo(1.5, 3);
expect(frame.scaleY).toBeCloseTo(2, 3);
expect(frame.width).toBeCloseTo(300, 3);
expect(frame.height).toBeCloseTo(200, 3);
});
it("3D transform falls back to the axis-aligned frame", () => {
const frame = readElementCropFrame(
fakeEl("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)"),
overlayRect,
);
expect(frame).toEqual({
angleDeg: 0,
left: 100,
top: 50,
width: 220,
height: 130,
scaleX: 1,
scaleY: 1,
});
});
});
describe("rotateDeltaIntoFrame", () => {
it("passes deltas through at 0deg", () => {
expect(rotateDeltaIntoFrame(10, 5, 0)).toEqual({ deltaX: 10, deltaY: 5 });
});
it("rotates a screen delta into a 90deg-rotated frame", () => {
// Element rotated +90°: dragging DOWN on screen moves along the element's +x.
const { deltaX, deltaY } = rotateDeltaIntoFrame(0, 10, 90);
expect(deltaX).toBeCloseTo(10, 6);
expect(deltaY).toBeCloseTo(0, 6);
});
it("round-trips a 30deg rotation", () => {
const local = rotateDeltaIntoFrame(7, -3, 30);
const back = rotateDeltaIntoFrame(local.deltaX, local.deltaY, -30);
expect(back.deltaX).toBeCloseTo(7, 6);
expect(back.deltaY).toBeCloseTo(-3, 6);
});
});
@@ -28,15 +28,21 @@ export function cropRectFromInsets(
};
}
/** Current inset crop of an element (inline first, computed fallback), or zeros. */
export function readElementCropInsets(element: HTMLElement): ClipPathInsetSides & {
radius: number;
} {
/**
* Current inset crop of an element (inline first, computed fallback).
* Zeros = no clip (croppable, nothing cropped yet). `null` = the element
* carries a clip-path this tool cannot represent (circle/polygon/non-px
* inset) — croppers must not lift, edit, or restore it, or the clip gets
* silently replaced or destroyed on deselect.
*/
export function readElementCropInsets(
element: HTMLElement,
): (ClipPathInsetSides & { radius: number }) | null {
const inline = element.style.getPropertyValue("clip-path").trim();
const value =
inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
const parsed = parseInsetClipPathSides(value === "none" ? "" : value);
return parsed ?? { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
return parseInsetClipPathSides(value);
}
export interface CropInsetDragInput {
@@ -111,6 +117,96 @@ export function hugRectForElement(
element: HTMLElement,
): CropScreenRect {
const insets = readElementCropInsets(element);
if (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0) return rect;
// Uneditable clip (null) can't be hugged — show the full element rect.
if (!insets || (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0))
return rect;
return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
}
/**
* The element's own (unrotated) box in overlay space, plus the rotation to
* apply when drawing crop UI over it. `clip-path` applies in the element's
* LOCAL frame — before its transform — so the crop dim/outline/handles must be
* drawn rotated with the element, not on its axis-aligned bounding box: an
* AABB-drawn dim visually "straightens" a rotated element by masking its
* corners (the crop window looks axis-aligned while the pixels are not).
*
* scaleX/scaleY are overlay px per element CSS px (element's own scale × the
* editor zoom), so element-space insets map straight onto the frame. Assumes
* the default 50%/50% transform-origin (the GSAP/studio convention). 3D or
* unparseable transforms fall back to the axis-aligned frame (angle 0, AABB
* box) — the pre-existing presentation.
*/
export interface CropFrame {
angleDeg: number;
left: number;
top: number;
width: number;
height: number;
scaleX: number;
scaleY: number;
}
export function readElementCropFrame(
element: HTMLElement,
overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number },
): CropFrame {
const editX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
const editY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
const aabb: CropFrame = {
angleDeg: 0,
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
scaleX: editX,
scaleY: editY,
};
let transform = "";
try {
transform = element.ownerDocument.defaultView?.getComputedStyle(element).transform ?? "";
} catch {
return aabb;
}
if (!transform || transform === "none") return aabb;
const m = /^matrix\(([^)]+)\)$/.exec(transform);
if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback
const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v));
if (![a, b, c, d].every(Number.isFinite)) return aabb;
const elScaleX = Math.hypot(a!, b!);
const det = a! * d! - b! * c!;
const elScaleY = elScaleX !== 0 ? det / elScaleX : 1;
if (elScaleX <= 0 || elScaleY <= 0) return aabb;
const angleDeg = (Math.atan2(b!, a!) * 180) / Math.PI;
const scaleX = elScaleX * editX;
const scaleY = elScaleY * editY;
const width = element.offsetWidth * scaleX;
const height = element.offsetHeight * scaleY;
if (!(width > 0) || !(height > 0)) return aabb;
// Rotation about the default center keeps the center invariant, so the
// local box is centered on the AABB center.
const cx = overlayRect.left + overlayRect.width / 2;
const cy = overlayRect.top + overlayRect.height / 2;
return {
angleDeg,
left: cx - width / 2,
top: cy - height / 2,
width,
height,
scaleX,
scaleY,
};
}
/** Rotate a screen-space pointer delta into the element's local frame. */
export function rotateDeltaIntoFrame(
deltaX: number,
deltaY: number,
angleDeg: number,
): { deltaX: number; deltaY: number } {
if (angleDeg === 0) return { deltaX, deltaY };
const rad = (-angleDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return { deltaX: deltaX * cos - deltaY * sin, deltaY: deltaX * sin + deltaY * cos };
}
@@ -39,6 +39,25 @@ export interface GestureState {
actualRotation: number;
editScaleX: number;
editScaleY: number;
// Rendered-per-CSS-pixel factor of the element itself at gesture start (a GSAP
// scale() transform makes this > 1) — the resize draft divides by it so the box
// 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;
@@ -77,21 +96,31 @@ export function resolveDomEditResizeGesture(input: {
actualHeight: number;
scaleX: number;
scaleY: number;
// Rendered-per-CSS-pixel factor of the element itself (its live GSAP scale).
// The CSS width/height the draft writes get multiplied by this on screen, so
// the cursor delta must be divided by it — otherwise the box outruns the
// pointer on a rescaled element and snaps back on release. Defaults to 1.
contentScaleX?: number;
contentScaleY?: number;
dx: number;
dy: number;
uniform: boolean;
}): { overlayWidth: number; overlayHeight: number; width: number; height: number } {
const scaleX = input.scaleX > 0 ? input.scaleX : 1;
const scaleY = input.scaleY > 0 ? input.scaleY : 1;
const contentScaleX =
input.contentScaleX !== undefined && input.contentScaleX > 0 ? input.contentScaleX : 1;
const contentScaleY =
input.contentScaleY !== undefined && input.contentScaleY > 0 ? input.contentScaleY : 1;
if (input.uniform) {
const deltaX = input.dx / scaleX;
const deltaY = input.dy / scaleY;
const deltaX = input.dx / (scaleX * contentScaleX);
const deltaY = input.dy / (scaleY * contentScaleY);
const delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY;
const side = Math.max(1, Math.max(input.actualWidth, input.actualHeight) + delta);
return {
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX),
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY),
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX * contentScaleX),
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY * contentScaleY),
width: side,
height: side,
};
@@ -100,8 +129,8 @@ export function resolveDomEditResizeGesture(input: {
return {
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, input.originWidth + input.dx),
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, input.originHeight + input.dy),
width: Math.max(1, input.actualWidth + input.dx / scaleX),
height: Math.max(1, input.actualHeight + input.dy / scaleY),
width: Math.max(1, input.actualWidth + input.dx / (scaleX * contentScaleX)),
height: Math.max(1, input.actualHeight + input.dy / (scaleY * contentScaleY)),
};
}
@@ -2,6 +2,7 @@
* 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,
@@ -120,8 +121,37 @@ export function startGesture(
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
// element's actual visual angle and commits an absolute angle to the timeline.
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
// The draft writes CSS width/height, so the resize base must be the CSS
// layout size. offsetWidth/Height are transform-free; the overlay-rect
// fallback (rect / editScale) includes the element's own GSAP scale and
// would make a rescaled element's draft grow from the RENDERED size.
const layoutWidth = sel.element.offsetWidth;
const layoutHeight = sel.element.offsetHeight;
const actualWidth =
size.width > 0 ? size.width : layoutWidth > 0 ? layoutWidth : rect.width / rect.editScaleX;
const actualHeight =
size.height > 0 ? size.height : layoutHeight > 0 ? layoutHeight : rect.height / rect.editScaleY;
// overlay rect = cssSize x contentScale x editScale, so the element's own
// render factor (its GSAP scale) falls out of the measured rect. 1 when
// unscaled or unmeasurable.
const rawContentScaleX = rect.width / (rect.editScaleX * actualWidth);
const rawContentScaleY = rect.height / (rect.editScaleY * actualHeight);
const contentScaleX =
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;
@@ -184,6 +214,9 @@ export function startGesture(
actualRotation: rotation.angle,
editScaleX: rect.editScaleX,
editScaleY: rect.editScaleY,
contentScaleX,
contentScaleY,
resizeAnchor,
manualEditDragToken,
snapContext,
};
@@ -3,6 +3,7 @@
* selector utilities, and composition source resolution.
* No imports from other domEditing* modules — safe to import from anywhere.
*/
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes";
// ─── Type guard ───────────────────────────────────────────────────────────────
@@ -28,8 +29,6 @@ export function isTextBearingTag(tagName: string): boolean {
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
}
const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden";
export function isElementVisibleThroughAncestors(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return true;
@@ -4,6 +4,7 @@
* Owns: onPointerMove, onPointerUp, clearPointerState.
* startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
*/
import { setElementGsapPosition } from "../../utils/elementGsap";
import type { RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import {
@@ -53,6 +54,13 @@ import {
resolveEquidistanceGuides,
SNAP_THRESHOLD_PX,
} from "./snapEngine";
/** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */
function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void {
const anchor = g.resizeAnchor;
if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return;
setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY);
}
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
const setDraftOverlayRect = (next: OverlayRect) => {
opts.setOverlayRect(next);
@@ -175,7 +183,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle);
if (!draftViaGsap) {
applyStudioRotationDraft(sel.element, rotated);
}
return;
@@ -278,12 +287,35 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualHeight: g.actualHeight,
scaleX: g.editScaleX,
scaleY: g.editScaleY,
contentScaleX: g.contentScaleX,
contentScaleY: g.contentScaleY,
dx,
dy,
uniform: e.shiftKey,
});
applyStudioBoxSizeDraft(sel.element, nextSize);
// Pin the gesture anchor (top-left): with a live scale transform, the CSS
// size change shifts the rendered box around the element center. Measure
// the drift of the gesture-start corner and counter it via GSAP x/y —
// accumulated onto the previous pin so the correction converges instead
// of oscillating. The release-time position compensation re-measures the
// drop, so the pin composes with the commit.
const anchor = g.resizeAnchor;
if (anchor) {
const pinned = sel.element.getBoundingClientRect();
const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x);
const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y);
if (
setElementGsapPosition(
sel.element,
anchor.baseGsapX + nextPinX,
anchor.baseGsapY + nextPinY,
)
) {
anchor.pinX = nextPinX;
anchor.pinY = nextPinY;
}
}
// Re-read BCR after applying dimensions. For elements with a GSAP
// scale transform and centered transform-origin the visual top-left
// drifts and the visual size diverges from the raw CSS size, so BCR
@@ -382,6 +414,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}
if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
restoreResizeAnchorPin(sel.element, g);
restoreStudioBoxSize(sel.element, g.initialBoxSize);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
if (box) {
@@ -411,7 +444,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
restoreStudioRotation(sel.element, g.initialRotation);
}
};
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle);
if (!rotationChanged) {
restoreRotation();
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
return;
@@ -422,14 +456,17 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
applyStudioRotation(sel.element, finalRotation);
}
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
.catch(() => {
.catch((error) => {
console.error("rotate commit failed", error);
if (
g.manualEditDragToken &&
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
)
restoreRotation();
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
.finally(() => {
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
});
} else if (g.kind === "drag") {
const dx = g.lastSnappedDx ?? e.clientX - g.startX;
const dy = g.lastSnappedDy ?? e.clientY - g.startY;
@@ -469,12 +506,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
const finalSize = readStudioBoxSize(sel.element);
applyStudioBoxSize(sel.element, finalSize);
void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
.catch(() => {
.catch((error) => {
console.error("resize commit failed", error);
if (
g.manualEditDragToken &&
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
)
) {
restoreResizeAnchorPin(sel.element, g);
restoreStudioBoxSize(sel.element, g.initialBoxSize);
}
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
}
@@ -494,6 +534,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
restoreGestureOverlayRect(g);
}
if (g?.mode === "box-size" && sel) {
restoreResizeAnchorPin(sel.element, g);
restoreStudioBoxSize(sel.element, g.initialBoxSize);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
restoreGestureOverlayRect(g);
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import { afterEach, 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 });
});
/**
* Scale-route resize: an element whose visual size is driven by a scale-group
* tween. The intercept must (a) route the commit through SCALE, never
* width/height, and (b) resolve convert-to-keyframes from-values through the
* group filter — an opacity-touching intro tween on the same element must not
* ride into the converted keyframes (the disappearance bake class).
*/
function makeGradedElement(): HTMLElement {
const el = document.createElement("img");
el.id = "clip";
el.setAttribute("data-hf-studio-original-width", "640");
el.setAttribute("data-hf-studio-original-height", "360");
// Grading contract: source hidden, canvas carries effective opacity.
el.setAttribute("data-hf-color-grading-source-hidden", "");
const canvas = document.createElement("canvas");
canvas.id = "__hf_color_grading_clip";
canvas.style.opacity = "0.98";
document.body.append(el, canvas);
return el;
}
function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
// The element's OPACITY intro tween lives on the timeline: unfiltered
// capture would pick `opacity` up via the other-tween sweep.
const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } };
return {
contentWindow: {
__timelines: { main: { getChildren: () => [opacityIntro] } },
gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 },
},
contentDocument: document,
} as unknown as HTMLIFrameElement;
}
function scaleFromTween(): GsapAnimation {
return {
id: "#clip-from-200-scale",
targetSelector: "#clip",
propertyGroup: "scale",
method: "from",
properties: { scale: 0.9 },
position: 0.2,
resolvedStart: 0.2,
duration: 0.8,
} as unknown as GsapAnimation;
}
function keyframedScaleFixture(): GsapAnimation {
return {
...scaleFromTween(),
keyframes: {
keyframes: [
{ percentage: 0, properties: { scale: 0.9 } },
{ percentage: 100, properties: { scale: 1 } },
],
},
} as unknown as GsapAnimation;
}
/** Drive one resize through the intercept, returning every committed mutation. */
async function runResize(
el: HTMLElement,
iframe: HTMLIFrameElement,
size: { width: number; height: number },
): Promise<Array<Record<string, unknown>>> {
const selection = { id: "clip", selector: "#clip", element: el } as unknown as DomEditSelection;
usePlayerStore.setState({ currentTime: 0.5 }); // inside the tween's range
const committed: Array<Record<string, unknown>> = [];
const commitMutation = vi.fn(async (_sel: unknown, mutation: Record<string, unknown>) => {
committed.push(mutation);
});
const handled = await tryGsapResizeIntercept(
selection,
size,
[scaleFromTween()],
iframe,
commitMutation as never,
async () => [keyframedScaleFixture()],
);
expect(handled).toBe(true);
return committed;
}
it("scale-route resize converts via the group filter and commits scale, not width/height", async () => {
const el = makeGradedElement();
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
// uniform: 800/640 === 450/360
const committed = await runResize(el, iframe, { width: 800, height: 450 });
const convert = committed.find((m) => m.type === "convert-to-keyframes");
expect(convert).toBeDefined();
const fromValues = convert!.resolvedFromValues as Record<string, number>;
// Group filter: the opacity intro tween must NOT leak into the conversion.
expect(fromValues).not.toHaveProperty("opacity");
expect(fromValues).toHaveProperty("scale");
// Every committed property is scale-group — the resize never writes
// width/height for a scale-driven element (the double-apply bug class).
const allProps = committed.flatMap((m) => [
...Object.keys((m.properties as Record<string, unknown>) ?? {}),
...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
]);
expect(allProps).not.toContain("width");
expect(allProps).not.toContain("height");
expect(allProps.some((p) => p === "scale" || p === "scaleX")).toBe(true);
});
it("non-uniform drag commits scaleX/scaleY longhands", async () => {
const el = makeGradedElement();
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0 });
// scaleX 1.25 vs scaleY 1.0 → non-uniform
const committed = await runResize(el, iframe, { width: 800, height: 360 });
const serialized = JSON.stringify(committed);
expect(serialized).toContain("scaleX");
expect(serialized).toContain("scaleY");
});
@@ -0,0 +1,385 @@
/**
* Resize-gesture GSAP intercept: routes a manual resize on a scale-driven
* element into scale commits (per-axis longhands for non-uniform drags, with
* keyframe normalization), then settles position synchronously so the drop
* frame can't jump. Split from gsapRuntimeBridge, which owns the shared
* group-tween resolution used by the drag/resize/rotate intercepts.
*/
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { clearStudioBoxSize } from "../components/editor/manualEdits";
import { setElementGsapPosition } from "../utils/elementGsap";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import {
commitStaticGsapPosition,
commitStaticGsapSize,
commitKeyframedSizeFromResize,
computeCurrentPercentage,
findExistingPositionWrite,
findSizeSetAnimation,
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { selectorFromSelection } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
/** Build identity (zero / one) values for each property in `source`. */
function synthesizeIdentityProps(
source: Record<string, number | string>,
): Record<string, number | string> {
const id: Record<string, number | string> = {};
for (const [k, v] of Object.entries(source)) {
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
else id[k] = v;
}
return id;
}
// ── Resize intercept ──────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export async function tryGsapResizeIntercept(
selection: DomEditSelection,
size: { width: number; height: number },
animations: GsapAnimation[],
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
// If the element already has a scale-group tween, resize should modify scale
// (the user is resizing something whose visual size is driven by scale).
// Otherwise, use the size group (width/height).
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
const resolved = await resolveGroupTween(
resizeGroup,
animations,
selection,
commitMutation,
fetchFallbackAnimations,
);
let anim = resolved?.anim ?? null;
if (!anim || anim.method === "set") {
const sel = selectorFromSelection(selection);
if (!sel) return false;
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
// If the element is animated (has a real tween, not just a static size
// hold), keyframe the size at the playhead so other keyframes keep theirs —
// instead of a global set that resizes every frame.
if (resizeGroup === "size") {
const animatedTween = pickClosestToPlayhead(
animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
);
if (animatedTween) {
const handled = await commitKeyframedSizeFromResize(
selection,
size,
sel,
sizeSet,
animatedTween,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
);
if (handled) return true;
}
}
await commitStaticGsapSize(selection, size, sel, sizeSet, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
return true;
}
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
if (activeKeyframePct != null) setActiveKeyframePct(null);
const coalesceKey = `gsap:resize:${anim.id}`;
const selector = selectorFromSelection(selection);
// Scope every capture to the resize group — same contract as the rotation
// intercept. Unfiltered, an opacity-touching intro tween on the element
// would ride into resize conversions/backfills (the Fix-2 bake class).
const runtimeProps = selector
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: {};
let resizeProps: Record<string, number>;
let scaleDraftEl: HTMLElement | null = null;
let scaleDraftDropPoint: { x: number; y: number } | null = null;
let nonUniformScale = false;
if (resizeGroup === "scale") {
// Iframe-realm element — instanceof HTMLElement fails across realms; the
// selector targets composition elements, and every use below is duck-typed.
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
// The resize draft modifies el.style.width/height, so read the ORIGINAL
// dimensions saved by the draft system before it ran.
const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
const origH = Number.parseFloat(el?.getAttribute("data-hf-studio-original-height") ?? "");
const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
const cssH = Number.isFinite(origH) && origH > 0 ? origH : cssW;
// `size` is the draft's CSS box; on screen it is multiplied by the element's
// LIVE scale (the draft divides the cursor delta by it — see
// resolveDomEditResizeGesture). The committed keyframe REPLACES that live
// scale, so it must reproduce the rendered intent: css × live / original.
// Live scale is 1 on a fresh element (first resize), so this is a no-op there.
const rawLiveScaleX = readGsapProperty(iframe, selector ?? null, "scaleX") ?? 1;
const rawLiveScaleY = readGsapProperty(iframe, selector ?? null, "scaleY") ?? 1;
const liveScaleX = rawLiveScaleX > 0 ? rawLiveScaleX : 1;
const liveScaleY = rawLiveScaleY > 0 ? rawLiveScaleY : 1;
const newScaleX = roundTo3((size.width * liveScaleX) / cssW);
const newScaleY = roundTo3((size.height * liveScaleY) / cssH);
// A free-form corner drag is usually NON-uniform. A single `scale` value
// can't represent it — committing width-derived scale used to snap the
// height at drop. Commit scaleX/scaleY longhands instead; keep the uniform
// shorthand when the two agree (aspect-true drags, shift-drags).
nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX };
scaleDraftEl = el;
// Where the user DROPPED the box: the draft (anchor-pinned to the
// gesture-start top-left) is still applied here, so this rect is exactly
// what the preview showed at release. The committed scale renders around
// the element CENTER instead — the finalize step below measures that
// difference and compensates, so release matches the drop pixel-for-pixel
// regardless of live scale or repeat resizes.
if (el) {
const dropRect = el.getBoundingClientRect();
scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
}
} else {
resizeProps = {
width: Math.round(size.width),
height: Math.round(size.height),
};
}
// Finalize a scale-route commit: tear down the gesture's inline width/height
// draft (leaving it applied compounds with the committed scale — the element
// jumps past the dragged size), then MEASURE where the committed scale
// actually rendered the box and shift the position hold by the residual so
// it lands back on the drop point. The compensation only applies to a STATIC
// position (a `tl.set` hold or none) — a keyframed position path has no
// single anchor to preserve, so it keeps the plain center-scale behavior.
// The size route commits the same width/height channels the draft wrote, so
// it needs none of this.
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
// approximate rather than corner-exact.
// fallow-ignore-next-line complexity
const finalizeScaleResizeCommit = async () => {
if (!scaleDraftEl) return;
clearStudioBoxSize(scaleDraftEl);
if (!scaleDraftDropPoint || !selector) return;
const hasLivePositionTween = hasNonHoldTweenForElement(
iframe,
selector,
undefined,
POSITION_CHANNELS,
);
if (hasLivePositionTween) {
return;
}
// The scale commit has rendered (instant patch or soft-reload seek) and the
// draft is cleared — this rect is where the element ACTUALLY sits now.
const post = scaleDraftEl.getBoundingClientRect();
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) return;
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
// The ONE corrected position — rounded once so the live runtime and the
// persisted file agree exactly (commitStaticGsapPosition composes the same
// rounded value from this delta).
const corrected = {
x: Math.round(gsapPos.x + residual.x),
y: Math.round(gsapPos.y + residual.y),
};
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
// rendered the committed scale around the element center — NOT at the drop
// point — and everything up to here runs in the same microtask chain as
// that reload, so no frame has painted the uncorrected position yet. The
// server persist below costs network round-trips; without this set, the
// element visibly sits at the wrong spot for those frames (the drop
// "jump"). The persisted commit re-applies the same values (idempotent).
setElementGsapPosition(scaleDraftEl, corrected.x, corrected.y);
// Re-fetch: the scale commit above just rewrote the script, so the caller's
// animation list (and its ids) may be stale for the position lookup.
const currentAnimations = fetchFallbackAnimations
? await fetchFallbackAnimations()
: (resolved?.animations ?? animations);
const existingSet = findExistingPositionWrite(currentAnimations, selector);
// Delta chosen so the drag-path math composes back to exactly `corrected`
// (no drag scratch attrs exist during a resize, so base = gsapPos).
await commitStaticGsapPosition(
selection,
{ x: corrected.x - gsapPos.x, y: corrected.y - gsapPos.y },
gsapPos,
selector,
existingSet,
{
commitMutation,
fetchAnimations: fetchFallbackAnimations,
},
);
};
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
// tween for this resize group, so nudge it as a whole rather than adding a
// keyframe at the playhead.
if (!usePlayerStore.getState().autoKeyframeEnabled) {
if (activeKeyframePct != null) setActiveKeyframePct(null);
await commitWholePropertyOffset(
selection,
anim,
resizeProps,
pct,
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
"Resize animation",
);
await finalizeScaleResizeCommit();
return true;
}
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveTweenDuration(anim);
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
// Outside-range uses the extend path which handles everything atomically.
if (!outsideRange) {
// fallow-ignore-next-line code-duplication
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
const resolvedFromValues = selector
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: undefined;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
);
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const refreshed = fresh.find(
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
);
if (refreshed) anim = refreshed;
}
}
}
// A NON-uniform scale must also take the full-rewrite path: it mixes
// scaleX/scaleY into a tween whose existing keyframes may carry the uniform
// `scale` shorthand, and GSAP's percentage keyframes animate each property
// name independently — a shorthand/longhand mix would leave the old `scale`
// sub-tween running against the new scaleX/scaleY. The rewrite below
// normalizes every keyframe to the longhands. For an in-range resize the
// min/max window math below degenerates to the tween's own start/duration,
// so timing is unchanged.
if ((outsideRange || nonUniformScale) && ts !== null) {
// For flat tweens, synthesize the keyframes from the tween's properties
const kfs =
anim.keyframes?.keyframes ??
(() => {
const fromProps =
anim.method === "from" || anim.method === "fromTo"
? { ...anim.properties }
: synthesizeIdentityProps(anim.properties);
const toProps =
anim.method === "from"
? synthesizeIdentityProps(anim.properties)
: { ...anim.properties };
return [
{ percentage: 0, properties: fromProps },
{ percentage: 100, properties: toProps },
];
})();
const newStart = Math.min(ct, ts);
const newEnd = Math.max(ct, ts + td);
const newDuration = Math.max(0.01, newEnd - newStart);
const existingKfs = kfs;
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
for (const kf of existingKfs) {
const absTime = ts + (kf.percentage / 100) * td;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
const props = { ...kf.properties };
// Normalize the uniform `scale` shorthand to longhands when this commit
// writes scaleX/scaleY, so the tween never mixes the two forms.
if (nonUniformScale && "scale" in props) {
const uniform = props.scale;
if (typeof uniform === "number") {
props.scaleX = uniform;
props.scaleY = uniform;
}
delete props.scale;
}
// Only backfill properties that the animation already had (x, y, scale).
// Don't backfill width/height — they should only appear on the resize keyframe.
for (const k of Object.keys(resizeProps)) {
if (k in props) continue;
if (k === "width" || k === "height") continue;
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
remapped.push({ percentage: newPct, properties: props });
}
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
// An in-range rewrite can land on an existing keyframe's percentage —
// merge into it instead of emitting a duplicate step.
const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05);
if (collidingKf) Object.assign(collidingKf.properties, resizeProps);
else remapped.push({ percentage: targetPct, properties: resizeProps });
remapped.sort((a, b) => a.percentage - b.percentage);
await commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes: remapped,
},
{
label: outsideRange
? `Resize (extended to ${ct.toFixed(2)}s)`
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
softReload: true,
coalesceKey,
},
);
await finalizeScaleResizeCommit();
return true;
}
const SIZE_PROPS = new Set(["width", "height"]);
const backfillDefaults: Record<string, number> = {};
for (const k of Object.keys(runtimeProps)) {
if (SIZE_PROPS.has(k)) continue;
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
await commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties: resizeProps,
backfillDefaults,
},
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
);
await finalizeScaleResizeCommit();
return true;
}
// ── Rotation intercept ────────────────────────────────────────────────────
+3 -226
View File
@@ -17,17 +17,14 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import {
commitStaticGsapPosition,
commitStaticGsapRotation,
commitStaticGsapSize,
commitKeyframedSizeFromResize,
commitWholePathOffset,
computeCurrentPercentage,
findExistingPositionWrite,
findRotationSetAnimation,
findSizeSetAnimation,
materializeIfDynamic,
} from "./gsapDragCommit";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { selectorFromSelection } from "./gsapShared";
import {
@@ -36,12 +33,11 @@ import {
readGsapPositionFromIframe,
} from "./gsapPositionDetection";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
import { roundTo3 } from "../utils/rounding";
// Position channels — used to scope the "has a live position tween?" check so a
// sibling rotation/scale animation never forces a static position hold into the
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
const POSITION_CHANNELS = [
export const POSITION_CHANNELS = [
"x",
"y",
"xPercent",
@@ -67,7 +63,7 @@ const POSITION_CHANNELS = [
* re-fetch, then return the group tween
* 3. null — caller must handle the missing-tween case
*/
async function resolveGroupTween(
export async function resolveGroupTween(
group: PropertyGroupName,
animations: GsapAnimation[],
selection: DomEditSelection,
@@ -264,224 +260,6 @@ export { readGsapProperty, readAllAnimatedProperties };
// ── Identity-prop synthesis ───────────────────────────────────────────────
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
/** Build identity (zero / one) values for each property in `source`. */
function synthesizeIdentityProps(
source: Record<string, number | string>,
): Record<string, number | string> {
const id: Record<string, number | string> = {};
for (const [k, v] of Object.entries(source)) {
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
else id[k] = v;
}
return id;
}
// ── Resize intercept ──────────────────────────────────────────────────────
export async function tryGsapResizeIntercept(
selection: DomEditSelection,
size: { width: number; height: number },
animations: GsapAnimation[],
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
// If the element already has a scale-group tween, resize should modify scale
// (the user is resizing something whose visual size is driven by scale).
// Otherwise, use the size group (width/height).
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
const resolved = await resolveGroupTween(
resizeGroup,
animations,
selection,
commitMutation,
fetchFallbackAnimations,
);
let anim = resolved?.anim ?? null;
if (!anim || anim.method === "set") {
const sel = selectorFromSelection(selection);
if (!sel) return false;
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
// If the element is animated (has a real tween, not just a static size
// hold), keyframe the size at the playhead so other keyframes keep theirs —
// instead of a global set that resizes every frame.
if (resizeGroup === "size") {
const animatedTween = pickClosestToPlayhead(
animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
);
if (animatedTween) {
const handled = await commitKeyframedSizeFromResize(
selection,
size,
sel,
sizeSet,
animatedTween,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
);
if (handled) return true;
}
}
await commitStaticGsapSize(selection, size, sel, sizeSet, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
return true;
}
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
if (activeKeyframePct != null) setActiveKeyframePct(null);
const coalesceKey = `gsap:resize:${anim.id}`;
const selector = selectorFromSelection(selection);
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
let resizeProps: Record<string, number>;
if (resizeGroup === "scale") {
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
// The resize draft modifies el.style.width, so read the ORIGINAL width
// saved by the draft system before it ran.
const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
const newScale = roundTo3(size.width / cssW);
resizeProps = { scale: newScale };
} else {
resizeProps = {
width: Math.round(size.width),
height: Math.round(size.height),
};
}
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
// tween for this resize group, so nudge it as a whole rather than adding a
// keyframe at the playhead.
if (!usePlayerStore.getState().autoKeyframeEnabled) {
if (activeKeyframePct != null) setActiveKeyframePct(null);
await commitWholePropertyOffset(
selection,
anim,
resizeProps,
pct,
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
"Resize animation",
);
return true;
}
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveTweenDuration(anim);
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
// Outside-range uses the extend path which handles everything atomically.
if (!outsideRange) {
// fallow-ignore-next-line code-duplication
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
const resolvedFromValues = selector
? readAllAnimatedProperties(iframe, selector, anim)
: undefined;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
);
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const refreshed = fresh.find(
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
);
if (refreshed) anim = refreshed;
}
}
}
if (outsideRange && ts !== null) {
// For flat tweens, synthesize the keyframes from the tween's properties
const kfs =
anim.keyframes?.keyframes ??
(() => {
const fromProps =
anim.method === "from" || anim.method === "fromTo"
? { ...anim.properties }
: synthesizeIdentityProps(anim.properties);
const toProps =
anim.method === "from"
? synthesizeIdentityProps(anim.properties)
: { ...anim.properties };
return [
{ percentage: 0, properties: fromProps },
{ percentage: 100, properties: toProps },
];
})();
const newStart = Math.min(ct, ts);
const newEnd = Math.max(ct, ts + td);
const newDuration = Math.max(0.01, newEnd - newStart);
const existingKfs = kfs;
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
for (const kf of existingKfs) {
const absTime = ts + (kf.percentage / 100) * td;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
const props = { ...kf.properties };
// Only backfill properties that the animation already had (x, y, scale).
// Don't backfill width/height — they should only appear on the resize keyframe.
for (const k of Object.keys(resizeProps)) {
if (k in props) continue;
if (k === "width" || k === "height") continue;
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
remapped.push({ percentage: newPct, properties: props });
}
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
remapped.push({ percentage: targetPct, properties: resizeProps });
remapped.sort((a, b) => a.percentage - b.percentage);
await commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes: remapped,
},
{ label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey },
);
return true;
}
const SIZE_PROPS = new Set(["width", "height"]);
const backfillDefaults: Record<string, number> = {};
for (const k of Object.keys(runtimeProps)) {
if (SIZE_PROPS.has(k)) continue;
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
await commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties: resizeProps,
backfillDefaults,
},
{ label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
);
return true;
}
// ── Rotation intercept ────────────────────────────────────────────────────
export async function tryGsapRotationIntercept(
selection: DomEditSelection,
angle: number,
@@ -517,7 +295,6 @@ export async function tryGsapRotationIntercept(
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
// gesture's live preview already gsap.set this value (single source of truth).
const newRotation = Math.round(angle);
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
// mirroring the static position set. Idempotent: re-rotate updates an existing
@@ -133,6 +133,69 @@ describe("patchRuntimeTweenInPlace — set tweens", () => {
});
});
describe("patchRuntimeTweenInPlace — authored-opacity capture guard", () => {
function makeStampedEl(id: string, stamped: string | null, inlineOpacity: string) {
const style = new Map<string, string>([["opacity", inlineOpacity]]);
return {
el: {
id,
style: {
setProperty: (k: string, v: string) => void style.set(k, v),
removeProperty: (k: string) => void style.delete(k),
},
getAttribute: (name: string) => (name === "data-hf-authored-opacity" ? stamped : null),
},
style,
};
}
it("restores the stamped authored opacity before an opacity-touching patch", () => {
// Runtime transient (grading hide / mid-flight tween) baked into inline style.
const { el, style } = makeStampedEl("box", "0.75", "0");
const setTween = makeTween(
{ vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
el,
);
const { iframe } = fakeIframe(el, [setTween]);
const ok = patchRuntimeTweenInPlace(iframe, "#box", {
kind: "set",
props: { opacity: 0.5 },
});
expect(ok).toBe(true);
// The re-init must capture the authored 0.75, not the transient 0.
expect(style.get("opacity")).toBe("0.75");
expect(setTween.vars.opacity).toBe(0.5);
});
it("removes inline opacity when the stamp recorded no authored value", () => {
const { el, style } = makeStampedEl("box", "", "0");
const setTween = makeTween(
{ vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
el,
);
const { iframe } = fakeIframe(el, [setTween]);
patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { opacity: 0.5 } });
expect(style.has("opacity")).toBe(false);
});
it("leaves inline opacity alone for a position-only patch", () => {
const { el, style } = makeStampedEl("box", "0.75", "0");
const setTween = makeTween(
{ vars: { x: 0, y: 0, duration: 0 }, targetIds: ["box"], duration: 0 },
el,
);
const { iframe } = fakeIframe(el, [setTween]);
patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { x: 10, y: 20 } });
expect(style.get("opacity")).toBe("0");
});
});
describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => {
it("patches the {x,y} set, not a co-located rotation-only set", () => {
const el = { id: "dual" };
+35 -1
View File
@@ -13,6 +13,7 @@
* "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes`
* uses (`resolveRuntimeTween`), so read and write agree on the target.
*/
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "../utils/authoredOpacity";
import {
resolveRuntimeTween,
type RuntimeTween,
@@ -235,6 +236,34 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo
player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
}
/** Does this change touch the opacity channel (whose re-init reads inline style)? */
function changeTouchesOpacity(change: RuntimeTweenChange): boolean {
if (change.kind === "set" || change.kind === "global-set")
return change.props.opacity !== undefined;
if (change.kind === "keyframes") return change.keyframes.some((step) => "opacity" in step);
return "opacity" in change.props;
}
/**
* A tween re-initialization (invalidate, or kill+recreate for keyframe-rebuild)
* captures opacity from the element's CURRENT inline style — for a color-graded
* source (hidden with `opacity: 0 !important`) or a mid-flight tween that's a
* runtime transient, not the authored value, and the capture makes it permanent.
* Restore the runtime's parse-time authored capture (data-hf-authored-opacity)
* first; the re-seek after the patch re-renders the animated value anyway.
* Duck-typed (no instanceof): the targets live in the preview iframe's realm.
*/
function restoreAuthoredOpacityForCapture(tween: RuntimeTween): void {
const targets = typeof tween.targets === "function" ? tween.targets() : [];
for (const target of targets ?? []) {
const el = target as HTMLElement | null;
if (!el?.style || typeof el.getAttribute !== "function") continue;
const authored = readStampedAuthoredOpacity(el);
if (authored === null) continue;
applyAuthoredInlineOpacity(el.style, authored);
}
}
/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload.
* `global-set` is handled before this (no tween) and never reaches here. */
function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
@@ -270,13 +299,18 @@ export function patchRuntimeTweenInPlace(
if (!resolved) return false;
const { tween, timeline } = resolved;
if (changeTouchesOpacity(change)) restoreAuthoredOpacityForCapture(tween);
if (!applyChange(tween, change)) return false;
// A rebuild already recreated the tween; set/keyframes mutate vars in place, so
// invalidate to make GSAP re-read them on the next render. Either way, re-seek.
// Invalidate ONLY the edited tween — never the whole timeline. A timeline-wide
// invalidate re-initializes every from() tween against the CURRENT inline
// styles, and the color-grading engine hides its source elements with
// `opacity: 0 !important` — so every graded element's from(opacity) re-captures
// 0 as its end value and animates 0→0 forever (all graded elements vanish).
if (change.kind !== "keyframe-rebuild") {
tween.invalidate?.();
timeline.invalidate?.();
}
seekToCurrent(iframe, timeline);
return true;
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
} from "@hyperframes/core/color-grading";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
/**
* Regression: converting a property-group tween to keyframes resolves "current
* values" via readAllAnimatedProperties. Two ways that used to bake garbage
* into the composition file:
*
* 1. The group filter only pruned the tween's OWN properties — the baseline
* pass still captured every property ANY tween on the element touches, so
* a rotation commit carried `opacity` from the intro from() tween.
* 2. `gsap.getProperty(el, "opacity")` on a color-grading source reads the
* runtime hide (inline `opacity: 0 !important`), not the animated value —
* so the captured opacity was the transient 0, which then animated 0 → 0
* on the next full load and the element disappeared.
*/
function fakeIframe(
el: Element,
opts: { gsapValues: Record<string, number>; otherTweenVars?: Record<string, number> },
): HTMLIFrameElement {
const children = opts.otherTweenVars
? [{ targets: () => [el], vars: { duration: 0.8, ...opts.otherTweenVars } }]
: [];
return {
contentWindow: {
__timelines: { main: { getChildren: () => children } },
gsap: { getProperty: (_el: Element, prop: string) => opts.gsapValues[prop] ?? 0 },
},
contentDocument: document,
} as unknown as HTMLIFrameElement;
}
function rotationSetAnim(): GsapAnimation {
return {
id: "#clip-set-0-rotation",
targetSelector: "#clip",
method: "set",
properties: { rotation: 0 },
} as unknown as GsapAnimation;
}
afterEach(() => {
document.body.innerHTML = "";
});
describe("readAllAnimatedProperties group filter", () => {
it("keeps other tweens' out-of-group properties out of a grouped resolve", () => {
const el = document.createElement("div");
el.id = "clip";
document.body.appendChild(el);
const iframe = fakeIframe(el, {
gsapValues: { rotation: -28.1, opacity: 0, rotationX: 52, rotationY: -47 },
otherTweenVars: { opacity: 0, rotationX: 52, rotationY: -47 },
});
const result = readAllAnimatedProperties(iframe, "#clip", rotationSetAnim(), "rotation");
expect(result).toEqual({ rotation: -28.1 });
});
});
describe("color-grading opacity truth", () => {
function gradedElement(): HTMLElement {
const el = document.createElement("img");
el.id = "clip";
el.setAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR, "");
const canvas = document.createElement("canvas");
canvas.id = `${HF_COLOR_GRADING_CANVAS_ID_PREFIX}clip`;
canvas.style.opacity = "0.98";
document.body.append(el, canvas);
return el;
}
it("resolves opacity from the grading canvas, not the runtime hide", () => {
const el = gradedElement();
const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
const anim = {
id: "#clip-from-200-visual",
targetSelector: "#clip",
method: "from",
properties: { opacity: 0.5 },
} as unknown as GsapAnimation;
const result = readAllAnimatedProperties(iframe, "#clip", anim);
expect(result.opacity).toBe(0.98);
});
it("readGsapProperty takes the same detour", () => {
const el = gradedElement();
const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.98);
});
it("reads GSAP directly when the source is not grading-hidden", () => {
const el = document.createElement("div");
el.id = "clip";
document.body.appendChild(el);
const iframe = fakeIframe(el, { gsapValues: { opacity: 0.3 } });
expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3);
});
});
+39 -14
View File
@@ -3,9 +3,33 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { getIframeGsap, queryIframeElement } from "./gsapShared";
import {
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
} from "@hyperframes/core/color-grading";
import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
/**
* The element's live value for `prop` as GSAP drives it. Opacity on a
* color-grading-hidden source needs a detour: the runtime hides the source
* with inline `opacity: 0 !important`, so computed opacity is the hide, not
* the animated value. The grading canvas mirrors the source's effective
* opacity every frame, so it is the truth for that one property reading the
* raw 0 here is what bakes `opacity: 0` into committed keyframes.
*/
function readLiveGsapValue(gsap: IframeGsap, el: Element, prop: string): number {
if (prop === "opacity" && el.getAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) != null && el.id) {
const canvas = el.ownerDocument.getElementById(HF_COLOR_GRADING_CANVAS_ID_PREFIX + el.id);
const win = el.ownerDocument.defaultView;
if (canvas && win) {
const val = Number(win.getComputedStyle(canvas).opacity);
if (Number.isFinite(val)) return val;
}
}
return Number(gsap.getProperty(el, prop));
}
export function readGsapProperty(
iframe: HTMLIFrameElement | null,
selector: string | null,
@@ -17,7 +41,7 @@ export function readGsapProperty(
const el = queryIframeElement(iframe, selector);
if (!el) return null;
try {
const val = Number(gsap.getProperty(el, prop));
const val = readLiveGsapValue(gsap, el, prop);
if (!Number.isFinite(val)) return null;
return POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
} catch {
@@ -77,15 +101,15 @@ export function readAllAnimatedProperties(
for (const p of Object.keys(anim.properties)) propKeys.add(p);
}
// When a group filter is specified, only keep properties belonging to that group.
if (group) {
for (const p of propKeys) {
if (classifyPropertyGroup(p) !== group) propKeys.delete(p);
}
}
// When a group filter is specified, only properties belonging to that group
// may enter the result — including the baseline passes below. The whole
// point of property-group tweens is that a rotation commit never carries
// opacity/rotationX/etc. captured from unrelated tweens on the element.
const inGroup = (p: string) => !group || classifyPropertyGroup(p) === group;
const groupedPropKeys = new Set([...propKeys].filter(inGroup));
for (const prop of propKeys) {
const val = Number(gsap.getProperty(el, prop));
for (const prop of groupedPropKeys) {
const val = readLiveGsapValue(gsap, el, prop);
if (Number.isFinite(val)) {
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
}
@@ -110,13 +134,13 @@ export function readAllAnimatedProperties(
const vars = child.vars;
if (!vars) continue;
for (const k of Object.keys(vars)) {
if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k);
if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k);
}
}
}
}
} catch {}
for (const p of propKeys) otherTweenProps.delete(p);
for (const p of groupedPropKeys) otherTweenProps.delete(p);
// Tier 1: Transform + visual properties with universal CSS defaults.
// Safe to compare against hardcoded values — these are always 0 or 1
@@ -148,11 +172,11 @@ export function readAllAnimatedProperties(
// Collect all properties that ANY tween on this element explicitly targets.
// Only capture baseline values for these — GSAP reports non-default values
// (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
const allTweenedProps = new Set([...propKeys, ...otherTweenProps]);
const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]);
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
if (prop in result) continue;
if (!allTweenedProps.has(prop)) continue;
const val = Number(gsap.getProperty(el, prop));
const val = readLiveGsapValue(gsap, el, prop);
if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
result[prop] = roundTo3(val);
}
@@ -184,6 +208,7 @@ export function readAllAnimatedProperties(
} catch {}
for (const prop of COMPUTED_BASELINE) {
if (prop in result) continue;
if (!inGroup(prop)) continue;
if (otherTweenProps.has(prop)) continue;
const gsapVal = Number(gsap.getProperty(el, prop));
if (!Number.isFinite(gsapVal)) continue;
@@ -39,15 +39,16 @@ type Commit = (
) => Promise<void>;
/** Renders the hook and hands its commit function to the caller via a ref callback. */
function renderCommitHook(
mutations: Array<Record<string, unknown>>,
function renderHookWith(
animations: GsapAnimation[],
onMutation: (mutation: Record<string, unknown>, label: string) => void,
onReady: (commit: Commit) => void,
) {
function Harness() {
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: [keyframedAnim],
gsapCommitMutation: async (_sel, mutation) => {
mutations.push(mutation);
selectedGsapAnimations: animations,
gsapCommitMutation: async (_sel, mutation, options) => {
onMutation(mutation, options.label);
},
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
@@ -66,6 +67,13 @@ function renderCommitHook(
return root;
}
function renderCommitHook(
mutations: Array<Record<string, unknown>>,
onReady: (commit: Commit) => void,
) {
return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady);
}
// Regression (#1808): a "3D transform" / design-panel property edit on an
// element that already has a keyframed tween is the ACTUAL path a manual
// canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
@@ -103,3 +111,56 @@ describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", ()
act(() => root.unmount());
});
});
// Regression: commitStaticSet picked the FIRST `set` for the selector with no
// group check — a panel W edit on a static element merged `width` into the
// POSITION set (`tl.set("#el",{x,y,width})`), a mixed-group set the split
// machinery exists to prevent, labeled "Set 3D transform" in undo history.
describe("commitStaticSet group routing", () => {
const positionSet = {
id: "#box-set-0-position",
targetSelector: "#box",
propertyGroup: "position",
method: "set",
properties: { x: 10, y: 20 },
} as unknown as GsapAnimation;
function renderStaticHook(
committed: Array<{ mutation: Record<string, unknown>; label: string }>,
onReady: (commit: Commit) => void,
) {
return renderHookWith(
[positionSet],
(mutation, label) => committed.push({ mutation, label }),
onReady,
);
}
it("width edit creates a size set instead of contaminating the position set", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
let commit!: Commit;
renderStaticHook(committed, (c) => (commit = c));
await act(async () => {
await commit(selection, { width: 500 });
});
const updates = committed.filter((c) => c.mutation.type === "update-properties");
expect(updates).toHaveLength(0);
const adds = committed.filter((c) => c.mutation.type === "add");
expect(adds).toHaveLength(1);
expect(adds[0]!.mutation.properties).toEqual({ width: 500 });
expect(adds[0]!.label).toBe("Resize layer");
});
it("x edit updates the position set with a Move label", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
let commit!: Commit;
renderStaticHook(committed, (c) => (commit = c));
await act(async () => {
await commit(selection, { x: 400 });
});
const update = committed.find((c) => c.mutation.type === "update-properties");
expect(update).toBeDefined();
expect(update!.mutation.animationId).toBe("#box-set-0-position");
expect(update!.label).toBe("Move layer");
});
});
@@ -103,6 +103,22 @@ async function maybeAutoKeyframeSet(
type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
/** Undo-history label for a static-set commit, from the group it writes. */
const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
position: "Move layer",
scale: "Resize layer",
size: "Resize layer",
rotation: "Rotate layer",
visual: "Set opacity",
other: "Set 3D transform",
};
function staticSetLabel(propEntries: [string, number | string][]): string {
const groups = new Set(propEntries.map(([k]) => classifyPropertyGroup(k)));
const only = groups.size === 1 ? [...groups][0] : undefined;
return (only && STATIC_SET_LABELS[only]) || "Set properties";
}
/** Merge ALL props into the static `set` in ONE commit (value-only, instant), then
* auto-keyframe. One mutation a per-property loop would shift the set's
* group-derived id mid-way (e.g. reset adding `scale` to a rotation set), 404-ing
@@ -133,7 +149,11 @@ async function commitSetProps(
await commit(
selection,
{ type: "update-properties", animationId: setAnim.id, properties },
{ label: "Set 3D transform", softReload: true, ...(instantPatch ? { instantPatch } : {}) },
{
label: staticSetLabel(propEntries),
softReload: true,
...(instantPatch ? { instantPatch } : {}),
},
);
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
}
@@ -152,22 +172,70 @@ async function commitStaticSet(
commit: Commit,
): Promise<void> {
if (!selector) return;
// Update an existing `set` in ONE batched commit — NEVER a flat `to`/`from`. A
// set's id is GROUP-derived, so a per-prop loop shifts it the instant a new-group
// prop lands (e.g. `scale` onto a rotation set), 404-ing the next prop; commitSetProps
// sends them together. A static element with no set gets a dedicated `set` carrying
// ALL props in ONE `add`.
const existingSet = animations.find((a) => a.method === "set" && a.targetSelector === selector);
if (existingSet) {
await commitSetProps(selection, existingSet, propEntries, selector, animations, commit);
return;
// One commit per PROPERTY GROUP, each into a set that owns that group — never a
// flat `to`/`from`, and never a foreign-group set (a width edit used to merge
// into the element's position set, producing a mixed set the split machinery
// exists to prevent). Within a group everything batches into ONE commit: a
// set's id is group-derived, so a per-prop loop would shift the id mid-way and
// 404 the next update.
const byGroup = new Map<string, [string, number | string][]>();
for (const entry of propEntries) {
const group = classifyPropertyGroup(entry[0]);
const batch = byGroup.get(group) ?? [];
batch.push(entry);
byGroup.set(group, batch);
}
// Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, so
// adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on
// the timeline (matches the manual-drag UX). The global-set instant patch applies
// it straight to the element so the first edit shows with no soft-reload flash.
const sets = animations.filter((a) => a.method === "set" && a.targetSelector === selector);
// Resolve every group's target BEFORE committing anything, and coalesce
// groups that land on the SAME set into one commit: the `sets` snapshot is
// captured once, so if two groups resolved to one legacy mixed set, a first
// commit could re-shape it server-side and leave the second chasing a stale
// id (404 on legacy pre-split files).
const byTargetSet = new Map<GsapAnimation, [string, number | string][]>();
const newSetBatches: [string, number | string][][] = [];
for (const [group, batch] of byGroup) {
const existingSet = findGroupOwningSet(sets, group);
if (existingSet) {
byTargetSet.set(existingSet, [...(byTargetSet.get(existingSet) ?? []), ...batch]);
} else {
newSetBatches.push(batch);
}
}
for (const [targetSet, batch] of byTargetSet) {
await commitSetProps(selection, targetSet, batch, selector, animations, commit);
}
// Fresh adds don't reshape existing sets, so their ids can't go stale.
for (const batch of newSetBatches) {
await addGlobalStaticSet(selection, batch, selector, commit);
}
}
/**
* The set that owns a property group: one already dedicated to the group wins;
* else a mixed set that already carries a property of the group (merging
* same-group values there beats spawning a second writer for the channel).
*/
function findGroupOwningSet(sets: GsapAnimation[], group: string): GsapAnimation | undefined {
return (
sets.find((a) => a.propertyGroup === group) ??
sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group))
);
}
/**
* Base `gsap.set` (off-timeline) a static hold with no 0% keyframe marker, so
* adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on
* the timeline (matches the manual-drag UX). The global-set instant patch applies
* it straight to the element so the first edit shows with no soft-reload flash.
*/
async function addGlobalStaticSet(
selection: DomEditSelection,
batch: [string, number | string][],
selector: string,
commit: Commit,
): Promise<void> {
const numericProps: SetPatchProps = {};
for (const [k, v] of propEntries) {
for (const [k, v] of batch) {
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
}
await commit(
@@ -177,11 +245,11 @@ async function commitStaticSet(
targetSelector: selector,
method: "set",
position: 0,
properties: Object.fromEntries(propEntries),
properties: Object.fromEntries(batch),
global: true,
},
{
label: "Set 3D transform",
label: staticSetLabel(batch),
softReload: true,
...(Object.keys(numericProps).length > 0
? {
@@ -10,11 +10,8 @@
import { useCallback } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
tryGsapDragIntercept,
tryGsapResizeIntercept,
tryGsapRotationIntercept,
} from "./gsapRuntimeBridge";
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
import {
useGsapSaveFailureTelemetry,
@@ -38,6 +38,27 @@ function result(over: Partial<MutationResult> = {}): MutationResult {
return { ok: true, scriptText: "tl.set('#a',{})", ...over };
}
/** The canonical drag commit options every path-decision test drives with. */
function dragOptions() {
return {
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } },
};
}
function syncDragPreview(res: MutationResult, reloadPreview: () => void) {
applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview);
}
function expectSoftReloadedWith(onAsyncFailure: unknown, authoredHtml: string | undefined) {
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", {
onAsyncFailure,
currentTimeOverride: 0,
authoredHtml,
});
}
describe("applyPreviewSync", () => {
beforeEach(() => {
patchRuntimeTweenInPlace.mockReset();
@@ -49,16 +70,7 @@ describe("applyPreviewSync", () => {
patchRuntimeTweenInPlace.mockReturnValue(true);
const reloadPreview = vi.fn();
applyPreviewSync(
FAKE_IFRAME,
result(),
{
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
},
reloadPreview,
);
syncDragPreview(result(), reloadPreview);
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
kind: "set",
@@ -73,20 +85,11 @@ describe("applyPreviewSync", () => {
applySoftReload.mockReturnValue("applied");
const reloadPreview = vi.fn();
applyPreviewSync(
FAKE_IFRAME,
result({ scriptText: "SCRIPT" }),
{
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
},
reloadPreview,
);
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
// reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
// A successful instant patch is the fast path; here it missed → fallback event.
expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -100,20 +103,11 @@ describe("applyPreviewSync", () => {
applySoftReload.mockReturnValue("verify-failed");
const reloadPreview = vi.fn();
applyPreviewSync(
FAKE_IFRAME,
result({ scriptText: "SCRIPT" }),
{
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
},
reloadPreview,
);
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
// U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
// is correct, so we must NOT escalate to a full reload.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
// Telemetry records the suppressed transient (escalated: false).
expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -131,19 +125,10 @@ describe("applyPreviewSync", () => {
applySoftReload.mockReturnValue("cannot-soft-reload");
const reloadPreview = vi.fn();
applyPreviewSync(
FAKE_IFRAME,
result({ scriptText: "SCRIPT" }),
{
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
},
reloadPreview,
);
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
// Structural failure: the preview is genuinely stale/broken → full reload.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
@@ -167,7 +152,7 @@ describe("applyPreviewSync", () => {
);
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
// "applied" emits no telemetry (only the failure paths do).
expect(trackStudioEvent).not.toHaveBeenCalled();
@@ -185,7 +170,7 @@ describe("applyPreviewSync", () => {
);
// onAsyncFailure is wired, but the transient result does not trigger it.
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
@@ -204,7 +189,7 @@ describe("applyPreviewSync", () => {
reloadPreview,
);
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
@@ -291,6 +276,57 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
}
describe("runCommit — instantPatch wiring", () => {
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
patchRuntimeTweenInPlace.mockReturnValue(true);
mockFetchResult({ changed: false });
const deps = renderCommitHook();
await act(async () => {
await deps.api.commitMutation(
selection,
{ type: "update-property", property: "y", value: 311 },
{
label: "Move layer",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
},
);
});
// The file already matched (changed:false) but the runtime patch deferred
// from the paired first commit must still land.
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
kind: "set",
props: { x: 485, y: 311 },
});
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
// Server contract: gsap-mutations returns scriptText on EVERY response,
// including changed:false — so the fallback re-runs the identical script
// ("applied") instead of escalating a genuine no-op to a full reload.
patchRuntimeTweenInPlace.mockReturnValue(false);
applySoftReload.mockReturnValue("applied");
mockFetchResult({ changed: false });
const deps = renderCommitHook();
await act(async () => {
await deps.api.commitMutation(
selection,
{ type: "update-property", property: "y", value: 311 },
{
label: "Move layer",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
},
);
});
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
beforeEach(() => {
patchRuntimeTweenInPlace.mockReset();
applySoftReload.mockReset();
@@ -308,15 +344,7 @@ describe("runCommit — instantPatch wiring", () => {
const deps = renderCommitHook();
await act(async () => {
await deps.api.commitMutation(
selection,
{ x: 10 },
{
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
},
);
await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
});
expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted
@@ -333,19 +361,11 @@ describe("runCommit — instantPatch wiring", () => {
const deps = renderCommitHook();
await act(async () => {
await deps.api.commitMutation(
selection,
{ x: 10 },
{
label: "drag",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
},
);
await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
expect(deps.reloadPreview).not.toHaveBeenCalled();
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
});
@@ -360,7 +380,7 @@ describe("runCommit — instantPatch wiring", () => {
});
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
});
@@ -66,12 +66,17 @@ function softReloadOrEscalate(
scriptText: string,
reloadPreview: () => void,
origin: "preview_sync" | "sdk_refresh",
authoredHtml?: string,
): void {
// Seek the rebuilt timeline to the studio's own authoritative scrub position,
// not the iframe's raw `__player.getTime()` — see the comment in
// applySoftReload for why the two can desync after a keyframe-node drag.
const currentTime = usePlayerStore.getState().currentTime;
const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview, currentTime);
const result: SoftReloadResult = applySoftReload(iframe, scriptText, {
onAsyncFailure: reloadPreview,
currentTimeOverride: currentTime,
authoredHtml,
});
if (result === "applied") return;
trackStudioEvent("gsap_soft_reload_outcome", {
origin,
@@ -116,7 +121,13 @@ export function applyPreviewSync(
// already correct on screen, and a remount re-flashes the WebGL context AND
// re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin
// load failure escalates separately via `onAsyncFailure`.
softReloadOrEscalate(iframe, result.scriptText, reloadPreview, "preview_sync");
softReloadOrEscalate(
iframe,
result.scriptText,
reloadPreview,
"preview_sync",
result.after ?? undefined,
);
} else {
reloadPreview();
}
@@ -149,7 +160,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
if (options.skipReload) return;
throw error;
}
if (result.changed === false) return;
if (result.changed === false) {
// The FILE already matched, but a deferred instant patch may still be
// owed to the RUNTIME: paired commits (x with skipReload, then y carrying
// the patch for both) rely on the SECOND commit to sync the preview — if
// that half happens to be a no-op (a purely-horizontal drag or resize
// compensation), returning here would leave the runtime showing the old
// value while the file holds the new one. Patching in place is idempotent
// when the values truly match everywhere.
if (!options.skipReload && options.instantPatch) {
applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
}
return;
}
domEditSaveTimestampRef.current = Date.now();
if (result.before != null && result.after != null) {
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
@@ -196,7 +219,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
// plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a
// synchronous "verify-failed" (transient empty __timelines) does NOT escalate,
// but a "cannot-soft-reload" (structural failure) does.
softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh");
softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh", after);
} else {
reloadPreview();
}
@@ -0,0 +1,35 @@
/**
* Authored-opacity contract, studio side. The runtime stamps every graded
* element's authored inline opacity at document parse time (see
* installAuthoredOpacityCapture in @hyperframes/core); studio code that makes
* GSAP re-initialize tweens (soft reload, in-place patches) restores it so
* re-captures never bake a runtime transient in as a tween bound.
*/
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading";
interface AttributeReader {
getAttribute(name: string): string | null;
}
/**
* The stamped authored inline opacity. Three-state:
* "0.98" the authored value; "" captured, authored none;
* null never captured (unknown).
* Duck-typed so iframe-realm elements (no shared HTMLElement) work.
*/
export function readStampedAuthoredOpacity(element: AttributeReader): string | null {
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
}
/**
* Write an authored inline opacity back: "" removes the property, a value sets
* it. Priority-lossy by design: the capture reads `style.opacity` (value only)
* and the write sets no priority, so an authored `opacity: X !important`
* round-trips as `opacity: X`. The only `!important` opacity in the pipeline
* is the color-grading runtime hide a transient this contract exists to
* discard and authored compositions don't `!important` their opacity.
*/
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
if (authored === "") style.removeProperty("opacity");
else style.setProperty("opacity", authored);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* GSAP access through an ELEMENT'S OWN window (the preview iframe's runtime),
* not the studio window. This is the single way studio gesture code touches an
* iframe element's GSAP position outside the commit pipeline the resize
* anchor pin (apply + restore) and the post-commit live correction. The commit
* pipeline itself stays the owner of persisted values.
*/
type ElementGsapWindow = Window & {
gsap?: {
set?: (target: Element, vars: Record<string, number>) => void;
getProperty?: (target: Element, prop: string) => unknown;
};
};
function gsapOf(element: HTMLElement): ElementGsapWindow["gsap"] | undefined {
return (element.ownerDocument.defaultView as ElementGsapWindow | null)?.gsap;
}
/** Set the element's GSAP x/y. Returns false when no runtime is reachable. */
export function setElementGsapPosition(element: HTMLElement, x: number, y: number): boolean {
const gsap = gsapOf(element);
if (!gsap?.set) return false;
gsap.set(element, { x, 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;
}
@@ -107,7 +107,7 @@ describe("applySoftReload", () => {
// async commit resolves. The rebuilt timeline must re-seek to the caller's
// value, not the iframe's possibly-stale one.
const { iframe, contentWindow } = buildMockIframe();
const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
expect(result).toBe("applied");
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
});
@@ -244,7 +244,7 @@ describe("applySoftReload", () => {
(iframe.contentDocument as unknown as { head: unknown }).head = head;
const onAsyncFailure = vi.fn();
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
// Optimistically "applied" (script will run once the plugin loads) — and the
// script has NOT executed yet, so the timeline isn't rebound synchronously.
@@ -363,3 +363,87 @@ describe("ensureMotionPathPluginLoaded", () => {
expect(appendedScripts).toHaveLength(2);
});
});
// The authored-opacity restore: before the script re-runs (and its tweens
// re-capture bounds), every animated element's inline opacity must be put back
// to its AUTHORED value — from the after-write file HTML when provided, else
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
describe("applySoftReload authored-opacity restore", () => {
function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent =
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
const tl = {
kill: vi.fn(),
pause: vi.fn(),
getChildren: () => [{ targets: () => [el] }],
};
const contentWindow = {
gsap: { timeline: vi.fn(), set: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: { root: tl } as Record<string, unknown>,
__player: { getTime: () => 2.0, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
...overrides,
};
const container = document.createElement("div");
container.appendChild(scriptEl);
// Intercept only POST-SETUP appends: simulate the re-run script
// repopulating __timelines (as in buildMockIframe).
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
}
return result;
};
const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
}
/** Run one restore cycle over `el` and return the final inline opacity. */
function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
const { iframe } = buildIframeWithTarget(el);
expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
"applied",
);
return el.style.getPropertyValue("opacity");
}
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-id", "hf-1");
el.style.setProperty("opacity", "0", "important"); // the grading hide
const opacity = restoreOpacity(
el,
'<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
);
expect(opacity).toBe("0.98");
expect(el.style.getPropertyPriority("opacity")).toBe("");
});
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "0.75");
el.style.opacity = "0.123"; // mid-flight tween transient
expect(restoreOpacity(el)).toBe("0.75");
});
it("an empty stamp (authored none) removes the inline opacity", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "");
el.style.opacity = "0";
expect(restoreOpacity(el)).toBe("");
});
});
+69 -7
View File
@@ -1,3 +1,6 @@
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
type IframeWindow = Window & {
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
__player?: { getTime?: () => number; seek?: (t: number) => void };
@@ -171,12 +174,21 @@ export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload
* caller should perform a full reload to recover. It never fires on the
* synchronous paths.
*/
export interface SoftReloadOptions {
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
onAsyncFailure?: () => void;
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
currentTimeOverride?: number;
/** After-write file HTML — the primary source for authored-opacity restore. */
authoredHtml?: string;
}
export function applySoftReload(
iframe: HTMLIFrameElement | null,
scriptText: string,
onAsyncFailure?: () => void,
currentTimeOverride?: number,
options: SoftReloadOptions = {},
): SoftReloadResult {
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
if (!iframe || !scriptText) return "cannot-soft-reload";
const win = iframe.contentWindow as IframeWindow | null;
@@ -227,6 +239,36 @@ export function applySoftReload(
// full iframe reload that destroys the very WebGL context we're preserving.
let deferredToAsync = false;
// Authored-opacity resolution for the restore loop below. Three-state:
// "0.98" — the element's authored inline opacity
// "" — resolved, and the element has NO authored inline opacity
// null — unknown (no authored HTML supplied, element not found in it,
// and no runtime parse-time stamp)
// The just-written file (`authoredHtml`) is the current truth; the runtime's
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
let authoredDoc: Document | null | undefined;
const findAuthoredSource = (el: HTMLElement): Element | null => {
if (authoredDoc === undefined) {
try {
authoredDoc = authoredHtml
? new DOMParser().parseFromString(authoredHtml, "text/html")
: null;
} catch {
authoredDoc = null;
}
}
if (!authoredDoc) return null;
const hfId = el.getAttribute("data-hf-id");
if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
return el.id ? authoredDoc.getElementById(el.id) : null;
};
const readAuthoredOpacity = (el: HTMLElement): string | null => {
const source = findAuthoredSource(el);
if (source instanceof HTMLElement) return source.style.opacity;
return readStampedAuthoredOpacity(el);
};
// fallow-ignore-next-line complexity
const doReload = () => {
const timelines = win.__timelines;
@@ -283,19 +325,39 @@ export function applySoftReload(
// nukes the element's CSS base (position, width, height, etc.) from the
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
if (allTargets.length > 0 && win.gsap?.set) {
const saved: Array<[Element, string]> = [];
const saved: Array<[HTMLElement, string]> = [];
for (const el of allTargets) {
const s = (el as HTMLElement).style;
if (s?.cssText != null) saved.push([el, s.cssText]);
// Iframe-realm node: instanceof HTMLElement fails across realms, and
// gsap targets() only yields elements here — style access is duck-typed.
const styled = el as HTMLElement;
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
}
try {
win.gsap.set(allTargets, { clearProps: "all" });
} catch {}
for (const [el, css] of saved) {
const s = (el as HTMLElement).style;
if (!s) continue;
const s = el.style;
s.cssText = css;
s.removeProperty("transform");
// The restored cssText carries RUNTIME opacity, not authored opacity:
// a mid-flight tween's interpolated value, or the color-grading hide
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
// against it — a from() captures it as its END, a to() as its START —
// turning the transient into the tween's permanent bound (dimmed or
// invisible elements). Put the AUTHORED inline opacity back; the seek
// below re-renders the correct animated value either way.
const authored = readAuthoredOpacity(el);
if (authored !== null) {
applyAuthoredInlineOpacity(s, authored);
} else if (
el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) &&
s.getPropertyValue("opacity") === "0" &&
s.getPropertyPriority("opacity") === "important"
) {
// Authored value unknown, but this is definitely the grading hide —
// never let a from() capture 0; fall back to the CSS cascade.
s.removeProperty("opacity");
}
}
}