From 5a022b4e1a70479b943647ad04c95b5eeaef597c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 7 Aug 2026 21:43:23 -0700 Subject: [PATCH 1/5] chore(studio): name whoever puts the pre-resize size back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resizing the card commits correctly — the source and a fresh load both read 273x181 — but 200ms after the drop, mid-commit, the element renders at 395x261 with the studio size vars still holding 273x181. Something writes the pre-gesture size back inline while the reload is still in flight, and every writer of that size was silent. Both are traced now under the existing hf-resize-debug flag, each with the size going in, the size being replaced, and a short stack. Restoring the pre-gesture size is right on a cancel and wrong after a successful commit, and the function doing it cannot tell the two apart from the inside — so the caller has to be named before this can be fixed at the right end. --- packages/studio/src/components/editor/manualEditsDom.ts | 6 ++++++ .../studio/src/components/editor/manualEditsSnapshot.ts | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 80697e4e3d..9235f88116 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -36,6 +36,7 @@ import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; +import { logResize } from "../../utils/resizeDebug"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -454,6 +455,11 @@ export function applyStudioBoxSize( element: HTMLElement, size: { width: number; height: number }, ): void { + logResize("apply-box-size", { + to: `${Math.round(size.width)} x ${Math.round(size.height)}`, + from: `${element.style.width || "-"} x ${element.style.height || "-"}`, + stack: new Error("apply-box-size").stack?.split("\n").slice(1, 6).join(" < "), + }); promoteInlineForTransform(element); applyStudioBoxSizeDimensions(element, size); } diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 826fb16829..9ab9269eb8 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -40,6 +40,7 @@ import type { StudioRotationSnapshot, StudioPathOffsetSnapshot, } from "./manualEditsTypes"; +import { logResize } from "../../utils/resizeDebug"; /* ── Capture ──────────────────────────────────────────────────────── */ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapshot { @@ -114,6 +115,14 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str } export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void { + // Putting the pre-gesture size back is correct on a cancel and wrong after a + // successful commit, and the two are indistinguishable from in here — so say + // who asked, with the size being restored and the one being replaced. + logResize("restore-box-size", { + to: `${previous.width || "-"} x ${previous.height || "-"}`, + from: `${element.style.width || "-"} x ${element.style.height || "-"}`, + stack: new Error("restore-box-size").stack?.split("\n").slice(1, 6).join(" < "), + }); restoreStyleProperty(element, "width", previous.width); restoreStyleProperty(element, "height", previous.height); restoreStyleProperty(element, "min-width", previous.minWidth); From 7457306855d3f973812b3875151e0f8defa8175c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 8 Aug 2026 10:36:45 -0700 Subject: [PATCH 2/5] fix(studio): hold a resized element's size while the timeline is rebuilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your log caught it across two resizes. The first commits 305x202 and the element is 305x202 at the drop; 200ms later it renders 395x261, its stylesheet size, while --hf-studio-width still reads 305. The second gesture then starts with `actual` at 305 against a live box of 395, and its very first move — a pointer delta of 0.1px — snaps the element back to 305. That snap is the jump. The gap belongs to the soft reload: it reverts the old timeline before building the new one, and GSAP hands back each tween's recorded starting width on the way out. Nothing held the size in between, because the seek reapply that exists for exactly this stands aside for elements GSAP animates. Standing aside is right for the offset — those channels compose, and applying both doubles the move — and wrong for size, where both channels write width and height so the later write simply wins on the same committed number. It applies now. Only an element mid-edit carries the vars, so nothing else is touched. A test seeks an element whose size GSAP owns after the revert put the stylesheet size back, and fails with the skip restored. --- .../src/components/editor/manualEditsDom.ts | 29 +++++---- .../editor/reapplyBoxSizeAfterSeek.test.ts | 64 +++++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 packages/studio/src/components/editor/reapplyBoxSizeAfterSeek.test.ts diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 9235f88116..50b4c2272e 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -561,26 +561,29 @@ 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 and both hold 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 until the new one + * rendered, the element sat at its stylesheet size. That is the jump after a + * resize, and the next gesture then started from a box disagreeing with these + * vars and snapped on its first move. Only an element mid-edit carries them. + */ 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"); + }); +}); From e49b016823099aa9158487c72328cf51a2df4799 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 03:21:59 -0400 Subject: [PATCH 3/5] refactor(studio): keep the resize files under the size cap --- packages/studio/src/components/editor/manualEditsDom.ts | 6 ------ .../studio/src/components/editor/manualEditsSnapshot.ts | 9 --------- 2 files changed, 15 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 50b4c2272e..fe19bff836 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -36,7 +36,6 @@ import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; -import { logResize } from "../../utils/resizeDebug"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -455,11 +454,6 @@ export function applyStudioBoxSize( element: HTMLElement, size: { width: number; height: number }, ): void { - logResize("apply-box-size", { - to: `${Math.round(size.width)} x ${Math.round(size.height)}`, - from: `${element.style.width || "-"} x ${element.style.height || "-"}`, - stack: new Error("apply-box-size").stack?.split("\n").slice(1, 6).join(" < "), - }); promoteInlineForTransform(element); applyStudioBoxSizeDimensions(element, size); } diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index 9ab9269eb8..826fb16829 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -40,7 +40,6 @@ import type { StudioRotationSnapshot, StudioPathOffsetSnapshot, } from "./manualEditsTypes"; -import { logResize } from "../../utils/resizeDebug"; /* ── Capture ──────────────────────────────────────────────────────── */ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapshot { @@ -115,14 +114,6 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str } export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void { - // Putting the pre-gesture size back is correct on a cancel and wrong after a - // successful commit, and the two are indistinguishable from in here — so say - // who asked, with the size being restored and the one being replaced. - logResize("restore-box-size", { - to: `${previous.width || "-"} x ${previous.height || "-"}`, - from: `${element.style.width || "-"} x ${element.style.height || "-"}`, - stack: new Error("restore-box-size").stack?.split("\n").slice(1, 6).join(" < "), - }); restoreStyleProperty(element, "width", previous.width); restoreStyleProperty(element, "height", previous.height); restoreStyleProperty(element, "min-width", previous.minWidth); From dd328ee83b24639ad3f6823d5f3b540c53f7a38b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 11:24:14 -0400 Subject: [PATCH 4/5] docs(studio): fold the resize note into the size-reapply comment --- .../studio/src/components/editor/manualEditsDom.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index fe19bff836..4a041e020f 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -567,14 +567,11 @@ function reapplyPathOffsets(doc: Document): void { /** * 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 and both hold 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 until the new one - * rendered, the element sat at its stylesheet size. That is the jump after a - * resize, and the next gesture then started from a box disagreeing with these - * vars and snapped on its first move. Only an element mid-edit carries them. + * 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)) { From d7ca044315c5a7f599c0fb28676e3f147acab85c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 16:34:49 -0400 Subject: [PATCH 5/5] fix(studio): rotate the child outlines with the element they outline Selecting a rotated element drew upright dashed boxes across its children: the chrome co-rotated with the element and the child outlines did not, so a text layer inside a rotated card got a square outline lying across the rotated glyphs. The chrome already measures an oriented box; the child outlines were still measured axis-aligned. They now use the same oriented measurement and render with the same rotation. An unrotated element measures identically to before, since the oriented rect returns the plain bounding box at angle 0. --- .../src/components/editor/DomEditOverlay.tsx | 1 + .../editor/domEditOverlayGeometry.test.ts | 25 +++++++++++++++++++ .../editor/domEditOverlayGeometry.ts | 18 +++++++++++++ .../editor/useDomEditOverlayRects.ts | 6 +++-- 4 files changed, 48 insertions(+), 2 deletions(-) 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/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)) {