From fee9a166b9435cf0dd7a80d16500a04e6dc82d15 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 16:05:20 -0700 Subject: [PATCH] feat(studio): a host can move layers by exact film px, one undo per call handleDomGroupMoveBy(moves) on useDomEditSession and DomEditValue moves each selection by its delta in composition px, for Desktop's align and distribute. It reuses the group drag's own path: a drag member per element (press-time route, measured offset map), the delta carried to screen px through the preview frames, then handleGsapAwareGroupPathOffsetCommit, so one shared coalesce key gives one undo entry and GSAP members take the GSAP write. A member that can't move refuses the call before anything is written; the refusal rejects with its reason and shows no Studio toast (the group commit takes refusalToast: false; telemetry stays). Zero deltas are skipped. Tests: a pre-offset element and one under a half-scale parent move by exactly {10,0} and {0,-5} under one group key; a half-scale parent with a 30 deg rotated ancestor lands within the 0.001 px a translate is written with; a GSAP member goes through the GSAP write; three refusal kinds write nothing, leave the boxes where they were, show no toast and leave no gesture mark, while a canvas group drag still toasts; the session hands the call to the group commit with refusalToast: false. Each mutation (no frame mapping, no zero skip, no restore, no teardown, no capability check, toast on refusal) fails at least one of them. --- .../components/editor/domGroupMoveBy.test.tsx | 272 ++++++++++++++++++ .../src/components/editor/domGroupMoveBy.ts | 95 ++++++ .../src/components/editor/manualOffsetDrag.ts | 11 +- .../studio/src/contexts/DomEditContext.tsx | 4 + .../src/hooks/useDomEditSession.test.tsx | 31 +- .../studio/src/hooks/useDomEditSession.ts | 9 + .../src/hooks/useGsapAwareEditing.test.tsx | 45 ++- .../studio/src/hooks/useGsapAwareEditing.ts | 23 +- .../useGsapInteractionFailureTelemetry.ts | 14 +- 9 files changed, 452 insertions(+), 52 deletions(-) create mode 100644 packages/studio/src/components/editor/domGroupMoveBy.test.tsx create mode 100644 packages/studio/src/components/editor/domGroupMoveBy.ts 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],