diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
| commit | c46d28891fee62ca2d3132da876af6411a41d9da (patch) | |
| tree | f856f71d04c69628f26b904088d675dd298a5080 /apps/web/src/components/editor/panels/preview | |
| parent | 66e4367bc4727f1d0bcf8f869529c7518812277e (diff) | |
| download | opencut-c46d28891fee62ca2d3132da876af6411a41d9da.tar.gz | |
feat: text / custom masks, fix preview pan, refactor selection
Diffstat (limited to 'apps/web/src/components/editor/panels/preview')
4 files changed, 635 insertions, 442 deletions
diff --git a/apps/web/src/components/editor/panels/preview/cursors.ts b/apps/web/src/components/editor/panels/preview/cursors.ts new file mode 100644 index 00000000..f04dbef0 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/cursors.ts @@ -0,0 +1,21 @@ +function svgCursor({ + svg, + hotspotX, + hotspotY, +}: { + svg: string; + hotspotX: number; + hotspotY: number; +}): string { + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hotspotX} ${hotspotY}, crosshair`; +} + +/** Hotspot is at the nib tip, which is where anchor points land. */ +export const PEN_CURSOR = svgCursor({ + svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path d="M 1 1 L 5 2 L 13 10 L 10 13 L 2 5 Z" fill="white" stroke="#111" stroke-width="1" stroke-linejoin="round"/> + <path d="M 1 1 L 5 2 L 2 5 Z" fill="#111"/> +</svg>`, + hotspotX: 1, + hotspotY: 1, +}); diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx index d4de3332..33baf9fc 100644 --- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx +++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx @@ -1,332 +1,427 @@ -"use client";
-
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import type { IconSvgElement } from "@hugeicons/react";
-
-export const HANDLE_SIZE = 10;
-export const HANDLE_HIT_AREA_SIZE = 18;
-export const ICON_HANDLE_RADIUS = 10;
-export const EDGE_HANDLE_THIN_SIZE = 6;
-export const EDGE_HANDLE_THICK_SIZE = 14;
-export const LINE_HIT_AREA_SIZE = 48;
-
-export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
- const normalized = ((angleDeg % 180) + 180) % 180;
- if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
- if (normalized < 67.5) return "cursor-nwse-resize";
- if (normalized < 112.5) return "cursor-ns-resize";
- return "cursor-nesw-resize";
-}
-
-export function HandleButton({
- screen,
- cursor,
- hitAreaSize,
- className,
- onPointerDown,
- onPointerMove,
- onPointerUp,
- children,
-}: {
- screen: { x: number; y: number };
- cursor?: string;
- hitAreaSize: number;
- className?: string;
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
- children: React.ReactNode;
-}) {
- return (
- <button
- type="button"
- className={cn(
- "absolute flex items-center justify-center outline-none",
- cursor,
- className,
- )}
- style={{
- left: screen.x - hitAreaSize / 2,
- top: screen.y - hitAreaSize / 2,
- width: hitAreaSize,
- height: hitAreaSize,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- {children}
- </button>
- );
-}
-
-export function CornerHandle({
- cursor,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- cursor?: string;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <HandleButton
- screen={screen}
- cursor={cursor}
- hitAreaSize={HANDLE_HIT_AREA_SIZE}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- >
- <div
- className="rounded-sm bg-white"
- style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
- />
- </HandleButton>
- );
-}
-
-export function EdgeHandle({
- edge,
- screen,
- rotation,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- edge: "right" | "left" | "bottom";
- screen: { x: number; y: number };
- rotation: number;
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- const isHorizontalEdge = edge === "right" || edge === "left";
- const width = isHorizontalEdge
- ? EDGE_HANDLE_THIN_SIZE
- : EDGE_HANDLE_THICK_SIZE;
- const height = isHorizontalEdge
- ? EDGE_HANDLE_THICK_SIZE
- : EDGE_HANDLE_THIN_SIZE;
- const cursor = getResizeCursor({
- angleDeg: isHorizontalEdge ? rotation : rotation + 90,
- });
-
- return (
- <HandleButton
- screen={screen}
- cursor={cursor}
- hitAreaSize={HANDLE_HIT_AREA_SIZE}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- >
- <div
- className="rounded-sm bg-white"
- style={{ width, height, transform: `rotate(${rotation}deg)` }}
- />
- </HandleButton>
- );
-}
-
-export function IconHandle({
- icon,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- icon: IconSvgElement;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <HandleButton
- screen={screen}
- hitAreaSize={ICON_HANDLE_RADIUS * 2}
- className="rounded-full bg-white text-black shadow-sm"
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- >
- <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
- </HandleButton>
- );
-}
-
-export function BoundingBoxOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
- cursor,
- dashed = false,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
- cursor?: string;
- dashed?: boolean;
- onPointerDown?: (event: React.PointerEvent) => void;
- onPointerMove?: (event: React.PointerEvent) => void;
- onPointerUp?: (event: React.PointerEvent) => void;
-}) {
- return (
- <svg
- className={cn("absolute overflow-visible", cursor)}
- aria-hidden="true"
- focusable="false"
- style={{
- left: center.x - outlineWidth / 2,
- top: center.y - outlineHeight / 2,
- width: outlineWidth,
- height: outlineHeight,
- transform: `rotate(${rotation}deg)`,
- transformOrigin: "center center",
- pointerEvents: onPointerDown ? "auto" : "none",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- >
- <rect
- x={0.5}
- y={0.5}
- width={Math.max(outlineWidth - 1, 0)}
- height={Math.max(outlineHeight - 1, 0)}
- fill="transparent"
- stroke="white"
- strokeDasharray={dashed ? "4 4" : undefined}
- strokeOpacity={0.75}
- vectorEffect="non-scaling-stroke"
- style={{ pointerEvents: onPointerDown ? "all" : "none" }}
- />
- </svg>
- );
-}
-
-export function ShapeOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
- pathData,
- cursor,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
- pathData: string;
- cursor?: string;
- onPointerDown?: (event: React.PointerEvent) => void;
- onPointerMove?: (event: React.PointerEvent) => void;
- onPointerUp?: (event: React.PointerEvent) => void;
-}) {
- return (
- <svg
- className={cn("absolute overflow-visible", cursor)}
- aria-hidden="true"
- focusable="false"
- style={{
- left: center.x - outlineWidth / 2,
- top: center.y - outlineHeight / 2,
- width: outlineWidth,
- height: outlineHeight,
- transform: `rotate(${rotation}deg)`,
- transformOrigin: "center center",
- pointerEvents: onPointerDown ? "auto" : "none",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- >
- <path
- d={pathData}
- fill="transparent"
- stroke="white"
- strokeOpacity={0.75}
- vectorEffect="non-scaling-stroke"
- style={{ pointerEvents: onPointerDown ? "all" : "none" }}
- />
- </svg>
- );
-}
-
-export function LineOverlay({
- start,
- end,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- start: { x: number; y: number };
- end: { x: number; y: number };
- onPointerDown?: (event: React.PointerEvent) => void;
- onPointerMove?: (event: React.PointerEvent) => void;
- onPointerUp?: (event: React.PointerEvent) => void;
-}) {
- const dx = end.x - start.x;
- const dy = end.y - start.y;
- const length = Math.sqrt(dx * dx + dy * dy);
- const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
- const cx = (start.x + end.x) / 2;
- const cy = (start.y + end.y) / 2;
-
- const sharedStyle = {
- left: cx - length / 2,
- width: length,
- transform: `rotate(${angleDeg}deg)`,
- transformOrigin: "center center",
- };
-
- return (
- <>
- {onPointerDown && (
- <div
- className="absolute"
- style={{
- ...sharedStyle,
- top: cy - LINE_HIT_AREA_SIZE / 2,
- height: LINE_HIT_AREA_SIZE,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- />
- )}
- <div
- className="pointer-events-none absolute"
- style={{
- ...sharedStyle,
- top: cy - 0.5,
- height: 1,
- backgroundColor: "white",
- opacity: 0.75,
- }}
- />
- </>
- );
-}
+"use client"; + +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { IconSvgElement } from "@hugeicons/react"; + +export const HANDLE_SIZE = 10; +export const HANDLE_HIT_AREA_SIZE = 18; +export const ICON_HANDLE_RADIUS = 10; +export const EDGE_HANDLE_THIN_SIZE = 6; +export const EDGE_HANDLE_THICK_SIZE = 14; +export const LINE_HIT_AREA_SIZE = 48; + +export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { + const normalized = ((angleDeg % 180) + 180) % 180; + if (normalized < 22.5 || normalized >= 157.5) return "ew-resize"; + if (normalized < 67.5) return "nwse-resize"; + if (normalized < 112.5) return "ns-resize"; + return "nesw-resize"; +} + +export function HandleButton({ + screen, + cursor, + hitAreaSize, + className, + onPointerDown, + onPointerMove, + onPointerUp, + children, +}: { + screen: { x: number; y: number }; + cursor?: string; + hitAreaSize: number; + className?: string; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + children: React.ReactNode; +}) { + return ( + <button + type="button" + className={cn( + "absolute flex items-center justify-center outline-none", + className, + )} + style={{ + left: screen.x - hitAreaSize / 2, + top: screen.y - hitAreaSize / 2, + width: hitAreaSize, + height: hitAreaSize, + pointerEvents: "auto", + cursor, + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} + onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} + > + {children} + </button> + ); +} + +export function CornerHandle({ + cursor, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + cursor?: string; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + <HandleButton + screen={screen} + cursor={cursor} + hitAreaSize={HANDLE_HIT_AREA_SIZE} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + <div + className="rounded-sm bg-white" + style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} + /> + </HandleButton> + ); +} + +export function CircleHandle({ + cursor, + screen, + size = HANDLE_SIZE, + isSelected = false, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + cursor?: string; + screen: { x: number; y: number }; + size?: number; + isSelected?: boolean; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + <HandleButton + screen={screen} + cursor={cursor} + hitAreaSize={HANDLE_HIT_AREA_SIZE} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + <div + className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")} + style={{ width: size, height: size }} + /> + </HandleButton> + ); +} + +export function EdgeHandle({ + edge, + screen, + rotation, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + edge: "right" | "left" | "bottom"; + screen: { x: number; y: number }; + rotation: number; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + const isHorizontalEdge = edge === "right" || edge === "left"; + const width = isHorizontalEdge + ? EDGE_HANDLE_THIN_SIZE + : EDGE_HANDLE_THICK_SIZE; + const height = isHorizontalEdge + ? EDGE_HANDLE_THICK_SIZE + : EDGE_HANDLE_THIN_SIZE; + const cursor = getResizeCursor({ + angleDeg: isHorizontalEdge ? rotation : rotation + 90, + }); + + return ( + <HandleButton + screen={screen} + cursor={cursor} + hitAreaSize={HANDLE_HIT_AREA_SIZE} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + <div + className="rounded-sm bg-white" + style={{ width, height, transform: `rotate(${rotation}deg)` }} + /> + </HandleButton> + ); +} + +export function IconHandle({ + icon, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + icon: IconSvgElement; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + <HandleButton + screen={screen} + hitAreaSize={ICON_HANDLE_RADIUS * 2} + className="rounded-full bg-white text-black shadow-sm" + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} /> + </HandleButton> + ); +} + +export function BoundingBoxOutline({ + center, + outlineWidth, + outlineHeight, + rotation, + cursor, + dashed = false, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; + cursor?: string; + dashed?: boolean; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + return ( + <svg + className="absolute overflow-visible" + aria-hidden="true" + focusable="false" + style={{ + left: center.x - outlineWidth / 2, + top: center.y - outlineHeight / 2, + width: outlineWidth, + height: outlineHeight, + transform: `rotate(${rotation}deg)`, + transformOrigin: "center center", + pointerEvents: onPointerDown ? "auto" : "none", + cursor, + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + > + <rect + x={0.5} + y={0.5} + width={Math.max(outlineWidth - 1, 0)} + height={Math.max(outlineHeight - 1, 0)} + fill="transparent" + stroke="white" + strokeDasharray={dashed ? "4 4" : undefined} + strokeOpacity={0.75} + vectorEffect="non-scaling-stroke" + style={{ pointerEvents: onPointerDown ? "all" : "none" }} + /> + </svg> + ); +} + +export function ShapeOutline({ + center, + outlineWidth, + outlineHeight, + rotation, + pathData, + cursor, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; + pathData: string; + cursor?: string; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + return ( + <svg + className="absolute overflow-visible" + aria-hidden="true" + focusable="false" + style={{ + left: center.x - outlineWidth / 2, + top: center.y - outlineHeight / 2, + width: outlineWidth, + height: outlineHeight, + transform: `rotate(${rotation}deg)`, + transformOrigin: "center center", + pointerEvents: onPointerDown ? "auto" : "none", + cursor, + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + > + <path + d={pathData} + fill="transparent" + stroke="white" + strokeOpacity={0.75} + vectorEffect="non-scaling-stroke" + style={{ pointerEvents: onPointerDown ? "all" : "none" }} + /> + </svg> + ); +} + +export function CanvasPathOutline({ + pathData, + translateX = 0, + translateY = 0, + scaleX = 1, + scaleY = 1, + cursor, + strokeWidth = 1, + strokeOpacity = 0.75, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + pathData: string; + translateX?: number; + translateY?: number; + scaleX?: number; + scaleY?: number; + cursor?: string; + strokeWidth?: number; + strokeOpacity?: number; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + return ( + <svg + className="absolute inset-0 overflow-visible" + aria-hidden="true" + focusable="false" + style={{ + pointerEvents: onPointerDown ? "auto" : "none", + cursor, + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + > + <g + transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`} + > + <path + d={pathData} + fill="transparent" + stroke="white" + strokeWidth={strokeWidth} + strokeOpacity={strokeOpacity} + vectorEffect="non-scaling-stroke" + style={{ pointerEvents: onPointerDown ? "stroke" : "none" }} + /> + </g> + </svg> + ); +} + +export function LineOverlay({ + start, + end, + cursor, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + start: { x: number; y: number }; + end: { x: number; y: number }; + cursor?: string; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const length = Math.sqrt(dx * dx + dy * dy); + const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; + const cx = (start.x + end.x) / 2; + const cy = (start.y + end.y) / 2; + + const sharedStyle = { + left: cx - length / 2, + width: length, + transform: `rotate(${angleDeg}deg)`, + transformOrigin: "center center", + }; + + return ( + <> + {onPointerDown && ( + <div + className="absolute" + style={{ + ...sharedStyle, + top: cy - LINE_HIT_AREA_SIZE / 2, + height: LINE_HIT_AREA_SIZE, + pointerEvents: "auto", + cursor, + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + /> + )} + <div + className="pointer-events-none absolute" + style={{ + ...sharedStyle, + top: cy - 0.5, + height: 1, + backgroundColor: "white", + opacity: 0.75, + }} + /> + </> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx index 0c11f27a..d32b6ff9 100644 --- a/apps/web/src/components/editor/panels/preview/mask-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx @@ -1,31 +1,23 @@ "use client"; +import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useMaskHandles } from "@/hooks/use-mask-handles"; -import { masksRegistry } from "@/lib/masks"; import type { SnapLine } from "@/lib/preview/preview-snap"; -import type { ParamValues } from "@/lib/params"; -import type { RectangleMaskParams } from "@/lib/masks/types"; import { CornerHandle, + CircleHandle, + CanvasPathOutline, EdgeHandle, IconHandle, LineOverlay, BoundingBoxOutline, ShapeOutline, } from "./handle-primitives"; -import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; -function hasRectangleOutlineParams( - params: ParamValues, -): params is RectangleMaskParams { - return ( - typeof params.centerX === "number" && - typeof params.centerY === "number" && - typeof params.width === "number" && - typeof params.height === "number" - ); -} +const CUSTOM_MASK_ANCHOR_SIZE = 7; +const CUSTOM_MASK_TANGENT_SIZE = 6; +import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; export function MaskHandles({ onSnapLinesChange, @@ -36,7 +28,9 @@ export function MaskHandles({ const { selectedWithMask, handlePositions, - linePoints, + overlays, + isCreatingCustomMask, + handleCanvasPointerDown, handlePointerDown, handlePointerMove, handlePointerUp, @@ -56,96 +50,158 @@ export function MaskHandles({ canvasY, }); - const def = masksRegistry.get(selectedWithMask.mask.type); - const { bounds } = selectedWithMask; - const maskRotation = selectedWithMask.mask.params.rotation; - const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); + const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 }); - const rectangleOutlineProps = hasRectangleOutlineParams( - selectedWithMask.mask.params, - ) - ? { - center: toOverlay({ - canvasX: - bounds.cx + selectedWithMask.mask.params.centerX * bounds.width, - canvasY: - bounds.cy + selectedWithMask.mask.params.centerY * bounds.height, - }), - outlineWidth: - selectedWithMask.mask.params.width * bounds.width * scaleX, - outlineHeight: - selectedWithMask.mask.params.height * bounds.height * scaleY, - rotation: maskRotation, - } - : null; + const onPointerMove = (event: React.PointerEvent) => { + if (viewport.handlePanPointerMove({ event })) { + return; + } - const onPointerMove = (event: React.PointerEvent) => handlePointerMove({ event }); - const onPointerUp = () => handlePointerUp(); + }; + const onPointerUp = (event: React.PointerEvent) => { + if (viewport.handlePanPointerUp({ event })) { + return; + } + + handlePointerUp(); + }; + const handleMaskPointerDown = ({ + event, + handleId, + }: { + event: React.PointerEvent; + handleId: string; + }) => { + if (viewport.handlePanPointerDown({ event })) { + return; + } + + handlePointerDown({ event, handleId }); + }; + const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => { + if (viewport.handlePanPointerDown({ event })) { + return; + } + + handleCanvasPointerDown({ event }); + }; return ( <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden > - {def.overlayShape === "line" && linePoints && ( - <LineOverlay - start={toOverlay({ - canvasX: linePoints.start.x, - canvasY: linePoints.start.y, - })} - end={toOverlay({ - canvasX: linePoints.end.x, - canvasY: linePoints.end.y, - })} - onPointerDown={(event) => - handlePointerDown({ event, handleId: "position" }) - } + {isCreatingCustomMask ? ( + <div + className="absolute inset-0 pointer-events-auto" + style={{ cursor: PEN_CURSOR }} + onPointerDown={handleCanvasOverlayPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} /> - )} - {def.overlayShape === "box" && rectangleOutlineProps && ( - def.buildOverlayPath ? ( - <> - <BoundingBoxOutline {...rectangleOutlineProps} dashed /> - <ShapeOutline - {...rectangleOutlineProps} - pathData={def.buildOverlayPath({ - width: rectangleOutlineProps.outlineWidth, - height: rectangleOutlineProps.outlineHeight, - })} - onPointerDown={(event) => - handlePointerDown({ event, handleId: "position" }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - </> - ) : ( + ) : null} + {overlays.map((overlay) => { + const overlayHandleId = overlay.handleId; + const pointerHandlers = overlayHandleId + ? { + onPointerDown: (event: React.PointerEvent) => + handleMaskPointerDown({ event, handleId: overlayHandleId }), + onPointerMove, + onPointerUp, + } + : {}; + + if (overlay.type === "line") { + return ( + <LineOverlay + key={overlay.id} + cursor={overlay.cursor} + start={toOverlay({ + canvasX: overlay.start.x, + canvasY: overlay.start.y, + })} + end={toOverlay({ + canvasX: overlay.end.x, + canvasY: overlay.end.y, + })} + {...pointerHandlers} + /> + ); + } + + if (overlay.type === "rect") { + return ( <BoundingBoxOutline - {...rectangleOutlineProps} - cursor="cursor-move" - onPointerDown={(event) => - handlePointerDown({ event, handleId: "position" }) + key={overlay.id} + center={toOverlay({ + canvasX: overlay.center.x, + canvasY: overlay.center.y, + })} + outlineWidth={overlay.width * scaleX} + outlineHeight={overlay.height * scaleY} + rotation={overlay.rotation} + cursor={overlay.cursor} + dashed={overlay.dashed} + {...pointerHandlers} + /> + ); + } + + if (overlay.type === "shape") { + return ( + <ShapeOutline + key={overlay.id} + center={toOverlay({ + canvasX: overlay.center.x, + canvasY: overlay.center.y, + })} + outlineWidth={overlay.width * scaleX} + outlineHeight={overlay.height * scaleY} + rotation={overlay.rotation} + pathData={overlay.pathData} + cursor={overlay.cursor} + {...pointerHandlers} + /> + ); + } + + if (overlay.type === "canvas-path") { + return ( + <CanvasPathOutline + key={overlay.id} + pathData={overlay.pathData} + translateX={ + overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0 } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} + translateY={ + overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0 + } + scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1} + scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1} + cursor={overlay.cursor} + strokeWidth={overlay.strokeWidth} + strokeOpacity={overlay.strokeOpacity} + {...pointerHandlers} /> - ) - )} + ); + } + + return null; + })} {handlePositions.map((handle) => { const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); - if (handle.id === "rotation") { + if (handle.kind === "icon" && handle.icon === "rotate") { return ( <IconHandle key={handle.id} icon={Rotate01Icon} screen={screen} onPointerDown={(event) => - handlePointerDown({ event, handleId: handle.id }) + handleMaskPointerDown({ event, handleId: handle.id }) } onPointerMove={onPointerMove} onPointerUp={onPointerUp} @@ -153,14 +209,14 @@ export function MaskHandles({ ); } - if (handle.id === "feather") { + if (handle.kind === "icon" && handle.icon === "feather") { return ( <IconHandle key={handle.id} icon={FeatherIcon} screen={screen} onPointerDown={(event) => - handlePointerDown({ event, handleId: handle.id }) + handleMaskPointerDown({ event, handleId: handle.id }) } onPointerMove={onPointerMove} onPointerUp={onPointerUp} @@ -168,15 +224,15 @@ export function MaskHandles({ ); } - if (handle.id === "right" || handle.id === "left") { + if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { return ( <EdgeHandle key={handle.id} edge="right" screen={screen} - rotation={maskRotation} + rotation={handle.rotation ?? 0} onPointerDown={(event) => - handlePointerDown({ event, handleId: handle.id }) + handleMaskPointerDown({ event, handleId: handle.id }) } onPointerMove={onPointerMove} onPointerUp={onPointerUp} @@ -184,15 +240,35 @@ export function MaskHandles({ ); } - if (handle.id === "bottom" || handle.id === "top") { + if (handle.kind === "edge" && handle.edgeAxis === "vertical") { return ( <EdgeHandle key={handle.id} edge="bottom" screen={screen} - rotation={maskRotation} + rotation={handle.rotation ?? 0} + onPointerDown={(event) => + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.kind === "point" || handle.kind === "tangent") { + return ( + <CircleHandle + key={handle.id} + screen={screen} + size={ + handle.kind === "tangent" + ? CUSTOM_MASK_TANGENT_SIZE + : CUSTOM_MASK_ANCHOR_SIZE + } + isSelected={handle.isSelected} onPointerDown={(event) => - handlePointerDown({ event, handleId: handle.id }) + handleMaskPointerDown({ event, handleId: handle.id }) } onPointerMove={onPointerMove} onPointerUp={onPointerUp} @@ -200,19 +276,13 @@ export function MaskHandles({ ); } - if ( - handle.id === "top-left" || - handle.id === "top-right" || - handle.id === "bottom-left" || - handle.id === "bottom-right" || - handle.id === "scale" - ) { + if (handle.kind === "corner") { return ( <CornerHandle key={handle.id} screen={screen} onPointerDown={(event) => - handlePointerDown({ event, handleId: handle.id }) + handleMaskPointerDown({ event, handleId: handle.id }) } onPointerMove={onPointerMove} onPointerUp={onPointerUp} @@ -226,7 +296,7 @@ export function MaskHandles({ cursor={handle.cursor} screen={screen} onPointerDown={(event) => - handlePointerDown({ event, handleId: handle.id }) + handleMaskPointerDown({ event, handleId: handle.id }) } onPointerMove={onPointerMove} onPointerUp={onPointerUp} diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index 44281ade..eabbde8a 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -4,14 +4,12 @@ import { useCallback, useEffect, useRef } from "react"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useEditor } from "@/hooks/use-editor"; import type { TextElement } from "@/lib/timeline"; -import { - FONT_SIZE_SCALE_REFERENCE, -} from "@/lib/text/typography"; import { DEFAULTS } from "@/lib/timeline/defaults"; import { getElementLocalTime, resolveTransformAtTime, } from "@/lib/animation"; +import { resolveTextLayout } from "@/lib/text/primitives"; export function TextEditOverlay({ trackId, @@ -82,20 +80,29 @@ export function TextEditOverlay({ }); const { x: displayScaleX } = viewport.getDisplayScale(); - - const scaledFontSize = - element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); + const resolvedTextLayout = resolveTextLayout({ + text: { + content: element.content, + fontSize: element.fontSize, + fontFamily: element.fontFamily, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textAlign: element.textAlign, + textDecoration: element.textDecoration, + letterSpacing: element.letterSpacing, + lineHeight: element.lineHeight, + }, + canvasHeight: canvasSize.height, + }); const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; const canvasLetterSpacing = element.letterSpacing ?? 0; - const lineHeightPx = scaledFontSize * lineHeight; + const lineHeightPx = resolvedTextLayout.lineHeightPx; const bg = element.background; const shouldShowBackground = bg.enabled && bg.color && bg.color !== "transparent"; - const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const fontSizeRatio = resolvedTextLayout.fontSizeRatio; const canvasPaddingX = shouldShowBackground ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio : 0; @@ -123,10 +130,10 @@ export function TextEditOverlay({ aria-label="Edit text" className="cursor-text select-text outline-none whitespace-pre" style={{ - fontSize: scaledFontSize, + fontSize: resolvedTextLayout.scaledFontSize, fontFamily: element.fontFamily, - fontWeight, - fontStyle, + fontWeight: element.fontWeight === "bold" ? "bold" : "normal", + fontStyle: element.fontStyle === "italic" ? "italic" : "normal", textAlign: element.textAlign, letterSpacing: `${canvasLetterSpacing}px`, lineHeight, |
