mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 ────────────────────────────────────────────────────
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user