feat: text / custom masks, fix preview pan, refactor selection
This commit is contained in:
parent
66e4367bc4
commit
c46d28891f
|
|
@ -1,233 +1,309 @@
|
|||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||
} from "@/lib/background/blur";
|
||||
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const renderPreview = () => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
effectPreviewService.renderPreview({
|
||||
effectType: "blur",
|
||||
params: { intensity: blur.value },
|
||||
targetCanvas: canvasRef.current,
|
||||
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
|
||||
});
|
||||
};
|
||||
|
||||
renderPreview();
|
||||
return effectPreviewService.onPreviewImageReady({
|
||||
callback: renderPreview,
|
||||
});
|
||||
}, [blur.value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
onSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
onSelect: (bg: string) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => onSelect(bg)}
|
||||
type="button"
|
||||
aria-label={`Select background ${bg}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
onSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
const COLOR_SECTIONS = [
|
||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||
] as const;
|
||||
|
||||
export function BackgroundContent() {
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async (blurIntensity: number) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const currentBlurIntensity = isBlurBackground
|
||||
? (activeProject.settings.background as { blurIntensity: number })
|
||||
.blurIntensity
|
||||
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor = isColorBackground
|
||||
? (activeProject.settings.background as { color: string }).color
|
||||
: DEFAULT_BACKGROUND_COLOR;
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect(blur.value)}
|
||||
/>
|
||||
)),
|
||||
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={true}
|
||||
sectionKey="background-blur"
|
||||
showTopBorder={false}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blur</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{COLOR_SECTIONS.map((section) => (
|
||||
<Section
|
||||
key={section.title}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{section.title}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds as string[]}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
onSelect={handleColorSelect}
|
||||
useBackgroundColor={
|
||||
"useBackgroundColor" in section
|
||||
? section.useBackgroundColor
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { ColorPickerContent } from "@/components/ui/color-picker";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||
} from "@/lib/background/blur";
|
||||
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
const CUSTOM_COLOR_SWATCH_BACKGROUND =
|
||||
"conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const renderPreview = () => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
effectPreviewService.renderPreview({
|
||||
effectType: "blur",
|
||||
params: { intensity: blur.value },
|
||||
targetCanvas: canvasRef.current,
|
||||
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
|
||||
});
|
||||
};
|
||||
|
||||
renderPreview();
|
||||
return effectPreviewService.onPreviewImageReady({
|
||||
callback: renderPreview,
|
||||
});
|
||||
}, [blur.value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
onSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: readonly string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
onSelect: (bg: string) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => onSelect(bg)}
|
||||
type="button"
|
||||
aria-label={`Select background ${bg}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
onSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function CustomColorPreview({
|
||||
currentBackgroundColor,
|
||||
isSelected,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
currentBackgroundColor: string;
|
||||
isSelected: boolean;
|
||||
onPreview: (color: string) => void;
|
||||
onCommit: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
type="button"
|
||||
aria-label="Pick a custom background color"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0"
|
||||
style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
|
||||
/>
|
||||
<span
|
||||
className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
|
||||
style={{ backgroundColor: currentBackgroundColor }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<ColorPickerContent
|
||||
value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
|
||||
onChange={(color) => onPreview(`#${color}`)}
|
||||
onChangeEnd={(color) => onCommit(`#${color}`)}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const COLOR_SECTIONS = [
|
||||
{ id: "colors", title: "Colors", backgrounds: colors, useBackgroundColor: true, showCustomPicker: true },
|
||||
{ id: "pattern-craft", title: "Pattern craft", backgrounds: patternCraftGradients, showCustomPicker: false },
|
||||
{ id: "syntax-ui", title: "Syntax UI", backgrounds: syntaxUIGradients, showCustomPicker: false },
|
||||
] as const;
|
||||
|
||||
export function BackgroundContent() {
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async (blurIntensity: number) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const previewBackgroundColor = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
pushHistory: false,
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const commitBackgroundColor = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
pushHistory: true,
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const currentBlurIntensity = isBlurBackground
|
||||
? (activeProject.settings.background as { blurIntensity: number })
|
||||
.blurIntensity
|
||||
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor = isColorBackground
|
||||
? (activeProject.settings.background as { color: string }).color
|
||||
: DEFAULT_BACKGROUND_COLOR;
|
||||
|
||||
const hasPresetColorMatch = colors.some(
|
||||
(color) => color.toLowerCase() === currentBackgroundColor.toLowerCase(),
|
||||
);
|
||||
|
||||
const handlePresetColorSelect = useCallback(
|
||||
(color: string) => {
|
||||
void commitBackgroundColor(color);
|
||||
},
|
||||
[commitBackgroundColor],
|
||||
);
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect(blur.value)}
|
||||
/>
|
||||
)),
|
||||
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={true}
|
||||
sectionKey="background-blur"
|
||||
showTopBorder={false}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blur</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{COLOR_SECTIONS.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
sectionKey={`settings:background-${section.id}`}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{section.title}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.showCustomPicker ? (
|
||||
<CustomColorPreview
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isSelected={isColorBackground && !hasPresetColorMatch}
|
||||
onPreview={previewBackgroundColor}
|
||||
onCommit={commitBackgroundColor}
|
||||
/>
|
||||
) : null}
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
onSelect={handlePresetColorSelect}
|
||||
useBackgroundColor={
|
||||
"useBackgroundColor" in section
|
||||
? section.useBackgroundColor
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -1,332 +1,427 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
export const HANDLE_SIZE = 10;
|
||||
export const HANDLE_HIT_AREA_SIZE = 18;
|
||||
export const ICON_HANDLE_RADIUS = 10;
|
||||
export const EDGE_HANDLE_THIN_SIZE = 6;
|
||||
export const EDGE_HANDLE_THICK_SIZE = 14;
|
||||
export const LINE_HIT_AREA_SIZE = 48;
|
||||
|
||||
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
|
||||
const normalized = ((angleDeg % 180) + 180) % 180;
|
||||
if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
|
||||
if (normalized < 67.5) return "cursor-nwse-resize";
|
||||
if (normalized < 112.5) return "cursor-ns-resize";
|
||||
return "cursor-nesw-resize";
|
||||
}
|
||||
|
||||
export function HandleButton({
|
||||
screen,
|
||||
cursor,
|
||||
hitAreaSize,
|
||||
className,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
children,
|
||||
}: {
|
||||
screen: { x: number; y: number };
|
||||
cursor?: string;
|
||||
hitAreaSize: number;
|
||||
className?: string;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center outline-none",
|
||||
cursor,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: screen.x - hitAreaSize / 2,
|
||||
top: screen.y - hitAreaSize / 2,
|
||||
width: hitAreaSize,
|
||||
height: hitAreaSize,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CornerHandle({
|
||||
cursor,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
cursor?: string;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function EdgeHandle({
|
||||
edge,
|
||||
screen,
|
||||
rotation,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
edge: "right" | "left" | "bottom";
|
||||
screen: { x: number; y: number };
|
||||
rotation: number;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const isHorizontalEdge = edge === "right" || edge === "left";
|
||||
const width = isHorizontalEdge
|
||||
? EDGE_HANDLE_THIN_SIZE
|
||||
: EDGE_HANDLE_THICK_SIZE;
|
||||
const height = isHorizontalEdge
|
||||
? EDGE_HANDLE_THICK_SIZE
|
||||
: EDGE_HANDLE_THIN_SIZE;
|
||||
const cursor = getResizeCursor({
|
||||
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
|
||||
});
|
||||
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width, height, transform: `rotate(${rotation}deg)` }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHandle({
|
||||
icon,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
icon: IconSvgElement;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
hitAreaSize={ICON_HANDLE_RADIUS * 2}
|
||||
className="rounded-full bg-white text-black shadow-sm"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoundingBoxOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
cursor,
|
||||
dashed = false,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
cursor?: string;
|
||||
dashed?: boolean;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute overflow-visible", cursor)}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<rect
|
||||
x={0.5}
|
||||
y={0.5}
|
||||
width={Math.max(outlineWidth - 1, 0)}
|
||||
height={Math.max(outlineHeight - 1, 0)}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeDasharray={dashed ? "4 4" : undefined}
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShapeOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
pathData,
|
||||
cursor,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
pathData: string;
|
||||
cursor?: string;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute overflow-visible", cursor)}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<path
|
||||
d={pathData}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineOverlay({
|
||||
start,
|
||||
end,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const cx = (start.x + end.x) / 2;
|
||||
const cy = (start.y + end.y) / 2;
|
||||
|
||||
const sharedStyle = {
|
||||
left: cx - length / 2,
|
||||
width: length,
|
||||
transform: `rotate(${angleDeg}deg)`,
|
||||
transformOrigin: "center center",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{onPointerDown && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - LINE_HIT_AREA_SIZE / 2,
|
||||
height: LINE_HIT_AREA_SIZE,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - 0.5,
|
||||
height: 1,
|
||||
backgroundColor: "white",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
export const HANDLE_SIZE = 10;
|
||||
export const HANDLE_HIT_AREA_SIZE = 18;
|
||||
export const ICON_HANDLE_RADIUS = 10;
|
||||
export const EDGE_HANDLE_THIN_SIZE = 6;
|
||||
export const EDGE_HANDLE_THICK_SIZE = 14;
|
||||
export const LINE_HIT_AREA_SIZE = 48;
|
||||
|
||||
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
|
||||
const normalized = ((angleDeg % 180) + 180) % 180;
|
||||
if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
|
||||
if (normalized < 67.5) return "nwse-resize";
|
||||
if (normalized < 112.5) return "ns-resize";
|
||||
return "nesw-resize";
|
||||
}
|
||||
|
||||
export function HandleButton({
|
||||
screen,
|
||||
cursor,
|
||||
hitAreaSize,
|
||||
className,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
children,
|
||||
}: {
|
||||
screen: { x: number; y: number };
|
||||
cursor?: string;
|
||||
hitAreaSize: number;
|
||||
className?: string;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center outline-none",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: screen.x - hitAreaSize / 2,
|
||||
top: screen.y - hitAreaSize / 2,
|
||||
width: hitAreaSize,
|
||||
height: hitAreaSize,
|
||||
pointerEvents: "auto",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CornerHandle({
|
||||
cursor,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
cursor?: string;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircleHandle({
|
||||
cursor,
|
||||
screen,
|
||||
size = HANDLE_SIZE,
|
||||
isSelected = false,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
cursor?: string;
|
||||
screen: { x: number; y: number };
|
||||
size?: number;
|
||||
isSelected?: boolean;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function EdgeHandle({
|
||||
edge,
|
||||
screen,
|
||||
rotation,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
edge: "right" | "left" | "bottom";
|
||||
screen: { x: number; y: number };
|
||||
rotation: number;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const isHorizontalEdge = edge === "right" || edge === "left";
|
||||
const width = isHorizontalEdge
|
||||
? EDGE_HANDLE_THIN_SIZE
|
||||
: EDGE_HANDLE_THICK_SIZE;
|
||||
const height = isHorizontalEdge
|
||||
? EDGE_HANDLE_THICK_SIZE
|
||||
: EDGE_HANDLE_THIN_SIZE;
|
||||
const cursor = getResizeCursor({
|
||||
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
|
||||
});
|
||||
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width, height, transform: `rotate(${rotation}deg)` }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHandle({
|
||||
icon,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
icon: IconSvgElement;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
hitAreaSize={ICON_HANDLE_RADIUS * 2}
|
||||
className="rounded-full bg-white text-black shadow-sm"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoundingBoxOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
cursor,
|
||||
dashed = false,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
cursor?: string;
|
||||
dashed?: boolean;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className="absolute overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<rect
|
||||
x={0.5}
|
||||
y={0.5}
|
||||
width={Math.max(outlineWidth - 1, 0)}
|
||||
height={Math.max(outlineHeight - 1, 0)}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeDasharray={dashed ? "4 4" : undefined}
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShapeOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
pathData,
|
||||
cursor,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
pathData: string;
|
||||
cursor?: string;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className="absolute overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<path
|
||||
d={pathData}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasPathOutline({
|
||||
pathData,
|
||||
translateX = 0,
|
||||
translateY = 0,
|
||||
scaleX = 1,
|
||||
scaleY = 1,
|
||||
cursor,
|
||||
strokeWidth = 1,
|
||||
strokeOpacity = 0.75,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
pathData: string;
|
||||
translateX?: number;
|
||||
translateY?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
cursor?: string;
|
||||
strokeWidth?: number;
|
||||
strokeOpacity?: number;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<g
|
||||
transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
|
||||
>
|
||||
<path
|
||||
d={pathData}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeOpacity={strokeOpacity}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineOverlay({
|
||||
start,
|
||||
end,
|
||||
cursor,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
cursor?: string;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const cx = (start.x + end.x) / 2;
|
||||
const cy = (start.y + end.y) / 2;
|
||||
|
||||
const sharedStyle = {
|
||||
left: cx - length / 2,
|
||||
width: length,
|
||||
transform: `rotate(${angleDeg}deg)`,
|
||||
transformOrigin: "center center",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{onPointerDown && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - LINE_HIT_AREA_SIZE / 2,
|
||||
height: LINE_HIT_AREA_SIZE,
|
||||
pointerEvents: "auto",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - 0.5,
|
||||
height: 1,
|
||||
backgroundColor: "white",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useMaskHandles } from "@/hooks/use-mask-handles";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
CornerHandle,
|
||||
CircleHandle,
|
||||
CanvasPathOutline,
|
||||
EdgeHandle,
|
||||
IconHandle,
|
||||
LineOverlay,
|
||||
BoundingBoxOutline,
|
||||
ShapeOutline,
|
||||
} from "./handle-primitives";
|
||||
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
function hasRectangleOutlineParams(
|
||||
params: ParamValues,
|
||||
): params is RectangleMaskParams {
|
||||
return (
|
||||
typeof params.centerX === "number" &&
|
||||
typeof params.centerY === "number" &&
|
||||
typeof params.width === "number" &&
|
||||
typeof params.height === "number"
|
||||
);
|
||||
}
|
||||
const CUSTOM_MASK_ANCHOR_SIZE = 7;
|
||||
const CUSTOM_MASK_TANGENT_SIZE = 6;
|
||||
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
export function MaskHandles({
|
||||
onSnapLinesChange,
|
||||
|
|
@ -36,7 +28,9 @@ export function MaskHandles({
|
|||
const {
|
||||
selectedWithMask,
|
||||
handlePositions,
|
||||
linePoints,
|
||||
overlays,
|
||||
isCreatingCustomMask,
|
||||
handleCanvasPointerDown,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
|
|
@ -56,96 +50,158 @@ export function MaskHandles({
|
|||
canvasY,
|
||||
});
|
||||
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
const { bounds } = selectedWithMask;
|
||||
const maskRotation = selectedWithMask.mask.params.rotation;
|
||||
|
||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||
const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
|
||||
|
||||
const rectangleOutlineProps = hasRectangleOutlineParams(
|
||||
selectedWithMask.mask.params,
|
||||
)
|
||||
? {
|
||||
center: toOverlay({
|
||||
canvasX:
|
||||
bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
|
||||
canvasY:
|
||||
bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
|
||||
}),
|
||||
outlineWidth:
|
||||
selectedWithMask.mask.params.width * bounds.width * scaleX,
|
||||
outlineHeight:
|
||||
selectedWithMask.mask.params.height * bounds.height * scaleY,
|
||||
rotation: maskRotation,
|
||||
}
|
||||
: null;
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
if (viewport.handlePanPointerMove({ event })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent) =>
|
||||
handlePointerMove({ event });
|
||||
const onPointerUp = () => handlePointerUp();
|
||||
};
|
||||
const onPointerUp = (event: React.PointerEvent) => {
|
||||
if (viewport.handlePanPointerUp({ event })) {
|
||||
return;
|
||||
}
|
||||
|
||||
handlePointerUp();
|
||||
};
|
||||
const handleMaskPointerDown = ({
|
||||
event,
|
||||
handleId,
|
||||
}: {
|
||||
event: React.PointerEvent;
|
||||
handleId: string;
|
||||
}) => {
|
||||
if (viewport.handlePanPointerDown({ event })) {
|
||||
return;
|
||||
}
|
||||
|
||||
handlePointerDown({ event, handleId });
|
||||
};
|
||||
const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => {
|
||||
if (viewport.handlePanPointerDown({ event })) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleCanvasPointerDown({ event });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
{def.overlayShape === "line" && linePoints && (
|
||||
<LineOverlay
|
||||
start={toOverlay({
|
||||
canvasX: linePoints.start.x,
|
||||
canvasY: linePoints.start.y,
|
||||
})}
|
||||
end={toOverlay({
|
||||
canvasX: linePoints.end.x,
|
||||
canvasY: linePoints.end.y,
|
||||
})}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
{isCreatingCustomMask ? (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-auto"
|
||||
style={{ cursor: PEN_CURSOR }}
|
||||
onPointerDown={handleCanvasOverlayPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
/>
|
||||
)}
|
||||
{def.overlayShape === "box" && rectangleOutlineProps && (
|
||||
def.buildOverlayPath ? (
|
||||
<>
|
||||
<BoundingBoxOutline {...rectangleOutlineProps} dashed />
|
||||
<ShapeOutline
|
||||
{...rectangleOutlineProps}
|
||||
pathData={def.buildOverlayPath({
|
||||
width: rectangleOutlineProps.outlineWidth,
|
||||
height: rectangleOutlineProps.outlineHeight,
|
||||
})}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<BoundingBoxOutline
|
||||
{...rectangleOutlineProps}
|
||||
cursor="cursor-move"
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
) : null}
|
||||
{overlays.map((overlay) => {
|
||||
const overlayHandleId = overlay.handleId;
|
||||
const pointerHandlers = overlayHandleId
|
||||
? {
|
||||
onPointerDown: (event: React.PointerEvent) =>
|
||||
handleMaskPointerDown({ event, handleId: overlayHandleId }),
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}
|
||||
: {};
|
||||
|
||||
if (overlay.type === "line") {
|
||||
return (
|
||||
<LineOverlay
|
||||
key={overlay.id}
|
||||
cursor={overlay.cursor}
|
||||
start={toOverlay({
|
||||
canvasX: overlay.start.x,
|
||||
canvasY: overlay.start.y,
|
||||
})}
|
||||
end={toOverlay({
|
||||
canvasX: overlay.end.x,
|
||||
canvasY: overlay.end.y,
|
||||
})}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
if (overlay.type === "rect") {
|
||||
return (
|
||||
<BoundingBoxOutline
|
||||
key={overlay.id}
|
||||
center={toOverlay({
|
||||
canvasX: overlay.center.x,
|
||||
canvasY: overlay.center.y,
|
||||
})}
|
||||
outlineWidth={overlay.width * scaleX}
|
||||
outlineHeight={overlay.height * scaleY}
|
||||
rotation={overlay.rotation}
|
||||
cursor={overlay.cursor}
|
||||
dashed={overlay.dashed}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (overlay.type === "shape") {
|
||||
return (
|
||||
<ShapeOutline
|
||||
key={overlay.id}
|
||||
center={toOverlay({
|
||||
canvasX: overlay.center.x,
|
||||
canvasY: overlay.center.y,
|
||||
})}
|
||||
outlineWidth={overlay.width * scaleX}
|
||||
outlineHeight={overlay.height * scaleY}
|
||||
rotation={overlay.rotation}
|
||||
pathData={overlay.pathData}
|
||||
cursor={overlay.cursor}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (overlay.type === "canvas-path") {
|
||||
return (
|
||||
<CanvasPathOutline
|
||||
key={overlay.id}
|
||||
pathData={overlay.pathData}
|
||||
translateX={
|
||||
overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0
|
||||
}
|
||||
translateY={
|
||||
overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0
|
||||
}
|
||||
scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1}
|
||||
scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1}
|
||||
cursor={overlay.cursor}
|
||||
strokeWidth={overlay.strokeWidth}
|
||||
strokeOpacity={overlay.strokeOpacity}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{handlePositions.map((handle) => {
|
||||
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
|
||||
|
||||
if (handle.id === "rotation") {
|
||||
if (handle.kind === "icon" && handle.icon === "rotate") {
|
||||
return (
|
||||
<IconHandle
|
||||
key={handle.id}
|
||||
icon={Rotate01Icon}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
|
@ -153,14 +209,14 @@ export function MaskHandles({
|
|||
);
|
||||
}
|
||||
|
||||
if (handle.id === "feather") {
|
||||
if (handle.kind === "icon" && handle.icon === "feather") {
|
||||
return (
|
||||
<IconHandle
|
||||
key={handle.id}
|
||||
icon={FeatherIcon}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
|
@ -168,15 +224,15 @@ export function MaskHandles({
|
|||
);
|
||||
}
|
||||
|
||||
if (handle.id === "right" || handle.id === "left") {
|
||||
if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
|
||||
return (
|
||||
<EdgeHandle
|
||||
key={handle.id}
|
||||
edge="right"
|
||||
screen={screen}
|
||||
rotation={maskRotation}
|
||||
rotation={handle.rotation ?? 0}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
|
@ -184,15 +240,15 @@ export function MaskHandles({
|
|||
);
|
||||
}
|
||||
|
||||
if (handle.id === "bottom" || handle.id === "top") {
|
||||
if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
|
||||
return (
|
||||
<EdgeHandle
|
||||
key={handle.id}
|
||||
edge="bottom"
|
||||
screen={screen}
|
||||
rotation={maskRotation}
|
||||
rotation={handle.rotation ?? 0}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
|
@ -200,19 +256,33 @@ export function MaskHandles({
|
|||
);
|
||||
}
|
||||
|
||||
if (
|
||||
handle.id === "top-left" ||
|
||||
handle.id === "top-right" ||
|
||||
handle.id === "bottom-left" ||
|
||||
handle.id === "bottom-right" ||
|
||||
handle.id === "scale"
|
||||
) {
|
||||
if (handle.kind === "point" || handle.kind === "tangent") {
|
||||
return (
|
||||
<CircleHandle
|
||||
key={handle.id}
|
||||
screen={screen}
|
||||
size={
|
||||
handle.kind === "tangent"
|
||||
? CUSTOM_MASK_TANGENT_SIZE
|
||||
: CUSTOM_MASK_ANCHOR_SIZE
|
||||
}
|
||||
isSelected={handle.isSelected}
|
||||
onPointerDown={(event) =>
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.kind === "corner") {
|
||||
return (
|
||||
<CornerHandle
|
||||
key={handle.id}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
|
@ -226,7 +296,7 @@ export function MaskHandles({
|
|||
cursor={handle.cursor}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ import { useCallback, useEffect, useRef } from "react";
|
|||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { TextElement } from "@/lib/timeline";
|
||||
import {
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/lib/text/typography";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveTextLayout } from "@/lib/text/primitives";
|
||||
|
||||
export function TextEditOverlay({
|
||||
trackId,
|
||||
|
|
@ -82,20 +80,29 @@ export function TextEditOverlay({
|
|||
});
|
||||
|
||||
const { x: displayScaleX } = viewport.getDisplayScale();
|
||||
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
|
||||
const resolvedTextLayout = resolveTextLayout({
|
||||
text: {
|
||||
content: element.content,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
textDecoration: element.textDecoration,
|
||||
letterSpacing: element.letterSpacing,
|
||||
lineHeight: element.lineHeight,
|
||||
},
|
||||
canvasHeight: canvasSize.height,
|
||||
});
|
||||
|
||||
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const canvasLetterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
const lineHeightPx = resolvedTextLayout.lineHeightPx;
|
||||
|
||||
const bg = element.background;
|
||||
const shouldShowBackground =
|
||||
bg.enabled && bg.color && bg.color !== "transparent";
|
||||
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
|
||||
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
|
||||
const canvasPaddingX = shouldShowBackground
|
||||
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
|
||||
: 0;
|
||||
|
|
@ -123,10 +130,10 @@ export function TextEditOverlay({
|
|||
aria-label="Edit text"
|
||||
className="cursor-text select-text outline-none whitespace-pre"
|
||||
style={{
|
||||
fontSize: scaledFontSize,
|
||||
fontSize: resolvedTextLayout.scaledFontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
|
||||
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
|
||||
textAlign: element.textAlign,
|
||||
letterSpacing: `${canvasLetterSpacing}px`,
|
||||
lineHeight,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import type { MaskableElement } from "@/lib/timeline";
|
||||
import type { Mask, MaskType } from "@/lib/masks/types";
|
||||
import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
|
||||
import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
|
||||
import type {
|
||||
NumberParamDefinition,
|
||||
SelectParamDefinition,
|
||||
} from "@/lib/params";
|
||||
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
|
|
@ -10,14 +13,17 @@ import { useMenuPreview } from "@/hooks/use-menu-preview";
|
|||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Delete02Icon,
|
||||
FeatherIcon,
|
||||
PlusSignIcon,
|
||||
RotateClockwiseIcon,
|
||||
TextFontIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -32,6 +38,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -52,7 +59,12 @@ import {
|
|||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
|
||||
import {
|
||||
OcMirrorIcon,
|
||||
OcShapesIcon,
|
||||
OcTextHeightIcon,
|
||||
OcTextWidthIcon,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
type MasksTabProps = {
|
||||
|
|
@ -78,6 +90,10 @@ type PreviewParamHandler = (
|
|||
|
||||
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
|
||||
|
||||
function isTextMask(mask: Mask): mask is TextMask {
|
||||
return mask.type === "text";
|
||||
}
|
||||
|
||||
export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||
const editor = useEditor();
|
||||
const { renderElement, previewUpdates, commit } =
|
||||
|
|
@ -370,12 +386,23 @@ function MaskParamsFields({
|
|||
previewParam(key)(value);
|
||||
const previewStrokeColor = previewParam("strokeColor");
|
||||
const strokeAlignParam = definition.params.find(
|
||||
(param): param is SelectParamDefinition =>
|
||||
(param): param is SelectParamDefinition<string> =>
|
||||
param.key === "strokeAlign" && param.type === "select",
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionFields>
|
||||
{isTextMask(mask) ? (
|
||||
<TextMaskFields
|
||||
mask={mask}
|
||||
previewParam={previewParam}
|
||||
onCommit={onCommit}
|
||||
fontSizeParam={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "fontSize",
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{definition.features.hasPosition &&
|
||||
"centerX" in mask.params &&
|
||||
"centerY" in mask.params && (
|
||||
|
|
@ -491,7 +518,9 @@ function MaskParamsFields({
|
|||
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
|
||||
<SectionField label="Scale">
|
||||
<MaskNumberField
|
||||
icon="S"
|
||||
icon={
|
||||
isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
|
||||
}
|
||||
param={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "scale",
|
||||
|
|
@ -587,6 +616,100 @@ function MaskParamsFields({
|
|||
);
|
||||
}
|
||||
|
||||
const LETTER_SPACING_PARAM: NumberParamDefinition = {
|
||||
key: "letterSpacing",
|
||||
label: "Letter spacing",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 500,
|
||||
step: 1,
|
||||
};
|
||||
|
||||
const LINE_HEIGHT_PARAM: NumberParamDefinition = {
|
||||
key: "lineHeight",
|
||||
label: "Line height",
|
||||
type: "number",
|
||||
default: 1.2,
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
step: 0.1,
|
||||
};
|
||||
|
||||
function TextMaskFields({
|
||||
mask,
|
||||
previewParam,
|
||||
onCommit,
|
||||
fontSizeParam,
|
||||
}: {
|
||||
mask: TextMask;
|
||||
previewParam: PreviewParamHandler;
|
||||
onCommit: () => void;
|
||||
fontSizeParam: NumberParamDefinition;
|
||||
}) {
|
||||
const content = usePropertyDraft({
|
||||
displayValue: mask.params.content,
|
||||
parse: (input) => input,
|
||||
onPreview: (value) => previewParam("content")(value),
|
||||
onCommit,
|
||||
});
|
||||
|
||||
const previewNumberParam = (key: string) => (value: number) =>
|
||||
previewParam(key)(value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionField label="Content">
|
||||
<Textarea
|
||||
value={content.displayValue}
|
||||
className="min-h-20"
|
||||
onFocus={content.onFocus}
|
||||
onChange={content.onChange}
|
||||
onBlur={content.onBlur}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Font">
|
||||
<FontPicker
|
||||
defaultValue={mask.params.fontFamily}
|
||||
onValueChange={(value) => {
|
||||
previewParam("fontFamily")(value);
|
||||
onCommit();
|
||||
}}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Size">
|
||||
<MaskNumberField
|
||||
icon={<HugeiconsIcon icon={TextFontIcon} />}
|
||||
param={fontSizeParam}
|
||||
value={mask.params.fontSize}
|
||||
onPreview={previewNumberParam("fontSize")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Spacing">
|
||||
<div className="flex items-start gap-2">
|
||||
<MaskNumberField
|
||||
className="w-1/2"
|
||||
icon={<OcTextWidthIcon size={14} />}
|
||||
param={LETTER_SPACING_PARAM}
|
||||
value={mask.params.letterSpacing ?? 0}
|
||||
onPreview={previewNumberParam("letterSpacing")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
<MaskNumberField
|
||||
className="w-1/2"
|
||||
icon={<OcTextHeightIcon size={14} />}
|
||||
param={LINE_HEIGHT_PARAM}
|
||||
value={mask.params.lineHeight ?? 1.2}
|
||||
onPreview={previewNumberParam("lineHeight")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getNumberParamDefinition({
|
||||
definition,
|
||||
key,
|
||||
|
|
@ -603,13 +726,10 @@ function getNumberParamDefinition({
|
|||
return param;
|
||||
}
|
||||
|
||||
function getMaskNumber({
|
||||
params,
|
||||
key,
|
||||
}: {
|
||||
params: Mask["params"];
|
||||
key: string;
|
||||
}): number {
|
||||
function getMaskNumber<
|
||||
TParams extends Mask["params"],
|
||||
TKey extends keyof TParams & string,
|
||||
>({ params, key }: { params: TParams; key: TKey }): number {
|
||||
const value = params[key];
|
||||
|
||||
if (typeof value !== "number") {
|
||||
|
|
@ -665,7 +785,9 @@ function MaskNumberField({
|
|||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
|
||||
return (
|
||||
clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
|
||||
);
|
||||
},
|
||||
onPreview,
|
||||
onCommit,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,34 @@ import { cn } from "@/utils/ui";
|
|||
const BAR_WIDTH = 2;
|
||||
const BAR_GAP = 1;
|
||||
const BAR_STEP = BAR_WIDTH + BAR_GAP;
|
||||
export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
|
||||
|
||||
function sampleGain({
|
||||
samples,
|
||||
startFraction,
|
||||
endFraction,
|
||||
barIndex,
|
||||
barCount,
|
||||
}: {
|
||||
samples: number[];
|
||||
startFraction: number;
|
||||
endFraction: number;
|
||||
barIndex: number;
|
||||
barCount: number;
|
||||
}): number {
|
||||
if (samples.length === 0) return 1;
|
||||
const progress =
|
||||
startFraction +
|
||||
((barIndex + 0.5) / barCount) * (endFraction - startFraction);
|
||||
const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
|
||||
const lo = Math.floor(rawIndex);
|
||||
const hi = Math.min(samples.length - 1, lo + 1);
|
||||
return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
|
||||
}
|
||||
interface AudioWaveformProps {
|
||||
audioUrl?: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
gainSamples?: number[];
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -19,6 +44,7 @@ interface AudioWaveformProps {
|
|||
export function AudioWaveform({
|
||||
audioUrl,
|
||||
audioBuffer,
|
||||
gainSamples,
|
||||
color = "rgba(255, 255, 255, 0.7)",
|
||||
className = "",
|
||||
}: AudioWaveformProps) {
|
||||
|
|
@ -26,6 +52,7 @@ export function AudioWaveform({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bufferRef = useRef<AudioBuffer | null>(null);
|
||||
const globalMaxRef = useRef<number>(1);
|
||||
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
|
||||
const scrollParentRef = useRef<HTMLElement | null>(null);
|
||||
const heightRef = useRef<number>(0);
|
||||
|
||||
|
|
@ -99,20 +126,37 @@ export function AudioWaveform({
|
|||
|
||||
const maxBarHeight = height * 0.7;
|
||||
|
||||
const samples = gainSamplesRef.current;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
|
||||
const gain =
|
||||
samples != null
|
||||
? sampleGain({
|
||||
samples,
|
||||
startFraction,
|
||||
endFraction,
|
||||
barIndex: i,
|
||||
barCount,
|
||||
})
|
||||
: 1;
|
||||
const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
|
||||
const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
|
||||
const barH = Math.max(1, scaled * maxBarHeight);
|
||||
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
useEffect(() => {
|
||||
gainSamplesRef.current = gainSamples;
|
||||
drawVisible();
|
||||
}, [gainSamples, drawVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
async function load() {
|
||||
let buffer = audioBuffer ?? null;
|
||||
|
||||
if (!buffer && audioUrl) {
|
||||
if (!buffer && audioUrl) {
|
||||
try {
|
||||
const resp = await fetch(audioUrl);
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import { AudioWaveform } from "./audio-waveform";
|
||||
import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
import {
|
||||
useKeyframeDrag,
|
||||
|
|
@ -44,6 +44,7 @@ import {
|
|||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
|
|
@ -382,6 +383,7 @@ export function TimelineElement({
|
|||
>
|
||||
<ElementInner
|
||||
element={element}
|
||||
displayElement={renderElement}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isExpanded={expandedRows.length > 0}
|
||||
|
|
@ -498,6 +500,7 @@ export function TimelineElement({
|
|||
|
||||
function ElementInner({
|
||||
element,
|
||||
displayElement,
|
||||
track,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
|
|
@ -509,6 +512,7 @@ function ElementInner({
|
|||
isDropTarget = false,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
displayElement?: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isExpanded: boolean;
|
||||
|
|
@ -530,8 +534,10 @@ function ElementInner({
|
|||
}) => void;
|
||||
isDropTarget?: boolean;
|
||||
}) {
|
||||
const visibleElement = displayElement ?? element;
|
||||
const isReducedOpacity =
|
||||
(canElementBeHidden(element) && element.hidden) || isDropTarget;
|
||||
(canElementBeHidden(visibleElement) && visibleElement.hidden) ||
|
||||
isDropTarget;
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
|
|
@ -576,7 +582,7 @@ function ElementInner({
|
|||
style={{ height: `${baseTrackHeight}px` }}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
|
||||
<ElementContent element={element} track={track} />
|
||||
<ElementContent element={visibleElement} track={track} />
|
||||
</div>
|
||||
</div>
|
||||
{expandedContent}
|
||||
|
|
@ -979,6 +985,11 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
|||
const audioUrl =
|
||||
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
|
||||
const mediaLabel = mediaAsset?.name ?? element.name;
|
||||
const gainSamples = useMemo(
|
||||
() =>
|
||||
buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
|
||||
[element],
|
||||
);
|
||||
|
||||
if (audioBuffer || audioUrl) {
|
||||
return (
|
||||
|
|
@ -986,6 +997,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
|||
<AudioWaveform
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
gainSamples={gainSamples}
|
||||
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
||||
/>
|
||||
<MediaElementHeader name={mediaLabel} hasFade={false} />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,13 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { Command, CommandResult } from "@/lib/commands";
|
||||
import type { EditorSelectionSnapshot } from "@/lib/selection/editor-selection";
|
||||
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
|
||||
import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
|
||||
import type { SceneTracks } from "@/lib/timeline/types";
|
||||
|
||||
interface CommandHistoryEntry {
|
||||
command: Command;
|
||||
previousSelection: ElementRef[];
|
||||
selectionOverride?: ElementRef[];
|
||||
previousSelection: EditorSelectionSnapshot;
|
||||
selectionOverride?: EditorSelectionSnapshot;
|
||||
}
|
||||
|
||||
export class CommandManager {
|
||||
|
|
@ -19,7 +20,7 @@ export class CommandManager {
|
|||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
const beforeTracks = this.isRippleEnabled
|
||||
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
|
||||
: null;
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = command.execute();
|
||||
|
|
@ -55,11 +56,11 @@ export class CommandManager {
|
|||
// Only restore selection for commands that explicitly changed it.
|
||||
// Commands without selection intent leave selection untouched,
|
||||
// preserving any UI-driven selection changes (clicks, box select)
|
||||
// that happened between commands. Commands that remove elements
|
||||
// must declare { select: [] } to clear stale refs.
|
||||
// that happened between commands. Commands that remove editor-owned
|
||||
// selection targets must declare a selection override to clear stale refs.
|
||||
if (entry.selectionOverride !== undefined) {
|
||||
this.editor.selection.setSelectedElements({
|
||||
elements: [...entry.previousSelection],
|
||||
this.editor.selection.restoreSnapshot({
|
||||
snapshot: entry.previousSelection,
|
||||
});
|
||||
}
|
||||
this.redoStack.push(entry);
|
||||
|
|
@ -74,7 +75,7 @@ export class CommandManager {
|
|||
}
|
||||
|
||||
const beforeTracks = this.isRippleEnabled
|
||||
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
|
||||
: null;
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = entry.command.redo();
|
||||
|
|
@ -102,20 +103,19 @@ export class CommandManager {
|
|||
this.redoStack = [];
|
||||
}
|
||||
|
||||
private getSelectionSnapshot(): ElementRef[] {
|
||||
return [...this.editor.selection.getSelectedElements()];
|
||||
private getSelectionSnapshot(): EditorSelectionSnapshot {
|
||||
return this.editor.selection.getSnapshot();
|
||||
}
|
||||
|
||||
private applySelectionOverride(
|
||||
result: CommandResult | undefined,
|
||||
): ElementRef[] | undefined {
|
||||
if (result?.select === undefined) {
|
||||
): EditorSelectionSnapshot | undefined {
|
||||
if (!result?.selection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectionOverride = [...result.select];
|
||||
this.editor.selection.setSelectedElements({ elements: selectionOverride });
|
||||
return selectionOverride;
|
||||
return this.editor.selection.applySelectionPatch({
|
||||
patch: result.selection,
|
||||
});
|
||||
}
|
||||
|
||||
private runReactors(): void {
|
||||
|
|
|
|||
|
|
@ -1,73 +1,195 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
||||
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(editor: EditorCore) {
|
||||
void editor;
|
||||
}
|
||||
|
||||
getSelectedElements(): ElementRef[] {
|
||||
return this.selectedElements;
|
||||
}
|
||||
|
||||
getSelectedKeyframes(): SelectedKeyframeRef[] {
|
||||
return this.selectedKeyframes;
|
||||
}
|
||||
|
||||
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
|
||||
return this.keyframeSelectionAnchor;
|
||||
}
|
||||
|
||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||
this.selectedElements = elements;
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSelectedKeyframes({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef | null;
|
||||
}): void {
|
||||
this.selectedKeyframes = keyframes;
|
||||
if (anchorKeyframe !== undefined) {
|
||||
this.keyframeSelectionAnchor = anchorKeyframe;
|
||||
} else if (keyframes.length === 0) {
|
||||
this.keyframeSelectionAnchor = null;
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedElements = [];
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearKeyframeSelection(): void {
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
import type {
|
||||
EditorSelectionKind,
|
||||
EditorSelectionPatch,
|
||||
EditorSelectionSnapshot,
|
||||
SelectedMaskPointSelection,
|
||||
} from "@/lib/selection/editor-selection";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
||||
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
||||
private selectedMaskPoints: SelectedMaskPointSelection | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(editor: EditorCore) {
|
||||
void editor;
|
||||
}
|
||||
|
||||
getSelectedElements(): ElementRef[] {
|
||||
return this.selectedElements;
|
||||
}
|
||||
|
||||
getSelectedKeyframes(): SelectedKeyframeRef[] {
|
||||
return this.selectedKeyframes;
|
||||
}
|
||||
|
||||
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
|
||||
return this.keyframeSelectionAnchor;
|
||||
}
|
||||
|
||||
getSelectedMaskPointSelection(): SelectedMaskPointSelection | null {
|
||||
return this.selectedMaskPoints;
|
||||
}
|
||||
|
||||
getActiveSelectionKind(): EditorSelectionKind | null {
|
||||
if ((this.selectedMaskPoints?.pointIds.length ?? 0) > 0) {
|
||||
return "mask-points";
|
||||
}
|
||||
if (this.selectedKeyframes.length > 0) {
|
||||
return "keyframes";
|
||||
}
|
||||
if (this.selectedElements.length > 0) {
|
||||
return "elements";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSnapshot(): EditorSelectionSnapshot {
|
||||
return {
|
||||
selectedElements: [...this.selectedElements],
|
||||
selectedKeyframes: [...this.selectedKeyframes],
|
||||
keyframeSelectionAnchor: this.keyframeSelectionAnchor,
|
||||
selectedMaskPoints: this.selectedMaskPoints
|
||||
? {
|
||||
...this.selectedMaskPoints,
|
||||
pointIds: [...this.selectedMaskPoints.pointIds],
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||
this.selectedElements = elements;
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.selectedMaskPoints = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSelectedKeyframes({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef | null;
|
||||
}): void {
|
||||
this.selectedKeyframes = keyframes;
|
||||
if (anchorKeyframe !== undefined) {
|
||||
this.keyframeSelectionAnchor = anchorKeyframe;
|
||||
} else if (keyframes.length === 0) {
|
||||
this.keyframeSelectionAnchor = null;
|
||||
}
|
||||
this.selectedMaskPoints = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSelectedMaskPoints({
|
||||
selection,
|
||||
}: {
|
||||
selection: SelectedMaskPointSelection | null;
|
||||
}): void {
|
||||
this.selectedMaskPoints =
|
||||
selection && selection.pointIds.length > 0
|
||||
? {
|
||||
...selection,
|
||||
pointIds: [...selection.pointIds],
|
||||
}
|
||||
: null;
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedElements = [];
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.selectedMaskPoints = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearKeyframeSelection(): void {
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearMaskPointSelection(): void {
|
||||
if (!this.selectedMaskPoints) {
|
||||
return;
|
||||
}
|
||||
this.selectedMaskPoints = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearMostSpecificSelection(): boolean {
|
||||
const activeSelectionKind = this.getActiveSelectionKind();
|
||||
if (activeSelectionKind === "mask-points") {
|
||||
this.clearMaskPointSelection();
|
||||
return true;
|
||||
}
|
||||
if (activeSelectionKind === "keyframes") {
|
||||
this.clearKeyframeSelection();
|
||||
return true;
|
||||
}
|
||||
if (activeSelectionKind === "elements") {
|
||||
this.setSelectedElements({ elements: [] });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
applySelectionPatch({
|
||||
patch,
|
||||
}: {
|
||||
patch: EditorSelectionPatch;
|
||||
}): EditorSelectionSnapshot {
|
||||
if (patch.selectedElements !== undefined) {
|
||||
this.selectedElements = [...patch.selectedElements];
|
||||
}
|
||||
if (patch.selectedKeyframes !== undefined) {
|
||||
this.selectedKeyframes = [...patch.selectedKeyframes];
|
||||
}
|
||||
if (patch.keyframeSelectionAnchor !== undefined) {
|
||||
this.keyframeSelectionAnchor = patch.keyframeSelectionAnchor;
|
||||
}
|
||||
if (patch.selectedMaskPoints !== undefined) {
|
||||
this.selectedMaskPoints = patch.selectedMaskPoints
|
||||
? {
|
||||
...patch.selectedMaskPoints,
|
||||
pointIds: [...patch.selectedMaskPoints.pointIds],
|
||||
}
|
||||
: null;
|
||||
}
|
||||
this.notify();
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
restoreSnapshot({ snapshot }: { snapshot: EditorSelectionSnapshot }): void {
|
||||
this.selectedElements = [...snapshot.selectedElements];
|
||||
this.selectedKeyframes = [...snapshot.selectedKeyframes];
|
||||
this.keyframeSelectionAnchor = snapshot.keyframeSelectionAnchor;
|
||||
this.selectedMaskPoints = snapshot.selectedMaskPoints
|
||||
? {
|
||||
...snapshot.selectedMaskPoints,
|
||||
pointIds: [...snapshot.selectedMaskPoints.pointIds],
|
||||
}
|
||||
: null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
SceneTracks,
|
||||
|
|
@ -44,6 +45,8 @@ import {
|
|||
RetimeKeyframeCommand,
|
||||
UpdateScalarKeyframeCurveCommand,
|
||||
AddClipEffectCommand,
|
||||
DeleteCustomMaskPointsCommand,
|
||||
InsertCustomMaskPointCommand,
|
||||
RemoveClipEffectCommand,
|
||||
UpdateClipEffectParamsCommand,
|
||||
ToggleClipEffectCommand,
|
||||
|
|
@ -344,6 +347,55 @@ export class TimelineManager {
|
|||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
deleteCustomMaskPoints({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}): void {
|
||||
if (pointIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const command = new DeleteCustomMaskPointsCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
pointIds,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
insertCustomMaskPoint({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): void {
|
||||
const command = new InsertCustomMaskPointCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateClipEffectParams({
|
||||
trackId,
|
||||
elementId,
|
||||
|
|
|
|||
|
|
@ -21,19 +21,27 @@ export function useEditorActions() {
|
|||
const editor = useEditor();
|
||||
const { selectedElements, setElementSelection } = useElementSelection();
|
||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||
const selectedMaskPointSelection = useEditor((e) =>
|
||||
e.selection.getSelectedMaskPointSelection(),
|
||||
);
|
||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||
const hasTimelineSelectionRef = useRef(false);
|
||||
const clearTimelineSelectionRef = useRef(() => {});
|
||||
const clearTimelineActiveSelectionRef = useRef(() => {});
|
||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
||||
const hasTimelineSelection =
|
||||
selectedElements.length > 0 || selectedKeyframes.length > 0;
|
||||
selectedElements.length > 0 ||
|
||||
selectedKeyframes.length > 0 ||
|
||||
selectedMaskPointSelection !== null;
|
||||
|
||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
||||
clearTimelineSelectionRef.current = () => {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearSelection();
|
||||
};
|
||||
clearTimelineActiveSelectionRef.current = () => {
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
};
|
||||
|
||||
if (!timelineScopeRef.current) {
|
||||
|
|
@ -42,6 +50,9 @@ export function useEditorActions() {
|
|||
clear: () => {
|
||||
clearTimelineSelectionRef.current();
|
||||
},
|
||||
clearActive: () => {
|
||||
clearTimelineActiveSelectionRef.current();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -257,17 +268,36 @@ export function useEditorActions() {
|
|||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedKeyframes.length > 0) {
|
||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||
clearKeyframeSelection();
|
||||
return;
|
||||
switch (editor.selection.getActiveSelectionKind()) {
|
||||
case "mask-points":
|
||||
if (!selectedMaskPointSelection) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteCustomMaskPoints({
|
||||
trackId: selectedMaskPointSelection.trackId,
|
||||
elementId: selectedMaskPointSelection.elementId,
|
||||
maskId: selectedMaskPointSelection.maskId,
|
||||
pointIds: selectedMaskPointSelection.pointIds,
|
||||
});
|
||||
return;
|
||||
case "keyframes":
|
||||
if (selectedKeyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||
clearKeyframeSelection();
|
||||
return;
|
||||
case "elements":
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
|
@ -343,8 +373,7 @@ export function useEditorActions() {
|
|||
"deselect-all",
|
||||
() => {
|
||||
if (!clearActiveScope()) {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
|
|
|
|||
|
|
@ -1,260 +1,633 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import {
|
||||
getMaskHandlePositions,
|
||||
getLineMaskLinePoints,
|
||||
} from "@/lib/masks/handle-positions";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskHandlePosition,
|
||||
MaskLinePoints,
|
||||
} from "@/lib/masks/types";
|
||||
import type { MaskableElement } from "@/lib/timeline";
|
||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||
|
||||
interface DragState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
handleId: string;
|
||||
startCanvasX: number;
|
||||
startCanvasY: number;
|
||||
startParams: BaseMaskParams & ParamValues;
|
||||
}
|
||||
|
||||
export function useMaskHandles({
|
||||
onSnapLinesChange,
|
||||
}: {
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const tracks = useEditor(
|
||||
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||
);
|
||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
const canvasSize = useEditor(
|
||||
(e) => e.project.getActive().settings.canvasSize,
|
||||
);
|
||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const selectedWithMask =
|
||||
selectedElements.length === 1
|
||||
? (() => {
|
||||
const sel = selectedElements[0];
|
||||
const entry = elementsWithBounds.find(
|
||||
(item) =>
|
||||
item.trackId === sel.trackId && item.elementId === sel.elementId,
|
||||
);
|
||||
if (!entry) return null;
|
||||
const element = entry.element as MaskableElement;
|
||||
if (!element.masks?.length) return null;
|
||||
return { ...entry, element, mask: element.masks[0] };
|
||||
})()
|
||||
: null;
|
||||
|
||||
const handlePositions: MaskHandlePosition[] = selectedWithMask
|
||||
? (() => {
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||
const displayScale = (scaleX + scaleY) / 2;
|
||||
return getMaskHandlePositions({
|
||||
overlayShape: def.overlayShape,
|
||||
features: def.features,
|
||||
params: selectedWithMask.mask.params,
|
||||
bounds: selectedWithMask.bounds,
|
||||
displayScale,
|
||||
});
|
||||
})()
|
||||
: [];
|
||||
|
||||
const linePoints: MaskLinePoints | null =
|
||||
selectedWithMask?.mask.type === "split"
|
||||
? getLineMaskLinePoints({
|
||||
centerX: selectedWithMask.mask.params.centerX,
|
||||
centerY: selectedWithMask.mask.params.centerY,
|
||||
rotation: selectedWithMask.mask.params.rotation,
|
||||
bounds: selectedWithMask.bounds,
|
||||
})
|
||||
: null;
|
||||
|
||||
const clearMaskHandleState = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
setActiveHandleId(null);
|
||||
onSnapLinesChange?.([]);
|
||||
}, [onSnapLinesChange]);
|
||||
|
||||
const releaseCapturedPointer = useCallback(() => {
|
||||
const capture = captureRef.current;
|
||||
if (!capture) return;
|
||||
|
||||
if (capture.element.hasPointerCapture(capture.pointerId)) {
|
||||
capture.element.releasePointerCapture(capture.pointerId);
|
||||
}
|
||||
|
||||
captureRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHandleId) return;
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
editor.timeline.discardPreview();
|
||||
clearMaskHandleState();
|
||||
releaseCapturedPointer();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeHandleId,
|
||||
clearMaskHandleState,
|
||||
editor.timeline,
|
||||
releaseCapturedPointer,
|
||||
]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
|
||||
if (!selectedWithMask) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const pos = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!pos) return;
|
||||
|
||||
dragStateRef.current = {
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
handleId,
|
||||
startCanvasX: pos.x,
|
||||
startCanvasY: pos.y,
|
||||
startParams: { ...selectedWithMask.mask.params },
|
||||
};
|
||||
setActiveHandleId(handleId);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithMask, viewport],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag || !selectedWithMask) return;
|
||||
|
||||
const pos = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!pos) return;
|
||||
|
||||
const deltaX = pos.x - drag.startCanvasX;
|
||||
const deltaY = pos.y - drag.startCanvasY;
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
|
||||
const rawParams = def.computeParamUpdate({
|
||||
handleId: drag.handleId,
|
||||
startParams: drag.startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
startCanvasX: drag.startCanvasX,
|
||||
startCanvasY: drag.startCanvasY,
|
||||
bounds: selectedWithMask.bounds,
|
||||
canvasSize,
|
||||
});
|
||||
const proposedParams = { ...drag.startParams, ...rawParams };
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { params: nextParams, activeLines } = isShiftHeldRef.current
|
||||
? { params: proposedParams, activeLines: [] as SnapLine[] }
|
||||
: snapMaskInteraction({
|
||||
handleId: drag.handleId,
|
||||
startParams: drag.startParams,
|
||||
proposedParams,
|
||||
bounds: selectedWithMask.bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
const updatedMask = {
|
||||
...selectedWithMask.mask,
|
||||
params: nextParams,
|
||||
};
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: drag.trackId,
|
||||
elementId: drag.elementId,
|
||||
updates: {
|
||||
masks: [
|
||||
updatedMask,
|
||||
...(selectedWithMask.element.masks?.slice(1) ?? []),
|
||||
],
|
||||
} as Partial<MaskableElement>,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
[
|
||||
selectedWithMask,
|
||||
canvasSize,
|
||||
editor,
|
||||
isShiftHeldRef,
|
||||
onSnapLinesChange,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (dragStateRef.current) {
|
||||
editor.timeline.commitPreview();
|
||||
clearMaskHandleState();
|
||||
}
|
||||
releaseCapturedPointer();
|
||||
},
|
||||
[clearMaskHandleState, editor, releaseCapturedPointer],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedWithMask,
|
||||
handlePositions,
|
||||
linePoints,
|
||||
activeHandleId,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
};
|
||||
}
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import {
|
||||
parseCustomMaskHandleId,
|
||||
parseCustomMaskSegmentHandleId,
|
||||
} from "@/lib/masks/custom-path";
|
||||
import { appendPointToCustomMask } from "@/lib/masks/definitions/custom";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementBounds,
|
||||
} from "@/lib/preview/element-bounds";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection";
|
||||
import type { Mask, MaskInteractionResult } from "@/lib/masks/types";
|
||||
import type { MaskableElement } from "@/lib/timeline";
|
||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||
|
||||
interface DragState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
handleId: string;
|
||||
startCanvasX: number;
|
||||
startCanvasY: number;
|
||||
startParams: Mask["params"];
|
||||
}
|
||||
|
||||
interface PendingSegmentInsertState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startCanvasX: number;
|
||||
startCanvasY: number;
|
||||
startParams: Mask["params"];
|
||||
bounds: ElementBounds;
|
||||
}
|
||||
|
||||
const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
function isMaskSelectionForElement({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
selection,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
selection: SelectedMaskPointSelection | null;
|
||||
}): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
selection.trackId === trackId &&
|
||||
selection.elementId === elementId &&
|
||||
selection.maskId === maskId
|
||||
);
|
||||
}
|
||||
|
||||
function replaceElementMask({
|
||||
masks,
|
||||
updatedMask,
|
||||
}: {
|
||||
masks: MaskableElement["masks"];
|
||||
updatedMask: Mask;
|
||||
}): Mask[] {
|
||||
return (masks ?? []).map((mask) =>
|
||||
mask.id === updatedMask.id ? updatedMask : mask,
|
||||
);
|
||||
}
|
||||
|
||||
function withUpdatedMaskParams<TMask extends Mask>({
|
||||
mask,
|
||||
params,
|
||||
}: {
|
||||
mask: TMask;
|
||||
params: TMask["params"];
|
||||
}): TMask {
|
||||
return {
|
||||
...mask,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMaskHandles({
|
||||
onSnapLinesChange,
|
||||
}: {
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
|
||||
null,
|
||||
);
|
||||
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const tracks = useEditor(
|
||||
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||
);
|
||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
const canvasSize = useEditor(
|
||||
(e) => e.project.getActive().settings.canvasSize,
|
||||
);
|
||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||
const selectedMaskPointSelection = useEditor((e) =>
|
||||
e.selection.getSelectedMaskPointSelection(),
|
||||
);
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const selectedWithMask =
|
||||
selectedElements.length === 1
|
||||
? (() => {
|
||||
const sel = selectedElements[0];
|
||||
const entry = elementsWithBounds.find(
|
||||
(item) =>
|
||||
item.trackId === sel.trackId && item.elementId === sel.elementId,
|
||||
);
|
||||
if (!entry) return null;
|
||||
const element = entry.element as MaskableElement;
|
||||
const masks = element.masks ?? [];
|
||||
if (masks.length === 0) return null;
|
||||
const activeMaskId = masks.some((mask) =>
|
||||
isMaskSelectionForElement({
|
||||
trackId: entry.trackId,
|
||||
elementId: entry.elementId,
|
||||
maskId: mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
}),
|
||||
)
|
||||
? selectedMaskPointSelection?.maskId
|
||||
: masks[0].id;
|
||||
const mask =
|
||||
masks.find((candidate) => candidate.id === activeMaskId) ??
|
||||
masks[0];
|
||||
return { ...entry, element, mask };
|
||||
})()
|
||||
: null;
|
||||
|
||||
const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
|
||||
selectedWithMask
|
||||
? (() => {
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||
const displayScale = (scaleX + scaleY) / 2;
|
||||
return def.interaction.getInteraction({
|
||||
params: selectedWithMask.mask.params,
|
||||
bounds: selectedWithMask.bounds,
|
||||
displayScale,
|
||||
scaleX,
|
||||
scaleY,
|
||||
});
|
||||
})()
|
||||
: { handles: [], overlays: [] };
|
||||
|
||||
const selectedPointIds = new Set(
|
||||
selectedWithMask &&
|
||||
isMaskSelectionForElement({
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
})
|
||||
? (selectedMaskPointSelection?.pointIds ?? [])
|
||||
: [],
|
||||
);
|
||||
|
||||
const handlePositions = baseHandlePositions.map((handle) => {
|
||||
const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
|
||||
if (!parsedHandle || parsedHandle.part !== "anchor") {
|
||||
return handle;
|
||||
}
|
||||
return {
|
||||
...handle,
|
||||
isSelected: selectedPointIds.has(parsedHandle.pointId),
|
||||
};
|
||||
});
|
||||
|
||||
const customMaskPointIds =
|
||||
selectedWithMask?.mask.type === "custom"
|
||||
? handlePositions
|
||||
.filter((h) => h.kind === "point")
|
||||
.map((h) => {
|
||||
const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
|
||||
return parsedHandle?.part === "anchor"
|
||||
? parsedHandle.pointId
|
||||
: null;
|
||||
})
|
||||
.filter((id): id is string => id !== null)
|
||||
: [];
|
||||
const isCreatingCustomMask =
|
||||
selectedWithMask?.mask.type === "custom" &&
|
||||
(!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMaskPointSelection) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!selectedWithMask ||
|
||||
selectedWithMask.mask.type !== "custom" ||
|
||||
!isMaskSelectionForElement({
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
})
|
||||
) {
|
||||
editor.selection.clearMaskPointSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const availablePointIds = new Set(customMaskPointIds);
|
||||
const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter(
|
||||
(pointId) => availablePointIds.has(pointId),
|
||||
);
|
||||
if (
|
||||
nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (nextSelectedPointIds.length === 0) {
|
||||
editor.selection.clearMaskPointSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.selection.setSelectedMaskPoints({
|
||||
selection: {
|
||||
...selectedMaskPointSelection,
|
||||
pointIds: nextSelectedPointIds,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
customMaskPointIds,
|
||||
editor.selection,
|
||||
selectedMaskPointSelection,
|
||||
selectedWithMask,
|
||||
]);
|
||||
|
||||
const updateCustomMaskPointSelection = useCallback(
|
||||
({
|
||||
pointId,
|
||||
toggleSelection,
|
||||
}: {
|
||||
pointId: string;
|
||||
toggleSelection: boolean;
|
||||
}) => {
|
||||
if (!selectedWithMask || selectedWithMask.mask.type !== "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelectionForCurrentMask = isMaskSelectionForElement({
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
});
|
||||
const currentPointIds = isSelectionForCurrentMask
|
||||
? (selectedMaskPointSelection?.pointIds ?? [])
|
||||
: [];
|
||||
const nextPointIds = toggleSelection
|
||||
? currentPointIds.includes(pointId)
|
||||
? currentPointIds.filter(
|
||||
(currentPointId) => currentPointId !== pointId,
|
||||
)
|
||||
: [...currentPointIds, pointId]
|
||||
: [pointId];
|
||||
|
||||
if (nextPointIds.length === 0) {
|
||||
editor.selection.clearMaskPointSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.selection.setSelectedMaskPoints({
|
||||
selection: {
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
pointIds: nextPointIds,
|
||||
},
|
||||
});
|
||||
},
|
||||
[editor.selection, selectedMaskPointSelection, selectedWithMask],
|
||||
);
|
||||
|
||||
const clearMaskHandleState = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
pendingSegmentInsertRef.current = null;
|
||||
setActiveHandleId(null);
|
||||
onSnapLinesChange?.([]);
|
||||
}, [onSnapLinesChange]);
|
||||
|
||||
const releaseCapturedPointer = useCallback(() => {
|
||||
const capture = captureRef.current;
|
||||
if (!capture) return;
|
||||
|
||||
if (capture.element.hasPointerCapture(capture.pointerId)) {
|
||||
capture.element.releasePointerCapture(capture.pointerId);
|
||||
}
|
||||
|
||||
captureRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHandleId) return;
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
editor.timeline.discardPreview();
|
||||
clearMaskHandleState();
|
||||
releaseCapturedPointer();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeHandleId,
|
||||
clearMaskHandleState,
|
||||
editor.timeline,
|
||||
releaseCapturedPointer,
|
||||
]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
|
||||
if (!selectedWithMask) return;
|
||||
if (event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
const parsedHandle =
|
||||
selectedWithMask.mask.type === "custom"
|
||||
? parseCustomMaskHandleId({ handleId })
|
||||
: null;
|
||||
const parsedSegmentHandle =
|
||||
selectedWithMask.mask.type === "custom"
|
||||
? parseCustomMaskSegmentHandleId({ handleId })
|
||||
: null;
|
||||
if (isCreatingCustomMask) {
|
||||
const firstPointId = customMaskPointIds[0];
|
||||
if (
|
||||
firstPointId &&
|
||||
handleId === `point:${firstPointId}:anchor` &&
|
||||
customMaskPointIds.length >= 3 &&
|
||||
selectedWithMask.mask.type === "custom"
|
||||
) {
|
||||
const updatedMask = withUpdatedMaskParams({
|
||||
mask: selectedWithMask.mask,
|
||||
params: {
|
||||
...selectedWithMask.mask.params,
|
||||
closed: true,
|
||||
},
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
patch: {
|
||||
masks: replaceElementMask({
|
||||
masks: selectedWithMask.element.masks,
|
||||
updatedMask,
|
||||
}),
|
||||
} as Partial<MaskableElement>,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!pos) return;
|
||||
|
||||
if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") {
|
||||
setActiveHandleId(handleId);
|
||||
pendingSegmentInsertRef.current = {
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
segmentIndex: parsedSegmentHandle.segmentIndex,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startCanvasX: pos.x,
|
||||
startCanvasY: pos.y,
|
||||
startParams: { ...selectedWithMask.mask.params },
|
||||
bounds: selectedWithMask.bounds,
|
||||
};
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedHandle?.part === "anchor") {
|
||||
updateCustomMaskPointSelection({
|
||||
pointId: parsedHandle.pointId,
|
||||
toggleSelection: event.shiftKey,
|
||||
});
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
} else if (selectedWithMask.mask.type === "custom") {
|
||||
editor.selection.clearMaskPointSelection();
|
||||
}
|
||||
|
||||
dragStateRef.current = {
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
handleId,
|
||||
startCanvasX: pos.x,
|
||||
startCanvasY: pos.y,
|
||||
startParams: { ...selectedWithMask.mask.params },
|
||||
};
|
||||
setActiveHandleId(handleId);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[
|
||||
customMaskPointIds,
|
||||
editor.selection,
|
||||
editor.timeline,
|
||||
isCreatingCustomMask,
|
||||
selectedWithMask,
|
||||
updateCustomMaskPointSelection,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCanvasPointerDown = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (!selectedWithMask || !isCreatingCustomMask) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedWithMask.mask.type !== "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
const pos = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!pos) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextParams = appendPointToCustomMask({
|
||||
params: selectedWithMask.mask.params,
|
||||
canvasPoint: pos,
|
||||
bounds: selectedWithMask.bounds,
|
||||
});
|
||||
const updatedMask = withUpdatedMaskParams({
|
||||
mask: selectedWithMask.mask,
|
||||
params: nextParams,
|
||||
});
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
patch: {
|
||||
masks: replaceElementMask({
|
||||
masks: selectedWithMask.element.masks,
|
||||
updatedMask,
|
||||
}),
|
||||
} as Partial<MaskableElement>,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
[editor.timeline, isCreatingCustomMask, selectedWithMask, viewport],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
const pendingSegmentInsert = pendingSegmentInsertRef.current;
|
||||
if (pendingSegmentInsert && !dragStateRef.current) {
|
||||
const distance = Math.hypot(
|
||||
event.clientX - pendingSegmentInsert.startClientX,
|
||||
event.clientY - pendingSegmentInsert.startClientY,
|
||||
);
|
||||
if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) {
|
||||
dragStateRef.current = {
|
||||
trackId: pendingSegmentInsert.trackId,
|
||||
elementId: pendingSegmentInsert.elementId,
|
||||
handleId: "position",
|
||||
startCanvasX: pendingSegmentInsert.startCanvasX,
|
||||
startCanvasY: pendingSegmentInsert.startCanvasY,
|
||||
startParams: pendingSegmentInsert.startParams,
|
||||
};
|
||||
pendingSegmentInsertRef.current = null;
|
||||
setActiveHandleId("position");
|
||||
}
|
||||
}
|
||||
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag || !selectedWithMask) return;
|
||||
|
||||
const pos = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!pos) return;
|
||||
|
||||
const deltaX = pos.x - drag.startCanvasX;
|
||||
const deltaY = pos.y - drag.startCanvasY;
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
|
||||
const rawParams = def.computeParamUpdate({
|
||||
handleId: drag.handleId,
|
||||
startParams: drag.startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
startCanvasX: drag.startCanvasX,
|
||||
startCanvasY: drag.startCanvasY,
|
||||
bounds: selectedWithMask.bounds,
|
||||
canvasSize,
|
||||
});
|
||||
const proposedParams = { ...drag.startParams, ...rawParams };
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { params: nextParams, activeLines } = isShiftHeldRef.current
|
||||
? { params: proposedParams, activeLines: [] as SnapLine[] }
|
||||
: (def.interaction.snap?.({
|
||||
handleId: drag.handleId,
|
||||
startParams: drag.startParams,
|
||||
proposedParams,
|
||||
bounds: selectedWithMask.bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}) ?? { params: proposedParams, activeLines: [] as SnapLine[] });
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
const updatedMask = withUpdatedMaskParams({
|
||||
mask: selectedWithMask.mask,
|
||||
params: nextParams as typeof selectedWithMask.mask.params,
|
||||
});
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: drag.trackId,
|
||||
elementId: drag.elementId,
|
||||
updates: {
|
||||
masks: replaceElementMask({
|
||||
masks: selectedWithMask.element.masks,
|
||||
updatedMask,
|
||||
}),
|
||||
} as Partial<MaskableElement>,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
[
|
||||
selectedWithMask,
|
||||
canvasSize,
|
||||
editor,
|
||||
isShiftHeldRef,
|
||||
onSnapLinesChange,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
const pendingSegmentInsert = pendingSegmentInsertRef.current;
|
||||
if (pendingSegmentInsert && !dragStateRef.current) {
|
||||
editor.timeline.insertCustomMaskPoint({
|
||||
trackId: pendingSegmentInsert.trackId,
|
||||
elementId: pendingSegmentInsert.elementId,
|
||||
maskId: pendingSegmentInsert.maskId,
|
||||
segmentIndex: pendingSegmentInsert.segmentIndex,
|
||||
canvasPoint: {
|
||||
x: pendingSegmentInsert.startCanvasX,
|
||||
y: pendingSegmentInsert.startCanvasY,
|
||||
},
|
||||
bounds: pendingSegmentInsert.bounds,
|
||||
});
|
||||
clearMaskHandleState();
|
||||
releaseCapturedPointer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragStateRef.current) {
|
||||
editor.timeline.commitPreview();
|
||||
clearMaskHandleState();
|
||||
}
|
||||
releaseCapturedPointer();
|
||||
}, [clearMaskHandleState, editor, releaseCapturedPointer]);
|
||||
|
||||
return {
|
||||
selectedWithMask,
|
||||
handlePositions,
|
||||
overlays,
|
||||
isCreatingCustomMask,
|
||||
handleCanvasPointerDown,
|
||||
activeHandleId,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,209 +1,209 @@
|
|||
import type {
|
||||
KeybindingConfig,
|
||||
ShortcutKey,
|
||||
} from "@/lib/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "./types";
|
||||
|
||||
export type TActionCategory =
|
||||
| "playback"
|
||||
| "navigation"
|
||||
| "editing"
|
||||
| "selection"
|
||||
| "history"
|
||||
| "timeline"
|
||||
| "controls"
|
||||
| "assets";
|
||||
|
||||
export interface TActionBaseDefinition {
|
||||
description: string;
|
||||
category: TActionCategory;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TActionDefinition extends TActionBaseDefinition {
|
||||
defaultShortcuts?: readonly ShortcutKey[];
|
||||
}
|
||||
|
||||
export const ACTIONS = {
|
||||
"toggle-play": {
|
||||
description: "Play/Pause",
|
||||
category: "playback",
|
||||
},
|
||||
"stop-playback": {
|
||||
description: "Stop playback",
|
||||
category: "playback",
|
||||
},
|
||||
"seek-forward": {
|
||||
description: "Seek forward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"seek-backward": {
|
||||
description: "Seek backward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"frame-step-forward": {
|
||||
description: "Frame step forward",
|
||||
category: "navigation",
|
||||
},
|
||||
"frame-step-backward": {
|
||||
description: "Frame step backward",
|
||||
category: "navigation",
|
||||
},
|
||||
"jump-forward": {
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"jump-backward": {
|
||||
description: "Jump backward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"goto-start": {
|
||||
description: "Go to timeline start",
|
||||
category: "navigation",
|
||||
},
|
||||
"goto-end": {
|
||||
description: "Go to timeline end",
|
||||
category: "navigation",
|
||||
},
|
||||
split: {
|
||||
description: "Split elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"split-left": {
|
||||
description: "Split and remove left",
|
||||
category: "editing",
|
||||
},
|
||||
"split-right": {
|
||||
description: "Split and remove right",
|
||||
category: "editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete selected elements",
|
||||
category: "editing",
|
||||
},
|
||||
"copy-selected": {
|
||||
description: "Copy selected elements",
|
||||
category: "editing",
|
||||
},
|
||||
"paste-copied": {
|
||||
description: "Paste elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-snapping": {
|
||||
description: "Toggle snapping",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-ripple-editing": {
|
||||
description: "Toggle ripple editing",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-source-audio": {
|
||||
description: "Extract or recover source audio",
|
||||
category: "editing",
|
||||
},
|
||||
"select-all": {
|
||||
description: "Select all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"cancel-interaction": {
|
||||
description: "Cancel current interaction",
|
||||
category: "controls",
|
||||
},
|
||||
"deselect-all": {
|
||||
description: "Deselect all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-muted-selected": {
|
||||
description: "Mute/unmute selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-visibility-selected": {
|
||||
description: "Show/hide selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-bookmark": {
|
||||
description: "Toggle bookmark at playhead",
|
||||
category: "timeline",
|
||||
},
|
||||
undo: {
|
||||
description: "Undo",
|
||||
category: "history",
|
||||
},
|
||||
redo: {
|
||||
description: "Redo",
|
||||
category: "history",
|
||||
},
|
||||
"remove-media-asset": {
|
||||
description: "Remove media asset",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetId: "string" },
|
||||
},
|
||||
"remove-media-assets": {
|
||||
description: "Remove media assets",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetIds: "string[]" },
|
||||
},
|
||||
} as const satisfies Record<string, TActionBaseDefinition>;
|
||||
|
||||
export type TAction = keyof typeof ACTIONS;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS = {
|
||||
"toggle-play": ["space", "k"],
|
||||
"seek-forward": ["l"],
|
||||
"seek-backward": ["j"],
|
||||
"frame-step-forward": ["right"],
|
||||
"frame-step-backward": ["left"],
|
||||
"jump-forward": ["shift+right"],
|
||||
"jump-backward": ["shift+left"],
|
||||
"goto-start": ["home", "enter"],
|
||||
"goto-end": ["end"],
|
||||
split: ["s"],
|
||||
"split-left": ["q"],
|
||||
"split-right": ["w"],
|
||||
"delete-selected": ["backspace", "delete"],
|
||||
"copy-selected": ["ctrl+c"],
|
||||
"paste-copied": ["ctrl+v"],
|
||||
"toggle-snapping": ["n"],
|
||||
"select-all": ["ctrl+a"],
|
||||
"cancel-interaction": ["escape"],
|
||||
"duplicate-selected": ["ctrl+d"],
|
||||
undo: ["ctrl+z"],
|
||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
||||
Record<TAction, readonly ShortcutKey[]>
|
||||
> = ACTION_DEFAULT_SHORTCUTS;
|
||||
|
||||
export function getActionDefinition({
|
||||
action,
|
||||
}: {
|
||||
action: TAction;
|
||||
}): TActionDefinition {
|
||||
return {
|
||||
...ACTIONS[action],
|
||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultShortcuts(): KeybindingConfig {
|
||||
const shortcuts: KeybindingConfig = {};
|
||||
|
||||
for (const [action, defaultShortcuts] of Object.entries(
|
||||
ACTION_DEFAULT_SHORTCUTS,
|
||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
||||
for (const shortcut of defaultShortcuts) {
|
||||
shortcuts[shortcut] = action;
|
||||
}
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
import type {
|
||||
KeybindingConfig,
|
||||
ShortcutKey,
|
||||
} from "@/lib/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "./types";
|
||||
|
||||
export type TActionCategory =
|
||||
| "playback"
|
||||
| "navigation"
|
||||
| "editing"
|
||||
| "selection"
|
||||
| "history"
|
||||
| "timeline"
|
||||
| "controls"
|
||||
| "assets";
|
||||
|
||||
export interface TActionBaseDefinition {
|
||||
description: string;
|
||||
category: TActionCategory;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TActionDefinition extends TActionBaseDefinition {
|
||||
defaultShortcuts?: readonly ShortcutKey[];
|
||||
}
|
||||
|
||||
export const ACTIONS = {
|
||||
"toggle-play": {
|
||||
description: "Play/Pause",
|
||||
category: "playback",
|
||||
},
|
||||
"stop-playback": {
|
||||
description: "Stop playback",
|
||||
category: "playback",
|
||||
},
|
||||
"seek-forward": {
|
||||
description: "Seek forward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"seek-backward": {
|
||||
description: "Seek backward 1 second",
|
||||
category: "playback",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"frame-step-forward": {
|
||||
description: "Frame step forward",
|
||||
category: "navigation",
|
||||
},
|
||||
"frame-step-backward": {
|
||||
description: "Frame step backward",
|
||||
category: "navigation",
|
||||
},
|
||||
"jump-forward": {
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"jump-backward": {
|
||||
description: "Jump backward 5 seconds",
|
||||
category: "navigation",
|
||||
args: { seconds: "number" },
|
||||
},
|
||||
"goto-start": {
|
||||
description: "Go to timeline start",
|
||||
category: "navigation",
|
||||
},
|
||||
"goto-end": {
|
||||
description: "Go to timeline end",
|
||||
category: "navigation",
|
||||
},
|
||||
split: {
|
||||
description: "Split elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"split-left": {
|
||||
description: "Split and remove left",
|
||||
category: "editing",
|
||||
},
|
||||
"split-right": {
|
||||
description: "Split and remove right",
|
||||
category: "editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete current selection",
|
||||
category: "editing",
|
||||
},
|
||||
"copy-selected": {
|
||||
description: "Copy selected elements",
|
||||
category: "editing",
|
||||
},
|
||||
"paste-copied": {
|
||||
description: "Paste elements at playhead",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-snapping": {
|
||||
description: "Toggle snapping",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-ripple-editing": {
|
||||
description: "Toggle ripple editing",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-source-audio": {
|
||||
description: "Extract or recover source audio",
|
||||
category: "editing",
|
||||
},
|
||||
"select-all": {
|
||||
description: "Select all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"cancel-interaction": {
|
||||
description: "Cancel current interaction",
|
||||
category: "controls",
|
||||
},
|
||||
"deselect-all": {
|
||||
description: "Deselect all elements",
|
||||
category: "selection",
|
||||
},
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-muted-selected": {
|
||||
description: "Mute/unmute selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-elements-visibility-selected": {
|
||||
description: "Show/hide selected elements",
|
||||
category: "selection",
|
||||
},
|
||||
"toggle-bookmark": {
|
||||
description: "Toggle bookmark at playhead",
|
||||
category: "timeline",
|
||||
},
|
||||
undo: {
|
||||
description: "Undo",
|
||||
category: "history",
|
||||
},
|
||||
redo: {
|
||||
description: "Redo",
|
||||
category: "history",
|
||||
},
|
||||
"remove-media-asset": {
|
||||
description: "Remove media asset",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetId: "string" },
|
||||
},
|
||||
"remove-media-assets": {
|
||||
description: "Remove media assets",
|
||||
category: "assets",
|
||||
args: { projectId: "string", assetIds: "string[]" },
|
||||
},
|
||||
} as const satisfies Record<string, TActionBaseDefinition>;
|
||||
|
||||
export type TAction = keyof typeof ACTIONS;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS = {
|
||||
"toggle-play": ["space", "k"],
|
||||
"seek-forward": ["l"],
|
||||
"seek-backward": ["j"],
|
||||
"frame-step-forward": ["right"],
|
||||
"frame-step-backward": ["left"],
|
||||
"jump-forward": ["shift+right"],
|
||||
"jump-backward": ["shift+left"],
|
||||
"goto-start": ["home", "enter"],
|
||||
"goto-end": ["end"],
|
||||
split: ["s"],
|
||||
"split-left": ["q"],
|
||||
"split-right": ["w"],
|
||||
"delete-selected": ["backspace", "delete"],
|
||||
"copy-selected": ["ctrl+c"],
|
||||
"paste-copied": ["ctrl+v"],
|
||||
"toggle-snapping": ["n"],
|
||||
"select-all": ["ctrl+a"],
|
||||
"cancel-interaction": ["escape"],
|
||||
"duplicate-selected": ["ctrl+d"],
|
||||
undo: ["ctrl+z"],
|
||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
||||
Record<TAction, readonly ShortcutKey[]>
|
||||
> = ACTION_DEFAULT_SHORTCUTS;
|
||||
|
||||
export function getActionDefinition({
|
||||
action,
|
||||
}: {
|
||||
action: TAction;
|
||||
}): TActionDefinition {
|
||||
return {
|
||||
...ACTIONS[action],
|
||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultShortcuts(): KeybindingConfig {
|
||||
const shortcuts: KeybindingConfig = {};
|
||||
|
||||
for (const [action, defaultShortcuts] of Object.entries(
|
||||
ACTION_DEFAULT_SHORTCUTS,
|
||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
||||
for (const shortcut of defaultShortcuts) {
|
||||
shortcuts[shortcut] = action;
|
||||
}
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,8 @@ title: "Title"
|
|||
description: "Description"
|
||||
changes:
|
||||
- type: new
|
||||
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
||||
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
||||
- type: new
|
||||
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
|
||||
- type: fixed
|
||||
text: "Preview panning now keeps working while the Masks tab is open."
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
import type { EditorSelectionPatch } from "@/lib/selection/editor-selection";
|
||||
|
||||
export interface CommandResult {
|
||||
select?: ElementRef[];
|
||||
selection?: EditorSelectionPatch;
|
||||
}
|
||||
|
||||
export abstract class Command {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,12 @@ export class DeleteElementsCommand extends Command {
|
|||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
return {
|
||||
select: [],
|
||||
selection: {
|
||||
selectedElements: [],
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,18 +102,25 @@ export class InsertElementCommand extends Command {
|
|||
});
|
||||
}
|
||||
|
||||
if (asset?.type === "video" && asset?.fps) {
|
||||
editor.project.updateSettings({
|
||||
settings: { fps: floatToFrameRate(asset.fps) },
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
if (asset?.type === "video" && asset?.fps) {
|
||||
editor.project.updateSettings({
|
||||
settings: { fps: floatToFrameRate(asset.fps) },
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
return {
|
||||
select: [{ trackId: targetTrackId, elementId: this.elementId }],
|
||||
selection: {
|
||||
selectedElements: [
|
||||
{ trackId: targetTrackId, elementId: this.elementId },
|
||||
],
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +180,10 @@ export class InsertElementCommand extends Command {
|
|||
|
||||
if (element.type === "graphic") {
|
||||
registerDefaultGraphics();
|
||||
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
|
||||
if (
|
||||
!element.definitionId ||
|
||||
!graphicsRegistry.has(element.definitionId)
|
||||
) {
|
||||
console.error("Graphic element must have a valid definitionId");
|
||||
return false;
|
||||
}
|
||||
|
|
@ -236,8 +246,8 @@ export class InsertElementCommand extends Command {
|
|||
const targetTrack =
|
||||
tracks.main.id === placement.trackId
|
||||
? tracks.main
|
||||
: tracks.overlay.find((track) => track.id === placement.trackId) ??
|
||||
tracks.audio.find((track) => track.id === placement.trackId);
|
||||
: (tracks.overlay.find((track) => track.id === placement.trackId) ??
|
||||
tracks.audio.find((track) => track.id === placement.trackId));
|
||||
if (!targetTrack) {
|
||||
console.error("Track not found:", placement.trackId);
|
||||
return null;
|
||||
|
|
@ -257,8 +267,7 @@ export class InsertElementCommand extends Command {
|
|||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime ?? element.startTime,
|
||||
startTime: placementResult.adjustedStartTime ?? element.startTime,
|
||||
}
|
||||
: element;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
|
||||
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
|
||||
export { RemoveMaskCommand } from "./remove-mask";
|
||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,26 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
findClosestPointOnCustomMaskSegment,
|
||||
getCustomMaskClosedStateAfterPointRemoval,
|
||||
insertPointIntoCustomMaskSegment,
|
||||
removeCustomMaskPoints,
|
||||
} from "@/lib/masks/custom-path";
|
||||
import {
|
||||
appendPointToCustomMask,
|
||||
customMaskDefinition,
|
||||
insertPointOnCustomMaskSegment,
|
||||
} from "@/lib/masks/definitions/custom";
|
||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||
import { textMaskDefinition } from "@/lib/masks/definitions/text";
|
||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
import type {
|
||||
CustomMaskParams,
|
||||
RectangleMaskParams,
|
||||
SplitMaskParams,
|
||||
TextMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
|
||||
const bounds: ElementBounds = {
|
||||
cx: 200,
|
||||
|
|
@ -58,6 +75,78 @@ function buildRectangleParams(
|
|||
};
|
||||
}
|
||||
|
||||
function buildTextMaskParams(
|
||||
overrides: Partial<TextMaskParams> = {},
|
||||
): TextMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
content: "Mask",
|
||||
fontSize: 15,
|
||||
fontFamily: "Arial",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomMaskParams(
|
||||
overrides: Partial<CustomMaskParams> = {},
|
||||
): CustomMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
path: [
|
||||
{
|
||||
id: "a",
|
||||
x: -0.2,
|
||||
y: -0.1,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
x: 0.2,
|
||||
y: -0.1,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
x: 0,
|
||||
y: 0.2,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
],
|
||||
closed: true,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSegment(
|
||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||
|
|
@ -234,4 +323,165 @@ describe("mask snapping", () => {
|
|||
expect(result.params.height).toBe(0.5);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
|
||||
test("snaps text mask movement using intrinsic text bounds", () => {
|
||||
const params = buildTextMaskParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
});
|
||||
const result = textMaskDefinition.interaction.snap?.({
|
||||
handleId: "position",
|
||||
startParams: params,
|
||||
proposedParams: params,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result?.params.centerX).toBe(0);
|
||||
expect(result?.params.centerY).toBe(0);
|
||||
expect(result?.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps custom mask movement using path geometry bounds", () => {
|
||||
const params = buildCustomMaskParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
});
|
||||
const result = customMaskDefinition.interaction.snap?.({
|
||||
handleId: "position",
|
||||
startParams: params,
|
||||
proposedParams: params,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result?.params.centerX).toBe(0);
|
||||
expect(result?.params.centerY).toBe(0);
|
||||
expect(result?.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("marks blank text masks inactive", () => {
|
||||
expect(
|
||||
textMaskDefinition.isActive?.(
|
||||
buildTextMaskParams({
|
||||
content: " ",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom mask creation", () => {
|
||||
test("anchors the first point at the click position", () => {
|
||||
const params = buildCustomMaskParams({
|
||||
path: [],
|
||||
closed: false,
|
||||
});
|
||||
const next = appendPointToCustomMask({
|
||||
params,
|
||||
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
|
||||
bounds,
|
||||
});
|
||||
|
||||
expect(next.centerX).toBeCloseTo(0.1);
|
||||
expect(next.centerY).toBeCloseTo(-0.1);
|
||||
expect(next.rotation).toBe(0);
|
||||
expect(next.scale).toBe(1);
|
||||
expect(next.path).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom mask point deletion", () => {
|
||||
test("removes the selected points by id", () => {
|
||||
const points = buildCustomMaskParams().path;
|
||||
const nextPoints = removeCustomMaskPoints({
|
||||
points,
|
||||
pointIds: ["b"],
|
||||
});
|
||||
|
||||
expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
test("reopens a closed path once fewer than three points remain", () => {
|
||||
const points = buildCustomMaskParams().path;
|
||||
const nextPoints = removeCustomMaskPoints({
|
||||
points,
|
||||
pointIds: ["c"],
|
||||
});
|
||||
|
||||
expect(
|
||||
getCustomMaskClosedStateAfterPointRemoval({
|
||||
wasClosed: true,
|
||||
remainingPointCount: nextPoints.length,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom mask point insertion", () => {
|
||||
test("finds the closest point on the clicked segment", () => {
|
||||
const params = buildCustomMaskParams();
|
||||
const points = params.path;
|
||||
const closestPoint = findClosestPointOnCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex: 0,
|
||||
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
closed: params.closed,
|
||||
});
|
||||
|
||||
expect(closestPoint).not.toBeNull();
|
||||
expect(closestPoint?.t).toBeCloseTo(0.5, 1);
|
||||
expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
|
||||
expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
|
||||
});
|
||||
|
||||
test("splits a segment into two segments at the insertion point", () => {
|
||||
const points = buildCustomMaskParams().path;
|
||||
const nextPoints = insertPointIntoCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex: 0,
|
||||
pointId: "new",
|
||||
t: 0.5,
|
||||
closed: true,
|
||||
});
|
||||
|
||||
expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
|
||||
expect(nextPoints[1]).toMatchObject({
|
||||
id: "new",
|
||||
x: 0,
|
||||
y: -0.1,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("builds updated custom mask params for a clicked segment", () => {
|
||||
const result = insertPointOnCustomMaskSegment({
|
||||
params: buildCustomMaskParams(),
|
||||
segmentIndex: 0,
|
||||
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
||||
bounds,
|
||||
pointId: "new",
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const nextPoints = result?.params.path ?? [];
|
||||
expect(nextPoints).toHaveLength(4);
|
||||
expect(nextPoints.some((point) => point.id === "new")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
}
|
||||
|
|
@ -1,270 +1,313 @@
|
|||
import {
|
||||
DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
|
||||
MIN_MASK_DIMENSION,
|
||||
} from "@/lib/masks/dimensions";
|
||||
import { computeFeatherUpdate } from "../param-update";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskDefaultContext,
|
||||
MaskParamUpdateArgs,
|
||||
RectangleMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import type {
|
||||
NumberParamDefinition,
|
||||
ParamDefinition,
|
||||
ParamValues,
|
||||
} from "@/lib/params";
|
||||
|
||||
const PERCENTAGE_DISPLAY: Pick<
|
||||
NumberParamDefinition,
|
||||
"displayMultiplier" | "step"
|
||||
> = {
|
||||
displayMultiplier: 100,
|
||||
step: 1,
|
||||
};
|
||||
|
||||
export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
|
||||
keyof RectangleMaskParams & string
|
||||
>[] = [
|
||||
{
|
||||
key: "centerX",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "centerY",
|
||||
label: "Y",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
default: 0.6,
|
||||
min: 1,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
default: 0.6,
|
||||
min: 1,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "scale",
|
||||
label: "Scale",
|
||||
type: "number",
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 500,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "strokeAlign",
|
||||
label: "Stroke Align",
|
||||
type: "select",
|
||||
default: "center",
|
||||
options: [
|
||||
{ value: "inside", label: "Inside" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "outside", label: "Outside" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getDefaultBaseMaskParams(): BaseMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
};
|
||||
}
|
||||
|
||||
export function getStrokeOffset({
|
||||
strokeAlign,
|
||||
strokeWidth,
|
||||
}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
|
||||
if (strokeAlign === "inside") {
|
||||
return -(strokeWidth / 2);
|
||||
}
|
||||
|
||||
if (strokeAlign === "outside") {
|
||||
return strokeWidth / 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getDefaultSquareMaskParams({
|
||||
elementSize,
|
||||
}: MaskDefaultContext): RectangleMaskParams {
|
||||
const absWidth = Math.abs(elementSize?.width ?? 0);
|
||||
const absHeight = Math.abs(elementSize?.height ?? 0);
|
||||
const shortSide = Math.min(absWidth, absHeight);
|
||||
const squareSide =
|
||||
shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
|
||||
const width =
|
||||
absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
||||
const height =
|
||||
absHeight > 0
|
||||
? squareSide / absHeight
|
||||
: DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
||||
|
||||
return {
|
||||
...getDefaultBaseMaskParams(),
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width,
|
||||
height,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBoxLikeGeometry({
|
||||
params,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
params: RectangleMaskParams;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
return {
|
||||
centerX: width / 2 + params.centerX * width,
|
||||
centerY: height / 2 + params.centerY * height,
|
||||
maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
|
||||
maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
|
||||
rotationRad: (params.rotation * Math.PI) / 180,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeBoxMaskParamUpdate({
|
||||
handleId,
|
||||
startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
bounds,
|
||||
}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues {
|
||||
if (handleId === "position") {
|
||||
return {
|
||||
centerX: startParams.centerX + deltaX / bounds.width,
|
||||
centerY: startParams.centerY + deltaY / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
const newRotation = (startParams.rotation + currentAngle) % 360;
|
||||
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
|
||||
}
|
||||
|
||||
if (handleId === "feather") {
|
||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||
return computeFeatherUpdate({
|
||||
startFeather: startParams.feather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX: -Math.sin(angleRad),
|
||||
directionY: Math.cos(angleRad),
|
||||
});
|
||||
}
|
||||
|
||||
const halfWidth = startParams.width * bounds.width;
|
||||
const halfHeight = startParams.height * bounds.height;
|
||||
|
||||
if (handleId === "right" || handleId === "left") {
|
||||
const sign = handleId === "right" ? 1 : -1;
|
||||
return {
|
||||
width: Math.max(
|
||||
MIN_MASK_DIMENSION,
|
||||
startParams.width + (sign * deltaX * 2) / bounds.width,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "bottom" || handleId === "top") {
|
||||
const sign = handleId === "bottom" ? 1 : -1;
|
||||
return {
|
||||
height: Math.max(
|
||||
MIN_MASK_DIMENSION,
|
||||
startParams.height + (sign * deltaY * 2) / bounds.height,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
handleId === "top-left" ||
|
||||
handleId === "top-right" ||
|
||||
handleId === "bottom-left" ||
|
||||
handleId === "bottom-right"
|
||||
) {
|
||||
const signX = handleId.includes("right") ? 1 : -1;
|
||||
const signY = handleId.includes("bottom") ? 1 : -1;
|
||||
const distance = Math.sqrt(
|
||||
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
|
||||
);
|
||||
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
||||
const scale = originalDistance > 0 ? distance / originalDistance : 1;
|
||||
return {
|
||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
|
||||
height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
|
||||
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
||||
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
|
||||
return {
|
||||
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function rotatePoint({
|
||||
x,
|
||||
y,
|
||||
centerX,
|
||||
centerY,
|
||||
rotationRad,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotationRad: number;
|
||||
}) {
|
||||
const dx = x - centerX;
|
||||
const dy = y - centerY;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
|
||||
return {
|
||||
x: centerX + dx * cos - dy * sin,
|
||||
y: centerY + dx * sin + dy * cos,
|
||||
};
|
||||
}
|
||||
import {
|
||||
DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
|
||||
MIN_MASK_DIMENSION,
|
||||
} from "@/lib/masks/dimensions";
|
||||
import { computeFeatherUpdate } from "../param-update";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskDefaultContext,
|
||||
MaskFeatures,
|
||||
MaskInteractionDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
RectangleMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import type { NumberParamDefinition, ParamDefinition } from "@/lib/params";
|
||||
import {
|
||||
getBoxMaskHandlePositions,
|
||||
getBoxMaskOverlays,
|
||||
} from "@/lib/masks/handle-positions";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
|
||||
const PERCENTAGE_DISPLAY: Pick<
|
||||
NumberParamDefinition,
|
||||
"displayMultiplier" | "step"
|
||||
> = {
|
||||
displayMultiplier: 100,
|
||||
step: 1,
|
||||
};
|
||||
|
||||
export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
|
||||
keyof RectangleMaskParams & string
|
||||
>[] = [
|
||||
{
|
||||
key: "centerX",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "centerY",
|
||||
label: "Y",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
default: 0.6,
|
||||
min: 1,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
default: 0.6,
|
||||
min: 1,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "scale",
|
||||
label: "Scale",
|
||||
type: "number",
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 500,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "strokeAlign",
|
||||
label: "Stroke Align",
|
||||
type: "select",
|
||||
default: "center",
|
||||
options: [
|
||||
{ value: "inside", label: "Inside" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "outside", label: "Outside" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getDefaultBaseMaskParams(): BaseMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
};
|
||||
}
|
||||
|
||||
export function getStrokeOffset({
|
||||
strokeAlign,
|
||||
strokeWidth,
|
||||
}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
|
||||
if (strokeAlign === "inside") {
|
||||
return -(strokeWidth / 2);
|
||||
}
|
||||
|
||||
if (strokeAlign === "outside") {
|
||||
return strokeWidth / 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getDefaultSquareMaskParams({
|
||||
elementSize,
|
||||
}: MaskDefaultContext): RectangleMaskParams {
|
||||
const absWidth = Math.abs(elementSize?.width ?? 0);
|
||||
const absHeight = Math.abs(elementSize?.height ?? 0);
|
||||
const shortSide = Math.min(absWidth, absHeight);
|
||||
const squareSide =
|
||||
shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
|
||||
const width =
|
||||
absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
||||
const height =
|
||||
absHeight > 0
|
||||
? squareSide / absHeight
|
||||
: DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
||||
|
||||
return {
|
||||
...getDefaultBaseMaskParams(),
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width,
|
||||
height,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBoxLikeGeometry({
|
||||
params,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
params: RectangleMaskParams;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
return {
|
||||
centerX: width / 2 + params.centerX * width,
|
||||
centerY: height / 2 + params.centerY * height,
|
||||
maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
|
||||
maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
|
||||
rotationRad: (params.rotation * Math.PI) / 180,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBoxMaskInteraction({
|
||||
sizeMode,
|
||||
buildOverlayPath,
|
||||
showBoundingBox = true,
|
||||
}: {
|
||||
sizeMode: MaskFeatures["sizeMode"];
|
||||
buildOverlayPath?: (args: { width: number; height: number }) => string;
|
||||
showBoundingBox?: boolean;
|
||||
}): MaskInteractionDefinition<RectangleMaskParams> {
|
||||
return {
|
||||
getInteraction({ params, bounds, displayScale, scaleX, scaleY }) {
|
||||
return {
|
||||
handles: getBoxMaskHandlePositions({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
rotation: params.rotation,
|
||||
feather: params.feather,
|
||||
sizeMode,
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
overlays: getBoxMaskOverlays({
|
||||
params,
|
||||
bounds,
|
||||
pathData: buildOverlayPath?.({
|
||||
width: params.width * bounds.width * scaleX,
|
||||
height: params.height * bounds.height * scaleY,
|
||||
}),
|
||||
showBoundingBox,
|
||||
}),
|
||||
};
|
||||
},
|
||||
snap(args) {
|
||||
return snapMaskInteraction(args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function computeBoxMaskParamUpdate({
|
||||
handleId,
|
||||
startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
bounds,
|
||||
}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
|
||||
if (handleId === "position") {
|
||||
return {
|
||||
centerX: startParams.centerX + deltaX / bounds.width,
|
||||
centerY: startParams.centerY + deltaY / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
const newRotation = (startParams.rotation + currentAngle) % 360;
|
||||
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
|
||||
}
|
||||
|
||||
if (handleId === "feather") {
|
||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||
return computeFeatherUpdate({
|
||||
startFeather: startParams.feather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX: -Math.sin(angleRad),
|
||||
directionY: Math.cos(angleRad),
|
||||
});
|
||||
}
|
||||
|
||||
const halfWidth = startParams.width * bounds.width;
|
||||
const halfHeight = startParams.height * bounds.height;
|
||||
|
||||
if (handleId === "right" || handleId === "left") {
|
||||
const sign = handleId === "right" ? 1 : -1;
|
||||
return {
|
||||
width: Math.max(
|
||||
MIN_MASK_DIMENSION,
|
||||
startParams.width + (sign * deltaX * 2) / bounds.width,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "bottom" || handleId === "top") {
|
||||
const sign = handleId === "bottom" ? 1 : -1;
|
||||
return {
|
||||
height: Math.max(
|
||||
MIN_MASK_DIMENSION,
|
||||
startParams.height + (sign * deltaY * 2) / bounds.height,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
handleId === "top-left" ||
|
||||
handleId === "top-right" ||
|
||||
handleId === "bottom-left" ||
|
||||
handleId === "bottom-right"
|
||||
) {
|
||||
const signX = handleId.includes("right") ? 1 : -1;
|
||||
const signY = handleId.includes("bottom") ? 1 : -1;
|
||||
const distance = Math.sqrt(
|
||||
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
|
||||
);
|
||||
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
||||
const scale = originalDistance > 0 ? distance / originalDistance : 1;
|
||||
return {
|
||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
|
||||
height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
|
||||
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
||||
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
|
||||
return {
|
||||
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function rotatePoint({
|
||||
x,
|
||||
y,
|
||||
centerX,
|
||||
centerY,
|
||||
rotationRad,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotationRad: number;
|
||||
}) {
|
||||
const dx = x - centerX;
|
||||
const dy = y - centerY;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
|
||||
return {
|
||||
x: centerX + dx * cos - dy * sin,
|
||||
y: centerY + dx * sin + dy * cos,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
} from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getDefaultBaseMaskParams,
|
||||
getStrokeOffset,
|
||||
|
|
@ -73,16 +74,18 @@ function buildBandPath({
|
|||
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "cinematic-bars",
|
||||
name: "Cinematic Bars",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M 0,0 H ${width} V ${height} H 0 Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "height-only",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "height-only",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M 0,0 H ${width} V ${height} H 0 Z`;
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "cinematic-bars",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
|
|
@ -47,16 +48,18 @@ function buildDiamondPath({
|
|||
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "diamond",
|
||||
name: "Diamond",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "diamond",
|
||||
|
|
|
|||
|
|
@ -1,72 +1,75 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
getStrokeOffset,
|
||||
} from "./box-like";
|
||||
|
||||
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "ellipse",
|
||||
name: "Ellipse",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const rx = Math.max((width - 1) / 2, 0);
|
||||
const ry = Math.max((height - 1) / 2, 0);
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "ellipse",
|
||||
params: getDefaultSquareMaskParams(context),
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||
renderer: {
|
||||
buildPath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
const path = new Path2D();
|
||||
path.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
maskWidth / 2,
|
||||
maskHeight / 2,
|
||||
rotationRad,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
return path;
|
||||
},
|
||||
buildStrokePath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
const offset = getStrokeOffset({
|
||||
strokeAlign: params.strokeAlign,
|
||||
strokeWidth: params.strokeWidth,
|
||||
});
|
||||
const path = new Path2D();
|
||||
path.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
Math.max(1, maskWidth / 2 + offset),
|
||||
Math.max(1, maskHeight / 2 + offset),
|
||||
rotationRad,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
return path;
|
||||
},
|
||||
},
|
||||
};
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
getStrokeOffset,
|
||||
} from "./box-like";
|
||||
|
||||
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "ellipse",
|
||||
name: "Ellipse",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const rx = Math.max((width - 1) / 2, 0);
|
||||
const ry = Math.max((height - 1) / 2, 0);
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "ellipse",
|
||||
params: getDefaultSquareMaskParams(context),
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||
renderer: {
|
||||
buildPath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
const path = new Path2D();
|
||||
path.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
maskWidth / 2,
|
||||
maskHeight / 2,
|
||||
rotationRad,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
return path;
|
||||
},
|
||||
buildStrokePath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
const offset = getStrokeOffset({
|
||||
strokeAlign: params.strokeAlign,
|
||||
strokeWidth: params.strokeWidth,
|
||||
});
|
||||
const path = new Path2D();
|
||||
path.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
Math.max(1, maskWidth / 2 + offset),
|
||||
Math.max(1, maskHeight / 2 + offset),
|
||||
rotationRad,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
return path;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
|
|
@ -36,23 +37,23 @@ function buildHeartPath({
|
|||
rotationRad,
|
||||
});
|
||||
|
||||
const start = toPoint({ localX: 0, localY: -halfHeight * 0.2 });
|
||||
const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
|
||||
const rightControl1 = toPoint({
|
||||
localX: halfWidth,
|
||||
localY: -halfHeight * 0.95,
|
||||
localY: -halfHeight * 1.225,
|
||||
});
|
||||
const rightControl2 = toPoint({
|
||||
localX: halfWidth,
|
||||
localY: halfHeight * 0.15,
|
||||
localY: -halfHeight * 0.125,
|
||||
});
|
||||
const bottom = toPoint({ localX: 0, localY: halfHeight });
|
||||
const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
|
||||
const leftControl1 = toPoint({
|
||||
localX: -halfWidth,
|
||||
localY: halfHeight * 0.15,
|
||||
localY: -halfHeight * 0.125,
|
||||
});
|
||||
const leftControl2 = toPoint({
|
||||
localX: -halfWidth,
|
||||
localY: -halfHeight * 0.95,
|
||||
localY: -halfHeight * 1.225,
|
||||
});
|
||||
|
||||
const path = new Path2D();
|
||||
|
|
@ -80,25 +81,27 @@ function buildHeartPath({
|
|||
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "heart",
|
||||
name: "Heart",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return [
|
||||
`M ${cx},${cy - halfHeight * 0.2}`,
|
||||
`C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`,
|
||||
`C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return [
|
||||
`M ${cx},${cy - halfHeight * 0.475}`,
|
||||
`C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
|
||||
`C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "heart",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
|
||||
import { masksRegistry, type MaskIconProps } from "../registry";
|
||||
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
|
||||
import { customMaskDefinition } from "./custom";
|
||||
import { diamondMaskDefinition } from "./diamond";
|
||||
import { ellipseMaskDefinition } from "./ellipse";
|
||||
import { heartMaskDefinition } from "./heart";
|
||||
import { rectangleMaskDefinition } from "./rectangle";
|
||||
import { splitMaskDefinition } from "./split";
|
||||
import { starMaskDefinition } from "./star";
|
||||
import { textMaskDefinition } from "./text";
|
||||
import {
|
||||
MinusSignIcon,
|
||||
PanelRightDashedIcon,
|
||||
|
|
@ -15,6 +17,7 @@ import {
|
|||
FavouriteIcon,
|
||||
DiamondIcon,
|
||||
StarsIcon,
|
||||
TextFontIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
||||
function registerDefaultMask<TParams extends BaseMaskParams>({
|
||||
|
|
@ -60,4 +63,12 @@ export function registerDefaultMasks(): void {
|
|||
definition: starMaskDefinition,
|
||||
icon: { icon: StarsIcon },
|
||||
});
|
||||
registerDefaultMask({
|
||||
definition: textMaskDefinition,
|
||||
icon: { icon: TextFontIcon },
|
||||
});
|
||||
registerDefaultMask({
|
||||
definition: customMaskDefinition,
|
||||
icon: { icon: SquareIcon },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,97 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
getStrokeOffset,
|
||||
rotatePoint,
|
||||
} from "./box-like";
|
||||
|
||||
function buildRectanglePath({
|
||||
centerX,
|
||||
centerY,
|
||||
halfWidth,
|
||||
halfHeight,
|
||||
rotationRad,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
halfWidth: number;
|
||||
halfHeight: number;
|
||||
rotationRad: number;
|
||||
}): Path2D {
|
||||
const corners = [
|
||||
{ x: centerX - halfWidth, y: centerY - halfHeight },
|
||||
{ x: centerX + halfWidth, y: centerY - halfHeight },
|
||||
{ x: centerX + halfWidth, y: centerY + halfHeight },
|
||||
{ x: centerX - halfWidth, y: centerY + halfHeight },
|
||||
].map((point) =>
|
||||
rotatePoint({
|
||||
...point,
|
||||
centerX,
|
||||
centerY,
|
||||
rotationRad,
|
||||
}),
|
||||
);
|
||||
|
||||
const path = new Path2D();
|
||||
path.moveTo(corners[0].x, corners[0].y);
|
||||
for (const corner of corners.slice(1)) {
|
||||
path.lineTo(corner.x, corner.y);
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
}
|
||||
|
||||
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "rectangle",
|
||||
name: "Rectangle",
|
||||
overlayShape: "box",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "rectangle",
|
||||
params: getDefaultSquareMaskParams(context),
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||
renderer: {
|
||||
buildPath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
return buildRectanglePath({
|
||||
centerX,
|
||||
centerY,
|
||||
halfWidth: maskWidth / 2,
|
||||
halfHeight: maskHeight / 2,
|
||||
rotationRad,
|
||||
});
|
||||
},
|
||||
buildStrokePath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
const offset = getStrokeOffset({
|
||||
strokeAlign: params.strokeAlign,
|
||||
strokeWidth: params.strokeWidth,
|
||||
});
|
||||
return buildRectanglePath({
|
||||
centerX,
|
||||
centerY,
|
||||
halfWidth: Math.max(1, maskWidth / 2 + offset),
|
||||
halfHeight: Math.max(1, maskHeight / 2 + offset),
|
||||
rotationRad,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
getStrokeOffset,
|
||||
rotatePoint,
|
||||
} from "./box-like";
|
||||
|
||||
function buildRectanglePath({
|
||||
centerX,
|
||||
centerY,
|
||||
halfWidth,
|
||||
halfHeight,
|
||||
rotationRad,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
halfWidth: number;
|
||||
halfHeight: number;
|
||||
rotationRad: number;
|
||||
}): Path2D {
|
||||
const corners = [
|
||||
{ x: centerX - halfWidth, y: centerY - halfHeight },
|
||||
{ x: centerX + halfWidth, y: centerY - halfHeight },
|
||||
{ x: centerX + halfWidth, y: centerY + halfHeight },
|
||||
{ x: centerX - halfWidth, y: centerY + halfHeight },
|
||||
].map((point) =>
|
||||
rotatePoint({
|
||||
...point,
|
||||
centerX,
|
||||
centerY,
|
||||
rotationRad,
|
||||
}),
|
||||
);
|
||||
|
||||
const path = new Path2D();
|
||||
path.moveTo(corners[0].x, corners[0].y);
|
||||
for (const corner of corners.slice(1)) {
|
||||
path.lineTo(corner.x, corner.y);
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
}
|
||||
|
||||
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "rectangle",
|
||||
name: "Rectangle",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "rectangle",
|
||||
params: getDefaultSquareMaskParams(context),
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||
renderer: {
|
||||
buildPath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
return buildRectanglePath({
|
||||
centerX,
|
||||
centerY,
|
||||
halfWidth: maskWidth / 2,
|
||||
halfHeight: maskHeight / 2,
|
||||
rotationRad,
|
||||
});
|
||||
},
|
||||
buildStrokePath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as RectangleMaskParams;
|
||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||
getBoxLikeGeometry({ params, width, height });
|
||||
const offset = getStrokeOffset({
|
||||
strokeAlign: params.strokeAlign,
|
||||
strokeWidth: params.strokeWidth,
|
||||
});
|
||||
return buildRectanglePath({
|
||||
centerX,
|
||||
centerY,
|
||||
halfWidth: Math.max(1, maskWidth / 2 + offset),
|
||||
halfHeight: Math.max(1, maskHeight / 2 + offset),
|
||||
rotationRad,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,347 +1,382 @@
|
|||
import { computeFeatherUpdate } from "../param-update";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
MaskDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
SplitMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
|
||||
|
||||
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
|
||||
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
|
||||
// exactly on the split line, which produces spurious midpoint vertices.
|
||||
const NORMAL_SNAP_EPSILON = 1e-10;
|
||||
|
||||
// Guards against collinear vertices from float noise at canvas edges.
|
||||
const MIN_POLYGON_AREA_PX = 0.5;
|
||||
const INTERSECTION_EPSILON = 1e-6;
|
||||
|
||||
function polygonArea({ vertices }: { vertices: [number, number][] }): number {
|
||||
let area = 0;
|
||||
for (let i = 0; i < vertices.length; i++) {
|
||||
const [x1, y1] = vertices[i];
|
||||
const [x2, y2] = vertices[(i + 1) % vertices.length];
|
||||
area += x1 * y2 - x2 * y1;
|
||||
}
|
||||
return Math.abs(area) * 0.5;
|
||||
}
|
||||
|
||||
function splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): { normalX: number; normalY: number; lineX: number; lineY: number } {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const normalX =
|
||||
Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
|
||||
const normalY =
|
||||
Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
|
||||
const lineX = width / 2 + centerX * width;
|
||||
const lineY = height / 2 + centerY * height;
|
||||
return { normalX, normalY, lineX, lineY };
|
||||
}
|
||||
|
||||
function pointsEqual(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
|
||||
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function getSplitMaskStrokeSegment({
|
||||
resolvedParams,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}): [{ x: number; y: number }, { x: number; y: number }] | null {
|
||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const edges: [number, number, number, number][] = [
|
||||
[0, 0, width, 0],
|
||||
[width, 0, width, height],
|
||||
[width, height, 0, height],
|
||||
[0, height, 0, 0],
|
||||
];
|
||||
const intersections: { x: number; y: number }[] = [];
|
||||
|
||||
for (const [x1, y1, x2, y2] of edges) {
|
||||
const hit = lineEdgeIntersection({
|
||||
lineX,
|
||||
lineY,
|
||||
normalX,
|
||||
normalY,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
});
|
||||
|
||||
if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
intersections.push(hit);
|
||||
}
|
||||
|
||||
if (intersections.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [intersections[0], intersections[1]];
|
||||
}
|
||||
|
||||
function computeSplitMaskParamUpdate({
|
||||
handleId,
|
||||
startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
startCanvasX,
|
||||
startCanvasY,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues {
|
||||
if (handleId === "position") {
|
||||
const rawX = startParams.centerX + deltaX / bounds.width;
|
||||
const rawY = startParams.centerY + deltaY / bounds.height;
|
||||
|
||||
const minX = -bounds.cx / bounds.width;
|
||||
const maxX = (canvasSize.width - bounds.cx) / bounds.width;
|
||||
const minY = -bounds.cy / bounds.height;
|
||||
const maxY = (canvasSize.height - bounds.cy) / bounds.height;
|
||||
|
||||
return {
|
||||
centerX: Math.max(minX, Math.min(maxX, rawX)),
|
||||
centerY: Math.max(minY, Math.min(maxY, rawY)),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "feather") {
|
||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||
return computeFeatherUpdate({
|
||||
startFeather: startParams.feather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX: -Math.cos(angleRad),
|
||||
directionY: -Math.sin(angleRad),
|
||||
});
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const pivotX = bounds.cx + startParams.centerX * bounds.width;
|
||||
const pivotY = bounds.cy + startParams.centerY * bounds.height;
|
||||
const startAngle =
|
||||
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
|
||||
Math.PI;
|
||||
const currentAngle =
|
||||
(Math.atan2(
|
||||
startCanvasY + deltaY - pivotY,
|
||||
startCanvasX + deltaX - pivotX,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
|
||||
let deltaAngle = currentAngle - startAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
return {
|
||||
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
|
||||
type: "split",
|
||||
name: "Split",
|
||||
overlayShape: "line",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "none",
|
||||
},
|
||||
buildDefault() {
|
||||
return {
|
||||
type: "split",
|
||||
params: {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeSplitMaskParamUpdate,
|
||||
params: [
|
||||
{
|
||||
key: "centerX",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
displayMultiplier: 100,
|
||||
},
|
||||
{
|
||||
key: "centerY",
|
||||
label: "Y",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
displayMultiplier: 100,
|
||||
},
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
],
|
||||
renderer: {
|
||||
renderMask({ resolvedParams, ctx, width, height, feather }) {
|
||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
|
||||
const featherHalf = feather / 2;
|
||||
const gradient = ctx.createLinearGradient(
|
||||
lineX - normalX * featherHalf,
|
||||
lineY - normalY * featherHalf,
|
||||
lineX + normalX * featherHalf,
|
||||
lineY + normalY * featherHalf,
|
||||
);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||||
gradient.addColorStop(1, "white");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
},
|
||||
|
||||
buildPath({ resolvedParams, width, height }) {
|
||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const edges: [number, number, number, number][] = [
|
||||
[0, 0, width, 0],
|
||||
[width, 0, width, height],
|
||||
[width, height, 0, height],
|
||||
[0, height, 0, 0],
|
||||
];
|
||||
|
||||
const isInsideHalfPlane = (x: number, y: number) =>
|
||||
halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
|
||||
|
||||
const vertices: [number, number][] = [];
|
||||
for (const [x1, y1, x2, y2] of edges) {
|
||||
const isVertex1Inside = isInsideHalfPlane(x1, y1);
|
||||
const isVertex2Inside = isInsideHalfPlane(x2, y2);
|
||||
|
||||
if (isVertex1Inside && isVertex2Inside) {
|
||||
vertices.push([x2, y2]);
|
||||
} else if (isVertex1Inside && !isVertex2Inside) {
|
||||
const hit = lineEdgeIntersection({
|
||||
lineX,
|
||||
lineY,
|
||||
normalX,
|
||||
normalY,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
});
|
||||
if (hit) vertices.push([hit.x, hit.y]);
|
||||
} else if (!isVertex1Inside && isVertex2Inside) {
|
||||
const hit = lineEdgeIntersection({
|
||||
lineX,
|
||||
lineY,
|
||||
normalX,
|
||||
normalY,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
});
|
||||
if (hit) {
|
||||
vertices.push([hit.x, hit.y]);
|
||||
vertices.push([x2, y2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
vertices.length < 3 ||
|
||||
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
|
||||
) {
|
||||
return new Path2D();
|
||||
}
|
||||
|
||||
const path = new Path2D();
|
||||
path.moveTo(vertices[0][0], vertices[0][1]);
|
||||
for (let i = 1; i < vertices.length; i++) {
|
||||
path.lineTo(vertices[i][0], vertices[i][1]);
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
},
|
||||
buildStrokePath({ resolvedParams, width, height }) {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const path = new Path2D();
|
||||
|
||||
if (!segment) {
|
||||
return path;
|
||||
}
|
||||
|
||||
path.moveTo(segment[0].x, segment[0].y);
|
||||
path.lineTo(segment[1].x, segment[1].y);
|
||||
return path;
|
||||
},
|
||||
},
|
||||
};
|
||||
import { computeFeatherUpdate } from "../param-update";
|
||||
import type {
|
||||
MaskDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
SplitMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
|
||||
import {
|
||||
getLineMaskHandlePositions,
|
||||
getLineMaskOverlay,
|
||||
} from "@/lib/masks/handle-positions";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
|
||||
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
|
||||
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
|
||||
// exactly on the split line, which produces spurious midpoint vertices.
|
||||
const NORMAL_SNAP_EPSILON = 1e-10;
|
||||
|
||||
// Guards against collinear vertices from float noise at canvas edges.
|
||||
const MIN_POLYGON_AREA_PX = 0.5;
|
||||
const INTERSECTION_EPSILON = 1e-6;
|
||||
|
||||
function polygonArea({ vertices }: { vertices: [number, number][] }): number {
|
||||
let area = 0;
|
||||
for (let i = 0; i < vertices.length; i++) {
|
||||
const [x1, y1] = vertices[i];
|
||||
const [x2, y2] = vertices[(i + 1) % vertices.length];
|
||||
area += x1 * y2 - x2 * y1;
|
||||
}
|
||||
return Math.abs(area) * 0.5;
|
||||
}
|
||||
|
||||
function splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): { normalX: number; normalY: number; lineX: number; lineY: number } {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const normalX =
|
||||
Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
|
||||
const normalY =
|
||||
Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
|
||||
const lineX = width / 2 + centerX * width;
|
||||
const lineY = height / 2 + centerY * height;
|
||||
return { normalX, normalY, lineX, lineY };
|
||||
}
|
||||
|
||||
function pointsEqual(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
|
||||
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function getSplitMaskStrokeSegment({
|
||||
resolvedParams,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}): [{ x: number; y: number }, { x: number; y: number }] | null {
|
||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const edges: [number, number, number, number][] = [
|
||||
[0, 0, width, 0],
|
||||
[width, 0, width, height],
|
||||
[width, height, 0, height],
|
||||
[0, height, 0, 0],
|
||||
];
|
||||
const intersections: { x: number; y: number }[] = [];
|
||||
|
||||
for (const [x1, y1, x2, y2] of edges) {
|
||||
const hit = lineEdgeIntersection({
|
||||
lineX,
|
||||
lineY,
|
||||
normalX,
|
||||
normalY,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
});
|
||||
|
||||
if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
intersections.push(hit);
|
||||
}
|
||||
|
||||
if (intersections.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [intersections[0], intersections[1]];
|
||||
}
|
||||
|
||||
function computeSplitMaskParamUpdate({
|
||||
handleId,
|
||||
startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
startCanvasX,
|
||||
startCanvasY,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
|
||||
if (handleId === "position") {
|
||||
const rawX = startParams.centerX + deltaX / bounds.width;
|
||||
const rawY = startParams.centerY + deltaY / bounds.height;
|
||||
|
||||
const minX = -bounds.cx / bounds.width;
|
||||
const maxX = (canvasSize.width - bounds.cx) / bounds.width;
|
||||
const minY = -bounds.cy / bounds.height;
|
||||
const maxY = (canvasSize.height - bounds.cy) / bounds.height;
|
||||
|
||||
return {
|
||||
centerX: Math.max(minX, Math.min(maxX, rawX)),
|
||||
centerY: Math.max(minY, Math.min(maxY, rawY)),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "feather") {
|
||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||
return computeFeatherUpdate({
|
||||
startFeather: startParams.feather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX: -Math.cos(angleRad),
|
||||
directionY: -Math.sin(angleRad),
|
||||
});
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const pivotX = bounds.cx + startParams.centerX * bounds.width;
|
||||
const pivotY = bounds.cy + startParams.centerY * bounds.height;
|
||||
const startAngle =
|
||||
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
|
||||
Math.PI;
|
||||
const currentAngle =
|
||||
(Math.atan2(
|
||||
startCanvasY + deltaY - pivotY,
|
||||
startCanvasX + deltaX - pivotX,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
|
||||
let deltaAngle = currentAngle - startAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
return {
|
||||
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
|
||||
type: "split",
|
||||
name: "Split",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "none",
|
||||
},
|
||||
interaction: {
|
||||
getInteraction({
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
scaleX: _scaleX,
|
||||
scaleY: _scaleY,
|
||||
}) {
|
||||
return {
|
||||
handles: getLineMaskHandlePositions({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
feather: params.feather,
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
overlays: [
|
||||
getLineMaskOverlay({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
bounds,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
snap(args) {
|
||||
return snapMaskInteraction(args);
|
||||
},
|
||||
},
|
||||
buildDefault() {
|
||||
return {
|
||||
type: "split",
|
||||
params: {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeSplitMaskParamUpdate,
|
||||
params: [
|
||||
{
|
||||
key: "centerX",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
displayMultiplier: 100,
|
||||
},
|
||||
{
|
||||
key: "centerY",
|
||||
label: "Y",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
displayMultiplier: 100,
|
||||
},
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
],
|
||||
renderer: {
|
||||
renderMaskHandlesFeather: true,
|
||||
renderMask({ resolvedParams, ctx, width, height, feather }) {
|
||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
|
||||
const featherHalf = feather / 2;
|
||||
const gradient = ctx.createLinearGradient(
|
||||
lineX - normalX * featherHalf,
|
||||
lineY - normalY * featherHalf,
|
||||
lineX + normalX * featherHalf,
|
||||
lineY + normalY * featherHalf,
|
||||
);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||||
gradient.addColorStop(1, "white");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
},
|
||||
|
||||
buildPath({ resolvedParams, width, height }) {
|
||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const edges: [number, number, number, number][] = [
|
||||
[0, 0, width, 0],
|
||||
[width, 0, width, height],
|
||||
[width, height, 0, height],
|
||||
[0, height, 0, 0],
|
||||
];
|
||||
|
||||
const isInsideHalfPlane = (x: number, y: number) =>
|
||||
halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
|
||||
|
||||
const vertices: [number, number][] = [];
|
||||
for (const [x1, y1, x2, y2] of edges) {
|
||||
const isVertex1Inside = isInsideHalfPlane(x1, y1);
|
||||
const isVertex2Inside = isInsideHalfPlane(x2, y2);
|
||||
|
||||
if (isVertex1Inside && isVertex2Inside) {
|
||||
vertices.push([x2, y2]);
|
||||
} else if (isVertex1Inside && !isVertex2Inside) {
|
||||
const hit = lineEdgeIntersection({
|
||||
lineX,
|
||||
lineY,
|
||||
normalX,
|
||||
normalY,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
});
|
||||
if (hit) vertices.push([hit.x, hit.y]);
|
||||
} else if (!isVertex1Inside && isVertex2Inside) {
|
||||
const hit = lineEdgeIntersection({
|
||||
lineX,
|
||||
lineY,
|
||||
normalX,
|
||||
normalY,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
});
|
||||
if (hit) {
|
||||
vertices.push([hit.x, hit.y]);
|
||||
vertices.push([x2, y2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
vertices.length < 3 ||
|
||||
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
|
||||
) {
|
||||
return new Path2D();
|
||||
}
|
||||
|
||||
const path = new Path2D();
|
||||
path.moveTo(vertices[0][0], vertices[0][1]);
|
||||
for (let i = 1; i < vertices.length; i++) {
|
||||
path.lineTo(vertices[i][0], vertices[i][1]);
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
},
|
||||
buildStrokePath({ resolvedParams, width, height }) {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const path = new Path2D();
|
||||
|
||||
if (!segment) {
|
||||
return path;
|
||||
}
|
||||
|
||||
path.moveTo(segment[0].x, segment[0].y);
|
||||
path.lineTo(segment[1].x, segment[1].y);
|
||||
return path;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
|
|
@ -87,16 +88,18 @@ function buildOverlayStarPath({
|
|||
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "star",
|
||||
name: "Star",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return buildOverlayStarPath({ width, height });
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return buildOverlayStarPath({ width, height });
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "star",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,112 +1,121 @@
|
|||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
||||
|
||||
export function hasCenterParams(params: ParamValues): params is ParamValues & {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
} {
|
||||
return (
|
||||
typeof params.centerX === "number" && typeof params.centerY === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isRectangleMaskParams(
|
||||
params: ParamValues,
|
||||
): params is RectangleMaskParams {
|
||||
return (
|
||||
hasCenterParams(params) &&
|
||||
typeof params.width === "number" &&
|
||||
typeof params.height === "number" &&
|
||||
typeof params.rotation === "number" &&
|
||||
typeof params.scale === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function getMaskLocalCenter({
|
||||
params,
|
||||
bounds,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
}): { x: number; y: number } | null {
|
||||
if (!hasCenterParams(params)) return null;
|
||||
|
||||
return {
|
||||
x: params.centerX * bounds.width,
|
||||
y: params.centerY * bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function setMaskLocalCenter({
|
||||
center,
|
||||
bounds,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): Pick<ParamValues, "centerX" | "centerY"> {
|
||||
return {
|
||||
centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
|
||||
centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMaskSnapGeometry({
|
||||
params,
|
||||
bounds,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
}): {
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
rotation: number;
|
||||
} | null {
|
||||
const position = getMaskLocalCenter({ params, bounds });
|
||||
if (!position) return null;
|
||||
|
||||
if (isRectangleMaskParams(params)) {
|
||||
return {
|
||||
position,
|
||||
size: {
|
||||
width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
|
||||
height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
|
||||
},
|
||||
rotation: params.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
size: { width: 0, height: 0 },
|
||||
rotation: typeof params.rotation === "number" ? params.rotation : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toGlobalMaskSnapLines({
|
||||
lines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}: {
|
||||
lines: SnapLine[];
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
}): SnapLine[] {
|
||||
const centerX = bounds.cx - canvasSize.width / 2;
|
||||
const centerY = bounds.cy - canvasSize.height / 2;
|
||||
|
||||
return lines.map((line) =>
|
||||
line.type === "vertical"
|
||||
? {
|
||||
type: "vertical" as const,
|
||||
position: centerX + line.position,
|
||||
}
|
||||
: {
|
||||
type: "horizontal" as const,
|
||||
position: centerY + line.position,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
||||
|
||||
type CenterMaskParams = {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
};
|
||||
|
||||
type SnapGeometryParams = CenterMaskParams & {
|
||||
rotation?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
export function hasCenterParams(
|
||||
params: Partial<CenterMaskParams>,
|
||||
): params is CenterMaskParams {
|
||||
return (
|
||||
typeof params.centerX === "number" && typeof params.centerY === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isRectangleMaskParams(
|
||||
params: SnapGeometryParams,
|
||||
): params is RectangleMaskParams {
|
||||
return (
|
||||
hasCenterParams(params) &&
|
||||
typeof params.width === "number" &&
|
||||
typeof params.height === "number" &&
|
||||
typeof params.rotation === "number" &&
|
||||
typeof params.scale === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function getMaskLocalCenter({
|
||||
params,
|
||||
bounds,
|
||||
}: {
|
||||
params: CenterMaskParams;
|
||||
bounds: ElementBounds;
|
||||
}): { x: number; y: number } | null {
|
||||
if (!hasCenterParams(params)) return null;
|
||||
|
||||
return {
|
||||
x: params.centerX * bounds.width,
|
||||
y: params.centerY * bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function setMaskLocalCenter({
|
||||
center,
|
||||
bounds,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): { centerX: number; centerY: number } {
|
||||
return {
|
||||
centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
|
||||
centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMaskSnapGeometry({
|
||||
params,
|
||||
bounds,
|
||||
}: {
|
||||
params: SnapGeometryParams;
|
||||
bounds: ElementBounds;
|
||||
}): {
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
rotation: number;
|
||||
} | null {
|
||||
const position = getMaskLocalCenter({ params, bounds });
|
||||
if (!position) return null;
|
||||
|
||||
if (isRectangleMaskParams(params)) {
|
||||
return {
|
||||
position,
|
||||
size: {
|
||||
width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
|
||||
height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
|
||||
},
|
||||
rotation: params.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
size: { width: 0, height: 0 },
|
||||
rotation: typeof params.rotation === "number" ? params.rotation : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toGlobalMaskSnapLines({
|
||||
lines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}: {
|
||||
lines: SnapLine[];
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
}): SnapLine[] {
|
||||
const centerX = bounds.cx - canvasSize.width / 2;
|
||||
const centerY = bounds.cy - canvasSize.height / 2;
|
||||
|
||||
return lines.map((line) =>
|
||||
line.type === "vertical"
|
||||
? {
|
||||
type: "vertical" as const,
|
||||
position: centerX + line.position,
|
||||
}
|
||||
: {
|
||||
type: "horizontal" as const,
|
||||
position: centerY + line.position,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,382 +1,497 @@
|
|||
import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type {
|
||||
MaskFeatures,
|
||||
MaskHandlePosition,
|
||||
MaskLinePoints,
|
||||
MaskOverlayShape,
|
||||
} from "@/lib/masks/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
|
||||
const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||
const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||
const LINE_EXTENT_MULTIPLIER = 50;
|
||||
|
||||
const CURSOR = {
|
||||
rotate: "cursor-crosshair",
|
||||
resizeDiagonal: "cursor-nwse-resize",
|
||||
resizeHorizontal: "cursor-ew-resize",
|
||||
resizeVertical: "cursor-ns-resize",
|
||||
} as const;
|
||||
|
||||
function getNumParam({
|
||||
params,
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
key: string;
|
||||
fallback: number;
|
||||
}): number {
|
||||
const value = params[key];
|
||||
return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer defines the split line as:
|
||||
* - normal direction: (cos(rotation), sin(rotation))
|
||||
* - line direction (parallel to cut): (-sin(rotation), cos(rotation))
|
||||
* - reference point: (centerX * width, centerY * height) from element centre
|
||||
*
|
||||
* So rotation=0 → normal points right → line runs vertically.
|
||||
*/
|
||||
export function getLineMaskLinePoints({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
bounds,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
}): MaskLinePoints {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const normalX = Math.cos(angleRad);
|
||||
const normalY = Math.sin(angleRad);
|
||||
const lineDirX = -normalY;
|
||||
const lineDirY = normalX;
|
||||
|
||||
const cx = bounds.cx + centerX * bounds.width;
|
||||
const cy = bounds.cy + centerY * bounds.height;
|
||||
|
||||
const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
|
||||
|
||||
return {
|
||||
start: {
|
||||
x: cx - lineDirX * extent,
|
||||
y: cy - lineDirY * extent,
|
||||
},
|
||||
end: {
|
||||
x: cx + lineDirX * extent,
|
||||
y: cy + lineDirY * extent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getLineMaskHandlePositions({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
feather,
|
||||
bounds,
|
||||
displayScale,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
feather: number;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}): MaskHandlePosition[] {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const normalX = Math.cos(angleRad);
|
||||
const normalY = Math.sin(angleRad);
|
||||
|
||||
const cx = bounds.cx + centerX * bounds.width;
|
||||
const cy = bounds.cy + centerY * bounds.height;
|
||||
|
||||
const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
||||
const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "rotation",
|
||||
x: cx + normalX * iconOffsetCanvas,
|
||||
y: cy + normalY * iconOffsetCanvas,
|
||||
cursor: CURSOR.rotate,
|
||||
},
|
||||
{
|
||||
id: "feather",
|
||||
x: cx - normalX * featherOffset,
|
||||
y: cy - normalY * featherOffset,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function rotatePoint({
|
||||
localX,
|
||||
localY,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
}: {
|
||||
localX: number;
|
||||
localY: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
angleRad: number;
|
||||
}): { x: number; y: number } {
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
return {
|
||||
x: cx + localX * cos - localY * sin,
|
||||
y: cy + localX * sin + localY * cos,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBoxMaskHandlePositions({
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
feather,
|
||||
sizeMode,
|
||||
bounds,
|
||||
displayScale,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
feather: number;
|
||||
sizeMode: MaskFeatures["sizeMode"];
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}): MaskHandlePosition[] {
|
||||
const cx = bounds.cx + centerX * bounds.width;
|
||||
const cy = bounds.cy + centerY * bounds.height;
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const halfWidth = (width * bounds.width) / 2;
|
||||
const halfHeight = (height * bounds.height) / 2;
|
||||
|
||||
const handles: MaskHandlePosition[] = [];
|
||||
const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
||||
|
||||
const rotHandle = rotatePoint({
|
||||
localX: 0,
|
||||
localY: -halfHeight - handleOffsetCanvas,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "rotation",
|
||||
x: rotHandle.x,
|
||||
y: rotHandle.y,
|
||||
cursor: CURSOR.rotate,
|
||||
});
|
||||
|
||||
const featherHandle = rotatePoint({
|
||||
localX: 0,
|
||||
localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "feather",
|
||||
x: featherHandle.x,
|
||||
y: featherHandle.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
});
|
||||
|
||||
if (sizeMode === "width-height") {
|
||||
const corners = [
|
||||
{ localX: -halfWidth, localY: -halfHeight, id: "top-left" },
|
||||
{ localX: halfWidth, localY: -halfHeight, id: "top-right" },
|
||||
{ localX: halfWidth, localY: halfHeight, id: "bottom-right" },
|
||||
{ localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
|
||||
];
|
||||
for (const { localX, localY, id } of corners) {
|
||||
const point = rotatePoint({ localX, localY, cx, cy, angleRad });
|
||||
handles.push({
|
||||
id,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
cursor: CURSOR.resizeDiagonal,
|
||||
});
|
||||
}
|
||||
const right = rotatePoint({
|
||||
localX: halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const left = rotatePoint({
|
||||
localX: -halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const bottom = rotatePoint({
|
||||
localX: 0,
|
||||
localY: halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "left",
|
||||
x: left.x,
|
||||
y: left.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
});
|
||||
handles.push({
|
||||
id: "right",
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
});
|
||||
handles.push({
|
||||
id: "bottom",
|
||||
x: bottom.x,
|
||||
y: bottom.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
});
|
||||
} else if (sizeMode === "height-only") {
|
||||
const top = rotatePoint({
|
||||
localX: 0,
|
||||
localY: -halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const bottom = rotatePoint({
|
||||
localX: 0,
|
||||
localY: halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "top",
|
||||
x: top.x,
|
||||
y: top.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
});
|
||||
handles.push({
|
||||
id: "bottom",
|
||||
x: bottom.x,
|
||||
y: bottom.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
});
|
||||
} else if (sizeMode === "width-only") {
|
||||
const left = rotatePoint({
|
||||
localX: -halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const right = rotatePoint({
|
||||
localX: halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "left",
|
||||
x: left.x,
|
||||
y: left.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
});
|
||||
handles.push({
|
||||
id: "right",
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
});
|
||||
} else if (sizeMode === "uniform") {
|
||||
const point = rotatePoint({
|
||||
localX: halfWidth,
|
||||
localY: halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "scale",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
cursor: CURSOR.resizeDiagonal,
|
||||
});
|
||||
}
|
||||
|
||||
return handles;
|
||||
}
|
||||
|
||||
type MaskHandleResolver = (args: {
|
||||
features: MaskFeatures;
|
||||
params: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}) => MaskHandlePosition[];
|
||||
|
||||
const rectangleHandles: MaskHandleResolver = ({
|
||||
features,
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
}) =>
|
||||
getBoxMaskHandlePositions({
|
||||
centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
|
||||
centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
|
||||
width: getNumParam({ params, key: "width", fallback: 1 }),
|
||||
height: getNumParam({ params, key: "height", fallback: 1 }),
|
||||
rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
|
||||
feather: getNumParam({ params, key: "feather", fallback: 0 }),
|
||||
sizeMode: features.sizeMode,
|
||||
bounds,
|
||||
displayScale,
|
||||
});
|
||||
|
||||
const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = {
|
||||
line: ({ params, bounds, displayScale }) =>
|
||||
getLineMaskHandlePositions({
|
||||
centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
|
||||
centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
|
||||
rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
|
||||
feather: getNumParam({ params, key: "feather", fallback: 0 }),
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
box: rectangleHandles,
|
||||
};
|
||||
|
||||
export function getMaskHandlePositions({
|
||||
overlayShape,
|
||||
features,
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
}: {
|
||||
overlayShape: MaskOverlayShape;
|
||||
features: MaskFeatures;
|
||||
params: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}): MaskHandlePosition[] {
|
||||
return HANDLE_RESOLVERS[overlayShape]({
|
||||
features,
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
});
|
||||
}
|
||||
import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type {
|
||||
MaskFeatures,
|
||||
MaskHandlePosition,
|
||||
MaskLineOverlay,
|
||||
MaskOverlay,
|
||||
MaskRectOverlay,
|
||||
RectangleMaskParams,
|
||||
MaskShapeOverlay,
|
||||
} from "@/lib/masks/types";
|
||||
|
||||
const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||
const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||
const LINE_EXTENT_MULTIPLIER = 50;
|
||||
|
||||
const CURSOR = {
|
||||
rotate: "crosshair",
|
||||
resizeDiagonal: "nwse-resize",
|
||||
resizeHorizontal: "ew-resize",
|
||||
resizeVertical: "ns-resize",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The renderer defines the split line as:
|
||||
* - normal direction: (cos(rotation), sin(rotation))
|
||||
* - line direction (parallel to cut): (-sin(rotation), cos(rotation))
|
||||
* - reference point: (centerX * width, centerY * height) from element centre
|
||||
*
|
||||
* So rotation=0 → normal points right → line runs vertically.
|
||||
*/
|
||||
export function getLineMaskLinePoints({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
bounds,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
}): { start: { x: number; y: number }; end: { x: number; y: number } } {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const normalX = Math.cos(angleRad);
|
||||
const normalY = Math.sin(angleRad);
|
||||
const lineDirX = -normalY;
|
||||
const lineDirY = normalX;
|
||||
|
||||
const cx = bounds.cx + centerX * bounds.width;
|
||||
const cy = bounds.cy + centerY * bounds.height;
|
||||
|
||||
const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
|
||||
|
||||
return {
|
||||
start: {
|
||||
x: cx - lineDirX * extent,
|
||||
y: cy - lineDirY * extent,
|
||||
},
|
||||
end: {
|
||||
x: cx + lineDirX * extent,
|
||||
y: cy + lineDirY * extent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getLineMaskOverlay({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
bounds,
|
||||
handleId = "position",
|
||||
cursor = "move",
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
handleId?: string;
|
||||
cursor?: string;
|
||||
}): MaskLineOverlay {
|
||||
const { start, end } = getLineMaskLinePoints({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return {
|
||||
id: "line",
|
||||
type: "line",
|
||||
start,
|
||||
end,
|
||||
handleId,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLineMaskHandlePositions({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
feather,
|
||||
bounds,
|
||||
displayScale,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
feather: number;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}): MaskHandlePosition[] {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const normalX = Math.cos(angleRad);
|
||||
const normalY = Math.sin(angleRad);
|
||||
|
||||
const cx = bounds.cx + centerX * bounds.width;
|
||||
const cy = bounds.cy + centerY * bounds.height;
|
||||
|
||||
const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
||||
const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "rotation",
|
||||
x: cx + normalX * iconOffsetCanvas,
|
||||
y: cy + normalY * iconOffsetCanvas,
|
||||
cursor: CURSOR.rotate,
|
||||
kind: "icon",
|
||||
icon: "rotate",
|
||||
},
|
||||
{
|
||||
id: "feather",
|
||||
x: cx - normalX * featherOffset,
|
||||
y: cy - normalY * featherOffset,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "icon",
|
||||
icon: "feather",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function rotatePoint({
|
||||
localX,
|
||||
localY,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
}: {
|
||||
localX: number;
|
||||
localY: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
angleRad: number;
|
||||
}): { x: number; y: number } {
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
return {
|
||||
x: cx + localX * cos - localY * sin,
|
||||
y: cy + localX * sin + localY * cos,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBoxMaskHandlePositions({
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
feather,
|
||||
sizeMode,
|
||||
showScaleHandle = true,
|
||||
bounds,
|
||||
displayScale,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
feather: number;
|
||||
sizeMode: MaskFeatures["sizeMode"];
|
||||
showScaleHandle?: boolean;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}): MaskHandlePosition[] {
|
||||
const cx = bounds.cx + centerX * bounds.width;
|
||||
const cy = bounds.cy + centerY * bounds.height;
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const halfWidth = (width * bounds.width) / 2;
|
||||
const halfHeight = (height * bounds.height) / 2;
|
||||
|
||||
const handles: MaskHandlePosition[] = [];
|
||||
const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
||||
|
||||
const rotHandle = rotatePoint({
|
||||
localX: 0,
|
||||
localY: -halfHeight - handleOffsetCanvas,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "rotation",
|
||||
x: rotHandle.x,
|
||||
y: rotHandle.y,
|
||||
cursor: CURSOR.rotate,
|
||||
kind: "icon",
|
||||
icon: "rotate",
|
||||
});
|
||||
|
||||
const featherHandle = rotatePoint({
|
||||
localX: 0,
|
||||
localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "feather",
|
||||
x: featherHandle.x,
|
||||
y: featherHandle.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "icon",
|
||||
icon: "feather",
|
||||
});
|
||||
|
||||
if (sizeMode === "width-height") {
|
||||
const corners = [
|
||||
{ localX: -halfWidth, localY: -halfHeight, id: "top-left" },
|
||||
{ localX: halfWidth, localY: -halfHeight, id: "top-right" },
|
||||
{ localX: halfWidth, localY: halfHeight, id: "bottom-right" },
|
||||
{ localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
|
||||
];
|
||||
for (const { localX, localY, id } of corners) {
|
||||
const point = rotatePoint({ localX, localY, cx, cy, angleRad });
|
||||
handles.push({
|
||||
id,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
cursor: CURSOR.resizeDiagonal,
|
||||
kind: "corner",
|
||||
});
|
||||
}
|
||||
const right = rotatePoint({
|
||||
localX: halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const left = rotatePoint({
|
||||
localX: -halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const bottom = rotatePoint({
|
||||
localX: 0,
|
||||
localY: halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "left",
|
||||
x: left.x,
|
||||
y: left.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "right",
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "bottom",
|
||||
x: bottom.x,
|
||||
y: bottom.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "edge",
|
||||
edgeAxis: "vertical",
|
||||
rotation,
|
||||
});
|
||||
} else if (sizeMode === "height-only") {
|
||||
const top = rotatePoint({
|
||||
localX: 0,
|
||||
localY: -halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const bottom = rotatePoint({
|
||||
localX: 0,
|
||||
localY: halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "top",
|
||||
x: top.x,
|
||||
y: top.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "edge",
|
||||
edgeAxis: "vertical",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "bottom",
|
||||
x: bottom.x,
|
||||
y: bottom.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "edge",
|
||||
edgeAxis: "vertical",
|
||||
rotation,
|
||||
});
|
||||
} else if (sizeMode === "width-only") {
|
||||
const left = rotatePoint({
|
||||
localX: -halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
const right = rotatePoint({
|
||||
localX: halfWidth,
|
||||
localY: 0,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "left",
|
||||
x: left.x,
|
||||
y: left.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "right",
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
} else if (sizeMode === "uniform" && showScaleHandle) {
|
||||
const point = rotatePoint({
|
||||
localX: halfWidth,
|
||||
localY: halfHeight,
|
||||
cx,
|
||||
cy,
|
||||
angleRad,
|
||||
});
|
||||
handles.push({
|
||||
id: "scale",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
cursor: CURSOR.resizeDiagonal,
|
||||
kind: "corner",
|
||||
});
|
||||
}
|
||||
|
||||
return handles;
|
||||
}
|
||||
|
||||
export function getBoxMaskRectOverlay({
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
bounds,
|
||||
handleId = "position",
|
||||
cursor = "move",
|
||||
dashed = false,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
handleId?: string;
|
||||
cursor?: string;
|
||||
dashed?: boolean;
|
||||
}): MaskRectOverlay {
|
||||
return {
|
||||
id: "bounding-box",
|
||||
type: "rect",
|
||||
center: {
|
||||
x: bounds.cx + centerX * bounds.width,
|
||||
y: bounds.cy + centerY * bounds.height,
|
||||
},
|
||||
width: width * bounds.width,
|
||||
height: height * bounds.height,
|
||||
rotation,
|
||||
handleId,
|
||||
cursor,
|
||||
dashed,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBoxMaskShapeOverlay({
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
bounds,
|
||||
pathData,
|
||||
handleId = "position",
|
||||
cursor = "move",
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
pathData: string;
|
||||
handleId?: string;
|
||||
cursor?: string;
|
||||
}): MaskShapeOverlay {
|
||||
return {
|
||||
id: "shape-outline",
|
||||
type: "shape",
|
||||
center: {
|
||||
x: bounds.cx + centerX * bounds.width,
|
||||
y: bounds.cy + centerY * bounds.height,
|
||||
},
|
||||
width: width * bounds.width,
|
||||
height: height * bounds.height,
|
||||
rotation,
|
||||
pathData,
|
||||
handleId,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBoxMaskOverlays({
|
||||
params,
|
||||
bounds,
|
||||
pathData,
|
||||
showBoundingBox = true,
|
||||
}: {
|
||||
params: Pick<
|
||||
RectangleMaskParams,
|
||||
"centerX" | "centerY" | "width" | "height" | "rotation"
|
||||
>;
|
||||
bounds: ElementBounds;
|
||||
pathData?: string;
|
||||
showBoundingBox?: boolean;
|
||||
}): MaskOverlay[] {
|
||||
const overlays: MaskOverlay[] = [];
|
||||
if (showBoundingBox) {
|
||||
overlays.push(
|
||||
getBoxMaskRectOverlay({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
rotation: params.rotation,
|
||||
bounds,
|
||||
dashed: Boolean(pathData),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (pathData) {
|
||||
overlays.push(
|
||||
getBoxMaskShapeOverlay({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
rotation: params.rotation,
|
||||
bounds,
|
||||
pathData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return overlays;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,26 @@
|
|||
import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskParamUpdateArgs,
|
||||
MaskType,
|
||||
} from "@/lib/masks/types";
|
||||
|
||||
function compactMaskParamValues({
|
||||
params,
|
||||
}: {
|
||||
params: Partial<ParamValues>;
|
||||
}): ParamValues {
|
||||
const nextParams: ParamValues = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) {
|
||||
nextParams[key] = value;
|
||||
}
|
||||
}
|
||||
return nextParams;
|
||||
}
|
||||
|
||||
export function computeFeatherUpdate({
|
||||
startFeather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX,
|
||||
directionY,
|
||||
}: {
|
||||
startFeather: number;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
directionX: number;
|
||||
directionY: number;
|
||||
}): ParamValues {
|
||||
const projection = deltaX * directionX + deltaY * directionY;
|
||||
return {
|
||||
feather: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
MAX_FEATHER,
|
||||
Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function computeMaskParamUpdate({
|
||||
maskType,
|
||||
...args
|
||||
}: {
|
||||
maskType: MaskType;
|
||||
} & MaskParamUpdateArgs<BaseMaskParams & ParamValues>): ParamValues {
|
||||
const definition = masksRegistry.get(maskType);
|
||||
return compactMaskParamValues({
|
||||
params: definition.computeParamUpdate(args),
|
||||
});
|
||||
}
|
||||
import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
|
||||
|
||||
export function computeFeatherUpdate({
|
||||
startFeather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX,
|
||||
directionY,
|
||||
}: {
|
||||
startFeather: number;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
directionX: number;
|
||||
directionY: number;
|
||||
}): { feather: number } {
|
||||
const projection = deltaX * directionX + deltaY * directionY;
|
||||
return {
|
||||
feather: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
MAX_FEATHER,
|
||||
Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { MAX_FEATHER } from "@/lib/masks/feather";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { BaseMaskParams, MaskDefinition, MaskType } from "@/lib/masks/types";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskDefaultContext,
|
||||
MaskDefinition,
|
||||
MaskInteractionResult,
|
||||
MaskSnapArgs,
|
||||
MaskSnapResult,
|
||||
MaskType,
|
||||
} from "@/lib/masks/types";
|
||||
import type { HugeiconsIconProps } from "@hugeicons/react";
|
||||
import { DefinitionRegistry } from "@/lib/registry";
|
||||
|
||||
|
|
@ -39,9 +47,33 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
|
|||
},
|
||||
];
|
||||
|
||||
export type RegisteredMaskDefinition = MaskDefinition<BaseMaskParams> & {
|
||||
export interface RegisteredMaskDefinition {
|
||||
type: MaskType;
|
||||
name: string;
|
||||
features: MaskDefinition<BaseMaskParams>["features"];
|
||||
params: ParamDefinition<string>[];
|
||||
renderer: MaskDefinition<BaseMaskParams>["renderer"];
|
||||
interaction: {
|
||||
getInteraction(args: {
|
||||
params: BaseMaskParams;
|
||||
bounds: Parameters<
|
||||
MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
|
||||
>[0]["bounds"];
|
||||
displayScale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}): MaskInteractionResult;
|
||||
snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
|
||||
};
|
||||
isActive?: (params: BaseMaskParams) => boolean;
|
||||
buildDefault(
|
||||
context: MaskDefaultContext,
|
||||
): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
|
||||
computeParamUpdate(
|
||||
args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
|
||||
): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
|
||||
icon: MaskIconProps;
|
||||
};
|
||||
}
|
||||
|
||||
export class MasksRegistry extends DefinitionRegistry<
|
||||
MaskType,
|
||||
|
|
@ -59,8 +91,28 @@ export class MasksRegistry extends DefinitionRegistry<
|
|||
icon: MaskIconProps;
|
||||
}): void {
|
||||
const withBaseParams: RegisteredMaskDefinition = {
|
||||
...definition,
|
||||
type: definition.type,
|
||||
name: definition.name,
|
||||
features: definition.features,
|
||||
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
|
||||
renderer: definition.renderer,
|
||||
interaction: {
|
||||
getInteraction(args) {
|
||||
return definition.interaction.getInteraction(args as never);
|
||||
},
|
||||
snap: definition.interaction.snap
|
||||
? (args) => definition.interaction.snap?.(args as never) as never
|
||||
: undefined,
|
||||
},
|
||||
isActive: definition.isActive
|
||||
? (params) => definition.isActive?.(params as TParams) ?? true
|
||||
: undefined,
|
||||
buildDefault(context) {
|
||||
return definition.buildDefault(context);
|
||||
},
|
||||
computeParamUpdate(args) {
|
||||
return definition.computeParamUpdate(args as never);
|
||||
},
|
||||
icon,
|
||||
};
|
||||
this.register(definition.type, withBaseParams);
|
||||
|
|
|
|||
|
|
@ -1,335 +1,336 @@
|
|||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
||||
import {
|
||||
snapPosition,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
snapScaleAxes,
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import {
|
||||
isRectangleMaskParams,
|
||||
getMaskSnapGeometry,
|
||||
setMaskLocalCenter,
|
||||
toGlobalMaskSnapLines,
|
||||
} from "./geometry";
|
||||
|
||||
type MaskSnapResult = {
|
||||
params: ParamValues;
|
||||
activeLines: SnapLine[];
|
||||
};
|
||||
|
||||
const CORNER_SIZE_HANDLES = new Set([
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
]);
|
||||
|
||||
function getClampedRatio({
|
||||
next,
|
||||
base,
|
||||
}: {
|
||||
next: number;
|
||||
base: number;
|
||||
}): number {
|
||||
return (
|
||||
Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
|
||||
);
|
||||
}
|
||||
|
||||
function getPreferredEdges({
|
||||
handleId,
|
||||
}: {
|
||||
handleId: string;
|
||||
}): ScaleEdgePreference | undefined {
|
||||
const preferredEdges = {
|
||||
left:
|
||||
handleId === "left" ||
|
||||
handleId === "top-left" ||
|
||||
handleId === "bottom-left",
|
||||
right:
|
||||
handleId === "right" ||
|
||||
handleId === "top-right" ||
|
||||
handleId === "bottom-right",
|
||||
top:
|
||||
handleId === "top" || handleId === "top-left" || handleId === "top-right",
|
||||
bottom:
|
||||
handleId === "bottom" ||
|
||||
handleId === "bottom-left" ||
|
||||
handleId === "bottom-right",
|
||||
} satisfies ScaleEdgePreference;
|
||||
|
||||
return Object.values(preferredEdges).some(Boolean)
|
||||
? preferredEdges
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function snapMaskPosition({
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}: {
|
||||
proposedParams: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult {
|
||||
const geometry = getMaskSnapGeometry({
|
||||
params: proposedParams,
|
||||
bounds,
|
||||
});
|
||||
if (!geometry) {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const { snappedPosition, activeLines } = snapPosition({
|
||||
proposedPosition: geometry.position,
|
||||
canvasSize: bounds,
|
||||
elementSize: geometry.size,
|
||||
rotation: geometry.rotation,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
...setMaskLocalCenter({
|
||||
center: snappedPosition,
|
||||
bounds,
|
||||
}),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function snapMaskRotation({
|
||||
proposedParams,
|
||||
}: {
|
||||
proposedParams: ParamValues;
|
||||
}): MaskSnapResult {
|
||||
if (typeof proposedParams.rotation !== "number") {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const { snappedRotation } = snapRotation({
|
||||
proposedRotation: proposedParams.rotation,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
rotation: snappedRotation,
|
||||
},
|
||||
activeLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function snapBoxMaskSize({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}: {
|
||||
handleId: string;
|
||||
startParams: ParamValues;
|
||||
proposedParams: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult {
|
||||
if (
|
||||
!isRectangleMaskParams(startParams) ||
|
||||
!isRectangleMaskParams(proposedParams)
|
||||
) {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const geometry = getMaskSnapGeometry({
|
||||
params: proposedParams,
|
||||
bounds,
|
||||
});
|
||||
if (!geometry) {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const localCanvasSize = bounds;
|
||||
const baseWidth =
|
||||
Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
|
||||
const baseHeight =
|
||||
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
|
||||
const preferredEdges = getPreferredEdges({ handleId });
|
||||
|
||||
if (handleId === "right" || handleId === "left") {
|
||||
const proposedScaleX = getClampedRatio({
|
||||
next: proposedParams.width,
|
||||
base: startParams.width,
|
||||
});
|
||||
const { x } = snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY: 1,
|
||||
position: geometry.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: x.activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "top" || handleId === "bottom") {
|
||||
const proposedScaleY = getClampedRatio({
|
||||
next: proposedParams.height,
|
||||
base: startParams.height,
|
||||
});
|
||||
const { y } = snapScaleAxes({
|
||||
proposedScaleX: 1,
|
||||
proposedScaleY,
|
||||
position: geometry.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
height: Math.max(
|
||||
MIN_MASK_DIMENSION,
|
||||
startParams.height * y.snappedScale,
|
||||
),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: y.activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
|
||||
const proposedScale = getClampedRatio({
|
||||
next: proposedParams.scale,
|
||||
base: startParams.scale,
|
||||
});
|
||||
const { snappedScale, activeLines } = snapScale({
|
||||
proposedScale,
|
||||
position: geometry.position,
|
||||
baseWidth: baseWidth * baseScale,
|
||||
baseHeight: baseHeight * baseScale,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (CORNER_SIZE_HANDLES.has(handleId)) {
|
||||
const proposedScale = getClampedRatio({
|
||||
next: proposedParams.width,
|
||||
base: startParams.width,
|
||||
});
|
||||
const { snappedScale, activeLines } = snapScale({
|
||||
proposedScale,
|
||||
position: geometry.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
|
||||
height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
export function snapMaskInteraction({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}: {
|
||||
handleId: string;
|
||||
startParams: ParamValues;
|
||||
proposedParams: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult {
|
||||
if (handleId === "position") {
|
||||
return snapMaskPosition({
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
return snapMaskRotation({ proposedParams });
|
||||
}
|
||||
|
||||
return snapBoxMaskSize({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
||||
import {
|
||||
snapPosition,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
snapScaleAxes,
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
isRectangleMaskParams,
|
||||
getMaskSnapGeometry,
|
||||
setMaskLocalCenter,
|
||||
toGlobalMaskSnapLines,
|
||||
} from "./geometry";
|
||||
|
||||
type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
|
||||
|
||||
type MaskSnapResult<TParams extends SharedMaskParams> = {
|
||||
params: TParams;
|
||||
activeLines: SnapLine[];
|
||||
};
|
||||
|
||||
const CORNER_SIZE_HANDLES = new Set([
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
]);
|
||||
|
||||
function getClampedRatio({
|
||||
next,
|
||||
base,
|
||||
}: {
|
||||
next: number;
|
||||
base: number;
|
||||
}): number {
|
||||
return (
|
||||
Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
|
||||
);
|
||||
}
|
||||
|
||||
function getPreferredEdges({
|
||||
handleId,
|
||||
}: {
|
||||
handleId: string;
|
||||
}): ScaleEdgePreference | undefined {
|
||||
const preferredEdges = {
|
||||
left:
|
||||
handleId === "left" ||
|
||||
handleId === "top-left" ||
|
||||
handleId === "bottom-left",
|
||||
right:
|
||||
handleId === "right" ||
|
||||
handleId === "top-right" ||
|
||||
handleId === "bottom-right",
|
||||
top:
|
||||
handleId === "top" || handleId === "top-left" || handleId === "top-right",
|
||||
bottom:
|
||||
handleId === "bottom" ||
|
||||
handleId === "bottom-left" ||
|
||||
handleId === "bottom-right",
|
||||
} satisfies ScaleEdgePreference;
|
||||
|
||||
return Object.values(preferredEdges).some(Boolean)
|
||||
? preferredEdges
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function snapMaskPosition({
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}: {
|
||||
proposedParams: SharedMaskParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult<SharedMaskParams> {
|
||||
const geometry = getMaskSnapGeometry({
|
||||
params: proposedParams,
|
||||
bounds,
|
||||
});
|
||||
if (!geometry) {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const { snappedPosition, activeLines } = snapPosition({
|
||||
proposedPosition: geometry.position,
|
||||
canvasSize: bounds,
|
||||
elementSize: geometry.size,
|
||||
rotation: geometry.rotation,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
...setMaskLocalCenter({
|
||||
center: snappedPosition,
|
||||
bounds,
|
||||
}),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function snapMaskRotation({
|
||||
proposedParams,
|
||||
}: {
|
||||
proposedParams: SharedMaskParams;
|
||||
}): MaskSnapResult<SharedMaskParams> {
|
||||
if (typeof proposedParams.rotation !== "number") {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const { snappedRotation } = snapRotation({
|
||||
proposedRotation: proposedParams.rotation,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
rotation: snappedRotation,
|
||||
},
|
||||
activeLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function snapBoxMaskSize({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}: {
|
||||
handleId: string;
|
||||
startParams: SharedMaskParams;
|
||||
proposedParams: SharedMaskParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult<SharedMaskParams> {
|
||||
if (
|
||||
!isRectangleMaskParams(startParams) ||
|
||||
!isRectangleMaskParams(proposedParams)
|
||||
) {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const geometry = getMaskSnapGeometry({
|
||||
params: proposedParams,
|
||||
bounds,
|
||||
});
|
||||
if (!geometry) {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
const localCanvasSize = bounds;
|
||||
const baseWidth =
|
||||
Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
|
||||
const baseHeight =
|
||||
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
|
||||
const preferredEdges = getPreferredEdges({ handleId });
|
||||
|
||||
if (handleId === "right" || handleId === "left") {
|
||||
const proposedScaleX = getClampedRatio({
|
||||
next: proposedParams.width,
|
||||
base: startParams.width,
|
||||
});
|
||||
const { x } = snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY: 1,
|
||||
position: geometry.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: x.activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "top" || handleId === "bottom") {
|
||||
const proposedScaleY = getClampedRatio({
|
||||
next: proposedParams.height,
|
||||
base: startParams.height,
|
||||
});
|
||||
const { y } = snapScaleAxes({
|
||||
proposedScaleX: 1,
|
||||
proposedScaleY,
|
||||
position: geometry.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
height: Math.max(
|
||||
MIN_MASK_DIMENSION,
|
||||
startParams.height * y.snappedScale,
|
||||
),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: y.activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
|
||||
const proposedScale = getClampedRatio({
|
||||
next: proposedParams.scale,
|
||||
base: startParams.scale,
|
||||
});
|
||||
const { snappedScale, activeLines } = snapScale({
|
||||
proposedScale,
|
||||
position: geometry.position,
|
||||
baseWidth: baseWidth * baseScale,
|
||||
baseHeight: baseHeight * baseScale,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (CORNER_SIZE_HANDLES.has(handleId)) {
|
||||
const proposedScale = getClampedRatio({
|
||||
next: proposedParams.width,
|
||||
base: startParams.width,
|
||||
});
|
||||
const { snappedScale, activeLines } = snapScale({
|
||||
proposedScale,
|
||||
position: geometry.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: localCanvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
|
||||
height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
export function snapMaskInteraction<TParams extends SharedMaskParams>({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}: {
|
||||
handleId: string;
|
||||
startParams: TParams;
|
||||
proposedParams: TParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult<TParams> {
|
||||
if (handleId === "position") {
|
||||
return snapMaskPosition({
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}) as MaskSnapResult<TParams>;
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
|
||||
}
|
||||
|
||||
return snapBoxMaskSize({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}) as MaskSnapResult<TParams>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { CustomMaskPathPoint } from "@/lib/masks/custom-path";
|
||||
import type {
|
||||
TextDecoration,
|
||||
TextFontStyle,
|
||||
TextFontWeight,
|
||||
} from "@/lib/text/primitives";
|
||||
|
||||
export type MaskType =
|
||||
| "split"
|
||||
|
|
@ -8,9 +15,11 @@ export type MaskType =
|
|||
| "ellipse"
|
||||
| "heart"
|
||||
| "diamond"
|
||||
| "star";
|
||||
| "star"
|
||||
| "text"
|
||||
| "custom";
|
||||
|
||||
export interface BaseMaskParams extends ParamValues {
|
||||
export interface BaseMaskParams {
|
||||
feather: number;
|
||||
inverted: boolean;
|
||||
strokeColor: string;
|
||||
|
|
@ -33,6 +42,30 @@ export interface RectangleMaskParams extends BaseMaskParams {
|
|||
scale: number;
|
||||
}
|
||||
|
||||
export interface TextMaskParams extends BaseMaskParams {
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: TextFontWeight;
|
||||
fontStyle: TextFontStyle;
|
||||
textDecoration: TextDecoration;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface CustomMaskParams extends BaseMaskParams {
|
||||
path: CustomMaskPathPoint[];
|
||||
closed: boolean;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface SplitMask {
|
||||
id: string;
|
||||
type: "split";
|
||||
|
|
@ -75,6 +108,18 @@ export interface StarMask {
|
|||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface TextMask {
|
||||
id: string;
|
||||
type: "text";
|
||||
params: TextMaskParams;
|
||||
}
|
||||
|
||||
export interface CustomMask {
|
||||
id: string;
|
||||
type: "custom";
|
||||
params: CustomMaskParams;
|
||||
}
|
||||
|
||||
export type Mask =
|
||||
| SplitMask
|
||||
| CinematicBarsMask
|
||||
|
|
@ -82,14 +127,16 @@ export type Mask =
|
|||
| EllipseMask
|
||||
| HeartMask
|
||||
| DiamondMask
|
||||
| StarMask;
|
||||
| StarMask
|
||||
| TextMask
|
||||
| CustomMask;
|
||||
|
||||
export interface MaskRenderer {
|
||||
buildPath(params: {
|
||||
buildPath?: (params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Path2D;
|
||||
}) => Path2D;
|
||||
buildStrokePath?: (params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
|
|
@ -103,33 +150,94 @@ export interface MaskRenderer {
|
|||
height: number;
|
||||
feather: number;
|
||||
}) => void;
|
||||
renderMaskHandlesFeather?: boolean;
|
||||
renderStroke?: (params: {
|
||||
resolvedParams: unknown;
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export type MaskOverlayShape = "line" | "box";
|
||||
|
||||
export interface MaskFeatures {
|
||||
hasPosition: boolean;
|
||||
hasRotation: boolean;
|
||||
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
|
||||
}
|
||||
|
||||
export type MaskHandleIcon = "rotate" | "feather";
|
||||
|
||||
export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
|
||||
|
||||
export interface MaskHandlePosition {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
cursor: string;
|
||||
kind: MaskHandleKind;
|
||||
isSelected?: boolean;
|
||||
edgeAxis?: "horizontal" | "vertical";
|
||||
rotation?: number;
|
||||
icon?: MaskHandleIcon;
|
||||
}
|
||||
|
||||
export interface MaskLinePoints {
|
||||
export interface MaskLineOverlay {
|
||||
id: string;
|
||||
type: "line";
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export interface MaskRectOverlay {
|
||||
id: string;
|
||||
type: "rect";
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
dashed?: boolean;
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export interface MaskShapeOverlay {
|
||||
id: string;
|
||||
type: "shape";
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
pathData: string;
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export interface MaskCanvasPathOverlay {
|
||||
id: string;
|
||||
type: "canvas-path";
|
||||
pathData: string;
|
||||
coordinateSpace?: "canvas" | "overlay";
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
strokeWidth?: number;
|
||||
strokeOpacity?: number;
|
||||
}
|
||||
|
||||
export type MaskOverlay =
|
||||
| MaskLineOverlay
|
||||
| MaskRectOverlay
|
||||
| MaskShapeOverlay
|
||||
| MaskCanvasPathOverlay;
|
||||
|
||||
export interface MaskDefaultContext {
|
||||
elementSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
export interface MaskParamUpdateArgs<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
handleId: string;
|
||||
startParams: TParams;
|
||||
deltaX: number;
|
||||
|
|
@ -140,14 +248,51 @@ export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskPa
|
|||
canvasSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
handleId: string;
|
||||
startParams: TParams;
|
||||
proposedParams: TParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface MaskSnapResult<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
params: TParams;
|
||||
activeLines: SnapLine[];
|
||||
}
|
||||
|
||||
export interface MaskInteractionResult {
|
||||
handles: MaskHandlePosition[];
|
||||
overlays: MaskOverlay[];
|
||||
}
|
||||
|
||||
export interface MaskInteractionDefinition<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
getInteraction(args: {
|
||||
params: TParams;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}): MaskInteractionResult;
|
||||
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
|
||||
}
|
||||
|
||||
export interface MaskDefinition<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
type: MaskType;
|
||||
name: string;
|
||||
overlayShape: MaskOverlayShape;
|
||||
features: MaskFeatures;
|
||||
params: ParamDefinition<keyof TParams & string>[];
|
||||
renderer: MaskRenderer;
|
||||
buildOverlayPath?: (params: { width: number; height: number }) => string;
|
||||
interaction: MaskInteractionDefinition<TParams>;
|
||||
/** When defined and returning false, the mask is not applied and the element renders fully visible. */
|
||||
isActive?: (params: TParams) => boolean;
|
||||
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
|
||||
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
|
||||
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -1,29 +1,30 @@
|
|||
export type ScopeEntry = {
|
||||
hasSelection: () => boolean;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
let activeScope: ScopeEntry | null = null;
|
||||
|
||||
export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
|
||||
if (activeScope && activeScope !== entry) {
|
||||
activeScope.clear();
|
||||
}
|
||||
|
||||
activeScope = entry;
|
||||
|
||||
return () => {
|
||||
if (activeScope === entry) {
|
||||
activeScope = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function clearActiveScope(): boolean {
|
||||
if (!activeScope?.hasSelection()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeScope.clear();
|
||||
return true;
|
||||
}
|
||||
export type ScopeEntry = {
|
||||
hasSelection: () => boolean;
|
||||
clear: () => void;
|
||||
clearActive?: () => void;
|
||||
};
|
||||
|
||||
let activeScope: ScopeEntry | null = null;
|
||||
|
||||
export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
|
||||
if (activeScope && activeScope !== entry) {
|
||||
activeScope.clear();
|
||||
}
|
||||
|
||||
activeScope = entry;
|
||||
|
||||
return () => {
|
||||
if (activeScope === entry) {
|
||||
activeScope = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function clearActiveScope(): boolean {
|
||||
if (!activeScope?.hasSelection()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
(activeScope.clearActive ?? activeScope.clear)();
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { CORNER_RADIUS_MIN } from "@/lib/text/background";
|
||||
import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography";
|
||||
import { resolveNumberAtTime } from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import type { TextBackground, TextElement } from "@/lib/timeline";
|
||||
import {
|
||||
measureTextBlock,
|
||||
setCanvasLetterSpacing,
|
||||
getTextVisualRect,
|
||||
type TextBlockMeasurement,
|
||||
} from "./layout";
|
||||
import {
|
||||
measureTextLayout,
|
||||
type MeasuredTextLayout,
|
||||
} from "./primitives";
|
||||
|
||||
export interface ResolvedTextBackground extends TextBackground {
|
||||
paddingX: number;
|
||||
|
|
@ -18,15 +18,7 @@ export interface ResolvedTextBackground extends TextBackground {
|
|||
cornerRadius: number;
|
||||
}
|
||||
|
||||
export interface MeasuredTextElement {
|
||||
scaledFontSize: number;
|
||||
fontString: string;
|
||||
letterSpacing: number;
|
||||
lineHeightPx: number;
|
||||
lines: string[];
|
||||
lineMetrics: TextMetrics[];
|
||||
block: TextBlockMeasurement;
|
||||
fontSizeRatio: number;
|
||||
export interface MeasuredTextElement extends MeasuredTextLayout {
|
||||
resolvedBackground: ResolvedTextBackground;
|
||||
visualRect: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
|
@ -75,28 +67,20 @@ export function measureTextElement({
|
|||
localTime: number;
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
}): MeasuredTextElement {
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
|
||||
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeightPx =
|
||||
scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
|
||||
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
|
||||
const lines = element.content.split("\n");
|
||||
|
||||
ctx.save();
|
||||
ctx.font = fontString;
|
||||
ctx.textBaseline = "middle";
|
||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
|
||||
const lineMetrics = lines.map((line) => ctx.measureText(line));
|
||||
ctx.restore();
|
||||
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
const measuredLayout = measureTextLayout({
|
||||
text: {
|
||||
content: element.content,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
textDecoration: element.textDecoration,
|
||||
letterSpacing: element.letterSpacing,
|
||||
lineHeight: element.lineHeight,
|
||||
},
|
||||
canvasHeight,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const bg = element.background;
|
||||
|
|
@ -136,20 +120,13 @@ export function measureTextElement({
|
|||
|
||||
const visualRect = getTextVisualRect({
|
||||
textAlign: element.textAlign,
|
||||
block,
|
||||
block: measuredLayout.block,
|
||||
background: resolvedBackground,
|
||||
fontSizeRatio,
|
||||
fontSizeRatio: measuredLayout.fontSizeRatio,
|
||||
});
|
||||
|
||||
return {
|
||||
scaledFontSize,
|
||||
fontString,
|
||||
letterSpacing,
|
||||
lineHeightPx,
|
||||
lines,
|
||||
lineMetrics,
|
||||
block,
|
||||
fontSizeRatio,
|
||||
...measuredLayout,
|
||||
resolvedBackground,
|
||||
visualRect,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,20 @@ export function resolveEffectiveAudioGain({
|
|||
return dBToLinear(resolvedDb);
|
||||
}
|
||||
|
||||
export function buildWaveformGainSamples({
|
||||
element,
|
||||
count,
|
||||
}: {
|
||||
element: AudioCapableElement;
|
||||
count: number;
|
||||
}): number[] {
|
||||
const durationSeconds = element.duration / TICKS_PER_SECOND;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const localTime = ((i + 0.5) / count) * durationSeconds;
|
||||
return resolveEffectiveAudioGain({ element, localTime });
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAudioGainAutomation({
|
||||
element,
|
||||
trackMuted = false,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
type TextElement,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
type TimelineTrack,
|
||||
type AudioElement,
|
||||
type VideoElement,
|
||||
type ImageElement,
|
||||
|
|
@ -414,6 +413,13 @@ export function getElementFontFamilies({
|
|||
if (element.type === "text" && element.fontFamily) {
|
||||
families.add(element.fontFamily);
|
||||
}
|
||||
if ("masks" in element) {
|
||||
for (const mask of element.masks ?? []) {
|
||||
if (mask.type === "text" && mask.params.fontFamily) {
|
||||
families.add(mask.params.fontFamily);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...families];
|
||||
|
|
|
|||
|
|
@ -401,6 +401,11 @@ function buildMaskArtifacts({
|
|||
}
|
||||
|
||||
const definition = masksRegistry.get(mask.type);
|
||||
|
||||
if (definition.isActive?.(mask.params) === false) {
|
||||
return { mask: null, strokeLayer: null };
|
||||
}
|
||||
|
||||
const elementMaskCanvas = createOffscreenCanvas({
|
||||
width: Math.round(transform.width),
|
||||
height: Math.round(transform.height),
|
||||
|
|
@ -416,7 +421,12 @@ function buildMaskArtifacts({
|
|||
|
||||
let strokePath: Path2D | null = null;
|
||||
let feather = mask.params.feather;
|
||||
if (mask.params.feather > 0 && definition.renderer.renderMask) {
|
||||
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
|
||||
const shouldRenderMaskDirectly =
|
||||
canRenderMaskDirectly &&
|
||||
(!definition.renderer.buildPath ||
|
||||
(mask.params.feather > 0 && definition.renderer.renderMaskHandlesFeather));
|
||||
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
|
||||
definition.renderer.renderMask({
|
||||
resolvedParams: mask.params,
|
||||
ctx: elementMaskCtx,
|
||||
|
|
@ -424,13 +434,18 @@ function buildMaskArtifacts({
|
|||
height: Math.round(transform.height),
|
||||
feather: mask.params.feather,
|
||||
});
|
||||
feather = 0;
|
||||
if (definition.renderer.renderMaskHandlesFeather) {
|
||||
feather = 0;
|
||||
}
|
||||
strokePath = definition.renderer.buildStrokePath?.({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
}) ?? null;
|
||||
} else {
|
||||
if (!definition.renderer.buildPath) {
|
||||
return { mask: null, strokeLayer: null };
|
||||
}
|
||||
const path2d = definition.renderer.buildPath({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
|
|
@ -472,7 +487,7 @@ function buildMaskArtifacts({
|
|||
});
|
||||
|
||||
let strokeLayer: FrameItemDescriptor | null = null;
|
||||
if (mask.params.strokeWidth > 0 && strokePath) {
|
||||
if (mask.params.strokeWidth > 0 && (strokePath || definition.renderer.renderStroke)) {
|
||||
const strokeCanvas = createOffscreenCanvas({
|
||||
width: Math.round(transform.width),
|
||||
height: Math.round(transform.height),
|
||||
|
|
@ -482,9 +497,18 @@ function buildMaskArtifacts({
|
|||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (strokeCtx) {
|
||||
strokeCtx.strokeStyle = mask.params.strokeColor;
|
||||
strokeCtx.lineWidth = mask.params.strokeWidth;
|
||||
strokeCtx.stroke(strokePath);
|
||||
if (definition.renderer.renderStroke) {
|
||||
definition.renderer.renderStroke({
|
||||
resolvedParams: mask.params,
|
||||
ctx: strokeCtx,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
});
|
||||
} else if (strokePath) {
|
||||
strokeCtx.strokeStyle = mask.params.strokeColor;
|
||||
strokeCtx.lineWidth = mask.params.strokeWidth;
|
||||
strokeCtx.stroke(strokePath);
|
||||
}
|
||||
|
||||
const fullStrokeCanvas = createOffscreenCanvas({
|
||||
width: renderer.width,
|
||||
|
|
|
|||
|
|
@ -3,16 +3,9 @@ import type { TextElement } from "@/lib/timeline";
|
|||
import type { EffectPass } from "@/lib/effects/types";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/lib/text/background";
|
||||
import {
|
||||
drawTextDecoration,
|
||||
getTextBackgroundRect,
|
||||
setCanvasLetterSpacing,
|
||||
} from "@/lib/text/layout";
|
||||
drawMeasuredTextLayout,
|
||||
} from "@/lib/text/primitives";
|
||||
import type { MeasuredTextElement } from "@/lib/text/measure-element";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
export type TextNodeParams = TextElement & {
|
||||
canvasCenter: { x: number; y: number };
|
||||
|
|
@ -46,22 +39,6 @@ export function renderTextToContext({
|
|||
const x = resolved.transform.position.x + node.params.canvasCenter.x;
|
||||
const y = resolved.transform.position.y + node.params.canvasCenter.y;
|
||||
const baseline = node.params.textBaseline ?? "middle";
|
||||
const {
|
||||
scaledFontSize,
|
||||
fontString,
|
||||
letterSpacing,
|
||||
lineHeightPx,
|
||||
lines,
|
||||
lineMetrics,
|
||||
block,
|
||||
fontSizeRatio,
|
||||
resolvedBackground,
|
||||
} = resolved.measuredText;
|
||||
const lineCount = lines.length;
|
||||
const resolvedBackgroundWithColor = {
|
||||
...resolvedBackground,
|
||||
color: resolved.backgroundColor,
|
||||
};
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
|
@ -70,60 +47,14 @@ export function renderTextToContext({
|
|||
ctx.rotate((resolved.transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
|
||||
ctx.font = fontString;
|
||||
ctx.textAlign = node.params.textAlign;
|
||||
ctx.textBaseline = baseline;
|
||||
ctx.fillStyle = resolved.textColor;
|
||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
|
||||
|
||||
if (
|
||||
node.params.background.enabled &&
|
||||
node.params.background.color &&
|
||||
node.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: node.params.textAlign,
|
||||
block,
|
||||
background: resolvedBackgroundWithColor,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
const p =
|
||||
clamp({
|
||||
value: resolvedBackgroundWithColor.cornerRadius,
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
}) / 100;
|
||||
const radius =
|
||||
(Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
|
||||
ctx.fillStyle = resolvedBackgroundWithColor.color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
backgroundRect.left,
|
||||
backgroundRect.top,
|
||||
backgroundRect.width,
|
||||
backgroundRect.height,
|
||||
radius,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = resolved.textColor;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < lineCount; index++) {
|
||||
const lineY = index * lineHeightPx - block.visualCenterOffset;
|
||||
ctx.fillText(lines[index], 0, lineY);
|
||||
drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration: node.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[index].width,
|
||||
lineY,
|
||||
metrics: lineMetrics[index],
|
||||
scaledFontSize,
|
||||
textAlign: node.params.textAlign,
|
||||
});
|
||||
}
|
||||
drawMeasuredTextLayout({
|
||||
ctx,
|
||||
layout: resolved.measuredText,
|
||||
textColor: resolved.textColor,
|
||||
background: resolved.measuredText.resolvedBackground,
|
||||
backgroundColor: resolved.backgroundColor,
|
||||
textBaseline: baseline,
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -25,10 +25,11 @@ import { V22toV23Migration } from "./v22-to-v23";
|
|||
import { V23toV24Migration } from "./v23-to-v24";
|
||||
import { V24toV25Migration } from "./v24-to-v25";
|
||||
import { V25toV26Migration } from "./v25-to-v26";
|
||||
import { V26toV27Migration } from "./v26-to-v27";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 26;
|
||||
export const CURRENT_PROJECT_VERSION = 27;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
|
|
@ -57,4 +58,5 @@ export const migrations = [
|
|||
new V23toV24Migration(),
|
||||
new V24toV25Migration(),
|
||||
new V25toV26Migration(),
|
||||
new V26toV27Migration(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue