From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: [PATCH] feat: text / custom masks, fix preview pan, refactor selection --- .../assets/views/settings/background.tsx | 542 +++++---- .../editor/panels/preview/cursors.ts | 21 + .../panels/preview/handle-primitives.tsx | 759 ++++++------ .../editor/panels/preview/mask-handles.tsx | 268 +++-- .../panels/preview/text-edit-overlay.tsx | 33 +- .../panels/properties/tabs/masks-tab.tsx | 148 ++- .../editor/panels/timeline/audio-waveform.tsx | 48 +- .../panels/timeline/timeline-element.tsx | 18 +- apps/web/src/components/ui/color-picker.tsx | 1029 +++++++++-------- apps/web/src/core/managers/commands.ts | 34 +- .../src/core/managers/selection-manager.ts | 268 +++-- .../web/src/core/managers/timeline-manager.ts | 52 + .../src/hooks/actions/use-editor-actions.ts | 59 +- apps/web/src/hooks/use-mask-handles.ts | 893 +++++++++----- apps/web/src/lib/actions/definitions.ts | 418 +++---- apps/web/src/lib/changelog/entries/0.4.0.md | 6 +- apps/web/src/lib/commands/base-command.ts | 4 +- .../timeline/element/delete-elements.ts | 7 +- .../timeline/element/insert-element.ts | 33 +- .../masks/delete-custom-mask-points.ts | 131 +++ .../commands/timeline/element/masks/index.ts | 2 + .../element/masks/insert-custom-mask-point.ts | 169 +++ apps/web/src/lib/masks/__tests__/snap.test.ts | 252 +++- apps/web/src/lib/masks/custom-path.ts | 842 ++++++++++++++ .../web/src/lib/masks/definitions/box-like.ts | 583 +++++----- .../lib/masks/definitions/cinematic-bars.ts | 11 +- apps/web/src/lib/masks/definitions/custom.ts | 652 +++++++++++ apps/web/src/lib/masks/definitions/diamond.ts | 11 +- apps/web/src/lib/masks/definitions/ellipse.ts | 147 +-- apps/web/src/lib/masks/definitions/heart.ts | 41 +- apps/web/src/lib/masks/definitions/index.ts | 11 + .../src/lib/masks/definitions/rectangle.ts | 191 +-- apps/web/src/lib/masks/definitions/split.ts | 729 ++++++------ apps/web/src/lib/masks/definitions/star.ts | 11 +- apps/web/src/lib/masks/definitions/text.ts | 438 +++++++ apps/web/src/lib/masks/geometry.ts | 233 ++-- apps/web/src/lib/masks/handle-positions.ts | 879 ++++++++------ apps/web/src/lib/masks/param-update.ts | 85 +- apps/web/src/lib/masks/registry.ts | 60 +- apps/web/src/lib/masks/snap.ts | 671 +++++------ apps/web/src/lib/masks/types.ts | 173 ++- .../web/src/lib/selection/editor-selection.ts | 25 + apps/web/src/lib/selection/scope.ts | 59 +- apps/web/src/lib/text/measure-element.ts | 67 +- apps/web/src/lib/text/primitives.ts | 241 ++++ apps/web/src/lib/timeline/audio-state.ts | 14 + apps/web/src/lib/timeline/element-utils.ts | 8 +- .../renderer/compositor/frame-descriptor.ts | 36 +- .../src/services/renderer/nodes/text-node.ts | 89 +- .../migrations/__tests__/v26-to-v27.test.ts | 83 ++ .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v26-to-v27.ts | 114 ++ .../services/storage/migrations/v26-to-v27.ts | 16 + 53 files changed, 8082 insertions(+), 3636 deletions(-) create mode 100644 apps/web/src/components/editor/panels/preview/cursors.ts create mode 100644 apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts create mode 100644 apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts create mode 100644 apps/web/src/lib/masks/custom-path.ts create mode 100644 apps/web/src/lib/masks/definitions/custom.ts create mode 100644 apps/web/src/lib/masks/definitions/text.ts create mode 100644 apps/web/src/lib/selection/editor-selection.ts create mode 100644 apps/web/src/lib/text/primitives.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts create mode 100644 apps/web/src/services/storage/migrations/v26-to-v27.ts diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx index 98847453..099e335a 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -1,233 +1,309 @@ -"use client"; - -import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - BACKGROUND_BLUR_INTENSITY_PRESETS, - DEFAULT_BACKGROUND_BLUR_INTENSITY, -} from "@/lib/background/blur"; -import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color"; -import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { colors } from "@/data/colors/solid"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { useEditor } from "@/hooks/use-editor"; -import { effectPreviewService } from "@/services/renderer/effect-preview"; -import { cn } from "@/utils/ui"; - -const BLUR_PREVIEW_UNIFORM_DIMENSIONS = { - width: 1920, - height: 1080, -} as const; - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => { - const canvasRef = useRef(null); - - useEffect(() => { - const renderPreview = () => { - if (!canvasRef.current) return; - - effectPreviewService.renderPreview({ - effectType: "blur", - params: { intensity: blur.value }, - targetCanvas: canvasRef.current, - uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS, - }); - }; - - renderPreview(); - return effectPreviewService.onPreviewImageReady({ - callback: renderPreview, - }); - }, [blur.value]); - - return ( - - ); - }, -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - onSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - onSelect: (bg: string) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg) => ( - + ); + }, +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + onSelect, + useBackgroundColor = false, + }: { + backgrounds: readonly string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + onSelect: (bg: string) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg) => ( + + + onPreview(`#${color}`)} + onChangeEnd={(color) => onCommit(`#${color}`)} + /> + + ); +} + +const COLOR_SECTIONS = [ + { id: "colors", title: "Colors", backgrounds: colors, useBackgroundColor: true, showCustomPicker: true }, + { id: "pattern-craft", title: "Pattern craft", backgrounds: patternCraftGradients, showCustomPicker: false }, + { id: "syntax-ui", title: "Syntax UI", backgrounds: syntaxUIGradients, showCustomPicker: false }, +] as const; + +export function BackgroundContent() { + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + + const handleBlurSelect = useCallback( + async (blurIntensity: number) => { + await editor.project.updateSettings({ + settings: { background: { type: "blur", blurIntensity } }, + }); + }, + [editor.project], + ); + + const previewBackgroundColor = useCallback( + async (color: string) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + pushHistory: false, + }); + }, + [editor.project], + ); + + const commitBackgroundColor = useCallback( + async (color: string) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + pushHistory: true, + }); + }, + [editor.project], + ); + + const isBlurBackground = activeProject.settings.background.type === "blur"; + const isColorBackground = activeProject.settings.background.type === "color"; + + const currentBlurIntensity = isBlurBackground + ? (activeProject.settings.background as { blurIntensity: number }) + .blurIntensity + : DEFAULT_BACKGROUND_BLUR_INTENSITY; + + const currentBackgroundColor = isColorBackground + ? (activeProject.settings.background as { color: string }).color + : DEFAULT_BACKGROUND_COLOR; + + const hasPresetColorMatch = colors.some( + (color) => color.toLowerCase() === currentBackgroundColor.toLowerCase(), + ); + + const handlePresetColorSelect = useCallback( + (color: string) => { + void commitBackgroundColor(color); + }, + [commitBackgroundColor], + ); + + const blurPreviews = useMemo( + () => + BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => ( + handleBlurSelect(blur.value)} + /> + )), + [isBlurBackground, currentBlurIntensity, handleBlurSelect], + ); + + return ( +
+
+ + Blur + + +
{blurPreviews}
+
+
+ {COLOR_SECTIONS.map((section) => ( +
+ + {section.title} + + +
+ {section.showCustomPicker ? ( + + ) : null} + +
+
+
+ ))} +
+ ); +} 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 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 (
- {def.overlayShape === "line" && linePoints && ( - - handlePointerDown({ event, handleId: "position" }) - } + {isCreatingCustomMask ? ( +
- )} - {def.overlayShape === "box" && rectangleOutlineProps && ( - def.buildOverlayPath ? ( - <> - - - handlePointerDown({ event, handleId: "position" }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - - ) : ( - - 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 ( + + ); + } + + 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,15 @@ export function MaskHandles({ ); } - if (handle.id === "bottom" || handle.id === "top") { + if (handle.kind === "edge" && handle.edgeAxis === "vertical") { return ( - handlePointerDown({ event, handleId: handle.id }) + handleMaskPointerDown({ event, handleId: handle.id }) } onPointerMove={onPointerMove} onPointerUp={onPointerUp} @@ -200,19 +256,33 @@ 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 === "point" || handle.kind === "tangent") { + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + 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, diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx index cfc69720..090fce64 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -1,8 +1,11 @@ "use client"; import type { MaskableElement } from "@/lib/timeline"; -import type { Mask, MaskType } from "@/lib/masks/types"; -import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params"; +import type { Mask, MaskType, TextMask } from "@/lib/masks/types"; +import type { + NumberParamDefinition, + SelectParamDefinition, +} from "@/lib/params"; import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks"; import { useEditor } from "@/hooks/use-editor"; import { useElementPreview } from "@/hooks/use-element-preview"; @@ -10,14 +13,17 @@ import { useMenuPreview } from "@/hooks/use-menu-preview"; import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; import { HugeiconsIcon } from "@hugeicons/react"; import { + ArrowExpandIcon, Delete02Icon, FeatherIcon, PlusSignIcon, RotateClockwiseIcon, + TextFontIcon, } from "@hugeicons/core-free-icons"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { ColorPicker } from "@/components/ui/color-picker"; +import { FontPicker } from "@/components/ui/font-picker"; import { DropdownMenu, DropdownMenuContent, @@ -32,6 +38,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, @@ -52,7 +59,12 @@ import { SectionTitle, } from "@/components/section"; import { usePropertyDraft } from "../hooks/use-property-draft"; -import { OcMirrorIcon, OcShapesIcon } from "@/components/icons"; +import { + OcMirrorIcon, + OcShapesIcon, + OcTextHeightIcon, + OcTextWidthIcon, +} from "@/components/icons"; import { cn } from "@/utils/ui"; type MasksTabProps = { @@ -78,6 +90,10 @@ type PreviewParamHandler = ( type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; +function isTextMask(mask: Mask): mask is TextMask { + return mask.type === "text"; +} + export function MasksTab({ element, trackId }: MasksTabProps) { const editor = useEditor(); const { renderElement, previewUpdates, commit } = @@ -370,12 +386,23 @@ function MaskParamsFields({ previewParam(key)(value); const previewStrokeColor = previewParam("strokeColor"); const strokeAlignParam = definition.params.find( - (param): param is SelectParamDefinition => + (param): param is SelectParamDefinition => param.key === "strokeAlign" && param.type === "select", ); return ( + {isTextMask(mask) ? ( + + ) : null} {definition.features.hasPosition && "centerX" in mask.params && "centerY" in mask.params && ( @@ -491,7 +518,9 @@ function MaskParamsFields({ {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( : "S" + } param={getNumberParamDefinition({ definition, key: "scale", @@ -587,6 +616,100 @@ function MaskParamsFields({ ); } +const LETTER_SPACING_PARAM: NumberParamDefinition = { + key: "letterSpacing", + label: "Letter spacing", + type: "number", + default: 0, + min: -100, + max: 500, + step: 1, +}; + +const LINE_HEIGHT_PARAM: NumberParamDefinition = { + key: "lineHeight", + label: "Line height", + type: "number", + default: 1.2, + min: 0.1, + max: 10, + step: 0.1, +}; + +function TextMaskFields({ + mask, + previewParam, + onCommit, + fontSizeParam, +}: { + mask: TextMask; + previewParam: PreviewParamHandler; + onCommit: () => void; + fontSizeParam: NumberParamDefinition; +}) { + const content = usePropertyDraft({ + displayValue: mask.params.content, + parse: (input) => input, + onPreview: (value) => previewParam("content")(value), + onCommit, + }); + + const previewNumberParam = (key: string) => (value: number) => + previewParam(key)(value); + + return ( + <> + +