From 3d39a7a355bbb66f9af293a763a4f630248d529b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 07:58:20 -0400 Subject: [PATCH 1/3] fix(studio): resizing a cropped element keeps the same part of it in view Rebased onto main as one commit (was a0a86cc1..080dfecf). Merged with the crop release rule from main: a drag that ends where it started saves nothing. --- .../editor/DomEditCropHandles.test.tsx | 100 +++++--------- .../components/editor/DomEditCropHandles.tsx | 92 +++++-------- .../src/components/editor/cropResize.test.ts | 110 +++++++++++++++ .../src/components/editor/cropResize.ts | 126 ++++++++++++++++++ .../components/editor/domEditOverlayCrop.ts | 80 ++++++++--- .../editor/domEditOverlayCropLift.test.ts | 44 ++++++ .../components/editor/gsapAnimatesProperty.ts | 49 +++---- .../src/components/editor/manualEditsDom.ts | 16 +++ .../components/editor/manualEditsSnapshot.ts | 3 + .../src/hooks/domSelectionTestHarness.ts | 8 ++ packages/studio/src/hooks/gsapEditOutcome.ts | 6 + .../hooks/useAnimatedPropertyCommit.test.tsx | 41 +++++- .../src/hooks/useAnimatedPropertyCommit.ts | 24 ++-- packages/studio/src/hooks/useCropOverlay.ts | 10 +- .../studio/src/hooks/useDomEditCommits.ts | 1 + .../hooks/useDomEditPositionPatchCommit.ts | 4 +- .../studio/src/hooks/useDomEditSession.ts | 2 + .../studio/src/hooks/useDomGeometryCommit.ts | 1 + .../src/hooks/useDomGeometryCommits.test.tsx | 112 ++++++++++++++++ .../studio/src/hooks/useDomGeometryCommits.ts | 5 + .../src/hooks/useGsapAwareEditing.test.tsx | 104 ++++++++++++++- .../studio/src/hooks/useGsapAwareEditing.ts | 60 +++++---- 22 files changed, 777 insertions(+), 221 deletions(-) create mode 100644 packages/studio/src/components/editor/cropResize.test.ts create mode 100644 packages/studio/src/components/editor/cropResize.ts create mode 100644 packages/studio/src/components/editor/domEditOverlayCropLift.test.ts diff --git a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx index 33ec296a83..327b750930 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { DomEditSelection } from "./domEditing"; import type { OverlayRect } from "./domEditOverlayGeometry"; import { DomEditCropHandles } from "./DomEditCropHandles"; +import { isElementCropLifted } from "./domEditOverlayCrop"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -55,27 +56,27 @@ function render( 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", () => { +// The lift never touches the element's own clip-path: select+deselect must leave what the author +// wrote verbatim, and a direct A→B switch must drop A's lift, not B's. +describe("DomEditCropHandles clip lift", () => { + it("lifts on select without rewriting the inline clip, and drops the lift on unmount", () => { const a = makeEl("a", "inset(16px round 12px)"); const { root } = render(a); - expect(a.style.getPropertyValue("clip-path")).toBe("none"); + expect(isElementCropLifted(a)).toBe(true); + expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)"); act(() => root.unmount()); + expect(isElementCropLifted(a)).toBe(false); expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)"); }); - it("restores A's own clip when switching directly to B", () => { + it("drops A's lift 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(isElementCropLifted(a)).toBe(false); + expect(isElementCropLifted(b)).toBe(true); 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)"); }); @@ -83,49 +84,11 @@ describe("DomEditCropHandles clip lift/restore", () => { 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%)"); + expect(isElementCropLifted(a)).toBe(false); act(() => root.unmount()); expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)"); }); - it("re-lifts synchronously after the commit path re-applies the cropped value", async () => { - const a = makeEl("a", "inset(10px)"); - let resolveCommit: (() => void) | undefined; - const pendingCommit = new Promise((resolve) => { - resolveCommit = resolve; - }); - const onStyleCommit = vi.fn((property: string, value: string) => { - a.style.setProperty(property, value); - return pendingCommit; - }); - const { root } = render(a, onStyleCommit); - const handle = document.querySelector('[aria-label="Crop right"]'); - expect(handle).toBeTruthy(); - - act(() => - handle!.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 100 }), - ), - ); - act(() => - handle!.dispatchEvent( - new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 80 }), - ), - ); - act(() => - handle!.dispatchEvent( - new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 80 }), - ), - ); - - expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)"); - expect(a.style.getPropertyValue("clip-path")).toBe("none"); - resolveCommit?.(); - await act(async () => pendingCommit); - act(() => root.unmount()); - expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)"); - }); - it.each([ { name: "a crop edge", clip: "inset(10px)", handle: "Crop right", dx: -20, dy: 0 }, { @@ -189,33 +152,38 @@ describe("DomEditCropHandles clip lift/restore", () => { expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)"); }); - it("re-lifts when the crop commit rejects", async () => { + it("commits the dragged crop and stays lifted, whether the save lands or fails", async () => { const a = makeEl("a", "inset(10px)"); const onStyleCommit = vi.fn((property: string, value: string) => { a.style.setProperty(property, value); return Promise.reject(new Error("persist failed")); }); const { root } = render(a, onStyleCommit); - const handle = document.querySelector('[aria-label="Crop right"]'); - - act(() => - handle!.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 100 }), - ), - ); - act(() => - handle!.dispatchEvent( - new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 80 }), - ), - ); - await act(async () => { - handle!.dispatchEvent( - new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 80 }), + const handle = document.querySelector('[aria-label="Crop right"]')!; + const press = (type: string, clientX: number) => + act(() => + handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientX })), ); + press("pointerdown", 100); + press("pointermove", 80); + await act(async () => { + press("pointerup", 80); await Promise.resolve(); }); - expect(a.style.getPropertyValue("clip-path")).toBe("none"); + expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)"); + expect(isElementCropLifted(a)).toBe(true); + act(() => root.unmount()); + }); + + it("draws the crop the element has now, after an undo rewrites it", () => { + const a = makeEl("a", "inset(0px 20px 0px 0px)"); + const { root, rerender } = render(a); + const outline = () => document.querySelector(".border-dashed")!.style.width; + expect(outline()).toBe("180px"); + a.style.setProperty("clip-path", "inset(0px 50px 0px 0px)"); + rerender(a); + expect(outline()).toBe("150px"); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index b223d7a900..67172d83e6 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -4,6 +4,9 @@ import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry import { type CropEdge, cropRectFromInsets, + dropElementCropLift, + hasCropInsets, + liftElementCrop, readElementCropFrame, readElementCropInsets, resolveCropInsetFromEdgeDrag, @@ -11,6 +14,7 @@ import { rotateDeltaIntoFrame, } from "./domEditOverlayCrop"; import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers"; +import { readCropFollowingResize } from "./cropResize"; interface CropGestureState { edge: CropEdge | "move"; @@ -19,6 +23,7 @@ interface CropGestureState { startY: number; startInsets: ClipPathInsetSides; insets: ClipPathInsetSides; + radius: number; /** Element frame captured at gesture start: pointer deltas rotate into it. */ angleDeg: number; scaleX: number; @@ -83,6 +88,7 @@ function repositionHandleSize(rect: Rect): number { } const EDGES: CropEdge[] = ["top", "right", "bottom", "left"]; +const NO_CROP = { top: 0, right: 0, bottom: 0, left: 0, radius: 0 }; /** * Always-on crop, integrated with the selection (no crop "mode"): while a @@ -92,7 +98,7 @@ const EDGES: CropEdge[] = ["top", "right", "bottom", "left"]; * grid guides framing); release commits `clip-path: inset(...)` through the * normal style-commit path (one undo step per drag). When cropped, a center * handle pans the crop window. Corners stay free for the selection's own resize - * handle. Leaving the selection restores the committed crop. The clip-path model + * handle. Leaving the selection drops the lift. The element's clip-path * is the source of truth — nothing here mutates layout. */ export function DomEditCropHandles({ @@ -109,54 +115,29 @@ export function DomEditCropHandles({ // 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 { top, right, bottom, left } = parsed ?? NO_CROP; + return { element, croppable: parsed !== null, insets: { top, right, bottom, left } }; }; 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. + // undo/redo that re-keys the node). if (state.element !== selection.element) { setState(cropStateFor(selection.element)); } - const hasCrop = - state.insets.top > 0 || - state.insets.right > 0 || - state.insets.bottom > 0 || - state.insets.left > 0; + // The element's clip-path is the crop; state only holds a crop drag's draft. + const committed = readCropFollowingResize(selection.element) ?? NO_CROP; + const insets = dragging ? state.insets : committed; + const hasCrop = hasCropInsets(insets); // Lift the clip while the element is selected so the full content shows and the - // 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); + // cropped-away area can be dimmed. Keyed on the element so a direct A→B switch drops A's lift. 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; - 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"); - }; + liftElementCrop(el); + return () => dropElementCropLift(el); }, [selection.element]); // The crop applies in the element's LOCAL frame (clip-path precedes the @@ -169,7 +150,7 @@ export function DomEditCropHandles({ // 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, + insets, frame.scaleX, frame.scaleY, ); @@ -180,19 +161,23 @@ export function DomEditCropHandles({ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); + // Read at press: a resize may have rescaled the crop since the last render. + const pressed = readCropFollowingResize(selection.element) ?? NO_CROP; gestureRef.current = { edge, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, - startInsets: state.insets, - insets: state.insets, + startInsets: pressed, + insets: pressed, + radius: pressed.radius, 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. + setState((prev) => ({ ...prev, insets: pressed })); setDragging(true); }; @@ -234,29 +219,12 @@ export function DomEditCropHandles({ const finishCropGesture = (event: ReactPointerEvent) => { const gesture = endCropGesture(event); if (!gesture) return; - // Commit to the file. The commit path re-applies the value to the live - // element synchronously, so re-lift in the same turn to keep showing the full - // content + dim while selected. Re-lift again on rejection so a failed commit - // still restores crop-mode presentation without an unhandled rejection. - const el = selection.element; - const reLift = () => { - if (liftedRef.current) el.style.setProperty("clip-path", "none"); - }; - const { insets } = gesture; - const committedValue = buildInsetClipPathSides(insets, state.radius); - if (committedValue === buildInsetClipPathSides(gesture.startInsets, state.radius)) return; - const cropped = insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0; - const commit = onStyleCommit?.("clip-path", committedValue); - // handleDomStyleCommit applies the persisted value to the live element - // synchronously before its first await. Restore the crop-mode lift in this - // same turn so the browser never paints that intermediate cropped state. - reLift(); - void Promise.resolve(commit).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); + // The commit writes the element's clip-path (and puts it back if the save fails); + // the lift keeps it hidden while selected. A drag that ends where it started saves nothing. + const value = buildInsetClipPathSides(gesture.insets, gesture.radius); + if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return; + const commit = onStyleCommit?.("clip-path", value); + void Promise.resolve(commit).catch(() => undefined); }; const cancelCropGesture = (event: ReactPointerEvent) => { diff --git a/packages/studio/src/components/editor/cropResize.test.ts b/packages/studio/src/components/editor/cropResize.test.ts new file mode 100644 index 0000000000..08c9f7d5a7 --- /dev/null +++ b/packages/studio/src/components/editor/cropResize.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; +import { prepareCropResize, readCropFollowingResize, saveCropResize } from "./cropResize"; +import type { DomEditSelection } from "./domEditingTypes"; +import { + applyStudioBoxSizeDraft, + captureStudioBoxSize, + clearStudioBoxSize, + restoreStudioBoxSize, +} from "./manualEdits"; +import { withInlineLayoutBox } from "../../hooks/domSelectionTestHarness"; + +function sizedElement(width: number, height: number, clip: string): HTMLElement { + const el = withInlineLayoutBox(document.createElement("div")); + el.style.cssText = `width: ${width}px; height: ${height}px; clip-path: ${clip}`; + return el; +} + +describe("crop during a resize", () => { + it("follows the box only while a resize draft is live", () => { + const el = sizedElement(300, 200, "inset(10px 60px 20px 30px round 8px)"); + const before = captureStudioBoxSize(el); + const crop = (top: number, right: number, bottom: number, left: number) => ({ + top, + right, + bottom, + left, + radius: 8, + }); + applyStudioBoxSizeDraft(el, { width: 600, height: 400 }); + expect(readCropFollowingResize(el)).toEqual(crop(20, 120, 40, 60)); + restoreStudioBoxSize(el, before); + // An animated width is not a resize: the crop keeps its pixels. + el.style.width = "600px"; + expect(readCropFollowingResize(el)).toEqual(crop(10, 60, 20, 30)); + }); + + it("leaves the crop alone when the box kept its size (a resize saved as scale)", () => { + const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); + applyStudioBoxSizeDraft(el, { width: 450, height: 300 }); + const stage = prepareCropResize(el); + clearStudioBoxSize(el); + el.style.width = "300px"; + el.style.height = "200px"; + expect(stage()).toBeNull(); + expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)"); + }); + + it("puts the crop back, !important and all, when its save fails", async () => { + const el = sizedElement(300, 200, "none"); + el.style.setProperty("clip-path", "inset(0px 60px 0px 0px)", "important"); + const stage = prepareCropResize(el); + el.style.width = "450px"; + let priorityWhileSaving = ""; + const commit = vi.fn(() => { + priorityWhileSaving = el.style.getPropertyPriority("clip-path"); + return Promise.reject(new Error("save failed")); + }); + const selection = { element: el } as unknown as DomEditSelection; + await expect(saveCropResize(stage, selection, commit, "undo-key")).rejects.toThrow( + "save failed", + ); + + const value = "inset(0px 90px 0px 0px) !important"; + expect(commit.mock.calls[0]![1]).toEqual([ + { type: "inline-style", property: "clip-path", value }, + ]); + expect(priorityWhileSaving).toBe("important"); + expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)"); + expect(el.style.getPropertyPriority("clip-path")).toBe("important"); + }); + + it("scales the crop once: the stage ends the draft", () => { + const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); + applyStudioBoxSizeDraft(el, { width: 450, height: 300 }); + prepareCropResize(el)(); + expect(readCropFollowingResize(el)).toMatchObject({ right: 90 }); + }); + + it("leaves a crop edited while the size saved, and a tweened crop, alone", () => { + const edited = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); + const stageEdited = prepareCropResize(edited); + edited.style.width = "450px"; + edited.style.setProperty("clip-path", "inset(0px 100px 0px 0px)"); + expect(stageEdited()).toBeNull(); + + const tweened = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); + const child = { targets: () => [tweened], vars: { clipPath: "inset(0px 120px 0px 0px)" } }; + Object.assign(window, { __timelines: { main: { getChildren: () => [child] } } }); + const stageTweened = prepareCropResize(tweened); + tweened.style.width = "450px"; + expect(stageTweened()).toBeNull(); + Object.assign(window, { __timelines: undefined }); + }); + + it("leaves the crop as authored when the size is tweened, but not when it is only held", () => { + const stageWith = (child: object) => { + const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); + const timeline = { getChildren: () => [{ targets: () => [el], ...child }] }; + Object.assign(window, { __timelines: { main: timeline } }); + const stage = prepareCropResize(el); + el.style.width = "450px"; + return stage(); + }; + const keyframes = { "0%": { width: 300 }, "100%": { width: 600 } }; + expect(stageWith({ vars: { keyframes }, duration: () => 4 })).toBeNull(); + expect(stageWith({ vars: { width: 473 }, duration: () => 0 })).not.toBeNull(); + Object.assign(window, { __timelines: undefined }); + }); +}); diff --git a/packages/studio/src/components/editor/cropResize.ts b/packages/studio/src/components/editor/cropResize.ts new file mode 100644 index 0000000000..70bf36d572 --- /dev/null +++ b/packages/studio/src/components/editor/cropResize.ts @@ -0,0 +1,126 @@ +import type { PatchOperation } from "../../utils/sourcePatcher"; +import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes"; +import type { DomEditSelection } from "./domEditingTypes"; +import { gsapAnimatesProperty, gsapTweensProperty } from "./gsapAnimatesProperty"; +import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers"; +import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop"; +import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom"; + +interface Box { + width: number; + height: number; +} + +const boxOf = (element: HTMLElement): Box => ({ + width: element.offsetWidth, + height: element.offsetHeight, +}); + +function scaleCrop(crop: ParsedInsetClipPathSides, from: Box, to: Box): ParsedInsetClipPathSides { + const sx = from.width > 0 ? to.width / from.width : 1; + const sy = from.height > 0 ? to.height / from.height : 1; + return { + top: crop.top * sy, + right: crop.right * sx, + bottom: crop.bottom * sy, + left: crop.left * sx, + radius: crop.radius, + }; +} + +/** The element's crop as the crop UI draws it: it follows the box while a resize draft is live. */ +export function readCropFollowingResize(element: HTMLElement): ParsedInsetClipPathSides | null { + const crop = readElementCropInsets(element); + const base = readStudioBoxSizeDraftBase(element); + return crop && base ? scaleCrop(crop, base, boxOf(element)) : crop; +} + +export interface CropResize { + patch: PatchOperation; + revert: () => void; +} + +/** Call before a resize lands. Once the new size is live, the stage writes the crop that keeps the + * same part of the element in view and returns its patch, or null when nothing needs rescaling. */ +export function prepareCropResize(element: HTMLElement): () => CropResize | null { + const from = readStudioBoxSizeDraftBase(element) ?? boxOf(element); + const before = element.style.getPropertyValue("clip-path"); + const priority = element.style.getPropertyPriority("clip-path"); + // A crop is left as authored when GSAP drives the clip, or tweens the size it is measured against. + const owned = + gsapAnimatesProperty(element, "clipPath") || gsapTweensProperty(element, "width", "height"); + const crop = owned ? null : readElementCropInsets(element); + return () => { + forgetStudioBoxSizeDraftBase(element); + const to = boxOf(element); + // A crop edited while the size saved was drawn in the new box already. + const untouched = element.style.getPropertyValue("clip-path") === before; + if (!crop || !hasCropInsets(crop) || !untouched || !(to.width > 0 && to.height > 0)) + return null; + if (to.width === from.width && to.height === from.height) return null; + const clip = buildInsetClipPathSides(scaleCrop(crop, from, to), crop.radius); + element.style.setProperty("clip-path", clip, priority); + return { + patch: { + type: "inline-style", + property: "clip-path", + value: priority ? `${clip} !important` : clip, + }, + revert: () => { + if (before) element.style.setProperty("clip-path", before, priority); + else element.style.removeProperty("clip-path"); + }, + }; + }; +} + +type PatchCommit = ( + selection: DomEditSelection, + patches: PatchOperation[], + options: { label: string; coalesceKey: string; coalesceMs?: number }, +) => Promise; + +/** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */ +export async function saveCropResize( + stage: () => CropResize | null, + selection: DomEditSelection, + commit: PatchCommit, + coalesceKey: string, +): Promise { + const crop = stage(); + if (!crop) return; + try { + await commit(selection, [crop.patch], { + label: "Resize layer", + coalesceKey, + coalesceMs: Number.POSITIVE_INFINITY, + }); + } catch (error) { + crop.revert(); + throw error; + } +} + +let sizeWriteCounter = 0; + +/** A property write that may size the element (a W/H field on an animated element): when it + * does, its writes and the rescaled crop share one undo step. */ +export async function writeSizeWithCrop( + selection: DomEditSelection, + properties: Record, + mutation: CommitMutation | null, + commit: PatchCommit, + write: (keyed?: CommitMutation) => Promise, +): Promise { + if (!mutation || !("width" in properties || "height" in properties)) return write(); + const stage = prepareCropResize(selection.element); + const coalesceKey = `size-write:${++sizeWriteCounter}`; + const undoStep = { coalesceKey, coalesceMs: Number.POSITIVE_INFINITY }; + const keyed: CommitMutation = (s, m, options) => mutation(s, m, { ...options, ...undoStep }); + if (mutation.batch) { + const batch = mutation.batch; + keyed.batch = (calls, options) => batch(calls, { ...options, ...undoStep }); + } + await write(keyed); + await saveCropResize(stage, selection, commit, coalesceKey); +} diff --git a/packages/studio/src/components/editor/domEditOverlayCrop.ts b/packages/studio/src/components/editor/domEditOverlayCrop.ts index fdd61465c4..9b16f8e932 100644 --- a/packages/studio/src/components/editor/domEditOverlayCrop.ts +++ b/packages/studio/src/components/editor/domEditOverlayCrop.ts @@ -1,5 +1,9 @@ import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform"; -import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers"; +import { + parseInsetClipPathSides, + type ClipPathInsetSides, + type ParsedInsetClipPathSides, +} from "./clipPathHelpers"; export type CropEdge = "top" | "right" | "bottom" | "left"; @@ -29,23 +33,69 @@ export function cropRectFromInsets( }; } -/** - * 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 { +// A selected croppable element shows uncropped so the crop UI can dim what is cut away. A rule keyed on its +// identity does that, so the element's own clip-path stays the one record undo, redo and panel edits rewrite. +const cropLifts = new WeakMap(); + +function liftSelector(element: HTMLElement): string | null { + const quote = (value: string) => `"${value.replace(/["\\]/g, "\\$&")}"`; + const hfId = element.getAttribute("data-hf-id"); + if (hfId) return `[data-hf-id=${quote(hfId)}]`; + return element.id ? `[id=${quote(element.id)}]` : null; +} + +export function liftElementCrop(element: HTMLElement): void { + const selector = liftSelector(element); + if (!selector || cropLifts.has(element)) return; + const doc = element.ownerDocument; + const rule = doc.createElement("style"); + rule.textContent = `${selector}{clip-path:none!important}`; + (doc.head ?? doc.documentElement).append(rule); + cropLifts.set(element, { rule }); +} + +export function dropElementCropLift(element: HTMLElement): void { + cropLifts.get(element)?.rule.remove(); + cropLifts.delete(element); +} + +export function isElementCropLifted(element: HTMLElement): boolean { + return cropLifts.has(element); +} + +const computedClipPath = (element: HTMLElement) => + element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || ""; + +/** The element's clip-path, inline first, then the stylesheet's as it is without the lift. */ +function readElementClipPath(element: HTMLElement): string { const inline = element.style.getPropertyValue("clip-path").trim(); - const value = - inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || ""; + const lift = cropLifts.get(element); + if (inline || !lift) return inline || computedClipPath(element); + // Only inline writes happen while selected, so the stylesheet's clip is read once per lift. + if (lift.sheetClip === undefined) { + const { parentNode, nextSibling } = lift.rule; + lift.rule.remove(); + lift.sheetClip = computedClipPath(element); + parentNode?.insertBefore(lift.rule, nextSibling); + } + return lift.sheetClip; +} + +/** Zeros = no clip yet. `null` = a clip this tool cannot represent (circle/polygon/non-px inset): + * croppers must not lift, edit, or restore it, or deselect silently replaces or destroys it. */ +function parseCropClipPath(value: string): ParsedInsetClipPathSides | null { if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 }; return parseInsetClipPathSides(value); } +export function readElementCropInsets(element: HTMLElement): ParsedInsetClipPathSides | null { + return parseCropClipPath(readElementClipPath(element)); +} + +export function hasCropInsets(insets: ClipPathInsetSides): boolean { + return insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0; +} + export interface CropInsetDragInput { edge: CropEdge; startInsets: ClipPathInsetSides; @@ -117,8 +167,8 @@ export function hugRectForElement( rect: CropScreenRect & { editScaleX: number; editScaleY: number }, element: HTMLElement, ): CropScreenRect { - const insets = readElementCropInsets(element); - // Uneditable clip (null) can't be hugged — show the full element rect. + const insets = isElementCropLifted(element) ? null : readElementCropInsets(element); + // Uneditable or lifted clip — 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); diff --git a/packages/studio/src/components/editor/domEditOverlayCropLift.test.ts b/packages/studio/src/components/editor/domEditOverlayCropLift.test.ts new file mode 100644 index 0000000000..f0c9594e2d --- /dev/null +++ b/packages/studio/src/components/editor/domEditOverlayCropLift.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from "vitest"; +import { + dropElementCropLift, + hugRectForElement, + liftElementCrop, + readElementCropInsets, +} from "./domEditOverlayCrop"; + +afterEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; +}); + +function cropped(style: string): HTMLElement { + const el = document.createElement("div"); + el.setAttribute("data-hf-id", "hf-a"); + el.style.cssText = style; + document.body.append(el); + return el; +} + +describe("crop lift", () => { + it("shows the element uncropped over its inline clip, and drop takes the rule away", () => { + const el = cropped("clip-path: inset(0px 60px 0px 0px)"); + liftElementCrop(el); + expect(getComputedStyle(el).clipPath).toBe("none"); + expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)"); + dropElementCropLift(el); + expect(document.head.querySelectorAll("style")).toHaveLength(0); + expect(getComputedStyle(el).clipPath).toBe("inset(0px 60px 0px 0px)"); + }); + + it("still reads a stylesheet crop while lifted, and the hover hug shows the full box", () => { + const sheet = document.createElement("style"); + sheet.textContent = '[data-hf-id="hf-a"] { clip-path: inset(0px 60px 0px 0px); }'; + document.head.append(sheet); + const el = cropped("width: 300px"); + liftElementCrop(el); + expect(readElementCropInsets(el)).toMatchObject({ right: 60 }); + const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 }; + expect(hugRectForElement(rect, el)).toEqual(rect); + }); +}); diff --git a/packages/studio/src/components/editor/gsapAnimatesProperty.ts b/packages/studio/src/components/editor/gsapAnimatesProperty.ts index 9485c410c0..a34cb827bd 100644 --- a/packages/studio/src/components/editor/gsapAnimatesProperty.ts +++ b/packages/studio/src/components/editor/gsapAnimatesProperty.ts @@ -1,46 +1,28 @@ -// GSAP's CSSPlugin takes ownership of the element's entire transform stack -// when it tweens ANY of these — it bakes the CSS `translate` longhand into -// style.transform at init and writes `translate: none` every tick. Position -// reapply/strip logic must therefore stand down for all of them, not just x/y. -const GSAP_TRANSFORM_PROPS = [ - "x", - "y", - "xPercent", - "yPercent", - "scale", - "scaleX", - "scaleY", - "rotation", - "rotate", - "rotationX", - "rotationY", - "skewX", - "skewY", - "transform", -]; - -/** - * True when GSAP animates any transform-affecting property on the element, - * meaning GSAP owns `style.transform` and has neutralized CSS `translate`. - */ -export function gsapAnimatesTransform(el: HTMLElement): boolean { - return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS); -} - /** * Checks whether GSAP actively animates one or more CSS/GSAP properties on * the given element by inspecting all registered `__timelines`. */ -// fallow-ignore-next-line complexity export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { + return findGsapWrite(el, props, false); +} + +/** Like gsapAnimatesProperty, but only tweens that run over time count, not instant holds. */ +export function gsapTweensProperty(el: HTMLElement, ...props: string[]): boolean { + return findGsapWrite(el, props, true); +} + +// fallow-ignore-next-line complexity +function findGsapWrite(el: HTMLElement, props: string[], tweensOnly: boolean): boolean { const win = el.ownerDocument.defaultView as | (Window & { __timelines?: Record< string, { - getChildren?: ( - deep: boolean, - ) => Array<{ targets?: () => Element[]; vars?: Record }>; + getChildren?: (deep: boolean) => Array<{ + targets?: () => Element[]; + vars?: Record; + duration?: () => number; + }>; } >; }) @@ -52,6 +34,7 @@ export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boole try { for (const child of tl.getChildren(true)) { if (!child.targets || !child.vars) continue; + if (tweensOnly && !((child.duration?.() ?? 0) > 0)) continue; let targetsEl = false; for (const t of child.targets()) { if (t === el || (el.id && t.id === el.id)) { diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 99b9b0eea4..a75ded7846 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -457,10 +457,26 @@ export function applyStudioBoxSize( applyStudioBoxSizeDimensions(element, size); } +// The element's box before a resize draft first changed it; gone once the resize saves or is restored. +const boxSizeDraftBases = new WeakMap(); + +export function readStudioBoxSizeDraftBase( + element: HTMLElement, +): { width: number; height: number } | null { + return boxSizeDraftBases.get(element) ?? null; +} + +export function forgetStudioBoxSizeDraftBase(element: HTMLElement): void { + boxSizeDraftBases.delete(element); +} + export function applyStudioBoxSizeDraft( element: HTMLElement, size: { width: number; height: number }, ): void { + if (!boxSizeDraftBases.has(element)) { + boxSizeDraftBases.set(element, { width: element.offsetWidth, height: element.offsetHeight }); + } promoteInlineForTransform(element); applyStudioBoxSizeDimensions(element, size); } diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 826fb16829..1fb3569869 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -3,6 +3,7 @@ import { styleUsesStudioSize, styleUsesStudioRotation, restoreInlineDisplay, + forgetStudioBoxSizeDraftBase, } from "./manualEditsDom"; import { STUDIO_OFFSET_X_PROP, @@ -114,6 +115,7 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str } export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void { + forgetStudioBoxSizeDraftBase(element); restoreStyleProperty(element, "width", previous.width); restoreStyleProperty(element, "height", previous.height); restoreStyleProperty(element, "min-width", previous.minWidth); @@ -290,6 +292,7 @@ export function clearStudioRotation(element: HTMLElement): void { } export function clearStudioBoxSize(element: HTMLElement): void { + forgetStudioBoxSizeDraftBase(element); if ( element.hasAttribute(STUDIO_BOX_SIZE_ATTR) || styleUsesStudioSize(element.style.getPropertyValue("width")) || diff --git a/packages/studio/src/hooks/domSelectionTestHarness.ts b/packages/studio/src/hooks/domSelectionTestHarness.ts index affd703311..c2b0bc9bd3 100644 --- a/packages/studio/src/hooks/domSelectionTestHarness.ts +++ b/packages/studio/src/hooks/domSelectionTestHarness.ts @@ -12,6 +12,14 @@ export function installReactActEnvironment(): void { }); } +export function withInlineLayoutBox(el: T): T { + Object.defineProperties(el, { + offsetWidth: { get: () => Number.parseFloat(el.style.width) || 0 }, + offsetHeight: { get: () => Number.parseFloat(el.style.height) || 0 }, + }); + return el; +} + /** Mount a React element into a fresh detached host and return its root. */ export function mountReactHarness(node: React.ReactElement): Root { const host = document.createElement("div"); diff --git a/packages/studio/src/hooks/gsapEditOutcome.ts b/packages/studio/src/hooks/gsapEditOutcome.ts index 72a4aa362e..487ce34cc5 100644 --- a/packages/studio/src/hooks/gsapEditOutcome.ts +++ b/packages/studio/src/hooks/gsapEditOutcome.ts @@ -67,6 +67,12 @@ export function assertGsapEditPersisted(outcome: GsapEditOutcome): void { if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail); } +/** A move only a shared tween positions is saved on the element itself; a blocked one throws. */ +export async function saveMove(outcome: GsapEditOutcome, saveOnElement: () => Promise) { + if (outcome.status === "element-offset") return saveOnElement(); + assertGsapEditPersisted(outcome); +} + function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void { const editability = editabilityForProvenance(animation.provenance); if (editability === "unroll") throw new GsapEditBlockedError("unroll-required"); diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx index e664db4900..74c9217dc0 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx @@ -6,7 +6,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; -import { mountReactHarness } from "./domSelectionTestHarness"; +import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness"; +import { writeSizeWithCrop } from "../components/editor/cropResize"; +import type { CommitMutation } from "./gsapScriptCommitTypes"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -362,3 +364,40 @@ describe("commitStaticSet group routing", () => { act(() => root.unmount()); }); }); + +describe("useAnimatedPropertyCommit — a size write and its crop are one undo step", () => { + it.each([[{ width: 450 }], [{ x: 10, width: 450 }]])("%o on a static element", async (props) => { + const element = withInlineLayoutBox(document.createElement("div")); + element.id = "box"; + element.style.cssText = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)"; + document.body.append(element); + const sel = { ...selection, element } as DomEditSelection; + const keys: unknown[] = []; + const land = async (options: { coalesceKey?: string }) => { + keys.push(options.coalesceKey); + element.style.width = "450px"; + }; + const mutation: CommitMutation = (_s, _m, options) => land(options); + mutation.batch = (_calls, options) => land(options); + let raw!: ReturnType["commitAnimatedProperties"]; + function Harness() { + raw = useAnimatedPropertyCommit({ + selectedGsapAnimations: [], + gsapCommitMutation: mutation, + addGsapAnimation: vi.fn(), + convertToKeyframes: vi.fn(), + previewIframeRef: { current: null }, + bumpGsapCache: vi.fn(), + }).commitAnimatedProperties; + return null; + } + const root = mountReactHarness(); + const patch = vi.fn().mockResolvedValue(undefined); + await writeSizeWithCrop(sel, props, mutation, patch, (keyed) => raw(sel, props, keyed)); + + const cropKey = patch.mock.calls[0]![2].coalesceKey; + expect(patch.mock.calls[0]![1][0]).toMatchObject({ value: "inset(0px 90px 0px 0px)" }); + expect(keys).toEqual([cropKey]); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 177d8c5c5d..254474be6f 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -431,8 +431,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { const commitAnimatedProperties = useCallback( // This is the single routing boundary for set, keyframe, whole-tween, and first-group writes. // fallow-ignore-next-line complexity - async (selection: DomEditSelection, props: Record): Promise => { - if (!gsapCommitMutation) return; + async ( + selection: DomEditSelection, + props: Record, + commit: CommitMutation | null = gsapCommitMutation, + ): Promise => { + if (!commit) return; const propEntries = Object.entries(props); if (propEntries.length === 0) return; const primaryProp = propEntries[0]![0]; @@ -490,7 +494,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { ), pct, iframe, - { commitMutation: gsapCommitMutation }, + { commitMutation: commit }, `Edit ${primaryProp} (whole animation)`, ); return; @@ -503,7 +507,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { primaryProp, selector, iframe, - gsapCommitMutation, + commit, ); return; } @@ -515,13 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { // must update the position set AND create a size set atomically rather // than contaminating the first set with a foreign property group. if (!elementHasKeyframes) { - await commitStaticSet( - selection, - propEntries, - selector, - selectedGsapAnimations, - gsapCommitMutation, - ); + await commitStaticSet(selection, propEntries, selector, selectedGsapAnimations, commit); return; } @@ -534,7 +532,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { propEntries, selector, selectedGsapAnimations, - gsapCommitMutation, + commit, ); return; } @@ -566,7 +564,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { { percentage: 0, properties: { ...newProps, _auto: 1 } }, { percentage: pct, properties: newProps }, ]; - await gsapCommitMutation( + await commit( selection, { type: "add-with-keyframes", diff --git a/packages/studio/src/hooks/useCropOverlay.ts b/packages/studio/src/hooks/useCropOverlay.ts index 77e5a3b266..2563b33ce5 100644 --- a/packages/studio/src/hooks/useCropOverlay.ts +++ b/packages/studio/src/hooks/useCropOverlay.ts @@ -1,6 +1,9 @@ import type { OverlayRect } from "../components/editor/domEditOverlayGeometry"; import type { DomEditSelection } from "../components/editor/domEditing"; -import { readElementCropInsets } from "../components/editor/domEditOverlayCrop"; +import { + isElementCropLifted, + readElementCropInsets, +} from "../components/editor/domEditOverlayCrop"; /** Selection-box crop hug: the outline that makes the selection box hug the * element's committed inset crop. Crop is always-on (no mode) — the draggable @@ -13,7 +16,10 @@ export function useCropOverlay(params: { }) { const { selection, overlayRect } = params; - const cropInsets = selection ? readElementCropInsets(selection.element) : null; + const cropInsets = + selection && !isElementCropLifted(selection.element) + ? readElementCropInsets(selection.element) + : null; const hasCropInsets = Boolean( cropInsets && (cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0), diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index fdff1119d4..eacf065279 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -314,6 +314,7 @@ export function useDomEditCommits({ handleDomAddTextField, handleDomRemoveTextField, stageElementPositionOffset, + commitPositionPatchToHtml, handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, diff --git a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts index 1b8b4aaa12..73728061df 100644 --- a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts +++ b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts @@ -12,12 +12,12 @@ interface UseDomEditPositionPatchCommitParams { showToast: (message: string, tone?: "error" | "info") => void; } -interface PositionPatchOptions { +type PositionPatchOptions = { label: string; coalesceKey: string; coalesceMs?: number; skipRefresh?: boolean; -} +}; export function useDomEditPositionPatchCommit({ activeCompPath, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 2d1d48ae7d..367b7566fd 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -230,6 +230,7 @@ export function useDomEditSession({ handleDomAddTextField, handleDomRemoveTextField, stageElementPositionOffset, + commitPositionPatchToHtml, handleDomBoxSizeCommit, handleDomManualEditsReset, handleDomEditElementsDelete, @@ -490,6 +491,7 @@ export function useDomEditSession({ trackGsapInteractionFailure, stageElementPositionOffset, handleDomBoxSizeCommit, + commitPositionPatchToHtml, addGsapAnimation, convertToKeyframes, setArcPath, diff --git a/packages/studio/src/hooks/useDomGeometryCommit.ts b/packages/studio/src/hooks/useDomGeometryCommit.ts index 39853af316..a40b36d405 100644 --- a/packages/studio/src/hooks/useDomGeometryCommit.ts +++ b/packages/studio/src/hooks/useDomGeometryCommit.ts @@ -136,6 +136,7 @@ export function useDomGeometryCommit({ trackGsapInteractionFailure, stageElementPositionOffset, handleDomBoxSizeCommit: noDomBoxSizeRoute, + commitPositionPatchToHtml: commitWithFreshQueue, addGsapAnimation: gsap.addGsapAnimation, convertToKeyframes: gsap.convertToKeyframes, setArcPath: gsap.setArcPath, diff --git a/packages/studio/src/hooks/useDomGeometryCommits.test.tsx b/packages/studio/src/hooks/useDomGeometryCommits.test.tsx index 5f5c9e6a71..9f39635ad4 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.test.tsx +++ b/packages/studio/src/hooks/useDomGeometryCommits.test.tsx @@ -13,6 +13,8 @@ import { readStudioRotation, } from "../components/editor/manualEdits"; import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits"; +import { DomEditCropHandles } from "../components/editor/DomEditCropHandles"; +import { withInlineLayoutBox } from "./domSelectionTestHarness"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -170,3 +172,113 @@ describe("useDomGeometryCommits element position offset", () => { unmount(); }); }); + +describe("useDomGeometryCommits resize of a cropped element", () => { + it("saves the crop scaled per axis with the box in the resize's own commit", async () => { + const element = withInlineLayoutBox(document.createElement("div")); + element.id = "cropped"; + element.style.cssText = "width: 300px; height: 200px; clip-path: inset(8px 60px 16px 30px)"; + document.body.append(element); + const selection = { + id: "cropped", + selector: "#cropped", + element, + } as unknown as DomEditSelection; + const commitPositionPatchToHtml = vi + .fn() + .mockResolvedValue(undefined); + const { commits, unmount } = mountCommits(commitPositionPatchToHtml); + const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 }; + const host = document.createElement("div"); + const crop = createRoot(host); + act(() => + crop.render( + , + ), + ); + + await commits().handleDomBoxSizeCommit(selection, { width: 450, height: 250 }); + + const scaled = "inset(10px 90px 20px 45px)"; + expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1); + expect(commitPositionPatchToHtml.mock.calls[0]![1]).toContainEqual({ + type: "inline-style", + property: "clip-path", + value: scaled, + }); + act(() => crop.unmount()); + expect(element.style.getPropertyValue("clip-path")).toBe(scaled); + unmount(); + }); + + function mountCroppedSelection(id: string, authored: string) { + const element = withInlineLayoutBox(document.createElement("div")); + element.id = id; + element.style.cssText = authored; + document.body.append(element); + const selection = { id, selector: `#${id}`, element } as unknown as DomEditSelection; + const commitPositionPatchToHtml = vi + .fn() + .mockResolvedValue(undefined); + const mounted = mountCommits(commitPositionPatchToHtml); + const onStyleCommit = vi.fn((property: string, value: string) => { + element.style.setProperty(property, value); + }); + const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 }; + const crop = createRoot(document.body.appendChild(document.createElement("div"))); + const draw = () => + act(() => + crop.render( + , + ), + ); + draw(); + const dragRightEdge = (by: number) => { + const handle = document.querySelector('[aria-label="Crop right"]')!; + const press = (type: string, clientX: number) => + act(() => + handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, clientX })), + ); + press("pointerdown", 100); + press("pointermove", 100 - by); + press("pointerup", 100 - by); + }; + const resize = () => + mounted.commits().handleDomBoxSizeCommit(selection, { width: 450, height: 300 }); + const done = () => { + act(() => crop.unmount()); + mounted.unmount(); + }; + return { element, onStyleCommit, draw, dragRightEdge, resize, done }; + } + + it("after an undo while still selected, the next crop drag starts from the undone crop", async () => { + const authored = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)"; + const h = mountCroppedSelection("undone", authored); + await h.resize(); + // Undo writes the file's style attribute back onto the same live element. + h.element.setAttribute("style", authored); + h.draw(); + h.dragRightEdge(20); + + expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 80px 0px 0px)"); + h.done(); + expect(h.element.style.getPropertyValue("clip-path")).toBe("inset(0px 80px 0px 0px)"); + }); + + it("a crop drag right after a resize, with no render between, starts from the scaled crop", async () => { + const h = mountCroppedSelection( + "resized", + "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)", + ); + await h.resize(); + h.dragRightEdge(20); + + expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 110px 0px 0px)"); + h.done(); + }); +}); diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index 8fefd3f63e..e358ddcaa6 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -15,6 +15,7 @@ import { clearStudioRotation, } from "../components/editor/manualEdits"; import { stageElementOffset } from "./elementOffsetStager"; +import { prepareCropResize } from "../components/editor/cropResize"; import { buildPathOffsetPatches, buildBoxSizePatches, @@ -103,7 +104,9 @@ export function useDomGeometryCommits({ if (gsapFallback) return gsapFallback; const beforeSize = captureStudioBoxSize(selection.element); const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null; + const stageCrop = prepareCropResize(selection.element); applyStudioBoxSize(selection.element, next); + const crop = stageCrop(); // Anchored-corner resize (NW/NE/SW) also moves the element to keep the // opposite corner fixed. Apply the offset and emit BOTH patch sets in a // SINGLE commit: one persist = one undo entry, and there is no @@ -111,6 +114,7 @@ export function useDomGeometryCommits({ // offset is not (that frame was the release "jump"). Both builders read // the already-mutated live element, so concatenation is safe. const patches = buildBoxSizePatches(selection.element); + if (crop) patches.push(crop.patch); if (offset) { applyStudioPathOffset(selection.element, offset); patches.push(...buildPathOffsetPatches(selection.element)); @@ -121,6 +125,7 @@ export function useDomGeometryCommits({ }).catch((error) => { restoreStudioBoxSize(selection.element, beforeSize); if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset); + crop?.revert(); throw error; }); }, diff --git a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx index 3866109387..9ac5e9fe96 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx @@ -1,11 +1,13 @@ // @vitest-environment happy-dom import React, { act } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; -import { mountReactHarness } from "./domSelectionTestHarness"; +import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness"; +import { DomEditCropHandles } from "../components/editor/DomEditCropHandles"; +import { applyStudioBoxSizeDraft } from "../components/editor/manualEdits"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -56,6 +58,7 @@ function mountResizeHandler( const anchorRollback = vi.fn(); const elementOffset = vi.fn(() => ({ save: anchorSave, rollback: anchorRollback })); const commitMutation = vi.fn().mockResolvedValue(undefined); + const commitPatch = vi.fn().mockResolvedValue(undefined); let resize: | (( selection: DomEditSelection, @@ -64,8 +67,9 @@ function mountResizeHandler( restore?: () => void, ) => Promise) | null = null; + let property: ReturnType["commitAnimatedProperty"] | null = null; function Harness() { - resize = useGsapAwareEditing({ + const editing = useGsapAwareEditing({ domEditSelection: selection, selectedGsapAnimations: animations, gsapCommitMutation: commitMutation, @@ -76,11 +80,14 @@ function mountResizeHandler( trackGsapInteractionFailure: vi.fn(), stageElementPositionOffset: elementOffset, handleDomBoxSizeCommit: fallback, + commitPositionPatchToHtml: commitPatch, addGsapAnimation: vi.fn(), convertToKeyframes: vi.fn(), setArcPath: vi.fn(), updateArcSegment: vi.fn(), - }).handleGsapAwareBoxSizeCommit; + }); + resize = editing.handleGsapAwareBoxSizeCommit; + property = editing.commitAnimatedProperty; return null; } const root = mountReactHarness(); @@ -91,7 +98,9 @@ function mountResizeHandler( anchorSave, anchorRollback, commitMutation, + commitPatch, resize: resize!, + property: property!, root, }; } @@ -122,6 +131,7 @@ function mountGroupHandler({ trackGsapInteractionFailure, stageElementPositionOffset, handleDomBoxSizeCommit: vi.fn(), + commitPositionPatchToHtml: vi.fn(), addGsapAnimation: vi.fn(), convertToKeyframes: vi.fn(), setArcPath: vi.fn(), @@ -257,6 +267,92 @@ describe("useGsapAwareEditing anchored resize", () => { act(() => h.root.unmount()); }); + function mountCropped() { + const h = mountResizeHandler([]); + const el = withInlineLayoutBox(h.selection.element); + el.id = "clip"; + el.style.cssText = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)"; + document.body.append(el); + const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 }; + const crop = mountReactHarness( + , + ); + return { ...h, el, crop }; + } + + function resizeLandsWhen(where: "intercept" | "dispatch", h: { commitMutation: Mock }) { + const land = (el: HTMLElement) => { + el.style.width = "450px"; + el.style.height = "300px"; + }; + mocks.resize.mockImplementation(async (selection, _next, _a, _i, commit) => { + if (where === "intercept") land(selection.element); + await commit(selection, { type: "size" }, { label: "Resize", softReload: true }); + return { status: "persisted" }; + }); + if (where === "dispatch") h.commitMutation.mockImplementation(async (s) => land(s.element)); + } + + const scaled = "inset(0px 90px 0px 0px)"; + + it("saves the crop scaled with a size that lands only when saved (the W/H fields)", async () => { + const h = mountCropped(); + resizeLandsWhen("dispatch", h); + await act(() => h.resize(h.selection, { width: 450, height: 300 })); + + expect(h.commitPatch).toHaveBeenCalledWith( + h.selection, + [{ type: "inline-style", property: "clip-path", value: scaled }], + expect.objectContaining({ coalesceKey: h.commitMutation.mock.calls[0]![2].coalesceKey }), + ); + act(() => h.crop.unmount()); + expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled); + act(() => h.root.unmount()); + }); + + it("scales from the box before a draft its caller applied (the agent tool)", async () => { + const h = mountCropped(); + resizeLandsWhen("intercept", h); + applyStudioBoxSizeDraft(h.el, { width: 450, height: 300 }); + await act(() => h.resize(h.selection, { width: 450, height: 300 })); + + expect(h.commitPatch.mock.calls[0]![1]).toEqual([ + { type: "inline-style", property: "clip-path", value: scaled }, + ]); + act(() => h.crop.unmount()); + act(() => h.root.unmount()); + }); + + it("a W field on an animated element saves the scaled crop in the same undo step", async () => { + const h = mountCropped(); + mocks.commitAnimatedProperties.mockImplementation(async (selection, _props, keyed) => { + await (keyed ?? h.commitMutation)(selection, { type: "set" }, { label: "Edit width" }); + selection.element.style.width = "450px"; + }); + await act(() => h.property(h.selection, "width", 450)); + + const key = h.commitMutation.mock.calls[0]![2].coalesceKey; + expect(key).toBeTruthy(); + expect(h.commitPatch).toHaveBeenCalledWith( + h.selection, + [{ type: "inline-style", property: "clip-path", value: "inset(0px 90px 0px 0px)" }], + expect.objectContaining({ coalesceKey: key }), + ); + act(() => h.crop.unmount()); + act(() => h.root.unmount()); + }); + + it("shows the scaled crop when deselected before the save lands", async () => { + const h = mountCropped(); + resizeLandsWhen("intercept", h); + const saved = h.resize(h.selection, { width: 450, height: 300 }); + act(() => h.crop.unmount()); + await act(() => saved); + + expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled); + act(() => h.root.unmount()); + }); + it("does not keep the anchor move when the size save fails", async () => { resizeWritesSize(); const h = mountResizeHandler([]); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 4f294423c5..5cbc734146 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -34,14 +34,14 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug"; import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; import { runGestureTransaction } from "./gestureTransaction"; import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes"; -import { assertGsapEditPersisted, type GsapEditOutcome } from "./gsapEditOutcome"; +import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome"; import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback"; - -/** A move only a shared tween positions is saved on the element itself; a blocked one throws. */ -async function saveMove(outcome: GsapEditOutcome, saveOnElement: () => Promise) { - if (outcome.status === "element-offset") return saveOnElement(); - assertGsapEditPersisted(outcome); -} +import type { ElementOffsetStagerDeps } from "./elementOffsetStager"; +import { + prepareCropResize, + saveCropResize, + writeSizeWithCrop, +} from "../components/editor/cropResize"; // Distinct coalesceKey per group drag so consecutive group drags don't fold // into one another's undo entry (module-local counter, not Date.now()). @@ -87,6 +87,7 @@ export interface UseGsapAwareEditingParams { next: { width: number; height: number }, offset?: { x: number; y: number }, ) => Promise; + commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"]; // GSAP script commit ops (from useGsapScriptCommits) addGsapAnimation: ( sel: DomEditSelection, @@ -130,6 +131,7 @@ export function useGsapAwareEditing({ trackGsapInteractionFailure, stageElementPositionOffset, handleDomBoxSizeCommit, + commitPositionPatchToHtml, addGsapAnimation, convertToKeyframes, setArcPath, @@ -353,6 +355,8 @@ export function useGsapAwareEditing({ animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`), }); let anchorMove: ReturnType | null = null; + const stageCrop = prepareCropResize(selection.element); + let cropUndoKey: string | null = null; return runGestureTransaction({ element: selection.element, label: "Resize layer", @@ -381,6 +385,7 @@ export function useGsapAwareEditing({ makeFetchFallback(selection), ); assertGsapEditPersisted(outcome); + cropUndoKey = coalesceKey; // What the resize actually did, not what its animations suggest // it would do. An element whose scale is an instant hold has a // scale-group tween and still commits width/height, so guessing @@ -424,7 +429,13 @@ export function useGsapAwareEditing({ logResizeSettle(selection.element, "dom-route"); await handleDomBoxSizeCommit(selection, next, offset); }, - afterBufferedCommitsSaved: () => anchorMove?.save() ?? Promise.resolve(), + afterBufferedCommitsSaved: async () => { + await anchorMove?.save(); + // Only now is the size live for every caller, drag or not. + if (cropUndoKey) { + await saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey); + } + }, restore: () => { anchorMove?.rollback(); restore(); @@ -434,6 +445,7 @@ export function useGsapAwareEditing({ }, [ handleDomBoxSizeCommit, + commitPositionPatchToHtml, stageElementPositionOffset, gsapCommitMutation, previewIframeRef, @@ -481,10 +493,7 @@ export function useGsapAwareEditing({ // ── Animated property commit ── - const { - commitAnimatedProperty: commitAnimatedPropertyRaw, - commitAnimatedProperties: commitAnimatedPropertiesRaw, - } = useAnimatedPropertyCommit({ + const { commitAnimatedProperties: commitAnimatedPropertiesRaw } = useAnimatedPropertyCommit({ selectedGsapAnimations, gsapCommitMutation, addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time), @@ -496,25 +505,30 @@ export function useGsapAwareEditing({ const commitAnimatedProperties = useCallback( async (selection: DomEditSelection, properties: Record) => { try { - await commitAnimatedPropertiesRaw(selection, properties); + await writeSizeWithCrop( + selection, + properties, + gsapCommitMutation, + commitPositionPatchToHtml, + (keyed) => commitAnimatedPropertiesRaw(selection, properties, keyed), + ); } catch (error) { trackGsapInteractionFailure(error, selection, "property", "Edit animated property"); throw error; } }, - [commitAnimatedPropertiesRaw, trackGsapInteractionFailure], + [ + commitAnimatedPropertiesRaw, + commitPositionPatchToHtml, + gsapCommitMutation, + trackGsapInteractionFailure, + ], ); const commitAnimatedProperty = useCallback( - async (selection: DomEditSelection, property: string, value: number | string) => { - try { - await commitAnimatedPropertyRaw(selection, property, value); - } catch (error) { - trackGsapInteractionFailure(error, selection, "property", "Edit animated property"); - throw error; - } - }, - [commitAnimatedPropertyRaw, trackGsapInteractionFailure], + (selection: DomEditSelection, property: string, value: number | string) => + commitAnimatedProperties(selection, { [property]: value }), + [commitAnimatedProperties], ); // ── Arc path wrappers ── From 48ee9675d37606f6d0fca07193395acff8a7c5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 08:04:37 -0400 Subject: [PATCH 2/3] fix(studio): the crop follows each axis GSAP does not tween, while dragging and once saved --- .../src/components/editor/cropResize.test.ts | 41 +++++++++++----- .../src/components/editor/cropResize.ts | 39 ++++++++++----- .../components/editor/gsapAnimatesProperty.ts | 49 +++++++++++++------ .../studio/src/hooks/gsapRuntimeKeyframes.ts | 39 ++++++++++----- 4 files changed, 117 insertions(+), 51 deletions(-) diff --git a/packages/studio/src/components/editor/cropResize.test.ts b/packages/studio/src/components/editor/cropResize.test.ts index 08c9f7d5a7..60eeacd083 100644 --- a/packages/studio/src/components/editor/cropResize.test.ts +++ b/packages/studio/src/components/editor/cropResize.test.ts @@ -93,18 +93,35 @@ describe("crop during a resize", () => { Object.assign(window, { __timelines: undefined }); }); - it("leaves the crop as authored when the size is tweened, but not when it is only held", () => { - const stageWith = (child: object) => { - const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); - const timeline = { getChildren: () => [{ targets: () => [el], ...child }] }; - Object.assign(window, { __timelines: { main: timeline } }); - const stage = prepareCropResize(el); - el.style.width = "450px"; - return stage(); - }; - const keyframes = { "0%": { width: 300 }, "100%": { width: 600 } }; - expect(stageWith({ vars: { keyframes }, duration: () => 4 })).toBeNull(); - expect(stageWith({ vars: { width: 473 }, duration: () => 0 })).not.toBeNull(); + it("decides before the write, which may add a width tween of its own", () => { + const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)"); + const stage = prepareCropResize(el); + const tween = { targets: () => [el], vars: { width: 600 }, duration: () => 4 }; + Object.assign(window, { __timelines: { main: { getChildren: () => [tween] } } }); + el.style.width = "600px"; + expect(stage()?.patch.value).toBe("inset(0px 120px 0px 0px)"); Object.assign(window, { __timelines: undefined }); }); + + it("follows only the axes GSAP does not tween, while dragging and once saved", () => { + const resize = (vars: object, duration: number) => { + const el = sizedElement(300, 200, "inset(20px 60px 20px 0px)"); + const tween = { targets: () => [el], vars, duration: () => duration }; + Object.assign(window, { __timelines: { main: { getChildren: () => [tween] } } }); + applyStudioBoxSizeDraft(el, { width: 600, height: 400 }); + const { top, right } = readCropFollowingResize(el)!; + const saved = prepareCropResize(el)()?.patch.value ?? null; + Object.assign(window, { __timelines: undefined }); + return [[top, right], saved]; + }; + // A held width still scales; a height tween leaves the width free. + expect(resize({ width: 473 }, 0)).toEqual([[40, 120], "inset(40px 120px 40px 0px)"]); + expect(resize({ height: 300 }, 4)).toEqual([[20, 120], "inset(20px 120px 20px 0px)"]); + // A width tween, in either keyframe form, leaves the height free. + const widthOnly = [[40, 60], "inset(40px 60px 40px 0px)"]; + const percent = { "0%": { width: 300 }, "100%": { width: 600 } }; + expect(resize({ keyframes: percent }, 4)).toEqual(widthOnly); + expect(resize({ keyframes: { width: [300, 600] } }, 4)).toEqual(widthOnly); + expect(resize({ width: 600, height: 400 }, 4)).toEqual([[20, 60], null]); + }); }); diff --git a/packages/studio/src/components/editor/cropResize.ts b/packages/studio/src/components/editor/cropResize.ts index 70bf36d572..9d2e89e47e 100644 --- a/packages/studio/src/components/editor/cropResize.ts +++ b/packages/studio/src/components/editor/cropResize.ts @@ -1,7 +1,8 @@ import type { PatchOperation } from "../../utils/sourcePatcher"; import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes"; import type { DomEditSelection } from "./domEditingTypes"; -import { gsapAnimatesProperty, gsapTweensProperty } from "./gsapAnimatesProperty"; +import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; +import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes"; import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers"; import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop"; import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom"; @@ -16,9 +17,25 @@ const boxOf = (element: HTMLElement): Box => ({ height: element.offsetHeight, }); -function scaleCrop(crop: ParsedInsetClipPathSides, from: Box, to: Box): ParsedInsetClipPathSides { - const sx = from.width > 0 ? to.width / from.width : 1; - const sy = from.height > 0 ? to.height / from.height : 1; +type Follows = Record; + +/** Per axis, whether a crop follows a resize: not when GSAP drives the clip or tweens that axis's size. */ +function cropFollows(element: HTMLElement): Follows { + const clip = gsapAnimatesProperty(element, "clipPath"); + return { + width: !clip && !elementHasNonHoldTween(element, ["width"]), + height: !clip && !elementHasNonHoldTween(element, ["height"]), + }; +} + +function scaleCrop( + crop: ParsedInsetClipPathSides, + from: Box, + to: Box, + follows: Follows, +): ParsedInsetClipPathSides { + const sx = follows.width && from.width > 0 ? to.width / from.width : 1; + const sy = follows.height && from.height > 0 ? to.height / from.height : 1; return { top: crop.top * sy, right: crop.right * sx, @@ -32,7 +49,7 @@ function scaleCrop(crop: ParsedInsetClipPathSides, from: Box, to: Box): ParsedIn export function readCropFollowingResize(element: HTMLElement): ParsedInsetClipPathSides | null { const crop = readElementCropInsets(element); const base = readStudioBoxSizeDraftBase(element); - return crop && base ? scaleCrop(crop, base, boxOf(element)) : crop; + return crop && base ? scaleCrop(crop, base, boxOf(element), cropFollows(element)) : crop; } export interface CropResize { @@ -46,10 +63,9 @@ export function prepareCropResize(element: HTMLElement): () => CropResize | null const from = readStudioBoxSizeDraftBase(element) ?? boxOf(element); const before = element.style.getPropertyValue("clip-path"); const priority = element.style.getPropertyPriority("clip-path"); - // A crop is left as authored when GSAP drives the clip, or tweens the size it is measured against. - const owned = - gsapAnimatesProperty(element, "clipPath") || gsapTweensProperty(element, "width", "height"); - const crop = owned ? null : readElementCropInsets(element); + // Decided before the write lands: a W edit may add a width keyframe of its own. + const follows = cropFollows(element); + const crop = readElementCropInsets(element); return () => { forgetStudioBoxSizeDraftBase(element); const to = boxOf(element); @@ -57,8 +73,9 @@ export function prepareCropResize(element: HTMLElement): () => CropResize | null const untouched = element.style.getPropertyValue("clip-path") === before; if (!crop || !hasCropInsets(crop) || !untouched || !(to.width > 0 && to.height > 0)) return null; - if (to.width === from.width && to.height === from.height) return null; - const clip = buildInsetClipPathSides(scaleCrop(crop, from, to), crop.radius); + const clip = buildInsetClipPathSides(scaleCrop(crop, from, to, follows), crop.radius); + // Unchanged when the box is (a resize saved as a `scale`) or no resized axis follows. + if (clip === buildInsetClipPathSides(crop, crop.radius)) return null; element.style.setProperty("clip-path", clip, priority); return { patch: { diff --git a/packages/studio/src/components/editor/gsapAnimatesProperty.ts b/packages/studio/src/components/editor/gsapAnimatesProperty.ts index a34cb827bd..9485c410c0 100644 --- a/packages/studio/src/components/editor/gsapAnimatesProperty.ts +++ b/packages/studio/src/components/editor/gsapAnimatesProperty.ts @@ -1,28 +1,46 @@ +// GSAP's CSSPlugin takes ownership of the element's entire transform stack +// when it tweens ANY of these — it bakes the CSS `translate` longhand into +// style.transform at init and writes `translate: none` every tick. Position +// reapply/strip logic must therefore stand down for all of them, not just x/y. +const GSAP_TRANSFORM_PROPS = [ + "x", + "y", + "xPercent", + "yPercent", + "scale", + "scaleX", + "scaleY", + "rotation", + "rotate", + "rotationX", + "rotationY", + "skewX", + "skewY", + "transform", +]; + /** - * Checks whether GSAP actively animates one or more CSS/GSAP properties on - * the given element by inspecting all registered `__timelines`. + * True when GSAP animates any transform-affecting property on the element, + * meaning GSAP owns `style.transform` and has neutralized CSS `translate`. */ -export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { - return findGsapWrite(el, props, false); -} - -/** Like gsapAnimatesProperty, but only tweens that run over time count, not instant holds. */ -export function gsapTweensProperty(el: HTMLElement, ...props: string[]): boolean { - return findGsapWrite(el, props, true); +export function gsapAnimatesTransform(el: HTMLElement): boolean { + return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS); } +/** + * Checks whether GSAP actively animates one or more CSS/GSAP properties on + * the given element by inspecting all registered `__timelines`. + */ // fallow-ignore-next-line complexity -function findGsapWrite(el: HTMLElement, props: string[], tweensOnly: boolean): boolean { +export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { const win = el.ownerDocument.defaultView as | (Window & { __timelines?: Record< string, { - getChildren?: (deep: boolean) => Array<{ - targets?: () => Element[]; - vars?: Record; - duration?: () => number; - }>; + getChildren?: ( + deep: boolean, + ) => Array<{ targets?: () => Element[]; vars?: Record }>; } >; }) @@ -34,7 +52,6 @@ function findGsapWrite(el: HTMLElement, props: string[], tweensOnly: boolean): b try { for (const child of tl.getChildren(true)) { if (!child.targets || !child.vars) continue; - if (tweensOnly && !((child.duration?.() ?? 0) > 0)) continue; let targetsEl = false; for (const t of child.targets()) { if (t === el || (el.id && t.id === el.id)) { diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index bbcfea0666..9193a835f3 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -200,7 +200,7 @@ function varsCarryChannel(vars: Record | undefined, channels: s /** * Like `varsCarryChannel` but for a keyframe tween: the channels live inside the * keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object - * form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`). + * form (`{ "0%": {...} }`), the array form (`[{...}, ...]`) and the object-of-arrays form. */ function keyframeVarsCarryChannel( vars: Record | undefined, @@ -208,6 +208,8 @@ function keyframeVarsCarryChannel( ): boolean { const kf = vars?.keyframes; if (!kf || typeof kf !== "object") return false; + if (!Array.isArray(kf) && channels.some((ch) => Object.prototype.hasOwnProperty.call(kf, ch))) + return true; const steps = Array.isArray(kf) ? kf : Object.values(kf); return steps.some( (step) => @@ -387,35 +389,48 @@ export function readRuntimeKeyframes( * rotation/scale tween doesn't make a static position hold enter the keyframe * branch. */ -// fallow-ignore-next-line complexity export function hasNonHoldTweenForElement( iframe: HTMLIFrameElement | null, selector: string, compositionId?: string, channels?: string[], ): boolean { - const timelines = timelinesOf(iframe); - if (!timelines) return false; - const tlId = - compositionId || - Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function"); - if (!tlId) return false; - const timeline = timelines[tlId]; - if (!timeline?.getChildren) return false; - let targetEl: Element | null = null; try { targetEl = iframe?.contentDocument?.querySelector(selector) ?? null; } catch { return false; } - if (!targetEl) return false; + return !!targetEl && hasNonHoldTween(timelinesOf(iframe), targetEl, channels, compositionId); +} + +/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */ +export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean { + const win = el.ownerDocument.defaultView as { + __timelines?: Record; + } | null; + return hasNonHoldTween(win?.__timelines ?? null, el, channels); +} +// fallow-ignore-next-line complexity +function hasNonHoldTween( + timelines: Record | null, + targetEl: Element, + channels?: string[], + compositionId?: string, +): boolean { + if (!timelines) return false; + const tlId = + compositionId || + Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function"); + const timeline = tlId ? timelines[tlId] : undefined; + if (!timeline?.getChildren) return false; // fallow-ignore-next-line code-duplication for (const tween of timeline.getChildren(true)) { if (!tween.vars || !matchesElement(tween, targetEl)) continue; const dur = typeof tween.duration === "function" ? tween.duration() : 0; if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) + if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true; const read = readTween(tween.vars); if (read && (!channels || readCarriesChannel(read, channels))) return true; } From 4175ab488b7ceb7a489266a792c7a5bb90e15ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 09:40:45 -0400 Subject: [PATCH 3/3] fix(studio): the live-tween check reads every timeline, not only the first after a soft reload --- .../editor/DomEditCropHandles.test.tsx | 2 +- .../src/components/editor/cropResize.test.ts | 4 ++- .../src/hooks/gsapRuntimeKeyframes.test.ts | 7 +++++ .../studio/src/hooks/gsapRuntimeKeyframes.ts | 28 +++++++++---------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx index 327b750930..e4c191f742 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx @@ -152,7 +152,7 @@ describe("DomEditCropHandles clip lift", () => { expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)"); }); - it("commits the dragged crop and stays lifted, whether the save lands or fails", async () => { + it("commits the dragged crop and stays lifted when the save fails", async () => { const a = makeEl("a", "inset(10px)"); const onStyleCommit = vi.fn((property: string, value: string) => { a.style.setProperty(property, value); diff --git a/packages/studio/src/components/editor/cropResize.test.ts b/packages/studio/src/components/editor/cropResize.test.ts index 60eeacd083..b77901b842 100644 --- a/packages/studio/src/components/editor/cropResize.test.ts +++ b/packages/studio/src/components/editor/cropResize.test.ts @@ -107,7 +107,9 @@ describe("crop during a resize", () => { const resize = (vars: object, duration: number) => { const el = sizedElement(300, 200, "inset(20px 60px 20px 0px)"); const tween = { targets: () => [el], vars, duration: () => duration }; - Object.assign(window, { __timelines: { main: { getChildren: () => [tween] } } }); + // The tween's timeline is not the first key, as after a soft reload. + const timelines = { sub: { getChildren: () => [] }, main: { getChildren: () => [tween] } }; + Object.assign(window, { __timelines: timelines }); applyStudioBoxSizeDraft(el, { width: 600, height: 400 }); const { top, right } = readCropFollowingResize(el)!; const saved = prepareCropResize(el)()?.patch.value ?? null; diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts index 85bf8749f4..b09a326148 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts @@ -167,6 +167,13 @@ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale- expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true); }); + it("true when the tween sits in a later timeline (a soft reload re-adds main last)", () => { + const iframe = fakeIframe(el, []); + const win = iframe.contentWindow as unknown as { __timelines: Record }; + win.__timelines.main = { getChildren: () => [liveTween] }; + expect(hasNonHoldTweenForElement(iframe, "#puck-b")).toBe(true); + }); + it("false when only a zero-duration hold/set remains (post delete-all)", () => { expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false); }); diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 9193a835f3..9f1bc35220 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -377,8 +377,9 @@ export function readRuntimeKeyframes( } /** - * Whether the live timeline has at least one NON-HOLD tween (non-zero duration, - * not the studio position-hold `set`) targeting `selector`. Stricter than a + * Whether any live timeline has at least one NON-HOLD tween (non-zero duration, + * not the studio position-hold `set`) targeting `selector`. Every timeline is read: + * a soft reload re-adds the rebuilt composition's key last. Stricter than a * truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing * tween, so it can't distinguish a real animation from a leftover hold/marker. * The drag's stale-parse guard needs this exact distinction — after a delete-all @@ -420,19 +421,16 @@ function hasNonHoldTween( compositionId?: string, ): boolean { if (!timelines) return false; - const tlId = - compositionId || - Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function"); - const timeline = tlId ? timelines[tlId] : undefined; - if (!timeline?.getChildren) return false; - // fallow-ignore-next-line code-duplication - for (const tween of timeline.getChildren(true)) { - if (!tween.vars || !matchesElement(tween, targetEl)) continue; - const dur = typeof tween.duration === "function" ? tween.duration() : 0; - if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) - if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true; - const read = readTween(tween.vars); - if (read && (!channels || readCarriesChannel(read, channels))) return true; + for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) { + // fallow-ignore-next-line code-duplication + for (const tween of timelines[tlId]?.getChildren?.(true) ?? []) { + if (!tween.vars || !matchesElement(tween, targetEl)) continue; + const dur = typeof tween.duration === "function" ? tween.duration() : 0; + if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) + if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true; + const read = readTween(tween.vars); + if (read && (!channels || readCarriesChannel(read, channels))) return true; + } } return false; }