Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/studio/src/components/editor/DomEditCropHandles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "./domEditOverlayCrop";
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
import { readCropFollowingResize } from "./cropResize";
import { CHROME_HEIGHT, CHROME_LEFT, CHROME_TOP, CHROME_WIDTH } from "./domEditChromeRect";

interface CropGestureState {
edge: CropEdge | "move";
Expand Down Expand Up @@ -243,8 +244,8 @@ export function DomEditCropHandles({
data-dom-edit-crop-frame="true"
className="pointer-events-none absolute"
style={{
left: frame.left,
top: frame.top,
left: `calc(${CHROME_LEFT} + ${CHROME_WIDTH} / 2 - ${frame.width / 2}px)`,
top: `calc(${CHROME_TOP} + ${CHROME_HEIGHT} / 2 - ${frame.height / 2}px)`,
width: frame.width,
height: frame.height,
transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
Expand Down
11 changes: 3 additions & 8 deletions packages/studio/src/components/editor/DomEditRotateHandle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PointerEvent as ReactPointerEvent } from "react";
import type { OverlayRect } from "./domEditOverlayGeometry";
import { CHROME_HEIGHT, CHROME_LEFT, CHROME_TOP, CHROME_WIDTH } from "./domEditChromeRect";

/** Rotate handle below the selection: an attached circular-arrows icon chip
* (no connecting stem). Anchors to the crop outline when the element is
Expand All @@ -8,25 +8,20 @@ import type { OverlayRect } from "./domEditOverlayGeometry";
* (resolveDomEditRotationGesture), so the handle position doesn't affect the
* math. Sits 12px below the bbox, past the bottom crop handle's hit strip. */
export function DomEditRotateHandle({
overlayRect,
cropOutlineInsetPx,
onStartRotate,
}: {
overlayRect: OverlayRect;
cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
onStartRotate: (e: ReactPointerEvent<HTMLButtonElement>) => void;
}) {
const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 };
const visibleLeft = overlayRect.left + inset.left;
const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right);
const visibleBottom = overlayRect.top + overlayRect.height - inset.bottom;
return (
<button
type="button"
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
style={{
left: visibleLeft + visibleWidth / 2,
top: visibleBottom + 12,
left: `calc(${CHROME_LEFT} + ${inset.left}px + max(0px, ${CHROME_WIDTH} - ${inset.left + inset.right}px) / 2)`,
top: `calc(${CHROME_TOP} + ${CHROME_HEIGHT} + ${12 - inset.bottom}px)`,
width: 22,
height: 22,
transform: "translateX(-50%)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditing";
import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
Expand Down Expand Up @@ -322,3 +323,76 @@ describe("DomEditSelectionChrome with body drag off", () => {
act(() => root.unmount());
});
});

describe("DomEditSelectionChrome rect variables", () => {
const rectA = { left: 40, top: 30, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
const rectB = { ...rectA, left: 75.5, top: 12 };
const rectC = { left: 10, top: 4, width: 120, height: 64, editScaleX: 1, editScaleY: 1 };

// Server markup keeps each style verbatim; happy-dom's parser drops var() in `left`/`top`.
function renderAt(rect: typeof rectA) {
const element = document.createElement("div");
const { selection, host } = selectionFixture(element, "#el", true);
host.innerHTML = renderToStaticMarkup(
<DomEditSelectionChrome
selection={selection}
overlayRect={rect}
allowCanvasMovement
allowBodyDrag
boxRef={createRef()}
boxChromeClass=""
boxClipPath={undefined}
selectionKey="el"
groupSelectionCount={0}
gestures={{ startGesture: vi.fn() } as never}
onStyleCommit={vi.fn()}
onBoxClick={vi.fn()}
/>,
);
const styleOf = (el: Element) =>
Object.fromEntries(
(el.getAttribute("style") ?? "").split(";").map((d) => {
const at = d.indexOf(":");
return [d.slice(0, at).trim(), d.slice(at + 1).trim()];
}),
) as Record<string, string>;
const vars = styleOf(host.firstElementChild!);
const handles = [
...host.querySelectorAll('[aria-label="Rotate selection"], .h-4.w-4'),
host.querySelector("[data-dom-edit-crop-frame]")!,
].map((el) => {
const { left, top } = styleOf(el);
return { left, top };
});
// Resolves a position against the wrapper's variables, as the browser does.
const at = (value: string) => {
const expr = value
.replace(/var\((--[\w-]+)\)/g, (_, name: string) => vars[name] ?? "NaN")
.replace(/max\(/g, "Math.max(")
.replace(/calc\(/g, "(")
.replace(/px/g, "");
return Number(new Function(`return ${expr}`)());
};
host.remove();
return { handles, places: handles.map((h) => [at(h.left), at(h.top)]) };
}

it("moves every handle with the rect while their own styles stay the same", () => {
const a = renderAt(rectA);
const b = renderAt(rectB);
const c = renderAt(rectC);
expect(a.handles).toHaveLength(6);
expect(b.handles).toEqual(a.handles);
const expected = (r: typeof rectA) => [
[r.left + r.width / 2, r.top + r.height + 12],
[r.left - 8, r.top - 8],
[r.left + r.width - 8, r.top - 8],
[r.left - 8, r.top + r.height - 8],
[r.left + r.width - 8, r.top + r.height - 8],
[r.left, r.top],
];
expect(a.places).toEqual(expected(rectA));
expect(b.places).toEqual(expected(rectB));
expect(c.places).toEqual(expected(rectC));
});
});
25 changes: 15 additions & 10 deletions packages/studio/src/components/editor/DomEditSelectionChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import { DomEditCropHandles } from "./DomEditCropHandles";
import { DomEditRotateHandle } from "./DomEditRotateHandle";
import { resolveRotatedResizeCursor } from "./domEditResizeLocal";
import { usePreviewReadOnly } from "./previewReadOnlyContext";
import {
CHROME_HEIGHT,
CHROME_LEFT,
CHROME_TOP,
CHROME_WIDTH,
chromeRectVars,
} from "./domEditChromeRect";

// Corner resize handles, Canva-style: one per corner, diagonal cursors.
// Corners scale about the element center; the translate keeps the center
Expand All @@ -32,7 +39,6 @@ const NO_CROP_INSET: CropInset = { top: 0, right: 0, bottom: 0, left: 0 };

function resizeHandleStyle(
def: (typeof RESIZE_HANDLE_DEFS)[number],
overlayRect: { left: number; top: number; width: number; height: number },
cropInset?: CropInset,
): React.CSSProperties {
const half = RESIZE_HANDLE_HIT_PX / 2;
Expand All @@ -44,12 +50,12 @@ function resizeHandleStyle(
// overlap the dot circle at the corner.
style.left =
def.x === "left"
? overlayRect.left + inset.left - half
: overlayRect.left + overlayRect.width - inset.right - half;
? `calc(${CHROME_LEFT} + ${inset.left - half}px)`
: `calc(${CHROME_LEFT} + ${CHROME_WIDTH} - ${inset.right + half}px)`;
style.top =
def.y === "top"
? overlayRect.top + inset.top - half
: overlayRect.top + overlayRect.height - inset.bottom - half;
? `calc(${CHROME_TOP} + ${inset.top - half}px)`
: `calc(${CHROME_TOP} + ${CHROME_HEIGHT} - ${inset.bottom + half}px)`;
return style;
}

Expand Down Expand Up @@ -167,17 +173,16 @@ export function DomEditSelectionChrome({
const canManipulate = allowCanvasMovement && !readOnly;

return (
<>
<div className="contents" style={chromeRectVars(overlayRect)}>
<div
className="pointer-events-none absolute inset-0"
style={{
transformOrigin: `${overlayRect.left + overlayRect.width / 2}px ${overlayRect.top + overlayRect.height / 2}px`,
transformOrigin: `calc(${CHROME_LEFT} + ${CHROME_WIDTH} / 2) calc(${CHROME_TOP} + ${CHROME_HEIGHT} / 2)`,
transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
}}
>
{canManipulate && !editing && selection.capabilities.canApplyManualRotation && (
<DomEditRotateHandle
overlayRect={overlayRect}
cropOutlineInsetPx={cropOutlineInsetPx}
onStartRotate={(e) => {
e.stopPropagation();
Expand Down Expand Up @@ -247,7 +252,7 @@ export function DomEditSelectionChrome({
key={def.handle}
className="pointer-events-auto absolute flex h-4 w-4 items-center justify-center"
style={{
...resizeHandleStyle(def, overlayRect, cropOutlineInsetPx ?? undefined),
...resizeHandleStyle(def, cropOutlineInsetPx ?? undefined),
// Cursor rotates with the object: bucket the corner's base
// diagonal + element rotation into the 8 CSS resize cursors.
cursor: resolveRotatedResizeCursor(def.handle, overlayRect.angle ?? 0),
Expand All @@ -271,6 +276,6 @@ export function DomEditSelectionChrome({
onStyleCommit={onStyleCommit}
/>
)}
</>
</div>
);
}
25 changes: 25 additions & 0 deletions packages/studio/src/components/editor/domEditChromeRect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { CSSProperties } from "react";

type Rect = { left: number; top: number; width: number; height: number };

/** The selection chrome positions from these, so one style write on its wrapper moves all of it. */
const VAR = {
left: "--hf-chrome-left",
top: "--hf-chrome-top",
width: "--hf-chrome-width",
height: "--hf-chrome-height",
} as const;

export const CHROME_LEFT = `var(${VAR.left})`;
export const CHROME_TOP = `var(${VAR.top})`;
export const CHROME_WIDTH = `var(${VAR.width})`;
export const CHROME_HEIGHT = `var(${VAR.height})`;

export function chromeRectVars(rect: Rect): CSSProperties {
return {
[VAR.left]: `${rect.left}px`,
[VAR.top]: `${rect.top}px`,
[VAR.width]: `${rect.width}px`,
[VAR.height]: `${rect.height}px`,
} as CSSProperties;
}
Loading