diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
| commit | c46d28891fee62ca2d3132da876af6411a41d9da (patch) | |
| tree | f856f71d04c69628f26b904088d675dd298a5080 /apps/web | |
| parent | 66e4367bc4727f1d0bcf8f869529c7518812277e (diff) | |
| download | opencut-c46d28891fee62ca2d3132da876af6411a41d9da.tar.gz | |
feat: text / custom masks, fix preview pan, refactor selection
Diffstat (limited to 'apps/web')
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 }); + } +} |
