summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/preview/transform-handles.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/preview/transform-handles.tsx
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/preview/transform-handles.tsx')
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx273
1 files changed, 65 insertions, 208 deletions
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
index 2fcf8096..88332625 100644
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -1,112 +1,55 @@
"use client";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { useEditor } from "@/hooks/use-editor";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import { SnapGuides } from "./snap-guides";
-import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ getCornerPosition,
+ getEdgeHandlePosition,
+ getRotationHandlePosition,
+ type Corner,
+ type Edge,
+} from "@/lib/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
import { Rotate01Icon } from "@hugeicons/core-free-icons";
-const HANDLE_SIZE = 10;
-const ROTATION_HANDLE_OFFSET = 24;
-const ROTATION_HANDLE_RADIUS = 10;
-const CORNER_HIT_AREA_SIZE = 18;
-
-type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const CORNERS: Corner[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
-
-export function getCornerPosition({
- bounds,
- corner,
-}: {
- bounds: ElementBounds;
- corner: Corner;
-}): { x: number; y: number } {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
-
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
-
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
- x: number;
- y: number;
-} {
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
- return {
- x: bounds.cx - localY * sin,
- y: bounds.cy + localY * cos,
- };
-}
-
-export function getOverlayContext({
- canvasRef,
- containerRef,
-}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
-}): {
- canvasRect: DOMRect;
- containerRect: DOMRect;
-} | null {
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
- if (!canvasRect || !containerRect) return null;
- return { canvasRect, containerRect };
-}
+const EDGES: Edge[] = ["right", "left", "bottom"];
export function TransformHandles({
- canvasRef,
- containerRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
+ const viewport = usePreviewViewport();
const {
selectedWithBounds,
hasVisualSelection,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
- } = useTransformHandles({ canvasRef });
-
- const editor = useEditor();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ } = useTransformHandles({ onSnapLinesChange });
if (!hasVisualSelection || !selectedWithBounds) return null;
- const overlayContext = getOverlayContext({ canvasRef, containerRef });
- if (!overlayContext) return null;
-
const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null;
- const { canvasRect, containerRect } = overlayContext;
- const displayScale = getDisplayScale({ canvasRect, canvasSize });
+ const displayScale = viewport.getDisplayScale();
const toOverlay = ({
canvasX,
@@ -115,31 +58,30 @@ export function TransformHandles({
canvasX: number;
canvasY: number;
}) =>
- canvasToOverlay({
+ viewport.canvasToOverlay({
canvasX,
canvasY,
- canvasRect,
- containerRect,
- canvasSize,
});
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
- const outlineWidth = bounds.width * displayScale.x;
- const outlineHeight = bounds.height * displayScale.y;
+ const outlineWidth = Math.abs(bounds.width) * displayScale.x;
+ const outlineHeight = Math.abs(bounds.height) * displayScale.y;
- const rotationHandle = getRotationHandlePosition({ bounds });
+ const rotationHandleCanvas = getRotationHandlePosition({ bounds });
const rotationHandleScreen = toOverlay({
- canvasX: rotationHandle.x,
- canvasY: rotationHandle.y,
+ canvasX: rotationHandleCanvas.x,
+ canvasY: rotationHandleCanvas.y,
});
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
return (
- <div className="pointer-events-none absolute inset-0" aria-hidden>
- <SnapGuides
- lines={snapLines}
- canvasRef={canvasRef}
- containerRef={containerRef}
- />
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
<BoundingBoxOutline
center={center}
outlineWidth={outlineWidth}
@@ -152,132 +94,47 @@ export function TransformHandles({
canvasX: cornerPosition.x,
canvasY: cornerPosition.y,
});
+ const angleDeg =
+ Math.atan2(screen.y - center.y, screen.x - center.x) *
+ (180 / Math.PI);
return (
<CornerHandle
key={corner}
- corner={corner}
+ cursor={getResizeCursor({ angleDeg })}
screen={screen}
onPointerDown={(event) =>
handleCornerPointerDown({ event, corner })
}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ {EDGES.map((edge) => {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ <EdgeHandle
+ key={edge}
+ edge={edge}
+ screen={screen}
+ rotation={bounds.rotation}
+ onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
);
})}
- <RotationHandle
+ <IconHandle
+ icon={Rotate01Icon}
screen={rotationHandleScreen}
onPointerDown={(event) => handleRotationPointerDown({ event })}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
</div>
);
}
-
-function BoundingBoxOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
-}) {
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: center.x - outlineWidth / 2,
- top: center.y - outlineHeight / 2,
- width: outlineWidth,
- height: outlineHeight,
- transform: `rotate(${rotation}deg)`,
- transformOrigin: "center center",
- border: "1px dashed white",
- boxSizing: "border-box",
- opacity: 0.75,
- }}
- />
- );
-}
-
-function CornerHandle({
- corner,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- corner: Corner;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- const isNesw = corner === "top-right" || corner === "bottom-left";
-
- return (
- <button
- type="button"
- className={cn(
- "absolute flex items-center justify-center outline-none",
- isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
- )}
- style={{
- left: screen.x - CORNER_HIT_AREA_SIZE / 2,
- top: screen.y - CORNER_HIT_AREA_SIZE / 2,
- width: CORNER_HIT_AREA_SIZE,
- height: CORNER_HIT_AREA_SIZE,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- <div
- className="rounded-sm bg-white"
- style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
- />
- </button>
- );
-}
-
-function RotationHandle({
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <button
- type="button"
- className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
- style={{
- left: screen.x - ROTATION_HANDLE_RADIUS,
- top: screen.y - ROTATION_HANDLE_RADIUS,
- width: ROTATION_HANDLE_RADIUS * 2,
- height: ROTATION_HANDLE_RADIUS * 2,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
- </button>
- );
-}