From db783597bb60ff2b20958f7bdf40df93a26629d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 12 Jun 2026 18:30:26 -0400 Subject: [PATCH] fix(studio): stabilize manual drag targets --- .../components/editor/DomEditOverlay.test.ts | 190 +++++++++++++++--- .../src/components/editor/DomEditOverlay.tsx | 24 +-- .../src/components/editor/PropertyPanel.tsx | 26 +-- .../src/components/editor/domEditing.test.ts | 84 ++++++++ .../src/components/editor/domEditingLayers.ts | 19 ++ .../components/editor/domEditingRootLayer.ts | 64 ++++++ .../studio/src/hooks/useDomEditCommits.ts | 64 ++++-- .../studio/src/hooks/useDomEditSession.ts | 15 +- 8 files changed, 390 insertions(+), 96 deletions(-) create mode 100644 packages/studio/src/components/editor/domEditingRootLayer.ts diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index 00424601b..44f30ba6c 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -2,7 +2,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Window } from "happy-dom"; import { DomEditOverlay, @@ -19,13 +19,21 @@ import type { DomEditSelection } from "./domEditing"; // React 19 warns unless the test environment opts into act(). globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const gestureSpies = vi.hoisted(() => ({ + startGesture: vi.fn(() => true), + startGroupDrag: vi.fn(), + onPointerMove: vi.fn(), + onPointerUp: vi.fn(), + clearPointerState: vi.fn(), +})); + vi.mock("./useDomEditOverlayGestures", () => ({ createDomEditOverlayGestureHandlers: () => ({ - startGesture: () => true, - startGroupDrag: () => {}, - onPointerMove: () => {}, - onPointerUp: () => {}, - clearPointerState: () => {}, + startGesture: gestureSpies.startGesture, + startGroupDrag: gestureSpies.startGroupDrag, + onPointerMove: gestureSpies.onPointerMove, + onPointerUp: gestureSpies.onPointerUp, + clearPointerState: gestureSpies.clearPointerState, }), })); @@ -34,9 +42,18 @@ vi.mock("./useDomEditOverlayRects", async () => { const { rectsEqual } = await import("./domEditOverlayGeometry"); return { - useDomEditOverlayRects: () => { - const [overlayRect, setOverlayRectState] = React.useState(null); - const overlayRectRef = React.useRef(null); + useDomEditOverlayRects: (options: { selectionRef: { current: unknown } }) => { + const defaultSelectionRect = { + left: 24, + top: 36, + width: 180, + height: 72, + editScaleX: 1, + editScaleY: 1, + }; + const initialOverlayRect = options.selectionRef.current ? defaultSelectionRect : null; + const [overlayRect, setOverlayRectState] = React.useState(initialOverlayRect); + const overlayRectRef = React.useRef(initialOverlayRect); const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]); const groupOverlayItemsRef = React.useRef([]); @@ -85,6 +102,30 @@ vi.mock("./domEditOverlayGeometry", async () => { }; }); +function createOverlayProps(args: { + iframeRef: { current: HTMLIFrameElement | null }; + selection: DomEditSelection | null; + hoverSelection: DomEditSelection | null; + onSelectionChange: (next: DomEditSelection) => void; +}) { + return { + iframeRef: args.iframeRef, + activeCompositionPath: null, + selection: args.selection, + hoverSelection: args.hoverSelection, + groupSelections: [], + onCanvasMouseDown: () => {}, + onCanvasPointerMove: () => Promise.resolve(args.hoverSelection ?? args.selection), + onCanvasPointerLeave: () => {}, + onSelectionChange: args.onSelectionChange, + onBlockedMove: () => {}, + onPathOffsetCommit: () => {}, + onGroupPathOffsetCommit: () => {}, + onBoxSizeCommit: () => {}, + onRotationCommit: () => {}, + }; +} + describe("focusDomEditOverlayElement", () => { it("focuses the canvas overlay without scrolling", () => { const calls: Array = []; @@ -97,7 +138,94 @@ describe("focusDomEditOverlayElement", () => { }); describe("DomEditOverlay", () => { - it("renders selected bounds right after clicking a movable selection", async () => { + beforeEach(() => { + gestureSpies.startGesture.mockClear(); + gestureSpies.startGroupDrag.mockClear(); + gestureSpies.onPointerMove.mockClear(); + gestureSpies.onPointerUp.mockClear(); + gestureSpies.clearPointerState.mockClear(); + }); + + it("does not start a drag from a stale hover target on canvas pointer-down", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const selection: DomEditSelection = { + element: document.createElement("div"), + id: "cta-label", + selector: ".cta-label", + selectorIndex: 0, + sourceFile: "index.html", + tagName: "span", + label: "CTA Label", + textContent: "Add to basket", + textFields: [], + capabilities: { + canEditText: true, + canEditLayout: true, + canMove: true, + canApplyManualOffset: true, + canApplyManualSize: false, + canApplyManualRotation: false, + canAdjustOpacity: true, + canAdjustFill: true, + canAdjustBorderRadius: true, + canAdjustStroke: true, + canAdjustShadow: true, + canAdjustZIndex: true, + }, + computedStyle: { + display: "inline", + position: "static", + }, + }; + + let currentSelection: DomEditSelection | null = null; + const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; + + function Harness() { + const [selected, setSelected] = React.useState(null); + currentSelection = selected; + + return React.createElement( + DomEditOverlay, + createOverlayProps({ + iframeRef, + selection: selected, + hoverSelection: selection, + onSelectionChange: (next: DomEditSelection) => setSelected(next), + }), + ); + } + + act(() => { + root.render(React.createElement(Harness)); + }); + + const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; + expect(overlay).toBeTruthy(); + + act(() => { + overlay.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 120, + clientY: 80, + }), + ); + }); + + expect(gestureSpies.startGesture).not.toHaveBeenCalled(); + expect(currentSelection).toBe(null); + + act(() => { + root.unmount(); + }); + host.remove(); + }); + + it("starts movement from the selected bounds", async () => { // The overlay's compRect updates via a RAF loop reading iframe + overlay // getBoundingClientRect. happy-dom returns all zeros for newly-created // elements with no layout, so without stubs the RAF early-returns @@ -153,32 +281,24 @@ describe("DomEditOverlay", () => { }, }; - let currentSelection: DomEditSelection | null = null; + let currentSelection: DomEditSelection | null = selection; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture; HTMLDivElement.prototype.setPointerCapture = () => {}; function Harness() { - const [selected, setSelected] = React.useState(null); + const [selected, setSelected] = React.useState(selection); currentSelection = selected; - return React.createElement(DomEditOverlay, { - iframeRef, - activeCompositionPath: null, - selection: selected, - // Simulate the element being hovered before pointer-down (real users always hover first) - hoverSelection: selection, - groupSelections: [], - onCanvasMouseDown: () => {}, - onCanvasPointerMove: () => Promise.resolve(selection), - onCanvasPointerLeave: () => {}, - onSelectionChange: (next: DomEditSelection) => setSelected(next), - onBlockedMove: () => {}, - onPathOffsetCommit: () => {}, - onGroupPathOffsetCommit: () => {}, - onBoxSizeCommit: () => {}, - onRotationCommit: () => {}, - }); + return React.createElement( + DomEditOverlay, + createOverlayProps({ + iframeRef, + selection: selected, + hoverSelection: null, + onSelectionChange: (next: DomEditSelection) => setSelected(next), + }), + ); } act(() => { @@ -197,8 +317,13 @@ describe("DomEditOverlay", () => { const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; expect(overlay).toBeTruthy(); + const selectionBox = host.querySelector( + '[data-dom-edit-selection-box="true"]', + ) as HTMLDivElement; + expect(selectionBox).toBeTruthy(); + act(() => { - overlay.dispatchEvent( + selectionBox.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, button: 0, @@ -209,7 +334,10 @@ describe("DomEditOverlay", () => { }); expect(currentSelection).toBe(selection); - expect(host.querySelector('[data-dom-edit-selection-box="true"]')).toBeTruthy(); + expect(gestureSpies.startGesture).toHaveBeenCalledWith( + "drag", + expect.objectContaining({ button: 0 }), + ); act(() => { root.unmount(); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 914d6718a..9794b3109 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, useRef, useState, type RefObject } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { type DomEditSelection } from "./domEditing"; -import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry"; +import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry"; import { type BlockedMoveState, type FocusableDomEditOverlay, @@ -304,28 +304,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const target = event.target as HTMLElement | null; if (target?.closest('[data-dom-edit-selection-box="true"]')) return; - - const candidate = hoverSelectionRef.current; - if (!candidate?.capabilities.canApplyManualOffset) return; - - const overlayEl = overlayRef.current; - const iframe = iframeRef.current; - const candidateRect = - overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null; - if (!candidateRect) return; - - suppressNextOverlayMouseDownRef.current = true; - selectionRef.current = candidate; - setOverlayRect(candidateRect); - const didStartGesture = gestures.startGesture("drag", event, { - selection: candidate, - rect: candidateRect, - }); - if (!didStartGesture) { - suppressNextOverlayMouseDownRef.current = false; - return; - } - onSelectionChangeRef.current(candidate); }; const handleBoxClick = (event: React.MouseEvent) => { diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index f192436b3..80f22e6ee 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -34,10 +34,6 @@ export { setCssFilterFunctionPx, } from "./propertyPanelHelpers"; -/* ------------------------------------------------------------------ */ -/* PropertyPanel */ -/* ------------------------------------------------------------------ */ - // fallow-ignore-next-line complexity export const PropertyPanel = memo(function PropertyPanel({ projectId, @@ -177,10 +173,12 @@ export const PropertyPanel = memo(function PropertyPanel({ return; } const current = readStudioPathOffset(element.element); - onSetManualOffset(element, { - x: axis === "x" ? parsed : current.x, - y: axis === "y" ? parsed : current.y, - }); + void Promise.resolve( + onSetManualOffset(element, { + x: axis === "x" ? parsed : current.x, + y: axis === "y" ? parsed : current.y, + }), + ).catch(() => undefined); }; // fallow-ignore-next-line complexity @@ -204,17 +202,19 @@ export const PropertyPanel = memo(function PropertyPanel({ current.height > 0 ? current.height : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height); - onSetManualSize(element, { - width: axis === "width" ? parsed : width, - height: axis === "height" ? parsed : height, - }); + void Promise.resolve( + onSetManualSize(element, { + width: axis === "width" ? parsed : width, + height: axis === "height" ? parsed : height, + }), + ).catch(() => undefined); }; const manualRotation = readStudioRotation(element.element); const commitManualRotation = (nextValue: string) => { const parsed = Number.parseFloat(nextValue); if (!Number.isFinite(parsed)) return; - onSetManualRotation(element, { angle: parsed }); + void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined); }; const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0; diff --git a/packages/studio/src/components/editor/domEditing.test.ts b/packages/studio/src/components/editor/domEditing.test.ts index cb663c386..50692a4fd 100644 --- a/packages/studio/src/components/editor/domEditing.test.ts +++ b/packages/studio/src/components/editor/domEditing.test.ts @@ -431,6 +431,90 @@ describe("resolveDomEditSelection", () => { }); }); + it("keeps the full-canvas stage layer transform disabled while allowing style edits", async () => { + const document = createDocument(` +
+ +
+ `); + document.documentElement.setAttribute("data-composition-id", "root"); + document.documentElement.setAttribute("data-width", "1920"); + document.documentElement.setAttribute("data-height", "1080"); + setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 }); + const stage = document.getElementById("stage") as HTMLElement; + setElementRect(stage, { left: 0, top: 0, width: 1920, height: 1080 }); + + const selection = await resolveDomEditSelection(stage, { + activeCompositionPath: null, + isMasterView: true, + skipSourceProbe: true, + }); + + expect(selection?.id).toBe("stage"); + expect(selection?.capabilities).toMatchObject({ + canSelect: true, + canEditStyles: true, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + reasonIfDisabled: "The root composition defines the preview bounds.", + }); + }); + + it("keeps direct full-bleed absolute layers editable", async () => { + const document = createDocument(` +
+ `); + document.documentElement.setAttribute("data-composition-id", "root"); + document.documentElement.setAttribute("data-width", "1920"); + document.documentElement.setAttribute("data-height", "1080"); + setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 }); + const hero = document.getElementById("hero") as HTMLElement; + setElementRect(hero, { left: 0, top: 0, width: 1920, height: 1080 }); + + const selection = await resolveDomEditSelection(hero, { + activeCompositionPath: null, + isMasterView: true, + skipSourceProbe: true, + }); + + expect(selection?.id).toBe("hero"); + expect(selection?.capabilities).toMatchObject({ + canSelect: true, + canEditStyles: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }); + }); + + it("lets full-canvas layers opt out of root-layer classification", async () => { + const document = createDocument(` +
+ +
+ `); + document.documentElement.setAttribute("data-composition-id", "root"); + document.documentElement.setAttribute("data-width", "1920"); + document.documentElement.setAttribute("data-height", "1080"); + setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 }); + const editableStage = document.getElementById("editable-stage") as HTMLElement; + setElementRect(editableStage, { left: 0, top: 0, width: 1920, height: 1080 }); + + const selection = await resolveDomEditSelection(editableStage, { + activeCompositionPath: null, + isMasterView: true, + skipSourceProbe: true, + }); + + expect(selection?.id).toBe("editable-stage"); + expect(selection?.capabilities.canApplyManualOffset).toBe(true); + }); + it("resolves child clicks inside a composition host to the child in master view", async () => { const document = createDocument(`
diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 8b3fa73b7..536abba8d 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -31,6 +31,7 @@ import { getDirectLayerChildren, getSelectionCandidate, } from "./domEditingElement"; +import { isCompositionRootLayer } from "./domEditingRootLayer"; // ─── Text fields ──────────────────────────────────────────────────────────── @@ -179,6 +180,7 @@ export function resolveDomEditCapabilities(args: { inlineStyles: Record; computedStyles: Record; isCompositionHost: boolean; + isCompositionRoot?: boolean; isInsideLockedComposition: boolean; isMasterView: boolean; existsInSource?: boolean; @@ -211,6 +213,19 @@ export function resolveDomEditCapabilities(args: { }; } + if (args.isCompositionRoot) { + return { + canSelect: true, + canEditStyles: true, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + reasonIfDisabled: "The root composition defines the preview bounds.", + }; + } + const position = args.computedStyles.position; const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left); const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top); @@ -341,6 +356,9 @@ export async function resolveDomEditSelection( undefined; const inlineStyles = getInlineStyles(current); const computedStyles = getCuratedComputedStyles(current); + const isCompositionRoot = + (current.hasAttribute("data-composition-id") && !compositionSrc) || + isCompositionRootLayer(current, doc, computedStyles); const textFields = collectDomEditTextFields(current); const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"])); let existsInSource: boolean | undefined; @@ -361,6 +379,7 @@ export async function resolveDomEditSelection( inlineStyles, computedStyles, isCompositionHost: Boolean(compositionSrc), + isCompositionRoot, isInsideLockedComposition: isInsideLocked, isMasterView: options.isMasterView, existsInSource, diff --git a/packages/studio/src/components/editor/domEditingRootLayer.ts b/packages/studio/src/components/editor/domEditingRootLayer.ts new file mode 100644 index 000000000..2dff58f96 --- /dev/null +++ b/packages/studio/src/components/editor/domEditingRootLayer.ts @@ -0,0 +1,64 @@ +import { parsePx } from "./domEditingDom"; + +const COMPOSITION_ROOT_LAYER_EPSILON_PX = 1; + +function readPositiveDimension(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function approximatelyEqual(a: number, b: number) { + return Math.abs(a - b) <= COMPOSITION_ROOT_LAYER_EPSILON_PX; +} + +function getCompositionRootBounds(doc: Document) { + const root = + doc.querySelector("[data-composition-id]") ?? doc.documentElement ?? null; + const rootWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null); + const rootHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null); + if (!root || !rootWidth || !rootHeight) return null; + return { rect: root.getBoundingClientRect(), width: rootWidth, height: rootHeight }; +} + +function getRenderedLayerSize(element: HTMLElement, computedStyles: Record) { + const rect = element.getBoundingClientRect(); + const width = rect.width || parsePx(computedStyles.width); + const height = rect.height || parsePx(computedStyles.height); + return width && height ? { width, height } : null; +} + +function matchesCompositionRootBounds( + elementRect: DOMRect, + elementSize: { width: number; height: number }, + rootBounds: { rect: DOMRect; width: number; height: number }, +) { + return ( + approximatelyEqual(elementRect.left, rootBounds.rect.left) && + approximatelyEqual(elementRect.top, rootBounds.rect.top) && + approximatelyEqual(elementSize.width, rootBounds.width) && + approximatelyEqual(elementSize.height, rootBounds.height) + ); +} + +function isExplicitFullBleedLayer(computedStyles: Record) { + return computedStyles.position === "absolute" || computedStyles.position === "fixed"; +} + +export function isCompositionRootLayer( + element: HTMLElement, + doc: Document, + computedStyles: Record, +) { + if (element.parentElement !== doc.body) return false; + if (element.hasAttribute("data-hf-allow-root-edit")) return false; + if (isExplicitFullBleedLayer(computedStyles)) return false; + + const rootBounds = getCompositionRootBounds(doc); + const elementSize = getRenderedLayerSize(element, computedStyles); + return Boolean( + rootBounds && + elementSize && + matchesCompositionRootBounds(element.getBoundingClientRect(), elementSize, rootBounds), + ); +} diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index e70e9f038..a683d08dc 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -36,6 +36,9 @@ import { useDomEditTextCommits } from "./useDomEditTextCommits"; // ── Helpers ── type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; +export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE = + "This element is GSAP-animated — dragging via CSS would corrupt keyframes"; + // fallow-ignore-next-line complexity function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { // When the GSAP drag intercept is disabled for debugging, treat every @@ -291,7 +294,7 @@ export function useDomEditCommits({ patches: PatchOperation[], options: { label: string; coalesceKey: string; skipRefresh?: boolean }, ) => { - void queueDomEditSave(async () => { + return queueDomEditSave(async () => { await persistDomEditOperations(selection, patches, { label: options.label, coalesceKey: options.coalesceKey, @@ -309,6 +312,7 @@ export function useDomEditCommits({ target_selector: selection.selector ?? undefined, target_source_file: selection.sourceFile ?? undefined, }); + throw error; }); }, [persistDomEditOperations, queueDomEditSave, showToast], @@ -318,57 +322,77 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } applyStudioPathOffset(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; - commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { + return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml, previewIframeRef, showToast], ); const handleDomGroupPathOffsetCommit = useCallback( (updates: DomEditGroupPathOffsetCommit[]) => { - if (updates.length === 0) return; + if (updates.length === 0) return Promise.resolve(); + const blockedUpdate = updates.find(({ selection }) => + isElementGsapTargeted(previewIframeRef.current, selection.element), + ); + if (blockedUpdate) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } const coalesceKey = updates .map((u) => getDomEditTargetKey(u.selection)) .sort() .join(":"); - for (const { selection, next } of updates) { + const saves = updates.map(({ selection, next }) => { applyStudioPathOffset(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue; - commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { + return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: `Move ${updates.length} layers`, coalesceKey: `group-path-offset:${coalesceKey}`, }); - } + }); + return Promise.all(saves).then(() => undefined); }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml, previewIframeRef, showToast], ); const handleDomBoxSizeCommit = useCallback( (selection: DomEditSelection, next: { width: number; height: number }) => { + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } applyStudioBoxSize(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; - commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { + return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { label: "Resize layer box", coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml, previewIframeRef, showToast], ); const handleDomRotationCommit = useCallback( (selection: DomEditSelection, next: { angle: number }) => { + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } applyStudioRotation(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; - commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { + return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { label: "Rotate layer", coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml, previewIframeRef, showToast], ); const handleDomManualEditsReset = useCallback( @@ -383,11 +407,11 @@ export function useDomEditCommits({ clearStudioBoxSize(element); clearStudioRotation(element); // skipRefresh:false triggers reloadPreview() which re-syncs selection on load - commitPositionPatchToHtml(selection, clearPatches, { + void commitPositionPatchToHtml(selection, clearPatches, { label: "Reset layer edits", coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`, skipRefresh: false, - }); + }).catch(() => undefined); }, [commitPositionPatchToHtml], ); @@ -490,7 +514,7 @@ export function useDomEditCommits({ } catch { /* cross-origin or detached — skip */ } - commitPositionPatchToHtml( + void commitPositionPatchToHtml( { element: entry.element, id: entry.id ?? null, @@ -505,7 +529,7 @@ export function useDomEditCommits({ coalesceKey, skipRefresh: i < entries.length - 1, }, - ); + ).catch(() => undefined); } }, [commitPositionPatchToHtml], diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 6636efcaf..e2a0c9edd 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -15,7 +15,7 @@ import type { SidebarTab } from "../components/sidebar/LeftSidebar"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; -import { useDomEditCommits } from "./useDomEditCommits"; +import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, useDomEditCommits } from "./useDomEditCommits"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapAnimationsForElement, @@ -330,11 +330,8 @@ export function useDomEditSession({ async (selection: DomEditSelection, next: { x: number; y: number }) => { const hasGsapAnims = selectedGsapAnimations.length > 0; if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { - showToast( - "This element is GSAP-animated — dragging via CSS would corrupt keyframes", - "error", - ); - return; + showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error"); + throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); } if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { const handled = await tryGsapDragIntercept( @@ -354,7 +351,7 @@ export function useDomEditSession({ ); if (handled) return; } - handleDomPathOffsetCommit(selection, next); + return handleDomPathOffsetCommit(selection, next); }, [ handleDomPathOffsetCommit, @@ -394,7 +391,7 @@ export function useDomEditSession({ ); if (handled) return; } - handleDomBoxSizeCommit(selection, next); + return handleDomBoxSizeCommit(selection, next); }, [ handleDomBoxSizeCommit, @@ -418,7 +415,7 @@ export function useDomEditSession({ ); if (handled) return; } - handleDomRotationCommit(selection, next); + return handleDomRotationCommit(selection, next); }, [ handleDomRotationCommit,