mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
fix(studio): correct gesture commits for scaled and graded elements
- resize on a scale-driven element commits per-axis scale (scaleX/scaleY for non-uniform drags) with keyframe normalization to the longhands, and clears the width/height draft so size can't double-apply; the intercept moves to gsapResizeIntercept.ts - the drop frame applies the corrected position synchronously in the same microtask chain as the soft reload (no network-window jump), and the draft pins the anchor through accumulated moves on scaled elements - gesture size/position math divides by the element's own content scale - convert-to-keyframes resolves current values through the property-group filter for ALL capture passes (opacity/rotationX from unrelated tweens no longer leak into a rotation commit), and a grading-hidden source's opacity is read from its canvas, not the inline hide - canvas pointer-down confirms the hover target with a synchronous hit-test before starting a marquee (stale-hover race lost selections)
This commit is contained in:
@@ -535,6 +535,47 @@ describe("resolveDomEditResizeGesture", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("divides the cursor delta by the element's content scale (rescaled element)", () => {
|
||||
// Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the
|
||||
// CSS box by only 15px so the RENDERED box tracks the pointer 1:1.
|
||||
const next = resolveDomEditResizeGesture({
|
||||
originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1)
|
||||
originHeight: 240,
|
||||
actualWidth: 240,
|
||||
actualHeight: 120,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
contentScaleX: 2,
|
||||
contentScaleY: 2,
|
||||
dx: 30,
|
||||
dy: 12,
|
||||
uniform: false,
|
||||
});
|
||||
expect(next.width).toBe(255);
|
||||
expect(next.height).toBe(126);
|
||||
// The overlay box keeps tracking the raw cursor.
|
||||
expect(next.overlayWidth).toBe(510);
|
||||
expect(next.overlayHeight).toBe(252);
|
||||
});
|
||||
|
||||
it("treats a missing/invalid content scale as 1 (unscaled element)", () => {
|
||||
const next = resolveDomEditResizeGesture({
|
||||
originWidth: 240,
|
||||
originHeight: 120,
|
||||
actualWidth: 240,
|
||||
actualHeight: 120,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
contentScaleX: 0,
|
||||
contentScaleY: Number.NaN,
|
||||
dx: 30,
|
||||
dy: 12,
|
||||
uniform: false,
|
||||
});
|
||||
expect(next.width).toBe(270);
|
||||
expect(next.height).toBe(132);
|
||||
});
|
||||
|
||||
it("snaps width and height to the same value when Shift is held", () => {
|
||||
expect(
|
||||
resolveDomEditResizeGesture({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
||||
import { useMarqueeGestures } from "./marqueeCommit";
|
||||
@@ -305,8 +306,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
||||
|
||||
// Start marquee if clicking on empty canvas (no element under pointer)
|
||||
// Start marquee if clicking on empty canvas (no element under pointer).
|
||||
// The hover selection is an ASYNC cache: on a fast click (or when the
|
||||
// pointer was already resting over an element) it can still be empty while
|
||||
// an element IS under the pointer — starting a marquee here would swallow
|
||||
// the selection mousedown and the click would silently select nothing.
|
||||
// Confirm emptiness with a fresh SYNCHRONOUS hit-test before committing.
|
||||
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
|
||||
const iframe = iframeRef.current;
|
||||
const freshTarget = iframe
|
||||
? getPreviewTargetFromPointer(iframe, event.clientX, event.clientY, activeCompositionPath)
|
||||
: null;
|
||||
if (freshTarget) return;
|
||||
const overlayEl = overlayRef.current;
|
||||
if (overlayEl) {
|
||||
const oRect = overlayEl.getBoundingClientRect();
|
||||
|
||||
@@ -39,6 +39,25 @@ export interface GestureState {
|
||||
actualRotation: number;
|
||||
editScaleX: number;
|
||||
editScaleY: number;
|
||||
// Rendered-per-CSS-pixel factor of the element itself at gesture start (a GSAP
|
||||
// scale() transform makes this > 1) — the resize draft divides by it so the box
|
||||
// follows the cursor instead of overshooting by the live scale.
|
||||
contentScaleX: number;
|
||||
contentScaleY: number;
|
||||
// Resize anchor pinning: with a live scale transform, growing the CSS box
|
||||
// shifts the rendered box (scaling happens around the element center), so the
|
||||
// un-dragged corner creeps during the draft. The move handler measures the
|
||||
// gesture-start top-left drift each frame and counters it through the GSAP
|
||||
// position channel; the pin accumulates so the correction converges.
|
||||
// Present only on resize gestures.
|
||||
resizeAnchor?: {
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
baseGsapX: number;
|
||||
baseGsapY: number;
|
||||
pinX: number;
|
||||
pinY: number;
|
||||
};
|
||||
manualEditDragToken?: string;
|
||||
snapContext?: SnapContext;
|
||||
lastSnappedDx?: number;
|
||||
@@ -77,21 +96,31 @@ export function resolveDomEditResizeGesture(input: {
|
||||
actualHeight: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
// Rendered-per-CSS-pixel factor of the element itself (its live GSAP scale).
|
||||
// The CSS width/height the draft writes get multiplied by this on screen, so
|
||||
// the cursor delta must be divided by it — otherwise the box outruns the
|
||||
// pointer on a rescaled element and snaps back on release. Defaults to 1.
|
||||
contentScaleX?: number;
|
||||
contentScaleY?: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
uniform: boolean;
|
||||
}): { overlayWidth: number; overlayHeight: number; width: number; height: number } {
|
||||
const scaleX = input.scaleX > 0 ? input.scaleX : 1;
|
||||
const scaleY = input.scaleY > 0 ? input.scaleY : 1;
|
||||
const contentScaleX =
|
||||
input.contentScaleX !== undefined && input.contentScaleX > 0 ? input.contentScaleX : 1;
|
||||
const contentScaleY =
|
||||
input.contentScaleY !== undefined && input.contentScaleY > 0 ? input.contentScaleY : 1;
|
||||
|
||||
if (input.uniform) {
|
||||
const deltaX = input.dx / scaleX;
|
||||
const deltaY = input.dy / scaleY;
|
||||
const deltaX = input.dx / (scaleX * contentScaleX);
|
||||
const deltaY = input.dy / (scaleY * contentScaleY);
|
||||
const delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY;
|
||||
const side = Math.max(1, Math.max(input.actualWidth, input.actualHeight) + delta);
|
||||
return {
|
||||
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX),
|
||||
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY),
|
||||
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX * contentScaleX),
|
||||
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY * contentScaleY),
|
||||
width: side,
|
||||
height: side,
|
||||
};
|
||||
@@ -100,8 +129,8 @@ export function resolveDomEditResizeGesture(input: {
|
||||
return {
|
||||
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, input.originWidth + input.dx),
|
||||
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, input.originHeight + input.dy),
|
||||
width: Math.max(1, input.actualWidth + input.dx / scaleX),
|
||||
height: Math.max(1, input.actualHeight + input.dy / scaleY),
|
||||
width: Math.max(1, input.actualWidth + input.dx / (scaleX * contentScaleX)),
|
||||
height: Math.max(1, input.actualHeight + input.dy / (scaleY * contentScaleY)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Gesture-begin functions: startGroupDrag and startGesture.
|
||||
* These are pure "start a new gesture" operations — no draft rect updates.
|
||||
*/
|
||||
import { readElementGsapNumber } from "../../utils/elementGsap";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
createManualOffsetDragMember,
|
||||
@@ -120,8 +121,37 @@ export function startGesture(
|
||||
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
|
||||
// element's actual visual angle and commits an absolute angle to the timeline.
|
||||
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
|
||||
const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
|
||||
const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
|
||||
// The draft writes CSS width/height, so the resize base must be the CSS
|
||||
// layout size. offsetWidth/Height are transform-free; the overlay-rect
|
||||
// fallback (rect / editScale) includes the element's own GSAP scale and
|
||||
// would make a rescaled element's draft grow from the RENDERED size.
|
||||
const layoutWidth = sel.element.offsetWidth;
|
||||
const layoutHeight = sel.element.offsetHeight;
|
||||
const actualWidth =
|
||||
size.width > 0 ? size.width : layoutWidth > 0 ? layoutWidth : rect.width / rect.editScaleX;
|
||||
const actualHeight =
|
||||
size.height > 0 ? size.height : layoutHeight > 0 ? layoutHeight : rect.height / rect.editScaleY;
|
||||
// overlay rect = cssSize x contentScale x editScale, so the element's own
|
||||
// render factor (its GSAP scale) falls out of the measured rect. 1 when
|
||||
// unscaled or unmeasurable.
|
||||
const rawContentScaleX = rect.width / (rect.editScaleX * actualWidth);
|
||||
const rawContentScaleY = rect.height / (rect.editScaleY * actualHeight);
|
||||
const contentScaleX =
|
||||
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
|
||||
const contentScaleY =
|
||||
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
|
||||
let resizeAnchor: GestureState["resizeAnchor"];
|
||||
if (kind === "resize") {
|
||||
const startBcr = sel.element.getBoundingClientRect();
|
||||
resizeAnchor = {
|
||||
anchorX: startBcr.x,
|
||||
anchorY: startBcr.y,
|
||||
baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0,
|
||||
baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0,
|
||||
pinX: 0,
|
||||
pinY: 0,
|
||||
};
|
||||
}
|
||||
let initialPathOffset = captureStudioPathOffset(sel.element);
|
||||
let manualEditDragToken: string | undefined;
|
||||
let pathOffsetMember: ManualOffsetDragMember | undefined;
|
||||
@@ -184,6 +214,9 @@ export function startGesture(
|
||||
actualRotation: rotation.angle,
|
||||
editScaleX: rect.editScaleX,
|
||||
editScaleY: rect.editScaleY,
|
||||
contentScaleX,
|
||||
contentScaleY,
|
||||
resizeAnchor,
|
||||
manualEditDragToken,
|
||||
snapContext,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Owns: onPointerMove, onPointerUp, clearPointerState.
|
||||
* startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
|
||||
*/
|
||||
import { setElementGsapPosition } from "../../utils/elementGsap";
|
||||
import type { RefObject } from "react";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
@@ -53,6 +54,13 @@ import {
|
||||
resolveEquidistanceGuides,
|
||||
SNAP_THRESHOLD_PX,
|
||||
} from "./snapEngine";
|
||||
/** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */
|
||||
function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void {
|
||||
const anchor = g.resizeAnchor;
|
||||
if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return;
|
||||
setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY);
|
||||
}
|
||||
|
||||
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
|
||||
const setDraftOverlayRect = (next: OverlayRect) => {
|
||||
opts.setOverlayRect(next);
|
||||
@@ -175,7 +183,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
actualAngle: g.actualRotation,
|
||||
snap: e.shiftKey,
|
||||
});
|
||||
if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
|
||||
const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle);
|
||||
if (!draftViaGsap) {
|
||||
applyStudioRotationDraft(sel.element, rotated);
|
||||
}
|
||||
return;
|
||||
@@ -278,12 +287,35 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
actualHeight: g.actualHeight,
|
||||
scaleX: g.editScaleX,
|
||||
scaleY: g.editScaleY,
|
||||
contentScaleX: g.contentScaleX,
|
||||
contentScaleY: g.contentScaleY,
|
||||
dx,
|
||||
dy,
|
||||
uniform: e.shiftKey,
|
||||
});
|
||||
applyStudioBoxSizeDraft(sel.element, nextSize);
|
||||
|
||||
// Pin the gesture anchor (top-left): with a live scale transform, the CSS
|
||||
// size change shifts the rendered box around the element center. Measure
|
||||
// the drift of the gesture-start corner and counter it via GSAP x/y —
|
||||
// accumulated onto the previous pin so the correction converges instead
|
||||
// of oscillating. The release-time position compensation re-measures the
|
||||
// drop, so the pin composes with the commit.
|
||||
const anchor = g.resizeAnchor;
|
||||
if (anchor) {
|
||||
const pinned = sel.element.getBoundingClientRect();
|
||||
const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x);
|
||||
const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y);
|
||||
if (
|
||||
setElementGsapPosition(
|
||||
sel.element,
|
||||
anchor.baseGsapX + nextPinX,
|
||||
anchor.baseGsapY + nextPinY,
|
||||
)
|
||||
) {
|
||||
anchor.pinX = nextPinX;
|
||||
anchor.pinY = nextPinY;
|
||||
}
|
||||
}
|
||||
// Re-read BCR after applying dimensions. For elements with a GSAP
|
||||
// scale transform and centered transform-origin the visual top-left
|
||||
// drifts and the visual size diverges from the raw CSS size, so BCR
|
||||
@@ -382,6 +414,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
}
|
||||
|
||||
if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
|
||||
restoreResizeAnchorPin(sel.element, g);
|
||||
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
if (box) {
|
||||
@@ -411,7 +444,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
restoreStudioRotation(sel.element, g.initialRotation);
|
||||
}
|
||||
};
|
||||
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
|
||||
const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle);
|
||||
if (!rotationChanged) {
|
||||
restoreRotation();
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
return;
|
||||
@@ -422,14 +456,17 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
applyStudioRotation(sel.element, finalRotation);
|
||||
}
|
||||
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
console.error("rotate commit failed", error);
|
||||
if (
|
||||
g.manualEditDragToken &&
|
||||
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
|
||||
)
|
||||
restoreRotation();
|
||||
})
|
||||
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
||||
.finally(() => {
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
});
|
||||
} else if (g.kind === "drag") {
|
||||
const dx = g.lastSnappedDx ?? e.clientX - g.startX;
|
||||
const dy = g.lastSnappedDy ?? e.clientY - g.startY;
|
||||
@@ -469,12 +506,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
const finalSize = readStudioBoxSize(sel.element);
|
||||
applyStudioBoxSize(sel.element, finalSize);
|
||||
void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
console.error("resize commit failed", error);
|
||||
if (
|
||||
g.manualEditDragToken &&
|
||||
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
|
||||
)
|
||||
) {
|
||||
restoreResizeAnchorPin(sel.element, g);
|
||||
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
||||
}
|
||||
})
|
||||
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
||||
}
|
||||
@@ -494,6 +534,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
restoreGestureOverlayRect(g);
|
||||
}
|
||||
if (g?.mode === "box-size" && sel) {
|
||||
restoreResizeAnchorPin(sel.element, g);
|
||||
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
restoreGestureOverlayRect(g);
|
||||
|
||||
Reference in New Issue
Block a user