diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 151c24ce08..261b82f24b 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -519,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ top: cr.top, width: cr.width, height: cr.height, + transform: cr.angle ? `rotate(${cr.angle}deg)` : undefined, }} /> ))} diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts index 3ecfc83227..0554bb4f4a 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts @@ -5,6 +5,7 @@ import { orientedGroupAwareOverlayRect, overlayCornersCentroid, selectionCacheKey, + orientedVisibleOverlayRect, } from "./domEditOverlayGeometry"; describe("overlayCornersCentroid", () => { @@ -159,6 +160,30 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () => expect(rect!.angle ?? 0).toBe(0); }); + /** + * A child outline is drawn ON the child, not around it. Measured axis-aligned, + * a text layer inside a rotated card got an upright dashed box sitting across + * the rotated glyphs — the parent's chrome rotated and its children's did not. + */ + it("child outlines carry the element's angle, so they can co-rotate with it", () => { + const { overlayEl, iframe, el } = buildHarness(); + el.style.transform = ROTATE_30DEG_MATRIX; + const rect = orientedVisibleOverlayRect(overlayEl, iframe, el); + expect(rect).not.toBeNull(); + expect(rect!.angle).toBeCloseTo(30, 3); + }); + + it("an unrotated child outline is unchanged — no angle, same box as before", () => { + const { overlayEl, iframe, el } = buildHarness(); + const rect = orientedVisibleOverlayRect(overlayEl, iframe, el); + expect(rect).not.toBeNull(); + expect(rect!.angle ?? 0).toBe(0); + expect(rect!.left).toBeCloseTo(400, 5); + expect(rect!.top).toBeCloseTo(450, 5); + expect(rect!.width).toBeCloseTo(200, 5); + expect(rect!.height).toBeCloseTo(100, 5); + }); + it("rotated element takes the corner-geometry path — reports the live angle", () => { const { overlayEl, iframe, el } = buildHarness(); el.style.transform = ROTATE_30DEG_MATRIX; diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index 8ad23ddcb0..004bef4369 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -430,6 +430,24 @@ export function orientedOverlayRect( }; } +/** + * `toVisibleOverlayRect`'s oriented twin: the element's crop-hugged box plus its + * live rotation, for chrome that has to sit on a rotated element rather than + * around it. Rendering the result with `transform: rotate(angle)` about its + * centre lands it on the element's real corners. + * + * At angle 0 `orientedOverlayRect` returns the plain AABB, so an unrotated + * element measures exactly as it did before. + */ +export function orientedVisibleOverlayRect( + overlayEl: HTMLDivElement, + iframe: HTMLIFrameElement, + element: HTMLElement, +): OverlayRect | null { + const rect = orientedOverlayRect(overlayEl, iframe, element); + return rect ? { ...rect, ...hugRectForElement(rect, element) } : null; +} + const OVERLAY_RECT_EPSILON_PX = 0.5; const OVERLAY_RECT_ANGLE_EPSILON_DEG = 0.1; diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 80697e4e3d..4a041e020f 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -555,26 +555,26 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { - const gsapSkip = gsapAnimatesProperty(el, "x", "y"); + // Unlike size below, the offset channels COMPOSE — applying both doubles the move. + if (gsapAnimatesProperty(el, "x", "y")) continue; const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); - if (gsapSkip) continue; - if (x || y) { - applyStudioPathOffset( - el, - { - x: Number.parseFloat(x) || 0, - y: Number.parseFloat(y) || 0, - }, - { updateBase: false }, - ); - } + if (!x && !y) continue; + const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 }; + applyStudioPathOffset(el, offset, { updateBase: false }); } } +/** + * Put the studio's committed size back after a seek, GSAP-sized elements included. + * Size does not compose the way the offset above does: both channels write width + * and height, so the later write wins on the same number. Standing aside meant + * nothing held the size while a soft reload reverted the old timeline (GSAP hands + * back each tween's recorded starting width), so the element sat at its stylesheet + * size until the new one rendered — the jump after a resize. + */ function reapplyBoxSizes(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) { - if (gsapAnimatesProperty(el, "width", "height")) continue; const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP)); const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP)); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { diff --git a/packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts b/packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts new file mode 100644 index 0000000000..92a9087968 --- /dev/null +++ b/packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { reapplyPositionEditsAfterSeek } from "./manualEditsDom"; +import { STUDIO_BOX_SIZE_ATTR, STUDIO_HEIGHT_PROP, STUDIO_WIDTH_PROP } from "./manualEditsTypes"; + +/** + * A resize commit hands the size to a GSAP tween, and a soft reload reverts the + * old timeline before the new one renders — GSAP restores each tween's recorded + * starting width on the way out. Nothing else held the size across that window, + * so the element sat at its stylesheet size for a few hundred milliseconds: the + * jump after a resize. Worse, the next gesture then started from a box that + * disagreed with the studio's own vars and snapped on its first move. + * + * The seek reapply is what closes the window, and it used to stand aside for + * exactly the elements that need it — the ones GSAP sizes. + */ +describe("box size survives a seek while GSAP owns the size", () => { + afterEach(() => { + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "__timelines"); + }); + + function cardSizedByGsap(): HTMLElement { + const el = document.createElement("div"); + el.id = "card"; + el.setAttribute(STUDIO_BOX_SIZE_ATTR, "true"); + el.style.setProperty(STUDIO_WIDTH_PROP, "305px"); + el.style.setProperty(STUDIO_HEIGHT_PROP, "202px"); + document.body.append(el); + // A timeline that animates this element's width/height, as the committed + // resize leaves behind. + Object.assign(window, { + __timelines: { + main: { + getChildren: () => [{ targets: () => [el], vars: { width: 305, height: 202 } }], + }, + }, + }); + return el; + } + + it("re-applies the committed size after the timeline gave it back", () => { + const el = cardSizedByGsap(); + // The revert: GSAP puts the tween's recorded starting size back. + el.style.width = "395px"; + el.style.height = "261px"; + + reapplyPositionEditsAfterSeek(document); + + expect(el.style.width).toBe("305px"); + expect(el.style.height).toBe("202px"); + }); + + it("leaves an element alone once its studio size is cleared", () => { + const el = cardSizedByGsap(); + el.style.removeProperty(STUDIO_WIDTH_PROP); + el.style.removeProperty(STUDIO_HEIGHT_PROP); + el.style.width = "395px"; + + reapplyPositionEditsAfterSeek(document); + + expect(el.style.width).toBe("395px"); + }); +}); diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index b45ff6f26c..ba402bafb6 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -17,7 +17,7 @@ import { rectsEqual, resolveElementForOverlay, selectionCacheKey, - toVisibleOverlayRect, + orientedVisibleOverlayRect, } from "./domEditOverlayGeometry"; function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean { @@ -172,7 +172,9 @@ export function useDomEditOverlayRects({ for (let i = 0; i < descendants.length; i++) { const child = descendants[i] as HTMLElement; if (!child.getBoundingClientRect) continue; - const r = toVisibleOverlayRect(overlayEl, iframe, child); + // Oriented, not axis-aligned: a child of a rotated element drew its + // outline square around the rotated glyphs instead of on them. + const r = orientedVisibleOverlayRect(overlayEl, iframe, child); if (r && r.width > 2 && r.height > 2) nextChildRects.push(r); } if (!childRectsEqual(childRectsRef.current, nextChildRects)) {