feat: text / custom masks, fix preview pan, refactor selection

This commit is contained in:
Maze Winther 2026-04-15 15:45:50 +02:00
parent 66e4367bc4
commit c46d28891f
53 changed files with 8082 additions and 3636 deletions

View File

@ -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>
);
}

View File

@ -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,
});

View File

@ -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,
}}
/>
</>
);
}

View File

@ -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}
/>
</>
) : (
<BoundingBoxOutline
{...rectangleOutlineProps}
cursor="cursor-move"
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
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
}
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,15 @@ 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) =>
handlePointerDown({ event, handleId: handle.id })
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@ -200,19 +256,33 @@ export function MaskHandles({
);
}
if (
handle.id === "top-left" ||
handle.id === "top-right" ||
handle.id === "bottom-left" ||
handle.id === "bottom-right" ||
handle.id === "scale"
) {
if (handle.kind === "point" || handle.kind === "tangent") {
return (
<CircleHandle
key={handle.id}
screen={screen}
size={
handle.kind === "tangent"
? CUSTOM_MASK_TANGENT_SIZE
: CUSTOM_MASK_ANCHOR_SIZE
}
isSelected={handle.isSelected}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
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}

View File

@ -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,

View File

@ -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,

View File

@ -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();

View File

@ -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} />

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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();
});
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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."
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."

View File

@ -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 {

View File

@ -54,7 +54,12 @@ export class DeleteElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks);
return {
select: [],
selection: {
selectedElements: [],
selectedKeyframes: [],
keyframeSelectionAnchor: null,
selectedMaskPoints: null,
},
};
}

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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";

View File

@ -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);
}
}
}

View File

@ -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);
});
});

View File

@ -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(" ");
}

View File

@ -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,
};
}

View File

@ -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",

View File

@ -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,
},
};
}

View File

@ -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",

View File

@ -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;
},
},
};

View File

@ -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",

View File

@ -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 },
});
}

View File

@ -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,
});
},
},
};

View File

@ -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;
},
},
};

View File

@ -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",

View File

@ -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();
},
},
};

View File

@ -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,
},
);
}

View File

@ -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;
}

View File

@ -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),
),
),
};
}

View File

@ -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);

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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";

View File

@ -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;
}

View File

@ -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,
};

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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];

View File

@ -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,

View File

@ -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();
}

View File

@ -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);
});
});

View File

@ -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(),
];

View File

@ -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 }),
},
};
}

View File

@ -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 });
}
}