From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: feat: text / custom masks, fix preview pan, refactor selection --- .../components/editor/panels/preview/cursors.ts | 21 + .../editor/panels/preview/handle-primitives.tsx | 759 ++++++++++++--------- .../editor/panels/preview/mask-handles.tsx | 266 +++++--- .../editor/panels/preview/text-edit-overlay.tsx | 33 +- 4 files changed, 636 insertions(+), 443 deletions(-) create mode 100644 apps/web/src/components/editor/panels/preview/cursors.ts (limited to 'apps/web/src/components/editor/panels/preview') 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: ` + + +`, + 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 ( - - ); -} - -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 ( - -
- - ); -} - -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 ( - -
- - ); -} - -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 ( - - - - ); -} - -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 ( - - ); -} - -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 ( - - ); -} - -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 && ( -
- )} -
- - ); -} +"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 ( + + ); +} + +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 ( + +
+ + ); +} + +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 ( + +
+ + ); +} + +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 ( + +
+ + ); +} + +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 ( + + + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 && ( +
+ )} +
+ + ); +} 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 onPointerMove = (event: React.PointerEvent) => { + if (viewport.handlePanPointerMove({ event })) { + return; + } - 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) => 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 (
- {def.overlayShape === "line" && linePoints && ( - - handlePointerDown({ event, handleId: "position" }) - } + {isCreatingCustomMask ? ( +
- )} - {def.overlayShape === "box" && rectangleOutlineProps && ( - def.buildOverlayPath ? ( - <> - - - 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 ( + + ); + } + + if (overlay.type === "rect") { + return ( - 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 ( + + ); + } + + if (overlay.type === "canvas-path") { + return ( + - ) - )} + ); + } + + 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 ( - 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 ( - 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 ( - 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 ( + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.kind === "point" || handle.kind === "tangent") { + return ( + - 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 ( - 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, -- cgit v1.3.1