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"; "use client";
import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { import {
Section, Section,
SectionContent, SectionContent,
SectionHeader, SectionHeader,
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { import { ColorPickerContent } from "@/components/ui/color-picker";
BACKGROUND_BLUR_INTENSITY_PRESETS, import { Popover, PopoverTrigger } from "@/components/ui/popover";
DEFAULT_BACKGROUND_BLUR_INTENSITY, import {
} from "@/lib/background/blur"; BACKGROUND_BLUR_INTENSITY_PRESETS,
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color"; DEFAULT_BACKGROUND_BLUR_INTENSITY,
import { patternCraftGradients } from "@/data/colors/pattern-craft"; } from "@/lib/background/blur";
import { colors } from "@/data/colors/solid"; import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { syntaxUIGradients } from "@/data/colors/syntax-ui"; import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { useEditor } from "@/hooks/use-editor"; import { colors } from "@/data/colors/solid";
import { effectPreviewService } from "@/services/renderer/effect-preview"; import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { cn } from "@/utils/ui"; import { useEditor } from "@/hooks/use-editor";
import { effectPreviewService } from "@/services/renderer/effect-preview";
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = { import { cn } from "@/utils/ui";
width: 1920,
height: 1080, const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
} as const; width: 1920,
height: 1080,
const BlurPreview = memo( } as const;
({
blur, const CUSTOM_COLOR_SWATCH_BACKGROUND =
isSelected, "conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
onSelect,
}: { const BlurPreview = memo(
blur: { label: string; value: number }; ({
isSelected: boolean; blur,
onSelect: () => void; isSelected,
}) => { onSelect,
const canvasRef = useRef<HTMLCanvasElement>(null); }: {
blur: { label: string; value: number };
useEffect(() => { isSelected: boolean;
const renderPreview = () => { onSelect: () => void;
if (!canvasRef.current) return; }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
effectPreviewService.renderPreview({
effectType: "blur", useEffect(() => {
params: { intensity: blur.value }, const renderPreview = () => {
targetCanvas: canvasRef.current, if (!canvasRef.current) return;
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
}); effectPreviewService.renderPreview({
}; effectType: "blur",
params: { intensity: blur.value },
renderPreview(); targetCanvas: canvasRef.current,
return effectPreviewService.onPreviewImageReady({ uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
callback: renderPreview, });
}); };
}, [blur.value]);
renderPreview();
return ( return effectPreviewService.onPreviewImageReady({
<button callback: renderPreview,
className={cn( });
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", }, [blur.value]);
isSelected && "border-primary border-2",
)} return (
onClick={onSelect} <button
type="button" className={cn(
aria-label={`Select ${blur.label} blur`} "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
> isSelected && "border-primary border-2",
<canvas )}
ref={canvasRef} onClick={onSelect}
className="absolute inset-0 h-full w-full object-cover" type="button"
/> aria-label={`Select ${blur.label} blur`}
<div className="absolute right-1 bottom-1 left-1 text-center"> >
<span className="rounded bg-black/50 px-1 text-xs text-white"> <canvas
{blur.label} ref={canvasRef}
</span> className="absolute inset-0 h-full w-full object-cover"
</div> />
</button> <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>
BlurPreview.displayName = "BlurPreview"; </button>
);
const BackgroundPreviews = memo( },
({ );
backgrounds,
currentBackgroundColor, BlurPreview.displayName = "BlurPreview";
isColorBackground,
onSelect, const BackgroundPreviews = memo(
useBackgroundColor = false, ({
}: { backgrounds,
backgrounds: string[]; currentBackgroundColor,
currentBackgroundColor: string; isColorBackground,
isColorBackground: boolean; onSelect,
onSelect: (bg: string) => void; useBackgroundColor = false,
useBackgroundColor?: boolean; }: {
}) => { backgrounds: readonly string[];
return useMemo( currentBackgroundColor: string;
() => isColorBackground: boolean;
backgrounds.map((bg) => ( onSelect: (bg: string) => void;
<button useBackgroundColor?: boolean;
key={bg} }) => {
className={cn( return useMemo(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", () =>
isColorBackground && backgrounds.map((bg) => (
bg === currentBackgroundColor && <button
"border-primary border-2", key={bg}
)} className={cn(
style={ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
useBackgroundColor isColorBackground &&
? { backgroundColor: bg } bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
: { "border-primary border-2",
background: bg, )}
backgroundSize: "cover", style={
backgroundPosition: "center", useBackgroundColor
backgroundRepeat: "no-repeat", ? { backgroundColor: bg }
} : {
} background: bg,
onClick={() => onSelect(bg)} backgroundSize: "cover",
type="button" backgroundPosition: "center",
aria-label={`Select background ${bg}`} backgroundRepeat: "no-repeat",
/> }
)), }
[ onClick={() => onSelect(bg)}
backgrounds, type="button"
isColorBackground, aria-label={`Select background ${bg}`}
currentBackgroundColor, />
onSelect, )),
useBackgroundColor, [
], 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 }, BackgroundPreviews.displayName = "BackgroundPreviews";
] as const;
function CustomColorPreview({
export function BackgroundContent() { currentBackgroundColor,
const editor = useEditor(); isSelected,
const activeProject = useEditor((e) => e.project.getActive()); onPreview,
onCommit,
const handleBlurSelect = useCallback( }: {
async (blurIntensity: number) => { currentBackgroundColor: string;
await editor.project.updateSettings({ isSelected: boolean;
settings: { background: { type: "blur", blurIntensity } }, onPreview: (color: string) => void;
}); onCommit: (color: string) => void;
}, }) {
[editor.project], return (
); <Popover>
<PopoverTrigger asChild>
const handleColorSelect = useCallback( <button
async (color: string) => { className={cn(
await editor.project.updateSettings({ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
settings: { background: { type: "color", color } }, isSelected && "border-primary border-2",
}); )}
}, type="button"
[editor.project], aria-label="Pick a custom background color"
); >
<span
const isBlurBackground = activeProject.settings.background.type === "blur"; className="absolute inset-0"
const isColorBackground = activeProject.settings.background.type === "color"; style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
/>
const currentBlurIntensity = isBlurBackground <span
? (activeProject.settings.background as { blurIntensity: number }) className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
.blurIntensity style={{ backgroundColor: currentBackgroundColor }}
: DEFAULT_BACKGROUND_BLUR_INTENSITY; />
</button>
const currentBackgroundColor = isColorBackground </PopoverTrigger>
? (activeProject.settings.background as { color: string }).color <ColorPickerContent
: DEFAULT_BACKGROUND_COLOR; value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
onChange={(color) => onPreview(`#${color}`)}
const blurPreviews = useMemo( onChangeEnd={(color) => onCommit(`#${color}`)}
() => />
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => ( </Popover>
<BlurPreview );
key={blur.value} }
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value} const COLOR_SECTIONS = [
onSelect={() => handleBlurSelect(blur.value)} { 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 },
[isBlurBackground, currentBlurIntensity, handleBlurSelect], ] as const;
);
export function BackgroundContent() {
return ( const editor = useEditor();
<div className="flex flex-col"> const activeProject = useEditor((e) => e.project.getActive());
<Section
collapsible const handleBlurSelect = useCallback(
defaultOpen={true} async (blurIntensity: number) => {
sectionKey="background-blur" await editor.project.updateSettings({
showTopBorder={false} settings: { background: { type: "blur", blurIntensity } },
> });
<SectionHeader> },
<SectionTitle>Blur</SectionTitle> [editor.project],
</SectionHeader> );
<SectionContent>
<div className="flex flex-wrap gap-2">{blurPreviews}</div> const previewBackgroundColor = useCallback(
</SectionContent> async (color: string) => {
</Section> await editor.project.updateSettings({
{COLOR_SECTIONS.map((section) => ( settings: { background: { type: "color", color } },
<Section pushHistory: false,
key={section.title} });
collapsible },
defaultOpen={false} [editor.project],
sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`} );
>
<SectionHeader> const commitBackgroundColor = useCallback(
<SectionTitle>{section.title}</SectionTitle> async (color: string) => {
</SectionHeader> await editor.project.updateSettings({
<SectionContent> settings: { background: { type: "color", color } },
<div className="flex flex-wrap gap-2"> pushHistory: true,
<BackgroundPreviews });
backgrounds={section.backgrounds as string[]} },
currentBackgroundColor={currentBackgroundColor} [editor.project],
isColorBackground={isColorBackground} );
onSelect={handleColorSelect}
useBackgroundColor={ const isBlurBackground = activeProject.settings.background.type === "blur";
"useBackgroundColor" in section const isColorBackground = activeProject.settings.background.type === "color";
? section.useBackgroundColor
: false const currentBlurIntensity = isBlurBackground
} ? (activeProject.settings.background as { blurIntensity: number })
/> .blurIntensity
</div> : DEFAULT_BACKGROUND_BLUR_INTENSITY;
</SectionContent>
</Section> const currentBackgroundColor = isColorBackground
))} ? (activeProject.settings.background as { color: string }).color
</div> : 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"; "use client";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react"; import type { IconSvgElement } from "@hugeicons/react";
export const HANDLE_SIZE = 10; export const HANDLE_SIZE = 10;
export const HANDLE_HIT_AREA_SIZE = 18; export const HANDLE_HIT_AREA_SIZE = 18;
export const ICON_HANDLE_RADIUS = 10; export const ICON_HANDLE_RADIUS = 10;
export const EDGE_HANDLE_THIN_SIZE = 6; export const EDGE_HANDLE_THIN_SIZE = 6;
export const EDGE_HANDLE_THICK_SIZE = 14; export const EDGE_HANDLE_THICK_SIZE = 14;
export const LINE_HIT_AREA_SIZE = 48; export const LINE_HIT_AREA_SIZE = 48;
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
const normalized = ((angleDeg % 180) + 180) % 180; const normalized = ((angleDeg % 180) + 180) % 180;
if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize"; if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
if (normalized < 67.5) return "cursor-nwse-resize"; if (normalized < 67.5) return "nwse-resize";
if (normalized < 112.5) return "cursor-ns-resize"; if (normalized < 112.5) return "ns-resize";
return "cursor-nesw-resize"; return "nesw-resize";
} }
export function HandleButton({ export function HandleButton({
screen, screen,
cursor, cursor,
hitAreaSize, hitAreaSize,
className, className,
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
children, children,
}: { }: {
screen: { x: number; y: number }; screen: { x: number; y: number };
cursor?: string; cursor?: string;
hitAreaSize: number; hitAreaSize: number;
className?: string; className?: string;
onPointerDown: (event: React.PointerEvent) => void; onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void; onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void; onPointerUp: (event: React.PointerEvent) => void;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<button <button
type="button" type="button"
className={cn( className={cn(
"absolute flex items-center justify-center outline-none", "absolute flex items-center justify-center outline-none",
cursor, className,
className, )}
)} style={{
style={{ left: screen.x - hitAreaSize / 2,
left: screen.x - hitAreaSize / 2, top: screen.y - hitAreaSize / 2,
top: screen.y - hitAreaSize / 2, width: hitAreaSize,
width: hitAreaSize, height: hitAreaSize,
height: hitAreaSize, pointerEvents: "auto",
pointerEvents: "auto", cursor,
}} }}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerLeave={onPointerUp} onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
> >
{children} {children}
</button> </button>
); );
} }
export function CornerHandle({ export function CornerHandle({
cursor, cursor,
screen, screen,
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
}: { }: {
cursor?: string; cursor?: string;
screen: { x: number; y: number }; screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void; onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void; onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void; onPointerUp: (event: React.PointerEvent) => void;
}) { }) {
return ( return (
<HandleButton <HandleButton
screen={screen} screen={screen}
cursor={cursor} cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE} hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
> >
<div <div
className="rounded-sm bg-white" className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/> />
</HandleButton> </HandleButton>
); );
} }
export function EdgeHandle({ export function CircleHandle({
edge, cursor,
screen, screen,
rotation, size = HANDLE_SIZE,
onPointerDown, isSelected = false,
onPointerMove, onPointerDown,
onPointerUp, onPointerMove,
}: { onPointerUp,
edge: "right" | "left" | "bottom"; }: {
screen: { x: number; y: number }; cursor?: string;
rotation: number; screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void; size?: number;
onPointerMove: (event: React.PointerEvent) => void; isSelected?: boolean;
onPointerUp: (event: React.PointerEvent) => void; onPointerDown: (event: React.PointerEvent) => void;
}) { onPointerMove: (event: React.PointerEvent) => void;
const isHorizontalEdge = edge === "right" || edge === "left"; onPointerUp: (event: React.PointerEvent) => void;
const width = isHorizontalEdge }) {
? EDGE_HANDLE_THIN_SIZE return (
: EDGE_HANDLE_THICK_SIZE; <HandleButton
const height = isHorizontalEdge screen={screen}
? EDGE_HANDLE_THICK_SIZE cursor={cursor}
: EDGE_HANDLE_THIN_SIZE; hitAreaSize={HANDLE_HIT_AREA_SIZE}
const cursor = getResizeCursor({ onPointerDown={onPointerDown}
angleDeg: isHorizontalEdge ? rotation : rotation + 90, onPointerMove={onPointerMove}
}); onPointerUp={onPointerUp}
>
return ( <div
<HandleButton className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
screen={screen} style={{ width: size, height: size }}
cursor={cursor} />
hitAreaSize={HANDLE_HIT_AREA_SIZE} </HandleButton>
onPointerDown={onPointerDown} );
onPointerMove={onPointerMove} }
onPointerUp={onPointerUp}
> export function EdgeHandle({
<div edge,
className="rounded-sm bg-white" screen,
style={{ width, height, transform: `rotate(${rotation}deg)` }} rotation,
/> onPointerDown,
</HandleButton> onPointerMove,
); onPointerUp,
} }: {
edge: "right" | "left" | "bottom";
export function IconHandle({ screen: { x: number; y: number };
icon, rotation: number;
screen, onPointerDown: (event: React.PointerEvent) => void;
onPointerDown, onPointerMove: (event: React.PointerEvent) => void;
onPointerMove, onPointerUp: (event: React.PointerEvent) => void;
onPointerUp, }) {
}: { const isHorizontalEdge = edge === "right" || edge === "left";
icon: IconSvgElement; const width = isHorizontalEdge
screen: { x: number; y: number }; ? EDGE_HANDLE_THIN_SIZE
onPointerDown: (event: React.PointerEvent) => void; : EDGE_HANDLE_THICK_SIZE;
onPointerMove: (event: React.PointerEvent) => void; const height = isHorizontalEdge
onPointerUp: (event: React.PointerEvent) => void; ? EDGE_HANDLE_THICK_SIZE
}) { : EDGE_HANDLE_THIN_SIZE;
return ( const cursor = getResizeCursor({
<HandleButton angleDeg: isHorizontalEdge ? rotation : rotation + 90,
screen={screen} });
hitAreaSize={ICON_HANDLE_RADIUS * 2}
className="rounded-full bg-white text-black shadow-sm" return (
onPointerDown={onPointerDown} <HandleButton
onPointerMove={onPointerMove} screen={screen}
onPointerUp={onPointerUp} cursor={cursor}
> hitAreaSize={HANDLE_HIT_AREA_SIZE}
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} /> onPointerDown={onPointerDown}
</HandleButton> onPointerMove={onPointerMove}
); onPointerUp={onPointerUp}
} >
<div
export function BoundingBoxOutline({ className="rounded-sm bg-white"
center, style={{ width, height, transform: `rotate(${rotation}deg)` }}
outlineWidth, />
outlineHeight, </HandleButton>
rotation, );
cursor, }
dashed = false,
onPointerDown, export function IconHandle({
onPointerMove, icon,
onPointerUp, screen,
}: { onPointerDown,
center: { x: number; y: number }; onPointerMove,
outlineWidth: number; onPointerUp,
outlineHeight: number; }: {
rotation: number; icon: IconSvgElement;
cursor?: string; screen: { x: number; y: number };
dashed?: boolean; onPointerDown: (event: React.PointerEvent) => void;
onPointerDown?: (event: React.PointerEvent) => void; onPointerMove: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void; onPointerUp: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void; }) {
}) { return (
return ( <HandleButton
<svg screen={screen}
className={cn("absolute overflow-visible", cursor)} hitAreaSize={ICON_HANDLE_RADIUS * 2}
aria-hidden="true" className="rounded-full bg-white text-black shadow-sm"
focusable="false" onPointerDown={onPointerDown}
style={{ onPointerMove={onPointerMove}
left: center.x - outlineWidth / 2, onPointerUp={onPointerUp}
top: center.y - outlineHeight / 2, >
width: outlineWidth, <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
height: outlineHeight, </HandleButton>
transform: `rotate(${rotation}deg)`, );
transformOrigin: "center center", }
pointerEvents: onPointerDown ? "auto" : "none",
}} export function BoundingBoxOutline({
onPointerDown={onPointerDown} center,
onPointerMove={onPointerMove} outlineWidth,
onPointerUp={onPointerUp} outlineHeight,
onPointerLeave={onPointerUp} rotation,
> cursor,
<rect dashed = false,
x={0.5} onPointerDown,
y={0.5} onPointerMove,
width={Math.max(outlineWidth - 1, 0)} onPointerUp,
height={Math.max(outlineHeight - 1, 0)} }: {
fill="transparent" center: { x: number; y: number };
stroke="white" outlineWidth: number;
strokeDasharray={dashed ? "4 4" : undefined} outlineHeight: number;
strokeOpacity={0.75} rotation: number;
vectorEffect="non-scaling-stroke" cursor?: string;
style={{ pointerEvents: onPointerDown ? "all" : "none" }} dashed?: boolean;
/> onPointerDown?: (event: React.PointerEvent) => void;
</svg> onPointerMove?: (event: React.PointerEvent) => void;
); onPointerUp?: (event: React.PointerEvent) => void;
} }) {
return (
export function ShapeOutline({ <svg
center, className="absolute overflow-visible"
outlineWidth, aria-hidden="true"
outlineHeight, focusable="false"
rotation, style={{
pathData, left: center.x - outlineWidth / 2,
cursor, top: center.y - outlineHeight / 2,
onPointerDown, width: outlineWidth,
onPointerMove, height: outlineHeight,
onPointerUp, transform: `rotate(${rotation}deg)`,
}: { transformOrigin: "center center",
center: { x: number; y: number }; pointerEvents: onPointerDown ? "auto" : "none",
outlineWidth: number; cursor,
outlineHeight: number; }}
rotation: number; onPointerDown={onPointerDown}
pathData: string; onPointerMove={onPointerMove}
cursor?: string; onPointerUp={onPointerUp}
onPointerDown?: (event: React.PointerEvent) => void; onPointerLeave={onPointerUp}
onPointerMove?: (event: React.PointerEvent) => void; >
onPointerUp?: (event: React.PointerEvent) => void; <rect
}) { x={0.5}
return ( y={0.5}
<svg width={Math.max(outlineWidth - 1, 0)}
className={cn("absolute overflow-visible", cursor)} height={Math.max(outlineHeight - 1, 0)}
aria-hidden="true" fill="transparent"
focusable="false" stroke="white"
style={{ strokeDasharray={dashed ? "4 4" : undefined}
left: center.x - outlineWidth / 2, strokeOpacity={0.75}
top: center.y - outlineHeight / 2, vectorEffect="non-scaling-stroke"
width: outlineWidth, style={{ pointerEvents: onPointerDown ? "all" : "none" }}
height: outlineHeight, />
transform: `rotate(${rotation}deg)`, </svg>
transformOrigin: "center center", );
pointerEvents: onPointerDown ? "auto" : "none", }
}}
onPointerDown={onPointerDown} export function ShapeOutline({
onPointerMove={onPointerMove} center,
onPointerUp={onPointerUp} outlineWidth,
onPointerLeave={onPointerUp} outlineHeight,
> rotation,
<path pathData,
d={pathData} cursor,
fill="transparent" onPointerDown,
stroke="white" onPointerMove,
strokeOpacity={0.75} onPointerUp,
vectorEffect="non-scaling-stroke" }: {
style={{ pointerEvents: onPointerDown ? "all" : "none" }} center: { x: number; y: number };
/> outlineWidth: number;
</svg> outlineHeight: number;
); rotation: number;
} pathData: string;
cursor?: string;
export function LineOverlay({ onPointerDown?: (event: React.PointerEvent) => void;
start, onPointerMove?: (event: React.PointerEvent) => void;
end, onPointerUp?: (event: React.PointerEvent) => void;
onPointerDown, }) {
onPointerMove, return (
onPointerUp, <svg
}: { className="absolute overflow-visible"
start: { x: number; y: number }; aria-hidden="true"
end: { x: number; y: number }; focusable="false"
onPointerDown?: (event: React.PointerEvent) => void; style={{
onPointerMove?: (event: React.PointerEvent) => void; left: center.x - outlineWidth / 2,
onPointerUp?: (event: React.PointerEvent) => void; top: center.y - outlineHeight / 2,
}) { width: outlineWidth,
const dx = end.x - start.x; height: outlineHeight,
const dy = end.y - start.y; transform: `rotate(${rotation}deg)`,
const length = Math.sqrt(dx * dx + dy * dy); transformOrigin: "center center",
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; pointerEvents: onPointerDown ? "auto" : "none",
const cx = (start.x + end.x) / 2; cursor,
const cy = (start.y + end.y) / 2; }}
onPointerDown={onPointerDown}
const sharedStyle = { onPointerMove={onPointerMove}
left: cx - length / 2, onPointerUp={onPointerUp}
width: length, onPointerLeave={onPointerUp}
transform: `rotate(${angleDeg}deg)`, >
transformOrigin: "center center", <path
}; d={pathData}
fill="transparent"
return ( stroke="white"
<> strokeOpacity={0.75}
{onPointerDown && ( vectorEffect="non-scaling-stroke"
<div style={{ pointerEvents: onPointerDown ? "all" : "none" }}
className="absolute" />
style={{ </svg>
...sharedStyle, );
top: cy - LINE_HIT_AREA_SIZE / 2, }
height: LINE_HIT_AREA_SIZE,
pointerEvents: "auto", export function CanvasPathOutline({
}} pathData,
onPointerDown={onPointerDown} translateX = 0,
onPointerMove={onPointerMove} translateY = 0,
onPointerUp={onPointerUp} scaleX = 1,
onPointerLeave={onPointerUp} scaleY = 1,
/> cursor,
)} strokeWidth = 1,
<div strokeOpacity = 0.75,
className="pointer-events-none absolute" onPointerDown,
style={{ onPointerMove,
...sharedStyle, onPointerUp,
top: cy - 0.5, }: {
height: 1, pathData: string;
backgroundColor: "white", translateX?: number;
opacity: 0.75, 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"; "use client";
import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useMaskHandles } from "@/hooks/use-mask-handles"; import { useMaskHandles } from "@/hooks/use-mask-handles";
import { masksRegistry } from "@/lib/masks";
import type { SnapLine } from "@/lib/preview/preview-snap"; import type { SnapLine } from "@/lib/preview/preview-snap";
import type { ParamValues } from "@/lib/params";
import type { RectangleMaskParams } from "@/lib/masks/types";
import { import {
CornerHandle, CornerHandle,
CircleHandle,
CanvasPathOutline,
EdgeHandle, EdgeHandle,
IconHandle, IconHandle,
LineOverlay, LineOverlay,
BoundingBoxOutline, BoundingBoxOutline,
ShapeOutline, ShapeOutline,
} from "./handle-primitives"; } from "./handle-primitives";
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
function hasRectangleOutlineParams( const CUSTOM_MASK_ANCHOR_SIZE = 7;
params: ParamValues, const CUSTOM_MASK_TANGENT_SIZE = 6;
): params is RectangleMaskParams { import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
return (
typeof params.centerX === "number" &&
typeof params.centerY === "number" &&
typeof params.width === "number" &&
typeof params.height === "number"
);
}
export function MaskHandles({ export function MaskHandles({
onSnapLinesChange, onSnapLinesChange,
@ -36,7 +28,9 @@ export function MaskHandles({
const { const {
selectedWithMask, selectedWithMask,
handlePositions, handlePositions,
linePoints, overlays,
isCreatingCustomMask,
handleCanvasPointerDown,
handlePointerDown, handlePointerDown,
handlePointerMove, handlePointerMove,
handlePointerUp, handlePointerUp,
@ -56,96 +50,158 @@ export function MaskHandles({
canvasY, 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 { x: scaleX, y: scaleY } = viewport.getDisplayScale();
const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
const rectangleOutlineProps = hasRectangleOutlineParams( const onPointerMove = (event: React.PointerEvent) => {
selectedWithMask.mask.params, if (viewport.handlePanPointerMove({ event })) {
) return;
? { }
center: toOverlay({
canvasX:
bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
canvasY:
bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
}),
outlineWidth:
selectedWithMask.mask.params.width * bounds.width * scaleX,
outlineHeight:
selectedWithMask.mask.params.height * bounds.height * scaleY,
rotation: maskRotation,
}
: null;
const onPointerMove = (event: React.PointerEvent) =>
handlePointerMove({ event }); 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 ( return (
<div <div
className="pointer-events-none absolute inset-0 overflow-hidden" className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden aria-hidden
> >
{def.overlayShape === "line" && linePoints && ( {isCreatingCustomMask ? (
<LineOverlay <div
start={toOverlay({ className="absolute inset-0 pointer-events-auto"
canvasX: linePoints.start.x, style={{ cursor: PEN_CURSOR }}
canvasY: linePoints.start.y, onPointerDown={handleCanvasOverlayPointerDown}
})}
end={toOverlay({
canvasX: linePoints.end.x,
canvasY: linePoints.end.y,
})}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" })
}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/> />
)} ) : null}
{def.overlayShape === "box" && rectangleOutlineProps && ( {overlays.map((overlay) => {
def.buildOverlayPath ? ( const overlayHandleId = overlay.handleId;
<> const pointerHandlers = overlayHandleId
<BoundingBoxOutline {...rectangleOutlineProps} dashed /> ? {
<ShapeOutline onPointerDown: (event: React.PointerEvent) =>
{...rectangleOutlineProps} handleMaskPointerDown({ event, handleId: overlayHandleId }),
pathData={def.buildOverlayPath({ onPointerMove,
width: rectangleOutlineProps.outlineWidth, onPointerUp,
height: rectangleOutlineProps.outlineHeight, }
})} : {};
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" }) if (overlay.type === "line") {
} return (
onPointerMove={onPointerMove} <LineOverlay
onPointerUp={onPointerUp} key={overlay.id}
/> cursor={overlay.cursor}
</> start={toOverlay({
) : ( canvasX: overlay.start.x,
<BoundingBoxOutline canvasY: overlay.start.y,
{...rectangleOutlineProps} })}
cursor="cursor-move" end={toOverlay({
onPointerDown={(event) => canvasX: overlay.end.x,
handlePointerDown({ event, handleId: "position" }) canvasY: overlay.end.y,
} })}
onPointerMove={onPointerMove} {...pointerHandlers}
onPointerUp={onPointerUp}
/> />
) );
)} }
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) => { {handlePositions.map((handle) => {
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
if (handle.id === "rotation") { if (handle.kind === "icon" && handle.icon === "rotate") {
return ( return (
<IconHandle <IconHandle
key={handle.id} key={handle.id}
icon={Rotate01Icon} icon={Rotate01Icon}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id }) handleMaskPointerDown({ event, handleId: handle.id })
} }
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
@ -153,14 +209,14 @@ export function MaskHandles({
); );
} }
if (handle.id === "feather") { if (handle.kind === "icon" && handle.icon === "feather") {
return ( return (
<IconHandle <IconHandle
key={handle.id} key={handle.id}
icon={FeatherIcon} icon={FeatherIcon}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id }) handleMaskPointerDown({ event, handleId: handle.id })
} }
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
@ -168,15 +224,15 @@ export function MaskHandles({
); );
} }
if (handle.id === "right" || handle.id === "left") { if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
return ( return (
<EdgeHandle <EdgeHandle
key={handle.id} key={handle.id}
edge="right" edge="right"
screen={screen} screen={screen}
rotation={maskRotation} rotation={handle.rotation ?? 0}
onPointerDown={(event) => onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id }) handleMaskPointerDown({ event, handleId: handle.id })
} }
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
@ -184,15 +240,15 @@ export function MaskHandles({
); );
} }
if (handle.id === "bottom" || handle.id === "top") { if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
return ( return (
<EdgeHandle <EdgeHandle
key={handle.id} key={handle.id}
edge="bottom" edge="bottom"
screen={screen} screen={screen}
rotation={maskRotation} rotation={handle.rotation ?? 0}
onPointerDown={(event) => onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id }) handleMaskPointerDown({ event, handleId: handle.id })
} }
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
@ -200,19 +256,33 @@ export function MaskHandles({
); );
} }
if ( if (handle.kind === "point" || handle.kind === "tangent") {
handle.id === "top-left" || return (
handle.id === "top-right" || <CircleHandle
handle.id === "bottom-left" || key={handle.id}
handle.id === "bottom-right" || screen={screen}
handle.id === "scale" 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 ( return (
<CornerHandle <CornerHandle
key={handle.id} key={handle.id}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id }) handleMaskPointerDown({ event, handleId: handle.id })
} }
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
@ -226,7 +296,7 @@ export function MaskHandles({
cursor={handle.cursor} cursor={handle.cursor}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id }) handleMaskPointerDown({ event, handleId: handle.id })
} }
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}

View File

@ -4,14 +4,12 @@ import { useCallback, useEffect, useRef } from "react";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import type { TextElement } from "@/lib/timeline"; import type { TextElement } from "@/lib/timeline";
import {
FONT_SIZE_SCALE_REFERENCE,
} from "@/lib/text/typography";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import { import {
getElementLocalTime, getElementLocalTime,
resolveTransformAtTime, resolveTransformAtTime,
} from "@/lib/animation"; } from "@/lib/animation";
import { resolveTextLayout } from "@/lib/text/primitives";
export function TextEditOverlay({ export function TextEditOverlay({
trackId, trackId,
@ -82,20 +80,29 @@ export function TextEditOverlay({
}); });
const { x: displayScaleX } = viewport.getDisplayScale(); const { x: displayScaleX } = viewport.getDisplayScale();
const resolvedTextLayout = resolveTextLayout({
const scaledFontSize = text: {
element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); 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 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 canvasLetterSpacing = element.letterSpacing ?? 0;
const lineHeightPx = scaledFontSize * lineHeight; const lineHeightPx = resolvedTextLayout.lineHeightPx;
const bg = element.background; const bg = element.background;
const shouldShowBackground = const shouldShowBackground =
bg.enabled && bg.color && bg.color !== "transparent"; bg.enabled && bg.color && bg.color !== "transparent";
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
const canvasPaddingX = shouldShowBackground const canvasPaddingX = shouldShowBackground
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
: 0; : 0;
@ -123,10 +130,10 @@ export function TextEditOverlay({
aria-label="Edit text" aria-label="Edit text"
className="cursor-text select-text outline-none whitespace-pre" className="cursor-text select-text outline-none whitespace-pre"
style={{ style={{
fontSize: scaledFontSize, fontSize: resolvedTextLayout.scaledFontSize,
fontFamily: element.fontFamily, fontFamily: element.fontFamily,
fontWeight, fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
fontStyle, fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
textAlign: element.textAlign, textAlign: element.textAlign,
letterSpacing: `${canvasLetterSpacing}px`, letterSpacing: `${canvasLetterSpacing}px`,
lineHeight, lineHeight,

View File

@ -1,8 +1,11 @@
"use client"; "use client";
import type { MaskableElement } from "@/lib/timeline"; import type { MaskableElement } from "@/lib/timeline";
import type { Mask, MaskType } from "@/lib/masks/types"; import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params"; import type {
NumberParamDefinition,
SelectParamDefinition,
} from "@/lib/params";
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks"; import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useElementPreview } from "@/hooks/use-element-preview"; 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 { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { import {
ArrowExpandIcon,
Delete02Icon, Delete02Icon,
FeatherIcon, FeatherIcon,
PlusSignIcon, PlusSignIcon,
RotateClockwiseIcon, RotateClockwiseIcon,
TextFontIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker"; import { ColorPicker } from "@/components/ui/color-picker";
import { FontPicker } from "@/components/ui/font-picker";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -32,6 +38,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -52,7 +59,12 @@ import {
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft"; 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"; import { cn } from "@/utils/ui";
type MasksTabProps = { type MasksTabProps = {
@ -78,6 +90,10 @@ type PreviewParamHandler = (
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
function isTextMask(mask: Mask): mask is TextMask {
return mask.type === "text";
}
export function MasksTab({ element, trackId }: MasksTabProps) { export function MasksTab({ element, trackId }: MasksTabProps) {
const editor = useEditor(); const editor = useEditor();
const { renderElement, previewUpdates, commit } = const { renderElement, previewUpdates, commit } =
@ -370,12 +386,23 @@ function MaskParamsFields({
previewParam(key)(value); previewParam(key)(value);
const previewStrokeColor = previewParam("strokeColor"); const previewStrokeColor = previewParam("strokeColor");
const strokeAlignParam = definition.params.find( const strokeAlignParam = definition.params.find(
(param): param is SelectParamDefinition => (param): param is SelectParamDefinition<string> =>
param.key === "strokeAlign" && param.type === "select", param.key === "strokeAlign" && param.type === "select",
); );
return ( return (
<SectionFields> <SectionFields>
{isTextMask(mask) ? (
<TextMaskFields
mask={mask}
previewParam={previewParam}
onCommit={onCommit}
fontSizeParam={getNumberParamDefinition({
definition,
key: "fontSize",
})}
/>
) : null}
{definition.features.hasPosition && {definition.features.hasPosition &&
"centerX" in mask.params && "centerX" in mask.params &&
"centerY" in mask.params && ( "centerY" in mask.params && (
@ -491,7 +518,9 @@ function MaskParamsFields({
{definition.features.sizeMode === "uniform" && "scale" in mask.params && ( {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
<SectionField label="Scale"> <SectionField label="Scale">
<MaskNumberField <MaskNumberField
icon="S" icon={
isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
}
param={getNumberParamDefinition({ param={getNumberParamDefinition({
definition, definition,
key: "scale", 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({ function getNumberParamDefinition({
definition, definition,
key, key,
@ -603,13 +726,10 @@ function getNumberParamDefinition({
return param; return param;
} }
function getMaskNumber({ function getMaskNumber<
params, TParams extends Mask["params"],
key, TKey extends keyof TParams & string,
}: { >({ params, key }: { params: TParams; key: TKey }): number {
params: Mask["params"];
key: string;
}): number {
const value = params[key]; const value = params[key];
if (typeof value !== "number") { if (typeof value !== "number") {
@ -665,7 +785,9 @@ function MaskNumberField({
parse: (input) => { parse: (input) => {
const parsed = parseFloat(input); const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null; if (Number.isNaN(parsed)) return null;
return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier; return (
clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
);
}, },
onPreview, onPreview,
onCommit, onCommit,

View File

@ -9,9 +9,34 @@ import { cn } from "@/utils/ui";
const BAR_WIDTH = 2; const BAR_WIDTH = 2;
const BAR_GAP = 1; const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP; 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 { interface AudioWaveformProps {
audioUrl?: string; audioUrl?: string;
audioBuffer?: AudioBuffer; audioBuffer?: AudioBuffer;
gainSamples?: number[];
color?: string; color?: string;
className?: string; className?: string;
} }
@ -19,6 +44,7 @@ interface AudioWaveformProps {
export function AudioWaveform({ export function AudioWaveform({
audioUrl, audioUrl,
audioBuffer, audioBuffer,
gainSamples,
color = "rgba(255, 255, 255, 0.7)", color = "rgba(255, 255, 255, 0.7)",
className = "", className = "",
}: AudioWaveformProps) { }: AudioWaveformProps) {
@ -26,6 +52,7 @@ export function AudioWaveform({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const bufferRef = useRef<AudioBuffer | null>(null); const bufferRef = useRef<AudioBuffer | null>(null);
const globalMaxRef = useRef<number>(1); const globalMaxRef = useRef<number>(1);
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
const scrollParentRef = useRef<HTMLElement | null>(null); const scrollParentRef = useRef<HTMLElement | null>(null);
const heightRef = useRef<number>(0); const heightRef = useRef<number>(0);
@ -99,20 +126,37 @@ export function AudioWaveform({
const maxBarHeight = height * 0.7; const maxBarHeight = height * 0.7;
const samples = gainSamplesRef.current;
for (let i = 0; i < barCount; i++) { 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); const barH = Math.max(1, scaled * maxBarHeight);
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH); ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
} }
}, [color]); }, [color]);
useEffect(() => {
gainSamplesRef.current = gainSamples;
drawVisible();
}, [gainSamples, drawVisible]);
useEffect(() => { useEffect(() => {
let isCancelled = false; let isCancelled = false;
async function load() { async function load() {
let buffer = audioBuffer ?? null; let buffer = audioBuffer ?? null;
if (!buffer && audioUrl) { if (!buffer && audioUrl) {
try { try {
const resp = await fetch(audioUrl); const resp = await fetch(audioUrl);
const arrayBuffer = await resp.arrayBuffer(); const arrayBuffer = await resp.arrayBuffer();

View File

@ -2,7 +2,7 @@
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store"; 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 { useElementPreview } from "@/hooks/use-element-preview";
import { import {
useKeyframeDrag, useKeyframeDrag,
@ -44,6 +44,7 @@ import {
getSourceAudioActionLabel, getSourceAudioActionLabel,
isSourceAudioSeparated, isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation"; } from "@/lib/timeline/audio-separation";
import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
import { import {
getActionDefinition, getActionDefinition,
type TAction, type TAction,
@ -382,6 +383,7 @@ export function TimelineElement({
> >
<ElementInner <ElementInner
element={element} element={element}
displayElement={renderElement}
track={track} track={track}
isSelected={isSelected} isSelected={isSelected}
isExpanded={expandedRows.length > 0} isExpanded={expandedRows.length > 0}
@ -498,6 +500,7 @@ export function TimelineElement({
function ElementInner({ function ElementInner({
element, element,
displayElement,
track, track,
isSelected, isSelected,
isExpanded, isExpanded,
@ -509,6 +512,7 @@ function ElementInner({
isDropTarget = false, isDropTarget = false,
}: { }: {
element: TimelineElementType; element: TimelineElementType;
displayElement?: TimelineElementType;
track: TimelineTrack; track: TimelineTrack;
isSelected: boolean; isSelected: boolean;
isExpanded: boolean; isExpanded: boolean;
@ -530,8 +534,10 @@ function ElementInner({
}) => void; }) => void;
isDropTarget?: boolean; isDropTarget?: boolean;
}) { }) {
const visibleElement = displayElement ?? element;
const isReducedOpacity = const isReducedOpacity =
(canElementBeHidden(element) && element.hidden) || isDropTarget; (canElementBeHidden(visibleElement) && visibleElement.hidden) ||
isDropTarget;
return ( return (
<div <div
className="absolute top-0 bottom-0" className="absolute top-0 bottom-0"
@ -576,7 +582,7 @@ function ElementInner({
style={{ height: `${baseTrackHeight}px` }} style={{ height: `${baseTrackHeight}px` }}
> >
<div className="flex flex-1 min-h-0 h-full items-center overflow-hidden"> <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>
</div> </div>
{expandedContent} {expandedContent}
@ -979,6 +985,11 @@ function AudioElementContent({ element }: { element: AudioElement }) {
const audioUrl = const audioUrl =
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
const mediaLabel = mediaAsset?.name ?? element.name; const mediaLabel = mediaAsset?.name ?? element.name;
const gainSamples = useMemo(
() =>
buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
[element],
);
if (audioBuffer || audioUrl) { if (audioBuffer || audioUrl) {
return ( return (
@ -986,6 +997,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
<AudioWaveform <AudioWaveform
audioBuffer={audioBuffer} audioBuffer={audioBuffer}
audioUrl={audioUrl} audioUrl={audioUrl}
gainSamples={gainSamples}
color={TIMELINE_TRACK_THEME.audio.waveformColor} color={TIMELINE_TRACK_THEME.audio.waveformColor}
/> />
<MediaElementHeader name={mediaLabel} hasFade={false} /> <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 { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands"; import type { Command, CommandResult } from "@/lib/commands";
import type { EditorSelectionSnapshot } from "@/lib/selection/editor-selection";
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple"; import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
import type { ElementRef, SceneTracks } from "@/lib/timeline/types"; import type { SceneTracks } from "@/lib/timeline/types";
interface CommandHistoryEntry { interface CommandHistoryEntry {
command: Command; command: Command;
previousSelection: ElementRef[]; previousSelection: EditorSelectionSnapshot;
selectionOverride?: ElementRef[]; selectionOverride?: EditorSelectionSnapshot;
} }
export class CommandManager { export class CommandManager {
@ -19,7 +20,7 @@ export class CommandManager {
execute({ command }: { command: Command }): Command { execute({ command }: { command: Command }): Command {
const beforeTracks = this.isRippleEnabled const beforeTracks = this.isRippleEnabled
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null ? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null; : null;
const previousSelection = this.getSelectionSnapshot(); const previousSelection = this.getSelectionSnapshot();
const result = command.execute(); const result = command.execute();
@ -55,11 +56,11 @@ export class CommandManager {
// Only restore selection for commands that explicitly changed it. // Only restore selection for commands that explicitly changed it.
// Commands without selection intent leave selection untouched, // Commands without selection intent leave selection untouched,
// preserving any UI-driven selection changes (clicks, box select) // preserving any UI-driven selection changes (clicks, box select)
// that happened between commands. Commands that remove elements // that happened between commands. Commands that remove editor-owned
// must declare { select: [] } to clear stale refs. // selection targets must declare a selection override to clear stale refs.
if (entry.selectionOverride !== undefined) { if (entry.selectionOverride !== undefined) {
this.editor.selection.setSelectedElements({ this.editor.selection.restoreSnapshot({
elements: [...entry.previousSelection], snapshot: entry.previousSelection,
}); });
} }
this.redoStack.push(entry); this.redoStack.push(entry);
@ -74,7 +75,7 @@ export class CommandManager {
} }
const beforeTracks = this.isRippleEnabled const beforeTracks = this.isRippleEnabled
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null ? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null; : null;
const previousSelection = this.getSelectionSnapshot(); const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo(); const result = entry.command.redo();
@ -102,20 +103,19 @@ export class CommandManager {
this.redoStack = []; this.redoStack = [];
} }
private getSelectionSnapshot(): ElementRef[] { private getSelectionSnapshot(): EditorSelectionSnapshot {
return [...this.editor.selection.getSelectedElements()]; return this.editor.selection.getSnapshot();
} }
private applySelectionOverride( private applySelectionOverride(
result: CommandResult | undefined, result: CommandResult | undefined,
): ElementRef[] | undefined { ): EditorSelectionSnapshot | undefined {
if (result?.select === undefined) { if (!result?.selection) {
return undefined; return undefined;
} }
return this.editor.selection.applySelectionPatch({
const selectionOverride = [...result.select]; patch: result.selection,
this.editor.selection.setSelectedElements({ elements: selectionOverride }); });
return selectionOverride;
} }
private runReactors(): void { private runReactors(): void {

View File

@ -1,73 +1,195 @@
import type { EditorCore } from "@/core"; import type { EditorCore } from "@/core";
import type { SelectedKeyframeRef } from "@/lib/animation/types"; import type { SelectedKeyframeRef } from "@/lib/animation/types";
import type { ElementRef } from "@/lib/timeline/types"; import type {
EditorSelectionKind,
export class SelectionManager { EditorSelectionPatch,
private selectedElements: ElementRef[] = []; EditorSelectionSnapshot,
private selectedKeyframes: SelectedKeyframeRef[] = []; SelectedMaskPointSelection,
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null; } from "@/lib/selection/editor-selection";
private listeners = new Set<() => void>(); import type { ElementRef } from "@/lib/timeline/types";
constructor(editor: EditorCore) { export class SelectionManager {
void editor; private selectedElements: ElementRef[] = [];
} private selectedKeyframes: SelectedKeyframeRef[] = [];
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
getSelectedElements(): ElementRef[] { private selectedMaskPoints: SelectedMaskPointSelection | null = null;
return this.selectedElements; private listeners = new Set<() => void>();
}
constructor(editor: EditorCore) {
getSelectedKeyframes(): SelectedKeyframeRef[] { void editor;
return this.selectedKeyframes; }
}
getSelectedElements(): ElementRef[] {
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null { return this.selectedElements;
return this.keyframeSelectionAnchor; }
}
getSelectedKeyframes(): SelectedKeyframeRef[] {
setSelectedElements({ elements }: { elements: ElementRef[] }): void { return this.selectedKeyframes;
this.selectedElements = elements; }
this.selectedKeyframes = [];
this.keyframeSelectionAnchor = null; getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
this.notify(); return this.keyframeSelectionAnchor;
} }
setSelectedKeyframes({ getSelectedMaskPointSelection(): SelectedMaskPointSelection | null {
keyframes, return this.selectedMaskPoints;
anchorKeyframe, }
}: {
keyframes: SelectedKeyframeRef[]; getActiveSelectionKind(): EditorSelectionKind | null {
anchorKeyframe?: SelectedKeyframeRef | null; if ((this.selectedMaskPoints?.pointIds.length ?? 0) > 0) {
}): void { return "mask-points";
this.selectedKeyframes = keyframes; }
if (anchorKeyframe !== undefined) { if (this.selectedKeyframes.length > 0) {
this.keyframeSelectionAnchor = anchorKeyframe; return "keyframes";
} else if (keyframes.length === 0) { }
this.keyframeSelectionAnchor = null; if (this.selectedElements.length > 0) {
} return "elements";
this.notify(); }
} return null;
}
clearSelection(): void {
this.selectedElements = []; getSnapshot(): EditorSelectionSnapshot {
this.selectedKeyframes = []; return {
this.keyframeSelectionAnchor = null; selectedElements: [...this.selectedElements],
this.notify(); selectedKeyframes: [...this.selectedKeyframes],
} keyframeSelectionAnchor: this.keyframeSelectionAnchor,
selectedMaskPoints: this.selectedMaskPoints
clearKeyframeSelection(): void { ? {
this.selectedKeyframes = []; ...this.selectedMaskPoints,
this.keyframeSelectionAnchor = null; pointIds: [...this.selectedMaskPoints.pointIds],
this.notify(); }
} : null,
};
subscribe(listener: () => void): () => void { }
this.listeners.add(listener);
return () => this.listeners.delete(listener); setSelectedElements({ elements }: { elements: ElementRef[] }): void {
} this.selectedElements = elements;
this.selectedKeyframes = [];
private notify(): void { this.keyframeSelectionAnchor = null;
this.listeners.forEach((fn) => { this.selectedMaskPoints = null;
fn(); 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 { EditorCore } from "@/core";
import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/lib/params";
import type { import type {
SceneTracks, SceneTracks,
@ -44,6 +45,8 @@ import {
RetimeKeyframeCommand, RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand, UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand, AddClipEffectCommand,
DeleteCustomMaskPointsCommand,
InsertCustomMaskPointCommand,
RemoveClipEffectCommand, RemoveClipEffectCommand,
UpdateClipEffectParamsCommand, UpdateClipEffectParamsCommand,
ToggleClipEffectCommand, ToggleClipEffectCommand,
@ -344,6 +347,55 @@ export class TimelineManager {
this.editor.command.execute({ command }); 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({ updateClipEffectParams({
trackId, trackId,
elementId, elementId,

View File

@ -21,19 +21,27 @@ export function useEditorActions() {
const editor = useEditor(); const editor = useEditor();
const { selectedElements, setElementSelection } = useElementSelection(); const { selectedElements, setElementSelection } = useElementSelection();
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
const selectedMaskPointSelection = useEditor((e) =>
e.selection.getSelectedMaskPointSelection(),
);
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
const hasTimelineSelectionRef = useRef(false); const hasTimelineSelectionRef = useRef(false);
const clearTimelineSelectionRef = useRef(() => {}); const clearTimelineSelectionRef = useRef(() => {});
const clearTimelineActiveSelectionRef = useRef(() => {});
const timelineScopeRef = useRef<ScopeEntry | null>(null); const timelineScopeRef = useRef<ScopeEntry | null>(null);
const hasTimelineSelection = const hasTimelineSelection =
selectedElements.length > 0 || selectedKeyframes.length > 0; selectedElements.length > 0 ||
selectedKeyframes.length > 0 ||
selectedMaskPointSelection !== null;
hasTimelineSelectionRef.current = hasTimelineSelection; hasTimelineSelectionRef.current = hasTimelineSelection;
clearTimelineSelectionRef.current = () => { clearTimelineSelectionRef.current = () => {
setElementSelection({ elements: [] }); editor.selection.clearSelection();
clearKeyframeSelection(); };
clearTimelineActiveSelectionRef.current = () => {
editor.selection.clearMostSpecificSelection();
}; };
if (!timelineScopeRef.current) { if (!timelineScopeRef.current) {
@ -42,6 +50,9 @@ export function useEditorActions() {
clear: () => { clear: () => {
clearTimelineSelectionRef.current(); clearTimelineSelectionRef.current();
}, },
clearActive: () => {
clearTimelineActiveSelectionRef.current();
},
}; };
} }
@ -257,17 +268,36 @@ export function useEditorActions() {
useActionHandler( useActionHandler(
"delete-selected", "delete-selected",
() => { () => {
if (selectedKeyframes.length > 0) { switch (editor.selection.getActiveSelectionKind()) {
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); case "mask-points":
clearKeyframeSelection(); if (!selectedMaskPointSelection) {
return; 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, undefined,
); );
@ -343,8 +373,7 @@ export function useEditorActions() {
"deselect-all", "deselect-all",
() => { () => {
if (!clearActiveScope()) { if (!clearActiveScope()) {
setElementSelection({ elements: [] }); editor.selection.clearMostSpecificSelection();
clearKeyframeSelection();
} }
}, },
undefined, undefined,

View File

@ -1,260 +1,633 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { masksRegistry } from "@/lib/masks"; import { masksRegistry } from "@/lib/masks";
import { import {
getMaskHandlePositions, parseCustomMaskHandleId,
getLineMaskLinePoints, parseCustomMaskSegmentHandleId,
} from "@/lib/masks/handle-positions"; } from "@/lib/masks/custom-path";
import { snapMaskInteraction } from "@/lib/masks/snap"; import { appendPointToCustomMask } from "@/lib/masks/definitions/custom";
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; import {
import { getVisibleElementsWithBounds,
SNAP_THRESHOLD_SCREEN_PIXELS, type ElementBounds,
type SnapLine, } from "@/lib/preview/element-bounds";
} from "@/lib/preview/preview-snap"; import {
import type { ParamValues } from "@/lib/params"; SNAP_THRESHOLD_SCREEN_PIXELS,
import type { type SnapLine,
BaseMaskParams, } from "@/lib/preview/preview-snap";
MaskHandlePosition, import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection";
MaskLinePoints, import type { Mask, MaskInteractionResult } from "@/lib/masks/types";
} from "@/lib/masks/types"; import type { MaskableElement } from "@/lib/timeline";
import type { MaskableElement } from "@/lib/timeline"; import { registerCanceller } from "@/lib/cancel-interaction";
import { registerCanceller } from "@/lib/cancel-interaction";
interface DragState {
interface DragState { trackId: string;
trackId: string; elementId: string;
elementId: string; handleId: string;
handleId: string; startCanvasX: number;
startCanvasX: number; startCanvasY: number;
startCanvasY: number; startParams: Mask["params"];
startParams: BaseMaskParams & ParamValues; }
}
interface PendingSegmentInsertState {
export function useMaskHandles({ trackId: string;
onSnapLinesChange, elementId: string;
}: { maskId: string;
onSnapLinesChange?: (lines: SnapLine[]) => void; segmentIndex: number;
}) { startClientX: number;
const editor = useEditor(); startClientY: number;
const isShiftHeldRef = useShiftKey(); startCanvasX: number;
const viewport = usePreviewViewport(); startCanvasY: number;
const [activeHandleId, setActiveHandleId] = useState<string | null>(null); startParams: Mask["params"];
const dragStateRef = useRef<DragState | null>(null); bounds: ElementBounds;
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( }
null,
); const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4;
const tracks = useEditor( function isMaskSelectionForElement({
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, trackId,
); elementId,
const currentTime = useEditor((e) => e.playback.getCurrentTime()); maskId,
const mediaAssets = useEditor((e) => e.media.getAssets()); selection,
const canvasSize = useEditor( }: {
(e) => e.project.getActive().settings.canvasSize, trackId: string;
); elementId: string;
const selectedElements = useEditor((e) => e.selection.getSelectedElements()); maskId: string;
selection: SelectedMaskPointSelection | null;
const elementsWithBounds = getVisibleElementsWithBounds({ }): boolean {
tracks, if (!selection) {
currentTime, return false;
canvasSize, }
mediaAssets, return (
}); selection.trackId === trackId &&
selection.elementId === elementId &&
const selectedWithMask = selection.maskId === maskId
selectedElements.length === 1 );
? (() => { }
const sel = selectedElements[0];
const entry = elementsWithBounds.find( function replaceElementMask({
(item) => masks,
item.trackId === sel.trackId && item.elementId === sel.elementId, updatedMask,
); }: {
if (!entry) return null; masks: MaskableElement["masks"];
const element = entry.element as MaskableElement; updatedMask: Mask;
if (!element.masks?.length) return null; }): Mask[] {
return { ...entry, element, mask: element.masks[0] }; return (masks ?? []).map((mask) =>
})() mask.id === updatedMask.id ? updatedMask : mask,
: null; );
}
const handlePositions: MaskHandlePosition[] = selectedWithMask
? (() => { function withUpdatedMaskParams<TMask extends Mask>({
const def = masksRegistry.get(selectedWithMask.mask.type); mask,
const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); params,
const displayScale = (scaleX + scaleY) / 2; }: {
return getMaskHandlePositions({ mask: TMask;
overlayShape: def.overlayShape, params: TMask["params"];
features: def.features, }): TMask {
params: selectedWithMask.mask.params, return {
bounds: selectedWithMask.bounds, ...mask,
displayScale, params,
}); };
})() }
: [];
export function useMaskHandles({
const linePoints: MaskLinePoints | null = onSnapLinesChange,
selectedWithMask?.mask.type === "split" }: {
? getLineMaskLinePoints({ onSnapLinesChange?: (lines: SnapLine[]) => void;
centerX: selectedWithMask.mask.params.centerX, }) {
centerY: selectedWithMask.mask.params.centerY, const editor = useEditor();
rotation: selectedWithMask.mask.params.rotation, const isShiftHeldRef = useShiftKey();
bounds: selectedWithMask.bounds, const viewport = usePreviewViewport();
}) const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
: null; const dragStateRef = useRef<DragState | null>(null);
const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
const clearMaskHandleState = useCallback(() => { null,
dragStateRef.current = null; );
setActiveHandleId(null); const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
onSnapLinesChange?.([]); null,
}, [onSnapLinesChange]); );
const releaseCapturedPointer = useCallback(() => { const tracks = useEditor(
const capture = captureRef.current; (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
if (!capture) return; );
const currentTime = useEditor((e) => e.playback.getCurrentTime());
if (capture.element.hasPointerCapture(capture.pointerId)) { const mediaAssets = useEditor((e) => e.media.getAssets());
capture.element.releasePointerCapture(capture.pointerId); const canvasSize = useEditor(
} (e) => e.project.getActive().settings.canvasSize,
);
captureRef.current = null; const selectedElements = useEditor((e) => e.selection.getSelectedElements());
}, []); const selectedMaskPointSelection = useEditor((e) =>
e.selection.getSelectedMaskPointSelection(),
useEffect(() => { );
if (!activeHandleId) return;
const elementsWithBounds = getVisibleElementsWithBounds({
return registerCanceller({ tracks,
fn: () => { currentTime,
editor.timeline.discardPreview(); canvasSize,
clearMaskHandleState(); mediaAssets,
releaseCapturedPointer(); });
},
}); const selectedWithMask =
}, [ selectedElements.length === 1
activeHandleId, ? (() => {
clearMaskHandleState, const sel = selectedElements[0];
editor.timeline, const entry = elementsWithBounds.find(
releaseCapturedPointer, (item) =>
]); item.trackId === sel.trackId && item.elementId === sel.elementId,
);
const handlePointerDown = useCallback( if (!entry) return null;
({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { const element = entry.element as MaskableElement;
if (!selectedWithMask) return; const masks = element.masks ?? [];
event.stopPropagation(); if (masks.length === 0) return null;
const activeMaskId = masks.some((mask) =>
const pos = viewport.screenToCanvas({ isMaskSelectionForElement({
clientX: event.clientX, trackId: entry.trackId,
clientY: event.clientY, elementId: entry.elementId,
}); maskId: mask.id,
if (!pos) return; selection: selectedMaskPointSelection,
}),
dragStateRef.current = { )
trackId: selectedWithMask.trackId, ? selectedMaskPointSelection?.maskId
elementId: selectedWithMask.elementId, : masks[0].id;
handleId, const mask =
startCanvasX: pos.x, masks.find((candidate) => candidate.id === activeMaskId) ??
startCanvasY: pos.y, masks[0];
startParams: { ...selectedWithMask.mask.params }, return { ...entry, element, mask };
}; })()
setActiveHandleId(handleId); : null;
const captureTarget = event.currentTarget as HTMLElement;
captureTarget.setPointerCapture(event.pointerId); const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
captureRef.current = { selectedWithMask
element: captureTarget, ? (() => {
pointerId: event.pointerId, const def = masksRegistry.get(selectedWithMask.mask.type);
}; const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
}, const displayScale = (scaleX + scaleY) / 2;
[selectedWithMask, viewport], return def.interaction.getInteraction({
); params: selectedWithMask.mask.params,
bounds: selectedWithMask.bounds,
const handlePointerMove = useCallback( displayScale,
({ event }: { event: React.PointerEvent }) => { scaleX,
const drag = dragStateRef.current; scaleY,
if (!drag || !selectedWithMask) return; });
})()
const pos = viewport.screenToCanvas({ : { handles: [], overlays: [] };
clientX: event.clientX,
clientY: event.clientY, const selectedPointIds = new Set(
}); selectedWithMask &&
if (!pos) return; isMaskSelectionForElement({
trackId: selectedWithMask.trackId,
const deltaX = pos.x - drag.startCanvasX; elementId: selectedWithMask.elementId,
const deltaY = pos.y - drag.startCanvasY; maskId: selectedWithMask.mask.id,
const def = masksRegistry.get(selectedWithMask.mask.type); selection: selectedMaskPointSelection,
})
const rawParams = def.computeParamUpdate({ ? (selectedMaskPointSelection?.pointIds ?? [])
handleId: drag.handleId, : [],
startParams: drag.startParams, );
deltaX,
deltaY, const handlePositions = baseHandlePositions.map((handle) => {
startCanvasX: drag.startCanvasX, const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
startCanvasY: drag.startCanvasY, if (!parsedHandle || parsedHandle.part !== "anchor") {
bounds: selectedWithMask.bounds, return handle;
canvasSize, }
}); return {
const proposedParams = { ...drag.startParams, ...rawParams }; ...handle,
isSelected: selectedPointIds.has(parsedHandle.pointId),
const snapThreshold = viewport.screenPixelsToLogicalThreshold({ };
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, });
});
const { params: nextParams, activeLines } = isShiftHeldRef.current const customMaskPointIds =
? { params: proposedParams, activeLines: [] as SnapLine[] } selectedWithMask?.mask.type === "custom"
: snapMaskInteraction({ ? handlePositions
handleId: drag.handleId, .filter((h) => h.kind === "point")
startParams: drag.startParams, .map((h) => {
proposedParams, const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
bounds: selectedWithMask.bounds, return parsedHandle?.part === "anchor"
canvasSize, ? parsedHandle.pointId
snapThreshold, : null;
}); })
.filter((id): id is string => id !== null)
onSnapLinesChange?.(activeLines); : [];
const isCreatingCustomMask =
const updatedMask = { selectedWithMask?.mask.type === "custom" &&
...selectedWithMask.mask, (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
params: nextParams,
}; useEffect(() => {
editor.timeline.previewElements({ if (!selectedMaskPointSelection) {
updates: [ return;
{ }
trackId: drag.trackId, if (
elementId: drag.elementId, !selectedWithMask ||
updates: { selectedWithMask.mask.type !== "custom" ||
masks: [ !isMaskSelectionForElement({
updatedMask, trackId: selectedWithMask.trackId,
...(selectedWithMask.element.masks?.slice(1) ?? []), elementId: selectedWithMask.elementId,
], maskId: selectedWithMask.mask.id,
} as Partial<MaskableElement>, selection: selectedMaskPointSelection,
}, })
], ) {
}); editor.selection.clearMaskPointSelection();
}, return;
[ }
selectedWithMask,
canvasSize, const availablePointIds = new Set(customMaskPointIds);
editor, const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter(
isShiftHeldRef, (pointId) => availablePointIds.has(pointId),
onSnapLinesChange, );
viewport, if (
], nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length
); ) {
return;
const handlePointerUp = useCallback(() => { }
if (dragStateRef.current) { if (nextSelectedPointIds.length === 0) {
editor.timeline.commitPreview(); editor.selection.clearMaskPointSelection();
clearMaskHandleState(); return;
} }
releaseCapturedPointer();
}, editor.selection.setSelectedMaskPoints({
[clearMaskHandleState, editor, releaseCapturedPointer], selection: {
); ...selectedMaskPointSelection,
pointIds: nextSelectedPointIds,
return { },
selectedWithMask, });
handlePositions, }, [
linePoints, customMaskPointIds,
activeHandleId, editor.selection,
handlePointerDown, selectedMaskPointSelection,
handlePointerMove, selectedWithMask,
handlePointerUp, ]);
};
} 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 { import type {
KeybindingConfig, KeybindingConfig,
ShortcutKey, ShortcutKey,
} from "@/lib/actions/keybinding"; } from "@/lib/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types"; import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory = export type TActionCategory =
| "playback" | "playback"
| "navigation" | "navigation"
| "editing" | "editing"
| "selection" | "selection"
| "history" | "history"
| "timeline" | "timeline"
| "controls" | "controls"
| "assets"; | "assets";
export interface TActionBaseDefinition { export interface TActionBaseDefinition {
description: string; description: string;
category: TActionCategory; category: TActionCategory;
args?: Record<string, unknown>; args?: Record<string, unknown>;
} }
export interface TActionDefinition extends TActionBaseDefinition { export interface TActionDefinition extends TActionBaseDefinition {
defaultShortcuts?: readonly ShortcutKey[]; defaultShortcuts?: readonly ShortcutKey[];
} }
export const ACTIONS = { export const ACTIONS = {
"toggle-play": { "toggle-play": {
description: "Play/Pause", description: "Play/Pause",
category: "playback", category: "playback",
}, },
"stop-playback": { "stop-playback": {
description: "Stop playback", description: "Stop playback",
category: "playback", category: "playback",
}, },
"seek-forward": { "seek-forward": {
description: "Seek forward 1 second", description: "Seek forward 1 second",
category: "playback", category: "playback",
args: { seconds: "number" }, args: { seconds: "number" },
}, },
"seek-backward": { "seek-backward": {
description: "Seek backward 1 second", description: "Seek backward 1 second",
category: "playback", category: "playback",
args: { seconds: "number" }, args: { seconds: "number" },
}, },
"frame-step-forward": { "frame-step-forward": {
description: "Frame step forward", description: "Frame step forward",
category: "navigation", category: "navigation",
}, },
"frame-step-backward": { "frame-step-backward": {
description: "Frame step backward", description: "Frame step backward",
category: "navigation", category: "navigation",
}, },
"jump-forward": { "jump-forward": {
description: "Jump forward 5 seconds", description: "Jump forward 5 seconds",
category: "navigation", category: "navigation",
args: { seconds: "number" }, args: { seconds: "number" },
}, },
"jump-backward": { "jump-backward": {
description: "Jump backward 5 seconds", description: "Jump backward 5 seconds",
category: "navigation", category: "navigation",
args: { seconds: "number" }, args: { seconds: "number" },
}, },
"goto-start": { "goto-start": {
description: "Go to timeline start", description: "Go to timeline start",
category: "navigation", category: "navigation",
}, },
"goto-end": { "goto-end": {
description: "Go to timeline end", description: "Go to timeline end",
category: "navigation", category: "navigation",
}, },
split: { split: {
description: "Split elements at playhead", description: "Split elements at playhead",
category: "editing", category: "editing",
}, },
"split-left": { "split-left": {
description: "Split and remove left", description: "Split and remove left",
category: "editing", category: "editing",
}, },
"split-right": { "split-right": {
description: "Split and remove right", description: "Split and remove right",
category: "editing", category: "editing",
}, },
"delete-selected": { "delete-selected": {
description: "Delete selected elements", description: "Delete current selection",
category: "editing", category: "editing",
}, },
"copy-selected": { "copy-selected": {
description: "Copy selected elements", description: "Copy selected elements",
category: "editing", category: "editing",
}, },
"paste-copied": { "paste-copied": {
description: "Paste elements at playhead", description: "Paste elements at playhead",
category: "editing", category: "editing",
}, },
"toggle-snapping": { "toggle-snapping": {
description: "Toggle snapping", description: "Toggle snapping",
category: "editing", category: "editing",
}, },
"toggle-ripple-editing": { "toggle-ripple-editing": {
description: "Toggle ripple editing", description: "Toggle ripple editing",
category: "editing", category: "editing",
}, },
"toggle-source-audio": { "toggle-source-audio": {
description: "Extract or recover source audio", description: "Extract or recover source audio",
category: "editing", category: "editing",
}, },
"select-all": { "select-all": {
description: "Select all elements", description: "Select all elements",
category: "selection", category: "selection",
}, },
"cancel-interaction": { "cancel-interaction": {
description: "Cancel current interaction", description: "Cancel current interaction",
category: "controls", category: "controls",
}, },
"deselect-all": { "deselect-all": {
description: "Deselect all elements", description: "Deselect all elements",
category: "selection", category: "selection",
}, },
"duplicate-selected": { "duplicate-selected": {
description: "Duplicate selected element", description: "Duplicate selected element",
category: "selection", category: "selection",
}, },
"toggle-elements-muted-selected": { "toggle-elements-muted-selected": {
description: "Mute/unmute selected elements", description: "Mute/unmute selected elements",
category: "selection", category: "selection",
}, },
"toggle-elements-visibility-selected": { "toggle-elements-visibility-selected": {
description: "Show/hide selected elements", description: "Show/hide selected elements",
category: "selection", category: "selection",
}, },
"toggle-bookmark": { "toggle-bookmark": {
description: "Toggle bookmark at playhead", description: "Toggle bookmark at playhead",
category: "timeline", category: "timeline",
}, },
undo: { undo: {
description: "Undo", description: "Undo",
category: "history", category: "history",
}, },
redo: { redo: {
description: "Redo", description: "Redo",
category: "history", category: "history",
}, },
"remove-media-asset": { "remove-media-asset": {
description: "Remove media asset", description: "Remove media asset",
category: "assets", category: "assets",
args: { projectId: "string", assetId: "string" }, args: { projectId: "string", assetId: "string" },
}, },
"remove-media-assets": { "remove-media-assets": {
description: "Remove media assets", description: "Remove media assets",
category: "assets", category: "assets",
args: { projectId: "string", assetIds: "string[]" }, args: { projectId: "string", assetIds: "string[]" },
}, },
} as const satisfies Record<string, TActionBaseDefinition>; } as const satisfies Record<string, TActionBaseDefinition>;
export type TAction = keyof typeof ACTIONS; export type TAction = keyof typeof ACTIONS;
const ACTION_DEFAULT_SHORTCUTS = { const ACTION_DEFAULT_SHORTCUTS = {
"toggle-play": ["space", "k"], "toggle-play": ["space", "k"],
"seek-forward": ["l"], "seek-forward": ["l"],
"seek-backward": ["j"], "seek-backward": ["j"],
"frame-step-forward": ["right"], "frame-step-forward": ["right"],
"frame-step-backward": ["left"], "frame-step-backward": ["left"],
"jump-forward": ["shift+right"], "jump-forward": ["shift+right"],
"jump-backward": ["shift+left"], "jump-backward": ["shift+left"],
"goto-start": ["home", "enter"], "goto-start": ["home", "enter"],
"goto-end": ["end"], "goto-end": ["end"],
split: ["s"], split: ["s"],
"split-left": ["q"], "split-left": ["q"],
"split-right": ["w"], "split-right": ["w"],
"delete-selected": ["backspace", "delete"], "delete-selected": ["backspace", "delete"],
"copy-selected": ["ctrl+c"], "copy-selected": ["ctrl+c"],
"paste-copied": ["ctrl+v"], "paste-copied": ["ctrl+v"],
"toggle-snapping": ["n"], "toggle-snapping": ["n"],
"select-all": ["ctrl+a"], "select-all": ["ctrl+a"],
"cancel-interaction": ["escape"], "cancel-interaction": ["escape"],
"duplicate-selected": ["ctrl+d"], "duplicate-selected": ["ctrl+d"],
undo: ["ctrl+z"], undo: ["ctrl+z"],
redo: ["ctrl+shift+z", "ctrl+y"], redo: ["ctrl+shift+z", "ctrl+y"],
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>; } as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
Record<TAction, readonly ShortcutKey[]> Record<TAction, readonly ShortcutKey[]>
> = ACTION_DEFAULT_SHORTCUTS; > = ACTION_DEFAULT_SHORTCUTS;
export function getActionDefinition({ export function getActionDefinition({
action, action,
}: { }: {
action: TAction; action: TAction;
}): TActionDefinition { }): TActionDefinition {
return { return {
...ACTIONS[action], ...ACTIONS[action],
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
}; };
} }
export function getDefaultShortcuts(): KeybindingConfig { export function getDefaultShortcuts(): KeybindingConfig {
const shortcuts: KeybindingConfig = {}; const shortcuts: KeybindingConfig = {};
for (const [action, defaultShortcuts] of Object.entries( for (const [action, defaultShortcuts] of Object.entries(
ACTION_DEFAULT_SHORTCUTS, ACTION_DEFAULT_SHORTCUTS,
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
for (const shortcut of defaultShortcuts) { for (const shortcut of defaultShortcuts) {
shortcuts[shortcut] = action; shortcuts[shortcut] = action;
} }
} }
return shortcuts; return shortcuts;
} }

View File

@ -6,4 +6,8 @@ title: "Title"
description: "Description" description: "Description"
changes: changes:
- type: new - 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 { export interface CommandResult {
select?: ElementRef[]; selection?: EditorSelectionPatch;
} }
export abstract class Command { export abstract class Command {

View File

@ -54,7 +54,12 @@ export class DeleteElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
return { 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) { if (asset?.type === "video" && asset?.fps) {
editor.project.updateSettings({ editor.project.updateSettings({
settings: { fps: floatToFrameRate(asset.fps) }, settings: { fps: floatToFrameRate(asset.fps) },
pushHistory: false, pushHistory: false,
}); });
} }
} }
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
return { 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") { if (element.type === "graphic") {
registerDefaultGraphics(); registerDefaultGraphics();
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) { if (
!element.definitionId ||
!graphicsRegistry.has(element.definitionId)
) {
console.error("Graphic element must have a valid definitionId"); console.error("Graphic element must have a valid definitionId");
return false; return false;
} }
@ -236,8 +246,8 @@ export class InsertElementCommand extends Command {
const targetTrack = const targetTrack =
tracks.main.id === placement.trackId tracks.main.id === placement.trackId
? tracks.main ? tracks.main
: tracks.overlay.find((track) => track.id === placement.trackId) ?? : (tracks.overlay.find((track) => track.id === placement.trackId) ??
tracks.audio.find((track) => track.id === placement.trackId); tracks.audio.find((track) => track.id === placement.trackId));
if (!targetTrack) { if (!targetTrack) {
console.error("Track not found:", placement.trackId); console.error("Track not found:", placement.trackId);
return null; return null;
@ -257,8 +267,7 @@ export class InsertElementCommand extends Command {
placementResult.kind === "existingTrack" placementResult.kind === "existingTrack"
? { ? {
...element, ...element,
startTime: startTime: placementResult.adjustedStartTime ?? element.startTime,
placementResult.adjustedStartTime ?? element.startTime,
} }
: element; : 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 { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; 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 { 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 { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
import { textMaskDefinition } from "@/lib/masks/definitions/text";
import { getMaskSnapGeometry } from "@/lib/masks/geometry"; import { getMaskSnapGeometry } from "@/lib/masks/geometry";
import { snapMaskInteraction } from "@/lib/masks/snap"; import { snapMaskInteraction } from "@/lib/masks/snap";
import type { ElementBounds } from "@/lib/preview/element-bounds"; 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 = { const bounds: ElementBounds = {
cx: 200, 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( function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }], segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ 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.params.height).toBe(0.5);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); 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 { import {
DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
MIN_MASK_DIMENSION, MIN_MASK_DIMENSION,
} from "@/lib/masks/dimensions"; } from "@/lib/masks/dimensions";
import { computeFeatherUpdate } from "../param-update"; import { computeFeatherUpdate } from "../param-update";
import type { import type {
BaseMaskParams, BaseMaskParams,
MaskDefaultContext, MaskDefaultContext,
MaskParamUpdateArgs, MaskFeatures,
RectangleMaskParams, MaskInteractionDefinition,
} from "@/lib/masks/types"; MaskParamUpdateArgs,
import type { RectangleMaskParams,
NumberParamDefinition, } from "@/lib/masks/types";
ParamDefinition, import type { NumberParamDefinition, ParamDefinition } from "@/lib/params";
ParamValues, import {
} from "@/lib/params"; getBoxMaskHandlePositions,
getBoxMaskOverlays,
const PERCENTAGE_DISPLAY: Pick< } from "@/lib/masks/handle-positions";
NumberParamDefinition, import { snapMaskInteraction } from "@/lib/masks/snap";
"displayMultiplier" | "step"
> = { const PERCENTAGE_DISPLAY: Pick<
displayMultiplier: 100, NumberParamDefinition,
step: 1, "displayMultiplier" | "step"
}; > = {
displayMultiplier: 100,
export const BOX_LIKE_MASK_PARAMS: ParamDefinition< step: 1,
keyof RectangleMaskParams & string };
>[] = [
{ export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
key: "centerX", keyof RectangleMaskParams & string
label: "X", >[] = [
type: "number", {
default: 0, key: "centerX",
min: -100, label: "X",
max: 100, type: "number",
...PERCENTAGE_DISPLAY, default: 0,
}, min: -100,
{ max: 100,
key: "centerY", ...PERCENTAGE_DISPLAY,
label: "Y", },
type: "number", {
default: 0, key: "centerY",
min: -100, label: "Y",
max: 100, type: "number",
...PERCENTAGE_DISPLAY, default: 0,
}, min: -100,
{ max: 100,
key: "width", ...PERCENTAGE_DISPLAY,
label: "Width", },
type: "number", {
default: 0.6, key: "width",
min: 1, label: "Width",
...PERCENTAGE_DISPLAY, type: "number",
}, default: 0.6,
{ min: 1,
key: "height", ...PERCENTAGE_DISPLAY,
label: "Height", },
type: "number", {
default: 0.6, key: "height",
min: 1, label: "Height",
...PERCENTAGE_DISPLAY, type: "number",
}, default: 0.6,
{ min: 1,
key: "rotation", ...PERCENTAGE_DISPLAY,
label: "Rotation", },
type: "number", {
default: 0, key: "rotation",
min: 0, label: "Rotation",
max: 360, type: "number",
step: 1, default: 0,
}, min: 0,
{ max: 360,
key: "scale", step: 1,
label: "Scale", },
type: "number", {
default: 1, key: "scale",
min: 1, label: "Scale",
max: 500, type: "number",
...PERCENTAGE_DISPLAY, default: 1,
}, min: 1,
{ max: 500,
key: "strokeAlign", ...PERCENTAGE_DISPLAY,
label: "Stroke Align", },
type: "select", {
default: "center", key: "strokeAlign",
options: [ label: "Stroke Align",
{ value: "inside", label: "Inside" }, type: "select",
{ value: "center", label: "Center" }, default: "center",
{ value: "outside", label: "Outside" }, options: [
], { value: "inside", label: "Inside" },
}, { value: "center", label: "Center" },
]; { value: "outside", label: "Outside" },
],
export function getDefaultBaseMaskParams(): BaseMaskParams { },
return { ];
feather: 0,
inverted: false, export function getDefaultBaseMaskParams(): BaseMaskParams {
strokeColor: "#ffffff", return {
strokeWidth: 0, feather: 0,
strokeAlign: "center", inverted: false,
}; strokeColor: "#ffffff",
} strokeWidth: 0,
strokeAlign: "center",
export function getStrokeOffset({ };
strokeAlign, }
strokeWidth,
}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number { export function getStrokeOffset({
if (strokeAlign === "inside") { strokeAlign,
return -(strokeWidth / 2); strokeWidth,
} }: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
if (strokeAlign === "inside") {
if (strokeAlign === "outside") { return -(strokeWidth / 2);
return strokeWidth / 2; }
}
if (strokeAlign === "outside") {
return 0; return strokeWidth / 2;
} }
export function getDefaultSquareMaskParams({ return 0;
elementSize, }
}: MaskDefaultContext): RectangleMaskParams {
const absWidth = Math.abs(elementSize?.width ?? 0); export function getDefaultSquareMaskParams({
const absHeight = Math.abs(elementSize?.height ?? 0); elementSize,
const shortSide = Math.min(absWidth, absHeight); }: MaskDefaultContext): RectangleMaskParams {
const squareSide = const absWidth = Math.abs(elementSize?.width ?? 0);
shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0; const absHeight = Math.abs(elementSize?.height ?? 0);
const width = const shortSide = Math.min(absWidth, absHeight);
absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; const squareSide =
const height = shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
absHeight > 0 const width =
? squareSide / absHeight absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
: DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; const height =
absHeight > 0
return { ? squareSide / absHeight
...getDefaultBaseMaskParams(), : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
centerX: 0,
centerY: 0, return {
width, ...getDefaultBaseMaskParams(),
height, centerX: 0,
rotation: 0, centerY: 0,
scale: 1, width,
}; height,
} rotation: 0,
scale: 1,
export function getBoxLikeGeometry({ };
params, }
width,
height, export function getBoxLikeGeometry({
}: { params,
params: RectangleMaskParams; width,
width: number; height,
height: number; }: {
}) { params: RectangleMaskParams;
return { width: number;
centerX: width / 2 + params.centerX * width, height: number;
centerY: height / 2 + params.centerY * height, }) {
maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width, return {
maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height, centerX: width / 2 + params.centerX * width,
rotationRad: (params.rotation * Math.PI) / 180, 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, export function buildBoxMaskInteraction({
deltaY, sizeMode,
bounds, buildOverlayPath,
}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues { showBoundingBox = true,
if (handleId === "position") { }: {
return { sizeMode: MaskFeatures["sizeMode"];
centerX: startParams.centerX + deltaX / bounds.width, buildOverlayPath?: (args: { width: number; height: number }) => string;
centerY: startParams.centerY + deltaY / bounds.height, showBoundingBox?: boolean;
}; }): MaskInteractionDefinition<RectangleMaskParams> {
} return {
getInteraction({ params, bounds, displayScale, scaleX, scaleY }) {
if (handleId === "rotation") { return {
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; handles: getBoxMaskHandlePositions({
const newRotation = (startParams.rotation + currentAngle) % 360; centerX: params.centerX,
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation }; centerY: params.centerY,
} width: params.width,
height: params.height,
if (handleId === "feather") { rotation: params.rotation,
const angleRad = (startParams.rotation * Math.PI) / 180; feather: params.feather,
return computeFeatherUpdate({ sizeMode,
startFeather: startParams.feather, bounds,
deltaX, displayScale,
deltaY, }),
directionX: -Math.sin(angleRad), overlays: getBoxMaskOverlays({
directionY: Math.cos(angleRad), params,
}); bounds,
} pathData: buildOverlayPath?.({
width: params.width * bounds.width * scaleX,
const halfWidth = startParams.width * bounds.width; height: params.height * bounds.height * scaleY,
const halfHeight = startParams.height * bounds.height; }),
showBoundingBox,
if (handleId === "right" || handleId === "left") { }),
const sign = handleId === "right" ? 1 : -1; };
return { },
width: Math.max( snap(args) {
MIN_MASK_DIMENSION, return snapMaskInteraction(args);
startParams.width + (sign * deltaX * 2) / bounds.width, },
), };
}; }
}
export function computeBoxMaskParamUpdate({
if (handleId === "bottom" || handleId === "top") { handleId,
const sign = handleId === "bottom" ? 1 : -1; startParams,
return { deltaX,
height: Math.max( deltaY,
MIN_MASK_DIMENSION, bounds,
startParams.height + (sign * deltaY * 2) / bounds.height, }: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
), if (handleId === "position") {
}; return {
} centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
if ( };
handleId === "top-left" || }
handleId === "top-right" ||
handleId === "bottom-left" || if (handleId === "rotation") {
handleId === "bottom-right" const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
) { const newRotation = (startParams.rotation + currentAngle) % 360;
const signX = handleId.includes("right") ? 1 : -1; return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
const signY = handleId.includes("bottom") ? 1 : -1; }
const distance = Math.sqrt(
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2, if (handleId === "feather") {
); const angleRad = (startParams.rotation * Math.PI) / 180;
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); return computeFeatherUpdate({
const scale = originalDistance > 0 ? distance / originalDistance : 1; startFeather: startParams.feather,
return { deltaX,
width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale), deltaY,
height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale), directionX: -Math.sin(angleRad),
}; directionY: Math.cos(angleRad),
} });
}
if (handleId === "scale") {
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); const halfWidth = startParams.width * bounds.width;
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); const halfHeight = startParams.height * bounds.height;
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
return { if (handleId === "right" || handleId === "left") {
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale), const sign = handleId === "right" ? 1 : -1;
}; return {
} width: Math.max(
MIN_MASK_DIMENSION,
return {}; startParams.width + (sign * deltaX * 2) / bounds.width,
} ),
};
export function rotatePoint({ }
x,
y, if (handleId === "bottom" || handleId === "top") {
centerX, const sign = handleId === "bottom" ? 1 : -1;
centerY, return {
rotationRad, height: Math.max(
}: { MIN_MASK_DIMENSION,
x: number; startParams.height + (sign * deltaY * 2) / bounds.height,
y: number; ),
centerX: number; };
centerY: number; }
rotationRad: number;
}) { if (
const dx = x - centerX; handleId === "top-left" ||
const dy = y - centerY; handleId === "top-right" ||
const cos = Math.cos(rotationRad); handleId === "bottom-left" ||
const sin = Math.sin(rotationRad); handleId === "bottom-right"
) {
return { const signX = handleId.includes("right") ? 1 : -1;
x: centerX + dx * cos - dy * sin, const signY = handleId.includes("bottom") ? 1 : -1;
y: centerY + dx * sin + dy * cos, 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"; } from "@/lib/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
computeBoxMaskParamUpdate, computeBoxMaskParamUpdate,
getDefaultBaseMaskParams, getDefaultBaseMaskParams,
getStrokeOffset, getStrokeOffset,
@ -73,16 +74,18 @@ function buildBandPath({
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "cinematic-bars", type: "cinematic-bars",
name: "Cinematic Bars", name: "Cinematic Bars",
overlayShape: "box",
buildOverlayPath({ width, height }) {
return `M 0,0 H ${width} V ${height} H 0 Z`;
},
features: { features: {
hasPosition: true, hasPosition: true,
hasRotation: true, hasRotation: true,
sizeMode: "height-only", sizeMode: "height-only",
}, },
params: BOX_LIKE_MASK_PARAMS, 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) { buildDefault(context) {
return { return {
type: "cinematic-bars", 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 type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
computeBoxMaskParamUpdate, computeBoxMaskParamUpdate,
getBoxLikeGeometry, getBoxLikeGeometry,
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
@ -47,16 +48,18 @@ function buildDiamondPath({
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "diamond", type: "diamond",
name: "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: { features: {
hasPosition: true, hasPosition: true,
hasRotation: true, hasRotation: true,
sizeMode: "width-height", sizeMode: "width-height",
}, },
params: BOX_LIKE_MASK_PARAMS, 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) { buildDefault(context) {
return { return {
type: "diamond", type: "diamond",

View File

@ -1,72 +1,75 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
computeBoxMaskParamUpdate, buildBoxMaskInteraction,
getBoxLikeGeometry, computeBoxMaskParamUpdate,
getDefaultSquareMaskParams, getBoxLikeGeometry,
getStrokeOffset, getDefaultSquareMaskParams,
} from "./box-like"; getStrokeOffset,
} from "./box-like";
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "ellipse", export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
name: "Ellipse", type: "ellipse",
overlayShape: "box", name: "Ellipse",
buildOverlayPath({ width, height }) { features: {
const rx = Math.max((width - 1) / 2, 0); hasPosition: true,
const ry = Math.max((height - 1) / 2, 0); hasRotation: true,
const cx = width / 2; sizeMode: "width-height",
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`; params: BOX_LIKE_MASK_PARAMS,
}, interaction: buildBoxMaskInteraction({
features: { sizeMode: "width-height",
hasPosition: true, buildOverlayPath({ width, height }) {
hasRotation: true, const rx = Math.max((width - 1) / 2, 0);
sizeMode: "width-height", const ry = Math.max((height - 1) / 2, 0);
}, const cx = width / 2;
params: BOX_LIKE_MASK_PARAMS, const cy = height / 2;
buildDefault(context) { return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
return { },
type: "ellipse", }),
params: getDefaultSquareMaskParams(context), buildDefault(context) {
}; return {
}, type: "ellipse",
computeParamUpdate: computeBoxMaskParamUpdate, params: getDefaultSquareMaskParams(context),
renderer: { };
buildPath({ resolvedParams, width, height }) { },
const params = resolvedParams as RectangleMaskParams; computeParamUpdate: computeBoxMaskParamUpdate,
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = renderer: {
getBoxLikeGeometry({ params, width, height }); buildPath({ resolvedParams, width, height }) {
const path = new Path2D(); const params = resolvedParams as RectangleMaskParams;
path.ellipse( const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
centerX, getBoxLikeGeometry({ params, width, height });
centerY, const path = new Path2D();
maskWidth / 2, path.ellipse(
maskHeight / 2, centerX,
rotationRad, centerY,
0, maskWidth / 2,
Math.PI * 2, maskHeight / 2,
); rotationRad,
return path; 0,
}, Math.PI * 2,
buildStrokePath({ resolvedParams, width, height }) { );
const params = resolvedParams as RectangleMaskParams; return path;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = },
getBoxLikeGeometry({ params, width, height }); buildStrokePath({ resolvedParams, width, height }) {
const offset = getStrokeOffset({ const params = resolvedParams as RectangleMaskParams;
strokeAlign: params.strokeAlign, const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
strokeWidth: params.strokeWidth, getBoxLikeGeometry({ params, width, height });
}); const offset = getStrokeOffset({
const path = new Path2D(); strokeAlign: params.strokeAlign,
path.ellipse( strokeWidth: params.strokeWidth,
centerX, });
centerY, const path = new Path2D();
Math.max(1, maskWidth / 2 + offset), path.ellipse(
Math.max(1, maskHeight / 2 + offset), centerX,
rotationRad, centerY,
0, Math.max(1, maskWidth / 2 + offset),
Math.PI * 2, Math.max(1, maskHeight / 2 + offset),
); rotationRad,
return path; 0,
}, Math.PI * 2,
}, );
}; return path;
},
},
};

View File

@ -1,6 +1,7 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
computeBoxMaskParamUpdate, computeBoxMaskParamUpdate,
getBoxLikeGeometry, getBoxLikeGeometry,
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
@ -36,23 +37,23 @@ function buildHeartPath({
rotationRad, rotationRad,
}); });
const start = toPoint({ localX: 0, localY: -halfHeight * 0.2 }); const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
const rightControl1 = toPoint({ const rightControl1 = toPoint({
localX: halfWidth, localX: halfWidth,
localY: -halfHeight * 0.95, localY: -halfHeight * 1.225,
}); });
const rightControl2 = toPoint({ const rightControl2 = toPoint({
localX: halfWidth, 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({ const leftControl1 = toPoint({
localX: -halfWidth, localX: -halfWidth,
localY: halfHeight * 0.15, localY: -halfHeight * 0.125,
}); });
const leftControl2 = toPoint({ const leftControl2 = toPoint({
localX: -halfWidth, localX: -halfWidth,
localY: -halfHeight * 0.95, localY: -halfHeight * 1.225,
}); });
const path = new Path2D(); const path = new Path2D();
@ -80,25 +81,27 @@ function buildHeartPath({
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "heart", type: "heart",
name: "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: { features: {
hasPosition: true, hasPosition: true,
hasRotation: true, hasRotation: true,
sizeMode: "width-height", sizeMode: "width-height",
}, },
params: BOX_LIKE_MASK_PARAMS, 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) { buildDefault(context) {
return { return {
type: "heart", type: "heart",

View File

@ -1,12 +1,14 @@
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types"; import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
import { masksRegistry, type MaskIconProps } from "../registry"; import { masksRegistry, type MaskIconProps } from "../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars"; import { cinematicBarsMaskDefinition } from "./cinematic-bars";
import { customMaskDefinition } from "./custom";
import { diamondMaskDefinition } from "./diamond"; import { diamondMaskDefinition } from "./diamond";
import { ellipseMaskDefinition } from "./ellipse"; import { ellipseMaskDefinition } from "./ellipse";
import { heartMaskDefinition } from "./heart"; import { heartMaskDefinition } from "./heart";
import { rectangleMaskDefinition } from "./rectangle"; import { rectangleMaskDefinition } from "./rectangle";
import { splitMaskDefinition } from "./split"; import { splitMaskDefinition } from "./split";
import { starMaskDefinition } from "./star"; import { starMaskDefinition } from "./star";
import { textMaskDefinition } from "./text";
import { import {
MinusSignIcon, MinusSignIcon,
PanelRightDashedIcon, PanelRightDashedIcon,
@ -15,6 +17,7 @@ import {
FavouriteIcon, FavouriteIcon,
DiamondIcon, DiamondIcon,
StarsIcon, StarsIcon,
TextFontIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
function registerDefaultMask<TParams extends BaseMaskParams>({ function registerDefaultMask<TParams extends BaseMaskParams>({
@ -60,4 +63,12 @@ export function registerDefaultMasks(): void {
definition: starMaskDefinition, definition: starMaskDefinition,
icon: { icon: StarsIcon }, 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 type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
computeBoxMaskParamUpdate, buildBoxMaskInteraction,
getBoxLikeGeometry, computeBoxMaskParamUpdate,
getDefaultSquareMaskParams, getBoxLikeGeometry,
getStrokeOffset, getDefaultSquareMaskParams,
rotatePoint, getStrokeOffset,
} from "./box-like"; rotatePoint,
} from "./box-like";
function buildRectanglePath({
centerX, function buildRectanglePath({
centerY, centerX,
halfWidth, centerY,
halfHeight, halfWidth,
rotationRad, halfHeight,
}: { rotationRad,
centerX: number; }: {
centerY: number; centerX: number;
halfWidth: number; centerY: number;
halfHeight: number; halfWidth: number;
rotationRad: number; halfHeight: number;
}): Path2D { rotationRad: number;
const corners = [ }): Path2D {
{ x: centerX - halfWidth, y: centerY - halfHeight }, 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 },
{ x: centerX - halfWidth, y: centerY + halfHeight }, { x: centerX + halfWidth, y: centerY + halfHeight },
].map((point) => { x: centerX - halfWidth, y: centerY + halfHeight },
rotatePoint({ ].map((point) =>
...point, rotatePoint({
centerX, ...point,
centerY, centerX,
rotationRad, centerY,
}), rotationRad,
); }),
);
const path = new Path2D();
path.moveTo(corners[0].x, corners[0].y); const path = new Path2D();
for (const corner of corners.slice(1)) { path.moveTo(corners[0].x, corners[0].y);
path.lineTo(corner.x, corner.y); for (const corner of corners.slice(1)) {
} path.lineTo(corner.x, corner.y);
path.closePath(); }
return path; path.closePath();
} return path;
}
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "rectangle", export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
name: "Rectangle", type: "rectangle",
overlayShape: "box", name: "Rectangle",
features: { features: {
hasPosition: true, hasPosition: true,
hasRotation: true, hasRotation: true,
sizeMode: "width-height", sizeMode: "width-height",
}, },
params: BOX_LIKE_MASK_PARAMS, params: BOX_LIKE_MASK_PARAMS,
buildDefault(context) { interaction: buildBoxMaskInteraction({
return { sizeMode: "width-height",
type: "rectangle", }),
params: getDefaultSquareMaskParams(context), buildDefault(context) {
}; return {
}, type: "rectangle",
computeParamUpdate: computeBoxMaskParamUpdate, params: getDefaultSquareMaskParams(context),
renderer: { };
buildPath({ resolvedParams, width, height }) { },
const params = resolvedParams as RectangleMaskParams; computeParamUpdate: computeBoxMaskParamUpdate,
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = renderer: {
getBoxLikeGeometry({ params, width, height }); buildPath({ resolvedParams, width, height }) {
return buildRectanglePath({ const params = resolvedParams as RectangleMaskParams;
centerX, const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
centerY, getBoxLikeGeometry({ params, width, height });
halfWidth: maskWidth / 2, return buildRectanglePath({
halfHeight: maskHeight / 2, centerX,
rotationRad, centerY,
}); halfWidth: maskWidth / 2,
}, halfHeight: maskHeight / 2,
buildStrokePath({ resolvedParams, width, height }) { rotationRad,
const params = resolvedParams as RectangleMaskParams; });
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = },
getBoxLikeGeometry({ params, width, height }); buildStrokePath({ resolvedParams, width, height }) {
const offset = getStrokeOffset({ const params = resolvedParams as RectangleMaskParams;
strokeAlign: params.strokeAlign, const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
strokeWidth: params.strokeWidth, getBoxLikeGeometry({ params, width, height });
}); const offset = getStrokeOffset({
return buildRectanglePath({ strokeAlign: params.strokeAlign,
centerX, strokeWidth: params.strokeWidth,
centerY, });
halfWidth: Math.max(1, maskWidth / 2 + offset), return buildRectanglePath({
halfHeight: Math.max(1, maskHeight / 2 + offset), centerX,
rotationRad, 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 { computeFeatherUpdate } from "../param-update";
import type { ParamValues } from "@/lib/params"; import type {
import type { MaskDefinition,
MaskDefinition, MaskParamUpdateArgs,
MaskParamUpdateArgs, SplitMaskParams,
SplitMaskParams, } from "@/lib/masks/types";
} from "@/lib/masks/types"; import { halfPlaneSign, lineEdgeIntersection } from "../utils";
import { halfPlaneSign, lineEdgeIntersection } from "../utils"; import {
getLineMaskHandlePositions,
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped getLineMaskOverlay,
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie } from "@/lib/masks/handle-positions";
// exactly on the split line, which produces spurious midpoint vertices. import { snapMaskInteraction } from "@/lib/masks/snap";
const NORMAL_SNAP_EPSILON = 1e-10;
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
// Guards against collinear vertices from float noise at canvas edges. // to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
const MIN_POLYGON_AREA_PX = 0.5; // exactly on the split line, which produces spurious midpoint vertices.
const INTERSECTION_EPSILON = 1e-6; const NORMAL_SNAP_EPSILON = 1e-10;
function polygonArea({ vertices }: { vertices: [number, number][] }): number { // Guards against collinear vertices from float noise at canvas edges.
let area = 0; const MIN_POLYGON_AREA_PX = 0.5;
for (let i = 0; i < vertices.length; i++) { const INTERSECTION_EPSILON = 1e-6;
const [x1, y1] = vertices[i];
const [x2, y2] = vertices[(i + 1) % vertices.length]; function polygonArea({ vertices }: { vertices: [number, number][] }): number {
area += x1 * y2 - x2 * y1; let area = 0;
} for (let i = 0; i < vertices.length; i++) {
return Math.abs(area) * 0.5; const [x1, y1] = vertices[i];
} const [x2, y2] = vertices[(i + 1) % vertices.length];
area += x1 * y2 - x2 * y1;
function splitLineGeometry({ }
centerX, return Math.abs(area) * 0.5;
centerY, }
rotation,
width, function splitLineGeometry({
height, centerX,
}: { centerY,
centerX: number; rotation,
centerY: number; width,
rotation: number; height,
width: number; }: {
height: number; centerX: number;
}): { normalX: number; normalY: number; lineX: number; lineY: number } { centerY: number;
const angleRad = (rotation * Math.PI) / 180; rotation: number;
const normalX = width: number;
Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad); height: number;
const normalY = }): { normalX: number; normalY: number; lineX: number; lineY: number } {
Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad); const angleRad = (rotation * Math.PI) / 180;
const lineX = width / 2 + centerX * width; const normalX =
const lineY = height / 2 + centerY * height; Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
return { normalX, normalY, lineX, lineY }; const normalY =
} Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
const lineX = width / 2 + centerX * width;
function pointsEqual( const lineY = height / 2 + centerY * height;
a: { x: number; y: number }, return { normalX, normalY, lineX, lineY };
b: { x: number; y: number }, }
): boolean {
return ( function pointsEqual(
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && a: { x: number; y: number },
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON b: { x: number; y: number },
); ): boolean {
} return (
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
export function getSplitMaskStrokeSegment({ Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
resolvedParams, );
width, }
height,
}: { export function getSplitMaskStrokeSegment({
resolvedParams: unknown; resolvedParams,
width: number; width,
height: number; height,
}): [{ x: number; y: number }, { x: number; y: number }] | null { }: {
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; resolvedParams: unknown;
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ width: number;
centerX, height: number;
centerY, }): [{ x: number; y: number }, { x: number; y: number }] | null {
rotation, const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
width, const { normalX, normalY, lineX, lineY } = splitLineGeometry({
height, centerX,
}); centerY,
rotation,
const edges: [number, number, number, number][] = [ width,
[0, 0, width, 0], height,
[width, 0, width, height], });
[width, height, 0, height],
[0, height, 0, 0], const edges: [number, number, number, number][] = [
]; [0, 0, width, 0],
const intersections: { x: number; y: number }[] = []; [width, 0, width, height],
[width, height, 0, height],
for (const [x1, y1, x2, y2] of edges) { [0, height, 0, 0],
const hit = lineEdgeIntersection({ ];
lineX, const intersections: { x: number; y: number }[] = [];
lineY,
normalX, for (const [x1, y1, x2, y2] of edges) {
normalY, const hit = lineEdgeIntersection({
x1, lineX,
y1, lineY,
x2, normalX,
y2, normalY,
}); x1,
y1,
if (!hit || intersections.some((point) => pointsEqual(point, hit))) { x2,
continue; y2,
} });
intersections.push(hit); if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
} continue;
}
if (intersections.length !== 2) {
return null; intersections.push(hit);
} }
return [intersections[0], intersections[1]]; if (intersections.length !== 2) {
} return null;
}
function computeSplitMaskParamUpdate({
handleId, return [intersections[0], intersections[1]];
startParams, }
deltaX,
deltaY, function computeSplitMaskParamUpdate({
startCanvasX, handleId,
startCanvasY, startParams,
bounds, deltaX,
canvasSize, deltaY,
}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues { startCanvasX,
if (handleId === "position") { startCanvasY,
const rawX = startParams.centerX + deltaX / bounds.width; bounds,
const rawY = startParams.centerY + deltaY / bounds.height; canvasSize,
}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
const minX = -bounds.cx / bounds.width; if (handleId === "position") {
const maxX = (canvasSize.width - bounds.cx) / bounds.width; const rawX = startParams.centerX + deltaX / bounds.width;
const minY = -bounds.cy / bounds.height; const rawY = startParams.centerY + deltaY / bounds.height;
const maxY = (canvasSize.height - bounds.cy) / bounds.height;
const minX = -bounds.cx / bounds.width;
return { const maxX = (canvasSize.width - bounds.cx) / bounds.width;
centerX: Math.max(minX, Math.min(maxX, rawX)), const minY = -bounds.cy / bounds.height;
centerY: Math.max(minY, Math.min(maxY, rawY)), const maxY = (canvasSize.height - bounds.cy) / bounds.height;
};
} return {
centerX: Math.max(minX, Math.min(maxX, rawX)),
if (handleId === "feather") { centerY: Math.max(minY, Math.min(maxY, rawY)),
const angleRad = (startParams.rotation * Math.PI) / 180; };
return computeFeatherUpdate({ }
startFeather: startParams.feather,
deltaX, if (handleId === "feather") {
deltaY, const angleRad = (startParams.rotation * Math.PI) / 180;
directionX: -Math.cos(angleRad), return computeFeatherUpdate({
directionY: -Math.sin(angleRad), startFeather: startParams.feather,
}); deltaX,
} deltaY,
directionX: -Math.cos(angleRad),
if (handleId === "rotation") { directionY: -Math.sin(angleRad),
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) / if (handleId === "rotation") {
Math.PI; const pivotX = bounds.cx + startParams.centerX * bounds.width;
const currentAngle = const pivotY = bounds.cy + startParams.centerY * bounds.height;
(Math.atan2( const startAngle =
startCanvasY + deltaY - pivotY, (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
startCanvasX + deltaX - pivotX, Math.PI;
) * const currentAngle =
180) / (Math.atan2(
Math.PI; startCanvasY + deltaY - pivotY,
startCanvasX + deltaX - pivotX,
let deltaAngle = currentAngle - startAngle; ) *
if (deltaAngle > 180) deltaAngle -= 360; 180) /
if (deltaAngle < -180) deltaAngle += 360; Math.PI;
return { let deltaAngle = currentAngle - startAngle;
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, if (deltaAngle > 180) deltaAngle -= 360;
}; if (deltaAngle < -180) deltaAngle += 360;
}
return {
return {}; rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
} };
}
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
type: "split", return {};
name: "Split", }
overlayShape: "line",
features: { export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
hasPosition: true, type: "split",
hasRotation: true, name: "Split",
sizeMode: "none", features: {
}, hasPosition: true,
buildDefault() { hasRotation: true,
return { sizeMode: "none",
type: "split", },
params: { interaction: {
feather: 0, getInteraction({
inverted: false, params,
strokeColor: "#ffffff", bounds,
strokeWidth: 0, displayScale,
strokeAlign: "center", scaleX: _scaleX,
centerX: 0, scaleY: _scaleY,
centerY: 0, }) {
rotation: 0, return {
}, handles: getLineMaskHandlePositions({
}; centerX: params.centerX,
}, centerY: params.centerY,
computeParamUpdate: computeSplitMaskParamUpdate, rotation: params.rotation,
params: [ feather: params.feather,
{ bounds,
key: "centerX", displayScale,
label: "X", }),
type: "number", overlays: [
default: 0, getLineMaskOverlay({
min: -100, centerX: params.centerX,
max: 100, centerY: params.centerY,
step: 1, rotation: params.rotation,
displayMultiplier: 100, bounds,
}, }),
{ ],
key: "centerY", };
label: "Y", },
type: "number", snap(args) {
default: 0, return snapMaskInteraction(args);
min: -100, },
max: 100, },
step: 1, buildDefault() {
displayMultiplier: 100, return {
}, type: "split",
{ params: {
key: "rotation", feather: 0,
label: "Rotation", inverted: false,
type: "number", strokeColor: "#ffffff",
default: 0, strokeWidth: 0,
min: 0, strokeAlign: "center",
max: 360, centerX: 0,
step: 1, centerY: 0,
}, rotation: 0,
], },
renderer: { };
renderMask({ resolvedParams, ctx, width, height, feather }) { },
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; computeParamUpdate: computeSplitMaskParamUpdate,
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ params: [
centerX, {
centerY, key: "centerX",
rotation, label: "X",
width, type: "number",
height, default: 0,
}); min: -100,
max: 100,
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. step: 1,
const featherHalf = feather / 2; displayMultiplier: 100,
const gradient = ctx.createLinearGradient( },
lineX - normalX * featherHalf, {
lineY - normalY * featherHalf, key: "centerY",
lineX + normalX * featherHalf, label: "Y",
lineY + normalY * featherHalf, type: "number",
); default: 0,
gradient.addColorStop(0, "rgba(255,255,255,0)"); min: -100,
gradient.addColorStop(1, "white"); max: 100,
step: 1,
ctx.fillStyle = gradient; displayMultiplier: 100,
ctx.fillRect(0, 0, width, height); },
}, {
key: "rotation",
buildPath({ resolvedParams, width, height }) { label: "Rotation",
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; type: "number",
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ default: 0,
centerX, min: 0,
centerY, max: 360,
rotation, step: 1,
width, },
height, ],
}); renderer: {
renderMaskHandlesFeather: true,
const edges: [number, number, number, number][] = [ renderMask({ resolvedParams, ctx, width, height, feather }) {
[0, 0, width, 0], const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
[width, 0, width, height], const { normalX, normalY, lineX, lineY } = splitLineGeometry({
[width, height, 0, height], centerX,
[0, height, 0, 0], centerY,
]; rotation,
width,
const isInsideHalfPlane = (x: number, y: number) => height,
halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; });
const vertices: [number, number][] = []; // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
for (const [x1, y1, x2, y2] of edges) { const featherHalf = feather / 2;
const isVertex1Inside = isInsideHalfPlane(x1, y1); const gradient = ctx.createLinearGradient(
const isVertex2Inside = isInsideHalfPlane(x2, y2); lineX - normalX * featherHalf,
lineY - normalY * featherHalf,
if (isVertex1Inside && isVertex2Inside) { lineX + normalX * featherHalf,
vertices.push([x2, y2]); lineY + normalY * featherHalf,
} else if (isVertex1Inside && !isVertex2Inside) { );
const hit = lineEdgeIntersection({ gradient.addColorStop(0, "rgba(255,255,255,0)");
lineX, gradient.addColorStop(1, "white");
lineY,
normalX, ctx.fillStyle = gradient;
normalY, ctx.fillRect(0, 0, width, height);
x1, },
y1,
x2, buildPath({ resolvedParams, width, height }) {
y2, const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
}); const { normalX, normalY, lineX, lineY } = splitLineGeometry({
if (hit) vertices.push([hit.x, hit.y]); centerX,
} else if (!isVertex1Inside && isVertex2Inside) { centerY,
const hit = lineEdgeIntersection({ rotation,
lineX, width,
lineY, height,
normalX, });
normalY,
x1, const edges: [number, number, number, number][] = [
y1, [0, 0, width, 0],
x2, [width, 0, width, height],
y2, [width, height, 0, height],
}); [0, height, 0, 0],
if (hit) { ];
vertices.push([hit.x, hit.y]);
vertices.push([x2, y2]); 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) {
if ( const isVertex1Inside = isInsideHalfPlane(x1, y1);
vertices.length < 3 || const isVertex2Inside = isInsideHalfPlane(x2, y2);
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
) { if (isVertex1Inside && isVertex2Inside) {
return new Path2D(); vertices.push([x2, y2]);
} } else if (isVertex1Inside && !isVertex2Inside) {
const hit = lineEdgeIntersection({
const path = new Path2D(); lineX,
path.moveTo(vertices[0][0], vertices[0][1]); lineY,
for (let i = 1; i < vertices.length; i++) { normalX,
path.lineTo(vertices[i][0], vertices[i][1]); normalY,
} x1,
path.closePath(); y1,
return path; x2,
}, y2,
buildStrokePath({ resolvedParams, width, height }) { });
const segment = getSplitMaskStrokeSegment({ if (hit) vertices.push([hit.x, hit.y]);
resolvedParams, } else if (!isVertex1Inside && isVertex2Inside) {
width, const hit = lineEdgeIntersection({
height, lineX,
}); lineY,
const path = new Path2D(); normalX,
normalY,
if (!segment) { x1,
return path; y1,
} x2,
y2,
path.moveTo(segment[0].x, segment[0].y); });
path.lineTo(segment[1].x, segment[1].y); if (hit) {
return path; 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 type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
computeBoxMaskParamUpdate, computeBoxMaskParamUpdate,
getBoxLikeGeometry, getBoxLikeGeometry,
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
@ -87,16 +88,18 @@ function buildOverlayStarPath({
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "star", type: "star",
name: "Star", name: "Star",
overlayShape: "box",
buildOverlayPath({ width, height }) {
return buildOverlayStarPath({ width, height });
},
features: { features: {
hasPosition: true, hasPosition: true,
hasRotation: true, hasRotation: true,
sizeMode: "width-height", sizeMode: "width-height",
}, },
params: BOX_LIKE_MASK_PARAMS, params: BOX_LIKE_MASK_PARAMS,
interaction: buildBoxMaskInteraction({
sizeMode: "width-height",
buildOverlayPath({ width, height }) {
return buildOverlayStarPath({ width, height });
},
}),
buildDefault(context) { buildDefault(context) {
return { return {
type: "star", 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 { ElementBounds } from "@/lib/preview/element-bounds";
import type { SnapLine } from "@/lib/preview/preview-snap"; import type { SnapLine } from "@/lib/preview/preview-snap";
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions"; import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
import type { ParamValues } from "@/lib/params"; import type { RectangleMaskParams } from "@/lib/masks/types";
import type { RectangleMaskParams } from "@/lib/masks/types";
type CenterMaskParams = {
export function hasCenterParams(params: ParamValues): params is ParamValues & { centerX: number;
centerX: number; centerY: number;
centerY: number; };
} {
return ( type SnapGeometryParams = CenterMaskParams & {
typeof params.centerX === "number" && typeof params.centerY === "number" rotation?: number;
); width?: number;
} height?: number;
scale?: number;
export function isRectangleMaskParams( };
params: ParamValues,
): params is RectangleMaskParams { export function hasCenterParams(
return ( params: Partial<CenterMaskParams>,
hasCenterParams(params) && ): params is CenterMaskParams {
typeof params.width === "number" && return (
typeof params.height === "number" && typeof params.centerX === "number" && typeof params.centerY === "number"
typeof params.rotation === "number" && );
typeof params.scale === "number" }
);
} export function isRectangleMaskParams(
params: SnapGeometryParams,
export function getMaskLocalCenter({ ): params is RectangleMaskParams {
params, return (
bounds, hasCenterParams(params) &&
}: { typeof params.width === "number" &&
params: ParamValues; typeof params.height === "number" &&
bounds: ElementBounds; typeof params.rotation === "number" &&
}): { x: number; y: number } | null { typeof params.scale === "number"
if (!hasCenterParams(params)) return null; );
}
return {
x: params.centerX * bounds.width, export function getMaskLocalCenter({
y: params.centerY * bounds.height, params,
}; bounds,
} }: {
params: CenterMaskParams;
export function setMaskLocalCenter({ bounds: ElementBounds;
center, }): { x: number; y: number } | null {
bounds, if (!hasCenterParams(params)) return null;
}: {
center: { x: number; y: number }; return {
bounds: ElementBounds; x: params.centerX * bounds.width,
}): Pick<ParamValues, "centerX" | "centerY"> { y: params.centerY * bounds.height,
return { };
centerX: bounds.width === 0 ? 0 : center.x / bounds.width, }
centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
}; export function setMaskLocalCenter({
} center,
bounds,
export function getMaskSnapGeometry({ }: {
params, center: { x: number; y: number };
bounds, bounds: ElementBounds;
}: { }): { centerX: number; centerY: number } {
params: ParamValues; return {
bounds: ElementBounds; centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
}): { centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
position: { x: number; y: number }; };
size: { width: number; height: number }; }
rotation: number;
} | null { export function getMaskSnapGeometry({
const position = getMaskLocalCenter({ params, bounds }); params,
if (!position) return null; bounds,
}: {
if (isRectangleMaskParams(params)) { params: SnapGeometryParams;
return { bounds: ElementBounds;
position, }): {
size: { position: { x: number; y: number };
width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width, size: { width: number; height: number };
height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height, rotation: number;
}, } | null {
rotation: params.rotation, const position = getMaskLocalCenter({ params, bounds });
}; if (!position) return null;
}
if (isRectangleMaskParams(params)) {
return { return {
position, position,
size: { width: 0, height: 0 }, size: {
rotation: typeof params.rotation === "number" ? params.rotation : 0, width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
}; height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
} },
rotation: params.rotation,
export function toGlobalMaskSnapLines({ };
lines, }
bounds,
canvasSize, return {
}: { position,
lines: SnapLine[]; size: { width: 0, height: 0 },
bounds: ElementBounds; rotation: typeof params.rotation === "number" ? params.rotation : 0,
canvasSize: { width: number; height: number }; };
}): SnapLine[] { }
const centerX = bounds.cx - canvasSize.width / 2;
const centerY = bounds.cy - canvasSize.height / 2; export function toGlobalMaskSnapLines({
lines,
return lines.map((line) => bounds,
line.type === "vertical" canvasSize,
? { }: {
type: "vertical" as const, lines: SnapLine[];
position: centerX + line.position, bounds: ElementBounds;
} canvasSize: { width: number; height: number };
: { }): SnapLine[] {
type: "horizontal" as const, const centerX = bounds.cx - canvasSize.width / 2;
position: centerY + line.position, 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 { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
import type { ElementBounds } from "@/lib/preview/element-bounds"; import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { import type {
MaskFeatures, MaskFeatures,
MaskHandlePosition, MaskHandlePosition,
MaskLinePoints, MaskLineOverlay,
MaskOverlayShape, MaskOverlay,
} from "@/lib/masks/types"; MaskRectOverlay,
import type { ParamValues } from "@/lib/params"; RectangleMaskParams,
MaskShapeOverlay,
const LINE_HANDLE_OFFSET_SCREEN_PX = 20; } from "@/lib/masks/types";
const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
const LINE_EXTENT_MULTIPLIER = 50; const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
const CURSOR = { const LINE_EXTENT_MULTIPLIER = 50;
rotate: "cursor-crosshair",
resizeDiagonal: "cursor-nwse-resize", const CURSOR = {
resizeHorizontal: "cursor-ew-resize", rotate: "crosshair",
resizeVertical: "cursor-ns-resize", resizeDiagonal: "nwse-resize",
} as const; resizeHorizontal: "ew-resize",
resizeVertical: "ns-resize",
function getNumParam({ } as const;
params,
key, /**
fallback, * The renderer defines the split line as:
}: { * - normal direction: (cos(rotation), sin(rotation))
params: ParamValues; * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
key: string; * - reference point: (centerX * width, centerY * height) from element centre
fallback: number; *
}): number { * So rotation=0 normal points right line runs vertically.
const value = params[key]; */
return typeof value === "number" && !Number.isNaN(value) ? value : fallback; export function getLineMaskLinePoints({
} centerX,
centerY,
/** rotation,
* The renderer defines the split line as: bounds,
* - normal direction: (cos(rotation), sin(rotation)) }: {
* - line direction (parallel to cut): (-sin(rotation), cos(rotation)) centerX: number;
* - reference point: (centerX * width, centerY * height) from element centre centerY: number;
* rotation: number;
* So rotation=0 normal points right line runs vertically. bounds: ElementBounds;
*/ }): { start: { x: number; y: number }; end: { x: number; y: number } } {
export function getLineMaskLinePoints({ const angleRad = (rotation * Math.PI) / 180;
centerX, const normalX = Math.cos(angleRad);
centerY, const normalY = Math.sin(angleRad);
rotation, const lineDirX = -normalY;
bounds, const lineDirY = normalX;
}: {
centerX: number; const cx = bounds.cx + centerX * bounds.width;
centerY: number; const cy = bounds.cy + centerY * bounds.height;
rotation: number;
bounds: ElementBounds; const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
}): MaskLinePoints {
const angleRad = (rotation * Math.PI) / 180; return {
const normalX = Math.cos(angleRad); start: {
const normalY = Math.sin(angleRad); x: cx - lineDirX * extent,
const lineDirX = -normalY; y: cy - lineDirY * extent,
const lineDirY = normalX; },
end: {
const cx = bounds.cx + centerX * bounds.width; x: cx + lineDirX * extent,
const cy = bounds.cy + centerY * bounds.height; y: cy + lineDirY * extent,
},
const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER; };
}
return {
start: { export function getLineMaskOverlay({
x: cx - lineDirX * extent, centerX,
y: cy - lineDirY * extent, centerY,
}, rotation,
end: { bounds,
x: cx + lineDirX * extent, handleId = "position",
y: cy + lineDirY * extent, cursor = "move",
}, }: {
}; centerX: number;
} centerY: number;
rotation: number;
export function getLineMaskHandlePositions({ bounds: ElementBounds;
centerX, handleId?: string;
centerY, cursor?: string;
rotation, }): MaskLineOverlay {
feather, const { start, end } = getLineMaskLinePoints({
bounds, centerX,
displayScale, centerY,
}: { rotation,
centerX: number; bounds,
centerY: number; });
rotation: number;
feather: number; return {
bounds: ElementBounds; id: "line",
displayScale: number; type: "line",
}): MaskHandlePosition[] { start,
const angleRad = (rotation * Math.PI) / 180; end,
const normalX = Math.cos(angleRad); handleId,
const normalY = Math.sin(angleRad); cursor,
};
const cx = bounds.cx + centerX * bounds.width; }
const cy = bounds.cy + centerY * bounds.height;
export function getLineMaskHandlePositions({
const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale; centerX,
const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE; centerY,
rotation,
return [ feather,
{ bounds,
id: "rotation", displayScale,
x: cx + normalX * iconOffsetCanvas, }: {
y: cy + normalY * iconOffsetCanvas, centerX: number;
cursor: CURSOR.rotate, centerY: number;
}, rotation: number;
{ feather: number;
id: "feather", bounds: ElementBounds;
x: cx - normalX * featherOffset, displayScale: number;
y: cy - normalY * featherOffset, }): MaskHandlePosition[] {
cursor: CURSOR.resizeHorizontal, const angleRad = (rotation * Math.PI) / 180;
}, const normalX = Math.cos(angleRad);
]; const normalY = Math.sin(angleRad);
}
const cx = bounds.cx + centerX * bounds.width;
function rotatePoint({ const cy = bounds.cy + centerY * bounds.height;
localX,
localY, const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
cx, const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
cy,
angleRad, return [
}: { {
localX: number; id: "rotation",
localY: number; x: cx + normalX * iconOffsetCanvas,
cx: number; y: cy + normalY * iconOffsetCanvas,
cy: number; cursor: CURSOR.rotate,
angleRad: number; kind: "icon",
}): { x: number; y: number } { icon: "rotate",
const cos = Math.cos(angleRad); },
const sin = Math.sin(angleRad); {
return { id: "feather",
x: cx + localX * cos - localY * sin, x: cx - normalX * featherOffset,
y: cy + localX * sin + localY * cos, y: cy - normalY * featherOffset,
}; cursor: CURSOR.resizeHorizontal,
} kind: "icon",
icon: "feather",
export function getBoxMaskHandlePositions({ },
centerX, ];
centerY, }
width,
height, function rotatePoint({
rotation, localX,
feather, localY,
sizeMode, cx,
bounds, cy,
displayScale, angleRad,
}: { }: {
centerX: number; localX: number;
centerY: number; localY: number;
width: number; cx: number;
height: number; cy: number;
rotation: number; angleRad: number;
feather: number; }): { x: number; y: number } {
sizeMode: MaskFeatures["sizeMode"]; const cos = Math.cos(angleRad);
bounds: ElementBounds; const sin = Math.sin(angleRad);
displayScale: number; return {
}): MaskHandlePosition[] { x: cx + localX * cos - localY * sin,
const cx = bounds.cx + centerX * bounds.width; y: cy + localX * sin + localY * cos,
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; export function getBoxMaskHandlePositions({
centerX,
const handles: MaskHandlePosition[] = []; centerY,
const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale; width,
height,
const rotHandle = rotatePoint({ rotation,
localX: 0, feather,
localY: -halfHeight - handleOffsetCanvas, sizeMode,
cx, showScaleHandle = true,
cy, bounds,
angleRad, displayScale,
}); }: {
handles.push({ centerX: number;
id: "rotation", centerY: number;
x: rotHandle.x, width: number;
y: rotHandle.y, height: number;
cursor: CURSOR.rotate, rotation: number;
}); feather: number;
sizeMode: MaskFeatures["sizeMode"];
const featherHandle = rotatePoint({ showScaleHandle?: boolean;
localX: 0, bounds: ElementBounds;
localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE, displayScale: number;
cx, }): MaskHandlePosition[] {
cy, const cx = bounds.cx + centerX * bounds.width;
angleRad, const cy = bounds.cy + centerY * bounds.height;
}); const angleRad = (rotation * Math.PI) / 180;
handles.push({ const halfWidth = (width * bounds.width) / 2;
id: "feather", const halfHeight = (height * bounds.height) / 2;
x: featherHandle.x,
y: featherHandle.y, const handles: MaskHandlePosition[] = [];
cursor: CURSOR.resizeVertical, const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
});
const rotHandle = rotatePoint({
if (sizeMode === "width-height") { localX: 0,
const corners = [ localY: -halfHeight - handleOffsetCanvas,
{ localX: -halfWidth, localY: -halfHeight, id: "top-left" }, cx,
{ localX: halfWidth, localY: -halfHeight, id: "top-right" }, cy,
{ localX: halfWidth, localY: halfHeight, id: "bottom-right" }, angleRad,
{ localX: -halfWidth, localY: halfHeight, id: "bottom-left" }, });
]; handles.push({
for (const { localX, localY, id } of corners) { id: "rotation",
const point = rotatePoint({ localX, localY, cx, cy, angleRad }); x: rotHandle.x,
handles.push({ y: rotHandle.y,
id, cursor: CURSOR.rotate,
x: point.x, kind: "icon",
y: point.y, icon: "rotate",
cursor: CURSOR.resizeDiagonal, });
});
} const featherHandle = rotatePoint({
const right = rotatePoint({ localX: 0,
localX: halfWidth, localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
localY: 0, cx,
cx, cy,
cy, angleRad,
angleRad, });
}); handles.push({
const left = rotatePoint({ id: "feather",
localX: -halfWidth, x: featherHandle.x,
localY: 0, y: featherHandle.y,
cx, cursor: CURSOR.resizeVertical,
cy, kind: "icon",
angleRad, icon: "feather",
}); });
const bottom = rotatePoint({
localX: 0, if (sizeMode === "width-height") {
localY: halfHeight, const corners = [
cx, { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
cy, { localX: halfWidth, localY: -halfHeight, id: "top-right" },
angleRad, { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
}); { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
handles.push({ ];
id: "left", for (const { localX, localY, id } of corners) {
x: left.x, const point = rotatePoint({ localX, localY, cx, cy, angleRad });
y: left.y, handles.push({
cursor: CURSOR.resizeHorizontal, id,
}); x: point.x,
handles.push({ y: point.y,
id: "right", cursor: CURSOR.resizeDiagonal,
x: right.x, kind: "corner",
y: right.y, });
cursor: CURSOR.resizeHorizontal, }
}); const right = rotatePoint({
handles.push({ localX: halfWidth,
id: "bottom", localY: 0,
x: bottom.x, cx,
y: bottom.y, cy,
cursor: CURSOR.resizeVertical, angleRad,
}); });
} else if (sizeMode === "height-only") { const left = rotatePoint({
const top = rotatePoint({ localX: -halfWidth,
localX: 0, localY: 0,
localY: -halfHeight, cx,
cx, cy,
cy, angleRad,
angleRad, });
}); const bottom = rotatePoint({
const bottom = rotatePoint({ localX: 0,
localX: 0, localY: halfHeight,
localY: halfHeight, cx,
cx, cy,
cy, angleRad,
angleRad, });
}); handles.push({
handles.push({ id: "left",
id: "top", x: left.x,
x: top.x, y: left.y,
y: top.y, cursor: CURSOR.resizeHorizontal,
cursor: CURSOR.resizeVertical, kind: "edge",
}); edgeAxis: "horizontal",
handles.push({ rotation,
id: "bottom", });
x: bottom.x, handles.push({
y: bottom.y, id: "right",
cursor: CURSOR.resizeVertical, x: right.x,
}); y: right.y,
} else if (sizeMode === "width-only") { cursor: CURSOR.resizeHorizontal,
const left = rotatePoint({ kind: "edge",
localX: -halfWidth, edgeAxis: "horizontal",
localY: 0, rotation,
cx, });
cy, handles.push({
angleRad, id: "bottom",
}); x: bottom.x,
const right = rotatePoint({ y: bottom.y,
localX: halfWidth, cursor: CURSOR.resizeVertical,
localY: 0, kind: "edge",
cx, edgeAxis: "vertical",
cy, rotation,
angleRad, });
}); } else if (sizeMode === "height-only") {
handles.push({ const top = rotatePoint({
id: "left", localX: 0,
x: left.x, localY: -halfHeight,
y: left.y, cx,
cursor: CURSOR.resizeHorizontal, cy,
}); angleRad,
handles.push({ });
id: "right", const bottom = rotatePoint({
x: right.x, localX: 0,
y: right.y, localY: halfHeight,
cursor: CURSOR.resizeHorizontal, cx,
}); cy,
} else if (sizeMode === "uniform") { angleRad,
const point = rotatePoint({ });
localX: halfWidth, handles.push({
localY: halfHeight, id: "top",
cx, x: top.x,
cy, y: top.y,
angleRad, cursor: CURSOR.resizeVertical,
}); kind: "edge",
handles.push({ edgeAxis: "vertical",
id: "scale", rotation,
x: point.x, });
y: point.y, handles.push({
cursor: CURSOR.resizeDiagonal, id: "bottom",
}); x: bottom.x,
} y: bottom.y,
cursor: CURSOR.resizeVertical,
return handles; kind: "edge",
} edgeAxis: "vertical",
rotation,
type MaskHandleResolver = (args: { });
features: MaskFeatures; } else if (sizeMode === "width-only") {
params: ParamValues; const left = rotatePoint({
bounds: ElementBounds; localX: -halfWidth,
displayScale: number; localY: 0,
}) => MaskHandlePosition[]; cx,
cy,
const rectangleHandles: MaskHandleResolver = ({ angleRad,
features, });
params, const right = rotatePoint({
bounds, localX: halfWidth,
displayScale, localY: 0,
}) => cx,
getBoxMaskHandlePositions({ cy,
centerX: getNumParam({ params, key: "centerX", fallback: 0 }), angleRad,
centerY: getNumParam({ params, key: "centerY", fallback: 0 }), });
width: getNumParam({ params, key: "width", fallback: 1 }), handles.push({
height: getNumParam({ params, key: "height", fallback: 1 }), id: "left",
rotation: getNumParam({ params, key: "rotation", fallback: 0 }), x: left.x,
feather: getNumParam({ params, key: "feather", fallback: 0 }), y: left.y,
sizeMode: features.sizeMode, cursor: CURSOR.resizeHorizontal,
bounds, kind: "edge",
displayScale, edgeAxis: "horizontal",
}); rotation,
});
const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = { handles.push({
line: ({ params, bounds, displayScale }) => id: "right",
getLineMaskHandlePositions({ x: right.x,
centerX: getNumParam({ params, key: "centerX", fallback: 0 }), y: right.y,
centerY: getNumParam({ params, key: "centerY", fallback: 0 }), cursor: CURSOR.resizeHorizontal,
rotation: getNumParam({ params, key: "rotation", fallback: 0 }), kind: "edge",
feather: getNumParam({ params, key: "feather", fallback: 0 }), edgeAxis: "horizontal",
bounds, rotation,
displayScale, });
}), } else if (sizeMode === "uniform" && showScaleHandle) {
box: rectangleHandles, const point = rotatePoint({
}; localX: halfWidth,
localY: halfHeight,
export function getMaskHandlePositions({ cx,
overlayShape, cy,
features, angleRad,
params, });
bounds, handles.push({
displayScale, id: "scale",
}: { x: point.x,
overlayShape: MaskOverlayShape; y: point.y,
features: MaskFeatures; cursor: CURSOR.resizeDiagonal,
params: ParamValues; kind: "corner",
bounds: ElementBounds; });
displayScale: number; }
}): MaskHandlePosition[] {
return HANDLE_RESOLVERS[overlayShape]({ return handles;
features, }
params,
bounds, export function getBoxMaskRectOverlay({
displayScale, 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 { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
import { masksRegistry } from "@/lib/masks";
import type { ParamValues } from "@/lib/params"; export function computeFeatherUpdate({
import type { startFeather,
BaseMaskParams, deltaX,
MaskParamUpdateArgs, deltaY,
MaskType, directionX,
} from "@/lib/masks/types"; directionY,
}: {
function compactMaskParamValues({ startFeather: number;
params, deltaX: number;
}: { deltaY: number;
params: Partial<ParamValues>; directionX: number;
}): ParamValues { directionY: number;
const nextParams: ParamValues = {}; }): { feather: number } {
for (const [key, value] of Object.entries(params)) { const projection = deltaX * directionX + deltaY * directionY;
if (value !== undefined) { return {
nextParams[key] = value; feather: Math.max(
} 0,
} Math.min(
return nextParams; MAX_FEATHER,
} Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
),
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),
});
}

View File

@ -1,6 +1,14 @@
import { MAX_FEATHER } from "@/lib/masks/feather"; import { MAX_FEATHER } from "@/lib/masks/feather";
import type { ParamDefinition } from "@/lib/params"; 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 type { HugeiconsIconProps } from "@hugeicons/react";
import { DefinitionRegistry } from "@/lib/registry"; 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; icon: MaskIconProps;
}; }
export class MasksRegistry extends DefinitionRegistry< export class MasksRegistry extends DefinitionRegistry<
MaskType, MaskType,
@ -59,8 +91,28 @@ export class MasksRegistry extends DefinitionRegistry<
icon: MaskIconProps; icon: MaskIconProps;
}): void { }): void {
const withBaseParams: RegisteredMaskDefinition = { const withBaseParams: RegisteredMaskDefinition = {
...definition, type: definition.type,
name: definition.name,
features: definition.features,
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], 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, icon,
}; };
this.register(definition.type, withBaseParams); this.register(definition.type, withBaseParams);

View File

@ -1,335 +1,336 @@
import type { ElementBounds } from "@/lib/preview/element-bounds"; import type { ElementBounds } from "@/lib/preview/element-bounds";
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions"; import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
import { import {
snapPosition, snapPosition,
snapRotation, snapRotation,
snapScale, snapScale,
snapScaleAxes, snapScaleAxes,
type ScaleEdgePreference, type ScaleEdgePreference,
type SnapLine, type SnapLine,
} from "@/lib/preview/preview-snap"; } from "@/lib/preview/preview-snap";
import type { ParamValues } from "@/lib/params"; import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
import { import {
isRectangleMaskParams, isRectangleMaskParams,
getMaskSnapGeometry, getMaskSnapGeometry,
setMaskLocalCenter, setMaskLocalCenter,
toGlobalMaskSnapLines, toGlobalMaskSnapLines,
} from "./geometry"; } from "./geometry";
type MaskSnapResult = { type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
params: ParamValues;
activeLines: SnapLine[]; type MaskSnapResult<TParams extends SharedMaskParams> = {
}; params: TParams;
activeLines: SnapLine[];
const CORNER_SIZE_HANDLES = new Set([ };
"top-left",
"top-right", const CORNER_SIZE_HANDLES = new Set([
"bottom-left", "top-left",
"bottom-right", "top-right",
]); "bottom-left",
"bottom-right",
function getClampedRatio({ ]);
next,
base, function getClampedRatio({
}: { next,
next: number; base,
base: number; }: {
}): number { next: number;
return ( base: number;
Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION) }): number {
); return (
} Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
);
function getPreferredEdges({ }
handleId,
}: { function getPreferredEdges({
handleId: string; handleId,
}): ScaleEdgePreference | undefined { }: {
const preferredEdges = { handleId: string;
left: }): ScaleEdgePreference | undefined {
handleId === "left" || const preferredEdges = {
handleId === "top-left" || left:
handleId === "bottom-left", handleId === "left" ||
right: handleId === "top-left" ||
handleId === "right" || handleId === "bottom-left",
handleId === "top-right" || right:
handleId === "bottom-right", handleId === "right" ||
top: handleId === "top-right" ||
handleId === "top" || handleId === "top-left" || handleId === "top-right", handleId === "bottom-right",
bottom: top:
handleId === "bottom" || handleId === "top" || handleId === "top-left" || handleId === "top-right",
handleId === "bottom-left" || bottom:
handleId === "bottom-right", handleId === "bottom" ||
} satisfies ScaleEdgePreference; handleId === "bottom-left" ||
handleId === "bottom-right",
return Object.values(preferredEdges).some(Boolean) } satisfies ScaleEdgePreference;
? preferredEdges
: undefined; return Object.values(preferredEdges).some(Boolean)
} ? preferredEdges
: undefined;
function snapMaskPosition({ }
proposedParams,
bounds, function snapMaskPosition({
canvasSize, proposedParams,
snapThreshold, bounds,
}: { canvasSize,
proposedParams: ParamValues; snapThreshold,
bounds: ElementBounds; }: {
canvasSize: { width: number; height: number }; proposedParams: SharedMaskParams;
snapThreshold: { x: number; y: number }; bounds: ElementBounds;
}): MaskSnapResult { canvasSize: { width: number; height: number };
const geometry = getMaskSnapGeometry({ snapThreshold: { x: number; y: number };
params: proposedParams, }): MaskSnapResult<SharedMaskParams> {
bounds, const geometry = getMaskSnapGeometry({
}); params: proposedParams,
if (!geometry) { bounds,
return { params: proposedParams, activeLines: [] }; });
} if (!geometry) {
return { params: proposedParams, activeLines: [] };
const { snappedPosition, activeLines } = snapPosition({ }
proposedPosition: geometry.position,
canvasSize: bounds, const { snappedPosition, activeLines } = snapPosition({
elementSize: geometry.size, proposedPosition: geometry.position,
rotation: geometry.rotation, canvasSize: bounds,
snapThreshold, elementSize: geometry.size,
}); rotation: geometry.rotation,
snapThreshold,
return { });
params: {
...proposedParams, return {
...setMaskLocalCenter({ params: {
center: snappedPosition, ...proposedParams,
bounds, ...setMaskLocalCenter({
}), center: snappedPosition,
}, bounds,
activeLines: toGlobalMaskSnapLines({ }),
lines: activeLines, },
bounds, activeLines: toGlobalMaskSnapLines({
canvasSize, lines: activeLines,
}), bounds,
}; canvasSize,
} }),
};
function snapMaskRotation({ }
proposedParams,
}: { function snapMaskRotation({
proposedParams: ParamValues; proposedParams,
}): MaskSnapResult { }: {
if (typeof proposedParams.rotation !== "number") { proposedParams: SharedMaskParams;
return { params: proposedParams, activeLines: [] }; }): MaskSnapResult<SharedMaskParams> {
} if (typeof proposedParams.rotation !== "number") {
return { params: proposedParams, activeLines: [] };
const { snappedRotation } = snapRotation({ }
proposedRotation: proposedParams.rotation,
}); const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation,
return { });
params: {
...proposedParams, return {
rotation: snappedRotation, params: {
}, ...proposedParams,
activeLines: [], rotation: snappedRotation,
}; },
} activeLines: [],
};
function snapBoxMaskSize({ }
handleId,
startParams, function snapBoxMaskSize({
proposedParams, handleId,
bounds, startParams,
canvasSize, proposedParams,
snapThreshold, bounds,
}: { canvasSize,
handleId: string; snapThreshold,
startParams: ParamValues; }: {
proposedParams: ParamValues; handleId: string;
bounds: ElementBounds; startParams: SharedMaskParams;
canvasSize: { width: number; height: number }; proposedParams: SharedMaskParams;
snapThreshold: { x: number; y: number }; bounds: ElementBounds;
}): MaskSnapResult { canvasSize: { width: number; height: number };
if ( snapThreshold: { x: number; y: number };
!isRectangleMaskParams(startParams) || }): MaskSnapResult<SharedMaskParams> {
!isRectangleMaskParams(proposedParams) if (
) { !isRectangleMaskParams(startParams) ||
return { params: proposedParams, activeLines: [] }; !isRectangleMaskParams(proposedParams)
} ) {
return { params: proposedParams, activeLines: [] };
const geometry = getMaskSnapGeometry({ }
params: proposedParams,
bounds, const geometry = getMaskSnapGeometry({
}); params: proposedParams,
if (!geometry) { bounds,
return { params: proposedParams, activeLines: [] }; });
} if (!geometry) {
return { params: proposedParams, activeLines: [] };
const localCanvasSize = bounds; }
const baseWidth =
Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width; const localCanvasSize = bounds;
const baseHeight = const baseWidth =
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
const preferredEdges = getPreferredEdges({ handleId }); const baseHeight =
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
if (handleId === "right" || handleId === "left") { const preferredEdges = getPreferredEdges({ handleId });
const proposedScaleX = getClampedRatio({
next: proposedParams.width, if (handleId === "right" || handleId === "left") {
base: startParams.width, const proposedScaleX = getClampedRatio({
}); next: proposedParams.width,
const { x } = snapScaleAxes({ base: startParams.width,
proposedScaleX, });
proposedScaleY: 1, const { x } = snapScaleAxes({
position: geometry.position, proposedScaleX,
baseWidth, proposedScaleY: 1,
baseHeight, position: geometry.position,
rotation: proposedParams.rotation, baseWidth,
canvasSize: localCanvasSize, baseHeight,
snapThreshold, rotation: proposedParams.rotation,
preferredEdges, canvasSize: localCanvasSize,
}); snapThreshold,
preferredEdges,
return { });
params: {
...proposedParams, return {
width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale), params: {
}, ...proposedParams,
activeLines: toGlobalMaskSnapLines({ width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
lines: x.activeLines, },
bounds, activeLines: toGlobalMaskSnapLines({
canvasSize, lines: x.activeLines,
}), bounds,
}; canvasSize,
} }),
};
if (handleId === "top" || handleId === "bottom") { }
const proposedScaleY = getClampedRatio({
next: proposedParams.height, if (handleId === "top" || handleId === "bottom") {
base: startParams.height, const proposedScaleY = getClampedRatio({
}); next: proposedParams.height,
const { y } = snapScaleAxes({ base: startParams.height,
proposedScaleX: 1, });
proposedScaleY, const { y } = snapScaleAxes({
position: geometry.position, proposedScaleX: 1,
baseWidth, proposedScaleY,
baseHeight, position: geometry.position,
rotation: proposedParams.rotation, baseWidth,
canvasSize: localCanvasSize, baseHeight,
snapThreshold, rotation: proposedParams.rotation,
preferredEdges, canvasSize: localCanvasSize,
}); snapThreshold,
preferredEdges,
return { });
params: {
...proposedParams, return {
height: Math.max( params: {
MIN_MASK_DIMENSION, ...proposedParams,
startParams.height * y.snappedScale, height: Math.max(
), MIN_MASK_DIMENSION,
}, startParams.height * y.snappedScale,
activeLines: toGlobalMaskSnapLines({ ),
lines: y.activeLines, },
bounds, activeLines: toGlobalMaskSnapLines({
canvasSize, lines: y.activeLines,
}), bounds,
}; canvasSize,
} }),
};
if (handleId === "scale") { }
const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
const proposedScale = getClampedRatio({ if (handleId === "scale") {
next: proposedParams.scale, const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
base: startParams.scale, const proposedScale = getClampedRatio({
}); next: proposedParams.scale,
const { snappedScale, activeLines } = snapScale({ base: startParams.scale,
proposedScale, });
position: geometry.position, const { snappedScale, activeLines } = snapScale({
baseWidth: baseWidth * baseScale, proposedScale,
baseHeight: baseHeight * baseScale, position: geometry.position,
rotation: proposedParams.rotation, baseWidth: baseWidth * baseScale,
canvasSize: localCanvasSize, baseHeight: baseHeight * baseScale,
snapThreshold, rotation: proposedParams.rotation,
preferredEdges, canvasSize: localCanvasSize,
}); snapThreshold,
preferredEdges,
return { });
params: {
...proposedParams, return {
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale), params: {
}, ...proposedParams,
activeLines: toGlobalMaskSnapLines({ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
lines: activeLines, },
bounds, activeLines: toGlobalMaskSnapLines({
canvasSize, lines: activeLines,
}), bounds,
}; canvasSize,
} }),
};
if (CORNER_SIZE_HANDLES.has(handleId)) { }
const proposedScale = getClampedRatio({
next: proposedParams.width, if (CORNER_SIZE_HANDLES.has(handleId)) {
base: startParams.width, const proposedScale = getClampedRatio({
}); next: proposedParams.width,
const { snappedScale, activeLines } = snapScale({ base: startParams.width,
proposedScale, });
position: geometry.position, const { snappedScale, activeLines } = snapScale({
baseWidth, proposedScale,
baseHeight, position: geometry.position,
rotation: proposedParams.rotation, baseWidth,
canvasSize: localCanvasSize, baseHeight,
snapThreshold, rotation: proposedParams.rotation,
preferredEdges, canvasSize: localCanvasSize,
}); snapThreshold,
preferredEdges,
return { });
params: {
...proposedParams, return {
width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale), params: {
height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale), ...proposedParams,
}, width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
activeLines: toGlobalMaskSnapLines({ height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
lines: activeLines, },
bounds, activeLines: toGlobalMaskSnapLines({
canvasSize, lines: activeLines,
}), bounds,
}; canvasSize,
} }),
};
return { params: proposedParams, activeLines: [] }; }
}
return { params: proposedParams, activeLines: [] };
export function snapMaskInteraction({ }
handleId,
startParams, export function snapMaskInteraction<TParams extends SharedMaskParams>({
proposedParams, handleId,
bounds, startParams,
canvasSize, proposedParams,
snapThreshold, bounds,
}: { canvasSize,
handleId: string; snapThreshold,
startParams: ParamValues; }: {
proposedParams: ParamValues; handleId: string;
bounds: ElementBounds; startParams: TParams;
canvasSize: { width: number; height: number }; proposedParams: TParams;
snapThreshold: { x: number; y: number }; bounds: ElementBounds;
}): MaskSnapResult { canvasSize: { width: number; height: number };
if (handleId === "position") { snapThreshold: { x: number; y: number };
return snapMaskPosition({ }): MaskSnapResult<TParams> {
proposedParams, if (handleId === "position") {
bounds, return snapMaskPosition({
canvasSize, proposedParams,
snapThreshold, bounds,
}); canvasSize,
} snapThreshold,
}) as MaskSnapResult<TParams>;
if (handleId === "rotation") { }
return snapMaskRotation({ proposedParams });
} if (handleId === "rotation") {
return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
return snapBoxMaskSize({ }
handleId,
startParams, return snapBoxMaskSize({
proposedParams, handleId,
bounds, startParams,
canvasSize, proposedParams,
snapThreshold, bounds,
}); canvasSize,
} snapThreshold,
}) as MaskSnapResult<TParams>;
}

View File

@ -1,5 +1,12 @@
import type { ElementBounds } from "@/lib/preview/element-bounds"; 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 = export type MaskType =
| "split" | "split"
@ -8,9 +15,11 @@ export type MaskType =
| "ellipse" | "ellipse"
| "heart" | "heart"
| "diamond" | "diamond"
| "star"; | "star"
| "text"
| "custom";
export interface BaseMaskParams extends ParamValues { export interface BaseMaskParams {
feather: number; feather: number;
inverted: boolean; inverted: boolean;
strokeColor: string; strokeColor: string;
@ -33,6 +42,30 @@ export interface RectangleMaskParams extends BaseMaskParams {
scale: number; 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 { export interface SplitMask {
id: string; id: string;
type: "split"; type: "split";
@ -75,6 +108,18 @@ export interface StarMask {
params: RectangleMaskParams; params: RectangleMaskParams;
} }
export interface TextMask {
id: string;
type: "text";
params: TextMaskParams;
}
export interface CustomMask {
id: string;
type: "custom";
params: CustomMaskParams;
}
export type Mask = export type Mask =
| SplitMask | SplitMask
| CinematicBarsMask | CinematicBarsMask
@ -82,14 +127,16 @@ export type Mask =
| EllipseMask | EllipseMask
| HeartMask | HeartMask
| DiamondMask | DiamondMask
| StarMask; | StarMask
| TextMask
| CustomMask;
export interface MaskRenderer { export interface MaskRenderer {
buildPath(params: { buildPath?: (params: {
resolvedParams: unknown; resolvedParams: unknown;
width: number; width: number;
height: number; height: number;
}): Path2D; }) => Path2D;
buildStrokePath?: (params: { buildStrokePath?: (params: {
resolvedParams: unknown; resolvedParams: unknown;
width: number; width: number;
@ -103,33 +150,94 @@ export interface MaskRenderer {
height: number; height: number;
feather: number; feather: number;
}) => void; }) => void;
renderMaskHandlesFeather?: boolean;
renderStroke?: (params: {
resolvedParams: unknown;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number;
height: number;
}) => void;
} }
export type MaskOverlayShape = "line" | "box";
export interface MaskFeatures { export interface MaskFeatures {
hasPosition: boolean; hasPosition: boolean;
hasRotation: boolean; hasRotation: boolean;
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only"; 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 { export interface MaskHandlePosition {
id: string; id: string;
x: number; x: number;
y: number; y: number;
cursor: string; 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 }; start: { x: number; y: number };
end: { 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 { export interface MaskDefaultContext {
elementSize?: { width: number; height: number }; elementSize?: { width: number; height: number };
} }
export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> { export interface MaskParamUpdateArgs<
TParams extends BaseMaskParams = BaseMaskParams,
> {
handleId: string; handleId: string;
startParams: TParams; startParams: TParams;
deltaX: number; deltaX: number;
@ -140,14 +248,51 @@ export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskPa
canvasSize: { width: number; height: number }; 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; type: MaskType;
name: string; name: string;
overlayShape: MaskOverlayShape;
features: MaskFeatures; features: MaskFeatures;
params: ParamDefinition<keyof TParams & string>[]; params: ParamDefinition<keyof TParams & string>[];
renderer: MaskRenderer; 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">; 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 = { export type ScopeEntry = {
hasSelection: () => boolean; hasSelection: () => boolean;
clear: () => void; clear: () => void;
}; clearActive?: () => void;
};
let activeScope: ScopeEntry | null = null;
let activeScope: ScopeEntry | null = null;
export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
if (activeScope && activeScope !== entry) { export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
activeScope.clear(); if (activeScope && activeScope !== entry) {
} activeScope.clear();
}
activeScope = entry;
activeScope = entry;
return () => {
if (activeScope === entry) { return () => {
activeScope = null; if (activeScope === entry) {
} activeScope = null;
}; }
} };
}
export function clearActiveScope(): boolean {
if (!activeScope?.hasSelection()) { export function clearActiveScope(): boolean {
return false; if (!activeScope?.hasSelection()) {
} return false;
}
activeScope.clear();
return true; (activeScope.clearActive ?? activeScope.clear)();
} return true;
}

View File

@ -1,14 +1,14 @@
import { CORNER_RADIUS_MIN } from "@/lib/text/background"; import { CORNER_RADIUS_MIN } from "@/lib/text/background";
import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography";
import { resolveNumberAtTime } from "@/lib/animation"; import { resolveNumberAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import type { TextBackground, TextElement } from "@/lib/timeline"; import type { TextBackground, TextElement } from "@/lib/timeline";
import { import {
measureTextBlock,
setCanvasLetterSpacing,
getTextVisualRect, getTextVisualRect,
type TextBlockMeasurement,
} from "./layout"; } from "./layout";
import {
measureTextLayout,
type MeasuredTextLayout,
} from "./primitives";
export interface ResolvedTextBackground extends TextBackground { export interface ResolvedTextBackground extends TextBackground {
paddingX: number; paddingX: number;
@ -18,15 +18,7 @@ export interface ResolvedTextBackground extends TextBackground {
cornerRadius: number; cornerRadius: number;
} }
export interface MeasuredTextElement { export interface MeasuredTextElement extends MeasuredTextLayout {
scaledFontSize: number;
fontString: string;
letterSpacing: number;
lineHeightPx: number;
lines: string[];
lineMetrics: TextMetrics[];
block: TextBlockMeasurement;
fontSizeRatio: number;
resolvedBackground: ResolvedTextBackground; resolvedBackground: ResolvedTextBackground;
visualRect: { left: number; top: number; width: number; height: number }; visualRect: { left: number; top: number; width: number; height: number };
} }
@ -75,28 +67,20 @@ export function measureTextElement({
localTime: number; localTime: number;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}): MeasuredTextElement { }): MeasuredTextElement {
const scaledFontSize = const measuredLayout = measureTextLayout({
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); text: {
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; content: element.content,
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; fontSize: element.fontSize,
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; fontFamily: element.fontFamily,
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; fontWeight: element.fontWeight,
const letterSpacing = element.letterSpacing ?? 0; fontStyle: element.fontStyle,
const lineHeightPx = textAlign: element.textAlign,
scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight); textDecoration: element.textDecoration,
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; letterSpacing: element.letterSpacing,
const lines = element.content.split("\n"); lineHeight: element.lineHeight,
},
ctx.save(); canvasHeight,
ctx.font = fontString; ctx,
ctx.textBaseline = "middle";
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
const lineMetrics = lines.map((line) => ctx.measureText(line));
ctx.restore();
const block = measureTextBlock({
lineMetrics,
lineHeightPx,
}); });
const bg = element.background; const bg = element.background;
@ -136,20 +120,13 @@ export function measureTextElement({
const visualRect = getTextVisualRect({ const visualRect = getTextVisualRect({
textAlign: element.textAlign, textAlign: element.textAlign,
block, block: measuredLayout.block,
background: resolvedBackground, background: resolvedBackground,
fontSizeRatio, fontSizeRatio: measuredLayout.fontSizeRatio,
}); });
return { return {
scaledFontSize, ...measuredLayout,
fontString,
letterSpacing,
lineHeightPx,
lines,
lineMetrics,
block,
fontSizeRatio,
resolvedBackground, resolvedBackground,
visualRect, 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); 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({ export function buildAudioGainAutomation({
element, element,
trackMuted = false, trackMuted = false,

View File

@ -15,7 +15,6 @@ import {
type TextElement, type TextElement,
type SceneTracks, type SceneTracks,
type TimelineElement, type TimelineElement,
type TimelineTrack,
type AudioElement, type AudioElement,
type VideoElement, type VideoElement,
type ImageElement, type ImageElement,
@ -414,6 +413,13 @@ export function getElementFontFamilies({
if (element.type === "text" && element.fontFamily) { if (element.type === "text" && element.fontFamily) {
families.add(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]; return [...families];

View File

@ -401,6 +401,11 @@ function buildMaskArtifacts({
} }
const definition = masksRegistry.get(mask.type); const definition = masksRegistry.get(mask.type);
if (definition.isActive?.(mask.params) === false) {
return { mask: null, strokeLayer: null };
}
const elementMaskCanvas = createOffscreenCanvas({ const elementMaskCanvas = createOffscreenCanvas({
width: Math.round(transform.width), width: Math.round(transform.width),
height: Math.round(transform.height), height: Math.round(transform.height),
@ -416,7 +421,12 @@ function buildMaskArtifacts({
let strokePath: Path2D | null = null; let strokePath: Path2D | null = null;
let feather = mask.params.feather; 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({ definition.renderer.renderMask({
resolvedParams: mask.params, resolvedParams: mask.params,
ctx: elementMaskCtx, ctx: elementMaskCtx,
@ -424,13 +434,18 @@ function buildMaskArtifacts({
height: Math.round(transform.height), height: Math.round(transform.height),
feather: mask.params.feather, feather: mask.params.feather,
}); });
feather = 0; if (definition.renderer.renderMaskHandlesFeather) {
feather = 0;
}
strokePath = definition.renderer.buildStrokePath?.({ strokePath = definition.renderer.buildStrokePath?.({
resolvedParams: mask.params, resolvedParams: mask.params,
width: transform.width, width: transform.width,
height: transform.height, height: transform.height,
}) ?? null; }) ?? null;
} else { } else {
if (!definition.renderer.buildPath) {
return { mask: null, strokeLayer: null };
}
const path2d = definition.renderer.buildPath({ const path2d = definition.renderer.buildPath({
resolvedParams: mask.params, resolvedParams: mask.params,
width: transform.width, width: transform.width,
@ -472,7 +487,7 @@ function buildMaskArtifacts({
}); });
let strokeLayer: FrameItemDescriptor | null = null; let strokeLayer: FrameItemDescriptor | null = null;
if (mask.params.strokeWidth > 0 && strokePath) { if (mask.params.strokeWidth > 0 && (strokePath || definition.renderer.renderStroke)) {
const strokeCanvas = createOffscreenCanvas({ const strokeCanvas = createOffscreenCanvas({
width: Math.round(transform.width), width: Math.round(transform.width),
height: Math.round(transform.height), height: Math.round(transform.height),
@ -482,9 +497,18 @@ function buildMaskArtifacts({
| OffscreenCanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
| null; | null;
if (strokeCtx) { if (strokeCtx) {
strokeCtx.strokeStyle = mask.params.strokeColor; if (definition.renderer.renderStroke) {
strokeCtx.lineWidth = mask.params.strokeWidth; definition.renderer.renderStroke({
strokeCtx.stroke(strokePath); 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({ const fullStrokeCanvas = createOffscreenCanvas({
width: renderer.width, width: renderer.width,

View File

@ -3,16 +3,9 @@ import type { TextElement } from "@/lib/timeline";
import type { EffectPass } from "@/lib/effects/types"; import type { EffectPass } from "@/lib/effects/types";
import type { Transform } from "@/lib/rendering"; import type { Transform } from "@/lib/rendering";
import { import {
CORNER_RADIUS_MAX, drawMeasuredTextLayout,
CORNER_RADIUS_MIN, } from "@/lib/text/primitives";
} from "@/lib/text/background";
import {
drawTextDecoration,
getTextBackgroundRect,
setCanvasLetterSpacing,
} from "@/lib/text/layout";
import type { MeasuredTextElement } from "@/lib/text/measure-element"; import type { MeasuredTextElement } from "@/lib/text/measure-element";
import { clamp } from "@/utils/math";
export type TextNodeParams = TextElement & { export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number }; canvasCenter: { x: number; y: number };
@ -46,22 +39,6 @@ export function renderTextToContext({
const x = resolved.transform.position.x + node.params.canvasCenter.x; const x = resolved.transform.position.x + node.params.canvasCenter.x;
const y = resolved.transform.position.y + node.params.canvasCenter.y; const y = resolved.transform.position.y + node.params.canvasCenter.y;
const baseline = node.params.textBaseline ?? "middle"; 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.save();
ctx.translate(x, y); ctx.translate(x, y);
@ -70,60 +47,14 @@ export function renderTextToContext({
ctx.rotate((resolved.transform.rotate * Math.PI) / 180); ctx.rotate((resolved.transform.rotate * Math.PI) / 180);
} }
ctx.font = fontString; drawMeasuredTextLayout({
ctx.textAlign = node.params.textAlign; ctx,
ctx.textBaseline = baseline; layout: resolved.measuredText,
ctx.fillStyle = resolved.textColor; textColor: resolved.textColor,
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); background: resolved.measuredText.resolvedBackground,
backgroundColor: resolved.backgroundColor,
if ( textBaseline: baseline,
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,
});
}
ctx.restore(); 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 { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25"; import { V24toV25Migration } from "./v24-to-v25";
import { V25toV26Migration } from "./v25-to-v26"; import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 26; export const CURRENT_PROJECT_VERSION = 27;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -57,4 +58,5 @@ export const migrations = [
new V23toV24Migration(), new V23toV24Migration(),
new V24toV25Migration(), new V24toV25Migration(),
new V25toV26Migration(), 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 });
}
}