diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 4715783fa5..bed082c865 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -76,7 +76,7 @@ export interface DomEditOverlayProps { selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }, ) => void; - onBlockedMove: (selection: DomEditSelection) => void; + onBlockedMove: (selection: DomEditSelection, reason?: string) => void; onManualDragStart?: () => void; onPathOffsetCommit: ( selection: DomEditSelection, @@ -296,6 +296,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ gestureRef, groupGestureRef, blockedMoveRef, + onBlockedMoveRef, onManualDragStartRef, onPathOffsetCommitRef, onGroupPathOffsetCommitRef, diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index b7c1d05094..dc4d8919c2 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -178,8 +178,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro transformPerspective: 0, }; // Unconditional like the hooks above: must not sit behind the `!element` return below. + const manualOffset = element ? readMoveOffset(element.element) : { x: 0, y: 0 }; const { manualOffsetEditingDisabled, manualSizeEditingDisabled, manualRotationEditingDisabled } = - useManualEditDisabledFlags(element?.capabilities); + useManualEditDisabledFlags(element?.capabilities, manualOffset); if (!element) { return ( @@ -200,7 +201,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro // selection) so the Timing section shows for pure-GSAP elements with no data-start. const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length)); const showEditableSections = element.capabilities.canEditStyles && sections.style; - const manualOffset = readMoveOffset(element.element); const manualSize = readStudioBoxSize(element.element); const resolvedWidth = manualSize.width > 0 diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 471c9a55fc..de6d453a39 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -253,7 +253,7 @@ export type UseDomEditOverlayGesturesOptions = { suppressNextBoxClickRef: RefObject; setOverlayRect: (next: OverlayRect | null) => void; setGroupOverlayItems: (next: GroupOverlayItem[]) => void; - onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>; + onBlockedMoveRef: RefObject<(selection: DomEditSelection, reason?: string) => void>; onManualDragStartRef: RefObject<(() => void) | undefined>; onPathOffsetCommitRef: RefObject< ( diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index 8bb387c817..8845cdef92 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -76,7 +76,7 @@ export function startGroupDrag( restoreManualOffsetDragMembers(members); e.preventDefault(); e.stopPropagation(); - opts.onBlockedMoveRef.current(result.selection); + opts.onBlockedMoveRef.current(result.selection, result.reason); return false; } members.push(result.member); @@ -198,7 +198,7 @@ export function startGesture( gesture: "drag", }); if (!result.ok) { - opts.onBlockedMoveRef.current(result.selection); + opts.onBlockedMoveRef.current(result.selection, result.reason); return false; } pathOffsetMember = result.member; diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts index 75b61b57d7..44e92788b0 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts @@ -13,6 +13,7 @@ import { resolveManualOffsetForPointerDelta, type ManualOffsetDragMatrix, } from "./manualOffsetDrag"; +import { UNREADABLE_TRANSLATE } from "./plainTranslate"; import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits"; import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition"; @@ -596,6 +597,21 @@ describe("a move of an element GSAP does not position", () => { return { window, element, gsapCalls, member }; } + it("refuses a translate it can't read instead of guessing, and leaves the element alone", () => { + const { element } = plainBox(); + element.style.setProperty("translate", "abs(10% - 50px) 0px"); + const result = createManualOffsetDragMember({ + key: "box", + selection: { element } as never, + element, + rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 }, + gesture: "drag", + }); + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toBe(UNREADABLE_TRANSLATE); + expect(element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px"); + }); + it("drafts and drops the element's own translate in plain px, never touching GSAP", () => { const { element, gsapCalls, member } = plainBox({ gsapLoaded: true }); const m = member(); diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index e9398c7056..f4b528ecdf 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -12,7 +12,7 @@ import { } from "./manualEdits"; import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition"; import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes"; -import { readTranslatePx, writeTranslatePx } from "./plainTranslate"; +import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate"; interface OffsetDragGsap { set: (el: Element, vars: Record) => void; @@ -345,6 +345,8 @@ export function createManualOffsetDragMember(input: { const initialOffset = plainTranslate ? readTranslatePx(input.element) : readAppliedStudioPathOffset(input.element); + if (!finitePoint(initialOffset)) + return { ok: false, reason: UNREADABLE_TRANSLATE, selection: input.selection }; const win = input.element.ownerDocument.defaultView as | (Window & { gsap?: { getProperty?: (el: Element, prop: string) => number }; @@ -382,10 +384,8 @@ export function createManualOffsetDragMember(input: { plainTranslate, }); if (!measured.ok) { - // Fallback: when GSAP transforms interfere with probe measurement, use - // the preview scale as an approximation. The commit path reads the actual - // GSAP position from the iframe runtime, so visual imprecision during - // drag is acceptable — the final committed position is always exact. + // GSAP transforms can defeat the probe: approximate with the preview scale. The commit + // reads GSAP's real position, so only the drag preview is imprecise. const scaleX = input.rect.editScaleX || 1; const scaleY = input.rect.editScaleY || 1; const w = readTransformWDivisor(input.element); diff --git a/packages/studio/src/components/editor/plainTranslate.test.ts b/packages/studio/src/components/editor/plainTranslate.test.ts index 5d45e75b15..54d59a9159 100644 --- a/packages/studio/src/components/editor/plainTranslate.test.ts +++ b/packages/studio/src/components/editor/plainTranslate.test.ts @@ -28,18 +28,31 @@ describe("readTranslatePx", () => { }); it.each([ - "min(10px, 5%) 0px", - "calc(2 * 10px) 0px", - "calc(10px +) 0px", - "clamp(0px, 10%, 30px) 5px", - ])("hands %s to Chrome's own resolution and puts the element's style back", (translate) => { - const element = box(`translate: ${translate}; transform: rotate(5deg)`); - const before = element.getAttribute("style"); - const read = readTranslatePx(element); - expect(Number.isFinite(read.x) && Number.isFinite(read.y)).toBe(true); - expect(element.getAttribute("style")).toBe(before); + ["min(10px, 5%) max(-20%, -40px)", { x: 10, y: -32 }], + ["clamp(0px, 10%, 30px) 5px", { x: 24, y: 5 }], + ["calc(min(50%, 100px) * 2 - (10px + 5%)) calc(-1 * clamp(10%, 1px, 20%))", { x: 178, y: -16 }], + ["calc(2 * 10px) calc(80px / 4)", { x: 20, y: 20 }], + ])("works out %s by arithmetic", (translate, want) => { + expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want); }); + it.each(["calc(10px +) 0px", "abs(10% - 50px) 0px", "round(10%, 7px) 0px", "min(10px 5%) 0px"])( + "reads %s as NaN, never as a guess", + (translate) => { + expect(readTranslatePx(box(`translate: ${translate}`)).x).toBeNaN(); + }, + ); + + it.each(["content-box", "fill-box"])( + "resolves a percent against the content box under transform-box: %s", + (transformBox) => { + const element = box( + `translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box; transform-box: ${transformBox}`, + ); + expect(readTranslatePx(element)).toEqual({ x: 120, y: 80 }); + }, + ); + it("counts padding and border in the box a percent resolves against", () => { const element = box( "translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box", @@ -47,16 +60,19 @@ describe("readTranslatePx", () => { expect(readTranslatePx(element)).toEqual({ x: 135, y: 95 }); }); - it("writes nothing to the element it reads", () => { - const element = box("translate: -50% -50%"); - const before = element.getAttribute("style"); - const writes: MutationRecord[] = []; - const observer = new MutationObserver((records) => writes.push(...records)); - observer.observe(element, { attributes: true }); - readTranslatePx(element); - writes.push(...observer.takeRecords()); - observer.disconnect(); - expect(writes).toEqual([]); - expect(element.getAttribute("style")).toBe(before); - }); + it.each(["-50% -50%", "min(10px, 5%) clamp(0px, 10%, 30px)"])( + "writes nothing to the element while it reads %s", + (translate) => { + const element = box(`translate: ${translate}; transform: rotate(5deg)`); + const before = element.getAttribute("style"); + const writes: MutationRecord[] = []; + const observer = new MutationObserver((records) => writes.push(...records)); + observer.observe(element, { attributes: true }); + readTranslatePx(element); + writes.push(...observer.takeRecords()); + observer.disconnect(); + expect(writes).toEqual([]); + expect(element.getAttribute("style")).toBe(before); + }, + ); }); diff --git a/packages/studio/src/components/editor/plainTranslate.ts b/packages/studio/src/components/editor/plainTranslate.ts index d454a1d2cc..f0b275a177 100644 --- a/packages/studio/src/components/editor/plainTranslate.ts +++ b/packages/studio/src/components/editor/plainTranslate.ts @@ -6,29 +6,65 @@ import type { PatchOperation } from "../../utils/sourcePatcher"; type Point = { x: number; y: number }; -const TERM = /^(-?\d*\.?\d+(?:e[-+]?\d+)?)(px|%)$/i; +const TOKEN = /\s*(?:(\d*\.?\d+(?:e[-+]?\d+)?)(px|%)?|([a-z]+)\(|([-+*/(),]))/giy; +const FUNCTIONS: Record number> = { + "(": ([a = Number.NaN]) => a, + "calc(": ([a = Number.NaN]) => a, + "min(": (args) => Math.min(...args), + "max(": (args) => Math.max(...args), + "clamp(": ([lo = Number.NaN, v = Number.NaN, hi = Number.NaN]) => Math.max(lo, Math.min(v, hi)), +}; -const SIGN: Record = { "+": 1, "-": -1 }; - -function termPx(term: string | undefined, side: number): number | null { - const match = TERM.exec(term ?? ""); - return match ? Number(match[1]) * (match[2] === "%" ? side / 100 : 1) : null; +/** Lengths become px against `side`; functions keep their "(" so they can't pass for an operator. */ +function tokenize(value: string, side: number): (number | string)[] { + const tokens: (number | string)[] = []; + const end = value.trimEnd().length; + TOKEN.lastIndex = 0; + for (let m = TOKEN.exec(value); m; m = TOKEN.lastIndex < end ? TOKEN.exec(value) : null) { + if (m[1]) tokens.push(Number(m[1]) * (m[2] === "%" ? side / 100 : 1)); + else tokens.push(m[3] ? `${m[3].toLowerCase()}(` : (m[4] ?? "")); + } + return TOKEN.lastIndex < end ? [] : tokens; } -/** px, %, or Chrome's computed `calc(P% + Lpx)`, against the border-box side; null for min()/max()/clamp(). */ -function resolveLength(value: string, side: number): number | null { - const parts = (/^calc\((.*)\)$/.exec(value)?.[1] ?? value).split(" "); - if (parts.length % 2 === 0) return null; - let total = termPx(parts[0], side); - for (let i = 1; i < parts.length && total !== null; i += 2) { - const term = termPx(parts[i + 1], side); - const sign = SIGN[parts[i] ?? ""]; - total = term === null || !sign ? null : total + sign * term; +/** Chrome's computed length (px, %, calc/min/max/clamp, + - * /) in px against `side`; NaN otherwise. */ +function evaluateLength(value: string, side: number): number { + const tokens = tokenize(value, side); + let i = 0; + const next = () => tokens[i++]; + const take = (...ops: string[]) => ops.includes(tokens[i] as string) && next(); + function sum(): number { + let total = product(); + for (let op = take("+", "-"); op; op = take("+", "-")) + total += (op === "+" ? 1 : -1) * product(); + return total; + } + function product(): number { + let total = unary(); + for (let op = take("*", "/"); op; op = take("*", "/")) + total = op === "*" ? total * unary() : total / unary(); + return total; + } + function unary(): number { + return take("-") ? -unary() : atom(); + } + function atom(): number { + const token = next(); + if (typeof token === "number") return token; + const fn = FUNCTIONS[token ?? ""]; + if (!fn) return Number.NaN; + const args = [sum()]; + while (take(",")) args.push(sum()); + return take(")") ? fn(args) : Number.NaN; } - return total; + const total = tokens.length ? sum() : Number.NaN; + return i === tokens.length ? total : Number.NaN; } -function borderBox( +const CONTENT_BOXES = new Set(["content-box", "fill-box"]); + +/** The side % resolves against: the box `transform-box` names (fill-box is the content box on HTML). */ +function referenceBox( cs: CSSStyleDeclaration, size: "width" | "height", a: string, @@ -37,35 +73,29 @@ function borderBox( const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0; const pad = n(`padding-${a}`) + n(`padding-${b}`) + n(`border-${a}-width`) + n(`border-${b}-width`); - return n(size) + (cs.boxSizing === "border-box" ? 0 : pad); -} - -// Chrome resolves min()/max()/clamp() inside a transform function to a matrix; an unrendered box has none. -function resolveInTransform(el: HTMLElement, x: string, y: string): Point { - const style = el.style; - const saved = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")]; - style.setProperty("transform", `translate(${x}, ${y})`, "important"); - try { - const resolved = el.ownerDocument.defaultView?.getComputedStyle(el).transform ?? ""; - const matrix = new DOMMatrixReadOnly(resolved.startsWith("matrix") ? resolved : undefined); - return { x: matrix.m41, y: matrix.m42 }; - } finally { - style.setProperty("transform", saved[0] ?? "", saved[1] ?? ""); - } + const content = n(size) - (cs.boxSizing === "border-box" ? pad : 0); + return CONTENT_BOXES.has(cs.getPropertyValue("transform-box")) ? content : content + pad; } /** The element's CSS `translate` in px, as it renders now. */ // ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it. export function readTranslatePx(el: HTMLElement): Point { const cs = el.ownerDocument.defaultView?.getComputedStyle(el); - const value = el.style.getPropertyValue("translate") || cs?.translate || "none"; + // Computed first: Chrome resolves em, vw and var() there and leaves only % to work out. + const value = cs?.translate || el.style.getPropertyValue("translate") || "none"; if (value === "none") return { x: 0, y: 0 }; const [x = "0px", y = "0px"] = splitTopLevelWhitespace(value); - const px = cs && resolveLength(x, borderBox(cs, "width", "left", "right")); - const py = cs && resolveLength(y, borderBox(cs, "height", "top", "bottom")); - return px != null && py != null ? { x: px, y: py } : resolveInTransform(el, x, y); + const side = (size: "width" | "height", a: string, b: string) => + cs ? referenceBox(cs, size, a, b) : 0; + return { + x: evaluateLength(x, side("width", "left", "right")), + y: evaluateLength(y, side("height", "top", "bottom")), + }; } +export const UNREADABLE_TRANSLATE = + "Studio can't read this layer's translate. Move it in the Code tab."; + /** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */ function formatTranslatePx(p: Point): string { return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`; diff --git a/packages/studio/src/components/editor/previewReadOnly.overlay.test.tsx b/packages/studio/src/components/editor/previewReadOnly.overlay.test.tsx index 5c7a8c282d..bfb03479cd 100644 --- a/packages/studio/src/components/editor/previewReadOnly.overlay.test.tsx +++ b/packages/studio/src/components/editor/previewReadOnly.overlay.test.tsx @@ -9,6 +9,7 @@ import { __resetForTests } from "../../utils/canvasNudgeGate"; import { PreviewReadOnlyProvider } from "./previewReadOnlyContext"; import "./domEditOverlayTestMocks"; import { DomEditOverlay } from "./DomEditOverlay"; +import { UNREADABLE_TRANSLATE } from "./plainTranslate"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -475,3 +476,32 @@ describe("DomEditOverlay onTextEditingChange", () => { expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]); }); }); + +describe("DomEditOverlay on a layer whose translate Studio can't read", () => { + it("refuses a drag and an arrow nudge out loud, and commits nothing", () => { + const { spies, overlay, selection } = fixture(); + selection.element.style.setProperty("translate", "abs(10% - 50px) 0px"); + fire(overlay.querySelector(BOX)!, "pointerdown"); + const presses = [false, true, true].map( + (repeat) => + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + repeat, + }), + ); + act(() => { + for (const press of presses) window.dispatchEvent(press); + vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10); + }); + // One toast for the drag and one for the whole held arrow, whose every press is swallowed. + expect(spies.onBlockedMove.mock.calls).toEqual([ + [selection, UNREADABLE_TRANSLATE], + [selection, UNREADABLE_TRANSLATE], + ]); + expect(presses.map((press) => press.defaultPrevented)).toEqual([true, true, true]); + expect(spies.onPathOffsetCommit).not.toHaveBeenCalled(); + expect(selection.element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px"); + }); +}); diff --git a/packages/studio/src/components/editor/previewReadOnlyContext.tsx b/packages/studio/src/components/editor/previewReadOnlyContext.tsx index 037d6d8e6b..e6827aca93 100644 --- a/packages/studio/src/components/editor/previewReadOnlyContext.tsx +++ b/packages/studio/src/components/editor/previewReadOnlyContext.tsx @@ -49,10 +49,12 @@ interface ManualEditCapabilities { */ export function useManualEditDisabledFlags( capabilities: ManualEditCapabilities | null | undefined, + offset = { x: 0, y: 0 }, ) { const { readOnly } = useContext(PreviewReadOnlyContext); + const unreadable = !Number.isFinite(offset.x) || !Number.isFinite(offset.y); return { - manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly, + manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly || unreadable, manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly, manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly, }; diff --git a/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts b/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts index e07c1eab6b..94057491c7 100644 --- a/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts +++ b/packages/studio/src/components/editor/propertyPanelTransformCommit.test.ts @@ -1,9 +1,13 @@ // @vitest-environment happy-dom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; import { describe, expect, it, vi } from "vitest"; +import { useManualEditDisabledFlags } from "./previewReadOnlyContext"; import type { DomEditSelection } from "./domEditingTypes"; import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome"; import { createTransformCommitHandlers } from "./propertyPanelTransformCommit"; +import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate"; describe("createTransformCommitHandlers", () => { it.each([ @@ -55,3 +59,50 @@ describe("createTransformCommitHandlers", () => { expect(onSetManualRotation).not.toHaveBeenCalled(); }); }); + +describe("the panel's position fields on a translate Studio can't read", () => { + it("are disabled", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const box = document.createElement("div"); + box.style.setProperty("translate", "abs(10% - 50px) 30px"); + const seen: boolean[] = []; + function Probe({ element }: { element: HTMLElement }) { + seen.push( + useManualEditDisabledFlags({ canApplyManualOffset: true } as never, readMoveOffset(element)) + .manualOffsetEditingDisabled, + ); + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Probe, { element: box }))); + box.style.setProperty("translate", "40px 30px"); + act(() => root.render(React.createElement(Probe, { element: box }))); + act(() => root.unmount()); + expect(seen).toEqual([true, false]); + }); + + it("commit nothing and say why", async () => { + const onSetManualOffset = vi.fn(); + const showToast = vi.fn(); + const box = document.createElement("div"); + box.style.setProperty("translate", "abs(10% - 50px) 30px"); + const element = { id: "box", selector: "#box", element: box } as unknown as DomEditSelection; + const handlers = createTransformCommitHandlers({ + element, + styles: {}, + hasGsapAnimation: false, + gsapAnimId: null, + gsapKeyframes: null, + currentPct: 0, + onCommitAnimatedProperty: undefined, + onAddKeyframe: undefined, + onSetManualOffset, + onSetManualSize: vi.fn(), + onSetManualRotation: vi.fn(), + showToast, + }); + await handlers.commitManualOffset("y", "20px"); + expect(onSetManualOffset).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith(UNREADABLE_TRANSLATE); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelTransformCommit.ts b/packages/studio/src/components/editor/propertyPanelTransformCommit.ts index c11f3e60cb..0afa234e2e 100644 --- a/packages/studio/src/components/editor/propertyPanelTransformCommit.ts +++ b/packages/studio/src/components/editor/propertyPanelTransformCommit.ts @@ -1,6 +1,6 @@ import type { DomEditSelection } from "./domEditingTypes"; import { readStudioBoxSize } from "./manualEdits"; -import { readMoveOffset } from "./plainTranslate"; +import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate"; import { parsePxMetricValue, type PropertyPanelProps } from "./propertyPanelHelpers"; interface TransformCommitDeps { @@ -75,6 +75,8 @@ export function createTransformCommitHandlers({ ) return; const current = readMoveOffset(element.element); + if (!Number.isFinite(current.x) || !Number.isFinite(current.y)) + return void showToast?.(UNREADABLE_TRANSLATE); await Promise.resolve( onSetManualOffset(element, { x: axis === "x" ? parsed : current.x, diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index 9d7265f7a3..d568f9ae06 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -46,6 +46,7 @@ function Harness({ groupGestureRef: makeRef(null), blockedMoveRef: makeRef(null), onManualDragStartRef: makeRef(() => {}), + onBlockedMoveRef: makeRef(() => {}), onPathOffsetCommitRef: makeRef(onPathOffsetCommit), onGroupPathOffsetCommitRef: makeRef(async () => {}), }); diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index 85d9f53a6d..4385cd8124 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -55,6 +55,7 @@ export interface UseDomEditNudgeParams { groupGestureRef: RefObject; blockedMoveRef: RefObject; onManualDragStartRef: RefObject<(() => void) | undefined>; + onBlockedMoveRef: RefObject<(selection: DomEditSelection, reason?: string) => void>; onPathOffsetCommitRef: RefObject< ( s: DomEditSelection, @@ -179,7 +180,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () // Build drag members for the current target set — the same member snapshot a // pointer drag starts from (startGesture / startGroupDrag), so the nudge // commit converts offsets → GSAP x/y with identical math. - const beginSession = (): NudgeSession | null => { + const beginSession = (event: KeyboardEvent): NudgeSession | "refused" | null => { const p = paramsRef.current; const groupItems = p.groupOverlayItemsRef.current; const isGroup = groupItems.length > 1; @@ -192,7 +193,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () const result = createManualOffsetDragMember({ ...target, gesture: "nudge" }); if (!result.ok) { restoreManualOffsetDragMembers(members); - return null; + if (!event.repeat) p.onBlockedMoveRef.current(result.selection, result.reason); + return "refused"; } members.push(result.member); } @@ -207,7 +209,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () if (shouldIgnoreNudgeKey(p, event)) return; const delta = resolveCanvasNudgeDelta(event); if (!delta) return; - const session = sessionRef.current ?? beginSession(); + const session = sessionRef.current ?? beginSession(event); + if (session === "refused") return void event.preventDefault(); if (!session) return; sessionRef.current = session; event.preventDefault(); diff --git a/packages/studio/src/hooks/usePreviewInteraction.test.ts b/packages/studio/src/hooks/usePreviewInteraction.test.ts index 84c668fa4c..7a7013dfb1 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.test.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.test.ts @@ -267,3 +267,35 @@ describe("usePreviewInteraction", () => { cleanup(); }); }); + +describe("a refused move's toast", () => { + it("gives the refusal's own reason first, else the selection's", () => { + const showToast = vi.fn(); + let blocked!: (selection: DomEditSelection, reason?: string) => void; + function Harness() { + blocked = usePreviewInteraction({ + captionEditMode: false, + compositionLoading: false, + previewIframeRef: { current: null }, + showToast, + applyDomSelection: vi.fn(), + resolveDomSelectionFromPreviewPoint: vi.fn(async () => null), + resolveAllDomSelectionsFromPreviewPoint: vi.fn(async () => []), + updateDomEditHoverSelection: vi.fn(), + setActiveGroupElement: vi.fn(), + }).handleBlockedDomMove; + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Harness))); + const selection = makeSelection("Box", document.createElement("div")); + selection.capabilities.reasonIfDisabled = "Locked layer."; + blocked(selection, "Studio can't read it."); + blocked(selection); + act(() => root.unmount()); + expect(showToast.mock.calls).toEqual([ + ["Studio can't read it.", "info"], + ["Locked layer.", "info"], + ]); + }); +}); diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index 35b8e2dbc0..563cc816d8 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -260,9 +260,10 @@ export function usePreviewInteraction({ }, [updateDomEditHoverSelection]); const handleBlockedDomMove = useCallback( - (selection: DomEditSelection) => { + (selection: DomEditSelection, reason?: string) => { showToast( - selection.capabilities.reasonIfDisabled ?? + reason ?? + selection.capabilities.reasonIfDisabled ?? "This element can't be adjusted directly from the preview.", "info", );