Skip to content

Commit 0dcd5b7

Browse files
committed
fix(studio): a resized corner stays on the pointer when the size rounds to whole pixels
Resized sizes stay on whole CSS pixels (GSAP centres a -50% translate from the integer offset size), so the centre-anchored resize left the grabbed corner up to a third of a pixel times the element's scale off the pointer: 0.53 px on a 1.5x scaled layer. The centre pin now takes half the rounding remainder along the element's rendered edges, so the grabbed corner lands where the wanted size puts it while the written size stays whole.
1 parent 4a335aa commit 0dcd5b7

7 files changed

Lines changed: 112 additions & 10 deletions

File tree

‎packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,26 @@ describe("anchored corner resize — the release commit feeds the center-pin off
259259
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
260260
});
261261

262+
it("puts the grabbed corner on the pointer when the size rounds to whole px", () => {
263+
const { handlers, commits } = buildHarness();
264+
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
265+
resizeHandle: "se",
266+
});
267+
// Radial scale 1.503: the pointer asks for 300.6 x 150.3, Studio writes 301 x 150.
268+
for (let i = 0; i < 5; i++)
269+
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150.3, ORIGIN_CENTER.y));
270+
handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150.3, ORIGIN_CENTER.y));
271+
272+
const { size, offset } = commits[0]!;
273+
expect(size).toEqual({ width: 301, height: 150 });
274+
const corner = {
275+
x: ORIGIN_CENTER.x + (size.width - ORIGIN.width) / 2 + offset!.x + size.width / 2,
276+
y: ORIGIN_CENTER.y + (size.height - ORIGIN.height) / 2 + offset!.y + size.height / 2,
277+
};
278+
expect(corner.x).toBeCloseTo(ORIGIN_CENTER.x + 300.6 / 2, 6);
279+
expect(corner.y).toBeCloseTo(ORIGIN_CENTER.y + 150.3 / 2, 6);
280+
});
281+
262282
it("keeps the centre on the first frame when the authored translate is a percent", async () => {
263283
const { orientedOverlayRect } = await import("./domEditOverlayGeometry");
264284
authored.percent = 0.25;

‎packages/studio/src/components/editor/domEditResizeLocal.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
resizeRemainderShift,
34
resolveCenterResizeScale,
45
resolveCenterResizeSize,
56
resolveRotatedResizeCursor,
@@ -129,3 +130,36 @@ describe("resolveRotatedResizeCursor", () => {
129130
expect(resolveRotatedResizeCursor("se", -90)).toBe("nesw-resize");
130131
});
131132
});
133+
134+
describe("resizeRemainderShift", () => {
135+
// A 30 deg, 1.5x scaled box: local (u, v) px map to overlay px through M.
136+
const angle = Math.PI / 6;
137+
const k = 1.5;
138+
const M = (u: number, v: number) => ({
139+
x: k * (u * Math.cos(angle) - v * Math.sin(angle)),
140+
y: k * (u * Math.sin(angle) + v * Math.cos(angle)),
141+
});
142+
const centre = { x: 400, y: 300 };
143+
const at = (u: number, v: number) => ({ x: centre.x + M(u, v).x, y: centre.y + M(u, v).y });
144+
const written = { width: 301, height: 150 };
145+
const wanted = { width: 300.6, height: 150.3 };
146+
const corners = {
147+
nw: at(-written.width / 2, -written.height / 2),
148+
ne: at(written.width / 2, -written.height / 2),
149+
sw: at(-written.width / 2, written.height / 2),
150+
se: at(written.width / 2, written.height / 2),
151+
};
152+
153+
it.each([
154+
["nw", -1, -1],
155+
["ne", 1, -1],
156+
["sw", -1, 1],
157+
["se", 1, 1],
158+
] as const)("moves the %s corner to where the wanted size puts it", (handle, sx, sy) => {
159+
const shift = resizeRemainderShift(corners, handle, wanted, written);
160+
const grabbed = corners[handle];
161+
const target = at((sx * wanted.width) / 2, (sy * wanted.height) / 2);
162+
expect(grabbed.x + shift.x).toBeCloseTo(target.x, 9);
163+
expect(grabbed.y + shift.y).toBeCloseTo(target.y, 9);
164+
});
165+
});

‎packages/studio/src/components/editor/domEditResizeLocal.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* rendered center, feeding the center-pin translate through the manual-offset
1515
* channel) lives in useDomEditOverlayGestures.ts.
1616
*/
17+
import type { FixedCorner } from "./domEditOverlayGeometry";
1718
import type { ResizeHandle } from "./domEditOverlayGestures";
1819

1920
/** Minimum element edge in LOCAL px — mirrors the old MIN_RESIZE_EDGE_PX clamp
@@ -112,6 +113,31 @@ export function resolveRotatedResizeCursor(handle: ResizeHandle, rotationDeg: nu
112113
return CURSORS_8[bucket]!;
113114
}
114115

116+
type Size = { width: number; height: number };
117+
const HANDLE_SIGN: Record<ResizeHandle, readonly [number, number]> = {
118+
nw: [-1, -1],
119+
ne: [1, -1],
120+
sw: [-1, 1],
121+
se: [1, 1],
122+
};
123+
124+
/** Overlay shift of the pinned center that puts the grabbed corner where the wanted size would: the
125+
* written size is whole px, so half the remainder moves along the element's own rendered edges. */
126+
export function resizeRemainderShift(
127+
corners: Record<FixedCorner, { x: number; y: number }>,
128+
handle: ResizeHandle,
129+
wanted: Size,
130+
written: Size,
131+
): { x: number; y: number } {
132+
const [sx, sy] = HANDLE_SIGN[handle];
133+
const kw = (sx * (wanted.width - written.width)) / (2 * written.width);
134+
const kh = (sy * (wanted.height - written.height)) / (2 * written.height);
135+
return {
136+
x: (corners.ne.x - corners.nw.x) * kw + (corners.sw.x - corners.nw.x) * kh,
137+
y: (corners.ne.y - corners.nw.y) * kw + (corners.sw.y - corners.nw.y) * kh,
138+
};
139+
}
140+
115141
/** Per-frame anchored-resize center accumulator: ADD the residual center correction
116142
* (fixedStart − fixedNow) onto the previous anchor so the pin CONVERGES instead of
117143
* oscillating (fa4f39168). Pure; exported for the release-shift characterization tests. */

‎packages/studio/src/components/editor/manualEditsDom.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
} from "./manualEditsTypes";
3737
import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
3838
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
39-
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
39+
import { roundTo3, roundToLayoutPx, roundToLayoutSize } from "../../utils/rounding";
4040
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
4141

4242
/* ── Gesture tracking ─────────────────────────────────────────────── */
@@ -477,8 +477,8 @@ function writeStudioBoxSizeVars(
477477
}
478478

479479
element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
480-
element.style.setProperty(STUDIO_WIDTH_PROP, `${roundToLayoutPx(Math.max(1, size.width))}px`);
481-
element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutPx(Math.max(1, size.height))}px`);
480+
element.style.setProperty(STUDIO_WIDTH_PROP, `${roundToLayoutSize(size).width}px`);
481+
element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutSize(size).height}px`);
482482
}
483483

484484
function applyStudioBoxSizeDimensions(
@@ -488,8 +488,7 @@ function applyStudioBoxSizeDimensions(
488488
writeStudioBoxSizeVars(element, size);
489489
restoreStaleStudioScaleResize(element);
490490

491-
const width = roundToLayoutPx(Math.max(1, size.width));
492-
const height = roundToLayoutPx(Math.max(1, size.height));
491+
const { width, height } = roundToLayoutSize(size);
493492
element.style.setProperty("box-sizing", "border-box");
494493
element.style.setProperty("width", `${width}px`);
495494
element.style.setProperty("height", `${height}px`);
@@ -533,12 +532,13 @@ export function forgetStudioBoxSizeDraftBase(element: HTMLElement): void {
533532
export function applyStudioBoxSizeDraft(
534533
element: HTMLElement,
535534
size: { width: number; height: number },
536-
): void {
535+
): { width: number; height: number } {
537536
if (!boxSizeDraftBases.has(element)) {
538537
boxSizeDraftBases.set(element, { width: element.offsetWidth, height: element.offsetHeight });
539538
}
540539
promoteInlineForTransform(element);
541540
applyStudioBoxSizeDimensions(element, size);
541+
return roundToLayoutSize(size);
542542
}
543543

544544
/* ── Rotation apply ───────────────────────────────────────────────── */

‎packages/studio/src/components/editor/resizeDraft.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import {
66
elementCornerOverlayPoints,
77
overlayCornersCentroid,
88
} from "./domEditOverlayGeometry";
9-
import { computeNextResizeAnchor } from "./domEditResizeLocal";
9+
import { computeNextResizeAnchor, resizeRemainderShift } from "./domEditResizeLocal";
1010
import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
1111

1212
type Corners = ReturnType<typeof elementCornerOverlayPoints>;
13+
type Size = { width: number; height: number };
14+
/** The size the pointer asks for and the whole-px size written for it. */
15+
type ResizeDraftSizes = { wanted: Size; written: Size };
1316

1417
/**
1518
* The residual center-pin offset for this frame. With measurable corners and a
@@ -25,10 +28,18 @@ function resolveResizeAnchor(
2528
g: GestureState,
2629
corners: Corners | null,
2730
measureOrientedRect: () => OverlayRect | null,
31+
sizes: ResizeDraftSizes,
2832
): { dx: number; dy: number } {
2933
const fixedStart = g.resizeFixedCenterStart;
3034
if (corners && fixedStart) {
31-
return computeNextResizeAnchor(g.lastResizeAnchor, fixedStart, overlayCornersCentroid(corners));
35+
const shift = resizeRemainderShift(
36+
corners,
37+
g.resizeHandle ?? "se",
38+
sizes.wanted,
39+
sizes.written,
40+
);
41+
const target = { x: fixedStart.x + shift.x, y: fixedStart.y + shift.y };
42+
return computeNextResizeAnchor(g.lastResizeAnchor, target, overlayCornersCentroid(corners));
3243
}
3344
const fallbackRect = measureOrientedRect();
3445
return resolveResizeCenterAnchorOffset({
@@ -52,11 +63,12 @@ function resolveAnchoredResizeDraft(
5263
overlayEl: HTMLDivElement | null,
5364
iframe: HTMLIFrameElement | null,
5465
measureOrientedRect: () => OverlayRect | null,
66+
sizes: ResizeDraftSizes,
5567
): OverlayRect {
5668
// Measure real corners ONCE — reused for the anchor and the fallback size.
5769
const corners =
5870
overlayEl && iframe ? elementCornerOverlayPoints(overlayEl, iframe, element) : null;
59-
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect);
71+
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect, sizes);
6072
g.lastResizeAnchor = anchor;
6173
applyManualOffsetDragDraft(member, anchor.dx, anchor.dy);
6274
// Re-measure AFTER the anchor translate so it hugs the element every frame.
@@ -80,6 +92,7 @@ export function resolveResizeDraftRect(
8092
overlayEl: HTMLDivElement | null,
8193
iframe: HTMLIFrameElement | null,
8294
measureOrientedRect: () => OverlayRect | null,
95+
sizes: ResizeDraftSizes,
8396
): OverlayRect {
8497
if (g.pathOffsetMember) {
8598
return resolveAnchoredResizeDraft(
@@ -89,6 +102,7 @@ export function resolveResizeDraftRect(
89102
overlayEl,
90103
iframe,
91104
measureOrientedRect,
105+
sizes,
92106
);
93107
}
94108
// Re-measure the element's oriented box AFTER the size write. The size draft

‎packages/studio/src/components/editor/useDomEditOverlayGestures.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
257257
pointerStart: { x: g.startX, y: g.startY },
258258
centerStart: { x: g.centerX, y: g.centerY },
259259
});
260-
applyStudioBoxSizeDraft(sel.element, nextSize);
260+
const writtenSize = applyStudioBoxSizeDraft(sel.element, nextSize);
261261

262262
const overlayEl = opts.overlayRef.current;
263263
const iframe = opts.iframeRef.current;
@@ -270,6 +270,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
270270
overlayEl,
271271
iframe,
272272
measureOrientedRect,
273+
{ wanted: nextSize, written: writtenSize },
273274
);
274275
logResizeMove({
275276
pointer: { x: e.clientX, y: e.clientY },

‎packages/studio/src/utils/rounding.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export function roundToLayoutPx(val: number): number {
88
return Math.round(val);
99
}
1010

11+
export function roundToLayoutSize(size: { width: number; height: number }) {
12+
return {
13+
width: roundToLayoutPx(Math.max(1, size.width)),
14+
height: roundToLayoutPx(Math.max(1, size.height)),
15+
};
16+
}
17+
1118
/** Round to 2 decimal places (centisecond precision for timeline values). */
1219
export function roundToCenti(val: number): number {
1320
return Math.round(val * 100) / 100;

0 commit comments

Comments
 (0)