diff --git a/packages/studio/src/components/editor/domGroupMoveBy.test.tsx b/packages/studio/src/components/editor/domGroupMoveBy.test.tsx new file mode 100644 index 0000000000..767e9a0f80 --- /dev/null +++ b/packages/studio/src/components/editor/domGroupMoveBy.test.tsx @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DomEditSelection } from "./domEditingTypes"; +import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; +import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits"; +import { isStudioManualEditGestureLiveIn } from "./manualEditsDom"; +import { readTranslatePx } from "./plainTranslate"; +import { moveDomGroupBy } from "./domGroupMoveBy"; +import { mountReactHarness } from "../../hooks/domSelectionTestHarness"; +import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome"; + +Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + +const mocks = vi.hoisted(() => ({ drag: vi.fn() })); +vi.mock("../../hooks/gsapRuntimeBridge", () => ({ + tryGsapDragIntercept: mocks.drag, + tryGsapRotationIntercept: vi.fn(), +})); +vi.mock("../../hooks/useAnimatedPropertyCommit", () => ({ + useAnimatedPropertyCommit: () => ({ + commitAnimatedProperty: vi.fn(), + commitAnimatedProperties: vi.fn(), + }), +})); +vi.mock("../../hooks/useSafeGsapCommitMutation", () => ({ + useGsapSaveFailureTelemetry: () => vi.fn(), + useSafeGsapCommitMutation: (commit: unknown) => commit, +})); +vi.mock("../../utils/studioSaveDiagnostics", async (importOriginal) => ({ + ...(await importOriginal()), + trackStudioEditBlocked: vi.fn(), + trackStudioSaveFailure: vi.fn(), +})); + +import { useGsapAwareEditing } from "../../hooks/useGsapAwareEditing"; +import { useGsapInteractionFailureTelemetry } from "../../hooks/useGsapInteractionFailureTelemetry"; + +afterEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +/** A preview iframe drawn at half size: 200x100 composition px in a 100x50 box. */ +function preview(): Document { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const win = iframe.contentWindow!; + Object.defineProperty(win, "frameElement", { configurable: true, value: iframe }); + Object.defineProperty(win, "innerWidth", { configurable: true, value: 200 }); + Object.defineProperty(win, "innerHeight", { configurable: true, value: 100 }); + iframe.getBoundingClientRect = () => new DOMRect(50, 40, 100, 50); + return iframe.contentDocument!; +} + +const turned = (deg: number, scale: number) => { + const r = (deg * Math.PI) / 180; + return [scale * Math.cos(r), scale * Math.sin(r), -scale * Math.sin(r), scale * Math.cos(r)]; +}; + +/** An element whose box centre is `at` plus its own move carried through its ancestors' linear map. */ +function layer(doc: Document, id: string, parent = [1, 0, 0, 1], gsap?: object) { + const element = Object.assign(doc.createElement("div"), gsap ? { _gsap: gsap } : {}); + doc.body.append(element); + const own = () => { + if (!gsap) return readTranslatePx(element); + const read = (prop: string) => Number.parseFloat(element.style.getPropertyValue(prop)) || 0; + return { x: read(STUDIO_OFFSET_X_PROP), y: read(STUDIO_OFFSET_Y_PROP) }; + }; + element.getBoundingClientRect = () => { + const { x, y } = own(); + const [a, b, c, d] = parent as [number, number, number, number]; + return new DOMRect(60 + a * x + c * y - 20, 40 + b * x + d * y - 10, 40, 20); + }; + const selection = { + element, + id, + selector: `#${id}`, + label: id, + capabilities: { canApplyManualOffset: true }, + } as unknown as DomEditSelection; + const centre = () => { + const r = element.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }; + return { element, selection, centre }; +} + +function mountGroupCommit() { + const showToast = vi.fn(); + const save = vi.fn().mockResolvedValue(undefined); + const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() })); + let commit!: (u: DomEditGroupPathOffsetCommit[], o?: { refusalToast?: boolean }) => Promise; + function Harness() { + commit = useGsapAwareEditing({ + domEditSelection: null, + selectedGsapAnimations: [], + gsapCommitMutation: vi.fn().mockResolvedValue(undefined), + previewIframeRef: { current: null }, + showToast, + bumpGsapCache: vi.fn(), + makeFetchFallback: () => vi.fn().mockResolvedValue([]), + trackGsapInteractionFailure: useGsapInteractionFailureTelemetry("index.html", showToast), + stageElementPositionOffset, + handleDomBoxSizeCommit: vi.fn(), + handleDomRotationCommit: vi.fn(), + commitPositionPatchToHtml: vi.fn(), + addGsapAnimation: vi.fn(), + convertToKeyframes: vi.fn(), + setArcPath: vi.fn(), + updateArcSegment: vi.fn(), + }).handleGsapAwareGroupPathOffsetCommit; + return null; + } + const root = mountReactHarness(); + const moveBy = (moves: Parameters[0]) => + act(() => moveDomGroupBy(moves, (u) => commit(u, { refusalToast: false }))); + return { + commit: (u: DomEditGroupPathOffsetCommit[]) => commit(u), + moveBy, + showToast, + stageElementPositionOffset, + root, + }; +} + +const moved = (from: { x: number; y: number }, to: { x: number; y: number }) => ({ + x: to.x - from.x, + y: to.y - from.y, +}); + +describe("handleDomGroupMoveBy", () => { + it("moves each box by its delta in composition px, saved under one undo entry", async () => { + const doc = preview(); + const offset = layer(doc, "offset"); + offset.element.style.setProperty("translate", "30px 0px"); + const halved = layer(doc, "halved", [0.5, 0, 0, 0.5]); + const before = [offset.centre(), halved.centre()]; + const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit(); + + await moveBy([ + { selection: offset.selection, delta: { x: 10, y: 0 } }, + { selection: halved.selection, delta: { x: 0, y: -5 } }, + ]); + + expect(moved(before[0]!, offset.centre())).toEqual({ x: 10, y: 0 }); + expect(moved(before[1]!, halved.centre())).toEqual({ x: 0, y: -5 }); + const calls = stageElementPositionOffset.mock.calls as unknown as unknown[][]; + expect(calls.map((call) => call[1])).toEqual([ + { x: 40, y: 0 }, + { x: 0, y: -10 }, + ]); + expect(calls[0]![3]).toMatch(/^group-drag:\d+$/); + expect(calls[1]![3]).toBe(calls[0]![3]); + expect(showToast).not.toHaveBeenCalled(); + expect(isStudioManualEditGestureLiveIn(doc)).toBe(false); + act(() => root.unmount()); + }); + + it("lands exactly under a half-scale parent with a rotated ancestor", async () => { + const doc = preview(); + const tilted = layer(doc, "tilted", turned(30, 0.5)); + const before = tilted.centre(); + const { moveBy, root } = mountGroupCommit(); + + await moveBy([{ selection: tilted.selection, delta: { x: 10, y: 0 } }]); + + const delta = moved(before, tilted.centre()); + // Exact to the 0.001 px a translate is written with. + expect(delta.x).toBeCloseTo(10, 3); + expect(delta.y).toBeCloseTo(0, 3); + act(() => root.unmount()); + }); + + it("moves a member GSAP positions through the GSAP write, by the same delta", async () => { + mocks.drag.mockResolvedValue({ status: "persisted" }); + const doc = preview(); + const animated = layer(doc, "animated", [1, 0, 0, 1], { renderTransform: () => {} }); + const before = animated.centre(); + const { moveBy, stageElementPositionOffset, root } = mountGroupCommit(); + + await moveBy([{ selection: animated.selection, delta: { x: 10, y: 0 } }]); + + const write = mocks.drag.mock.calls.find((call) => !call[6]?.preflightOnly); + expect(write?.[0]).toBe(animated.selection); + expect(write?.[1]).toEqual({ x: 10, y: 0 }); + expect(moved(before, animated.centre())).toEqual({ x: 10, y: 0 }); + expect(stageElementPositionOffset).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("skips a zero delta, and writes nothing when every delta is zero", async () => { + const doc = preview(); + const still = layer(doc, "still"); + const { moveBy, stageElementPositionOffset, root } = mountGroupCommit(); + + await moveBy([{ selection: still.selection, delta: { x: 0, y: 0 } }]); + + expect(stageElementPositionOffset).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it.each([ + [ + "a member that can't take a manual move", + (doc: Document) => { + const locked = layer(doc, "locked"); + Object.assign(locked.selection.capabilities, { + canApplyManualOffset: false, + reasonIfDisabled: "Locked by a helper loop.", + }); + return locked; + }, + /helper loop/, + ], + [ + "a member an animation took over", + (doc: Document) => layer(doc, "folded", [1, 0, 0, 1], { x: "94px" }), + /animation took over/, + ], + [ + "a member the GSAP write refuses", + (doc: Document) => { + mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" }); + return layer(doc, "blocked", [1, 0, 0, 1], { renderTransform: () => {} }); + }, + new GsapEditBlockedError("no-selector").message, + ], + ])("refuses %s before any write, without a Studio toast", async (_, make, reason) => { + const doc = preview(); + const fine = layer(doc, "fine"); + const refused = make(doc); + const before = [fine.centre(), refused.centre()]; + const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit(); + + await expect( + moveBy([ + { selection: fine.selection, delta: { x: 10, y: 0 } }, + { selection: refused.selection, delta: { x: 10, y: 0 } }, + ]), + ).rejects.toThrow(reason); + + expect(stageElementPositionOffset).not.toHaveBeenCalled(); + expect(mocks.drag.mock.calls.some((call) => !call[6]?.preflightOnly)).toBe(false); + expect(showToast).not.toHaveBeenCalled(); + expect([fine.centre(), refused.centre()]).toEqual(before); + expect(isStudioManualEditGestureLiveIn(doc)).toBe(false); + act(() => root.unmount()); + }); + + it.each([ + ["an animation took over", () => layer(preview(), "folded", [1, 0, 0, 1], { x: "94px" }), true], + [ + "the GSAP write refuses", + () => { + mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" }); + return layer(preview(), "blocked", [1, 0, 0, 1], { renderTransform: () => {} }); + }, + false, + ], + ])("still toasts on a canvas group drag when %s", async (_, make, plainTranslate) => { + const { selection } = make(); + const { commit, showToast, root } = mountGroupCommit(); + + await expect( + act(() => commit([{ selection, next: { x: 1, y: 2 }, plainTranslate }])), + ).rejects.toThrow(); + + expect(showToast).toHaveBeenCalledWith(expect.any(String), "error"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/domGroupMoveBy.ts b/packages/studio/src/components/editor/domGroupMoveBy.ts new file mode 100644 index 0000000000..61cb40e718 --- /dev/null +++ b/packages/studio/src/components/editor/domGroupMoveBy.ts @@ -0,0 +1,95 @@ +import type { DomEditSelection } from "./domEditing"; +import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; +import { canCanvasNudgeTargets } from "./domEditNudge"; +import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits"; +import { countStudioManualEditSave } from "./manualEditsDom"; +import { + applyManualOffsetDragCommit, + createManualOffsetDragMember, + endManualOffsetDragMembers, + restoreManualOffsetDragMembers, + toTopScreenPoint, + type ManualOffsetDragMember, +} from "./manualOffsetDrag"; + +export interface DomGroupMove { + selection: DomEditSelection; + /** Composition px: the element's box moves by exactly this in the film. */ + delta: { x: number; y: number }; +} + +/** Composition px to the top page's px; the preview frames only scale and shift. */ +function toScreen(doc: Document, delta: { x: number; y: number }) { + const origin = toTopScreenPoint(doc, { x: 0, y: 0 }); + const moved = toTopScreenPoint(doc, delta); + if (!origin || !moved) throw new Error("The preview has no measurable size."); + return { x: moved.x - origin.x, y: moved.y - origin.y }; +} + +function memberFor( + selection: DomEditSelection, + scale: { x: number; y: number }, +): ManualOffsetDragMember { + const { element } = selection; + const box = element.getBoundingClientRect(); + const result = createManualOffsetDragMember({ + key: selection.id ?? selection.selector ?? selection.label, + selection, + element, + rect: { + left: box.left, + top: box.top, + width: box.width, + height: box.height, + editScaleX: scale.x, + editScaleY: scale.y, + }, + gesture: "drag", + }); + if (!result.ok) throw new Error(result.reason); + return result.member; +} + +/** + * Moves each element by its delta as one group drag would drop it: one save, one undo entry. + * Any member that can't move refuses the whole call before anything is drawn or written. + */ +export async function moveDomGroupBy( + moves: DomGroupMove[], + commitGroup: (updates: DomEditGroupPathOffsetCommit[]) => Promise, +): Promise { + const moving = moves.filter(({ delta }) => delta.x !== 0 || delta.y !== 0); + if (moving.length === 0) return; + const selections = moving.map(({ selection }) => selection); + if (!canCanvasNudgeTargets(selections)) { + const blocked = selections.find((s) => !s.capabilities.canApplyManualOffset); + throw new Error(blocked?.capabilities.reasonIfDisabled ?? `${blocked?.label} can't be moved.`); + } + const screens = moving.map(({ selection, delta }) => + toScreen(selection.element.ownerDocument, delta), + ); + const members: ManualOffsetDragMember[] = []; + try { + for (const selection of selections) + members.push(memberFor(selection, toScreen(selection.element.ownerDocument, { x: 1, y: 1 }))); + } catch (error) { + restoreManualOffsetDragMembers(members); + throw error; + } + const updates = members.map((member, index) => ({ + selection: member.selection, + next: applyManualOffsetDragCommit(member, screens[index]!.x, screens[index]!.y), + plainTranslate: member.plainTranslate, + })); + try { + await countStudioManualEditSave(members[0]!.element, () => commitGroup(updates)); + } catch (error) { + for (const member of members) { + if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) + restoreStudioPathOffset(member.element, member.initialPathOffset); + } + throw error; + } finally { + endManualOffsetDragMembers(members); + } +} diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 54fd975ede..48002d697d 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -146,12 +146,12 @@ function getRectCenter(element: HTMLElement): Point | null { return null; } - let point = { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }; + const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + return toTopScreenPoint(element.ownerDocument, point); +} - let win: Window | null = element.ownerDocument.defaultView; +export function toTopScreenPoint(doc: Document, point: Point): Point | null { + let win: Window | null = doc.defaultView; while (win) { const frameElement = getFrameElement(win); if (!frameElement) break; @@ -166,7 +166,6 @@ function getRectCenter(element: HTMLElement): Point | null { }; win = frameElement.ownerDocument.defaultView; } - return point; } diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index d84f19ca54..7673ddb3ea 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -24,6 +24,7 @@ const SAVES_AN_EDIT = { handleDomAttributeBatchCommit: true, handleDomPathOffsetCommit: true, handleDomGroupPathOffsetCommit: true, + handleDomGroupMoveBy: true, handleDomZIndexReorderCommit: true, handleDomBoxSizeCommit: true, handleDomRotationCommit: true, @@ -179,6 +180,7 @@ export function DomEditProvider({ handleDomAttributeBatchCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, + handleDomGroupMoveBy, handleDomZIndexReorderCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -274,6 +276,7 @@ export function DomEditProvider({ handleDomAttributeBatchCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, + handleDomGroupMoveBy, handleDomZIndexReorderCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -350,6 +353,7 @@ export function DomEditProvider({ handleDomAttributeBatchCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, + handleDomGroupMoveBy, handleDomZIndexReorderCommit, handleDomBoxSizeCommit, handleDomRotationCommit, diff --git a/packages/studio/src/hooks/useDomEditSession.test.tsx b/packages/studio/src/hooks/useDomEditSession.test.tsx index 41d8be9dbd..1a0f23c5dd 100644 --- a/packages/studio/src/hooks/useDomEditSession.test.tsx +++ b/packages/studio/src/hooks/useDomEditSession.test.tsx @@ -10,6 +10,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { TimelineElement } from "../player"; import type { UseDomEditSessionParams } from "./useDomEditSession"; import { makeSelection } from "./domSelectionTestHarness"; +import { readTranslatePx } from "../components/editor/plainTranslate"; const styleOp = (property: string, value: string): PatchOperation => ({ type: "inline-style", @@ -66,6 +67,7 @@ const groupSelectionSpy = vi.fn(); const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() }); const neverParsed = new Promise<{ animations: GsapAnimation[] }>(() => undefined); const parsedFile = { current: neverParsed }; +const groupCommitSpy = vi.fn(async () => {}); function createSessionParams( overrides: Partial = {}, @@ -256,7 +258,7 @@ vi.mock("./usePreviewInteraction", () => ({ vi.mock("./useGsapAwareEditing", () => ({ useGsapAwareEditing: () => ({ handleGsapAwarePathOffsetCommit: vi.fn(), - handleGsapAwareGroupPathOffsetCommit: vi.fn(), + handleGsapAwareGroupPathOffsetCommit: groupCommitSpy, handleGsapAwareBoxSizeCommit: vi.fn(), handleGsapAwareRotationCommit: vi.fn(), commitAnimatedProperty: vi.fn(), @@ -605,3 +607,30 @@ describe("a shadow reload promoted without a host re-render", () => { act(() => root.unmount()); }); }); + +describe("handleDomGroupMoveBy", () => { + it("saves through the group drag's commit, leaving a refusal for the host to explain", async () => { + const { useDomEditSession } = await import("./useDomEditSession"); + const element = document.createElement("div"); + document.body.append(element); + element.getBoundingClientRect = () => { + const { x, y } = readTranslatePx(element); + return new DOMRect(x, y, 40, 20); + }; + const selection = makeSelection("card", element); + let moveBy!: ReturnType["handleDomGroupMoveBy"]; + function Probe() { + moveBy = useDomEditSession(createSessionParams()).handleDomGroupMoveBy; + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render()); + await act(() => moveBy([{ selection, delta: { x: 10, y: 0 } }])); + expect(groupCommitSpy).toHaveBeenCalledWith( + [{ selection, next: { x: 10, y: 0 }, plainTranslate: true }], + { refusalToast: false }, + ); + act(() => root.unmount()); + element.remove(); + }); +}); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 24791bd37a..257ecbaee8 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -25,6 +25,7 @@ import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMemb import type { RecordEditInput } from "./domEditDeleteMembers"; import type { DomEditTimelineParams } from "./useDomSelectionTypes"; import { useLivePreviewIframe } from "./useLivePreviewIframe"; +import { moveDomGroupBy, type DomGroupMove } from "../components/editor/domGroupMoveBy"; // Re-exported: the delete rule lives in its own module; callers and its test import it from here. export { membersForDelete }; @@ -459,6 +460,13 @@ export function useDomEditSession({ setArcPath, updateArcSegment, }); + const handleDomGroupMoveBy = useCallback( + (moves: DomGroupMove[]) => + moveDomGroupBy(moves, (updates) => + handleGsapAwareGroupPathOffsetCommit(updates, { refusalToast: false }), + ), + [handleGsapAwareGroupPathOffsetCommit], + ); const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } = useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef }); const committable = useCommitPreflightCapabilities({ @@ -497,6 +505,7 @@ export function useDomEditSession({ handleDomAttributeBatchCommit, handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit, + handleDomGroupMoveBy, handleDomZIndexReorderCommit, handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit, handleDomRotationCommit: handleGsapAwareRotationCommit, diff --git a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx index d86f6686fd..aa2af279c5 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.test.tsx @@ -119,18 +119,21 @@ function mountResizeHandler( type AwareEditingParams = Parameters[0]; function mountGroupHandler({ - gsapCommitMutation, - makeFetchFallback, + gsapCommitMutation = vi.fn().mockResolvedValue(undefined), + makeFetchFallback = () => vi.fn().mockResolvedValue([]), trackGsapInteractionFailure = vi.fn(), stageElementPositionOffset = vi.fn(), handleDomRotationCommit = vi.fn(), -}: Pick & - Partial< - Pick< - AwareEditingParams, - "trackGsapInteractionFailure" | "stageElementPositionOffset" | "handleDomRotationCommit" - > - >) { +}: Partial< + Pick< + AwareEditingParams, + | "gsapCommitMutation" + | "makeFetchFallback" + | "trackGsapInteractionFailure" + | "stageElementPositionOffset" + | "handleDomRotationCommit" + > +> = {}) { let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise; let pathOffsetCommit!: ( selection: DomEditSelection, @@ -211,8 +214,6 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => { const save = vi.fn().mockResolvedValue(undefined); const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() })); const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({ - gsapCommitMutation: vi.fn().mockResolvedValue(undefined), - makeFetchFallback: () => vi.fn().mockResolvedValue([]), stageElementPositionOffset, }); const box = { @@ -231,11 +232,7 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => { describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => { it("writes no member when one CSS-route member has been folded since the press", async () => { const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() })); - const { groupCommit, root } = mountGroupHandler({ - gsapCommitMutation: vi.fn().mockResolvedValue(undefined), - makeFetchFallback: () => vi.fn().mockResolvedValue([]), - stageElementPositionOffset, - }); + const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset }); const card = { element: document.createElement("div"), id: "card", @@ -263,7 +260,6 @@ describe("useGsapAwareEditing rotation routing", () => { const gsapCommitMutation = vi.fn(); const h = mountGroupHandler({ gsapCommitMutation, - makeFetchFallback: () => vi.fn().mockResolvedValue([]), handleDomRotationCommit, }); const box = { element, id: "box", selector: "#box" } as unknown as DomEditSelection; @@ -327,7 +323,6 @@ describe("useGsapAwareEditing shared-tween moves", () => { const commitMutation = vi.fn(); const { pathOffsetCommit, root } = mountGroupHandler({ gsapCommitMutation: commitMutation, - makeFetchFallback: () => vi.fn().mockResolvedValue([]), stageElementPositionOffset, }); const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" }; @@ -346,11 +341,7 @@ describe("useGsapAwareEditing shared-tween moves", () => { }); const save = vi.fn().mockResolvedValue(undefined); const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() })); - const { groupCommit, root } = mountGroupHandler({ - gsapCommitMutation: vi.fn().mockResolvedValue(undefined), - makeFetchFallback: () => vi.fn().mockResolvedValue([]), - stageElementPositionOffset, - }); + const { groupCommit, root } = mountGroupHandler({ stageElementPositionOffset }); const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" }; const box = { element: gsapPositioned("div"), id: "box", selector: "#box" }; const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" }; @@ -653,7 +644,6 @@ describe("useGsapAwareEditing anchored resize", () => { ); const { groupCommit, root } = mountGroupHandler({ gsapCommitMutation: commitMutation, - makeFetchFallback: () => vi.fn().mockResolvedValue([]), }); const updates = [ { @@ -754,11 +744,7 @@ describe("useGsapAwareEditing anchored resize", () => { mocks.drag.mockImplementation(async (selection) => { throw selection.id === "a" ? failures[0] : failures[1]; }); - const { groupCommit, root } = mountGroupHandler({ - gsapCommitMutation: vi.fn().mockResolvedValue(undefined), - makeFetchFallback: () => vi.fn().mockResolvedValue([]), - trackGsapInteractionFailure, - }); + const { groupCommit, root } = mountGroupHandler({ trackGsapInteractionFailure }); const updates = [ { selection: { element: gsapPositioned("div"), id: "a", selector: "#a" }, @@ -777,6 +763,7 @@ describe("useGsapAwareEditing anchored resize", () => { updates[0]?.selection, "drag", "Move animated layer (group)", + true, ); mocks.drag.mockReset(); if (priorDragImplementation) mocks.drag.mockImplementation(priorDragImplementation); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 008b599c19..b26b6ce9c0 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -79,6 +79,7 @@ export interface UseGsapAwareEditingParams { selection: DomEditSelection | null, mutationType: string, label: string, + toast?: boolean, ) => void; // DOM fallbacks (from useDomEditCommits) stageElementPositionOffset: ( @@ -200,12 +201,11 @@ export function useGsapAwareEditing({ // Multi-select (group) drag: each member takes the single drag's writer, so a member GSAP // does not position is saved on itself and the rest go through the GSAP intercept. const handleGsapAwareGroupPathOffsetCommit = useCallback( - async (updates: DomEditGroupPathOffsetCommit[]) => { + async (updates: DomEditGroupPathOffsetCommit[], options: { refusalToast?: boolean } = {}) => { if (!gsapCommitMutation || updates.length === 0) return; - // A group drag is ONE user action: fold every member's position write into - // a single undo entry by forcing a shared coalesceKey (infinite window, so - // it survives the N sequential server round-trips) onto each commit — - // otherwise each member records its own entry and it takes N presses to undo. + const toastRefusal = options.refusalToast !== false; + // One user action, one undo entry: a shared coalesceKey with an infinite window survives + // the N sequential round trips, so N members don't take N presses to undo. const coalesceKey = `group-drag:${++groupDragCommitCounter}`; // Members are written one at a time, and a re-render re-runs the script with the OLD // position of every member not yet written, so they snap back until their own write @@ -219,9 +219,8 @@ export function useGsapAwareEditing({ deferPreviewSync: !renderOnCommit, previewFallbackLatch, }); - // Every member writes the same file. Queue their mutations and send them as - // ONE request instead of one round trip per member: the server reads, parses - // and writes the composition once, and the preview patches once. + // Every member writes the same file: queue the mutations and send ONE request, so the + // server parses and writes the composition once and the preview patches once. const queued: CommitMutationCall[] = []; const flushQueued = async () => { if (queued.length === 0) return; @@ -244,13 +243,12 @@ export function useGsapAwareEditing({ const preflightAnimations = new Map(); // Members saved on themselves, each with its route: true for its CSS translate. const offsetMembers = new Map(); - // Editability is user-atomic: prove every member can be written before the first source - // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights - // write nothing and share one in-flight parse per file, so they run together. + // Prove every member can be written before the first mutation, so a blocked one never leaves + // siblings half moved. Preflights write nothing and share one parse per file: run together. const preflightResults = await Promise.allSettled( updates.map(async ({ selection, plainTranslate }) => { if (plainTranslate ?? !gsapWritesPosition(selection.element)) { - refuseGsapTakeover(selection.element, showToast); + refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {}); return void offsetMembers.set(selection, true); } const animations = await makeFetchFallback(selection, { failOnFetchError: true })(); @@ -275,6 +273,7 @@ export function useGsapAwareEditing({ preflightFailure.selection, "drag", "Move animated layer (group)", + toastRefusal, ); throw preflightFailure.error; } diff --git a/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts index 6a1bebdc83..b2342d622a 100644 --- a/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts +++ b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts @@ -4,8 +4,8 @@ import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioS import { isGsapEditBlockedError } from "./gsapEditOutcome"; import { wasAlreadyToasted } from "./domEditPersistFailure"; -function failureToast(error: unknown): string | null { - if (wasAlreadyToasted(error)) return null; +function failureToast(error: unknown, toast: boolean): string | null { + if (!toast || wasAlreadyToasted(error)) return null; return isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit."; } @@ -14,7 +14,13 @@ export function useGsapInteractionFailureTelemetry( showToast: (message: string, tone?: "error" | "info") => void, ) { return useCallback( - (error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => { + ( + error: unknown, + selection: DomEditSelection | null, + mutationType: string, + label: string, + toast?: boolean, + ) => { const report = isGsapEditBlockedError(error) ? trackStudioEditBlocked : trackStudioSaveFailure; @@ -28,7 +34,7 @@ export function useGsapInteractionFailureTelemetry( targetSelector: selection?.selector, targetSourceFile: selection?.sourceFile, }); - const message = failureToast(error); + const message = failureToast(error, toast !== false); if (message) showToast(message, "error"); }, [activeCompPath, showToast],