summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 15:45:50 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 15:45:50 +0200
commitc46d28891fee62ca2d3132da876af6411a41d9da (patch)
treef856f71d04c69628f26b904088d675dd298a5080 /apps/web
parent66e4367bc4727f1d0bcf8f869529c7518812277e (diff)
downloadopencut-c46d28891fee62ca2d3132da876af6411a41d9da.tar.gz
feat: text / custom masks, fix preview pan, refactor selection
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx542
-rw-r--r--apps/web/src/components/editor/panels/preview/cursors.ts21
-rw-r--r--apps/web/src/components/editor/panels/preview/handle-primitives.tsx759
-rw-r--r--apps/web/src/components/editor/panels/preview/mask-handles.tsx264
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx33
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx148
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx48
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx18
-rw-r--r--apps/web/src/components/ui/color-picker.tsx1029
-rw-r--r--apps/web/src/core/managers/commands.ts34
-rw-r--r--apps/web/src/core/managers/selection-manager.ts268
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts52
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts59
-rw-r--r--apps/web/src/hooks/use-mask-handles.ts893
-rw-r--r--apps/web/src/lib/actions/definitions.ts418
-rw-r--r--apps/web/src/lib/changelog/entries/0.4.0.md6
-rw-r--r--apps/web/src/lib/commands/base-command.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts7
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts33
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts131
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts169
-rw-r--r--apps/web/src/lib/masks/__tests__/snap.test.ts252
-rw-r--r--apps/web/src/lib/masks/custom-path.ts842
-rw-r--r--apps/web/src/lib/masks/definitions/box-like.ts583
-rw-r--r--apps/web/src/lib/masks/definitions/cinematic-bars.ts11
-rw-r--r--apps/web/src/lib/masks/definitions/custom.ts652
-rw-r--r--apps/web/src/lib/masks/definitions/diamond.ts11
-rw-r--r--apps/web/src/lib/masks/definitions/ellipse.ts147
-rw-r--r--apps/web/src/lib/masks/definitions/heart.ts41
-rw-r--r--apps/web/src/lib/masks/definitions/index.ts11
-rw-r--r--apps/web/src/lib/masks/definitions/rectangle.ts191
-rw-r--r--apps/web/src/lib/masks/definitions/split.ts729
-rw-r--r--apps/web/src/lib/masks/definitions/star.ts11
-rw-r--r--apps/web/src/lib/masks/definitions/text.ts438
-rw-r--r--apps/web/src/lib/masks/geometry.ts233
-rw-r--r--apps/web/src/lib/masks/handle-positions.ts879
-rw-r--r--apps/web/src/lib/masks/param-update.ts85
-rw-r--r--apps/web/src/lib/masks/registry.ts60
-rw-r--r--apps/web/src/lib/masks/snap.ts671
-rw-r--r--apps/web/src/lib/masks/types.ts173
-rw-r--r--apps/web/src/lib/selection/editor-selection.ts25
-rw-r--r--apps/web/src/lib/selection/scope.ts59
-rw-r--r--apps/web/src/lib/text/measure-element.ts67
-rw-r--r--apps/web/src/lib/text/primitives.ts241
-rw-r--r--apps/web/src/lib/timeline/audio-state.ts14
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts8
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts36
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts89
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts83
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts114
-rw-r--r--apps/web/src/services/storage/migrations/v26-to-v27.ts16
53 files changed, 8080 insertions, 3634 deletions
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<HTMLCanvasElement>(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 (
- <button
- className={cn(
- "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
- isSelected && "border-primary border-2",
- )}
- onClick={onSelect}
- type="button"
- aria-label={`Select ${blur.label} blur`}
- >
- <canvas
- ref={canvasRef}
- className="absolute inset-0 h-full w-full object-cover"
- />
- <div className="absolute right-1 bottom-1 left-1 text-center">
- <span className="rounded bg-black/50 px-1 text-xs text-white">
- {blur.label}
- </span>
- </div>
- </button>
- );
- },
-);
-
-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) => (
- <button
- key={bg}
- className={cn(
- "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
- isColorBackground &&
- bg === currentBackgroundColor &&
- "border-primary border-2",
- )}
- style={
- useBackgroundColor
- ? { backgroundColor: bg }
- : {
- background: bg,
- backgroundSize: "cover",
- backgroundPosition: "center",
- backgroundRepeat: "no-repeat",
- }
- }
- onClick={() => onSelect(bg)}
- type="button"
- aria-label={`Select background ${bg}`}
- />
- )),
- [
- backgrounds,
- isColorBackground,
- currentBackgroundColor,
- onSelect,
- useBackgroundColor,
- ],
- );
- },
-);
-
-BackgroundPreviews.displayName = "BackgroundPreviews";
-
-const COLOR_SECTIONS = [
- { title: "Colors", backgrounds: colors, useBackgroundColor: true },
- { title: "Pattern craft", backgrounds: patternCraftGradients },
- { title: "Syntax UI", backgrounds: syntaxUIGradients },
-] 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 handleColorSelect = useCallback(
- async (color: string) => {
- await editor.project.updateSettings({
- settings: { background: { type: "color", color } },
- });
- },
- [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 blurPreviews = useMemo(
- () =>
- BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
- <BlurPreview
- key={blur.value}
- blur={blur}
- isSelected={isBlurBackground && currentBlurIntensity === blur.value}
- onSelect={() => handleBlurSelect(blur.value)}
- />
- )),
- [isBlurBackground, currentBlurIntensity, handleBlurSelect],
- );
-
- return (
- <div className="flex flex-col">
- <Section
- collapsible
- defaultOpen={true}
- sectionKey="background-blur"
- showTopBorder={false}
- >
- <SectionHeader>
- <SectionTitle>Blur</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex flex-wrap gap-2">{blurPreviews}</div>
- </SectionContent>
- </Section>
- {COLOR_SECTIONS.map((section) => (
- <Section
- key={section.title}
- collapsible
- defaultOpen={false}
- sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
- >
- <SectionHeader>
- <SectionTitle>{section.title}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex flex-wrap gap-2">
- <BackgroundPreviews
- backgrounds={section.backgrounds as string[]}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- onSelect={handleColorSelect}
- useBackgroundColor={
- "useBackgroundColor" in section
- ? section.useBackgroundColor
- : false
- }
- />
- </div>
- </SectionContent>
- </Section>
- ))}
- </div>
- );
-}
+"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { ColorPickerContent } from "@/components/ui/color-picker";
+import { Popover, PopoverTrigger } from "@/components/ui/popover";
+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 CUSTOM_COLOR_SWATCH_BACKGROUND =
+ "conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
+
+const BlurPreview = memo(
+ ({
+ blur,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(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 (
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ onClick={onSelect}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+ <div className="absolute right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ );
+ },
+);
+
+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) => (
+ <button
+ key={bg}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
+ isColorBackground &&
+ bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
+ "border-primary border-2",
+ )}
+ style={
+ useBackgroundColor
+ ? { backgroundColor: bg }
+ : {
+ background: bg,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ }
+ }
+ onClick={() => onSelect(bg)}
+ type="button"
+ aria-label={`Select background ${bg}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ onSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+function CustomColorPreview({
+ currentBackgroundColor,
+ isSelected,
+ onPreview,
+ onCommit,
+}: {
+ currentBackgroundColor: string;
+ isSelected: boolean;
+ onPreview: (color: string) => void;
+ onCommit: (color: string) => void;
+}) {
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ type="button"
+ aria-label="Pick a custom background color"
+ >
+ <span
+ className="absolute inset-0"
+ style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
+ />
+ <span
+ className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
+ style={{ backgroundColor: currentBackgroundColor }}
+ />
+ </button>
+ </PopoverTrigger>
+ <ColorPickerContent
+ value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
+ onChange={(color) => onPreview(`#${color}`)}
+ onChangeEnd={(color) => onCommit(`#${color}`)}
+ />
+ </Popover>
+ );
+}
+
+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) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect(blur.value)}
+ />
+ )),
+ [isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+ <div className="flex flex-col">
+ <Section
+ collapsible
+ defaultOpen={true}
+ sectionKey="background-blur"
+ showTopBorder={false}
+ >
+ <SectionHeader>
+ <SectionTitle>Blur</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
+ </SectionContent>
+ </Section>
+ {COLOR_SECTIONS.map((section) => (
+ <Section
+ key={section.id}
+ collapsible
+ defaultOpen={false}
+ sectionKey={`settings:background-${section.id}`}
+ >
+ <SectionHeader>
+ <SectionTitle>{section.title}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">
+ {section.showCustomPicker ? (
+ <CustomColorPreview
+ currentBackgroundColor={currentBackgroundColor}
+ isSelected={isColorBackground && !hasPresetColorMatch}
+ onPreview={previewBackgroundColor}
+ onCommit={commitBackgroundColor}
+ />
+ ) : null}
+ <BackgroundPreviews
+ backgrounds={section.backgrounds}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ onSelect={handlePresetColorSelect}
+ useBackgroundColor={
+ "useBackgroundColor" in section
+ ? section.useBackgroundColor
+ : false
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ ))}
+ </div>
+ );
+}
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,
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<string> =>
param.key === "strokeAlign" && param.type === "select",
);
return (
<SectionFields>
+ {isTextMask(mask) ? (
+ <TextMaskFields
+ mask={mask}
+ previewParam={previewParam}
+ onCommit={onCommit}
+ fontSizeParam={getNumberParamDefinition({
+ definition,
+ key: "fontSize",
+ })}
+ />
+ ) : 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 && (
<SectionField label="Scale">
<MaskNumberField
- icon="S"
+ icon={
+ isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "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 (
+ <>
+ <SectionField label="Content">
+ <Textarea
+ value={content.displayValue}
+ className="min-h-20"
+ onFocus={content.onFocus}
+ onChange={content.onChange}
+ onBlur={content.onBlur}
+ />
+ </SectionField>
+ <SectionField label="Font">
+ <FontPicker
+ defaultValue={mask.params.fontFamily}
+ onValueChange={(value) => {
+ previewParam("fontFamily")(value);
+ onCommit();
+ }}
+ />
+ </SectionField>
+ <SectionField label="Size">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ param={fontSizeParam}
+ value={mask.params.fontSize}
+ onPreview={previewNumberParam("fontSize")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ <SectionField label="Spacing">
+ <div className="flex items-start gap-2">
+ <MaskNumberField
+ className="w-1/2"
+ icon={<OcTextWidthIcon size={14} />}
+ param={LETTER_SPACING_PARAM}
+ value={mask.params.letterSpacing ?? 0}
+ onPreview={previewNumberParam("letterSpacing")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="w-1/2"
+ icon={<OcTextHeightIcon size={14} />}
+ param={LINE_HEIGHT_PARAM}
+ value={mask.params.lineHeight ?? 1.2}
+ onPreview={previewNumberParam("lineHeight")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ </>
+ );
+}
+
function getNumberParamDefinition({
definition,
key,
@@ -603,13 +726,10 @@ function getNumberParamDefinition({
return param;
}
-function getMaskNumber({
- params,
- key,
-}: {
- params: Mask["params"];
- key: string;
-}): number {
+function getMaskNumber<
+ TParams extends Mask["params"],
+ TKey extends keyof TParams & string,
+>({ params, key }: { params: TParams; key: TKey }): number {
const value = params[key];
if (typeof value !== "number") {
@@ -665,7 +785,9 @@ function MaskNumberField({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
- return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
+ return (
+ clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
+ );
},
onPreview,
onCommit,
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
index a481f1c2..430575e1 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -9,9 +9,34 @@ import { cn } from "@/utils/ui";
const BAR_WIDTH = 2;
const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP;
+export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
+
+function sampleGain({
+ samples,
+ startFraction,
+ endFraction,
+ barIndex,
+ barCount,
+}: {
+ samples: number[];
+ startFraction: number;
+ endFraction: number;
+ barIndex: number;
+ barCount: number;
+}): number {
+ if (samples.length === 0) return 1;
+ const progress =
+ startFraction +
+ ((barIndex + 0.5) / barCount) * (endFraction - startFraction);
+ const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
+ const lo = Math.floor(rawIndex);
+ const hi = Math.min(samples.length - 1, lo + 1);
+ return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
+}
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
+ gainSamples?: number[];
color?: string;
className?: string;
}
@@ -19,6 +44,7 @@ interface AudioWaveformProps {
export function AudioWaveform({
audioUrl,
audioBuffer,
+ gainSamples,
color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
@@ -26,6 +52,7 @@ export function AudioWaveform({
const containerRef = useRef<HTMLDivElement>(null);
const bufferRef = useRef<AudioBuffer | null>(null);
const globalMaxRef = useRef<number>(1);
+ const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
const scrollParentRef = useRef<HTMLElement | null>(null);
const heightRef = useRef<number>(0);
@@ -99,20 +126,37 @@ export function AudioWaveform({
const maxBarHeight = height * 0.7;
+ const samples = gainSamplesRef.current;
for (let i = 0; i < barCount; i++) {
- const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
+ const gain =
+ samples != null
+ ? sampleGain({
+ samples,
+ startFraction,
+ endFraction,
+ barIndex: i,
+ barCount,
+ })
+ : 1;
+ const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
+ const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
const barH = Math.max(1, scaled * maxBarHeight);
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
}
}, [color]);
useEffect(() => {
+ gainSamplesRef.current = gainSamples;
+ drawVisible();
+ }, [gainSamples, drawVisible]);
+
+ useEffect(() => {
let isCancelled = false;
async function load() {
let buffer = audioBuffer ?? null;
- if (!buffer && audioUrl) {
+ if (!buffer && audioUrl) {
try {
const resp = await fetch(audioUrl);
const arrayBuffer = await resp.arrayBuffer();
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 644dbc56..9283c651 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -2,7 +2,7 @@
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import { AudioWaveform } from "./audio-waveform";
+import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
import { useElementPreview } from "@/hooks/use-element-preview";
import {
useKeyframeDrag,
@@ -44,6 +44,7 @@ import {
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
+import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
import {
getActionDefinition,
type TAction,
@@ -382,6 +383,7 @@ export function TimelineElement({
>
<ElementInner
element={element}
+ displayElement={renderElement}
track={track}
isSelected={isSelected}
isExpanded={expandedRows.length > 0}
@@ -498,6 +500,7 @@ export function TimelineElement({
function ElementInner({
element,
+ displayElement,
track,
isSelected,
isExpanded,
@@ -509,6 +512,7 @@ function ElementInner({
isDropTarget = false,
}: {
element: TimelineElementType;
+ displayElement?: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
isExpanded: boolean;
@@ -530,8 +534,10 @@ function ElementInner({
}) => void;
isDropTarget?: boolean;
}) {
+ const visibleElement = displayElement ?? element;
const isReducedOpacity =
- (canElementBeHidden(element) && element.hidden) || isDropTarget;
+ (canElementBeHidden(visibleElement) && visibleElement.hidden) ||
+ isDropTarget;
return (
<div
className="absolute top-0 bottom-0"
@@ -576,7 +582,7 @@ function ElementInner({
style={{ height: `${baseTrackHeight}px` }}
>
<div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
- <ElementContent element={element} track={track} />
+ <ElementContent element={visibleElement} track={track} />
</div>
</div>
{expandedContent}
@@ -979,6 +985,11 @@ function AudioElementContent({ element }: { element: AudioElement }) {
const audioUrl =
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
const mediaLabel = mediaAsset?.name ?? element.name;
+ const gainSamples = useMemo(
+ () =>
+ buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
+ [element],
+ );
if (audioBuffer || audioUrl) {
return (
@@ -986,6 +997,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
<AudioWaveform
audioBuffer={audioBuffer}
audioUrl={audioUrl}
+ gainSamples={gainSamples}
color={TIMELINE_TRACK_THEME.audio.waveformColor}
/>
<MediaElementHeader name={mediaLabel} hasFade={false} />
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index 2bc7ec56..3b28ac6f 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -1,469 +1,560 @@
-import { forwardRef, useEffect, useRef, useState } from "react";
-import { cn } from "@/utils/ui";
-import { Input } from "./input";
-import {
- Popover,
- PopoverClose,
- PopoverContent,
- PopoverTrigger,
-} from "./popover";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "./select";
-import { Button } from "./button";
-import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- type ColorFormat,
- appendAlpha,
- extractColorFromText,
- formatColorValue,
- hexToHsv,
- hsvToHex,
- parseColorInput,
- parseHexAlpha,
-} from "@/utils/color";
-
-interface ColorPickerProps {
- value?: string;
- onChange?: (value: string) => void;
- onChangeEnd?: (value: string) => void;
- className?: string;
-}
-
-const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
- ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
- const [isDragging, setIsDragging] = useState<
- "saturation" | "hue" | "opacity" | null
- >(null);
- const [internalHue, setInternalHue] = useState(0);
- const [inputValue, setInputValue] = useState(value);
- const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
-
- const saturationRef = useRef<HTMLButtonElement>(null);
- const hueRef = useRef<HTMLButtonElement>(null);
- const opacityRef = useRef<HTMLButtonElement>(null);
- const latestDragColorRef = useRef<string | null>(null);
-
- const isEyeDropperSupported =
- typeof window !== "undefined" && "EyeDropper" in window;
-
- const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
- const [h, s, v] = hexToHsv({ hex: rgbValue });
-
- const handleEyeDropper = async () => {
- if (!isEyeDropperSupported || !EyeDropper) return;
- try {
- const dropper = new EyeDropper();
- const result = await dropper.open();
- const hex = result.sRGBHex.replace("#", "").toLowerCase();
- const finalHex = appendAlpha({ rgbHex: hex, alpha });
- onChange?.(finalHex);
- onChangeEnd?.(finalHex);
- } catch {
- // user cancelled the picker
- }
- };
- const hueDiff = Math.abs(h - internalHue);
- const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
- const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
-
- useEffect(() => {
- setInputValue(formatColorValue({ hex: value, format: colorFormat }));
- }, [value, colorFormat]);
-
- useEffect(() => {
- const handleMouseMove = (e: MouseEvent) => {
- if (!isDragging) return;
-
- if (isDragging === "saturation" && saturationRef.current) {
- const rect = saturationRef.current.getBoundingClientRect();
- const x = Math.max(
- 0,
- Math.min(1, (e.clientX - rect.left) / rect.width),
- );
- const y = Math.max(
- 0,
- Math.min(1, (e.clientY - rect.top) / rect.height),
- );
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
-
- if (isDragging === "hue" && hueRef.current) {
- const rect = hueRef.current.getBoundingClientRect();
- const x = Math.max(
- 0,
- Math.min(1, (e.clientX - rect.left) / rect.width),
- );
- const newH = x * 360;
- setInternalHue(newH);
- if (s > 0) {
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: newH, s, v }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
- }
-
- if (isDragging === "opacity" && opacityRef.current) {
- const rect = opacityRef.current.getBoundingClientRect();
- const x = Math.max(
- 0,
- Math.min(1, (e.clientX - rect.left) / rect.width),
- );
- const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
- };
-
- const handleMouseUp = () => {
- if (latestDragColorRef.current !== null && onChangeEnd) {
- onChangeEnd(latestDragColorRef.current);
- latestDragColorRef.current = null;
- }
- setIsDragging(null);
- };
-
- if (isDragging) {
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }
- }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
-
- const handleSaturationMouseDown = (e: React.MouseEvent) => {
- e.preventDefault();
- const saturationElement = saturationRef.current;
- if (!saturationElement) return;
- setIsDragging("saturation");
- const rect = saturationElement.getBoundingClientRect();
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- };
-
- const handleHueMouseDown = (e: React.MouseEvent) => {
- e.preventDefault();
- const hueElement = hueRef.current;
- if (!hueElement) return;
- setIsDragging("hue");
- const rect = hueElement.getBoundingClientRect();
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- const newH = x * 360;
- setInternalHue(newH);
- if (s > 0) {
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: newH, s, v }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
- };
-
- const handleOpacityMouseDown = (e: React.MouseEvent) => {
- e.preventDefault();
- const opacityElement = opacityRef.current;
- if (!opacityElement) return;
- setIsDragging("opacity");
- const rect = opacityElement.getBoundingClientRect();
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- };
-
- const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- setInputValue(
- colorFormat === "hex"
- ? e.target.value.replace("#", "")
- : e.target.value,
- );
- };
-
- const commitInputValue = () => {
- const parsed = parseColorInput({
- input: inputValue,
- format: colorFormat,
- });
- if (parsed) {
- const nextHex = appendAlpha({ rgbHex: parsed, alpha });
- onChange?.(nextHex);
- onChangeEnd?.(nextHex);
- return;
- }
-
- const extracted = extractColorFromText({ text: inputValue });
- if (extracted) {
- const hasExplicitAlpha = extracted.length > 6;
- const finalHex = hasExplicitAlpha
- ? extracted
- : appendAlpha({ rgbHex: extracted, alpha });
- onChange?.(finalHex);
- onChangeEnd?.(finalHex);
- }
- };
-
- const handleInputBlur = () => {
- commitInputValue();
- };
-
- const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
- if (e.key === "Enter") {
- commitInputValue();
- e.currentTarget.blur();
- }
- };
-
- const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
- const pastedText = event.clipboardData.getData("text");
- const extractedHex = extractColorFromText({ text: pastedText });
- if (!extractedHex) return;
-
- event.preventDefault();
- const hasExplicitAlpha = extractedHex.length > 6;
- const finalHex = hasExplicitAlpha
- ? extractedHex
- : appendAlpha({ rgbHex: extractedHex, alpha });
- onChange?.(finalHex);
- onChangeEnd?.(finalHex);
- };
-
- const saturationStyle = {
- background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
- };
-
- const hueStyle = {
- background:
- "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
- };
-
- const checkerboardStyle = {
- backgroundImage: `
- linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
- linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
- linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
- linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
- `,
- backgroundSize: "8px 8px",
- backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
- backgroundColor: "#fff",
- };
-
-
- return (
- <Popover>
- <div
- ref={ref}
- className={cn(
- "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
- className,
- )}
- {...props}
- >
- <PopoverTrigger asChild>
- <button
- className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
- type="button"
- >
- <span
- className="absolute inset-0 dark:invert"
- style={checkerboardStyle}
- />
- <span
- className="absolute inset-0"
- style={{ backgroundColor: `#${value}` }}
- />
- </button>
- </PopoverTrigger>
- <div className="flex flex-1 items-center">
- <Input
- className={cn(
- "border-0! bg-transparent p-0 ring-0! ring-offset-0!",
- colorFormat === "hex" && "uppercase",
- )}
- size="sm"
- containerClassName="w-full"
- value={inputValue}
- onChange={handleInputChange}
- onBlur={handleInputBlur}
- onKeyDown={handleInputKeyDown}
- onPaste={handlePaste}
- />
- </div>
- </div>
- <PopoverContent
- className="w-64 px-0 select-none flex flex-col gap-3 py-2"
- side="left"
- sideOffset={8}
- onOpenAutoFocus={(event) => {
- event.preventDefault();
- }}
- onCloseAutoFocus={(event) => {
- event.preventDefault();
- }}
- onInteractOutside={(event) => {
- if (isDragging) event.preventDefault();
- }}
- >
- <header className="border-b flex justify-between items-center pb-2 px-2">
- <Select defaultValue="custom">
- <SelectTrigger variant="outline">
- <SelectValue placeholder="Select a mode" />
- </SelectTrigger>
- <SelectContent position="popper">
- <SelectItem value="custom">Custom</SelectItem>
- <SelectItem value="saved">Saved</SelectItem>
- </SelectContent>
- </Select>
- <div>
- {isEyeDropperSupported && (
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={handleEyeDropper}
- >
- <HugeiconsIcon icon={ColorPickerIcon} />
- </Button>
- )}
- <PopoverClose asChild>
- <Button variant="ghost" size="icon" type="button">
- <HugeiconsIcon icon={Cancel01Icon} />
- </Button>
- </PopoverClose>
- </div>
- </header>
- <div className="px-2 flex flex-col gap-3">
- <button
- ref={saturationRef}
- className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
- style={saturationStyle}
- type="button"
- onMouseDown={handleSaturationMouseDown}
- >
- <ColorCircle
- size="sm"
- position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
- color={`#${value}`}
- />
- </button>
-
- <button
- ref={hueRef}
- className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
- style={hueStyle}
- type="button"
- onMouseDown={handleHueMouseDown}
- >
- <ColorCircle
- size="md"
- position={{
- left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
- top: "50%",
- }}
- />
- </button>
-
- <button
- ref={opacityRef}
- className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
- type="button"
- onMouseDown={handleOpacityMouseDown}
- >
- <div className="absolute inset-0 dark:invert" style={checkerboardStyle} />
- <div
- className="absolute inset-0 rounded-lg"
- style={{
- background: `linear-gradient(to right, transparent, #${rgbValue})`,
- }}
- />
- <ColorCircle
- size="md"
- position={{
- left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
- top: "50%",
- }}
- />
- </button>
-
- <div className="flex items-center gap-2">
- <Select
- value={colorFormat}
- onValueChange={(value) => setColorFormat(value as ColorFormat)}
- >
- <SelectTrigger variant="outline" className="min-w-18 max-w-18">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="hex">HEX</SelectItem>
- <SelectItem value="rgb">RGB</SelectItem>
- <SelectItem value="hsl">HSL</SelectItem>
- <SelectItem value="hsv">HSV</SelectItem>
- </SelectContent>
- </Select>
-
- <Input
- className={cn(
- "h-7 rounded-sm p-2.5",
- colorFormat === "hex" && "uppercase",
- )}
- type="text"
- value={inputValue}
- onChange={handleInputChange}
- onBlur={handleInputBlur}
- onKeyDown={handleInputKeyDown}
- onPaste={handlePaste}
- />
- </div>
- </div>
- </PopoverContent>
- </Popover>
- );
- },
-);
-ColorPicker.displayName = "ColorPicker";
-
-const ColorCircle = ({
- size,
- position,
- color,
-}: {
- size: "sm" | "md";
- position: { left: string; top: string };
- color?: string;
-}) => (
- <div
- className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${
- size === "sm" ? "size-3" : "size-4"
- }`}
- style={{
- left: position.left,
- top: position.top,
- transform: "translate(-50%, -50%)",
- backgroundColor: color,
- }}
- />
-);
-
-export { ColorPicker };
+import { type ComponentProps, forwardRef, useEffect, useRef, useState } from "react";
+import { cn } from "@/utils/ui";
+import { Input } from "./input";
+import {
+ Popover,
+ PopoverClose,
+ PopoverContent,
+ PopoverTrigger,
+} from "./popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./select";
+import { Button } from "./button";
+import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ type ColorFormat,
+ appendAlpha,
+ extractColorFromText,
+ formatColorValue,
+ hexToHsv,
+ hsvToHex,
+ parseColorInput,
+ parseHexAlpha,
+} from "@/utils/color";
+
+const CHECKERBOARD_STYLE = {
+ backgroundImage: `
+ linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
+ `,
+ backgroundSize: "8px 8px",
+ backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
+ backgroundColor: "#fff",
+} as const;
+
+interface ColorPickerContentProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ onChangeEnd?: (value: string) => void;
+ side?: ComponentProps<typeof PopoverContent>["side"];
+ align?: ComponentProps<typeof PopoverContent>["align"];
+}
+
+function ColorPickerContent({
+ value = "FFFFFF",
+ onChange,
+ onChangeEnd,
+ side = "left",
+ align = "center",
+}: ColorPickerContentProps) {
+ const [isDragging, setIsDragging] = useState<
+ "saturation" | "hue" | "opacity" | null
+ >(null);
+ const [internalHue, setInternalHue] = useState(0);
+ const [inputValue, setInputValue] = useState(value);
+ const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
+
+ const saturationRef = useRef<HTMLButtonElement>(null);
+ const hueRef = useRef<HTMLButtonElement>(null);
+ const opacityRef = useRef<HTMLButtonElement>(null);
+ const latestDragColorRef = useRef<string | null>(null);
+
+ const isEyeDropperSupported =
+ typeof window !== "undefined" && "EyeDropper" in window;
+
+ const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
+ const [h, s, v] = hexToHsv({ hex: rgbValue });
+
+ const hueDiff = Math.abs(h - internalHue);
+ const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
+ const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
+
+ useEffect(() => {
+ setInputValue(formatColorValue({ hex: value, format: colorFormat }));
+ }, [value, colorFormat]);
+
+ useEffect(() => {
+ const handleMouseMove = (event: MouseEvent) => {
+ if (!isDragging) return;
+
+ if (isDragging === "saturation" && saturationRef.current) {
+ const rect = saturationRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (event.clientX - rect.left) / rect.width),
+ );
+ const y = Math.max(
+ 0,
+ Math.min(1, (event.clientY - rect.top) / rect.height),
+ );
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+
+ if (isDragging === "hue" && hueRef.current) {
+ const rect = hueRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (event.clientX - rect.left) / rect.width),
+ );
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ }
+
+ if (isDragging === "opacity" && opacityRef.current) {
+ const rect = opacityRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (event.clientX - rect.left) / rect.width),
+ );
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (latestDragColorRef.current !== null && onChangeEnd) {
+ onChangeEnd(latestDragColorRef.current);
+ latestDragColorRef.current = null;
+ }
+ setIsDragging(null);
+ };
+
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }
+ }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
+
+ const handleEyeDropper = async () => {
+ if (!isEyeDropperSupported || !EyeDropper) return;
+ try {
+ const dropper = new EyeDropper();
+ const result = await dropper.open();
+ const hex = result.sRGBHex.replace("#", "").toLowerCase();
+ const finalHex = appendAlpha({ rgbHex: hex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ } catch {
+ // user cancelled the picker
+ }
+ };
+
+ const handleSaturationMouseDown = (event: React.MouseEvent) => {
+ event.preventDefault();
+ const saturationElement = saturationRef.current;
+ if (!saturationElement) return;
+ setIsDragging("saturation");
+ const rect = saturationElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
+ const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ };
+
+ const handleHueMouseDown = (event: React.MouseEvent) => {
+ event.preventDefault();
+ const hueElement = hueRef.current;
+ if (!hueElement) return;
+ setIsDragging("hue");
+ const rect = hueElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ };
+
+ const handleOpacityMouseDown = (event: React.MouseEvent) => {
+ event.preventDefault();
+ const opacityElement = opacityRef.current;
+ if (!opacityElement) return;
+ setIsDragging("opacity");
+ const rect = opacityElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ };
+
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ setInputValue(
+ colorFormat === "hex"
+ ? event.target.value.replace("#", "")
+ : event.target.value,
+ );
+ };
+
+ const commitInputValue = () => {
+ const parsed = parseColorInput({ input: inputValue, format: colorFormat });
+ if (parsed) {
+ const nextHex = appendAlpha({ rgbHex: parsed, alpha });
+ onChange?.(nextHex);
+ onChangeEnd?.(nextHex);
+ return;
+ }
+
+ const extracted = extractColorFromText({ text: inputValue });
+ if (extracted) {
+ const hasExplicitAlpha = extracted.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extracted
+ : appendAlpha({ rgbHex: extracted, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ }
+ };
+
+ const handleInputBlur = () => commitInputValue();
+
+ const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === "Enter") {
+ commitInputValue();
+ event.currentTarget.blur();
+ }
+ };
+
+ const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
+ const pastedText = event.clipboardData.getData("text");
+ const extractedHex = extractColorFromText({ text: pastedText });
+ if (!extractedHex) return;
+
+ event.preventDefault();
+ const hasExplicitAlpha = extractedHex.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extractedHex
+ : appendAlpha({ rgbHex: extractedHex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ };
+
+ const saturationStyle = {
+ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
+ };
+
+ const hueStyle = {
+ background:
+ "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
+ };
+
+ return (
+ <PopoverContent
+ className="w-64 px-0 select-none flex flex-col gap-3 py-2"
+ side={side}
+ align={align}
+ sideOffset={8}
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onInteractOutside={(event) => {
+ if (isDragging) event.preventDefault();
+ }}
+ >
+ <header className="border-b flex justify-between items-center pb-2 px-2">
+ <Select defaultValue="custom">
+ <SelectTrigger variant="outline">
+ <SelectValue placeholder="Select a mode" />
+ </SelectTrigger>
+ <SelectContent position="popper">
+ <SelectItem value="custom">Custom</SelectItem>
+ <SelectItem value="saved">Saved</SelectItem>
+ </SelectContent>
+ </Select>
+ <div>
+ {isEyeDropperSupported && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={handleEyeDropper}
+ >
+ <HugeiconsIcon icon={ColorPickerIcon} />
+ </Button>
+ )}
+ <PopoverClose asChild>
+ <Button variant="ghost" size="icon" type="button">
+ <HugeiconsIcon icon={Cancel01Icon} />
+ </Button>
+ </PopoverClose>
+ </div>
+ </header>
+ <div className="px-2 flex flex-col gap-3">
+ <button
+ ref={saturationRef}
+ className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
+ style={saturationStyle}
+ type="button"
+ onMouseDown={handleSaturationMouseDown}
+ >
+ <ColorCircle
+ size="sm"
+ position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
+ color={`#${value}`}
+ />
+ </button>
+
+ <button
+ ref={hueRef}
+ className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
+ style={hueStyle}
+ type="button"
+ onMouseDown={handleHueMouseDown}
+ >
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
+ top: "50%",
+ }}
+ />
+ </button>
+
+ <button
+ ref={opacityRef}
+ className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
+ type="button"
+ onMouseDown={handleOpacityMouseDown}
+ >
+ <div className="absolute inset-0 dark:invert" style={CHECKERBOARD_STYLE} />
+ <div
+ className="absolute inset-0 rounded-lg"
+ style={{
+ background: `linear-gradient(to right, transparent, #${rgbValue})`,
+ }}
+ />
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
+ top: "50%",
+ }}
+ />
+ </button>
+
+ <div className="flex items-center gap-2">
+ <Select
+ value={colorFormat}
+ onValueChange={(selectedFormat) =>
+ setColorFormat(selectedFormat as ColorFormat)
+ }
+ >
+ <SelectTrigger variant="outline" className="min-w-18 max-w-18">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="hex">HEX</SelectItem>
+ <SelectItem value="rgb">RGB</SelectItem>
+ <SelectItem value="hsl">HSL</SelectItem>
+ <SelectItem value="hsv">HSV</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Input
+ className={cn(
+ "h-7 rounded-sm p-2.5",
+ colorFormat === "hex" && "uppercase",
+ )}
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
+ />
+ </div>
+ </div>
+ </PopoverContent>
+ );
+}
+
+interface ColorPickerProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ onChangeEnd?: (value: string) => void;
+ className?: string;
+ contentSide?: ComponentProps<typeof PopoverContent>["side"];
+ contentAlign?: ComponentProps<typeof PopoverContent>["align"];
+}
+
+const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
+ (
+ {
+ className,
+ value = "FFFFFF",
+ onChange,
+ onChangeEnd,
+ contentSide,
+ contentAlign,
+ ...props
+ },
+ ref,
+ ) => {
+ const { alpha } = parseHexAlpha({ hex: value });
+
+ const [inputValue, setInputValue] = useState(value);
+
+ useEffect(() => {
+ setInputValue(value);
+ }, [value]);
+
+ const commitInputValue = (raw: string) => {
+ const input = raw.replace("#", "");
+ const parsed = parseColorInput({ input, format: "hex" });
+ if (parsed) {
+ const nextHex = appendAlpha({ rgbHex: parsed, alpha });
+ onChange?.(nextHex);
+ onChangeEnd?.(nextHex);
+ return;
+ }
+ const extracted = extractColorFromText({ text: input });
+ if (extracted) {
+ const hasExplicitAlpha = extracted.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extracted
+ : appendAlpha({ rgbHex: extracted, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ }
+ };
+
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ setInputValue(event.target.value.replace("#", ""));
+ };
+
+ const handleInputBlur = () => commitInputValue(inputValue);
+
+ const handleInputKeyDown = (
+ event: React.KeyboardEvent<HTMLInputElement>,
+ ) => {
+ if (event.key === "Enter") {
+ commitInputValue(inputValue);
+ event.currentTarget.blur();
+ }
+ };
+
+ const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
+ const pastedText = event.clipboardData.getData("text");
+ const extractedHex = extractColorFromText({ text: pastedText });
+ if (!extractedHex) return;
+ event.preventDefault();
+ const hasExplicitAlpha = extractedHex.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extractedHex
+ : appendAlpha({ rgbHex: extractedHex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ };
+
+ return (
+ <Popover>
+ <div
+ ref={ref}
+ className={cn(
+ "bg-accent flex h-7 border flex-1 items-center gap-2 rounded-md px-[0.45rem]",
+ className,
+ )}
+ {...props}
+ >
+ <PopoverTrigger asChild>
+ <button
+ className="size-4.5 relative cursor-pointer overflow-hidden rounded-sm border hover:ring-1 hover:ring-foreground/20"
+ type="button"
+ >
+ <span
+ className="absolute inset-0 dark:invert"
+ style={CHECKERBOARD_STYLE}
+ />
+ <span
+ className="absolute inset-0"
+ style={{ backgroundColor: `#${value}` }}
+ />
+ </button>
+ </PopoverTrigger>
+ <div className="flex flex-1 items-center">
+ <Input
+ className="border-0! bg-transparent p-0 ring-0! ring-offset-0! uppercase"
+ size="sm"
+ containerClassName="w-full"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
+ />
+ </div>
+ </div>
+ <ColorPickerContent
+ value={value}
+ onChange={onChange}
+ onChangeEnd={onChangeEnd}
+ side={contentSide}
+ align={contentAlign}
+ />
+ </Popover>
+ );
+ },
+);
+ColorPicker.displayName = "ColorPicker";
+
+const ColorCircle = ({
+ size,
+ position,
+ color,
+}: {
+ size: "sm" | "md";
+ position: { left: string; top: string };
+ color?: string;
+}) => (
+ <div
+ className={cn(
+ "pointer-events-none absolute rounded-full border-3 border-white shadow-lg",
+ size === "sm" ? "size-3" : "size-4",
+ )}
+ style={{
+ left: position.left,
+ top: position.top,
+ transform: "translate(-50%, -50%)",
+ backgroundColor: color,
+ }}
+ />
+);
+
+export { ColorPicker, ColorPickerContent };
diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts
index 2f652a85..7cdd6373 100644
--- a/apps/web/src/core/managers/commands.ts
+++ b/apps/web/src/core/managers/commands.ts
@@ -1,12 +1,13 @@
import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
+import type { EditorSelectionSnapshot } from "@/lib/selection/editor-selection";
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
-import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
+import type { SceneTracks } from "@/lib/timeline/types";
interface CommandHistoryEntry {
command: Command;
- previousSelection: ElementRef[];
- selectionOverride?: ElementRef[];
+ previousSelection: EditorSelectionSnapshot;
+ selectionOverride?: EditorSelectionSnapshot;
}
export class CommandManager {
@@ -19,7 +20,7 @@ export class CommandManager {
execute({ command }: { command: Command }): Command {
const beforeTracks = this.isRippleEnabled
- ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
+ ? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null;
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
@@ -55,11 +56,11 @@ export class CommandManager {
// Only restore selection for commands that explicitly changed it.
// Commands without selection intent leave selection untouched,
// preserving any UI-driven selection changes (clicks, box select)
- // that happened between commands. Commands that remove elements
- // must declare { select: [] } to clear stale refs.
+ // that happened between commands. Commands that remove editor-owned
+ // selection targets must declare a selection override to clear stale refs.
if (entry.selectionOverride !== undefined) {
- this.editor.selection.setSelectedElements({
- elements: [...entry.previousSelection],
+ this.editor.selection.restoreSnapshot({
+ snapshot: entry.previousSelection,
});
}
this.redoStack.push(entry);
@@ -74,7 +75,7 @@ export class CommandManager {
}
const beforeTracks = this.isRippleEnabled
- ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
+ ? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null;
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
@@ -102,20 +103,19 @@ export class CommandManager {
this.redoStack = [];
}
- private getSelectionSnapshot(): ElementRef[] {
- return [...this.editor.selection.getSelectedElements()];
+ private getSelectionSnapshot(): EditorSelectionSnapshot {
+ return this.editor.selection.getSnapshot();
}
private applySelectionOverride(
result: CommandResult | undefined,
- ): ElementRef[] | undefined {
- if (result?.select === undefined) {
+ ): EditorSelectionSnapshot | undefined {
+ if (!result?.selection) {
return undefined;
}
-
- const selectionOverride = [...result.select];
- this.editor.selection.setSelectedElements({ elements: selectionOverride });
- return selectionOverride;
+ return this.editor.selection.applySelectionPatch({
+ patch: result.selection,
+ });
}
private runReactors(): void {
diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts
index 9bafc6d7..5da4fa60 100644
--- a/apps/web/src/core/managers/selection-manager.ts
+++ b/apps/web/src/core/managers/selection-manager.ts
@@ -1,73 +1,195 @@
-import type { EditorCore } from "@/core";
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export class SelectionManager {
- private selectedElements: ElementRef[] = [];
- private selectedKeyframes: SelectedKeyframeRef[] = [];
- private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
- private listeners = new Set<() => void>();
-
- constructor(editor: EditorCore) {
- void editor;
- }
-
- getSelectedElements(): ElementRef[] {
- return this.selectedElements;
- }
-
- getSelectedKeyframes(): SelectedKeyframeRef[] {
- return this.selectedKeyframes;
- }
-
- getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
- return this.keyframeSelectionAnchor;
- }
-
- setSelectedElements({ elements }: { elements: ElementRef[] }): void {
- this.selectedElements = elements;
- this.selectedKeyframes = [];
- this.keyframeSelectionAnchor = null;
- this.notify();
- }
-
- setSelectedKeyframes({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef | null;
- }): void {
- this.selectedKeyframes = keyframes;
- if (anchorKeyframe !== undefined) {
- this.keyframeSelectionAnchor = anchorKeyframe;
- } else if (keyframes.length === 0) {
- this.keyframeSelectionAnchor = null;
- }
- this.notify();
- }
-
- clearSelection(): void {
- this.selectedElements = [];
- this.selectedKeyframes = [];
- this.keyframeSelectionAnchor = null;
- this.notify();
- }
-
- clearKeyframeSelection(): void {
- this.selectedKeyframes = [];
- this.keyframeSelectionAnchor = null;
- this.notify();
- }
-
- subscribe(listener: () => void): () => void {
- this.listeners.add(listener);
- return () => this.listeners.delete(listener);
- }
-
- private notify(): void {
- this.listeners.forEach((fn) => {
- fn();
- });
- }
-}
+import type { EditorCore } from "@/core";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type {
+ EditorSelectionKind,
+ EditorSelectionPatch,
+ EditorSelectionSnapshot,
+ SelectedMaskPointSelection,
+} from "@/lib/selection/editor-selection";
+import type { ElementRef } from "@/lib/timeline/types";
+
+export class SelectionManager {
+ private selectedElements: ElementRef[] = [];
+ private selectedKeyframes: SelectedKeyframeRef[] = [];
+ private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
+ private selectedMaskPoints: SelectedMaskPointSelection | null = null;
+ private listeners = new Set<() => void>();
+
+ constructor(editor: EditorCore) {
+ void editor;
+ }
+
+ getSelectedElements(): ElementRef[] {
+ return this.selectedElements;
+ }
+
+ getSelectedKeyframes(): SelectedKeyframeRef[] {
+ return this.selectedKeyframes;
+ }
+
+ getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
+ return this.keyframeSelectionAnchor;
+ }
+
+ getSelectedMaskPointSelection(): SelectedMaskPointSelection | null {
+ return this.selectedMaskPoints;
+ }
+
+ getActiveSelectionKind(): EditorSelectionKind | null {
+ if ((this.selectedMaskPoints?.pointIds.length ?? 0) > 0) {
+ return "mask-points";
+ }
+ if (this.selectedKeyframes.length > 0) {
+ return "keyframes";
+ }
+ if (this.selectedElements.length > 0) {
+ return "elements";
+ }
+ return null;
+ }
+
+ getSnapshot(): EditorSelectionSnapshot {
+ return {
+ selectedElements: [...this.selectedElements],
+ selectedKeyframes: [...this.selectedKeyframes],
+ keyframeSelectionAnchor: this.keyframeSelectionAnchor,
+ selectedMaskPoints: this.selectedMaskPoints
+ ? {
+ ...this.selectedMaskPoints,
+ pointIds: [...this.selectedMaskPoints.pointIds],
+ }
+ : null,
+ };
+ }
+
+ setSelectedElements({ elements }: { elements: ElementRef[] }): void {
+ this.selectedElements = elements;
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ setSelectedKeyframes({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef | null;
+ }): void {
+ this.selectedKeyframes = keyframes;
+ if (anchorKeyframe !== undefined) {
+ this.keyframeSelectionAnchor = anchorKeyframe;
+ } else if (keyframes.length === 0) {
+ this.keyframeSelectionAnchor = null;
+ }
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ setSelectedMaskPoints({
+ selection,
+ }: {
+ selection: SelectedMaskPointSelection | null;
+ }): void {
+ this.selectedMaskPoints =
+ selection && selection.pointIds.length > 0
+ ? {
+ ...selection,
+ pointIds: [...selection.pointIds],
+ }
+ : null;
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.notify();
+ }
+
+ clearSelection(): void {
+ this.selectedElements = [];
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ clearKeyframeSelection(): void {
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.notify();
+ }
+
+ clearMaskPointSelection(): void {
+ if (!this.selectedMaskPoints) {
+ return;
+ }
+ this.selectedMaskPoints = null;
+ this.notify();
+ }
+
+ clearMostSpecificSelection(): boolean {
+ const activeSelectionKind = this.getActiveSelectionKind();
+ if (activeSelectionKind === "mask-points") {
+ this.clearMaskPointSelection();
+ return true;
+ }
+ if (activeSelectionKind === "keyframes") {
+ this.clearKeyframeSelection();
+ return true;
+ }
+ if (activeSelectionKind === "elements") {
+ this.setSelectedElements({ elements: [] });
+ return true;
+ }
+ return false;
+ }
+
+ applySelectionPatch({
+ patch,
+ }: {
+ patch: EditorSelectionPatch;
+ }): EditorSelectionSnapshot {
+ if (patch.selectedElements !== undefined) {
+ this.selectedElements = [...patch.selectedElements];
+ }
+ if (patch.selectedKeyframes !== undefined) {
+ this.selectedKeyframes = [...patch.selectedKeyframes];
+ }
+ if (patch.keyframeSelectionAnchor !== undefined) {
+ this.keyframeSelectionAnchor = patch.keyframeSelectionAnchor;
+ }
+ if (patch.selectedMaskPoints !== undefined) {
+ this.selectedMaskPoints = patch.selectedMaskPoints
+ ? {
+ ...patch.selectedMaskPoints,
+ pointIds: [...patch.selectedMaskPoints.pointIds],
+ }
+ : null;
+ }
+ this.notify();
+ return this.getSnapshot();
+ }
+
+ restoreSnapshot({ snapshot }: { snapshot: EditorSelectionSnapshot }): void {
+ this.selectedElements = [...snapshot.selectedElements];
+ this.selectedKeyframes = [...snapshot.selectedKeyframes];
+ this.keyframeSelectionAnchor = snapshot.keyframeSelectionAnchor;
+ this.selectedMaskPoints = snapshot.selectedMaskPoints
+ ? {
+ ...snapshot.selectedMaskPoints,
+ pointIds: [...snapshot.selectedMaskPoints.pointIds],
+ }
+ : null;
+ this.notify();
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private notify(): void {
+ this.listeners.forEach((fn) => {
+ fn();
+ });
+ }
+}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index c1398e8b..898a7e89 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -1,4 +1,5 @@
import type { EditorCore } from "@/core";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { ParamValues } from "@/lib/params";
import type {
SceneTracks,
@@ -44,6 +45,8 @@ import {
RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand,
+ DeleteCustomMaskPointsCommand,
+ InsertCustomMaskPointCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
@@ -344,6 +347,55 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ deleteCustomMaskPoints({
+ trackId,
+ elementId,
+ maskId,
+ pointIds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ pointIds: string[];
+ }): void {
+ if (pointIds.length === 0) {
+ return;
+ }
+ const command = new DeleteCustomMaskPointsCommand({
+ trackId,
+ elementId,
+ maskId,
+ pointIds,
+ });
+ this.editor.command.execute({ command });
+ }
+
+ insertCustomMaskPoint({
+ trackId,
+ elementId,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+ }): void {
+ const command = new InsertCustomMaskPointCommand({
+ trackId,
+ elementId,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ });
+ this.editor.command.execute({ command });
+ }
+
updateClipEffectParams({
trackId,
elementId,
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index 1c4735bb..873fd33a 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -21,19 +21,27 @@ export function useEditorActions() {
const editor = useEditor();
const { selectedElements, setElementSelection } = useElementSelection();
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
+ const selectedMaskPointSelection = useEditor((e) =>
+ e.selection.getSelectedMaskPointSelection(),
+ );
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
const hasTimelineSelectionRef = useRef(false);
const clearTimelineSelectionRef = useRef(() => {});
+ const clearTimelineActiveSelectionRef = useRef(() => {});
const timelineScopeRef = useRef<ScopeEntry | null>(null);
const hasTimelineSelection =
- selectedElements.length > 0 || selectedKeyframes.length > 0;
+ selectedElements.length > 0 ||
+ selectedKeyframes.length > 0 ||
+ selectedMaskPointSelection !== null;
hasTimelineSelectionRef.current = hasTimelineSelection;
clearTimelineSelectionRef.current = () => {
- setElementSelection({ elements: [] });
- clearKeyframeSelection();
+ editor.selection.clearSelection();
+ };
+ clearTimelineActiveSelectionRef.current = () => {
+ editor.selection.clearMostSpecificSelection();
};
if (!timelineScopeRef.current) {
@@ -42,6 +50,9 @@ export function useEditorActions() {
clear: () => {
clearTimelineSelectionRef.current();
},
+ clearActive: () => {
+ clearTimelineActiveSelectionRef.current();
+ },
};
}
@@ -257,17 +268,36 @@ export function useEditorActions() {
useActionHandler(
"delete-selected",
() => {
- if (selectedKeyframes.length > 0) {
- editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
- clearKeyframeSelection();
- return;
+ switch (editor.selection.getActiveSelectionKind()) {
+ case "mask-points":
+ if (!selectedMaskPointSelection) {
+ return;
+ }
+ editor.timeline.deleteCustomMaskPoints({
+ trackId: selectedMaskPointSelection.trackId,
+ elementId: selectedMaskPointSelection.elementId,
+ maskId: selectedMaskPointSelection.maskId,
+ pointIds: selectedMaskPointSelection.pointIds,
+ });
+ return;
+ case "keyframes":
+ if (selectedKeyframes.length === 0) {
+ return;
+ }
+ editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
+ clearKeyframeSelection();
+ return;
+ case "elements":
+ if (selectedElements.length === 0) {
+ return;
+ }
+ editor.timeline.deleteElements({
+ elements: selectedElements,
+ });
+ return;
+ default:
+ return;
}
- if (selectedElements.length === 0) {
- return;
- }
- editor.timeline.deleteElements({
- elements: selectedElements,
- });
},
undefined,
);
@@ -343,8 +373,7 @@ export function useEditorActions() {
"deselect-all",
() => {
if (!clearActiveScope()) {
- setElementSelection({ elements: [] });
- clearKeyframeSelection();
+ editor.selection.clearMostSpecificSelection();
}
},
undefined,
diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts
index d2400ee0..917a0677 100644
--- a/apps/web/src/hooks/use-mask-handles.ts
+++ b/apps/web/src/hooks/use-mask-handles.ts
@@ -1,260 +1,633 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { masksRegistry } from "@/lib/masks";
-import {
- getMaskHandlePositions,
- getLineMaskLinePoints,
-} from "@/lib/masks/handle-positions";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
-import {
- SNAP_THRESHOLD_SCREEN_PIXELS,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import type { ParamValues } from "@/lib/params";
-import type {
- BaseMaskParams,
- MaskHandlePosition,
- MaskLinePoints,
-} from "@/lib/masks/types";
-import type { MaskableElement } from "@/lib/timeline";
-import { registerCanceller } from "@/lib/cancel-interaction";
-
-interface DragState {
- trackId: string;
- elementId: string;
- handleId: string;
- startCanvasX: number;
- startCanvasY: number;
- startParams: BaseMaskParams & ParamValues;
-}
-
-export function useMaskHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: (lines: SnapLine[]) => void;
-}) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const viewport = usePreviewViewport();
- const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
- const dragStateRef = useRef<DragState | null>(null);
- const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
- null,
- );
-
- const tracks = useEditor(
- (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
- );
- const currentTime = useEditor((e) => e.playback.getCurrentTime());
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const canvasSize = useEditor(
- (e) => e.project.getActive().settings.canvasSize,
- );
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
-
- const elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const selectedWithMask =
- selectedElements.length === 1
- ? (() => {
- const sel = selectedElements[0];
- const entry = elementsWithBounds.find(
- (item) =>
- item.trackId === sel.trackId && item.elementId === sel.elementId,
- );
- if (!entry) return null;
- const element = entry.element as MaskableElement;
- if (!element.masks?.length) return null;
- return { ...entry, element, mask: element.masks[0] };
- })()
- : null;
-
- const handlePositions: MaskHandlePosition[] = selectedWithMask
- ? (() => {
- const def = masksRegistry.get(selectedWithMask.mask.type);
- const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
- const displayScale = (scaleX + scaleY) / 2;
- return getMaskHandlePositions({
- overlayShape: def.overlayShape,
- features: def.features,
- params: selectedWithMask.mask.params,
- bounds: selectedWithMask.bounds,
- displayScale,
- });
- })()
- : [];
-
- const linePoints: MaskLinePoints | null =
- selectedWithMask?.mask.type === "split"
- ? getLineMaskLinePoints({
- centerX: selectedWithMask.mask.params.centerX,
- centerY: selectedWithMask.mask.params.centerY,
- rotation: selectedWithMask.mask.params.rotation,
- bounds: selectedWithMask.bounds,
- })
- : null;
-
- const clearMaskHandleState = useCallback(() => {
- dragStateRef.current = null;
- setActiveHandleId(null);
- onSnapLinesChange?.([]);
- }, [onSnapLinesChange]);
-
- const releaseCapturedPointer = useCallback(() => {
- const capture = captureRef.current;
- if (!capture) return;
-
- if (capture.element.hasPointerCapture(capture.pointerId)) {
- capture.element.releasePointerCapture(capture.pointerId);
- }
-
- captureRef.current = null;
- }, []);
-
- useEffect(() => {
- if (!activeHandleId) return;
-
- return registerCanceller({
- fn: () => {
- editor.timeline.discardPreview();
- clearMaskHandleState();
- releaseCapturedPointer();
- },
- });
- }, [
- activeHandleId,
- clearMaskHandleState,
- editor.timeline,
- releaseCapturedPointer,
- ]);
-
- const handlePointerDown = useCallback(
- ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
- if (!selectedWithMask) return;
- event.stopPropagation();
-
- const pos = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!pos) return;
-
- dragStateRef.current = {
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- handleId,
- startCanvasX: pos.x,
- startCanvasY: pos.y,
- startParams: { ...selectedWithMask.mask.params },
- };
- setActiveHandleId(handleId);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithMask, viewport],
- );
-
- const handlePointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const drag = dragStateRef.current;
- if (!drag || !selectedWithMask) return;
-
- const pos = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!pos) return;
-
- const deltaX = pos.x - drag.startCanvasX;
- const deltaY = pos.y - drag.startCanvasY;
- const def = masksRegistry.get(selectedWithMask.mask.type);
-
- const rawParams = def.computeParamUpdate({
- handleId: drag.handleId,
- startParams: drag.startParams,
- deltaX,
- deltaY,
- startCanvasX: drag.startCanvasX,
- startCanvasY: drag.startCanvasY,
- bounds: selectedWithMask.bounds,
- canvasSize,
- });
- const proposedParams = { ...drag.startParams, ...rawParams };
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { params: nextParams, activeLines } = isShiftHeldRef.current
- ? { params: proposedParams, activeLines: [] as SnapLine[] }
- : snapMaskInteraction({
- handleId: drag.handleId,
- startParams: drag.startParams,
- proposedParams,
- bounds: selectedWithMask.bounds,
- canvasSize,
- snapThreshold,
- });
-
- onSnapLinesChange?.(activeLines);
-
- const updatedMask = {
- ...selectedWithMask.mask,
- params: nextParams,
- };
- editor.timeline.previewElements({
- updates: [
- {
- trackId: drag.trackId,
- elementId: drag.elementId,
- updates: {
- masks: [
- updatedMask,
- ...(selectedWithMask.element.masks?.slice(1) ?? []),
- ],
- } as Partial<MaskableElement>,
- },
- ],
- });
- },
- [
- selectedWithMask,
- canvasSize,
- editor,
- isShiftHeldRef,
- onSnapLinesChange,
- viewport,
- ],
- );
-
- const handlePointerUp = useCallback(() => {
- if (dragStateRef.current) {
- editor.timeline.commitPreview();
- clearMaskHandleState();
- }
- releaseCapturedPointer();
- },
- [clearMaskHandleState, editor, releaseCapturedPointer],
- );
-
- return {
- selectedWithMask,
- handlePositions,
- linePoints,
- activeHandleId,
- handlePointerDown,
- handlePointerMove,
- handlePointerUp,
- };
-}
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { masksRegistry } from "@/lib/masks";
+import {
+ parseCustomMaskHandleId,
+ parseCustomMaskSegmentHandleId,
+} from "@/lib/masks/custom-path";
+import { appendPointToCustomMask } from "@/lib/masks/definitions/custom";
+import {
+ getVisibleElementsWithBounds,
+ type ElementBounds,
+} from "@/lib/preview/element-bounds";
+import {
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection";
+import type { Mask, MaskInteractionResult } from "@/lib/masks/types";
+import type { MaskableElement } from "@/lib/timeline";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+interface DragState {
+ trackId: string;
+ elementId: string;
+ handleId: string;
+ startCanvasX: number;
+ startCanvasY: number;
+ startParams: Mask["params"];
+}
+
+interface PendingSegmentInsertState {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ segmentIndex: number;
+ startClientX: number;
+ startClientY: number;
+ startCanvasX: number;
+ startCanvasY: number;
+ startParams: Mask["params"];
+ bounds: ElementBounds;
+}
+
+const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4;
+
+function isMaskSelectionForElement({
+ trackId,
+ elementId,
+ maskId,
+ selection,
+}: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ selection: SelectedMaskPointSelection | null;
+}): boolean {
+ if (!selection) {
+ return false;
+ }
+ return (
+ selection.trackId === trackId &&
+ selection.elementId === elementId &&
+ selection.maskId === maskId
+ );
+}
+
+function replaceElementMask({
+ masks,
+ updatedMask,
+}: {
+ masks: MaskableElement["masks"];
+ updatedMask: Mask;
+}): Mask[] {
+ return (masks ?? []).map((mask) =>
+ mask.id === updatedMask.id ? updatedMask : mask,
+ );
+}
+
+function withUpdatedMaskParams<TMask extends Mask>({
+ mask,
+ params,
+}: {
+ mask: TMask;
+ params: TMask["params"];
+}): TMask {
+ return {
+ ...mask,
+ params,
+ };
+}
+
+export function useMaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
+ const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
+ const dragStateRef = useRef<DragState | null>(null);
+ const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
+ null,
+ );
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
+ );
+
+ const tracks = useEditor(
+ (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
+ );
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const selectedMaskPointSelection = useEditor((e) =>
+ e.selection.getSelectedMaskPointSelection(),
+ );
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithMask =
+ selectedElements.length === 1
+ ? (() => {
+ const sel = selectedElements[0];
+ const entry = elementsWithBounds.find(
+ (item) =>
+ item.trackId === sel.trackId && item.elementId === sel.elementId,
+ );
+ if (!entry) return null;
+ const element = entry.element as MaskableElement;
+ const masks = element.masks ?? [];
+ if (masks.length === 0) return null;
+ const activeMaskId = masks.some((mask) =>
+ isMaskSelectionForElement({
+ trackId: entry.trackId,
+ elementId: entry.elementId,
+ maskId: mask.id,
+ selection: selectedMaskPointSelection,
+ }),
+ )
+ ? selectedMaskPointSelection?.maskId
+ : masks[0].id;
+ const mask =
+ masks.find((candidate) => candidate.id === activeMaskId) ??
+ masks[0];
+ return { ...entry, element, mask };
+ })()
+ : null;
+
+ const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
+ selectedWithMask
+ ? (() => {
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+ const displayScale = (scaleX + scaleY) / 2;
+ return def.interaction.getInteraction({
+ params: selectedWithMask.mask.params,
+ bounds: selectedWithMask.bounds,
+ displayScale,
+ scaleX,
+ scaleY,
+ });
+ })()
+ : { handles: [], overlays: [] };
+
+ const selectedPointIds = new Set(
+ selectedWithMask &&
+ isMaskSelectionForElement({
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ selection: selectedMaskPointSelection,
+ })
+ ? (selectedMaskPointSelection?.pointIds ?? [])
+ : [],
+ );
+
+ const handlePositions = baseHandlePositions.map((handle) => {
+ const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
+ if (!parsedHandle || parsedHandle.part !== "anchor") {
+ return handle;
+ }
+ return {
+ ...handle,
+ isSelected: selectedPointIds.has(parsedHandle.pointId),
+ };
+ });
+
+ const customMaskPointIds =
+ selectedWithMask?.mask.type === "custom"
+ ? handlePositions
+ .filter((h) => h.kind === "point")
+ .map((h) => {
+ const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
+ return parsedHandle?.part === "anchor"
+ ? parsedHandle.pointId
+ : null;
+ })
+ .filter((id): id is string => id !== null)
+ : [];
+ const isCreatingCustomMask =
+ selectedWithMask?.mask.type === "custom" &&
+ (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
+
+ useEffect(() => {
+ if (!selectedMaskPointSelection) {
+ return;
+ }
+ if (
+ !selectedWithMask ||
+ selectedWithMask.mask.type !== "custom" ||
+ !isMaskSelectionForElement({
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ selection: selectedMaskPointSelection,
+ })
+ ) {
+ editor.selection.clearMaskPointSelection();
+ return;
+ }
+
+ const availablePointIds = new Set(customMaskPointIds);
+ const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter(
+ (pointId) => availablePointIds.has(pointId),
+ );
+ if (
+ nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length
+ ) {
+ return;
+ }
+ if (nextSelectedPointIds.length === 0) {
+ editor.selection.clearMaskPointSelection();
+ return;
+ }
+
+ editor.selection.setSelectedMaskPoints({
+ selection: {
+ ...selectedMaskPointSelection,
+ pointIds: nextSelectedPointIds,
+ },
+ });
+ }, [
+ customMaskPointIds,
+ editor.selection,
+ selectedMaskPointSelection,
+ selectedWithMask,
+ ]);
+
+ const updateCustomMaskPointSelection = useCallback(
+ ({
+ pointId,
+ toggleSelection,
+ }: {
+ pointId: string;
+ toggleSelection: boolean;
+ }) => {
+ if (!selectedWithMask || selectedWithMask.mask.type !== "custom") {
+ return;
+ }
+
+ const isSelectionForCurrentMask = isMaskSelectionForElement({
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ selection: selectedMaskPointSelection,
+ });
+ const currentPointIds = isSelectionForCurrentMask
+ ? (selectedMaskPointSelection?.pointIds ?? [])
+ : [];
+ const nextPointIds = toggleSelection
+ ? currentPointIds.includes(pointId)
+ ? currentPointIds.filter(
+ (currentPointId) => currentPointId !== pointId,
+ )
+ : [...currentPointIds, pointId]
+ : [pointId];
+
+ if (nextPointIds.length === 0) {
+ editor.selection.clearMaskPointSelection();
+ return;
+ }
+
+ editor.selection.setSelectedMaskPoints({
+ selection: {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ pointIds: nextPointIds,
+ },
+ });
+ },
+ [editor.selection, selectedMaskPointSelection, selectedWithMask],
+ );
+
+ const clearMaskHandleState = useCallback(() => {
+ dragStateRef.current = null;
+ pendingSegmentInsertRef.current = null;
+ setActiveHandleId(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandleId) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearMaskHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [
+ activeHandleId,
+ clearMaskHandleState,
+ editor.timeline,
+ releaseCapturedPointer,
+ ]);
+
+ const handlePointerDown = useCallback(
+ ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
+ if (!selectedWithMask) return;
+ if (event.button !== 0) return;
+ event.stopPropagation();
+ const parsedHandle =
+ selectedWithMask.mask.type === "custom"
+ ? parseCustomMaskHandleId({ handleId })
+ : null;
+ const parsedSegmentHandle =
+ selectedWithMask.mask.type === "custom"
+ ? parseCustomMaskSegmentHandleId({ handleId })
+ : null;
+ if (isCreatingCustomMask) {
+ const firstPointId = customMaskPointIds[0];
+ if (
+ firstPointId &&
+ handleId === `point:${firstPointId}:anchor` &&
+ customMaskPointIds.length >= 3 &&
+ selectedWithMask.mask.type === "custom"
+ ) {
+ const updatedMask = withUpdatedMaskParams({
+ mask: selectedWithMask.mask,
+ params: {
+ ...selectedWithMask.mask.params,
+ closed: true,
+ },
+ });
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ patch: {
+ masks: replaceElementMask({
+ masks: selectedWithMask.element.masks,
+ updatedMask,
+ }),
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ }
+ return;
+ }
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") {
+ setActiveHandleId(handleId);
+ pendingSegmentInsertRef.current = {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ segmentIndex: parsedSegmentHandle.segmentIndex,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ startCanvasX: pos.x,
+ startCanvasY: pos.y,
+ startParams: { ...selectedWithMask.mask.params },
+ bounds: selectedWithMask.bounds,
+ };
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ return;
+ }
+
+ if (parsedHandle?.part === "anchor") {
+ updateCustomMaskPointSelection({
+ pointId: parsedHandle.pointId,
+ toggleSelection: event.shiftKey,
+ });
+ if (event.shiftKey) {
+ return;
+ }
+ } else if (selectedWithMask.mask.type === "custom") {
+ editor.selection.clearMaskPointSelection();
+ }
+
+ dragStateRef.current = {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ handleId,
+ startCanvasX: pos.x,
+ startCanvasY: pos.y,
+ startParams: { ...selectedWithMask.mask.params },
+ };
+ setActiveHandleId(handleId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [
+ customMaskPointIds,
+ editor.selection,
+ editor.timeline,
+ isCreatingCustomMask,
+ selectedWithMask,
+ updateCustomMaskPointSelection,
+ viewport,
+ ],
+ );
+
+ const handleCanvasPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!selectedWithMask || !isCreatingCustomMask) {
+ return;
+ }
+ if (event.button !== 0) {
+ return;
+ }
+ if (selectedWithMask.mask.type !== "custom") {
+ return;
+ }
+
+ event.stopPropagation();
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) {
+ return;
+ }
+
+ const nextParams = appendPointToCustomMask({
+ params: selectedWithMask.mask.params,
+ canvasPoint: pos,
+ bounds: selectedWithMask.bounds,
+ });
+ const updatedMask = withUpdatedMaskParams({
+ mask: selectedWithMask.mask,
+ params: nextParams,
+ });
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ patch: {
+ masks: replaceElementMask({
+ masks: selectedWithMask.element.masks,
+ updatedMask,
+ }),
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ },
+ [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const pendingSegmentInsert = pendingSegmentInsertRef.current;
+ if (pendingSegmentInsert && !dragStateRef.current) {
+ const distance = Math.hypot(
+ event.clientX - pendingSegmentInsert.startClientX,
+ event.clientY - pendingSegmentInsert.startClientY,
+ );
+ if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) {
+ dragStateRef.current = {
+ trackId: pendingSegmentInsert.trackId,
+ elementId: pendingSegmentInsert.elementId,
+ handleId: "position",
+ startCanvasX: pendingSegmentInsert.startCanvasX,
+ startCanvasY: pendingSegmentInsert.startCanvasY,
+ startParams: pendingSegmentInsert.startParams,
+ };
+ pendingSegmentInsertRef.current = null;
+ setActiveHandleId("position");
+ }
+ }
+
+ const drag = dragStateRef.current;
+ if (!drag || !selectedWithMask) return;
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ const deltaX = pos.x - drag.startCanvasX;
+ const deltaY = pos.y - drag.startCanvasY;
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+
+ const rawParams = def.computeParamUpdate({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ deltaX,
+ deltaY,
+ startCanvasX: drag.startCanvasX,
+ startCanvasY: drag.startCanvasY,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ });
+ const proposedParams = { ...drag.startParams, ...rawParams };
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { params: nextParams, activeLines } = isShiftHeldRef.current
+ ? { params: proposedParams, activeLines: [] as SnapLine[] }
+ : (def.interaction.snap?.({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ proposedParams,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ snapThreshold,
+ }) ?? { params: proposedParams, activeLines: [] as SnapLine[] });
+
+ onSnapLinesChange?.(activeLines);
+
+ const updatedMask = withUpdatedMaskParams({
+ mask: selectedWithMask.mask,
+ params: nextParams as typeof selectedWithMask.mask.params,
+ });
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: drag.trackId,
+ elementId: drag.elementId,
+ updates: {
+ masks: replaceElementMask({
+ masks: selectedWithMask.element.masks,
+ updatedMask,
+ }),
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ },
+ [
+ selectedWithMask,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ const pendingSegmentInsert = pendingSegmentInsertRef.current;
+ if (pendingSegmentInsert && !dragStateRef.current) {
+ editor.timeline.insertCustomMaskPoint({
+ trackId: pendingSegmentInsert.trackId,
+ elementId: pendingSegmentInsert.elementId,
+ maskId: pendingSegmentInsert.maskId,
+ segmentIndex: pendingSegmentInsert.segmentIndex,
+ canvasPoint: {
+ x: pendingSegmentInsert.startCanvasX,
+ y: pendingSegmentInsert.startCanvasY,
+ },
+ bounds: pendingSegmentInsert.bounds,
+ });
+ clearMaskHandleState();
+ releaseCapturedPointer();
+ return;
+ }
+
+ if (dragStateRef.current) {
+ editor.timeline.commitPreview();
+ clearMaskHandleState();
+ }
+ releaseCapturedPointer();
+ }, [clearMaskHandleState, editor, releaseCapturedPointer]);
+
+ return {
+ selectedWithMask,
+ handlePositions,
+ overlays,
+ isCreatingCustomMask,
+ handleCanvasPointerDown,
+ activeHandleId,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts
index 3059a2e3..f6846846 100644
--- a/apps/web/src/lib/actions/definitions.ts
+++ b/apps/web/src/lib/actions/definitions.ts
@@ -1,209 +1,209 @@
-import type {
- KeybindingConfig,
- ShortcutKey,
-} from "@/lib/actions/keybinding";
-import type { TActionWithOptionalArgs } from "./types";
-
-export type TActionCategory =
- | "playback"
- | "navigation"
- | "editing"
- | "selection"
- | "history"
- | "timeline"
- | "controls"
- | "assets";
-
-export interface TActionBaseDefinition {
- description: string;
- category: TActionCategory;
- args?: Record<string, unknown>;
-}
-
-export interface TActionDefinition extends TActionBaseDefinition {
- defaultShortcuts?: readonly ShortcutKey[];
-}
-
-export const ACTIONS = {
- "toggle-play": {
- description: "Play/Pause",
- category: "playback",
- },
- "stop-playback": {
- description: "Stop playback",
- category: "playback",
- },
- "seek-forward": {
- description: "Seek forward 1 second",
- category: "playback",
- args: { seconds: "number" },
- },
- "seek-backward": {
- description: "Seek backward 1 second",
- category: "playback",
- args: { seconds: "number" },
- },
- "frame-step-forward": {
- description: "Frame step forward",
- category: "navigation",
- },
- "frame-step-backward": {
- description: "Frame step backward",
- category: "navigation",
- },
- "jump-forward": {
- description: "Jump forward 5 seconds",
- category: "navigation",
- args: { seconds: "number" },
- },
- "jump-backward": {
- description: "Jump backward 5 seconds",
- category: "navigation",
- args: { seconds: "number" },
- },
- "goto-start": {
- description: "Go to timeline start",
- category: "navigation",
- },
- "goto-end": {
- description: "Go to timeline end",
- category: "navigation",
- },
- split: {
- description: "Split elements at playhead",
- category: "editing",
- },
- "split-left": {
- description: "Split and remove left",
- category: "editing",
- },
- "split-right": {
- description: "Split and remove right",
- category: "editing",
- },
- "delete-selected": {
- description: "Delete selected elements",
- category: "editing",
- },
- "copy-selected": {
- description: "Copy selected elements",
- category: "editing",
- },
- "paste-copied": {
- description: "Paste elements at playhead",
- category: "editing",
- },
- "toggle-snapping": {
- description: "Toggle snapping",
- category: "editing",
- },
- "toggle-ripple-editing": {
- description: "Toggle ripple editing",
- category: "editing",
- },
- "toggle-source-audio": {
- description: "Extract or recover source audio",
- category: "editing",
- },
- "select-all": {
- description: "Select all elements",
- category: "selection",
- },
- "cancel-interaction": {
- description: "Cancel current interaction",
- category: "controls",
- },
- "deselect-all": {
- description: "Deselect all elements",
- category: "selection",
- },
- "duplicate-selected": {
- description: "Duplicate selected element",
- category: "selection",
- },
- "toggle-elements-muted-selected": {
- description: "Mute/unmute selected elements",
- category: "selection",
- },
- "toggle-elements-visibility-selected": {
- description: "Show/hide selected elements",
- category: "selection",
- },
- "toggle-bookmark": {
- description: "Toggle bookmark at playhead",
- category: "timeline",
- },
- undo: {
- description: "Undo",
- category: "history",
- },
- redo: {
- description: "Redo",
- category: "history",
- },
- "remove-media-asset": {
- description: "Remove media asset",
- category: "assets",
- args: { projectId: "string", assetId: "string" },
- },
- "remove-media-assets": {
- description: "Remove media assets",
- category: "assets",
- args: { projectId: "string", assetIds: "string[]" },
- },
-} as const satisfies Record<string, TActionBaseDefinition>;
-
-export type TAction = keyof typeof ACTIONS;
-
-const ACTION_DEFAULT_SHORTCUTS = {
- "toggle-play": ["space", "k"],
- "seek-forward": ["l"],
- "seek-backward": ["j"],
- "frame-step-forward": ["right"],
- "frame-step-backward": ["left"],
- "jump-forward": ["shift+right"],
- "jump-backward": ["shift+left"],
- "goto-start": ["home", "enter"],
- "goto-end": ["end"],
- split: ["s"],
- "split-left": ["q"],
- "split-right": ["w"],
- "delete-selected": ["backspace", "delete"],
- "copy-selected": ["ctrl+c"],
- "paste-copied": ["ctrl+v"],
- "toggle-snapping": ["n"],
- "select-all": ["ctrl+a"],
- "cancel-interaction": ["escape"],
- "duplicate-selected": ["ctrl+d"],
- undo: ["ctrl+z"],
- redo: ["ctrl+shift+z", "ctrl+y"],
-} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
-
-const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
- Record<TAction, readonly ShortcutKey[]>
-> = ACTION_DEFAULT_SHORTCUTS;
-
-export function getActionDefinition({
- action,
-}: {
- action: TAction;
-}): TActionDefinition {
- return {
- ...ACTIONS[action],
- defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
- };
-}
-
-export function getDefaultShortcuts(): KeybindingConfig {
- const shortcuts: KeybindingConfig = {};
-
- for (const [action, defaultShortcuts] of Object.entries(
- ACTION_DEFAULT_SHORTCUTS,
- ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
- for (const shortcut of defaultShortcuts) {
- shortcuts[shortcut] = action;
- }
- }
-
- return shortcuts;
-}
+import type {
+ KeybindingConfig,
+ ShortcutKey,
+} from "@/lib/actions/keybinding";
+import type { TActionWithOptionalArgs } from "./types";
+
+export type TActionCategory =
+ | "playback"
+ | "navigation"
+ | "editing"
+ | "selection"
+ | "history"
+ | "timeline"
+ | "controls"
+ | "assets";
+
+export interface TActionBaseDefinition {
+ description: string;
+ category: TActionCategory;
+ args?: Record<string, unknown>;
+}
+
+export interface TActionDefinition extends TActionBaseDefinition {
+ defaultShortcuts?: readonly ShortcutKey[];
+}
+
+export const ACTIONS = {
+ "toggle-play": {
+ description: "Play/Pause",
+ category: "playback",
+ },
+ "stop-playback": {
+ description: "Stop playback",
+ category: "playback",
+ },
+ "seek-forward": {
+ description: "Seek forward 1 second",
+ category: "playback",
+ args: { seconds: "number" },
+ },
+ "seek-backward": {
+ description: "Seek backward 1 second",
+ category: "playback",
+ args: { seconds: "number" },
+ },
+ "frame-step-forward": {
+ description: "Frame step forward",
+ category: "navigation",
+ },
+ "frame-step-backward": {
+ description: "Frame step backward",
+ category: "navigation",
+ },
+ "jump-forward": {
+ description: "Jump forward 5 seconds",
+ category: "navigation",
+ args: { seconds: "number" },
+ },
+ "jump-backward": {
+ description: "Jump backward 5 seconds",
+ category: "navigation",
+ args: { seconds: "number" },
+ },
+ "goto-start": {
+ description: "Go to timeline start",
+ category: "navigation",
+ },
+ "goto-end": {
+ description: "Go to timeline end",
+ category: "navigation",
+ },
+ split: {
+ description: "Split elements at playhead",
+ category: "editing",
+ },
+ "split-left": {
+ description: "Split and remove left",
+ category: "editing",
+ },
+ "split-right": {
+ description: "Split and remove right",
+ category: "editing",
+ },
+ "delete-selected": {
+ description: "Delete current selection",
+ category: "editing",
+ },
+ "copy-selected": {
+ description: "Copy selected elements",
+ category: "editing",
+ },
+ "paste-copied": {
+ description: "Paste elements at playhead",
+ category: "editing",
+ },
+ "toggle-snapping": {
+ description: "Toggle snapping",
+ category: "editing",
+ },
+ "toggle-ripple-editing": {
+ description: "Toggle ripple editing",
+ category: "editing",
+ },
+ "toggle-source-audio": {
+ description: "Extract or recover source audio",
+ category: "editing",
+ },
+ "select-all": {
+ description: "Select all elements",
+ category: "selection",
+ },
+ "cancel-interaction": {
+ description: "Cancel current interaction",
+ category: "controls",
+ },
+ "deselect-all": {
+ description: "Deselect all elements",
+ category: "selection",
+ },
+ "duplicate-selected": {
+ description: "Duplicate selected element",
+ category: "selection",
+ },
+ "toggle-elements-muted-selected": {
+ description: "Mute/unmute selected elements",
+ category: "selection",
+ },
+ "toggle-elements-visibility-selected": {
+ description: "Show/hide selected elements",
+ category: "selection",
+ },
+ "toggle-bookmark": {
+ description: "Toggle bookmark at playhead",
+ category: "timeline",
+ },
+ undo: {
+ description: "Undo",
+ category: "history",
+ },
+ redo: {
+ description: "Redo",
+ category: "history",
+ },
+ "remove-media-asset": {
+ description: "Remove media asset",
+ category: "assets",
+ args: { projectId: "string", assetId: "string" },
+ },
+ "remove-media-assets": {
+ description: "Remove media assets",
+ category: "assets",
+ args: { projectId: "string", assetIds: "string[]" },
+ },
+} as const satisfies Record<string, TActionBaseDefinition>;
+
+export type TAction = keyof typeof ACTIONS;
+
+const ACTION_DEFAULT_SHORTCUTS = {
+ "toggle-play": ["space", "k"],
+ "seek-forward": ["l"],
+ "seek-backward": ["j"],
+ "frame-step-forward": ["right"],
+ "frame-step-backward": ["left"],
+ "jump-forward": ["shift+right"],
+ "jump-backward": ["shift+left"],
+ "goto-start": ["home", "enter"],
+ "goto-end": ["end"],
+ split: ["s"],
+ "split-left": ["q"],
+ "split-right": ["w"],
+ "delete-selected": ["backspace", "delete"],
+ "copy-selected": ["ctrl+c"],
+ "paste-copied": ["ctrl+v"],
+ "toggle-snapping": ["n"],
+ "select-all": ["ctrl+a"],
+ "cancel-interaction": ["escape"],
+ "duplicate-selected": ["ctrl+d"],
+ undo: ["ctrl+z"],
+ redo: ["ctrl+shift+z", "ctrl+y"],
+} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
+
+const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
+ Record<TAction, readonly ShortcutKey[]>
+> = ACTION_DEFAULT_SHORTCUTS;
+
+export function getActionDefinition({
+ action,
+}: {
+ action: TAction;
+}): TActionDefinition {
+ return {
+ ...ACTIONS[action],
+ defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
+ };
+}
+
+export function getDefaultShortcuts(): KeybindingConfig {
+ const shortcuts: KeybindingConfig = {};
+
+ for (const [action, defaultShortcuts] of Object.entries(
+ ACTION_DEFAULT_SHORTCUTS,
+ ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
+ for (const shortcut of defaultShortcuts) {
+ shortcuts[shortcut] = action;
+ }
+ }
+
+ return shortcuts;
+}
diff --git a/apps/web/src/lib/changelog/entries/0.4.0.md b/apps/web/src/lib/changelog/entries/0.4.0.md
index 1c1257e2..e20c2785 100644
--- a/apps/web/src/lib/changelog/entries/0.4.0.md
+++ b/apps/web/src/lib/changelog/entries/0.4.0.md
@@ -6,4 +6,8 @@ title: "Title"
description: "Description"
changes:
- type: new
- text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight." \ No newline at end of file
+ text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
+- type: new
+ text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
+- type: fixed
+ text: "Preview panning now keeps working while the Masks tab is open." \ No newline at end of file
diff --git a/apps/web/src/lib/commands/base-command.ts b/apps/web/src/lib/commands/base-command.ts
index a8ce1d34..2d0d04b7 100644
--- a/apps/web/src/lib/commands/base-command.ts
+++ b/apps/web/src/lib/commands/base-command.ts
@@ -1,7 +1,7 @@
-import type { ElementRef } from "@/lib/timeline/types";
+import type { EditorSelectionPatch } from "@/lib/selection/editor-selection";
export interface CommandResult {
- select?: ElementRef[];
+ selection?: EditorSelectionPatch;
}
export abstract class Command {
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
index 8d93c6ba..0fc7948f 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -54,7 +54,12 @@ export class DeleteElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks);
return {
- select: [],
+ selection: {
+ selectedElements: [],
+ selectedKeyframes: [],
+ keyframeSelectionAnchor: null,
+ selectedMaskPoints: null,
+ },
};
}
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index 0ac11ebd..0e806d6e 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -102,18 +102,25 @@ export class InsertElementCommand extends Command {
});
}
- if (asset?.type === "video" && asset?.fps) {
- editor.project.updateSettings({
- settings: { fps: floatToFrameRate(asset.fps) },
- pushHistory: false,
- });
- }
+ if (asset?.type === "video" && asset?.fps) {
+ editor.project.updateSettings({
+ settings: { fps: floatToFrameRate(asset.fps) },
+ pushHistory: false,
+ });
+ }
}
editor.timeline.updateTracks(updatedTracks);
return {
- select: [{ trackId: targetTrackId, elementId: this.elementId }],
+ selection: {
+ selectedElements: [
+ { trackId: targetTrackId, elementId: this.elementId },
+ ],
+ selectedKeyframes: [],
+ keyframeSelectionAnchor: null,
+ selectedMaskPoints: null,
+ },
};
}
@@ -173,7 +180,10 @@ export class InsertElementCommand extends Command {
if (element.type === "graphic") {
registerDefaultGraphics();
- if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
+ if (
+ !element.definitionId ||
+ !graphicsRegistry.has(element.definitionId)
+ ) {
console.error("Graphic element must have a valid definitionId");
return false;
}
@@ -236,8 +246,8 @@ export class InsertElementCommand extends Command {
const targetTrack =
tracks.main.id === placement.trackId
? tracks.main
- : tracks.overlay.find((track) => track.id === placement.trackId) ??
- tracks.audio.find((track) => track.id === placement.trackId);
+ : (tracks.overlay.find((track) => track.id === placement.trackId) ??
+ tracks.audio.find((track) => track.id === placement.trackId));
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
@@ -257,8 +267,7 @@ export class InsertElementCommand extends Command {
placementResult.kind === "existingTrack"
? {
...element,
- startTime:
- placementResult.adjustedStartTime ?? element.startTime,
+ startTime: placementResult.adjustedStartTime ?? element.startTime,
}
: element;
diff --git a/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts
new file mode 100644
index 00000000..346eb8d9
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts
@@ -0,0 +1,131 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import {
+ getCustomMaskClosedStateAfterPointRemoval,
+ removeCustomMaskPoints,
+} from "@/lib/masks/custom-path";
+import type { CustomMask } from "@/lib/masks/types";
+import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
+import type { MaskableElement, SceneTracks } from "@/lib/timeline";
+
+function deletePointsFromCustomMask({
+ mask,
+ pointIds,
+}: {
+ mask: CustomMask;
+ pointIds: string[];
+}): CustomMask {
+ const points = mask.params.path;
+ const nextPoints = removeCustomMaskPoints({ points, pointIds });
+ if (nextPoints.length === points.length) {
+ return mask;
+ }
+
+ return {
+ ...mask,
+ params: {
+ ...mask.params,
+ path: nextPoints,
+ closed: getCustomMaskClosedStateAfterPointRemoval({
+ wasClosed: mask.params.closed,
+ remainingPointCount: nextPoints.length,
+ }),
+ },
+ };
+}
+
+function deletePointsFromElementMask({
+ element,
+ maskId,
+ pointIds,
+}: {
+ element: MaskableElement;
+ maskId: string;
+ pointIds: string[];
+}): { element: MaskableElement; didDeletePoints: boolean } {
+ const currentMasks = element.masks ?? [];
+ let didDeletePoints = false;
+ const nextMasks = currentMasks.map((mask) => {
+ if (mask.id !== maskId || mask.type !== "custom") {
+ return mask;
+ }
+
+ const nextMask = deletePointsFromCustomMask({
+ mask,
+ pointIds,
+ });
+ didDeletePoints ||= nextMask !== mask;
+ return nextMask;
+ });
+
+ return {
+ element: didDeletePoints ? { ...element, masks: nextMasks } : element,
+ didDeletePoints,
+ };
+}
+
+export class DeleteCustomMaskPointsCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+ private readonly pointIds: string[];
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ pointIds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ pointIds: string[];
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ this.pointIds = pointIds;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ let didDeletePoints = false;
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) => {
+ const result = deletePointsFromElementMask({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ pointIds: this.pointIds,
+ });
+ didDeletePoints ||= result.didDeletePoints;
+ return result.element;
+ },
+ });
+
+ if (didDeletePoints) {
+ editor.timeline.updateTracks(updatedTracks);
+ return {
+ selection: {
+ selectedMaskPoints: null,
+ },
+ };
+ }
+
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts
index acc47184..dd6373b1 100644
--- a/apps/web/src/lib/commands/timeline/element/masks/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts
@@ -1,2 +1,4 @@
+export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
+export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts
new file mode 100644
index 00000000..9f837836
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts
@@ -0,0 +1,169 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import { insertPointOnCustomMaskSegment } from "@/lib/masks/definitions/custom";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { CustomMask } from "@/lib/masks/types";
+import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
+import type { MaskableElement, SceneTracks } from "@/lib/timeline";
+
+function insertPointIntoCustomMask({
+ mask,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+}: {
+ mask: CustomMask;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+}): { mask: CustomMask; insertedPointId: string | null } {
+ const result = insertPointOnCustomMaskSegment({
+ params: mask.params,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ });
+ if (!result) {
+ return {
+ mask,
+ insertedPointId: null,
+ };
+ }
+
+ return {
+ mask: {
+ ...mask,
+ params: result.params,
+ },
+ insertedPointId: result.pointId,
+ };
+}
+
+function insertPointIntoElementMask({
+ element,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+}: {
+ element: MaskableElement;
+ maskId: string;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+}): {
+ element: MaskableElement;
+ didInsertPoint: boolean;
+ insertedPointId: string | null;
+} {
+ const currentMasks = element.masks ?? [];
+ let insertedPointId: string | null = null;
+ let didInsertPoint = false;
+
+ const nextMasks = currentMasks.map((mask) => {
+ if (mask.id !== maskId || mask.type !== "custom") {
+ return mask;
+ }
+
+ const result = insertPointIntoCustomMask({
+ mask,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ });
+ if (result.insertedPointId) {
+ insertedPointId = result.insertedPointId;
+ didInsertPoint = true;
+ }
+ return result.mask;
+ });
+
+ return {
+ element: didInsertPoint ? { ...element, masks: nextMasks } : element,
+ didInsertPoint,
+ insertedPointId,
+ };
+}
+
+export class InsertCustomMaskPointCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+ private readonly segmentIndex: number;
+ private readonly canvasPoint: { x: number; y: number };
+ private readonly bounds: ElementBounds;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ this.segmentIndex = segmentIndex;
+ this.canvasPoint = canvasPoint;
+ this.bounds = bounds;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ let didInsertPoint = false;
+ let insertedPointId: string | null = null;
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) => {
+ const result = insertPointIntoElementMask({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ segmentIndex: this.segmentIndex,
+ canvasPoint: this.canvasPoint,
+ bounds: this.bounds,
+ });
+ didInsertPoint ||= result.didInsertPoint;
+ insertedPointId ??= result.insertedPointId;
+ return result.element;
+ },
+ });
+
+ if (!didInsertPoint || !insertedPointId) {
+ return undefined;
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ return {
+ selection: {
+ selectedMaskPoints: {
+ trackId: this.trackId,
+ elementId: this.elementId,
+ maskId: this.maskId,
+ pointIds: [insertedPointId],
+ },
+ },
+ };
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts
index 72b2ca80..e2f9cafb 100644
--- a/apps/web/src/lib/masks/__tests__/snap.test.ts
+++ b/apps/web/src/lib/masks/__tests__/snap.test.ts
@@ -1,9 +1,26 @@
import { describe, expect, test } from "bun:test";
+import {
+ findClosestPointOnCustomMaskSegment,
+ getCustomMaskClosedStateAfterPointRemoval,
+ insertPointIntoCustomMaskSegment,
+ removeCustomMaskPoints,
+} from "@/lib/masks/custom-path";
+import {
+ appendPointToCustomMask,
+ customMaskDefinition,
+ insertPointOnCustomMaskSegment,
+} from "@/lib/masks/definitions/custom";
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
+import { textMaskDefinition } from "@/lib/masks/definitions/text";
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
import { snapMaskInteraction } from "@/lib/masks/snap";
import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
+import type {
+ CustomMaskParams,
+ RectangleMaskParams,
+ SplitMaskParams,
+ TextMaskParams,
+} from "@/lib/masks/types";
const bounds: ElementBounds = {
cx: 200,
@@ -58,6 +75,78 @@ function buildRectangleParams(
};
}
+function buildTextMaskParams(
+ overrides: Partial<TextMaskParams> = {},
+): TextMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ content: "Mask",
+ fontSize: 15,
+ fontFamily: "Arial",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: 0,
+ lineHeight: 1.2,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
+function buildCustomMaskParams(
+ overrides: Partial<CustomMaskParams> = {},
+): CustomMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ path: [
+ {
+ id: "a",
+ x: -0.2,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ {
+ id: "b",
+ x: 0.2,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ {
+ id: "c",
+ x: 0,
+ y: 0.2,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ ],
+ closed: true,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] {
@@ -234,4 +323,165 @@ describe("mask snapping", () => {
expect(result.params.height).toBe(0.5);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
+
+ test("snaps text mask movement using intrinsic text bounds", () => {
+ const params = buildTextMaskParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ });
+ const result = textMaskDefinition.interaction.snap?.({
+ handleId: "position",
+ startParams: params,
+ proposedParams: params,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result?.params.centerX).toBe(0);
+ expect(result?.params.centerY).toBe(0);
+ expect(result?.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps custom mask movement using path geometry bounds", () => {
+ const params = buildCustomMaskParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ });
+ const result = customMaskDefinition.interaction.snap?.({
+ handleId: "position",
+ startParams: params,
+ proposedParams: params,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result?.params.centerX).toBe(0);
+ expect(result?.params.centerY).toBe(0);
+ expect(result?.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("marks blank text masks inactive", () => {
+ expect(
+ textMaskDefinition.isActive?.(
+ buildTextMaskParams({
+ content: " ",
+ }),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("custom mask creation", () => {
+ test("anchors the first point at the click position", () => {
+ const params = buildCustomMaskParams({
+ path: [],
+ closed: false,
+ });
+ const next = appendPointToCustomMask({
+ params,
+ canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
+ bounds,
+ });
+
+ expect(next.centerX).toBeCloseTo(0.1);
+ expect(next.centerY).toBeCloseTo(-0.1);
+ expect(next.rotation).toBe(0);
+ expect(next.scale).toBe(1);
+ expect(next.path).toHaveLength(1);
+ });
+});
+
+describe("custom mask point deletion", () => {
+ test("removes the selected points by id", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = removeCustomMaskPoints({
+ points,
+ pointIds: ["b"],
+ });
+
+ expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
+ });
+
+ test("reopens a closed path once fewer than three points remain", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = removeCustomMaskPoints({
+ points,
+ pointIds: ["c"],
+ });
+
+ expect(
+ getCustomMaskClosedStateAfterPointRemoval({
+ wasClosed: true,
+ remainingPointCount: nextPoints.length,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("custom mask point insertion", () => {
+ test("finds the closest point on the clicked segment", () => {
+ const params = buildCustomMaskParams();
+ const points = params.path;
+ const closestPoint = findClosestPointOnCustomMaskSegment({
+ points,
+ segmentIndex: 0,
+ canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: params.closed,
+ });
+
+ expect(closestPoint).not.toBeNull();
+ expect(closestPoint?.t).toBeCloseTo(0.5, 1);
+ expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
+ expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
+ });
+
+ test("splits a segment into two segments at the insertion point", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = insertPointIntoCustomMaskSegment({
+ points,
+ segmentIndex: 0,
+ pointId: "new",
+ t: 0.5,
+ closed: true,
+ });
+
+ expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
+ expect(nextPoints[1]).toMatchObject({
+ id: "new",
+ x: 0,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ });
+ });
+
+ test("builds updated custom mask params for a clicked segment", () => {
+ const result = insertPointOnCustomMaskSegment({
+ params: buildCustomMaskParams(),
+ segmentIndex: 0,
+ canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
+ bounds,
+ pointId: "new",
+ });
+
+ expect(result).not.toBeNull();
+ const nextPoints = result?.params.path ?? [];
+ expect(nextPoints).toHaveLength(4);
+ expect(nextPoints.some((point) => point.id === "new")).toBe(true);
+ });
});
diff --git a/apps/web/src/lib/masks/custom-path.ts b/apps/web/src/lib/masks/custom-path.ts
new file mode 100644
index 00000000..94a9043c
--- /dev/null
+++ b/apps/web/src/lib/masks/custom-path.ts
@@ -0,0 +1,842 @@
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+
+export interface CustomMaskPathPoint {
+ id: string;
+ x: number;
+ y: number;
+ inX: number;
+ inY: number;
+ outX: number;
+ outY: number;
+}
+
+export type CustomMaskHandlePart = "anchor" | "in" | "out";
+
+function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+
+ const candidate = value as Record<string, unknown>;
+ return (
+ typeof candidate.id === "string" &&
+ typeof candidate.x === "number" &&
+ typeof candidate.y === "number" &&
+ typeof candidate.inX === "number" &&
+ typeof candidate.inY === "number" &&
+ typeof candidate.outX === "number" &&
+ typeof candidate.outY === "number"
+ );
+}
+
+export function parseCustomMaskHandleId({
+ handleId,
+}: {
+ handleId: string;
+}): { pointId: string; part: CustomMaskHandlePart } | null {
+ const match = /^point:(.+):(anchor|in|out)$/.exec(handleId);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ pointId: match[1],
+ part: match[2] as CustomMaskHandlePart,
+ };
+}
+
+export function parseCustomMaskSegmentHandleId({
+ handleId,
+}: {
+ handleId: string;
+}): { segmentIndex: number } | null {
+ const match = /^segment:(\d+)$/.exec(handleId);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ segmentIndex: Number.parseInt(match[1], 10),
+ };
+}
+
+export function parseCustomMaskPath({
+ path,
+}: {
+ path: string;
+}): CustomMaskPathPoint[] {
+ if (!path) {
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(path);
+ return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function serializeCustomMaskPath({
+ points,
+}: {
+ points: CustomMaskPathPoint[];
+}): string {
+ return JSON.stringify(points);
+}
+
+export function removeCustomMaskPoints({
+ points,
+ pointIds,
+}: {
+ points: CustomMaskPathPoint[];
+ pointIds: string[];
+}): CustomMaskPathPoint[] {
+ if (pointIds.length === 0) {
+ return points;
+ }
+ const pointIdsToRemove = new Set(pointIds);
+ return points.filter((point) => !pointIdsToRemove.has(point.id));
+}
+
+export function getCustomMaskClosedStateAfterPointRemoval({
+ wasClosed,
+ remainingPointCount,
+}: {
+ wasClosed: boolean;
+ remainingPointCount: number;
+}): boolean {
+ return wasClosed && remainingPointCount >= 3;
+}
+
+function rotatePoint({
+ x,
+ y,
+ rotationDegrees,
+}: {
+ x: number;
+ y: number;
+ rotationDegrees: number;
+}): { x: number; y: number } {
+ const angleRad = (rotationDegrees * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ return {
+ x: x * cos - y * sin,
+ y: x * sin + y * cos,
+ };
+}
+
+export function getCustomMaskCenterCanvasPoint({
+ centerX,
+ centerY,
+ bounds,
+}: {
+ centerX: number;
+ centerY: number;
+ bounds: ElementBounds;
+}): { x: number; y: number } {
+ return {
+ x: bounds.cx + centerX * bounds.width,
+ y: bounds.cy + centerY * bounds.height,
+ };
+}
+
+export function customMaskLocalPointToCanvas({
+ point,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+}: {
+ point: { x: number; y: number };
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+}): { x: number; y: number } {
+ const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
+ const scaledLocal = {
+ x: point.x * bounds.width * scale,
+ y: point.y * bounds.height * scale,
+ };
+ const rotated = rotatePoint({
+ x: scaledLocal.x,
+ y: scaledLocal.y,
+ rotationDegrees: rotation,
+ });
+
+ return {
+ x: center.x + rotated.x,
+ y: center.y + rotated.y,
+ };
+}
+
+export function customMaskCanvasPointToLocal({
+ point,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+}: {
+ point: { x: number; y: number };
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+}): { x: number; y: number } {
+ const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
+ const translated = {
+ x: point.x - center.x,
+ y: point.y - center.y,
+ };
+ const rotated = rotatePoint({
+ x: translated.x,
+ y: translated.y,
+ rotationDegrees: -rotation,
+ });
+
+ return {
+ x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale),
+ y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale),
+ };
+}
+
+export function getCustomMaskCanvasGeometry({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+}: {
+ points: CustomMaskPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+}) {
+ const anchors = points.map((point) => ({
+ id: point.id,
+ anchor: customMaskLocalPointToCanvas({
+ point: { x: point.x, y: point.y },
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ }),
+ inHandle: customMaskLocalPointToCanvas({
+ point: { x: point.x + point.inX, y: point.y + point.inY },
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ }),
+ outHandle: customMaskLocalPointToCanvas({
+ point: { x: point.x + point.outX, y: point.y + point.outY },
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ }),
+ }));
+
+ const geometryPoints = anchors.flatMap((point) => [
+ point.anchor,
+ point.inHandle,
+ point.outHandle,
+ ]);
+ if (geometryPoints.length === 0) {
+ return {
+ anchors,
+ bounds: null,
+ };
+ }
+
+ const geometryBounds = getCanvasPointBounds({
+ points: geometryPoints,
+ });
+
+ return {
+ anchors,
+ bounds: geometryBounds,
+ };
+}
+
+export interface CanvasPoint {
+ x: number;
+ y: number;
+}
+
+function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ width: number;
+ height: number;
+ centerX: number;
+ centerY: number;
+} | null {
+ if (points.length === 0) {
+ return null;
+ }
+
+ const [firstPoint, ...restPoints] = points;
+ let minX = firstPoint.x;
+ let maxX = firstPoint.x;
+ let minY = firstPoint.y;
+ let maxY = firstPoint.y;
+
+ for (const point of restPoints) {
+ minX = Math.min(minX, point.x);
+ maxX = Math.max(maxX, point.x);
+ minY = Math.min(minY, point.y);
+ maxY = Math.max(maxY, point.y);
+ }
+
+ return {
+ minX,
+ maxX,
+ minY,
+ maxY,
+ width: Math.max(1, maxX - minX),
+ height: Math.max(1, maxY - minY),
+ centerX: (minX + maxX) / 2,
+ centerY: (minY + maxY) / 2,
+ };
+}
+
+export interface CustomMaskCanvasSegment {
+ index: number;
+ startPointId: string;
+ endPointId: string;
+ start: CanvasPoint;
+ startOut: CanvasPoint;
+ endIn: CanvasPoint;
+ end: CanvasPoint;
+ pathData: string;
+}
+
+function clampUnit(value: number): number {
+ return Math.min(1, Math.max(0, value));
+}
+
+function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return dx * dx + dy * dy;
+}
+
+function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint {
+ return {
+ x: a.x + (b.x - a.x) * t,
+ y: a.y + (b.y - a.y) * t,
+ };
+}
+
+function evaluateCubicBezier({
+ p0,
+ p1,
+ p2,
+ p3,
+ t,
+}: {
+ p0: CanvasPoint;
+ p1: CanvasPoint;
+ p2: CanvasPoint;
+ p3: CanvasPoint;
+ t: number;
+}): CanvasPoint {
+ const oneMinusT = 1 - t;
+ return {
+ x:
+ oneMinusT ** 3 * p0.x +
+ 3 * oneMinusT ** 2 * t * p1.x +
+ 3 * oneMinusT * t ** 2 * p2.x +
+ t ** 3 * p3.x,
+ y:
+ oneMinusT ** 3 * p0.y +
+ 3 * oneMinusT ** 2 * t * p1.y +
+ 3 * oneMinusT * t ** 2 * p2.y +
+ t ** 3 * p3.y,
+ };
+}
+
+function getCustomMaskSegmentIndices({
+ points,
+ segmentIndex,
+ closed,
+}: {
+ points: CustomMaskPathPoint[];
+ segmentIndex: number;
+ closed: boolean;
+}): { startIndex: number; endIndex: number } | null {
+ const segmentCount = getCustomMaskSegmentCount({ points, closed });
+ if (segmentIndex < 0 || segmentIndex >= segmentCount) {
+ return null;
+ }
+
+ return {
+ startIndex: segmentIndex,
+ endIndex: (segmentIndex + 1) % points.length,
+ };
+}
+
+export function getCustomMaskSegmentCount({
+ points,
+ closed,
+}: {
+ points: CustomMaskPathPoint[];
+ closed: boolean;
+}): number {
+ if (points.length < 2) {
+ return 0;
+ }
+ return closed ? points.length : points.length - 1;
+}
+
+export function getCustomMaskCanvasSegments({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: CustomMaskPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+ closed: boolean;
+}): CustomMaskCanvasSegment[] {
+ const geometry = getCustomMaskCanvasGeometry({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ });
+ const segmentCount = getCustomMaskSegmentCount({ points, closed });
+
+ return Array.from({ length: segmentCount }, (_, segmentIndex) => {
+ const start = geometry.anchors[segmentIndex];
+ const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length];
+ return {
+ index: segmentIndex,
+ startPointId: start.id,
+ endPointId: end.id,
+ start: start.anchor,
+ startOut: start.outHandle,
+ endIn: end.inHandle,
+ end: end.anchor,
+ pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`,
+ };
+ });
+}
+
+export function findClosestPointOnCustomMaskSegment({
+ points,
+ segmentIndex,
+ canvasPoint,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: CustomMaskPathPoint[];
+ segmentIndex: number;
+ canvasPoint: CanvasPoint;
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+ closed: boolean;
+}): { t: number; point: CanvasPoint } | null {
+ const segment = getCustomMaskCanvasSegments({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+ }).find((candidate) => candidate.index === segmentIndex);
+ if (!segment) {
+ return null;
+ }
+
+ const sampleCount = 24;
+ let bestT = 0;
+ let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start);
+
+ for (let step = 0; step <= sampleCount; step++) {
+ const t = step / sampleCount;
+ const point = evaluateCubicBezier({
+ p0: segment.start,
+ p1: segment.startOut,
+ p2: segment.endIn,
+ p3: segment.end,
+ t,
+ });
+ const distanceSquared = getDistanceSquared(canvasPoint, point);
+ if (distanceSquared < bestDistanceSquared) {
+ bestDistanceSquared = distanceSquared;
+ bestT = t;
+ }
+ }
+
+ let searchStep = 1 / sampleCount;
+ for (let iteration = 0; iteration < 8; iteration++) {
+ const candidates = [bestT - searchStep, bestT, bestT + searchStep]
+ .map(clampUnit)
+ .map((t) => ({
+ t,
+ point: evaluateCubicBezier({
+ p0: segment.start,
+ p1: segment.startOut,
+ p2: segment.endIn,
+ p3: segment.end,
+ t,
+ }),
+ }));
+ for (const candidate of candidates) {
+ const distanceSquared = getDistanceSquared(canvasPoint, candidate.point);
+ if (distanceSquared < bestDistanceSquared) {
+ bestDistanceSquared = distanceSquared;
+ bestT = candidate.t;
+ }
+ }
+ searchStep /= 2;
+ }
+
+ const clampedT = Math.min(0.999, Math.max(0.001, bestT));
+ return {
+ t: clampedT,
+ point: evaluateCubicBezier({
+ p0: segment.start,
+ p1: segment.startOut,
+ p2: segment.endIn,
+ p3: segment.end,
+ t: clampedT,
+ }),
+ };
+}
+
+export function insertPointIntoCustomMaskSegment({
+ points,
+ segmentIndex,
+ pointId,
+ t,
+ closed,
+}: {
+ points: CustomMaskPathPoint[];
+ segmentIndex: number;
+ pointId: string;
+ t: number;
+ closed: boolean;
+}): CustomMaskPathPoint[] {
+ const indices = getCustomMaskSegmentIndices({
+ points,
+ segmentIndex,
+ closed,
+ });
+ if (!indices) {
+ return points;
+ }
+
+ const startPoint = points[indices.startIndex];
+ const endPoint = points[indices.endIndex];
+ const clampedT = Math.min(0.999, Math.max(0.001, t));
+ const p0 = { x: startPoint.x, y: startPoint.y };
+ const p1 = {
+ x: startPoint.x + startPoint.outX,
+ y: startPoint.y + startPoint.outY,
+ };
+ const p2 = {
+ x: endPoint.x + endPoint.inX,
+ y: endPoint.y + endPoint.inY,
+ };
+ const p3 = { x: endPoint.x, y: endPoint.y };
+ const p01 = lerpPoint(p0, p1, clampedT);
+ const p12 = lerpPoint(p1, p2, clampedT);
+ const p23 = lerpPoint(p2, p3, clampedT);
+ const p012 = lerpPoint(p01, p12, clampedT);
+ const p123 = lerpPoint(p12, p23, clampedT);
+ const splitPoint = lerpPoint(p012, p123, clampedT);
+
+ const nextPoints = [...points];
+ nextPoints[indices.startIndex] = {
+ ...startPoint,
+ outX: p01.x - startPoint.x,
+ outY: p01.y - startPoint.y,
+ };
+ nextPoints[indices.endIndex] = {
+ ...endPoint,
+ inX: p23.x - endPoint.x,
+ inY: p23.y - endPoint.y,
+ };
+ nextPoints.splice(indices.endIndex, 0, {
+ id: pointId,
+ x: splitPoint.x,
+ y: splitPoint.y,
+ inX: p012.x - splitPoint.x,
+ inY: p012.y - splitPoint.y,
+ outX: p123.x - splitPoint.x,
+ outY: p123.y - splitPoint.y,
+ });
+ return nextPoints;
+}
+
+export function getCustomMaskLocalBounds({
+ points,
+ bounds,
+}: {
+ points: CustomMaskPathPoint[];
+ bounds: ElementBounds;
+}) {
+ if (points.length === 0) {
+ return null;
+ }
+
+ const values = points.flatMap((point) => [
+ { x: point.x * bounds.width, y: point.y * bounds.height },
+ {
+ x: (point.x + point.inX) * bounds.width,
+ y: (point.y + point.inY) * bounds.height,
+ },
+ {
+ x: (point.x + point.outX) * bounds.width,
+ y: (point.y + point.outY) * bounds.height,
+ },
+ ]);
+
+ const localBounds = getCanvasPointBounds({
+ points: values,
+ });
+ if (!localBounds) {
+ return null;
+ }
+
+ return {
+ width: localBounds.width,
+ height: localBounds.height,
+ };
+}
+
+export function recenterCustomMaskPath({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+}: {
+ points: CustomMaskPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+}) {
+ if (points.length === 0) {
+ return { centerX, centerY, points };
+ }
+
+ const geometry = getCustomMaskCanvasGeometry({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ });
+ if (!geometry.bounds) {
+ return { centerX, centerY, points };
+ }
+
+ const nextCenterCanvas = {
+ x: geometry.bounds.centerX,
+ y: geometry.bounds.centerY,
+ };
+ const nextCenterLocal = {
+ x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width,
+ y:
+ bounds.height === 0
+ ? 0
+ : (nextCenterCanvas.y - bounds.cy) / bounds.height,
+ };
+
+ const nextPoints = geometry.anchors.map((point) => {
+ const anchor = customMaskCanvasPointToLocal({
+ point: point.anchor,
+ centerX: nextCenterLocal.x,
+ centerY: nextCenterLocal.y,
+ rotation,
+ scale,
+ bounds,
+ });
+ const inHandle = customMaskCanvasPointToLocal({
+ point: point.inHandle,
+ centerX: nextCenterLocal.x,
+ centerY: nextCenterLocal.y,
+ rotation,
+ scale,
+ bounds,
+ });
+ const outHandle = customMaskCanvasPointToLocal({
+ point: point.outHandle,
+ centerX: nextCenterLocal.x,
+ centerY: nextCenterLocal.y,
+ rotation,
+ scale,
+ bounds,
+ });
+
+ return {
+ id: point.id,
+ x: anchor.x,
+ y: anchor.y,
+ inX: inHandle.x - anchor.x,
+ inY: inHandle.y - anchor.y,
+ outX: outHandle.x - anchor.x,
+ outY: outHandle.y - anchor.y,
+ };
+ });
+
+ return {
+ centerX: nextCenterLocal.x,
+ centerY: nextCenterLocal.y,
+ points: nextPoints,
+ };
+}
+
+export function buildCustomMaskPath2D({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: CustomMaskPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+ closed: boolean;
+}): Path2D {
+ const path = new Path2D();
+ if (points.length === 0) {
+ return path;
+ }
+
+ const geometry = getCustomMaskCanvasGeometry({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ });
+ const anchors = geometry.anchors;
+ path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y);
+
+ for (let index = 1; index < anchors.length; index++) {
+ const previous = anchors[index - 1];
+ const current = anchors[index];
+ path.bezierCurveTo(
+ previous.outHandle.x,
+ previous.outHandle.y,
+ current.inHandle.x,
+ current.inHandle.y,
+ current.anchor.x,
+ current.anchor.y,
+ );
+ }
+
+ if (closed && anchors.length > 1) {
+ const last = anchors[anchors.length - 1];
+ const first = anchors[0];
+ path.bezierCurveTo(
+ last.outHandle.x,
+ last.outHandle.y,
+ first.inHandle.x,
+ first.inHandle.y,
+ first.anchor.x,
+ first.anchor.y,
+ );
+ path.closePath();
+ }
+
+ return path;
+}
+
+export function buildCustomMaskSvgPath({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: CustomMaskPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+ closed: boolean;
+}): string {
+ if (points.length === 0) {
+ return "";
+ }
+
+ const geometry = getCustomMaskCanvasGeometry({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ });
+ const anchors = geometry.anchors;
+ const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`];
+
+ for (let index = 1; index < anchors.length; index++) {
+ const previous = anchors[index - 1];
+ const current = anchors[index];
+ segments.push(
+ `C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`,
+ );
+ }
+
+ if (closed && anchors.length > 1) {
+ const last = anchors[anchors.length - 1];
+ const first = anchors[0];
+ segments.push(
+ `C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`,
+ );
+ segments.push("Z");
+ }
+
+ return segments.join(" ");
+}
diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts
index cf42a9f0..fe0f1a85 100644
--- a/apps/web/src/lib/masks/definitions/box-like.ts
+++ b/apps/web/src/lib/masks/definitions/box-like.ts
@@ -1,270 +1,313 @@
-import {
- DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
- MIN_MASK_DIMENSION,
-} from "@/lib/masks/dimensions";
-import { computeFeatherUpdate } from "../param-update";
-import type {
- BaseMaskParams,
- MaskDefaultContext,
- MaskParamUpdateArgs,
- RectangleMaskParams,
-} from "@/lib/masks/types";
-import type {
- NumberParamDefinition,
- ParamDefinition,
- ParamValues,
-} from "@/lib/params";
-
-const PERCENTAGE_DISPLAY: Pick<
- NumberParamDefinition,
- "displayMultiplier" | "step"
-> = {
- displayMultiplier: 100,
- step: 1,
-};
-
-export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
- keyof RectangleMaskParams & string
->[] = [
- {
- key: "centerX",
- label: "X",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "centerY",
- label: "Y",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "width",
- label: "Width",
- type: "number",
- default: 0.6,
- min: 1,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "height",
- label: "Height",
- type: "number",
- default: 0.6,
- min: 1,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "rotation",
- label: "Rotation",
- type: "number",
- default: 0,
- min: 0,
- max: 360,
- step: 1,
- },
- {
- key: "scale",
- label: "Scale",
- type: "number",
- default: 1,
- min: 1,
- max: 500,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "strokeAlign",
- label: "Stroke Align",
- type: "select",
- default: "center",
- options: [
- { value: "inside", label: "Inside" },
- { value: "center", label: "Center" },
- { value: "outside", label: "Outside" },
- ],
- },
-];
-
-export function getDefaultBaseMaskParams(): BaseMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- };
-}
-
-export function getStrokeOffset({
- strokeAlign,
- strokeWidth,
-}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
- if (strokeAlign === "inside") {
- return -(strokeWidth / 2);
- }
-
- if (strokeAlign === "outside") {
- return strokeWidth / 2;
- }
-
- return 0;
-}
-
-export function getDefaultSquareMaskParams({
- elementSize,
-}: MaskDefaultContext): RectangleMaskParams {
- const absWidth = Math.abs(elementSize?.width ?? 0);
- const absHeight = Math.abs(elementSize?.height ?? 0);
- const shortSide = Math.min(absWidth, absHeight);
- const squareSide =
- shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
- const width =
- absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
- const height =
- absHeight > 0
- ? squareSide / absHeight
- : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
-
- return {
- ...getDefaultBaseMaskParams(),
- centerX: 0,
- centerY: 0,
- width,
- height,
- rotation: 0,
- scale: 1,
- };
-}
-
-export function getBoxLikeGeometry({
- params,
- width,
- height,
-}: {
- params: RectangleMaskParams;
- width: number;
- height: number;
-}) {
- return {
- centerX: width / 2 + params.centerX * width,
- centerY: height / 2 + params.centerY * height,
- maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
- maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
- rotationRad: (params.rotation * Math.PI) / 180,
- };
-}
-
-export function computeBoxMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- bounds,
-}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues {
- if (handleId === "position") {
- return {
- centerX: startParams.centerX + deltaX / bounds.width,
- centerY: startParams.centerY + deltaY / bounds.height,
- };
- }
-
- if (handleId === "rotation") {
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
- const newRotation = (startParams.rotation + currentAngle) % 360;
- return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
- }
-
- if (handleId === "feather") {
- const angleRad = (startParams.rotation * Math.PI) / 180;
- return computeFeatherUpdate({
- startFeather: startParams.feather,
- deltaX,
- deltaY,
- directionX: -Math.sin(angleRad),
- directionY: Math.cos(angleRad),
- });
- }
-
- const halfWidth = startParams.width * bounds.width;
- const halfHeight = startParams.height * bounds.height;
-
- if (handleId === "right" || handleId === "left") {
- const sign = handleId === "right" ? 1 : -1;
- return {
- width: Math.max(
- MIN_MASK_DIMENSION,
- startParams.width + (sign * deltaX * 2) / bounds.width,
- ),
- };
- }
-
- if (handleId === "bottom" || handleId === "top") {
- const sign = handleId === "bottom" ? 1 : -1;
- return {
- height: Math.max(
- MIN_MASK_DIMENSION,
- startParams.height + (sign * deltaY * 2) / bounds.height,
- ),
- };
- }
-
- if (
- handleId === "top-left" ||
- handleId === "top-right" ||
- handleId === "bottom-left" ||
- handleId === "bottom-right"
- ) {
- const signX = handleId.includes("right") ? 1 : -1;
- const signY = handleId.includes("bottom") ? 1 : -1;
- const distance = Math.sqrt(
- (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
- );
- const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
- const scale = originalDistance > 0 ? distance / originalDistance : 1;
- return {
- width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
- height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
- };
- }
-
- if (handleId === "scale") {
- const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
- const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
- const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
- return {
- scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
- };
- }
-
- return {};
-}
-
-export function rotatePoint({
- x,
- y,
- centerX,
- centerY,
- rotationRad,
-}: {
- x: number;
- y: number;
- centerX: number;
- centerY: number;
- rotationRad: number;
-}) {
- const dx = x - centerX;
- const dy = y - centerY;
- const cos = Math.cos(rotationRad);
- const sin = Math.sin(rotationRad);
-
- return {
- x: centerX + dx * cos - dy * sin,
- y: centerY + dx * sin + dy * cos,
- };
-}
+import {
+ DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
+ MIN_MASK_DIMENSION,
+} from "@/lib/masks/dimensions";
+import { computeFeatherUpdate } from "../param-update";
+import type {
+ BaseMaskParams,
+ MaskDefaultContext,
+ MaskFeatures,
+ MaskInteractionDefinition,
+ MaskParamUpdateArgs,
+ RectangleMaskParams,
+} from "@/lib/masks/types";
+import type { NumberParamDefinition, ParamDefinition } from "@/lib/params";
+import {
+ getBoxMaskHandlePositions,
+ getBoxMaskOverlays,
+} from "@/lib/masks/handle-positions";
+import { snapMaskInteraction } from "@/lib/masks/snap";
+
+const PERCENTAGE_DISPLAY: Pick<
+ NumberParamDefinition,
+ "displayMultiplier" | "step"
+> = {
+ displayMultiplier: 100,
+ step: 1,
+};
+
+export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
+ keyof RectangleMaskParams & string
+>[] = [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "width",
+ label: "Width",
+ type: "number",
+ default: 0.6,
+ min: 1,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "height",
+ label: "Height",
+ type: "number",
+ default: 0.6,
+ min: 1,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "scale",
+ label: "Scale",
+ type: "number",
+ default: 1,
+ min: 1,
+ max: 500,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "strokeAlign",
+ label: "Stroke Align",
+ type: "select",
+ default: "center",
+ options: [
+ { value: "inside", label: "Inside" },
+ { value: "center", label: "Center" },
+ { value: "outside", label: "Outside" },
+ ],
+ },
+];
+
+export function getDefaultBaseMaskParams(): BaseMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ };
+}
+
+export function getStrokeOffset({
+ strokeAlign,
+ strokeWidth,
+}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
+ if (strokeAlign === "inside") {
+ return -(strokeWidth / 2);
+ }
+
+ if (strokeAlign === "outside") {
+ return strokeWidth / 2;
+ }
+
+ return 0;
+}
+
+export function getDefaultSquareMaskParams({
+ elementSize,
+}: MaskDefaultContext): RectangleMaskParams {
+ const absWidth = Math.abs(elementSize?.width ?? 0);
+ const absHeight = Math.abs(elementSize?.height ?? 0);
+ const shortSide = Math.min(absWidth, absHeight);
+ const squareSide =
+ shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
+ const width =
+ absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
+ const height =
+ absHeight > 0
+ ? squareSide / absHeight
+ : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
+
+ return {
+ ...getDefaultBaseMaskParams(),
+ centerX: 0,
+ centerY: 0,
+ width,
+ height,
+ rotation: 0,
+ scale: 1,
+ };
+}
+
+export function getBoxLikeGeometry({
+ params,
+ width,
+ height,
+}: {
+ params: RectangleMaskParams;
+ width: number;
+ height: number;
+}) {
+ return {
+ centerX: width / 2 + params.centerX * width,
+ centerY: height / 2 + params.centerY * height,
+ maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
+ maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
+ rotationRad: (params.rotation * Math.PI) / 180,
+ };
+}
+
+export function buildBoxMaskInteraction({
+ sizeMode,
+ buildOverlayPath,
+ showBoundingBox = true,
+}: {
+ sizeMode: MaskFeatures["sizeMode"];
+ buildOverlayPath?: (args: { width: number; height: number }) => string;
+ showBoundingBox?: boolean;
+}): MaskInteractionDefinition<RectangleMaskParams> {
+ return {
+ getInteraction({ params, bounds, displayScale, scaleX, scaleY }) {
+ return {
+ handles: getBoxMaskHandlePositions({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: params.width,
+ height: params.height,
+ rotation: params.rotation,
+ feather: params.feather,
+ sizeMode,
+ bounds,
+ displayScale,
+ }),
+ overlays: getBoxMaskOverlays({
+ params,
+ bounds,
+ pathData: buildOverlayPath?.({
+ width: params.width * bounds.width * scaleX,
+ height: params.height * bounds.height * scaleY,
+ }),
+ showBoundingBox,
+ }),
+ };
+ },
+ snap(args) {
+ return snapMaskInteraction(args);
+ },
+ };
+}
+
+export function computeBoxMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ bounds,
+}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
+ if (handleId === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ if (handleId === "rotation") {
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ const newRotation = (startParams.rotation + currentAngle) % 360;
+ return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.sin(angleRad),
+ directionY: Math.cos(angleRad),
+ });
+ }
+
+ const halfWidth = startParams.width * bounds.width;
+ const halfHeight = startParams.height * bounds.height;
+
+ if (handleId === "right" || handleId === "left") {
+ const sign = handleId === "right" ? 1 : -1;
+ return {
+ width: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.width + (sign * deltaX * 2) / bounds.width,
+ ),
+ };
+ }
+
+ if (handleId === "bottom" || handleId === "top") {
+ const sign = handleId === "bottom" ? 1 : -1;
+ return {
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height + (sign * deltaY * 2) / bounds.height,
+ ),
+ };
+ }
+
+ if (
+ handleId === "top-left" ||
+ handleId === "top-right" ||
+ handleId === "bottom-left" ||
+ handleId === "bottom-right"
+ ) {
+ const signX = handleId.includes("right") ? 1 : -1;
+ const signY = handleId.includes("bottom") ? 1 : -1;
+ const distance = Math.sqrt(
+ (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
+ );
+ const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
+ const scale = originalDistance > 0 ? distance / originalDistance : 1;
+ return {
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
+ height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
+ };
+ }
+
+ if (handleId === "scale") {
+ const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
+ const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
+ const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
+ return {
+ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
+ };
+ }
+
+ return {};
+}
+
+export function rotatePoint({
+ x,
+ y,
+ centerX,
+ centerY,
+ rotationRad,
+}: {
+ x: number;
+ y: number;
+ centerX: number;
+ centerY: number;
+ rotationRad: number;
+}) {
+ const dx = x - centerX;
+ const dy = y - centerY;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+
+ return {
+ x: centerX + dx * cos - dy * sin,
+ y: centerY + dx * sin + dy * cos,
+ };
+}
diff --git a/apps/web/src/lib/masks/definitions/cinematic-bars.ts b/apps/web/src/lib/masks/definitions/cinematic-bars.ts
index 1eb65e71..ef9c6e00 100644
--- a/apps/web/src/lib/masks/definitions/cinematic-bars.ts
+++ b/apps/web/src/lib/masks/definitions/cinematic-bars.ts
@@ -5,6 +5,7 @@ import type {
} from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
computeBoxMaskParamUpdate,
getDefaultBaseMaskParams,
getStrokeOffset,
@@ -73,16 +74,18 @@ function buildBandPath({
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "cinematic-bars",
name: "Cinematic Bars",
- overlayShape: "box",
- buildOverlayPath({ width, height }) {
- return `M 0,0 H ${width} V ${height} H 0 Z`;
- },
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "height-only",
},
params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "height-only",
+ buildOverlayPath({ width, height }) {
+ return `M 0,0 H ${width} V ${height} H 0 Z`;
+ },
+ }),
buildDefault(context) {
return {
type: "cinematic-bars",
diff --git a/apps/web/src/lib/masks/definitions/custom.ts b/apps/web/src/lib/masks/definitions/custom.ts
new file mode 100644
index 00000000..0fc69929
--- /dev/null
+++ b/apps/web/src/lib/masks/definitions/custom.ts
@@ -0,0 +1,652 @@
+import { generateUUID } from "@/utils/id";
+import type { ParamDefinition } from "@/lib/params";
+import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type {
+ CustomMask,
+ CustomMaskParams,
+ MaskDefinition,
+ MaskHandlePosition,
+ MaskOverlay,
+ MaskParamUpdateArgs,
+} from "@/lib/masks/types";
+import {
+ buildCustomMaskPath2D,
+ buildCustomMaskSvgPath,
+ customMaskCanvasPointToLocal,
+ findClosestPointOnCustomMaskSegment,
+ getCustomMaskCanvasSegments,
+ getCustomMaskCanvasGeometry,
+ getCustomMaskLocalBounds,
+ getCustomMaskSegmentCount,
+ insertPointIntoCustomMaskSegment,
+ parseCustomMaskHandleId,
+ recenterCustomMaskPath,
+ type CustomMaskPathPoint,
+} from "@/lib/masks/custom-path";
+import { getBoxMaskHandlePositions } from "@/lib/masks/handle-positions";
+import { computeFeatherUpdate } from "@/lib/masks/param-update";
+import {
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "@/lib/masks/geometry";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+} from "@/lib/preview/preview-snap";
+
+const PERCENTAGE_DISPLAY = {
+ displayMultiplier: 100,
+ step: 1,
+} as const;
+
+const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "scale",
+ label: "Scale",
+ type: "number",
+ default: 1,
+ min: 1,
+ max: 500,
+ ...PERCENTAGE_DISPLAY,
+ },
+];
+
+function getCustomMaskDisplayHandles({
+ params,
+ displayScale,
+ bounds,
+}: {
+ params: CustomMaskParams;
+ displayScale: number;
+ bounds: ElementBounds;
+}): {
+ handles: MaskHandlePosition[];
+ overlays: MaskOverlay[];
+} {
+ const points = params.path;
+ const geometry = getCustomMaskCanvasGeometry({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+
+ const handles: MaskHandlePosition[] = [];
+ const overlays: MaskOverlay[] = [];
+
+ if (points.length > 0) {
+ overlays.push({
+ id: "path",
+ type: "canvas-path",
+ pathData: buildCustomMaskSvgPath({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: params.closed,
+ }),
+ coordinateSpace: "canvas",
+ });
+ }
+
+ if (params.closed) {
+ const segmentStrokeWidth = 12;
+ overlays.push(
+ ...getCustomMaskCanvasSegments({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: true,
+ }).map((segment) => ({
+ id: `segment:${segment.index}`,
+ type: "canvas-path" as const,
+ pathData: segment.pathData,
+ coordinateSpace: "canvas" as const,
+ handleId: `segment:${segment.index}`,
+ cursor: PEN_CURSOR,
+ strokeOpacity: 0,
+ strokeWidth: segmentStrokeWidth,
+ })),
+ );
+ }
+
+ const localBounds = getCustomMaskLocalBounds({ points, bounds });
+ if (params.closed && localBounds) {
+ handles.push(
+ ...getBoxMaskHandlePositions({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: (localBounds.width * params.scale) / bounds.width,
+ height: (localBounds.height * params.scale) / bounds.height,
+ rotation: params.rotation,
+ feather: params.feather,
+ sizeMode: "uniform",
+ showScaleHandle: false,
+ bounds,
+ displayScale,
+ }),
+ );
+ }
+
+ geometry.anchors.forEach((point) => {
+ handles.push({
+ id: `point:${point.id}:anchor`,
+ x: point.anchor.x,
+ y: point.anchor.y,
+ cursor: params.closed ? "move" : "pointer",
+ kind: "point",
+ });
+ });
+
+ return {
+ handles,
+ overlays,
+ };
+}
+
+function updateCustomMaskPoint({
+ points,
+ pointId,
+ updater,
+}: {
+ points: CustomMaskPathPoint[];
+ pointId: string;
+ updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint;
+}) {
+ return points.map((point) => (point.id === pointId ? updater(point) : point));
+}
+
+function computeCustomMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
+ if (handleId === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+
+ if (handleId === "rotation") {
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.sin(angleRad),
+ directionY: Math.cos(angleRad),
+ });
+ }
+
+ if (handleId === "scale") {
+ const startDistance = Math.hypot(
+ startCanvasX - pivotX,
+ startCanvasY - pivotY,
+ );
+ const currentDistance = Math.hypot(
+ startCanvasX + deltaX - pivotX,
+ startCanvasY + deltaY - pivotY,
+ );
+ const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
+ return {
+ scale: Math.max(0.01, startParams.scale * scaleFactor),
+ };
+ }
+
+ const parsedHandle = parseCustomMaskHandleId({ handleId });
+ if (!parsedHandle) {
+ return {};
+ }
+
+ const points = startParams.path;
+ const currentPoint = {
+ x: startCanvasX + deltaX,
+ y: startCanvasY + deltaY,
+ };
+ const localPoint = customMaskCanvasPointToLocal({
+ point: currentPoint,
+ centerX: startParams.centerX,
+ centerY: startParams.centerY,
+ rotation: startParams.rotation,
+ scale: startParams.scale,
+ bounds,
+ });
+
+ return {
+ path: updateCustomMaskPoint({
+ points,
+ pointId: parsedHandle.pointId,
+ updater: (point) => {
+ if (parsedHandle.part === "anchor") {
+ return {
+ ...point,
+ x: localPoint.x,
+ y: localPoint.y,
+ };
+ }
+
+ if (parsedHandle.part === "in") {
+ return {
+ ...point,
+ inX: localPoint.x - point.x,
+ inY: localPoint.y - point.y,
+ };
+ }
+
+ return {
+ ...point,
+ outX: localPoint.x - point.x,
+ outY: localPoint.y - point.y,
+ };
+ },
+ }),
+ };
+}
+
+export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
+ type: "custom",
+ name: "Custom",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "uniform",
+ },
+ params: CUSTOM_MASK_PARAMS,
+ interaction: {
+ getInteraction({
+ params,
+ bounds,
+ displayScale,
+ scaleX: _scaleX,
+ scaleY: _scaleY,
+ }) {
+ return getCustomMaskDisplayHandles({ params, bounds, displayScale });
+ },
+ snap({
+ handleId,
+ proposedParams,
+ startParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) {
+ const points = startParams.path;
+ const localBounds = getCustomMaskLocalBounds({ points, bounds });
+ if (!startParams.closed || !localBounds) {
+ return {
+ params: proposedParams,
+ activeLines: [],
+ };
+ }
+
+ const position = {
+ x: proposedParams.centerX * bounds.width,
+ y: proposedParams.centerY * bounds.height,
+ };
+
+ if (handleId === "position") {
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: position,
+ canvasSize: bounds,
+ elementSize: {
+ width: localBounds.width * proposedParams.scale,
+ height: localBounds.height * proposedParams.scale,
+ },
+ rotation: proposedParams.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "rotation") {
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+ }
+
+ if (handleId === "scale") {
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale: proposedParams.scale,
+ position,
+ baseWidth: localBounds.width,
+ baseHeight: localBounds.height,
+ rotation: proposedParams.rotation,
+ canvasSize: bounds,
+ snapThreshold,
+ preferredEdges: {
+ right: true,
+ bottom: true,
+ },
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(0.01, snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return {
+ params: proposedParams,
+ activeLines: [],
+ };
+ },
+ },
+ buildDefault(): Omit<CustomMask, "id"> {
+ return {
+ type: "custom",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ path: [],
+ closed: false,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ },
+ };
+ },
+ computeParamUpdate: computeCustomMaskParamUpdate,
+ isActive(params) {
+ return params.closed;
+ },
+ renderer: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as CustomMaskParams;
+ const points = params.path;
+ if (!params.closed) {
+ return new Path2D();
+ }
+
+ return buildCustomMaskPath2D({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds: {
+ cx: width / 2,
+ cy: height / 2,
+ width,
+ height,
+ rotation: 0,
+ },
+ closed: true,
+ });
+ },
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams as CustomMaskParams;
+ if (!params.closed) {
+ return;
+ }
+
+ const points = params.path;
+ const path = buildCustomMaskPath2D({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds: {
+ cx: width / 2,
+ cy: height / 2,
+ width,
+ height,
+ rotation: 0,
+ },
+ closed: true,
+ });
+
+ ctx.save();
+ ctx.strokeStyle = params.strokeColor;
+ ctx.lineWidth = params.strokeWidth;
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ ctx.stroke(path);
+
+ if (params.strokeAlign === "inside") {
+ ctx.globalCompositeOperation = "destination-in";
+ ctx.fillStyle = "#ffffff";
+ ctx.fill(path);
+ }
+
+ if (params.strokeAlign === "outside") {
+ ctx.globalCompositeOperation = "destination-out";
+ ctx.fillStyle = "#ffffff";
+ ctx.fill(path);
+ }
+ ctx.restore();
+ },
+ },
+};
+
+export function appendPointToCustomMask({
+ params,
+ canvasPoint,
+ bounds,
+}: {
+ params: CustomMaskParams;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+}): CustomMaskParams {
+ const points = params.path;
+
+ if (points.length === 0) {
+ return {
+ ...params,
+ centerX:
+ bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width,
+ centerY:
+ bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height,
+ rotation: 0,
+ scale: 1,
+ path: [
+ {
+ id: generateUUID(),
+ x: 0,
+ y: 0,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ ],
+ };
+ }
+
+ const localPoint = customMaskCanvasPointToLocal({
+ point: canvasPoint,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+ const nextPoints = [
+ ...points,
+ {
+ id: generateUUID(),
+ x: localPoint.x,
+ y: localPoint.y,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ ];
+ const recentered = recenterCustomMaskPath({
+ points: nextPoints,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+
+ return {
+ ...params,
+ centerX: recentered.centerX,
+ centerY: recentered.centerY,
+ path: recentered.points,
+ };
+}
+
+export function insertPointOnCustomMaskSegment({
+ params,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ pointId = generateUUID(),
+}: {
+ params: CustomMaskParams;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+ pointId?: string;
+}): { params: CustomMaskParams; pointId: string } | null {
+ const points = params.path;
+ if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) {
+ return null;
+ }
+
+ const closestPoint = findClosestPointOnCustomMaskSegment({
+ points,
+ segmentIndex,
+ canvasPoint,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: params.closed,
+ });
+ if (!closestPoint) {
+ return null;
+ }
+
+ const nextPoints = insertPointIntoCustomMaskSegment({
+ points,
+ segmentIndex,
+ pointId,
+ t: closestPoint.t,
+ closed: params.closed,
+ });
+ if (nextPoints.length === points.length) {
+ return null;
+ }
+
+ const recentered = recenterCustomMaskPath({
+ points: nextPoints,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+
+ return {
+ pointId,
+ params: {
+ ...params,
+ centerX: recentered.centerX,
+ centerY: recentered.centerY,
+ path: recentered.points,
+ },
+ };
+}
diff --git a/apps/web/src/lib/masks/definitions/diamond.ts b/apps/web/src/lib/masks/definitions/diamond.ts
index 8af3144f..56930678 100644
--- a/apps/web/src/lib/masks/definitions/diamond.ts
+++ b/apps/web/src/lib/masks/definitions/diamond.ts
@@ -1,6 +1,7 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
@@ -47,16 +48,18 @@ function buildDiamondPath({
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "diamond",
name: "Diamond",
- overlayShape: "box",
- buildOverlayPath({ width, height }) {
- return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
- },
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
+ },
+ }),
buildDefault(context) {
return {
type: "diamond",
diff --git a/apps/web/src/lib/masks/definitions/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts
index 1fd0301c..2eb85f0c 100644
--- a/apps/web/src/lib/masks/definitions/ellipse.ts
+++ b/apps/web/src/lib/masks/definitions/ellipse.ts
@@ -1,72 +1,75 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
-} from "./box-like";
-
-export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "ellipse",
- name: "Ellipse",
- overlayShape: "box",
- buildOverlayPath({ width, height }) {
- const rx = Math.max((width - 1) / 2, 0);
- const ry = Math.max((height - 1) / 2, 0);
- const cx = width / 2;
- const cy = height / 2;
- return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
- },
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- buildDefault(context) {
- return {
- type: "ellipse",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const path = new Path2D();
- path.ellipse(
- centerX,
- centerY,
- maskWidth / 2,
- maskHeight / 2,
- rotationRad,
- 0,
- Math.PI * 2,
- );
- return path;
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- const path = new Path2D();
- path.ellipse(
- centerX,
- centerY,
- Math.max(1, maskWidth / 2 + offset),
- Math.max(1, maskHeight / 2 + offset),
- rotationRad,
- 0,
- Math.PI * 2,
- );
- return path;
- },
- },
-};
+import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+} from "./box-like";
+
+export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+ type: "ellipse",
+ name: "Ellipse",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ const rx = Math.max((width - 1) / 2, 0);
+ const ry = Math.max((height - 1) / 2, 0);
+ const cx = width / 2;
+ const cy = height / 2;
+ return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
+ },
+ }),
+ buildDefault(context) {
+ return {
+ type: "ellipse",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ maskWidth / 2,
+ maskHeight / 2,
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ Math.max(1, maskWidth / 2 + offset),
+ Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/definitions/heart.ts b/apps/web/src/lib/masks/definitions/heart.ts
index 2c53367a..429c5d98 100644
--- a/apps/web/src/lib/masks/definitions/heart.ts
+++ b/apps/web/src/lib/masks/definitions/heart.ts
@@ -1,6 +1,7 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
@@ -36,23 +37,23 @@ function buildHeartPath({
rotationRad,
});
- const start = toPoint({ localX: 0, localY: -halfHeight * 0.2 });
+ const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
const rightControl1 = toPoint({
localX: halfWidth,
- localY: -halfHeight * 0.95,
+ localY: -halfHeight * 1.225,
});
const rightControl2 = toPoint({
localX: halfWidth,
- localY: halfHeight * 0.15,
+ localY: -halfHeight * 0.125,
});
- const bottom = toPoint({ localX: 0, localY: halfHeight });
+ const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
const leftControl1 = toPoint({
localX: -halfWidth,
- localY: halfHeight * 0.15,
+ localY: -halfHeight * 0.125,
});
const leftControl2 = toPoint({
localX: -halfWidth,
- localY: -halfHeight * 0.95,
+ localY: -halfHeight * 1.225,
});
const path = new Path2D();
@@ -80,25 +81,27 @@ function buildHeartPath({
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "heart",
name: "Heart",
- overlayShape: "box",
- buildOverlayPath({ width, height }) {
- const cx = width / 2;
- const cy = height / 2;
- const halfWidth = width / 2;
- const halfHeight = height / 2;
- return [
- `M ${cx},${cy - halfHeight * 0.2}`,
- `C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`,
- `C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`,
- "Z",
- ].join(" ");
- },
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ const cx = width / 2;
+ const cy = height / 2;
+ const halfWidth = width / 2;
+ const halfHeight = height / 2;
+ return [
+ `M ${cx},${cy - halfHeight * 0.475}`,
+ `C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
+ `C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
+ "Z",
+ ].join(" ");
+ },
+ }),
buildDefault(context) {
return {
type: "heart",
diff --git a/apps/web/src/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts
index c078ef60..fd00cab1 100644
--- a/apps/web/src/lib/masks/definitions/index.ts
+++ b/apps/web/src/lib/masks/definitions/index.ts
@@ -1,12 +1,14 @@
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
import { masksRegistry, type MaskIconProps } from "../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
+import { customMaskDefinition } from "./custom";
import { diamondMaskDefinition } from "./diamond";
import { ellipseMaskDefinition } from "./ellipse";
import { heartMaskDefinition } from "./heart";
import { rectangleMaskDefinition } from "./rectangle";
import { splitMaskDefinition } from "./split";
import { starMaskDefinition } from "./star";
+import { textMaskDefinition } from "./text";
import {
MinusSignIcon,
PanelRightDashedIcon,
@@ -15,6 +17,7 @@ import {
FavouriteIcon,
DiamondIcon,
StarsIcon,
+ TextFontIcon,
} from "@hugeicons/core-free-icons";
function registerDefaultMask<TParams extends BaseMaskParams>({
@@ -60,4 +63,12 @@ export function registerDefaultMasks(): void {
definition: starMaskDefinition,
icon: { icon: StarsIcon },
});
+ registerDefaultMask({
+ definition: textMaskDefinition,
+ icon: { icon: TextFontIcon },
+ });
+ registerDefaultMask({
+ definition: customMaskDefinition,
+ icon: { icon: SquareIcon },
+ });
}
diff --git a/apps/web/src/lib/masks/definitions/rectangle.ts b/apps/web/src/lib/masks/definitions/rectangle.ts
index 08f969ea..832b8307 100644
--- a/apps/web/src/lib/masks/definitions/rectangle.ts
+++ b/apps/web/src/lib/masks/definitions/rectangle.ts
@@ -1,94 +1,97 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function buildRectanglePath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const corners = [
- { x: centerX - halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY + halfHeight },
- { x: centerX - halfWidth, y: centerY + halfHeight },
- ].map((point) =>
- rotatePoint({
- ...point,
- centerX,
- centerY,
- rotationRad,
- }),
- );
-
- const path = new Path2D();
- path.moveTo(corners[0].x, corners[0].y);
- for (const corner of corners.slice(1)) {
- path.lineTo(corner.x, corner.y);
- }
- path.closePath();
- return path;
-}
-
-export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "rectangle",
- name: "Rectangle",
- overlayShape: "box",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- buildDefault(context) {
- return {
- type: "rectangle",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildRectanglePath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildRectanglePath({
- centerX,
- centerY,
- halfWidth: Math.max(1, maskWidth / 2 + offset),
- halfHeight: Math.max(1, maskHeight / 2 + offset),
- rotationRad,
- });
- },
- },
-};
+import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+ rotatePoint,
+} from "./box-like";
+
+function buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth,
+ halfHeight,
+ rotationRad,
+}: {
+ centerX: number;
+ centerY: number;
+ halfWidth: number;
+ halfHeight: number;
+ rotationRad: number;
+}): Path2D {
+ const corners = [
+ { x: centerX - halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY + halfHeight },
+ { x: centerX - halfWidth, y: centerY + halfHeight },
+ ].map((point) =>
+ rotatePoint({
+ ...point,
+ centerX,
+ centerY,
+ rotationRad,
+ }),
+ );
+
+ const path = new Path2D();
+ path.moveTo(corners[0].x, corners[0].y);
+ for (const corner of corners.slice(1)) {
+ path.lineTo(corner.x, corner.y);
+ }
+ path.closePath();
+ return path;
+}
+
+export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+ type: "rectangle",
+ name: "Rectangle",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ }),
+ buildDefault(context) {
+ return {
+ type: "rectangle",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(1, maskWidth / 2 + offset),
+ halfHeight: Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ });
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/definitions/split.ts b/apps/web/src/lib/masks/definitions/split.ts
index 91cd3001..c1a14d90 100644
--- a/apps/web/src/lib/masks/definitions/split.ts
+++ b/apps/web/src/lib/masks/definitions/split.ts
@@ -1,347 +1,382 @@
-import { computeFeatherUpdate } from "../param-update";
-import type { ParamValues } from "@/lib/params";
-import type {
- MaskDefinition,
- MaskParamUpdateArgs,
- SplitMaskParams,
-} from "@/lib/masks/types";
-import { halfPlaneSign, lineEdgeIntersection } from "../utils";
-
-// cos(Ï€/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
-// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
-// exactly on the split line, which produces spurious midpoint vertices.
-const NORMAL_SNAP_EPSILON = 1e-10;
-
-// Guards against collinear vertices from float noise at canvas edges.
-const MIN_POLYGON_AREA_PX = 0.5;
-const INTERSECTION_EPSILON = 1e-6;
-
-function polygonArea({ vertices }: { vertices: [number, number][] }): number {
- let area = 0;
- for (let i = 0; i < vertices.length; i++) {
- const [x1, y1] = vertices[i];
- const [x2, y2] = vertices[(i + 1) % vertices.length];
- area += x1 * y2 - x2 * y1;
- }
- return Math.abs(area) * 0.5;
-}
-
-function splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- width: number;
- height: number;
-}): { normalX: number; normalY: number; lineX: number; lineY: number } {
- const angleRad = (rotation * Math.PI) / 180;
- const normalX =
- Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
- const normalY =
- Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
- const lineX = width / 2 + centerX * width;
- const lineY = height / 2 + centerY * height;
- return { normalX, normalY, lineX, lineY };
-}
-
-function pointsEqual(
- a: { x: number; y: number },
- b: { x: number; y: number },
-): boolean {
- return (
- Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
- Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
- );
-}
-
-export function getSplitMaskStrokeSegment({
- resolvedParams,
- width,
- height,
-}: {
- resolvedParams: unknown;
- width: number;
- height: number;
-}): [{ x: number; y: number }, { x: number; y: number }] | null {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
-
- const edges: [number, number, number, number][] = [
- [0, 0, width, 0],
- [width, 0, width, height],
- [width, height, 0, height],
- [0, height, 0, 0],
- ];
- const intersections: { x: number; y: number }[] = [];
-
- for (const [x1, y1, x2, y2] of edges) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
-
- if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
- continue;
- }
-
- intersections.push(hit);
- }
-
- if (intersections.length !== 2) {
- return null;
- }
-
- return [intersections[0], intersections[1]];
-}
-
-function computeSplitMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- startCanvasX,
- startCanvasY,
- bounds,
- canvasSize,
-}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues {
- if (handleId === "position") {
- const rawX = startParams.centerX + deltaX / bounds.width;
- const rawY = startParams.centerY + deltaY / bounds.height;
-
- const minX = -bounds.cx / bounds.width;
- const maxX = (canvasSize.width - bounds.cx) / bounds.width;
- const minY = -bounds.cy / bounds.height;
- const maxY = (canvasSize.height - bounds.cy) / bounds.height;
-
- return {
- centerX: Math.max(minX, Math.min(maxX, rawX)),
- centerY: Math.max(minY, Math.min(maxY, rawY)),
- };
- }
-
- if (handleId === "feather") {
- const angleRad = (startParams.rotation * Math.PI) / 180;
- return computeFeatherUpdate({
- startFeather: startParams.feather,
- deltaX,
- deltaY,
- directionX: -Math.cos(angleRad),
- directionY: -Math.sin(angleRad),
- });
- }
-
- if (handleId === "rotation") {
- const pivotX = bounds.cx + startParams.centerX * bounds.width;
- const pivotY = bounds.cy + startParams.centerY * bounds.height;
- const startAngle =
- (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
- Math.PI;
- const currentAngle =
- (Math.atan2(
- startCanvasY + deltaY - pivotY,
- startCanvasX + deltaX - pivotX,
- ) *
- 180) /
- Math.PI;
-
- let deltaAngle = currentAngle - startAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
-
- return {
- rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
- };
- }
-
- return {};
-}
-
-export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
- type: "split",
- name: "Split",
- overlayShape: "line",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "none",
- },
- buildDefault() {
- return {
- type: "split",
- params: {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- centerX: 0,
- centerY: 0,
- rotation: 0,
- },
- };
- },
- computeParamUpdate: computeSplitMaskParamUpdate,
- params: [
- {
- key: "centerX",
- label: "X",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- step: 1,
- displayMultiplier: 100,
- },
- {
- key: "centerY",
- label: "Y",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- step: 1,
- displayMultiplier: 100,
- },
- {
- key: "rotation",
- label: "Rotation",
- type: "number",
- default: 0,
- min: 0,
- max: 360,
- step: 1,
- },
- ],
- renderer: {
- renderMask({ resolvedParams, ctx, width, height, feather }) {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
-
- // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
- const featherHalf = feather / 2;
- const gradient = ctx.createLinearGradient(
- lineX - normalX * featherHalf,
- lineY - normalY * featherHalf,
- lineX + normalX * featherHalf,
- lineY + normalY * featherHalf,
- );
- gradient.addColorStop(0, "rgba(255,255,255,0)");
- gradient.addColorStop(1, "white");
-
- ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, width, height);
- },
-
- buildPath({ resolvedParams, width, height }) {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
-
- const edges: [number, number, number, number][] = [
- [0, 0, width, 0],
- [width, 0, width, height],
- [width, height, 0, height],
- [0, height, 0, 0],
- ];
-
- const isInsideHalfPlane = (x: number, y: number) =>
- halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
-
- const vertices: [number, number][] = [];
- for (const [x1, y1, x2, y2] of edges) {
- const isVertex1Inside = isInsideHalfPlane(x1, y1);
- const isVertex2Inside = isInsideHalfPlane(x2, y2);
-
- if (isVertex1Inside && isVertex2Inside) {
- vertices.push([x2, y2]);
- } else if (isVertex1Inside && !isVertex2Inside) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
- if (hit) vertices.push([hit.x, hit.y]);
- } else if (!isVertex1Inside && isVertex2Inside) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
- if (hit) {
- vertices.push([hit.x, hit.y]);
- vertices.push([x2, y2]);
- }
- }
- }
-
- if (
- vertices.length < 3 ||
- polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
- ) {
- return new Path2D();
- }
-
- const path = new Path2D();
- path.moveTo(vertices[0][0], vertices[0][1]);
- for (let i = 1; i < vertices.length; i++) {
- path.lineTo(vertices[i][0], vertices[i][1]);
- }
- path.closePath();
- return path;
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const segment = getSplitMaskStrokeSegment({
- resolvedParams,
- width,
- height,
- });
- const path = new Path2D();
-
- if (!segment) {
- return path;
- }
-
- path.moveTo(segment[0].x, segment[0].y);
- path.lineTo(segment[1].x, segment[1].y);
- return path;
- },
- },
-};
+import { computeFeatherUpdate } from "../param-update";
+import type {
+ MaskDefinition,
+ MaskParamUpdateArgs,
+ SplitMaskParams,
+} from "@/lib/masks/types";
+import { halfPlaneSign, lineEdgeIntersection } from "../utils";
+import {
+ getLineMaskHandlePositions,
+ getLineMaskOverlay,
+} from "@/lib/masks/handle-positions";
+import { snapMaskInteraction } from "@/lib/masks/snap";
+
+// cos(Ï€/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
+// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
+// exactly on the split line, which produces spurious midpoint vertices.
+const NORMAL_SNAP_EPSILON = 1e-10;
+
+// Guards against collinear vertices from float noise at canvas edges.
+const MIN_POLYGON_AREA_PX = 0.5;
+const INTERSECTION_EPSILON = 1e-6;
+
+function polygonArea({ vertices }: { vertices: [number, number][] }): number {
+ let area = 0;
+ for (let i = 0; i < vertices.length; i++) {
+ const [x1, y1] = vertices[i];
+ const [x2, y2] = vertices[(i + 1) % vertices.length];
+ area += x1 * y2 - x2 * y1;
+ }
+ return Math.abs(area) * 0.5;
+}
+
+function splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ width: number;
+ height: number;
+}): { normalX: number; normalY: number; lineX: number; lineY: number } {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX =
+ Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
+ const normalY =
+ Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
+ const lineX = width / 2 + centerX * width;
+ const lineY = height / 2 + centerY * height;
+ return { normalX, normalY, lineX, lineY };
+}
+
+function pointsEqual(
+ a: { x: number; y: number },
+ b: { x: number; y: number },
+): boolean {
+ return (
+ Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
+ Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
+ );
+}
+
+export function getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+}: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+}): [{ x: number; y: number }, { x: number; y: number }] | null {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+ const intersections: { x: number; y: number }[] = [];
+
+ for (const [x1, y1, x2, y2] of edges) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+
+ if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
+ continue;
+ }
+
+ intersections.push(hit);
+ }
+
+ if (intersections.length !== 2) {
+ return null;
+ }
+
+ return [intersections[0], intersections[1]];
+}
+
+function computeSplitMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+ canvasSize,
+}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
+ if (handleId === "position") {
+ const rawX = startParams.centerX + deltaX / bounds.width;
+ const rawY = startParams.centerY + deltaY / bounds.height;
+
+ const minX = -bounds.cx / bounds.width;
+ const maxX = (canvasSize.width - bounds.cx) / bounds.width;
+ const minY = -bounds.cy / bounds.height;
+ const maxY = (canvasSize.height - bounds.cy) / bounds.height;
+
+ return {
+ centerX: Math.max(minX, Math.min(maxX, rawX)),
+ centerY: Math.max(minY, Math.min(maxY, rawY)),
+ };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.cos(angleRad),
+ directionY: -Math.sin(angleRad),
+ });
+ }
+
+ if (handleId === "rotation") {
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ return {};
+}
+
+export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
+ type: "split",
+ name: "Split",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "none",
+ },
+ interaction: {
+ getInteraction({
+ params,
+ bounds,
+ displayScale,
+ scaleX: _scaleX,
+ scaleY: _scaleY,
+ }) {
+ return {
+ handles: getLineMaskHandlePositions({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ feather: params.feather,
+ bounds,
+ displayScale,
+ }),
+ overlays: [
+ getLineMaskOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ bounds,
+ }),
+ ],
+ };
+ },
+ snap(args) {
+ return snapMaskInteraction(args);
+ },
+ },
+ buildDefault() {
+ return {
+ type: "split",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ },
+ };
+ },
+ computeParamUpdate: computeSplitMaskParamUpdate,
+ params: [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ ],
+ renderer: {
+ renderMaskHandlesFeather: true,
+ renderMask({ resolvedParams, ctx, width, height, feather }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
+ const featherHalf = feather / 2;
+ const gradient = ctx.createLinearGradient(
+ lineX - normalX * featherHalf,
+ lineY - normalY * featherHalf,
+ lineX + normalX * featherHalf,
+ lineY + normalY * featherHalf,
+ );
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
+ gradient.addColorStop(1, "white");
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+ },
+
+ buildPath({ resolvedParams, width, height }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+
+ const isInsideHalfPlane = (x: number, y: number) =>
+ halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
+
+ const vertices: [number, number][] = [];
+ for (const [x1, y1, x2, y2] of edges) {
+ const isVertex1Inside = isInsideHalfPlane(x1, y1);
+ const isVertex2Inside = isInsideHalfPlane(x2, y2);
+
+ if (isVertex1Inside && isVertex2Inside) {
+ vertices.push([x2, y2]);
+ } else if (isVertex1Inside && !isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) vertices.push([hit.x, hit.y]);
+ } else if (!isVertex1Inside && isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) {
+ vertices.push([hit.x, hit.y]);
+ vertices.push([x2, y2]);
+ }
+ }
+ }
+
+ if (
+ vertices.length < 3 ||
+ polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
+ ) {
+ return new Path2D();
+ }
+
+ const path = new Path2D();
+ path.moveTo(vertices[0][0], vertices[0][1]);
+ for (let i = 1; i < vertices.length; i++) {
+ path.lineTo(vertices[i][0], vertices[i][1]);
+ }
+ path.closePath();
+ return path;
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+ });
+ const path = new Path2D();
+
+ if (!segment) {
+ return path;
+ }
+
+ path.moveTo(segment[0].x, segment[0].y);
+ path.lineTo(segment[1].x, segment[1].y);
+ return path;
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/definitions/star.ts b/apps/web/src/lib/masks/definitions/star.ts
index cdab2855..d311ab80 100644
--- a/apps/web/src/lib/masks/definitions/star.ts
+++ b/apps/web/src/lib/masks/definitions/star.ts
@@ -1,6 +1,7 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
@@ -87,16 +88,18 @@ function buildOverlayStarPath({
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "star",
name: "Star",
- overlayShape: "box",
- buildOverlayPath({ width, height }) {
- return buildOverlayStarPath({ width, height });
- },
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ return buildOverlayStarPath({ width, height });
+ },
+ }),
buildDefault(context) {
return {
type: "star",
diff --git a/apps/web/src/lib/masks/definitions/text.ts b/apps/web/src/lib/masks/definitions/text.ts
new file mode 100644
index 00000000..b774ed28
--- /dev/null
+++ b/apps/web/src/lib/masks/definitions/text.ts
@@ -0,0 +1,438 @@
+import type { ParamDefinition } from "@/lib/params";
+import type {
+ MaskDefinition,
+ MaskParamUpdateArgs,
+ TextMask,
+ TextMaskParams,
+} from "@/lib/masks/types";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/lib/text/typography";
+import {
+ drawMeasuredTextLayout,
+ measureTextLayout,
+ strokeMeasuredTextLayout,
+} from "@/lib/text/primitives";
+import { getTextMeasurementContext } from "@/lib/text/measure-element";
+import { getTextVisualRect } from "@/lib/text/layout";
+import {
+ getBoxMaskHandlePositions,
+ getBoxMaskRectOverlay,
+} from "@/lib/masks/handle-positions";
+import { computeFeatherUpdate } from "@/lib/masks/param-update";
+import {
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "@/lib/masks/geometry";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+ type ScaleEdgePreference,
+} from "@/lib/preview/preview-snap";
+
+const PERCENTAGE_DISPLAY = {
+ displayMultiplier: 100,
+ step: 1,
+} as const;
+
+const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign;
+
+const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "fontSize",
+ label: "Size",
+ type: "number",
+ default: DEFAULTS.text.element.fontSize,
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ step: 1,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "scale",
+ label: "Scale",
+ type: "number",
+ default: 1,
+ min: 1,
+ max: 500,
+ ...PERCENTAGE_DISPLAY,
+ },
+];
+
+function measureTextMask({
+ params,
+ height,
+}: {
+ params: TextMaskParams;
+ height: number;
+}) {
+ const layout = measureTextLayout({
+ text: {
+ content: params.content,
+ fontSize: params.fontSize,
+ fontFamily: params.fontFamily,
+ fontWeight: params.fontWeight,
+ fontStyle: params.fontStyle,
+ textAlign: TEXT_MASK_ALIGNMENT,
+ textDecoration: params.textDecoration,
+ letterSpacing: params.letterSpacing,
+ lineHeight: params.lineHeight,
+ },
+ canvasHeight: height,
+ ctx: getTextMeasurementContext(),
+ });
+ const visualRect = getTextVisualRect({
+ textAlign: layout.textAlign,
+ block: layout.block,
+ background: { enabled: false, color: "transparent" },
+ fontSizeRatio: layout.fontSizeRatio,
+ });
+
+ return {
+ layout,
+ intrinsicWidth: Math.max(1, visualRect.width),
+ intrinsicHeight: Math.max(1, visualRect.height),
+ };
+}
+
+function getScalePreferredEdges({
+ handleId,
+}: {
+ handleId: string;
+}): ScaleEdgePreference | undefined {
+ if (handleId !== "scale") {
+ return undefined;
+ }
+
+ return {
+ right: true,
+ bottom: true,
+ };
+}
+
+function computeTextMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
+ if (handleId === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+
+ if (handleId === "rotation") {
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.sin(angleRad),
+ directionY: Math.cos(angleRad),
+ });
+ }
+
+ if (handleId === "scale") {
+ const startDistance = Math.hypot(
+ startCanvasX - pivotX,
+ startCanvasY - pivotY,
+ );
+ const currentDistance = Math.hypot(
+ startCanvasX + deltaX - pivotX,
+ startCanvasY + deltaY - pivotY,
+ );
+ const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
+ return {
+ scale: Math.max(0.01, startParams.scale * scaleFactor),
+ };
+ }
+
+ return {};
+}
+
+export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
+ type: "text",
+ name: "Text",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "uniform",
+ },
+ params: TEXT_MASK_PARAMS,
+ interaction: {
+ getInteraction({
+ params,
+ bounds,
+ displayScale,
+ scaleX: _scaleX,
+ scaleY: _scaleY,
+ }) {
+ const { intrinsicWidth, intrinsicHeight } = measureTextMask({
+ params,
+ height: bounds.height,
+ });
+ const width = (intrinsicWidth * params.scale) / bounds.width;
+ const height = (intrinsicHeight * params.scale) / bounds.height;
+ return {
+ handles: getBoxMaskHandlePositions({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width,
+ height,
+ rotation: params.rotation,
+ feather: params.feather,
+ sizeMode: "uniform",
+ bounds,
+ displayScale,
+ }),
+ overlays: [
+ getBoxMaskRectOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width,
+ height,
+ rotation: params.rotation,
+ bounds,
+ }),
+ ],
+ };
+ },
+ snap({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) {
+ const { intrinsicWidth, intrinsicHeight } = measureTextMask({
+ params: startParams,
+ height: bounds.height,
+ });
+ const position = {
+ x: proposedParams.centerX * bounds.width,
+ y: proposedParams.centerY * bounds.height,
+ };
+
+ if (handleId === "position") {
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: position,
+ canvasSize: bounds,
+ elementSize: {
+ width: intrinsicWidth * proposedParams.scale,
+ height: intrinsicHeight * proposedParams.scale,
+ },
+ rotation: proposedParams.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "rotation") {
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+ }
+
+ if (handleId === "scale") {
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale: proposedParams.scale,
+ position,
+ baseWidth: intrinsicWidth,
+ baseHeight: intrinsicHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: bounds,
+ snapThreshold,
+ preferredEdges: getScalePreferredEdges({ handleId }),
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(0.01, snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return {
+ params: proposedParams,
+ activeLines: [],
+ };
+ },
+ },
+ buildDefault(): Omit<TextMask, "id"> {
+ return {
+ type: "text",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ content: "Mask",
+ fontSize: DEFAULTS.text.element.fontSize,
+ fontFamily: DEFAULTS.text.element.fontFamily,
+ fontWeight: DEFAULTS.text.element.fontWeight,
+ fontStyle: DEFAULTS.text.element.fontStyle,
+ textDecoration: DEFAULTS.text.element.textDecoration,
+ letterSpacing: DEFAULTS.text.letterSpacing,
+ lineHeight: DEFAULTS.text.lineHeight,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ },
+ };
+ },
+ computeParamUpdate: computeTextMaskParamUpdate,
+ isActive(params) {
+ return params.content.trim().length > 0;
+ },
+ renderer: {
+ renderMask({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams as TextMaskParams;
+ const { layout } = measureTextMask({ params, height });
+
+ ctx.save();
+ ctx.translate(
+ width / 2 + params.centerX * width,
+ height / 2 + params.centerY * height,
+ );
+ ctx.scale(params.scale, params.scale);
+ if (params.rotation) {
+ ctx.rotate((params.rotation * Math.PI) / 180);
+ }
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ ctx.restore();
+ },
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams as TextMaskParams;
+ const { layout } = measureTextMask({ params, height });
+
+ ctx.save();
+ ctx.translate(
+ width / 2 + params.centerX * width,
+ height / 2 + params.centerY * height,
+ );
+ ctx.scale(params.scale, params.scale);
+ if (params.rotation) {
+ ctx.rotate((params.rotation * Math.PI) / 180);
+ }
+
+ strokeMeasuredTextLayout({
+ ctx,
+ layout,
+ strokeColor: params.strokeColor,
+ strokeWidth: params.strokeWidth,
+ });
+
+ if (params.strokeAlign === "inside") {
+ ctx.globalCompositeOperation = "destination-in";
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ }
+
+ if (params.strokeAlign === "outside") {
+ ctx.globalCompositeOperation = "destination-out";
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ }
+
+ ctx.restore();
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/geometry.ts b/apps/web/src/lib/masks/geometry.ts
index 0a631735..a5b05030 100644
--- a/apps/web/src/lib/masks/geometry.ts
+++ b/apps/web/src/lib/masks/geometry.ts
@@ -1,112 +1,121 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
-import type { ParamValues } from "@/lib/params";
-import type { RectangleMaskParams } from "@/lib/masks/types";
-
-export function hasCenterParams(params: ParamValues): params is ParamValues & {
- centerX: number;
- centerY: number;
-} {
- return (
- typeof params.centerX === "number" && typeof params.centerY === "number"
- );
-}
-
-export function isRectangleMaskParams(
- params: ParamValues,
-): params is RectangleMaskParams {
- return (
- hasCenterParams(params) &&
- typeof params.width === "number" &&
- typeof params.height === "number" &&
- typeof params.rotation === "number" &&
- typeof params.scale === "number"
- );
-}
-
-export function getMaskLocalCenter({
- params,
- bounds,
-}: {
- params: ParamValues;
- bounds: ElementBounds;
-}): { x: number; y: number } | null {
- if (!hasCenterParams(params)) return null;
-
- return {
- x: params.centerX * bounds.width,
- y: params.centerY * bounds.height,
- };
-}
-
-export function setMaskLocalCenter({
- center,
- bounds,
-}: {
- center: { x: number; y: number };
- bounds: ElementBounds;
-}): Pick<ParamValues, "centerX" | "centerY"> {
- return {
- centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
- centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
- };
-}
-
-export function getMaskSnapGeometry({
- params,
- bounds,
-}: {
- params: ParamValues;
- bounds: ElementBounds;
-}): {
- position: { x: number; y: number };
- size: { width: number; height: number };
- rotation: number;
-} | null {
- const position = getMaskLocalCenter({ params, bounds });
- if (!position) return null;
-
- if (isRectangleMaskParams(params)) {
- return {
- position,
- size: {
- width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
- height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
- },
- rotation: params.rotation,
- };
- }
-
- return {
- position,
- size: { width: 0, height: 0 },
- rotation: typeof params.rotation === "number" ? params.rotation : 0,
- };
-}
-
-export function toGlobalMaskSnapLines({
- lines,
- bounds,
- canvasSize,
-}: {
- lines: SnapLine[];
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
-}): SnapLine[] {
- const centerX = bounds.cx - canvasSize.width / 2;
- const centerY = bounds.cy - canvasSize.height / 2;
-
- return lines.map((line) =>
- line.type === "vertical"
- ? {
- type: "vertical" as const,
- position: centerX + line.position,
- }
- : {
- type: "horizontal" as const,
- position: centerY + line.position,
- },
- );
-}
-
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
+import type { RectangleMaskParams } from "@/lib/masks/types";
+
+type CenterMaskParams = {
+ centerX: number;
+ centerY: number;
+};
+
+type SnapGeometryParams = CenterMaskParams & {
+ rotation?: number;
+ width?: number;
+ height?: number;
+ scale?: number;
+};
+
+export function hasCenterParams(
+ params: Partial<CenterMaskParams>,
+): params is CenterMaskParams {
+ return (
+ typeof params.centerX === "number" && typeof params.centerY === "number"
+ );
+}
+
+export function isRectangleMaskParams(
+ params: SnapGeometryParams,
+): params is RectangleMaskParams {
+ return (
+ hasCenterParams(params) &&
+ typeof params.width === "number" &&
+ typeof params.height === "number" &&
+ typeof params.rotation === "number" &&
+ typeof params.scale === "number"
+ );
+}
+
+export function getMaskLocalCenter({
+ params,
+ bounds,
+}: {
+ params: CenterMaskParams;
+ bounds: ElementBounds;
+}): { x: number; y: number } | null {
+ if (!hasCenterParams(params)) return null;
+
+ return {
+ x: params.centerX * bounds.width,
+ y: params.centerY * bounds.height,
+ };
+}
+
+export function setMaskLocalCenter({
+ center,
+ bounds,
+}: {
+ center: { x: number; y: number };
+ bounds: ElementBounds;
+}): { centerX: number; centerY: number } {
+ return {
+ centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
+ centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
+ };
+}
+
+export function getMaskSnapGeometry({
+ params,
+ bounds,
+}: {
+ params: SnapGeometryParams;
+ bounds: ElementBounds;
+}): {
+ position: { x: number; y: number };
+ size: { width: number; height: number };
+ rotation: number;
+} | null {
+ const position = getMaskLocalCenter({ params, bounds });
+ if (!position) return null;
+
+ if (isRectangleMaskParams(params)) {
+ return {
+ position,
+ size: {
+ width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
+ height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
+ },
+ rotation: params.rotation,
+ };
+ }
+
+ return {
+ position,
+ size: { width: 0, height: 0 },
+ rotation: typeof params.rotation === "number" ? params.rotation : 0,
+ };
+}
+
+export function toGlobalMaskSnapLines({
+ lines,
+ bounds,
+ canvasSize,
+}: {
+ lines: SnapLine[];
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+}): SnapLine[] {
+ const centerX = bounds.cx - canvasSize.width / 2;
+ const centerY = bounds.cy - canvasSize.height / 2;
+
+ return lines.map((line) =>
+ line.type === "vertical"
+ ? {
+ type: "vertical" as const,
+ position: centerX + line.position,
+ }
+ : {
+ type: "horizontal" as const,
+ position: centerY + line.position,
+ },
+ );
+}
diff --git a/apps/web/src/lib/masks/handle-positions.ts b/apps/web/src/lib/masks/handle-positions.ts
index 91f301c5..d32b8424 100644
--- a/apps/web/src/lib/masks/handle-positions.ts
+++ b/apps/web/src/lib/masks/handle-positions.ts
@@ -1,382 +1,497 @@
-import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type {
- MaskFeatures,
- MaskHandlePosition,
- MaskLinePoints,
- MaskOverlayShape,
-} from "@/lib/masks/types";
-import type { ParamValues } from "@/lib/params";
-
-const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
-const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
-const LINE_EXTENT_MULTIPLIER = 50;
-
-const CURSOR = {
- rotate: "cursor-crosshair",
- resizeDiagonal: "cursor-nwse-resize",
- resizeHorizontal: "cursor-ew-resize",
- resizeVertical: "cursor-ns-resize",
-} as const;
-
-function getNumParam({
- params,
- key,
- fallback,
-}: {
- params: ParamValues;
- key: string;
- fallback: number;
-}): number {
- const value = params[key];
- return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
-}
-
-/**
- * The renderer defines the split line as:
- * - normal direction: (cos(rotation), sin(rotation))
- * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
- * - reference point: (centerX * width, centerY * height) from element centre
- *
- * So rotation=0 → normal points right → line runs vertically.
- */
-export function getLineMaskLinePoints({
- centerX,
- centerY,
- rotation,
- bounds,
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- bounds: ElementBounds;
-}): MaskLinePoints {
- const angleRad = (rotation * Math.PI) / 180;
- const normalX = Math.cos(angleRad);
- const normalY = Math.sin(angleRad);
- const lineDirX = -normalY;
- const lineDirY = normalX;
-
- const cx = bounds.cx + centerX * bounds.width;
- const cy = bounds.cy + centerY * bounds.height;
-
- const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
-
- return {
- start: {
- x: cx - lineDirX * extent,
- y: cy - lineDirY * extent,
- },
- end: {
- x: cx + lineDirX * extent,
- y: cy + lineDirY * extent,
- },
- };
-}
-
-export function getLineMaskHandlePositions({
- centerX,
- centerY,
- rotation,
- feather,
- bounds,
- displayScale,
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- feather: number;
- bounds: ElementBounds;
- displayScale: number;
-}): MaskHandlePosition[] {
- const angleRad = (rotation * Math.PI) / 180;
- const normalX = Math.cos(angleRad);
- const normalY = Math.sin(angleRad);
-
- const cx = bounds.cx + centerX * bounds.width;
- const cy = bounds.cy + centerY * bounds.height;
-
- const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
- const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
-
- return [
- {
- id: "rotation",
- x: cx + normalX * iconOffsetCanvas,
- y: cy + normalY * iconOffsetCanvas,
- cursor: CURSOR.rotate,
- },
- {
- id: "feather",
- x: cx - normalX * featherOffset,
- y: cy - normalY * featherOffset,
- cursor: CURSOR.resizeHorizontal,
- },
- ];
-}
-
-function rotatePoint({
- localX,
- localY,
- cx,
- cy,
- angleRad,
-}: {
- localX: number;
- localY: number;
- cx: number;
- cy: number;
- angleRad: number;
-}): { x: number; y: number } {
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- return {
- x: cx + localX * cos - localY * sin,
- y: cy + localX * sin + localY * cos,
- };
-}
-
-export function getBoxMaskHandlePositions({
- centerX,
- centerY,
- width,
- height,
- rotation,
- feather,
- sizeMode,
- bounds,
- displayScale,
-}: {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- feather: number;
- sizeMode: MaskFeatures["sizeMode"];
- bounds: ElementBounds;
- displayScale: number;
-}): MaskHandlePosition[] {
- const cx = bounds.cx + centerX * bounds.width;
- const cy = bounds.cy + centerY * bounds.height;
- const angleRad = (rotation * Math.PI) / 180;
- const halfWidth = (width * bounds.width) / 2;
- const halfHeight = (height * bounds.height) / 2;
-
- const handles: MaskHandlePosition[] = [];
- const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
-
- const rotHandle = rotatePoint({
- localX: 0,
- localY: -halfHeight - handleOffsetCanvas,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "rotation",
- x: rotHandle.x,
- y: rotHandle.y,
- cursor: CURSOR.rotate,
- });
-
- const featherHandle = rotatePoint({
- localX: 0,
- localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "feather",
- x: featherHandle.x,
- y: featherHandle.y,
- cursor: CURSOR.resizeVertical,
- });
-
- if (sizeMode === "width-height") {
- const corners = [
- { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
- { localX: halfWidth, localY: -halfHeight, id: "top-right" },
- { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
- { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
- ];
- for (const { localX, localY, id } of corners) {
- const point = rotatePoint({ localX, localY, cx, cy, angleRad });
- handles.push({
- id,
- x: point.x,
- y: point.y,
- cursor: CURSOR.resizeDiagonal,
- });
- }
- const right = rotatePoint({
- localX: halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- const left = rotatePoint({
- localX: -halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- const bottom = rotatePoint({
- localX: 0,
- localY: halfHeight,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "left",
- x: left.x,
- y: left.y,
- cursor: CURSOR.resizeHorizontal,
- });
- handles.push({
- id: "right",
- x: right.x,
- y: right.y,
- cursor: CURSOR.resizeHorizontal,
- });
- handles.push({
- id: "bottom",
- x: bottom.x,
- y: bottom.y,
- cursor: CURSOR.resizeVertical,
- });
- } else if (sizeMode === "height-only") {
- const top = rotatePoint({
- localX: 0,
- localY: -halfHeight,
- cx,
- cy,
- angleRad,
- });
- const bottom = rotatePoint({
- localX: 0,
- localY: halfHeight,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "top",
- x: top.x,
- y: top.y,
- cursor: CURSOR.resizeVertical,
- });
- handles.push({
- id: "bottom",
- x: bottom.x,
- y: bottom.y,
- cursor: CURSOR.resizeVertical,
- });
- } else if (sizeMode === "width-only") {
- const left = rotatePoint({
- localX: -halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- const right = rotatePoint({
- localX: halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "left",
- x: left.x,
- y: left.y,
- cursor: CURSOR.resizeHorizontal,
- });
- handles.push({
- id: "right",
- x: right.x,
- y: right.y,
- cursor: CURSOR.resizeHorizontal,
- });
- } else if (sizeMode === "uniform") {
- const point = rotatePoint({
- localX: halfWidth,
- localY: halfHeight,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "scale",
- x: point.x,
- y: point.y,
- cursor: CURSOR.resizeDiagonal,
- });
- }
-
- return handles;
-}
-
-type MaskHandleResolver = (args: {
- features: MaskFeatures;
- params: ParamValues;
- bounds: ElementBounds;
- displayScale: number;
-}) => MaskHandlePosition[];
-
-const rectangleHandles: MaskHandleResolver = ({
- features,
- params,
- bounds,
- displayScale,
-}) =>
- getBoxMaskHandlePositions({
- centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
- centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
- width: getNumParam({ params, key: "width", fallback: 1 }),
- height: getNumParam({ params, key: "height", fallback: 1 }),
- rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
- feather: getNumParam({ params, key: "feather", fallback: 0 }),
- sizeMode: features.sizeMode,
- bounds,
- displayScale,
- });
-
-const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = {
- line: ({ params, bounds, displayScale }) =>
- getLineMaskHandlePositions({
- centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
- centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
- rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
- feather: getNumParam({ params, key: "feather", fallback: 0 }),
- bounds,
- displayScale,
- }),
- box: rectangleHandles,
-};
-
-export function getMaskHandlePositions({
- overlayShape,
- features,
- params,
- bounds,
- displayScale,
-}: {
- overlayShape: MaskOverlayShape;
- features: MaskFeatures;
- params: ParamValues;
- bounds: ElementBounds;
- displayScale: number;
-}): MaskHandlePosition[] {
- return HANDLE_RESOLVERS[overlayShape]({
- features,
- params,
- bounds,
- displayScale,
- });
-}
+import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type {
+ MaskFeatures,
+ MaskHandlePosition,
+ MaskLineOverlay,
+ MaskOverlay,
+ MaskRectOverlay,
+ RectangleMaskParams,
+ MaskShapeOverlay,
+} from "@/lib/masks/types";
+
+const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
+const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
+const LINE_EXTENT_MULTIPLIER = 50;
+
+const CURSOR = {
+ rotate: "crosshair",
+ resizeDiagonal: "nwse-resize",
+ resizeHorizontal: "ew-resize",
+ resizeVertical: "ns-resize",
+} as const;
+
+/**
+ * The renderer defines the split line as:
+ * - normal direction: (cos(rotation), sin(rotation))
+ * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
+ * - reference point: (centerX * width, centerY * height) from element centre
+ *
+ * So rotation=0 → normal points right → line runs vertically.
+ */
+export function getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+}): { start: { x: number; y: number }; end: { x: number; y: number } } {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+ const lineDirX = -normalY;
+ const lineDirY = normalX;
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
+
+ return {
+ start: {
+ x: cx - lineDirX * extent,
+ y: cy - lineDirY * extent,
+ },
+ end: {
+ x: cx + lineDirX * extent,
+ y: cy + lineDirY * extent,
+ },
+ };
+}
+
+export function getLineMaskOverlay({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+ handleId = "position",
+ cursor = "move",
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+ handleId?: string;
+ cursor?: string;
+}): MaskLineOverlay {
+ const { start, end } = getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+ });
+
+ return {
+ id: "line",
+ type: "line",
+ start,
+ end,
+ handleId,
+ cursor,
+ };
+}
+
+export function getLineMaskHandlePositions({
+ centerX,
+ centerY,
+ rotation,
+ feather,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ feather: number;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
+ const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
+
+ return [
+ {
+ id: "rotation",
+ x: cx + normalX * iconOffsetCanvas,
+ y: cy + normalY * iconOffsetCanvas,
+ cursor: CURSOR.rotate,
+ kind: "icon",
+ icon: "rotate",
+ },
+ {
+ id: "feather",
+ x: cx - normalX * featherOffset,
+ y: cy - normalY * featherOffset,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "icon",
+ icon: "feather",
+ },
+ ];
+}
+
+function rotatePoint({
+ localX,
+ localY,
+ cx,
+ cy,
+ angleRad,
+}: {
+ localX: number;
+ localY: number;
+ cx: number;
+ cy: number;
+ angleRad: number;
+}): { x: number; y: number } {
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ return {
+ x: cx + localX * cos - localY * sin,
+ y: cy + localX * sin + localY * cos,
+ };
+}
+
+export function getBoxMaskHandlePositions({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ feather,
+ sizeMode,
+ showScaleHandle = true,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ feather: number;
+ sizeMode: MaskFeatures["sizeMode"];
+ showScaleHandle?: boolean;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+ const angleRad = (rotation * Math.PI) / 180;
+ const halfWidth = (width * bounds.width) / 2;
+ const halfHeight = (height * bounds.height) / 2;
+
+ const handles: MaskHandlePosition[] = [];
+ const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
+
+ const rotHandle = rotatePoint({
+ localX: 0,
+ localY: -halfHeight - handleOffsetCanvas,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "rotation",
+ x: rotHandle.x,
+ y: rotHandle.y,
+ cursor: CURSOR.rotate,
+ kind: "icon",
+ icon: "rotate",
+ });
+
+ const featherHandle = rotatePoint({
+ localX: 0,
+ localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "feather",
+ x: featherHandle.x,
+ y: featherHandle.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "icon",
+ icon: "feather",
+ });
+
+ if (sizeMode === "width-height") {
+ const corners = [
+ { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
+ { localX: halfWidth, localY: -halfHeight, id: "top-right" },
+ { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
+ { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
+ ];
+ for (const { localX, localY, id } of corners) {
+ const point = rotatePoint({ localX, localY, cx, cy, angleRad });
+ handles.push({
+ id,
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ kind: "corner",
+ });
+ }
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ } else if (sizeMode === "height-only") {
+ const top = rotatePoint({
+ localX: 0,
+ localY: -halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "top",
+ x: top.x,
+ y: top.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ } else if (sizeMode === "width-only") {
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ } else if (sizeMode === "uniform" && showScaleHandle) {
+ const point = rotatePoint({
+ localX: halfWidth,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "scale",
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ kind: "corner",
+ });
+ }
+
+ return handles;
+}
+
+export function getBoxMaskRectOverlay({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ bounds,
+ handleId = "position",
+ cursor = "move",
+ dashed = false,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ bounds: ElementBounds;
+ handleId?: string;
+ cursor?: string;
+ dashed?: boolean;
+}): MaskRectOverlay {
+ return {
+ id: "bounding-box",
+ type: "rect",
+ center: {
+ x: bounds.cx + centerX * bounds.width,
+ y: bounds.cy + centerY * bounds.height,
+ },
+ width: width * bounds.width,
+ height: height * bounds.height,
+ rotation,
+ handleId,
+ cursor,
+ dashed,
+ };
+}
+
+export function getBoxMaskShapeOverlay({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ bounds,
+ pathData,
+ handleId = "position",
+ cursor = "move",
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ bounds: ElementBounds;
+ pathData: string;
+ handleId?: string;
+ cursor?: string;
+}): MaskShapeOverlay {
+ return {
+ id: "shape-outline",
+ type: "shape",
+ center: {
+ x: bounds.cx + centerX * bounds.width,
+ y: bounds.cy + centerY * bounds.height,
+ },
+ width: width * bounds.width,
+ height: height * bounds.height,
+ rotation,
+ pathData,
+ handleId,
+ cursor,
+ };
+}
+
+export function getBoxMaskOverlays({
+ params,
+ bounds,
+ pathData,
+ showBoundingBox = true,
+}: {
+ params: Pick<
+ RectangleMaskParams,
+ "centerX" | "centerY" | "width" | "height" | "rotation"
+ >;
+ bounds: ElementBounds;
+ pathData?: string;
+ showBoundingBox?: boolean;
+}): MaskOverlay[] {
+ const overlays: MaskOverlay[] = [];
+ if (showBoundingBox) {
+ overlays.push(
+ getBoxMaskRectOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: params.width,
+ height: params.height,
+ rotation: params.rotation,
+ bounds,
+ dashed: Boolean(pathData),
+ }),
+ );
+ }
+
+ if (pathData) {
+ overlays.push(
+ getBoxMaskShapeOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: params.width,
+ height: params.height,
+ rotation: params.rotation,
+ bounds,
+ pathData,
+ }),
+ );
+ }
+
+ return overlays;
+}
diff --git a/apps/web/src/lib/masks/param-update.ts b/apps/web/src/lib/masks/param-update.ts
index c4cb92e6..4f8693eb 100644
--- a/apps/web/src/lib/masks/param-update.ts
+++ b/apps/web/src/lib/masks/param-update.ts
@@ -1,59 +1,26 @@
-import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
-import { masksRegistry } from "@/lib/masks";
-import type { ParamValues } from "@/lib/params";
-import type {
- BaseMaskParams,
- MaskParamUpdateArgs,
- MaskType,
-} from "@/lib/masks/types";
-
-function compactMaskParamValues({
- params,
-}: {
- params: Partial<ParamValues>;
-}): ParamValues {
- const nextParams: ParamValues = {};
- for (const [key, value] of Object.entries(params)) {
- if (value !== undefined) {
- nextParams[key] = value;
- }
- }
- return nextParams;
-}
-
-export function computeFeatherUpdate({
- startFeather,
- deltaX,
- deltaY,
- directionX,
- directionY,
-}: {
- startFeather: number;
- deltaX: number;
- deltaY: number;
- directionX: number;
- directionY: number;
-}): ParamValues {
- const projection = deltaX * directionX + deltaY * directionY;
- return {
- feather: Math.max(
- 0,
- Math.min(
- MAX_FEATHER,
- Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
- ),
- ),
- };
-}
-
-export function computeMaskParamUpdate({
- maskType,
- ...args
-}: {
- maskType: MaskType;
-} & MaskParamUpdateArgs<BaseMaskParams & ParamValues>): ParamValues {
- const definition = masksRegistry.get(maskType);
- return compactMaskParamValues({
- params: definition.computeParamUpdate(args),
- });
-}
+import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
+
+export function computeFeatherUpdate({
+ startFeather,
+ deltaX,
+ deltaY,
+ directionX,
+ directionY,
+}: {
+ startFeather: number;
+ deltaX: number;
+ deltaY: number;
+ directionX: number;
+ directionY: number;
+}): { feather: number } {
+ const projection = deltaX * directionX + deltaY * directionY;
+ return {
+ feather: Math.max(
+ 0,
+ Math.min(
+ MAX_FEATHER,
+ Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
+ ),
+ ),
+ };
+}
diff --git a/apps/web/src/lib/masks/registry.ts b/apps/web/src/lib/masks/registry.ts
index 0aa38d87..ae8ddd20 100644
--- a/apps/web/src/lib/masks/registry.ts
+++ b/apps/web/src/lib/masks/registry.ts
@@ -1,6 +1,14 @@
import { MAX_FEATHER } from "@/lib/masks/feather";
import type { ParamDefinition } from "@/lib/params";
-import type { BaseMaskParams, MaskDefinition, MaskType } from "@/lib/masks/types";
+import type {
+ BaseMaskParams,
+ MaskDefaultContext,
+ MaskDefinition,
+ MaskInteractionResult,
+ MaskSnapArgs,
+ MaskSnapResult,
+ MaskType,
+} from "@/lib/masks/types";
import type { HugeiconsIconProps } from "@hugeicons/react";
import { DefinitionRegistry } from "@/lib/registry";
@@ -39,9 +47,33 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
},
];
-export type RegisteredMaskDefinition = MaskDefinition<BaseMaskParams> & {
+export interface RegisteredMaskDefinition {
+ type: MaskType;
+ name: string;
+ features: MaskDefinition<BaseMaskParams>["features"];
+ params: ParamDefinition<string>[];
+ renderer: MaskDefinition<BaseMaskParams>["renderer"];
+ interaction: {
+ getInteraction(args: {
+ params: BaseMaskParams;
+ bounds: Parameters<
+ MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
+ >[0]["bounds"];
+ displayScale: number;
+ scaleX: number;
+ scaleY: number;
+ }): MaskInteractionResult;
+ snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
+ };
+ isActive?: (params: BaseMaskParams) => boolean;
+ buildDefault(
+ context: MaskDefaultContext,
+ ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
+ computeParamUpdate(
+ args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
+ ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
icon: MaskIconProps;
-};
+}
export class MasksRegistry extends DefinitionRegistry<
MaskType,
@@ -59,8 +91,28 @@ export class MasksRegistry extends DefinitionRegistry<
icon: MaskIconProps;
}): void {
const withBaseParams: RegisteredMaskDefinition = {
- ...definition,
+ type: definition.type,
+ name: definition.name,
+ features: definition.features,
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
+ renderer: definition.renderer,
+ interaction: {
+ getInteraction(args) {
+ return definition.interaction.getInteraction(args as never);
+ },
+ snap: definition.interaction.snap
+ ? (args) => definition.interaction.snap?.(args as never) as never
+ : undefined,
+ },
+ isActive: definition.isActive
+ ? (params) => definition.isActive?.(params as TParams) ?? true
+ : undefined,
+ buildDefault(context) {
+ return definition.buildDefault(context);
+ },
+ computeParamUpdate(args) {
+ return definition.computeParamUpdate(args as never);
+ },
icon,
};
this.register(definition.type, withBaseParams);
diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts
index 2d858f61..7396dd82 100644
--- a/apps/web/src/lib/masks/snap.ts
+++ b/apps/web/src/lib/masks/snap.ts
@@ -1,335 +1,336 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
-import {
- snapPosition,
- snapRotation,
- snapScale,
- snapScaleAxes,
- type ScaleEdgePreference,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import type { ParamValues } from "@/lib/params";
-import {
- isRectangleMaskParams,
- getMaskSnapGeometry,
- setMaskLocalCenter,
- toGlobalMaskSnapLines,
-} from "./geometry";
-
-type MaskSnapResult = {
- params: ParamValues;
- activeLines: SnapLine[];
-};
-
-const CORNER_SIZE_HANDLES = new Set([
- "top-left",
- "top-right",
- "bottom-left",
- "bottom-right",
-]);
-
-function getClampedRatio({
- next,
- base,
-}: {
- next: number;
- base: number;
-}): number {
- return (
- Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
- );
-}
-
-function getPreferredEdges({
- handleId,
-}: {
- handleId: string;
-}): ScaleEdgePreference | undefined {
- const preferredEdges = {
- left:
- handleId === "left" ||
- handleId === "top-left" ||
- handleId === "bottom-left",
- right:
- handleId === "right" ||
- handleId === "top-right" ||
- handleId === "bottom-right",
- top:
- handleId === "top" || handleId === "top-left" || handleId === "top-right",
- bottom:
- handleId === "bottom" ||
- handleId === "bottom-left" ||
- handleId === "bottom-right",
- } satisfies ScaleEdgePreference;
-
- return Object.values(preferredEdges).some(Boolean)
- ? preferredEdges
- : undefined;
-}
-
-function snapMaskPosition({
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
-}: {
- proposedParams: ParamValues;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult {
- const geometry = getMaskSnapGeometry({
- params: proposedParams,
- bounds,
- });
- if (!geometry) {
- return { params: proposedParams, activeLines: [] };
- }
-
- const { snappedPosition, activeLines } = snapPosition({
- proposedPosition: geometry.position,
- canvasSize: bounds,
- elementSize: geometry.size,
- rotation: geometry.rotation,
- snapThreshold,
- });
-
- return {
- params: {
- ...proposedParams,
- ...setMaskLocalCenter({
- center: snappedPosition,
- bounds,
- }),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
-}
-
-function snapMaskRotation({
- proposedParams,
-}: {
- proposedParams: ParamValues;
-}): MaskSnapResult {
- if (typeof proposedParams.rotation !== "number") {
- return { params: proposedParams, activeLines: [] };
- }
-
- const { snappedRotation } = snapRotation({
- proposedRotation: proposedParams.rotation,
- });
-
- return {
- params: {
- ...proposedParams,
- rotation: snappedRotation,
- },
- activeLines: [],
- };
-}
-
-function snapBoxMaskSize({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
-}: {
- handleId: string;
- startParams: ParamValues;
- proposedParams: ParamValues;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult {
- if (
- !isRectangleMaskParams(startParams) ||
- !isRectangleMaskParams(proposedParams)
- ) {
- return { params: proposedParams, activeLines: [] };
- }
-
- const geometry = getMaskSnapGeometry({
- params: proposedParams,
- bounds,
- });
- if (!geometry) {
- return { params: proposedParams, activeLines: [] };
- }
-
- const localCanvasSize = bounds;
- const baseWidth =
- Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
- const baseHeight =
- Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
- const preferredEdges = getPreferredEdges({ handleId });
-
- if (handleId === "right" || handleId === "left") {
- const proposedScaleX = getClampedRatio({
- next: proposedParams.width,
- base: startParams.width,
- });
- const { x } = snapScaleAxes({
- proposedScaleX,
- proposedScaleY: 1,
- position: geometry.position,
- baseWidth,
- baseHeight,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: x.activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "top" || handleId === "bottom") {
- const proposedScaleY = getClampedRatio({
- next: proposedParams.height,
- base: startParams.height,
- });
- const { y } = snapScaleAxes({
- proposedScaleX: 1,
- proposedScaleY,
- position: geometry.position,
- baseWidth,
- baseHeight,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- height: Math.max(
- MIN_MASK_DIMENSION,
- startParams.height * y.snappedScale,
- ),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: y.activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "scale") {
- const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
- const proposedScale = getClampedRatio({
- next: proposedParams.scale,
- base: startParams.scale,
- });
- const { snappedScale, activeLines } = snapScale({
- proposedScale,
- position: geometry.position,
- baseWidth: baseWidth * baseScale,
- baseHeight: baseHeight * baseScale,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (CORNER_SIZE_HANDLES.has(handleId)) {
- const proposedScale = getClampedRatio({
- next: proposedParams.width,
- base: startParams.width,
- });
- const { snappedScale, activeLines } = snapScale({
- proposedScale,
- position: geometry.position,
- baseWidth,
- baseHeight,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
- height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- return { params: proposedParams, activeLines: [] };
-}
-
-export function snapMaskInteraction({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
-}: {
- handleId: string;
- startParams: ParamValues;
- proposedParams: ParamValues;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult {
- if (handleId === "position") {
- return snapMaskPosition({
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- });
- }
-
- if (handleId === "rotation") {
- return snapMaskRotation({ proposedParams });
- }
-
- return snapBoxMaskSize({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- });
-}
-
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
+import {
+ isRectangleMaskParams,
+ getMaskSnapGeometry,
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "./geometry";
+
+type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
+
+type MaskSnapResult<TParams extends SharedMaskParams> = {
+ params: TParams;
+ activeLines: SnapLine[];
+};
+
+const CORNER_SIZE_HANDLES = new Set([
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+]);
+
+function getClampedRatio({
+ next,
+ base,
+}: {
+ next: number;
+ base: number;
+}): number {
+ return (
+ Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
+ );
+}
+
+function getPreferredEdges({
+ handleId,
+}: {
+ handleId: string;
+}): ScaleEdgePreference | undefined {
+ const preferredEdges = {
+ left:
+ handleId === "left" ||
+ handleId === "top-left" ||
+ handleId === "bottom-left",
+ right:
+ handleId === "right" ||
+ handleId === "top-right" ||
+ handleId === "bottom-right",
+ top:
+ handleId === "top" || handleId === "top-left" || handleId === "top-right",
+ bottom:
+ handleId === "bottom" ||
+ handleId === "bottom-left" ||
+ handleId === "bottom-right",
+ } satisfies ScaleEdgePreference;
+
+ return Object.values(preferredEdges).some(Boolean)
+ ? preferredEdges
+ : undefined;
+}
+
+function snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ proposedParams: SharedMaskParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<SharedMaskParams> {
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: geometry.position,
+ canvasSize: bounds,
+ elementSize: geometry.size,
+ rotation: geometry.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+}
+
+function snapMaskRotation({
+ proposedParams,
+}: {
+ proposedParams: SharedMaskParams;
+}): MaskSnapResult<SharedMaskParams> {
+ if (typeof proposedParams.rotation !== "number") {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+}
+
+function snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: SharedMaskParams;
+ proposedParams: SharedMaskParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<SharedMaskParams> {
+ if (
+ !isRectangleMaskParams(startParams) ||
+ !isRectangleMaskParams(proposedParams)
+ ) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const localCanvasSize = bounds;
+ const baseWidth =
+ Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
+ const baseHeight =
+ Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
+ const preferredEdges = getPreferredEdges({ handleId });
+
+ if (handleId === "right" || handleId === "left") {
+ const proposedScaleX = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { x } = snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY: 1,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: x.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "top" || handleId === "bottom") {
+ const proposedScaleY = getClampedRatio({
+ next: proposedParams.height,
+ base: startParams.height,
+ });
+ const { y } = snapScaleAxes({
+ proposedScaleX: 1,
+ proposedScaleY,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height * y.snappedScale,
+ ),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: y.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "scale") {
+ const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
+ const proposedScale = getClampedRatio({
+ next: proposedParams.scale,
+ base: startParams.scale,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth: baseWidth * baseScale,
+ baseHeight: baseHeight * baseScale,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (CORNER_SIZE_HANDLES.has(handleId)) {
+ const proposedScale = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
+ height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return { params: proposedParams, activeLines: [] };
+}
+
+export function snapMaskInteraction<TParams extends SharedMaskParams>({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: TParams;
+ proposedParams: TParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<TParams> {
+ if (handleId === "position") {
+ return snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) as MaskSnapResult<TParams>;
+ }
+
+ if (handleId === "rotation") {
+ return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
+ }
+
+ return snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) as MaskSnapResult<TParams>;
+}
diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts
index fe16e15e..5a0d126d 100644
--- a/apps/web/src/lib/masks/types.ts
+++ b/apps/web/src/lib/masks/types.ts
@@ -1,5 +1,12 @@
import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { ParamDefinition, ParamValues } from "@/lib/params";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import type { ParamDefinition } from "@/lib/params";
+import type { CustomMaskPathPoint } from "@/lib/masks/custom-path";
+import type {
+ TextDecoration,
+ TextFontStyle,
+ TextFontWeight,
+} from "@/lib/text/primitives";
export type MaskType =
| "split"
@@ -8,9 +15,11 @@ export type MaskType =
| "ellipse"
| "heart"
| "diamond"
- | "star";
+ | "star"
+ | "text"
+ | "custom";
-export interface BaseMaskParams extends ParamValues {
+export interface BaseMaskParams {
feather: number;
inverted: boolean;
strokeColor: string;
@@ -33,6 +42,30 @@ export interface RectangleMaskParams extends BaseMaskParams {
scale: number;
}
+export interface TextMaskParams extends BaseMaskParams {
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ fontWeight: TextFontWeight;
+ fontStyle: TextFontStyle;
+ textDecoration: TextDecoration;
+ letterSpacing: number;
+ lineHeight: number;
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+}
+
+export interface CustomMaskParams extends BaseMaskParams {
+ path: CustomMaskPathPoint[];
+ closed: boolean;
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+}
+
export interface SplitMask {
id: string;
type: "split";
@@ -75,6 +108,18 @@ export interface StarMask {
params: RectangleMaskParams;
}
+export interface TextMask {
+ id: string;
+ type: "text";
+ params: TextMaskParams;
+}
+
+export interface CustomMask {
+ id: string;
+ type: "custom";
+ params: CustomMaskParams;
+}
+
export type Mask =
| SplitMask
| CinematicBarsMask
@@ -82,14 +127,16 @@ export type Mask =
| EllipseMask
| HeartMask
| DiamondMask
- | StarMask;
+ | StarMask
+ | TextMask
+ | CustomMask;
export interface MaskRenderer {
- buildPath(params: {
+ buildPath?: (params: {
resolvedParams: unknown;
width: number;
height: number;
- }): Path2D;
+ }) => Path2D;
buildStrokePath?: (params: {
resolvedParams: unknown;
width: number;
@@ -103,33 +150,94 @@ export interface MaskRenderer {
height: number;
feather: number;
}) => void;
+ renderMaskHandlesFeather?: boolean;
+ renderStroke?: (params: {
+ resolvedParams: unknown;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ }) => void;
}
-export type MaskOverlayShape = "line" | "box";
-
export interface MaskFeatures {
hasPosition: boolean;
hasRotation: boolean;
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
}
+export type MaskHandleIcon = "rotate" | "feather";
+
+export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
+
export interface MaskHandlePosition {
id: string;
x: number;
y: number;
cursor: string;
+ kind: MaskHandleKind;
+ isSelected?: boolean;
+ edgeAxis?: "horizontal" | "vertical";
+ rotation?: number;
+ icon?: MaskHandleIcon;
}
-export interface MaskLinePoints {
+export interface MaskLineOverlay {
+ id: string;
+ type: "line";
start: { x: number; y: number };
end: { x: number; y: number };
+ cursor?: string;
+ handleId?: string;
}
+export interface MaskRectOverlay {
+ id: string;
+ type: "rect";
+ center: { x: number; y: number };
+ width: number;
+ height: number;
+ rotation: number;
+ dashed?: boolean;
+ cursor?: string;
+ handleId?: string;
+}
+
+export interface MaskShapeOverlay {
+ id: string;
+ type: "shape";
+ center: { x: number; y: number };
+ width: number;
+ height: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ handleId?: string;
+}
+
+export interface MaskCanvasPathOverlay {
+ id: string;
+ type: "canvas-path";
+ pathData: string;
+ coordinateSpace?: "canvas" | "overlay";
+ cursor?: string;
+ handleId?: string;
+ strokeWidth?: number;
+ strokeOpacity?: number;
+}
+
+export type MaskOverlay =
+ | MaskLineOverlay
+ | MaskRectOverlay
+ | MaskShapeOverlay
+ | MaskCanvasPathOverlay;
+
export interface MaskDefaultContext {
elementSize?: { width: number; height: number };
}
-export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
+export interface MaskParamUpdateArgs<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
handleId: string;
startParams: TParams;
deltaX: number;
@@ -140,14 +248,51 @@ export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskPa
canvasSize: { width: number; height: number };
}
-export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
+export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
+ handleId: string;
+ startParams: TParams;
+ proposedParams: TParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}
+
+export interface MaskSnapResult<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
+ params: TParams;
+ activeLines: SnapLine[];
+}
+
+export interface MaskInteractionResult {
+ handles: MaskHandlePosition[];
+ overlays: MaskOverlay[];
+}
+
+export interface MaskInteractionDefinition<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
+ getInteraction(args: {
+ params: TParams;
+ bounds: ElementBounds;
+ displayScale: number;
+ scaleX: number;
+ scaleY: number;
+ }): MaskInteractionResult;
+ snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
+}
+
+export interface MaskDefinition<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
type: MaskType;
name: string;
- overlayShape: MaskOverlayShape;
features: MaskFeatures;
params: ParamDefinition<keyof TParams & string>[];
renderer: MaskRenderer;
- buildOverlayPath?: (params: { width: number; height: number }) => string;
+ interaction: MaskInteractionDefinition<TParams>;
+ /** When defined and returning false, the mask is not applied and the element renders fully visible. */
+ isActive?: (params: TParams) => boolean;
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
- computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
+ computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
}
diff --git a/apps/web/src/lib/selection/editor-selection.ts b/apps/web/src/lib/selection/editor-selection.ts
new file mode 100644
index 00000000..e9563b97
--- /dev/null
+++ b/apps/web/src/lib/selection/editor-selection.ts
@@ -0,0 +1,25 @@
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type { ElementRef } from "@/lib/timeline/types";
+
+export interface SelectedMaskPointSelection {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ pointIds: string[];
+}
+
+export interface EditorSelectionSnapshot {
+ selectedElements: ElementRef[];
+ selectedKeyframes: SelectedKeyframeRef[];
+ keyframeSelectionAnchor: SelectedKeyframeRef | null;
+ selectedMaskPoints: SelectedMaskPointSelection | null;
+}
+
+export interface EditorSelectionPatch {
+ selectedElements?: ElementRef[];
+ selectedKeyframes?: SelectedKeyframeRef[];
+ keyframeSelectionAnchor?: SelectedKeyframeRef | null;
+ selectedMaskPoints?: SelectedMaskPointSelection | null;
+}
+
+export type EditorSelectionKind = "mask-points" | "keyframes" | "elements";
diff --git a/apps/web/src/lib/selection/scope.ts b/apps/web/src/lib/selection/scope.ts
index 35e86945..22c7710c 100644
--- a/apps/web/src/lib/selection/scope.ts
+++ b/apps/web/src/lib/selection/scope.ts
@@ -1,29 +1,30 @@
-export type ScopeEntry = {
- hasSelection: () => boolean;
- clear: () => void;
-};
-
-let activeScope: ScopeEntry | null = null;
-
-export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
- if (activeScope && activeScope !== entry) {
- activeScope.clear();
- }
-
- activeScope = entry;
-
- return () => {
- if (activeScope === entry) {
- activeScope = null;
- }
- };
-}
-
-export function clearActiveScope(): boolean {
- if (!activeScope?.hasSelection()) {
- return false;
- }
-
- activeScope.clear();
- return true;
-}
+export type ScopeEntry = {
+ hasSelection: () => boolean;
+ clear: () => void;
+ clearActive?: () => void;
+};
+
+let activeScope: ScopeEntry | null = null;
+
+export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
+ if (activeScope && activeScope !== entry) {
+ activeScope.clear();
+ }
+
+ activeScope = entry;
+
+ return () => {
+ if (activeScope === entry) {
+ activeScope = null;
+ }
+ };
+}
+
+export function clearActiveScope(): boolean {
+ if (!activeScope?.hasSelection()) {
+ return false;
+ }
+
+ (activeScope.clearActive ?? activeScope.clear)();
+ return true;
+}
diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts
index 213682ab..0b107587 100644
--- a/apps/web/src/lib/text/measure-element.ts
+++ b/apps/web/src/lib/text/measure-element.ts
@@ -1,14 +1,14 @@
import { CORNER_RADIUS_MIN } from "@/lib/text/background";
-import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography";
import { resolveNumberAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type { TextBackground, TextElement } from "@/lib/timeline";
import {
- measureTextBlock,
- setCanvasLetterSpacing,
getTextVisualRect,
- type TextBlockMeasurement,
} from "./layout";
+import {
+ measureTextLayout,
+ type MeasuredTextLayout,
+} from "./primitives";
export interface ResolvedTextBackground extends TextBackground {
paddingX: number;
@@ -18,15 +18,7 @@ export interface ResolvedTextBackground extends TextBackground {
cornerRadius: number;
}
-export interface MeasuredTextElement {
- scaledFontSize: number;
- fontString: string;
- letterSpacing: number;
- lineHeightPx: number;
- lines: string[];
- lineMetrics: TextMetrics[];
- block: TextBlockMeasurement;
- fontSizeRatio: number;
+export interface MeasuredTextElement extends MeasuredTextLayout {
resolvedBackground: ResolvedTextBackground;
visualRect: { left: number; top: number; width: number; height: number };
}
@@ -75,28 +67,20 @@ export function measureTextElement({
localTime: number;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}): MeasuredTextElement {
- const scaledFontSize =
- element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
- const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- const letterSpacing = element.letterSpacing ?? 0;
- const lineHeightPx =
- scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
- const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
- const lines = element.content.split("\n");
-
- ctx.save();
- ctx.font = fontString;
- ctx.textBaseline = "middle";
- setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
- const lineMetrics = lines.map((line) => ctx.measureText(line));
- ctx.restore();
-
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
+ const measuredLayout = measureTextLayout({
+ 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,
+ ctx,
});
const bg = element.background;
@@ -136,20 +120,13 @@ export function measureTextElement({
const visualRect = getTextVisualRect({
textAlign: element.textAlign,
- block,
+ block: measuredLayout.block,
background: resolvedBackground,
- fontSizeRatio,
+ fontSizeRatio: measuredLayout.fontSizeRatio,
});
return {
- scaledFontSize,
- fontString,
- letterSpacing,
- lineHeightPx,
- lines,
- lineMetrics,
- block,
- fontSizeRatio,
+ ...measuredLayout,
resolvedBackground,
visualRect,
};
diff --git a/apps/web/src/lib/text/primitives.ts b/apps/web/src/lib/text/primitives.ts
new file mode 100644
index 00000000..20674c3c
--- /dev/null
+++ b/apps/web/src/lib/text/primitives.ts
@@ -0,0 +1,241 @@
+import type { TextCanvasContext, TextBlockMeasurement } from "@/lib/text/layout";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { clamp } from "@/utils/math";
+import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "./background";
+import {
+ drawTextDecoration,
+ getTextBackgroundRect,
+ measureTextBlock,
+ setCanvasLetterSpacing,
+} from "./layout";
+import { FONT_SIZE_SCALE_REFERENCE } from "./typography";
+
+export type TextAlign = "left" | "center" | "right";
+export type TextFontWeight = "normal" | "bold";
+export type TextFontStyle = "normal" | "italic";
+export type TextDecoration = "none" | "underline" | "line-through";
+
+export interface TextLayoutParams {
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ fontWeight: TextFontWeight;
+ fontStyle: TextFontStyle;
+ textAlign: TextAlign;
+ textDecoration?: TextDecoration;
+ letterSpacing?: number;
+ lineHeight?: number;
+}
+
+export interface ResolvedTextLayout {
+ scaledFontSize: number;
+ fontString: string;
+ letterSpacing: number;
+ lineHeightPx: number;
+ fontSizeRatio: number;
+ textAlign: TextAlign;
+ textDecoration: TextDecoration;
+}
+
+export interface MeasuredTextLayout extends ResolvedTextLayout {
+ lines: string[];
+ lineMetrics: TextMetrics[];
+ block: TextBlockMeasurement;
+}
+
+export interface ResolvedTextBackgroundLike {
+ enabled: boolean;
+ color: string;
+ paddingX: number;
+ paddingY: number;
+ offsetX: number;
+ offsetY: number;
+ cornerRadius: number;
+}
+
+export function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
+ return `"${fontFamily.replace(/"/g, '\\"')}"`;
+}
+
+export function buildTextFontString({
+ fontFamily,
+ fontWeight,
+ fontStyle,
+ scaledFontSize,
+}: {
+ fontFamily: string;
+ fontWeight: TextFontWeight;
+ fontStyle: TextFontStyle;
+ scaledFontSize: number;
+}): string {
+ return `${fontStyle} ${fontWeight} ${scaledFontSize}px ${quoteFontFamily({ fontFamily })}, sans-serif`;
+}
+
+export function resolveTextLayout({
+ text,
+ canvasHeight,
+}: {
+ text: TextLayoutParams;
+ canvasHeight: number;
+}): ResolvedTextLayout {
+ const scaledFontSize =
+ text.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
+ const fontWeight = text.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = text.fontStyle === "italic" ? "italic" : "normal";
+ const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
+ const lineHeightPx =
+ scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
+ const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize;
+
+ return {
+ scaledFontSize,
+ fontString: buildTextFontString({
+ fontFamily: text.fontFamily,
+ fontWeight,
+ fontStyle,
+ scaledFontSize,
+ }),
+ letterSpacing,
+ lineHeightPx,
+ fontSizeRatio,
+ textAlign: text.textAlign,
+ textDecoration: text.textDecoration ?? "none",
+ };
+}
+
+export function measureTextLayout({
+ text,
+ canvasHeight,
+ ctx,
+}: {
+ text: TextLayoutParams;
+ canvasHeight: number;
+ ctx: TextCanvasContext;
+}): MeasuredTextLayout {
+ const resolvedLayout = resolveTextLayout({ text, canvasHeight });
+ const lines = text.content.split("\n");
+
+ ctx.save();
+ ctx.font = resolvedLayout.fontString;
+ ctx.textBaseline = "middle";
+ setCanvasLetterSpacing({
+ ctx,
+ letterSpacingPx: resolvedLayout.letterSpacing,
+ });
+ const lineMetrics = lines.map((line) => ctx.measureText(line));
+ ctx.restore();
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx: resolvedLayout.lineHeightPx,
+ });
+
+ return {
+ ...resolvedLayout,
+ lines,
+ lineMetrics,
+ block,
+ };
+}
+
+export function drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor,
+ background,
+ backgroundColor,
+ textBaseline = "middle",
+}: {
+ ctx: TextCanvasContext;
+ layout: MeasuredTextLayout;
+ textColor: string;
+ background?: ResolvedTextBackgroundLike | null;
+ backgroundColor?: string;
+ textBaseline?: CanvasTextBaseline;
+}): void {
+ ctx.font = layout.fontString;
+ ctx.textAlign = layout.textAlign;
+ ctx.textBaseline = textBaseline;
+ ctx.fillStyle = textColor;
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
+
+ if (
+ background?.enabled &&
+ backgroundColor &&
+ backgroundColor !== "transparent" &&
+ layout.lines.length > 0
+ ) {
+ const backgroundRect = getTextBackgroundRect({
+ textAlign: layout.textAlign,
+ block: layout.block,
+ background: {
+ ...background,
+ color: backgroundColor,
+ },
+ fontSizeRatio: layout.fontSizeRatio,
+ });
+ if (backgroundRect) {
+ const p =
+ clamp({
+ value: background.cornerRadius,
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ }) / 100;
+ const radius =
+ (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
+ ctx.fillStyle = backgroundColor;
+ ctx.beginPath();
+ ctx.roundRect(
+ backgroundRect.left,
+ backgroundRect.top,
+ backgroundRect.width,
+ backgroundRect.height,
+ radius,
+ );
+ ctx.fill();
+ ctx.fillStyle = textColor;
+ }
+ }
+
+ for (let index = 0; index < layout.lines.length; index++) {
+ const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
+ ctx.fillText(layout.lines[index], 0, lineY);
+ drawTextDecoration({
+ ctx,
+ textDecoration: layout.textDecoration,
+ lineWidth: layout.lineMetrics[index].width,
+ lineY,
+ metrics: layout.lineMetrics[index],
+ scaledFontSize: layout.scaledFontSize,
+ textAlign: layout.textAlign,
+ });
+ }
+}
+
+export function strokeMeasuredTextLayout({
+ ctx,
+ layout,
+ strokeColor,
+ strokeWidth,
+ textBaseline = "middle",
+}: {
+ ctx: TextCanvasContext;
+ layout: MeasuredTextLayout;
+ strokeColor: string;
+ strokeWidth: number;
+ textBaseline?: CanvasTextBaseline;
+}): void {
+ ctx.font = layout.fontString;
+ ctx.textAlign = layout.textAlign;
+ ctx.textBaseline = textBaseline;
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = strokeWidth;
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
+
+ for (let index = 0; index < layout.lines.length; index++) {
+ const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
+ ctx.strokeText(layout.lines[index], 0, lineY);
+ }
+}
diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts
index 9625c482..165ece94 100644
--- a/apps/web/src/lib/timeline/audio-state.ts
+++ b/apps/web/src/lib/timeline/audio-state.ts
@@ -57,6 +57,20 @@ export function resolveEffectiveAudioGain({
return dBToLinear(resolvedDb);
}
+export function buildWaveformGainSamples({
+ element,
+ count,
+}: {
+ element: AudioCapableElement;
+ count: number;
+}): number[] {
+ const durationSeconds = element.duration / TICKS_PER_SECOND;
+ return Array.from({ length: count }, (_, i) => {
+ const localTime = ((i + 0.5) / count) * durationSeconds;
+ return resolveEffectiveAudioGain({ element, localTime });
+ });
+}
+
export function buildAudioGainAutomation({
element,
trackMuted = false,
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index e56acd3c..e7ec4065 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -15,7 +15,6 @@ import {
type TextElement,
type SceneTracks,
type TimelineElement,
- type TimelineTrack,
type AudioElement,
type VideoElement,
type ImageElement,
@@ -414,6 +413,13 @@ export function getElementFontFamilies({
if (element.type === "text" && element.fontFamily) {
families.add(element.fontFamily);
}
+ if ("masks" in element) {
+ for (const mask of element.masks ?? []) {
+ if (mask.type === "text" && mask.params.fontFamily) {
+ families.add(mask.params.fontFamily);
+ }
+ }
+ }
}
}
return [...families];
diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
index 8b8201d5..f93eae27 100644
--- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -401,6 +401,11 @@ function buildMaskArtifacts({
}
const definition = masksRegistry.get(mask.type);
+
+ if (definition.isActive?.(mask.params) === false) {
+ return { mask: null, strokeLayer: null };
+ }
+
const elementMaskCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
@@ -416,7 +421,12 @@ function buildMaskArtifacts({
let strokePath: Path2D | null = null;
let feather = mask.params.feather;
- if (mask.params.feather > 0 && definition.renderer.renderMask) {
+ const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
+ const shouldRenderMaskDirectly =
+ canRenderMaskDirectly &&
+ (!definition.renderer.buildPath ||
+ (mask.params.feather > 0 && definition.renderer.renderMaskHandlesFeather));
+ if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({
resolvedParams: mask.params,
ctx: elementMaskCtx,
@@ -424,13 +434,18 @@ function buildMaskArtifacts({
height: Math.round(transform.height),
feather: mask.params.feather,
});
- feather = 0;
+ if (definition.renderer.renderMaskHandlesFeather) {
+ feather = 0;
+ }
strokePath = definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ?? null;
} else {
+ if (!definition.renderer.buildPath) {
+ return { mask: null, strokeLayer: null };
+ }
const path2d = definition.renderer.buildPath({
resolvedParams: mask.params,
width: transform.width,
@@ -472,7 +487,7 @@ function buildMaskArtifacts({
});
let strokeLayer: FrameItemDescriptor | null = null;
- if (mask.params.strokeWidth > 0 && strokePath) {
+ if (mask.params.strokeWidth > 0 && (strokePath || definition.renderer.renderStroke)) {
const strokeCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
@@ -482,9 +497,18 @@ function buildMaskArtifacts({
| OffscreenCanvasRenderingContext2D
| null;
if (strokeCtx) {
- strokeCtx.strokeStyle = mask.params.strokeColor;
- strokeCtx.lineWidth = mask.params.strokeWidth;
- strokeCtx.stroke(strokePath);
+ if (definition.renderer.renderStroke) {
+ definition.renderer.renderStroke({
+ resolvedParams: mask.params,
+ ctx: strokeCtx,
+ width: transform.width,
+ height: transform.height,
+ });
+ } else if (strokePath) {
+ strokeCtx.strokeStyle = mask.params.strokeColor;
+ strokeCtx.lineWidth = mask.params.strokeWidth;
+ strokeCtx.stroke(strokePath);
+ }
const fullStrokeCanvas = createOffscreenCanvas({
width: renderer.width,
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index c9e13261..86384328 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -3,16 +3,9 @@ import type { TextElement } from "@/lib/timeline";
import type { EffectPass } from "@/lib/effects/types";
import type { Transform } from "@/lib/rendering";
import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/lib/text/background";
-import {
- drawTextDecoration,
- getTextBackgroundRect,
- setCanvasLetterSpacing,
-} from "@/lib/text/layout";
+ drawMeasuredTextLayout,
+} from "@/lib/text/primitives";
import type { MeasuredTextElement } from "@/lib/text/measure-element";
-import { clamp } from "@/utils/math";
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };
@@ -46,22 +39,6 @@ export function renderTextToContext({
const x = resolved.transform.position.x + node.params.canvasCenter.x;
const y = resolved.transform.position.y + node.params.canvasCenter.y;
const baseline = node.params.textBaseline ?? "middle";
- const {
- scaledFontSize,
- fontString,
- letterSpacing,
- lineHeightPx,
- lines,
- lineMetrics,
- block,
- fontSizeRatio,
- resolvedBackground,
- } = resolved.measuredText;
- const lineCount = lines.length;
- const resolvedBackgroundWithColor = {
- ...resolvedBackground,
- color: resolved.backgroundColor,
- };
ctx.save();
ctx.translate(x, y);
@@ -70,60 +47,14 @@ export function renderTextToContext({
ctx.rotate((resolved.transform.rotate * Math.PI) / 180);
}
- ctx.font = fontString;
- ctx.textAlign = node.params.textAlign;
- ctx.textBaseline = baseline;
- ctx.fillStyle = resolved.textColor;
- setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
-
- if (
- node.params.background.enabled &&
- node.params.background.color &&
- node.params.background.color !== "transparent" &&
- lineCount > 0
- ) {
- const backgroundRect = getTextBackgroundRect({
- textAlign: node.params.textAlign,
- block,
- background: resolvedBackgroundWithColor,
- fontSizeRatio,
- });
- if (backgroundRect) {
- const p =
- clamp({
- value: resolvedBackgroundWithColor.cornerRadius,
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- }) / 100;
- const radius =
- (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
- ctx.fillStyle = resolvedBackgroundWithColor.color;
- ctx.beginPath();
- ctx.roundRect(
- backgroundRect.left,
- backgroundRect.top,
- backgroundRect.width,
- backgroundRect.height,
- radius,
- );
- ctx.fill();
- ctx.fillStyle = resolved.textColor;
- }
- }
-
- for (let index = 0; index < lineCount; index++) {
- const lineY = index * lineHeightPx - block.visualCenterOffset;
- ctx.fillText(lines[index], 0, lineY);
- drawTextDecoration({
- ctx,
- textDecoration: node.params.textDecoration ?? "none",
- lineWidth: lineMetrics[index].width,
- lineY,
- metrics: lineMetrics[index],
- scaledFontSize,
- textAlign: node.params.textAlign,
- });
- }
+ drawMeasuredTextLayout({
+ ctx,
+ layout: resolved.measuredText,
+ textColor: resolved.textColor,
+ background: resolved.measuredText.resolvedBackground,
+ backgroundColor: resolved.backgroundColor,
+ textBaseline: baseline,
+ });
ctx.restore();
}
diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts
new file mode 100644
index 00000000..730d958d
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV26ToV27 } from "../transformers/v26-to-v27";
+
+describe("V26 to V27 Migration", () => {
+ test("converts custom mask paths from JSON strings to typed point arrays", () => {
+ const result = transformProjectV26ToV27({
+ project: {
+ id: "project-v26-custom-mask",
+ version: 26,
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "track-1",
+ type: "video",
+ elements: [
+ {
+ id: "element-1",
+ type: "image",
+ masks: [
+ {
+ id: "mask-custom",
+ type: "custom",
+ params: {
+ path: JSON.stringify([
+ {
+ id: "point-1",
+ x: 0,
+ y: 0,
+ inX: 0,
+ inY: 0,
+ outX: 0.1,
+ outY: 0,
+ },
+ ]),
+ closed: false,
+ },
+ },
+ {
+ id: "mask-rectangle",
+ type: "rectangle",
+ params: {
+ width: 0.5,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(27);
+
+ const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const tracks = scenes[0].tracks as Record<string, unknown>;
+ const mainTrack = tracks.main as Record<string, unknown>;
+ const elements = mainTrack.elements as Array<Record<string, unknown>>;
+ const masks = elements[0].masks as Array<Record<string, unknown>>;
+ const customParams = masks[0].params as Record<string, unknown>;
+ const rectangleParams = masks[1].params as Record<string, unknown>;
+
+ expect(customParams.path).toEqual([
+ {
+ id: "point-1",
+ x: 0,
+ y: 0,
+ inX: 0,
+ inY: 0,
+ outX: 0.1,
+ outY: 0,
+ },
+ ]);
+ expect(rectangleParams.width).toBe(0.5);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index c5a2e237..b290b713 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -25,10 +25,11 @@ import { V22toV23Migration } from "./v22-to-v23";
import { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25";
import { V25toV26Migration } from "./v25-to-v26";
+import { V26toV27Migration } from "./v26-to-v27";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 26;
+export const CURRENT_PROJECT_VERSION = 27;
export const migrations = [
new V0toV1Migration(),
@@ -57,4 +58,5 @@ export const migrations = [
new V23toV24Migration(),
new V24toV25Migration(),
new V25toV26Migration(),
+ new V26toV27Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
new file mode 100644
index 00000000..186ae1e9
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
@@ -0,0 +1,114 @@
+import { parseCustomMaskPath } from "@/lib/masks/custom-path";
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV26ToV27({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 27) {
+ return { project, skipped: true, reason: "already v27" };
+ }
+ if (version !== 26) {
+ return { project, skipped: true, reason: "not v26" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 27,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ if (!Array.isArray(project.scenes)) {
+ return project;
+ }
+
+ return {
+ ...project,
+ scenes: project.scenes.map((scene) => migrateScene({ scene })),
+ };
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene) || !isRecord(scene.tracks)) {
+ return scene;
+ }
+
+ const tracks = scene.tracks;
+ const nextTracks: ProjectRecord = { ...tracks };
+
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+
+ return {
+ ...scene,
+ tracks: nextTracks,
+ };
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || !Array.isArray(element.masks)) {
+ return element;
+ }
+
+ return {
+ ...element,
+ masks: element.masks.map((mask) => migrateMask({ mask })),
+ };
+}
+
+function migrateMask({ mask }: { mask: unknown }): unknown {
+ if (!isRecord(mask) || mask.type !== "custom" || !isRecord(mask.params)) {
+ return mask;
+ }
+
+ const path = mask.params.path;
+ if (typeof path !== "string") {
+ return mask;
+ }
+
+ return {
+ ...mask,
+ params: {
+ ...mask.params,
+ path: parseCustomMaskPath({ path }),
+ },
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/v26-to-v27.ts b/apps/web/src/services/storage/migrations/v26-to-v27.ts
new file mode 100644
index 00000000..2df5329d
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v26-to-v27.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV26ToV27 } from "./transformers/v26-to-v27";
+
+export class V26toV27Migration extends StorageMigration {
+ from = 26;
+ to = 27;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV26ToV27({ project });
+ }
+}