diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts index d71ce1776..d5411e20a 100644 --- a/packages/core/src/colorGrading.ts +++ b/packages/core/src/colorGrading.ts @@ -1,5 +1,17 @@ export const HF_COLOR_GRADING_ATTR = "data-color-grading"; +// Runtime <-> studio contract attributes. The runtime grading engine writes +// them; studio editing/soft-reload code reads them. Single owner — never +// re-declare these literals elsewhere. +/** Set on a graded source while its pixels render on the grading canvas. */ +export const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; +/** + * The element's AUTHORED inline opacity, stamped at document parse time before + * any animation engine mutates it ("" = authored none; attribute absent = + * never captured). See installAuthoredOpacityCapture in the runtime. + */ +export const COLOR_GRADING_AUTHORED_OPACITY_ATTR = "data-hf-authored-opacity"; + export const HF_COLOR_GRADING_CANVAS_ID_PREFIX = "__hf_color_grading_"; export const HF_COLOR_GRADING_COLOR_SPACE = "rec709"; diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index e3a3ce49d..a02d6bdb3 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -1,7 +1,11 @@ // fallow-ignore-file code-duplication import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading"; -import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; +import { + createColorGradingRuntime, + installAuthoredOpacityCapture, + type RuntimeColorGradingApi, +} from "./colorGrading"; let lastUniform1f: ReturnType | null = null; let lastUniform3f: ReturnType | null = null; @@ -192,6 +196,60 @@ describe("createColorGradingRuntime", () => { runtime?.redraw(); } + it("restores the authored inline opacity captured before animation transients", () => { + const video = makeDrawableVideo(); + // Parse-time capture stamped the authored value; by hide time GSAP has + // already left a from()-tween transient (0) in the inline style. + video.setAttribute("data-hf-authored-opacity", "0.75"); + video.style.opacity = "0"; + startRuntimeWithVideo(video); + + expect(video.style.getPropertyPriority("opacity")).toBe("important"); + + runtime?.destroy(); + runtime = null; + + // Restore must use the authored 0.75, not the GSAP transient 0. + expect(video.style.getPropertyValue("opacity")).toBe("0.75"); + expect(video.style.getPropertyPriority("opacity")).toBe(""); + }); + + it("restores no inline opacity when the authored capture recorded none", () => { + const video = makeDrawableVideo(); + video.setAttribute("data-hf-authored-opacity", ""); + video.style.opacity = "0"; + startRuntimeWithVideo(video); + + runtime?.destroy(); + runtime = null; + + expect(video.style.getPropertyValue("opacity")).toBe(""); + }); + + it("re-syncs the graded canvas when the source's inline transform changes", async () => { + const { video } = startRuntimeWithVideo(); + const drawsBefore = texImage2DCalls.length; + + // Simulate a studio drag draft: only the inline transform moves. + video.style.transform = "translate(120px, 60px)"; + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + + expect(texImage2DCalls.length).toBeGreaterThan(drawsBefore); + }); + + it("does not redraw-loop on its own hide writes (opacity/visibility only)", async () => { + const { video } = startRuntimeWithVideo(); + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + const drawsBefore = texImage2DCalls.length; + + // drawEntry's own source-hide writes touch opacity — geometry unchanged. + video.style.opacity = "0.5"; + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + + expect(texImage2DCalls.length).toBe(drawsBefore); + }); + it("re-hides source media after timeline visibility sync", () => { const { video, canvas } = startRuntimeWithVideo(); @@ -555,3 +613,56 @@ describe("createColorGradingRuntime", () => { expect(video.style.getPropertyValue("opacity")).toBe("0"); }); }); + +describe("installAuthoredOpacityCapture", () => { + it("stamps graded elements at insertion and never overwrites the stamp", async () => { + installAuthoredOpacityCapture(); + const el = document.createElement("img"); + el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } })); + el.style.opacity = "0.98"; + document.body.appendChild(el); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.98"); + + // A re-insert after an animation engine mutated the element keeps the + // original capture (has-attribute guard). + el.style.opacity = "0"; + el.remove(); + document.body.appendChild(el); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.98"); + el.remove(); + }); + + it("stamps an empty value for graded elements without an authored inline opacity", async () => { + installAuthoredOpacityCapture(); + const el = document.createElement("img"); + el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } })); + document.body.appendChild(el); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe(""); + el.remove(); + }); + + it("stamps an already-inserted element the moment it GAINS grading at runtime", async () => { + installAuthoredOpacityCapture(); + const el = document.createElement("img"); + el.style.opacity = "0.9"; + document.body.appendChild(el); + await Promise.resolve(); + expect(el.hasAttribute("data-hf-authored-opacity")).toBe(false); + + // Studio applies a preset to a previously ungraded element — no re-insert. + el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } })); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9"); + + // Later attribute rewrites (preset tweaks) never overwrite the stamp, + // even if a transient is live by then. + el.style.opacity = "0"; + el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.9 } })); + await Promise.resolve(); + expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9"); + el.remove(); + }); +}); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 5b32c6c65..8ec35806b 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -6,6 +6,8 @@ import { normalizeHfColorGradingWithVariables, type HfColorGradingTarget, type NormalizedHfColorGrading, + COLOR_GRADING_SOURCE_HIDDEN_ATTR, + COLOR_GRADING_AUTHORED_OPACITY_ATTR, } from "../colorGrading"; import { DEFAULT_MAX_CUBE_LUT_SIZE, @@ -196,8 +198,64 @@ type LutCacheEntry = const LUT_CACHE = new Map(); const COLOR_GRADING_CANVAS_ATTR = "data-hf-color-grading-canvas"; -const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; const COLOR_GRADING_CANVAS_CLASS = "__hf_color_grading_canvas__"; + +/** + * Capture each color-graded element's AUTHORED inline opacity before any + * animation engine can mutate it. + * + * The grading engine hides its source elements with `opacity: 0 !important` + * and mirrors their pixels onto a canvas — so at runtime, a graded element's + * inline/computed opacity no longer represents authored state. Everything that + * later re-reads element state (GSAP from()-tween re-initialization after an + * invalidate or a studio soft reload, restoring the source when grading is + * removed, lint/selection tooling) needs the authored value, and by then it is + * unrecoverable from the DOM. Stamp it onto the element as + * `data-hf-authored-opacity` (empty string = no authored inline opacity). + * + * Must be installed at runtime-bundle evaluation, while the document is still + * parsing: the runtime ` +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeDefined(); + }); + + it("does NOT error for inline opacity: 0.98 + gsap.from({opacity:0}) — fractional is not zero", async () => { + const html = ` + +
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeUndefined(); + }); + + it("still errors for inline opacity: 0 without a trailing semicolon", async () => { + const html = ` + +
+
Hello
+
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeDefined(); + }); + it("does NOT error when gsap.from({opacity:0}) and CSS has no opacity:0", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index b54b7c873..ec2e4c9b8 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1128,11 +1128,18 @@ export const gsapRules: LintRule[] = [ const findings: HyperframeLintFinding[] = []; const cssOpacityZeroSelectors = new Set(); + // Single owner of "this declaration list sets opacity to EXACTLY zero" — + // boundary-anchored so `opacity: 0.98` never matches. Works for both a CSS + // block body (brace already stripped by the block regex) and an inline + // style attribute: the declaration ends at `;` or at end of input, which + // also catches a final declaration without a trailing semicolon. + const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/; + for (const style of styles) { for (const [, selector, body] of style.content.matchAll( /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g, )) { - if (body && /opacity\s*:\s*0\s*[;}]/.test(body)) { + if (body && opacityExactlyZero.test(body)) { cssOpacityZeroSelectors.add((selector ?? "").trim()); } } @@ -1140,7 +1147,7 @@ export const gsapRules: LintRule[] = [ for (const tag of tags) { const inlineStyle = readAttr(tag.raw, "style"); - if (!inlineStyle || !/opacity\s*:\s*0/.test(inlineStyle)) continue; + if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue; const id = readAttr(tag.raw, "id"); const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []; if (id) cssOpacityZeroSelectors.add(`#${id}`); diff --git a/packages/parsers/src/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts index 413fc5399..11b592f9a 100644 --- a/packages/parsers/src/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -3026,3 +3026,24 @@ describe("single position write per element (consolidation)", () => { expect(parseGsapScript(out).animations.some((a) => "opacity" in a.properties)).toBe(true); }); }); + +describe("recast writer never doubles vars keys", () => { + // Regression: buildTweenStatementCode pushed `immediateRender: true` for + // every timeline set AND appended extras — a parsed set carries the flag in + // extras, so splitting a mixed set emitted + // `immediateRender: true, immediateRender: true` into the file. + const src = `window.__timelines = window.__timelines || {}; +const tl = gsap.timeline({ paused: true }); +tl.set("#a", { z: 0, rotationX: 5, immediateRender: true, scale: 1 }, 0); +window.__timelines["main"] = tl;`; + + it("split of a mixed set emits the flag once per group", () => { + const id = parseGsapScript(src).animations[0]!.id; + const { script: out } = splitIntoPropertyGroups(src, id); + const setLines = out.split("\n").filter((l) => l.includes("tl.set(")); + expect(setLines.length).toBeGreaterThan(1); + for (const line of setLines) { + expect(line.match(/immediateRender/g) ?? []).toHaveLength(1); + } + }); +}); diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 4272ec060..315c238c8 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -1387,13 +1387,25 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit `${safeKey(k)}: ${valueToCode(v)}`); + const emitted = new Set(Object.keys(props)); // immediateRender forces GSAP to apply the set when added to the timeline, // not on the first seek — without it, tl.set at position 0 on a paused // timeline is invisible until the playhead moves past 0. A base `gsap.set` - // already runs immediately, so it doesn't need (or get) the flag. - if (anim.method === "set" && !anim.global) entries.push("immediateRender: true"); + // already runs immediately, so it doesn't need (or get) the flag. A parsed + // set carries the flag in extras — never emit the same key twice. + if ( + anim.method === "set" && + !anim.global && + !emitted.has("immediateRender") && + !(anim.extras && "immediateRender" in anim.extras) + ) { + entries.push("immediateRender: true"); + emitted.add("immediateRender"); + } if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { + if (emitted.has(k)) continue; + emitted.add(k); entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); } } diff --git a/packages/parsers/src/gsapWriterAcorn.inline.test.ts b/packages/parsers/src/gsapWriterAcorn.inline.test.ts index 4eaa4ca22..30b165242 100644 --- a/packages/parsers/src/gsapWriterAcorn.inline.test.ts +++ b/packages/parsers/src/gsapWriterAcorn.inline.test.ts @@ -101,3 +101,32 @@ window.__timelines["scene"] = gsap.timeline({ paused: true });`; expect(parseGsapScriptAcorn(out).animations).toHaveLength(2); }); }); + +describe("no duplicate vars keys on rewrite", () => { + const setSrc = `window.__timelines = window.__timelines || {}; +const tl = gsap.timeline({ paused: true }); +tl.set("#a", { z: 0, rotationX: 5, immediateRender: true }, 0); +window.__timelines["main"] = tl;`; + + it("update with immediateRender riding in newProps emits the key once", () => { + const id = parseGsapScriptAcorn(setSrc).animations[0]!.id; + // Studio paths that build props off live tween vars carry the flag along. + const out = updateAnimationInScript(setSrc, id, { + properties: { z: 0, rotationX: 9, immediateRender: "__raw:true" }, + }); + expect(out).toContain("rotationX: 9"); + expect(out.match(/immediateRender/g)).toHaveLength(1); + }); + + it("addAnimationToScript dedupes a key present in both properties and extras", () => { + const { script: out } = addAnimationToScript(setSrc, { + targetSelector: "#b", + method: "set", + position: 0, + properties: { scale: 1, immediateRender: "__raw:true" }, + extras: { immediateRender: "__raw:true" }, + }); + const setB = out.split("\n").find((l) => l.includes('"#b"')); + expect(setB?.match(/immediateRender/g)).toHaveLength(1); + }); +}); diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 29eabebef..556eecd09 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -57,8 +57,14 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit `${safeKey(k)}: ${valueToCode(v)}`); + const emitted = new Set(Object.keys(props)); if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { + // A key carried by both properties and extras (a set's parsed + // `immediateRender: true`) must emit once — properties win. Same + // dedupe shape as the recast twin (gsapParser.ts buildTweenStatementCode). + if (emitted.has(k)) continue; + emitted.add(k); entries.push(`${safeKey(k)}: ${valueToCode(v)}`); } } @@ -263,10 +269,16 @@ function reconcileEditableProps( const overrides = nonEditableOverrides ?? {}; const { entries, keys } = preservedEntries(objNode, source, isEditableVarKey, overrides); for (const [key, value] of Object.entries(overrides)) { - if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + if (!keys.has(key)) { + keys.add(key); + entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + } } for (const [key, value] of Object.entries(newProps)) { - entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + // A non-editable key riding along in newProps (immediateRender read off + // live tween vars) is already preserved above — emitting it again writes + // `immediateRender: true, immediateRender: true` into the file. + if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`); } ms.overwrite(objNode.start, objNode.end, `{ ${entries.join(", ")} }`); } diff --git a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx new file mode 100644 index 000000000..09c73decc --- /dev/null +++ b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx @@ -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( + 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%)"); + }); +}); diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index 9f83a90ba..7ac3b485b 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -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(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(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(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) => { 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) => { @@ -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 ( - <> +
{/* 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 && ( -
+
); })} - +
); } diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index db1f2291b..fbc8eccb2 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -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({ diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 737a47455..c0adaef18 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -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(); diff --git a/packages/studio/src/components/editor/domEditOverlayCrop.test.ts b/packages/studio/src/components/editor/domEditOverlayCrop.test.ts index 2dd54f357..9831f06ed 100644 --- a/packages/studio/src/components/editor/domEditOverlayCrop.test.ts +++ b/packages/studio/src/components/editor/domEditOverlayCrop.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/components/editor/domEditOverlayCrop.ts b/packages/studio/src/components/editor/domEditOverlayCrop.ts index 7eba37ec4..cfff9daf4 100644 --- a/packages/studio/src/components/editor/domEditOverlayCrop.ts +++ b/packages/studio/src/components/editor/domEditOverlayCrop.ts @@ -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 }; +} diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 7f3c63c80..2abc793b6 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -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)), }; } diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index f91a8a434..636265e20 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -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, }; diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts index cfd347445..b1f341ca7 100644 --- a/packages/studio/src/components/editor/domEditingDom.ts +++ b/packages/studio/src/components/editor/domEditingDom.ts @@ -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; diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index bce380b30..83da0418e 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -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); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.test.ts b/packages/studio/src/hooks/gsapResizeIntercept.test.ts new file mode 100644 index 000000000..6a0d75088 --- /dev/null +++ b/packages/studio/src/hooks/gsapResizeIntercept.test.ts @@ -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) { + // 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>> { + 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> = []; + const commitMutation = vi.fn(async (_sel: unknown, mutation: Record) => { + 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; + // 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) ?? {}), + ...Object.keys((m.resolvedFromValues as Record) ?? {}), + ]); + 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"); +}); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts new file mode 100644 index 000000000..e7cc47670 --- /dev/null +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -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, +): Record { + const id: Record = {}; + 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, +): Promise { + // 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; + 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 }> = []; + 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 = {}; + 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 ──────────────────────────────────────────────────── diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index e328d5205..601532300 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -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, -): Record { - const id: Record = {}; - 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, -): Promise { - // 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; - 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 }> = []; - 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 = {}; - 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 diff --git a/packages/studio/src/hooks/gsapRuntimePatch.test.ts b/packages/studio/src/hooks/gsapRuntimePatch.test.ts index bf999f9c8..1a17f7e06 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.test.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.test.ts @@ -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([["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" }; diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index a95f248f9..de2c6480e 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -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; diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.test.ts b/packages/studio/src/hooks/gsapRuntimeReaders.test.ts new file mode 100644 index 000000000..b65f59237 --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeReaders.test.ts @@ -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; otherTweenVars?: Record }, +): 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); + }); +}); diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts index 966a1b850..48d2ed3ff 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -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; diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx index 038e03d21..f5eeed3d3 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx @@ -39,15 +39,16 @@ type Commit = ( ) => Promise; /** Renders the hook and hands its commit function to the caller via a ref callback. */ -function renderCommitHook( - mutations: Array>, +function renderHookWith( + animations: GsapAnimation[], + onMutation: (mutation: Record, 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>, + 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; 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; 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; 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"); + }); +}); diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index a6ea2e308..e1cd37b96 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -103,6 +103,22 @@ async function maybeAutoKeyframeSet( type Commit = NonNullable; +/** Undo-history label for a static-set commit, from the group it writes. */ +const STATIC_SET_LABELS: Partial, 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 { 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(); + 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(); + 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 { 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 ? { diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index f695595a5..9e22897a1 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -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, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index ee192997f..2e867a4f4 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -38,6 +38,27 @@ function result(over: Partial = {}): 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 = {}): 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(); }); }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index e490f6ee5..cb088e347 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -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(); } diff --git a/packages/studio/src/utils/authoredOpacity.ts b/packages/studio/src/utils/authoredOpacity.ts new file mode 100644 index 000000000..53b3ede30 --- /dev/null +++ b/packages/studio/src/utils/authoredOpacity.ts @@ -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); +} diff --git a/packages/studio/src/utils/elementGsap.ts b/packages/studio/src/utils/elementGsap.ts new file mode 100644 index 000000000..e89b6f986 --- /dev/null +++ b/packages/studio/src/utils/elementGsap.ts @@ -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) => 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; +} diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index 1576a8bc8..25e07edd9 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -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 = {}) { + 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, + __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 = (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, + '', + ); + + 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(""); + }); +}); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 1cea9b789..63012108c 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -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 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"); + } } }