feat: text / custom masks, fix preview pan, refactor selection
This commit is contained in:
parent
66e4367bc4
commit
c46d28891f
|
|
@ -1,233 +1,309 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
import {
|
import { ColorPickerContent } from "@/components/ui/color-picker";
|
||||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
import {
|
||||||
} from "@/lib/background/blur";
|
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||||
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
|
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
} from "@/lib/background/blur";
|
||||||
import { colors } from "@/data/colors/solid";
|
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
|
||||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { colors } from "@/data/colors/solid";
|
||||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||||
import { cn } from "@/utils/ui";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||||
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
|
import { cn } from "@/utils/ui";
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
|
||||||
} as const;
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
const BlurPreview = memo(
|
} as const;
|
||||||
({
|
|
||||||
blur,
|
const CUSTOM_COLOR_SWATCH_BACKGROUND =
|
||||||
isSelected,
|
"conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
|
||||||
onSelect,
|
|
||||||
}: {
|
const BlurPreview = memo(
|
||||||
blur: { label: string; value: number };
|
({
|
||||||
isSelected: boolean;
|
blur,
|
||||||
onSelect: () => void;
|
isSelected,
|
||||||
}) => {
|
onSelect,
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
}: {
|
||||||
|
blur: { label: string; value: number };
|
||||||
useEffect(() => {
|
isSelected: boolean;
|
||||||
const renderPreview = () => {
|
onSelect: () => void;
|
||||||
if (!canvasRef.current) return;
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
effectPreviewService.renderPreview({
|
|
||||||
effectType: "blur",
|
useEffect(() => {
|
||||||
params: { intensity: blur.value },
|
const renderPreview = () => {
|
||||||
targetCanvas: canvasRef.current,
|
if (!canvasRef.current) return;
|
||||||
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
|
|
||||||
});
|
effectPreviewService.renderPreview({
|
||||||
};
|
effectType: "blur",
|
||||||
|
params: { intensity: blur.value },
|
||||||
renderPreview();
|
targetCanvas: canvasRef.current,
|
||||||
return effectPreviewService.onPreviewImageReady({
|
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
|
||||||
callback: renderPreview,
|
});
|
||||||
});
|
};
|
||||||
}, [blur.value]);
|
|
||||||
|
renderPreview();
|
||||||
return (
|
return effectPreviewService.onPreviewImageReady({
|
||||||
<button
|
callback: renderPreview,
|
||||||
className={cn(
|
});
|
||||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
}, [blur.value]);
|
||||||
isSelected && "border-primary border-2",
|
|
||||||
)}
|
return (
|
||||||
onClick={onSelect}
|
<button
|
||||||
type="button"
|
className={cn(
|
||||||
aria-label={`Select ${blur.label} blur`}
|
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||||
>
|
isSelected && "border-primary border-2",
|
||||||
<canvas
|
)}
|
||||||
ref={canvasRef}
|
onClick={onSelect}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
type="button"
|
||||||
/>
|
aria-label={`Select ${blur.label} blur`}
|
||||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
>
|
||||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
<canvas
|
||||||
{blur.label}
|
ref={canvasRef}
|
||||||
</span>
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
</div>
|
/>
|
||||||
</button>
|
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||||
);
|
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||||
},
|
{blur.label}
|
||||||
);
|
</span>
|
||||||
|
</div>
|
||||||
BlurPreview.displayName = "BlurPreview";
|
</button>
|
||||||
|
);
|
||||||
const BackgroundPreviews = memo(
|
},
|
||||||
({
|
);
|
||||||
backgrounds,
|
|
||||||
currentBackgroundColor,
|
BlurPreview.displayName = "BlurPreview";
|
||||||
isColorBackground,
|
|
||||||
onSelect,
|
const BackgroundPreviews = memo(
|
||||||
useBackgroundColor = false,
|
({
|
||||||
}: {
|
backgrounds,
|
||||||
backgrounds: string[];
|
currentBackgroundColor,
|
||||||
currentBackgroundColor: string;
|
isColorBackground,
|
||||||
isColorBackground: boolean;
|
onSelect,
|
||||||
onSelect: (bg: string) => void;
|
useBackgroundColor = false,
|
||||||
useBackgroundColor?: boolean;
|
}: {
|
||||||
}) => {
|
backgrounds: readonly string[];
|
||||||
return useMemo(
|
currentBackgroundColor: string;
|
||||||
() =>
|
isColorBackground: boolean;
|
||||||
backgrounds.map((bg) => (
|
onSelect: (bg: string) => void;
|
||||||
<button
|
useBackgroundColor?: boolean;
|
||||||
key={bg}
|
}) => {
|
||||||
className={cn(
|
return useMemo(
|
||||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
() =>
|
||||||
isColorBackground &&
|
backgrounds.map((bg) => (
|
||||||
bg === currentBackgroundColor &&
|
<button
|
||||||
"border-primary border-2",
|
key={bg}
|
||||||
)}
|
className={cn(
|
||||||
style={
|
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||||
useBackgroundColor
|
isColorBackground &&
|
||||||
? { backgroundColor: bg }
|
bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
|
||||||
: {
|
"border-primary border-2",
|
||||||
background: bg,
|
)}
|
||||||
backgroundSize: "cover",
|
style={
|
||||||
backgroundPosition: "center",
|
useBackgroundColor
|
||||||
backgroundRepeat: "no-repeat",
|
? { backgroundColor: bg }
|
||||||
}
|
: {
|
||||||
}
|
background: bg,
|
||||||
onClick={() => onSelect(bg)}
|
backgroundSize: "cover",
|
||||||
type="button"
|
backgroundPosition: "center",
|
||||||
aria-label={`Select background ${bg}`}
|
backgroundRepeat: "no-repeat",
|
||||||
/>
|
}
|
||||||
)),
|
}
|
||||||
[
|
onClick={() => onSelect(bg)}
|
||||||
backgrounds,
|
type="button"
|
||||||
isColorBackground,
|
aria-label={`Select background ${bg}`}
|
||||||
currentBackgroundColor,
|
/>
|
||||||
onSelect,
|
)),
|
||||||
useBackgroundColor,
|
[
|
||||||
],
|
backgrounds,
|
||||||
);
|
isColorBackground,
|
||||||
},
|
currentBackgroundColor,
|
||||||
);
|
onSelect,
|
||||||
|
useBackgroundColor,
|
||||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
],
|
||||||
|
);
|
||||||
const COLOR_SECTIONS = [
|
},
|
||||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
);
|
||||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
|
||||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||||
] as const;
|
|
||||||
|
function CustomColorPreview({
|
||||||
export function BackgroundContent() {
|
currentBackgroundColor,
|
||||||
const editor = useEditor();
|
isSelected,
|
||||||
const activeProject = useEditor((e) => e.project.getActive());
|
onPreview,
|
||||||
|
onCommit,
|
||||||
const handleBlurSelect = useCallback(
|
}: {
|
||||||
async (blurIntensity: number) => {
|
currentBackgroundColor: string;
|
||||||
await editor.project.updateSettings({
|
isSelected: boolean;
|
||||||
settings: { background: { type: "blur", blurIntensity } },
|
onPreview: (color: string) => void;
|
||||||
});
|
onCommit: (color: string) => void;
|
||||||
},
|
}) {
|
||||||
[editor.project],
|
return (
|
||||||
);
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
const handleColorSelect = useCallback(
|
<button
|
||||||
async (color: string) => {
|
className={cn(
|
||||||
await editor.project.updateSettings({
|
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||||
settings: { background: { type: "color", color } },
|
isSelected && "border-primary border-2",
|
||||||
});
|
)}
|
||||||
},
|
type="button"
|
||||||
[editor.project],
|
aria-label="Pick a custom background color"
|
||||||
);
|
>
|
||||||
|
<span
|
||||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
className="absolute inset-0"
|
||||||
const isColorBackground = activeProject.settings.background.type === "color";
|
style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
|
||||||
|
/>
|
||||||
const currentBlurIntensity = isBlurBackground
|
<span
|
||||||
? (activeProject.settings.background as { blurIntensity: number })
|
className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
|
||||||
.blurIntensity
|
style={{ backgroundColor: currentBackgroundColor }}
|
||||||
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
/>
|
||||||
|
</button>
|
||||||
const currentBackgroundColor = isColorBackground
|
</PopoverTrigger>
|
||||||
? (activeProject.settings.background as { color: string }).color
|
<ColorPickerContent
|
||||||
: DEFAULT_BACKGROUND_COLOR;
|
value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
|
||||||
|
onChange={(color) => onPreview(`#${color}`)}
|
||||||
const blurPreviews = useMemo(
|
onChangeEnd={(color) => onCommit(`#${color}`)}
|
||||||
() =>
|
/>
|
||||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
</Popover>
|
||||||
<BlurPreview
|
);
|
||||||
key={blur.value}
|
}
|
||||||
blur={blur}
|
|
||||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
const COLOR_SECTIONS = [
|
||||||
onSelect={() => handleBlurSelect(blur.value)}
|
{ id: "colors", title: "Colors", backgrounds: colors, useBackgroundColor: true, showCustomPicker: true },
|
||||||
/>
|
{ id: "pattern-craft", title: "Pattern craft", backgrounds: patternCraftGradients, showCustomPicker: false },
|
||||||
)),
|
{ id: "syntax-ui", title: "Syntax UI", backgrounds: syntaxUIGradients, showCustomPicker: false },
|
||||||
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
] as const;
|
||||||
);
|
|
||||||
|
export function BackgroundContent() {
|
||||||
return (
|
const editor = useEditor();
|
||||||
<div className="flex flex-col">
|
const activeProject = useEditor((e) => e.project.getActive());
|
||||||
<Section
|
|
||||||
collapsible
|
const handleBlurSelect = useCallback(
|
||||||
defaultOpen={true}
|
async (blurIntensity: number) => {
|
||||||
sectionKey="background-blur"
|
await editor.project.updateSettings({
|
||||||
showTopBorder={false}
|
settings: { background: { type: "blur", blurIntensity } },
|
||||||
>
|
});
|
||||||
<SectionHeader>
|
},
|
||||||
<SectionTitle>Blur</SectionTitle>
|
[editor.project],
|
||||||
</SectionHeader>
|
);
|
||||||
<SectionContent>
|
|
||||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
const previewBackgroundColor = useCallback(
|
||||||
</SectionContent>
|
async (color: string) => {
|
||||||
</Section>
|
await editor.project.updateSettings({
|
||||||
{COLOR_SECTIONS.map((section) => (
|
settings: { background: { type: "color", color } },
|
||||||
<Section
|
pushHistory: false,
|
||||||
key={section.title}
|
});
|
||||||
collapsible
|
},
|
||||||
defaultOpen={false}
|
[editor.project],
|
||||||
sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
|
);
|
||||||
>
|
|
||||||
<SectionHeader>
|
const commitBackgroundColor = useCallback(
|
||||||
<SectionTitle>{section.title}</SectionTitle>
|
async (color: string) => {
|
||||||
</SectionHeader>
|
await editor.project.updateSettings({
|
||||||
<SectionContent>
|
settings: { background: { type: "color", color } },
|
||||||
<div className="flex flex-wrap gap-2">
|
pushHistory: true,
|
||||||
<BackgroundPreviews
|
});
|
||||||
backgrounds={section.backgrounds as string[]}
|
},
|
||||||
currentBackgroundColor={currentBackgroundColor}
|
[editor.project],
|
||||||
isColorBackground={isColorBackground}
|
);
|
||||||
onSelect={handleColorSelect}
|
|
||||||
useBackgroundColor={
|
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||||
"useBackgroundColor" in section
|
const isColorBackground = activeProject.settings.background.type === "color";
|
||||||
? section.useBackgroundColor
|
|
||||||
: false
|
const currentBlurIntensity = isBlurBackground
|
||||||
}
|
? (activeProject.settings.background as { blurIntensity: number })
|
||||||
/>
|
.blurIntensity
|
||||||
</div>
|
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
||||||
</SectionContent>
|
|
||||||
</Section>
|
const currentBackgroundColor = isColorBackground
|
||||||
))}
|
? (activeProject.settings.background as { color: string }).color
|
||||||
</div>
|
: DEFAULT_BACKGROUND_COLOR;
|
||||||
);
|
|
||||||
}
|
const hasPresetColorMatch = colors.some(
|
||||||
|
(color) => color.toLowerCase() === currentBackgroundColor.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePresetColorSelect = useCallback(
|
||||||
|
(color: string) => {
|
||||||
|
void commitBackgroundColor(color);
|
||||||
|
},
|
||||||
|
[commitBackgroundColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurPreviews = useMemo(
|
||||||
|
() =>
|
||||||
|
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||||
|
<BlurPreview
|
||||||
|
key={blur.value}
|
||||||
|
blur={blur}
|
||||||
|
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||||
|
onSelect={() => handleBlurSelect(blur.value)}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Section
|
||||||
|
collapsible
|
||||||
|
defaultOpen={true}
|
||||||
|
sectionKey="background-blur"
|
||||||
|
showTopBorder={false}
|
||||||
|
>
|
||||||
|
<SectionHeader>
|
||||||
|
<SectionTitle>Blur</SectionTitle>
|
||||||
|
</SectionHeader>
|
||||||
|
<SectionContent>
|
||||||
|
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||||
|
</SectionContent>
|
||||||
|
</Section>
|
||||||
|
{COLOR_SECTIONS.map((section) => (
|
||||||
|
<Section
|
||||||
|
key={section.id}
|
||||||
|
collapsible
|
||||||
|
defaultOpen={false}
|
||||||
|
sectionKey={`settings:background-${section.id}`}
|
||||||
|
>
|
||||||
|
<SectionHeader>
|
||||||
|
<SectionTitle>{section.title}</SectionTitle>
|
||||||
|
</SectionHeader>
|
||||||
|
<SectionContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{section.showCustomPicker ? (
|
||||||
|
<CustomColorPreview
|
||||||
|
currentBackgroundColor={currentBackgroundColor}
|
||||||
|
isSelected={isColorBackground && !hasPresetColorMatch}
|
||||||
|
onPreview={previewBackgroundColor}
|
||||||
|
onCommit={commitBackgroundColor}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<BackgroundPreviews
|
||||||
|
backgrounds={section.backgrounds}
|
||||||
|
currentBackgroundColor={currentBackgroundColor}
|
||||||
|
isColorBackground={isColorBackground}
|
||||||
|
onSelect={handlePresetColorSelect}
|
||||||
|
useBackgroundColor={
|
||||||
|
"useBackgroundColor" in section
|
||||||
|
? section.useBackgroundColor
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionContent>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import type { IconSvgElement } from "@hugeicons/react";
|
import type { IconSvgElement } from "@hugeicons/react";
|
||||||
|
|
||||||
export const HANDLE_SIZE = 10;
|
export const HANDLE_SIZE = 10;
|
||||||
export const HANDLE_HIT_AREA_SIZE = 18;
|
export const HANDLE_HIT_AREA_SIZE = 18;
|
||||||
export const ICON_HANDLE_RADIUS = 10;
|
export const ICON_HANDLE_RADIUS = 10;
|
||||||
export const EDGE_HANDLE_THIN_SIZE = 6;
|
export const EDGE_HANDLE_THIN_SIZE = 6;
|
||||||
export const EDGE_HANDLE_THICK_SIZE = 14;
|
export const EDGE_HANDLE_THICK_SIZE = 14;
|
||||||
export const LINE_HIT_AREA_SIZE = 48;
|
export const LINE_HIT_AREA_SIZE = 48;
|
||||||
|
|
||||||
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
|
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
|
||||||
const normalized = ((angleDeg % 180) + 180) % 180;
|
const normalized = ((angleDeg % 180) + 180) % 180;
|
||||||
if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
|
if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
|
||||||
if (normalized < 67.5) return "cursor-nwse-resize";
|
if (normalized < 67.5) return "nwse-resize";
|
||||||
if (normalized < 112.5) return "cursor-ns-resize";
|
if (normalized < 112.5) return "ns-resize";
|
||||||
return "cursor-nesw-resize";
|
return "nesw-resize";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HandleButton({
|
export function HandleButton({
|
||||||
screen,
|
screen,
|
||||||
cursor,
|
cursor,
|
||||||
hitAreaSize,
|
hitAreaSize,
|
||||||
className,
|
className,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
onPointerUp,
|
onPointerUp,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
screen: { x: number; y: number };
|
screen: { x: number; y: number };
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
hitAreaSize: number;
|
hitAreaSize: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
onPointerDown: (event: React.PointerEvent) => void;
|
onPointerDown: (event: React.PointerEvent) => void;
|
||||||
onPointerMove: (event: React.PointerEvent) => void;
|
onPointerMove: (event: React.PointerEvent) => void;
|
||||||
onPointerUp: (event: React.PointerEvent) => void;
|
onPointerUp: (event: React.PointerEvent) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute flex items-center justify-center outline-none",
|
"absolute flex items-center justify-center outline-none",
|
||||||
cursor,
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
style={{
|
||||||
style={{
|
left: screen.x - hitAreaSize / 2,
|
||||||
left: screen.x - hitAreaSize / 2,
|
top: screen.y - hitAreaSize / 2,
|
||||||
top: screen.y - hitAreaSize / 2,
|
width: hitAreaSize,
|
||||||
width: hitAreaSize,
|
height: hitAreaSize,
|
||||||
height: hitAreaSize,
|
pointerEvents: "auto",
|
||||||
pointerEvents: "auto",
|
cursor,
|
||||||
}}
|
}}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerLeave={onPointerUp}
|
onPointerLeave={onPointerUp}
|
||||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CornerHandle({
|
export function CornerHandle({
|
||||||
cursor,
|
cursor,
|
||||||
screen,
|
screen,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
onPointerUp,
|
onPointerUp,
|
||||||
}: {
|
}: {
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
screen: { x: number; y: number };
|
screen: { x: number; y: number };
|
||||||
onPointerDown: (event: React.PointerEvent) => void;
|
onPointerDown: (event: React.PointerEvent) => void;
|
||||||
onPointerMove: (event: React.PointerEvent) => void;
|
onPointerMove: (event: React.PointerEvent) => void;
|
||||||
onPointerUp: (event: React.PointerEvent) => void;
|
onPointerUp: (event: React.PointerEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<HandleButton
|
<HandleButton
|
||||||
screen={screen}
|
screen={screen}
|
||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rounded-sm bg-white"
|
className="rounded-sm bg-white"
|
||||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||||
/>
|
/>
|
||||||
</HandleButton>
|
</HandleButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EdgeHandle({
|
export function CircleHandle({
|
||||||
edge,
|
cursor,
|
||||||
screen,
|
screen,
|
||||||
rotation,
|
size = HANDLE_SIZE,
|
||||||
onPointerDown,
|
isSelected = false,
|
||||||
onPointerMove,
|
onPointerDown,
|
||||||
onPointerUp,
|
onPointerMove,
|
||||||
}: {
|
onPointerUp,
|
||||||
edge: "right" | "left" | "bottom";
|
}: {
|
||||||
screen: { x: number; y: number };
|
cursor?: string;
|
||||||
rotation: number;
|
screen: { x: number; y: number };
|
||||||
onPointerDown: (event: React.PointerEvent) => void;
|
size?: number;
|
||||||
onPointerMove: (event: React.PointerEvent) => void;
|
isSelected?: boolean;
|
||||||
onPointerUp: (event: React.PointerEvent) => void;
|
onPointerDown: (event: React.PointerEvent) => void;
|
||||||
}) {
|
onPointerMove: (event: React.PointerEvent) => void;
|
||||||
const isHorizontalEdge = edge === "right" || edge === "left";
|
onPointerUp: (event: React.PointerEvent) => void;
|
||||||
const width = isHorizontalEdge
|
}) {
|
||||||
? EDGE_HANDLE_THIN_SIZE
|
return (
|
||||||
: EDGE_HANDLE_THICK_SIZE;
|
<HandleButton
|
||||||
const height = isHorizontalEdge
|
screen={screen}
|
||||||
? EDGE_HANDLE_THICK_SIZE
|
cursor={cursor}
|
||||||
: EDGE_HANDLE_THIN_SIZE;
|
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||||
const cursor = getResizeCursor({
|
onPointerDown={onPointerDown}
|
||||||
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
|
onPointerMove={onPointerMove}
|
||||||
});
|
onPointerUp={onPointerUp}
|
||||||
|
>
|
||||||
return (
|
<div
|
||||||
<HandleButton
|
className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
|
||||||
screen={screen}
|
style={{ width: size, height: size }}
|
||||||
cursor={cursor}
|
/>
|
||||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
</HandleButton>
|
||||||
onPointerDown={onPointerDown}
|
);
|
||||||
onPointerMove={onPointerMove}
|
}
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
>
|
export function EdgeHandle({
|
||||||
<div
|
edge,
|
||||||
className="rounded-sm bg-white"
|
screen,
|
||||||
style={{ width, height, transform: `rotate(${rotation}deg)` }}
|
rotation,
|
||||||
/>
|
onPointerDown,
|
||||||
</HandleButton>
|
onPointerMove,
|
||||||
);
|
onPointerUp,
|
||||||
}
|
}: {
|
||||||
|
edge: "right" | "left" | "bottom";
|
||||||
export function IconHandle({
|
screen: { x: number; y: number };
|
||||||
icon,
|
rotation: number;
|
||||||
screen,
|
onPointerDown: (event: React.PointerEvent) => void;
|
||||||
onPointerDown,
|
onPointerMove: (event: React.PointerEvent) => void;
|
||||||
onPointerMove,
|
onPointerUp: (event: React.PointerEvent) => void;
|
||||||
onPointerUp,
|
}) {
|
||||||
}: {
|
const isHorizontalEdge = edge === "right" || edge === "left";
|
||||||
icon: IconSvgElement;
|
const width = isHorizontalEdge
|
||||||
screen: { x: number; y: number };
|
? EDGE_HANDLE_THIN_SIZE
|
||||||
onPointerDown: (event: React.PointerEvent) => void;
|
: EDGE_HANDLE_THICK_SIZE;
|
||||||
onPointerMove: (event: React.PointerEvent) => void;
|
const height = isHorizontalEdge
|
||||||
onPointerUp: (event: React.PointerEvent) => void;
|
? EDGE_HANDLE_THICK_SIZE
|
||||||
}) {
|
: EDGE_HANDLE_THIN_SIZE;
|
||||||
return (
|
const cursor = getResizeCursor({
|
||||||
<HandleButton
|
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
|
||||||
screen={screen}
|
});
|
||||||
hitAreaSize={ICON_HANDLE_RADIUS * 2}
|
|
||||||
className="rounded-full bg-white text-black shadow-sm"
|
return (
|
||||||
onPointerDown={onPointerDown}
|
<HandleButton
|
||||||
onPointerMove={onPointerMove}
|
screen={screen}
|
||||||
onPointerUp={onPointerUp}
|
cursor={cursor}
|
||||||
>
|
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||||
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
|
onPointerDown={onPointerDown}
|
||||||
</HandleButton>
|
onPointerMove={onPointerMove}
|
||||||
);
|
onPointerUp={onPointerUp}
|
||||||
}
|
>
|
||||||
|
<div
|
||||||
export function BoundingBoxOutline({
|
className="rounded-sm bg-white"
|
||||||
center,
|
style={{ width, height, transform: `rotate(${rotation}deg)` }}
|
||||||
outlineWidth,
|
/>
|
||||||
outlineHeight,
|
</HandleButton>
|
||||||
rotation,
|
);
|
||||||
cursor,
|
}
|
||||||
dashed = false,
|
|
||||||
onPointerDown,
|
export function IconHandle({
|
||||||
onPointerMove,
|
icon,
|
||||||
onPointerUp,
|
screen,
|
||||||
}: {
|
onPointerDown,
|
||||||
center: { x: number; y: number };
|
onPointerMove,
|
||||||
outlineWidth: number;
|
onPointerUp,
|
||||||
outlineHeight: number;
|
}: {
|
||||||
rotation: number;
|
icon: IconSvgElement;
|
||||||
cursor?: string;
|
screen: { x: number; y: number };
|
||||||
dashed?: boolean;
|
onPointerDown: (event: React.PointerEvent) => void;
|
||||||
onPointerDown?: (event: React.PointerEvent) => void;
|
onPointerMove: (event: React.PointerEvent) => void;
|
||||||
onPointerMove?: (event: React.PointerEvent) => void;
|
onPointerUp: (event: React.PointerEvent) => void;
|
||||||
onPointerUp?: (event: React.PointerEvent) => void;
|
}) {
|
||||||
}) {
|
return (
|
||||||
return (
|
<HandleButton
|
||||||
<svg
|
screen={screen}
|
||||||
className={cn("absolute overflow-visible", cursor)}
|
hitAreaSize={ICON_HANDLE_RADIUS * 2}
|
||||||
aria-hidden="true"
|
className="rounded-full bg-white text-black shadow-sm"
|
||||||
focusable="false"
|
onPointerDown={onPointerDown}
|
||||||
style={{
|
onPointerMove={onPointerMove}
|
||||||
left: center.x - outlineWidth / 2,
|
onPointerUp={onPointerUp}
|
||||||
top: center.y - outlineHeight / 2,
|
>
|
||||||
width: outlineWidth,
|
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
|
||||||
height: outlineHeight,
|
</HandleButton>
|
||||||
transform: `rotate(${rotation}deg)`,
|
);
|
||||||
transformOrigin: "center center",
|
}
|
||||||
pointerEvents: onPointerDown ? "auto" : "none",
|
|
||||||
}}
|
export function BoundingBoxOutline({
|
||||||
onPointerDown={onPointerDown}
|
center,
|
||||||
onPointerMove={onPointerMove}
|
outlineWidth,
|
||||||
onPointerUp={onPointerUp}
|
outlineHeight,
|
||||||
onPointerLeave={onPointerUp}
|
rotation,
|
||||||
>
|
cursor,
|
||||||
<rect
|
dashed = false,
|
||||||
x={0.5}
|
onPointerDown,
|
||||||
y={0.5}
|
onPointerMove,
|
||||||
width={Math.max(outlineWidth - 1, 0)}
|
onPointerUp,
|
||||||
height={Math.max(outlineHeight - 1, 0)}
|
}: {
|
||||||
fill="transparent"
|
center: { x: number; y: number };
|
||||||
stroke="white"
|
outlineWidth: number;
|
||||||
strokeDasharray={dashed ? "4 4" : undefined}
|
outlineHeight: number;
|
||||||
strokeOpacity={0.75}
|
rotation: number;
|
||||||
vectorEffect="non-scaling-stroke"
|
cursor?: string;
|
||||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
dashed?: boolean;
|
||||||
/>
|
onPointerDown?: (event: React.PointerEvent) => void;
|
||||||
</svg>
|
onPointerMove?: (event: React.PointerEvent) => void;
|
||||||
);
|
onPointerUp?: (event: React.PointerEvent) => void;
|
||||||
}
|
}) {
|
||||||
|
return (
|
||||||
export function ShapeOutline({
|
<svg
|
||||||
center,
|
className="absolute overflow-visible"
|
||||||
outlineWidth,
|
aria-hidden="true"
|
||||||
outlineHeight,
|
focusable="false"
|
||||||
rotation,
|
style={{
|
||||||
pathData,
|
left: center.x - outlineWidth / 2,
|
||||||
cursor,
|
top: center.y - outlineHeight / 2,
|
||||||
onPointerDown,
|
width: outlineWidth,
|
||||||
onPointerMove,
|
height: outlineHeight,
|
||||||
onPointerUp,
|
transform: `rotate(${rotation}deg)`,
|
||||||
}: {
|
transformOrigin: "center center",
|
||||||
center: { x: number; y: number };
|
pointerEvents: onPointerDown ? "auto" : "none",
|
||||||
outlineWidth: number;
|
cursor,
|
||||||
outlineHeight: number;
|
}}
|
||||||
rotation: number;
|
onPointerDown={onPointerDown}
|
||||||
pathData: string;
|
onPointerMove={onPointerMove}
|
||||||
cursor?: string;
|
onPointerUp={onPointerUp}
|
||||||
onPointerDown?: (event: React.PointerEvent) => void;
|
onPointerLeave={onPointerUp}
|
||||||
onPointerMove?: (event: React.PointerEvent) => void;
|
>
|
||||||
onPointerUp?: (event: React.PointerEvent) => void;
|
<rect
|
||||||
}) {
|
x={0.5}
|
||||||
return (
|
y={0.5}
|
||||||
<svg
|
width={Math.max(outlineWidth - 1, 0)}
|
||||||
className={cn("absolute overflow-visible", cursor)}
|
height={Math.max(outlineHeight - 1, 0)}
|
||||||
aria-hidden="true"
|
fill="transparent"
|
||||||
focusable="false"
|
stroke="white"
|
||||||
style={{
|
strokeDasharray={dashed ? "4 4" : undefined}
|
||||||
left: center.x - outlineWidth / 2,
|
strokeOpacity={0.75}
|
||||||
top: center.y - outlineHeight / 2,
|
vectorEffect="non-scaling-stroke"
|
||||||
width: outlineWidth,
|
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||||
height: outlineHeight,
|
/>
|
||||||
transform: `rotate(${rotation}deg)`,
|
</svg>
|
||||||
transformOrigin: "center center",
|
);
|
||||||
pointerEvents: onPointerDown ? "auto" : "none",
|
}
|
||||||
}}
|
|
||||||
onPointerDown={onPointerDown}
|
export function ShapeOutline({
|
||||||
onPointerMove={onPointerMove}
|
center,
|
||||||
onPointerUp={onPointerUp}
|
outlineWidth,
|
||||||
onPointerLeave={onPointerUp}
|
outlineHeight,
|
||||||
>
|
rotation,
|
||||||
<path
|
pathData,
|
||||||
d={pathData}
|
cursor,
|
||||||
fill="transparent"
|
onPointerDown,
|
||||||
stroke="white"
|
onPointerMove,
|
||||||
strokeOpacity={0.75}
|
onPointerUp,
|
||||||
vectorEffect="non-scaling-stroke"
|
}: {
|
||||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
center: { x: number; y: number };
|
||||||
/>
|
outlineWidth: number;
|
||||||
</svg>
|
outlineHeight: number;
|
||||||
);
|
rotation: number;
|
||||||
}
|
pathData: string;
|
||||||
|
cursor?: string;
|
||||||
export function LineOverlay({
|
onPointerDown?: (event: React.PointerEvent) => void;
|
||||||
start,
|
onPointerMove?: (event: React.PointerEvent) => void;
|
||||||
end,
|
onPointerUp?: (event: React.PointerEvent) => void;
|
||||||
onPointerDown,
|
}) {
|
||||||
onPointerMove,
|
return (
|
||||||
onPointerUp,
|
<svg
|
||||||
}: {
|
className="absolute overflow-visible"
|
||||||
start: { x: number; y: number };
|
aria-hidden="true"
|
||||||
end: { x: number; y: number };
|
focusable="false"
|
||||||
onPointerDown?: (event: React.PointerEvent) => void;
|
style={{
|
||||||
onPointerMove?: (event: React.PointerEvent) => void;
|
left: center.x - outlineWidth / 2,
|
||||||
onPointerUp?: (event: React.PointerEvent) => void;
|
top: center.y - outlineHeight / 2,
|
||||||
}) {
|
width: outlineWidth,
|
||||||
const dx = end.x - start.x;
|
height: outlineHeight,
|
||||||
const dy = end.y - start.y;
|
transform: `rotate(${rotation}deg)`,
|
||||||
const length = Math.sqrt(dx * dx + dy * dy);
|
transformOrigin: "center center",
|
||||||
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
pointerEvents: onPointerDown ? "auto" : "none",
|
||||||
const cx = (start.x + end.x) / 2;
|
cursor,
|
||||||
const cy = (start.y + end.y) / 2;
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
const sharedStyle = {
|
onPointerMove={onPointerMove}
|
||||||
left: cx - length / 2,
|
onPointerUp={onPointerUp}
|
||||||
width: length,
|
onPointerLeave={onPointerUp}
|
||||||
transform: `rotate(${angleDeg}deg)`,
|
>
|
||||||
transformOrigin: "center center",
|
<path
|
||||||
};
|
d={pathData}
|
||||||
|
fill="transparent"
|
||||||
return (
|
stroke="white"
|
||||||
<>
|
strokeOpacity={0.75}
|
||||||
{onPointerDown && (
|
vectorEffect="non-scaling-stroke"
|
||||||
<div
|
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||||
className="absolute"
|
/>
|
||||||
style={{
|
</svg>
|
||||||
...sharedStyle,
|
);
|
||||||
top: cy - LINE_HIT_AREA_SIZE / 2,
|
}
|
||||||
height: LINE_HIT_AREA_SIZE,
|
|
||||||
pointerEvents: "auto",
|
export function CanvasPathOutline({
|
||||||
}}
|
pathData,
|
||||||
onPointerDown={onPointerDown}
|
translateX = 0,
|
||||||
onPointerMove={onPointerMove}
|
translateY = 0,
|
||||||
onPointerUp={onPointerUp}
|
scaleX = 1,
|
||||||
onPointerLeave={onPointerUp}
|
scaleY = 1,
|
||||||
/>
|
cursor,
|
||||||
)}
|
strokeWidth = 1,
|
||||||
<div
|
strokeOpacity = 0.75,
|
||||||
className="pointer-events-none absolute"
|
onPointerDown,
|
||||||
style={{
|
onPointerMove,
|
||||||
...sharedStyle,
|
onPointerUp,
|
||||||
top: cy - 0.5,
|
}: {
|
||||||
height: 1,
|
pathData: string;
|
||||||
backgroundColor: "white",
|
translateX?: number;
|
||||||
opacity: 0.75,
|
translateY?: number;
|
||||||
}}
|
scaleX?: number;
|
||||||
/>
|
scaleY?: number;
|
||||||
</>
|
cursor?: string;
|
||||||
);
|
strokeWidth?: number;
|
||||||
}
|
strokeOpacity?: number;
|
||||||
|
onPointerDown?: (event: React.PointerEvent) => void;
|
||||||
|
onPointerMove?: (event: React.PointerEvent) => void;
|
||||||
|
onPointerUp?: (event: React.PointerEvent) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 overflow-visible"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
style={{
|
||||||
|
pointerEvents: onPointerDown ? "auto" : "none",
|
||||||
|
cursor,
|
||||||
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="transparent"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineOverlay({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
cursor,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
}: {
|
||||||
|
start: { x: number; y: number };
|
||||||
|
end: { x: number; y: number };
|
||||||
|
cursor?: string;
|
||||||
|
onPointerDown?: (event: React.PointerEvent) => void;
|
||||||
|
onPointerMove?: (event: React.PointerEvent) => void;
|
||||||
|
onPointerUp?: (event: React.PointerEvent) => void;
|
||||||
|
}) {
|
||||||
|
const dx = end.x - start.x;
|
||||||
|
const dy = end.y - start.y;
|
||||||
|
const length = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||||
|
const cx = (start.x + end.x) / 2;
|
||||||
|
const cy = (start.y + end.y) / 2;
|
||||||
|
|
||||||
|
const sharedStyle = {
|
||||||
|
left: cx - length / 2,
|
||||||
|
width: length,
|
||||||
|
transform: `rotate(${angleDeg}deg)`,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{onPointerDown && (
|
||||||
|
<div
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
...sharedStyle,
|
||||||
|
top: cy - LINE_HIT_AREA_SIZE / 2,
|
||||||
|
height: LINE_HIT_AREA_SIZE,
|
||||||
|
pointerEvents: "auto",
|
||||||
|
cursor,
|
||||||
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
...sharedStyle,
|
||||||
|
top: cy - 0.5,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: "white",
|
||||||
|
opacity: 0.75,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
|
||||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||||
import { useMaskHandles } from "@/hooks/use-mask-handles";
|
import { useMaskHandles } from "@/hooks/use-mask-handles";
|
||||||
import { masksRegistry } from "@/lib/masks";
|
|
||||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||||
import type { ParamValues } from "@/lib/params";
|
|
||||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
|
||||||
import {
|
import {
|
||||||
CornerHandle,
|
CornerHandle,
|
||||||
|
CircleHandle,
|
||||||
|
CanvasPathOutline,
|
||||||
EdgeHandle,
|
EdgeHandle,
|
||||||
IconHandle,
|
IconHandle,
|
||||||
LineOverlay,
|
LineOverlay,
|
||||||
BoundingBoxOutline,
|
BoundingBoxOutline,
|
||||||
ShapeOutline,
|
ShapeOutline,
|
||||||
} from "./handle-primitives";
|
} from "./handle-primitives";
|
||||||
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
|
|
||||||
|
|
||||||
function hasRectangleOutlineParams(
|
const CUSTOM_MASK_ANCHOR_SIZE = 7;
|
||||||
params: ParamValues,
|
const CUSTOM_MASK_TANGENT_SIZE = 6;
|
||||||
): params is RectangleMaskParams {
|
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
|
||||||
return (
|
|
||||||
typeof params.centerX === "number" &&
|
|
||||||
typeof params.centerY === "number" &&
|
|
||||||
typeof params.width === "number" &&
|
|
||||||
typeof params.height === "number"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MaskHandles({
|
export function MaskHandles({
|
||||||
onSnapLinesChange,
|
onSnapLinesChange,
|
||||||
|
|
@ -36,7 +28,9 @@ export function MaskHandles({
|
||||||
const {
|
const {
|
||||||
selectedWithMask,
|
selectedWithMask,
|
||||||
handlePositions,
|
handlePositions,
|
||||||
linePoints,
|
overlays,
|
||||||
|
isCreatingCustomMask,
|
||||||
|
handleCanvasPointerDown,
|
||||||
handlePointerDown,
|
handlePointerDown,
|
||||||
handlePointerMove,
|
handlePointerMove,
|
||||||
handlePointerUp,
|
handlePointerUp,
|
||||||
|
|
@ -56,96 +50,158 @@ export function MaskHandles({
|
||||||
canvasY,
|
canvasY,
|
||||||
});
|
});
|
||||||
|
|
||||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
|
||||||
const { bounds } = selectedWithMask;
|
|
||||||
const maskRotation = selectedWithMask.mask.params.rotation;
|
|
||||||
|
|
||||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||||
|
const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
|
||||||
|
|
||||||
const rectangleOutlineProps = hasRectangleOutlineParams(
|
const onPointerMove = (event: React.PointerEvent) => {
|
||||||
selectedWithMask.mask.params,
|
if (viewport.handlePanPointerMove({ event })) {
|
||||||
)
|
return;
|
||||||
? {
|
}
|
||||||
center: toOverlay({
|
|
||||||
canvasX:
|
|
||||||
bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
|
|
||||||
canvasY:
|
|
||||||
bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
|
|
||||||
}),
|
|
||||||
outlineWidth:
|
|
||||||
selectedWithMask.mask.params.width * bounds.width * scaleX,
|
|
||||||
outlineHeight:
|
|
||||||
selectedWithMask.mask.params.height * bounds.height * scaleY,
|
|
||||||
rotation: maskRotation,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const onPointerMove = (event: React.PointerEvent) =>
|
|
||||||
handlePointerMove({ event });
|
handlePointerMove({ event });
|
||||||
const onPointerUp = () => handlePointerUp();
|
};
|
||||||
|
const onPointerUp = (event: React.PointerEvent) => {
|
||||||
|
if (viewport.handlePanPointerUp({ event })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerUp();
|
||||||
|
};
|
||||||
|
const handleMaskPointerDown = ({
|
||||||
|
event,
|
||||||
|
handleId,
|
||||||
|
}: {
|
||||||
|
event: React.PointerEvent;
|
||||||
|
handleId: string;
|
||||||
|
}) => {
|
||||||
|
if (viewport.handlePanPointerDown({ event })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerDown({ event, handleId });
|
||||||
|
};
|
||||||
|
const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => {
|
||||||
|
if (viewport.handlePanPointerDown({ event })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCanvasPointerDown({ event });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{def.overlayShape === "line" && linePoints && (
|
{isCreatingCustomMask ? (
|
||||||
<LineOverlay
|
<div
|
||||||
start={toOverlay({
|
className="absolute inset-0 pointer-events-auto"
|
||||||
canvasX: linePoints.start.x,
|
style={{ cursor: PEN_CURSOR }}
|
||||||
canvasY: linePoints.start.y,
|
onPointerDown={handleCanvasOverlayPointerDown}
|
||||||
})}
|
|
||||||
end={toOverlay({
|
|
||||||
canvasX: linePoints.end.x,
|
|
||||||
canvasY: linePoints.end.y,
|
|
||||||
})}
|
|
||||||
onPointerDown={(event) =>
|
|
||||||
handlePointerDown({ event, handleId: "position" })
|
|
||||||
}
|
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
{def.overlayShape === "box" && rectangleOutlineProps && (
|
{overlays.map((overlay) => {
|
||||||
def.buildOverlayPath ? (
|
const overlayHandleId = overlay.handleId;
|
||||||
<>
|
const pointerHandlers = overlayHandleId
|
||||||
<BoundingBoxOutline {...rectangleOutlineProps} dashed />
|
? {
|
||||||
<ShapeOutline
|
onPointerDown: (event: React.PointerEvent) =>
|
||||||
{...rectangleOutlineProps}
|
handleMaskPointerDown({ event, handleId: overlayHandleId }),
|
||||||
pathData={def.buildOverlayPath({
|
onPointerMove,
|
||||||
width: rectangleOutlineProps.outlineWidth,
|
onPointerUp,
|
||||||
height: rectangleOutlineProps.outlineHeight,
|
}
|
||||||
})}
|
: {};
|
||||||
onPointerDown={(event) =>
|
|
||||||
handlePointerDown({ event, handleId: "position" })
|
if (overlay.type === "line") {
|
||||||
}
|
return (
|
||||||
onPointerMove={onPointerMove}
|
<LineOverlay
|
||||||
onPointerUp={onPointerUp}
|
key={overlay.id}
|
||||||
/>
|
cursor={overlay.cursor}
|
||||||
</>
|
start={toOverlay({
|
||||||
) : (
|
canvasX: overlay.start.x,
|
||||||
<BoundingBoxOutline
|
canvasY: overlay.start.y,
|
||||||
{...rectangleOutlineProps}
|
})}
|
||||||
cursor="cursor-move"
|
end={toOverlay({
|
||||||
onPointerDown={(event) =>
|
canvasX: overlay.end.x,
|
||||||
handlePointerDown({ event, handleId: "position" })
|
canvasY: overlay.end.y,
|
||||||
}
|
})}
|
||||||
onPointerMove={onPointerMove}
|
{...pointerHandlers}
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
)}
|
}
|
||||||
|
|
||||||
|
if (overlay.type === "rect") {
|
||||||
|
return (
|
||||||
|
<BoundingBoxOutline
|
||||||
|
key={overlay.id}
|
||||||
|
center={toOverlay({
|
||||||
|
canvasX: overlay.center.x,
|
||||||
|
canvasY: overlay.center.y,
|
||||||
|
})}
|
||||||
|
outlineWidth={overlay.width * scaleX}
|
||||||
|
outlineHeight={overlay.height * scaleY}
|
||||||
|
rotation={overlay.rotation}
|
||||||
|
cursor={overlay.cursor}
|
||||||
|
dashed={overlay.dashed}
|
||||||
|
{...pointerHandlers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay.type === "shape") {
|
||||||
|
return (
|
||||||
|
<ShapeOutline
|
||||||
|
key={overlay.id}
|
||||||
|
center={toOverlay({
|
||||||
|
canvasX: overlay.center.x,
|
||||||
|
canvasY: overlay.center.y,
|
||||||
|
})}
|
||||||
|
outlineWidth={overlay.width * scaleX}
|
||||||
|
outlineHeight={overlay.height * scaleY}
|
||||||
|
rotation={overlay.rotation}
|
||||||
|
pathData={overlay.pathData}
|
||||||
|
cursor={overlay.cursor}
|
||||||
|
{...pointerHandlers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay.type === "canvas-path") {
|
||||||
|
return (
|
||||||
|
<CanvasPathOutline
|
||||||
|
key={overlay.id}
|
||||||
|
pathData={overlay.pathData}
|
||||||
|
translateX={
|
||||||
|
overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0
|
||||||
|
}
|
||||||
|
translateY={
|
||||||
|
overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0
|
||||||
|
}
|
||||||
|
scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1}
|
||||||
|
scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1}
|
||||||
|
cursor={overlay.cursor}
|
||||||
|
strokeWidth={overlay.strokeWidth}
|
||||||
|
strokeOpacity={overlay.strokeOpacity}
|
||||||
|
{...pointerHandlers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
{handlePositions.map((handle) => {
|
{handlePositions.map((handle) => {
|
||||||
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
|
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
|
||||||
|
|
||||||
if (handle.id === "rotation") {
|
if (handle.kind === "icon" && handle.icon === "rotate") {
|
||||||
return (
|
return (
|
||||||
<IconHandle
|
<IconHandle
|
||||||
key={handle.id}
|
key={handle.id}
|
||||||
icon={Rotate01Icon}
|
icon={Rotate01Icon}
|
||||||
screen={screen}
|
screen={screen}
|
||||||
onPointerDown={(event) =>
|
onPointerDown={(event) =>
|
||||||
handlePointerDown({ event, handleId: handle.id })
|
handleMaskPointerDown({ event, handleId: handle.id })
|
||||||
}
|
}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|
@ -153,14 +209,14 @@ export function MaskHandles({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handle.id === "feather") {
|
if (handle.kind === "icon" && handle.icon === "feather") {
|
||||||
return (
|
return (
|
||||||
<IconHandle
|
<IconHandle
|
||||||
key={handle.id}
|
key={handle.id}
|
||||||
icon={FeatherIcon}
|
icon={FeatherIcon}
|
||||||
screen={screen}
|
screen={screen}
|
||||||
onPointerDown={(event) =>
|
onPointerDown={(event) =>
|
||||||
handlePointerDown({ event, handleId: handle.id })
|
handleMaskPointerDown({ event, handleId: handle.id })
|
||||||
}
|
}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|
@ -168,15 +224,15 @@ export function MaskHandles({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handle.id === "right" || handle.id === "left") {
|
if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
|
||||||
return (
|
return (
|
||||||
<EdgeHandle
|
<EdgeHandle
|
||||||
key={handle.id}
|
key={handle.id}
|
||||||
edge="right"
|
edge="right"
|
||||||
screen={screen}
|
screen={screen}
|
||||||
rotation={maskRotation}
|
rotation={handle.rotation ?? 0}
|
||||||
onPointerDown={(event) =>
|
onPointerDown={(event) =>
|
||||||
handlePointerDown({ event, handleId: handle.id })
|
handleMaskPointerDown({ event, handleId: handle.id })
|
||||||
}
|
}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|
@ -184,15 +240,15 @@ export function MaskHandles({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handle.id === "bottom" || handle.id === "top") {
|
if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
|
||||||
return (
|
return (
|
||||||
<EdgeHandle
|
<EdgeHandle
|
||||||
key={handle.id}
|
key={handle.id}
|
||||||
edge="bottom"
|
edge="bottom"
|
||||||
screen={screen}
|
screen={screen}
|
||||||
rotation={maskRotation}
|
rotation={handle.rotation ?? 0}
|
||||||
onPointerDown={(event) =>
|
onPointerDown={(event) =>
|
||||||
handlePointerDown({ event, handleId: handle.id })
|
handleMaskPointerDown({ event, handleId: handle.id })
|
||||||
}
|
}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|
@ -200,19 +256,33 @@ export function MaskHandles({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (handle.kind === "point" || handle.kind === "tangent") {
|
||||||
handle.id === "top-left" ||
|
return (
|
||||||
handle.id === "top-right" ||
|
<CircleHandle
|
||||||
handle.id === "bottom-left" ||
|
key={handle.id}
|
||||||
handle.id === "bottom-right" ||
|
screen={screen}
|
||||||
handle.id === "scale"
|
size={
|
||||||
) {
|
handle.kind === "tangent"
|
||||||
|
? CUSTOM_MASK_TANGENT_SIZE
|
||||||
|
: CUSTOM_MASK_ANCHOR_SIZE
|
||||||
|
}
|
||||||
|
isSelected={handle.isSelected}
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
handleMaskPointerDown({ event, handleId: handle.id })
|
||||||
|
}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle.kind === "corner") {
|
||||||
return (
|
return (
|
||||||
<CornerHandle
|
<CornerHandle
|
||||||
key={handle.id}
|
key={handle.id}
|
||||||
screen={screen}
|
screen={screen}
|
||||||
onPointerDown={(event) =>
|
onPointerDown={(event) =>
|
||||||
handlePointerDown({ event, handleId: handle.id })
|
handleMaskPointerDown({ event, handleId: handle.id })
|
||||||
}
|
}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|
@ -226,7 +296,7 @@ export function MaskHandles({
|
||||||
cursor={handle.cursor}
|
cursor={handle.cursor}
|
||||||
screen={screen}
|
screen={screen}
|
||||||
onPointerDown={(event) =>
|
onPointerDown={(event) =>
|
||||||
handlePointerDown({ event, handleId: handle.id })
|
handleMaskPointerDown({ event, handleId: handle.id })
|
||||||
}
|
}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ import { useCallback, useEffect, useRef } from "react";
|
||||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import type { TextElement } from "@/lib/timeline";
|
import type { TextElement } from "@/lib/timeline";
|
||||||
import {
|
|
||||||
FONT_SIZE_SCALE_REFERENCE,
|
|
||||||
} from "@/lib/text/typography";
|
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveTransformAtTime,
|
resolveTransformAtTime,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
|
import { resolveTextLayout } from "@/lib/text/primitives";
|
||||||
|
|
||||||
export function TextEditOverlay({
|
export function TextEditOverlay({
|
||||||
trackId,
|
trackId,
|
||||||
|
|
@ -82,20 +80,29 @@ export function TextEditOverlay({
|
||||||
});
|
});
|
||||||
|
|
||||||
const { x: displayScaleX } = viewport.getDisplayScale();
|
const { x: displayScaleX } = viewport.getDisplayScale();
|
||||||
|
const resolvedTextLayout = resolveTextLayout({
|
||||||
const scaledFontSize =
|
text: {
|
||||||
element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
|
content: element.content,
|
||||||
|
fontSize: element.fontSize,
|
||||||
|
fontFamily: element.fontFamily,
|
||||||
|
fontWeight: element.fontWeight,
|
||||||
|
fontStyle: element.fontStyle,
|
||||||
|
textAlign: element.textAlign,
|
||||||
|
textDecoration: element.textDecoration,
|
||||||
|
letterSpacing: element.letterSpacing,
|
||||||
|
lineHeight: element.lineHeight,
|
||||||
|
},
|
||||||
|
canvasHeight: canvasSize.height,
|
||||||
|
});
|
||||||
|
|
||||||
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
|
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
|
||||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
|
||||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
|
||||||
const canvasLetterSpacing = element.letterSpacing ?? 0;
|
const canvasLetterSpacing = element.letterSpacing ?? 0;
|
||||||
const lineHeightPx = scaledFontSize * lineHeight;
|
const lineHeightPx = resolvedTextLayout.lineHeightPx;
|
||||||
|
|
||||||
const bg = element.background;
|
const bg = element.background;
|
||||||
const shouldShowBackground =
|
const shouldShowBackground =
|
||||||
bg.enabled && bg.color && bg.color !== "transparent";
|
bg.enabled && bg.color && bg.color !== "transparent";
|
||||||
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
|
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
|
||||||
const canvasPaddingX = shouldShowBackground
|
const canvasPaddingX = shouldShowBackground
|
||||||
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
|
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
|
||||||
: 0;
|
: 0;
|
||||||
|
|
@ -123,10 +130,10 @@ export function TextEditOverlay({
|
||||||
aria-label="Edit text"
|
aria-label="Edit text"
|
||||||
className="cursor-text select-text outline-none whitespace-pre"
|
className="cursor-text select-text outline-none whitespace-pre"
|
||||||
style={{
|
style={{
|
||||||
fontSize: scaledFontSize,
|
fontSize: resolvedTextLayout.scaledFontSize,
|
||||||
fontFamily: element.fontFamily,
|
fontFamily: element.fontFamily,
|
||||||
fontWeight,
|
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
|
||||||
fontStyle,
|
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
|
||||||
textAlign: element.textAlign,
|
textAlign: element.textAlign,
|
||||||
letterSpacing: `${canvasLetterSpacing}px`,
|
letterSpacing: `${canvasLetterSpacing}px`,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MaskableElement } from "@/lib/timeline";
|
import type { MaskableElement } from "@/lib/timeline";
|
||||||
import type { Mask, MaskType } from "@/lib/masks/types";
|
import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
|
||||||
import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
|
import type {
|
||||||
|
NumberParamDefinition,
|
||||||
|
SelectParamDefinition,
|
||||||
|
} from "@/lib/params";
|
||||||
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
|
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||||
|
|
@ -10,14 +13,17 @@ import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
|
ArrowExpandIcon,
|
||||||
Delete02Icon,
|
Delete02Icon,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
PlusSignIcon,
|
PlusSignIcon,
|
||||||
RotateClockwiseIcon,
|
RotateClockwiseIcon,
|
||||||
|
TextFontIcon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ColorPicker } from "@/components/ui/color-picker";
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
|
import { FontPicker } from "@/components/ui/font-picker";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -32,6 +38,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
|
|
@ -52,7 +59,12 @@ import {
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||||
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
|
import {
|
||||||
|
OcMirrorIcon,
|
||||||
|
OcShapesIcon,
|
||||||
|
OcTextHeightIcon,
|
||||||
|
OcTextWidthIcon,
|
||||||
|
} from "@/components/icons";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
type MasksTabProps = {
|
type MasksTabProps = {
|
||||||
|
|
@ -78,6 +90,10 @@ type PreviewParamHandler = (
|
||||||
|
|
||||||
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
|
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
|
||||||
|
|
||||||
|
function isTextMask(mask: Mask): mask is TextMask {
|
||||||
|
return mask.type === "text";
|
||||||
|
}
|
||||||
|
|
||||||
export function MasksTab({ element, trackId }: MasksTabProps) {
|
export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { renderElement, previewUpdates, commit } =
|
const { renderElement, previewUpdates, commit } =
|
||||||
|
|
@ -370,12 +386,23 @@ function MaskParamsFields({
|
||||||
previewParam(key)(value);
|
previewParam(key)(value);
|
||||||
const previewStrokeColor = previewParam("strokeColor");
|
const previewStrokeColor = previewParam("strokeColor");
|
||||||
const strokeAlignParam = definition.params.find(
|
const strokeAlignParam = definition.params.find(
|
||||||
(param): param is SelectParamDefinition =>
|
(param): param is SelectParamDefinition<string> =>
|
||||||
param.key === "strokeAlign" && param.type === "select",
|
param.key === "strokeAlign" && param.type === "select",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionFields>
|
<SectionFields>
|
||||||
|
{isTextMask(mask) ? (
|
||||||
|
<TextMaskFields
|
||||||
|
mask={mask}
|
||||||
|
previewParam={previewParam}
|
||||||
|
onCommit={onCommit}
|
||||||
|
fontSizeParam={getNumberParamDefinition({
|
||||||
|
definition,
|
||||||
|
key: "fontSize",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{definition.features.hasPosition &&
|
{definition.features.hasPosition &&
|
||||||
"centerX" in mask.params &&
|
"centerX" in mask.params &&
|
||||||
"centerY" in mask.params && (
|
"centerY" in mask.params && (
|
||||||
|
|
@ -491,7 +518,9 @@ function MaskParamsFields({
|
||||||
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
|
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
|
||||||
<SectionField label="Scale">
|
<SectionField label="Scale">
|
||||||
<MaskNumberField
|
<MaskNumberField
|
||||||
icon="S"
|
icon={
|
||||||
|
isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
|
||||||
|
}
|
||||||
param={getNumberParamDefinition({
|
param={getNumberParamDefinition({
|
||||||
definition,
|
definition,
|
||||||
key: "scale",
|
key: "scale",
|
||||||
|
|
@ -587,6 +616,100 @@ function MaskParamsFields({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LETTER_SPACING_PARAM: NumberParamDefinition = {
|
||||||
|
key: "letterSpacing",
|
||||||
|
label: "Letter spacing",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: -100,
|
||||||
|
max: 500,
|
||||||
|
step: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LINE_HEIGHT_PARAM: NumberParamDefinition = {
|
||||||
|
key: "lineHeight",
|
||||||
|
label: "Line height",
|
||||||
|
type: "number",
|
||||||
|
default: 1.2,
|
||||||
|
min: 0.1,
|
||||||
|
max: 10,
|
||||||
|
step: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function TextMaskFields({
|
||||||
|
mask,
|
||||||
|
previewParam,
|
||||||
|
onCommit,
|
||||||
|
fontSizeParam,
|
||||||
|
}: {
|
||||||
|
mask: TextMask;
|
||||||
|
previewParam: PreviewParamHandler;
|
||||||
|
onCommit: () => void;
|
||||||
|
fontSizeParam: NumberParamDefinition;
|
||||||
|
}) {
|
||||||
|
const content = usePropertyDraft({
|
||||||
|
displayValue: mask.params.content,
|
||||||
|
parse: (input) => input,
|
||||||
|
onPreview: (value) => previewParam("content")(value),
|
||||||
|
onCommit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewNumberParam = (key: string) => (value: number) =>
|
||||||
|
previewParam(key)(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionField label="Content">
|
||||||
|
<Textarea
|
||||||
|
value={content.displayValue}
|
||||||
|
className="min-h-20"
|
||||||
|
onFocus={content.onFocus}
|
||||||
|
onChange={content.onChange}
|
||||||
|
onBlur={content.onBlur}
|
||||||
|
/>
|
||||||
|
</SectionField>
|
||||||
|
<SectionField label="Font">
|
||||||
|
<FontPicker
|
||||||
|
defaultValue={mask.params.fontFamily}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
previewParam("fontFamily")(value);
|
||||||
|
onCommit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SectionField>
|
||||||
|
<SectionField label="Size">
|
||||||
|
<MaskNumberField
|
||||||
|
icon={<HugeiconsIcon icon={TextFontIcon} />}
|
||||||
|
param={fontSizeParam}
|
||||||
|
value={mask.params.fontSize}
|
||||||
|
onPreview={previewNumberParam("fontSize")}
|
||||||
|
onCommit={onCommit}
|
||||||
|
/>
|
||||||
|
</SectionField>
|
||||||
|
<SectionField label="Spacing">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MaskNumberField
|
||||||
|
className="w-1/2"
|
||||||
|
icon={<OcTextWidthIcon size={14} />}
|
||||||
|
param={LETTER_SPACING_PARAM}
|
||||||
|
value={mask.params.letterSpacing ?? 0}
|
||||||
|
onPreview={previewNumberParam("letterSpacing")}
|
||||||
|
onCommit={onCommit}
|
||||||
|
/>
|
||||||
|
<MaskNumberField
|
||||||
|
className="w-1/2"
|
||||||
|
icon={<OcTextHeightIcon size={14} />}
|
||||||
|
param={LINE_HEIGHT_PARAM}
|
||||||
|
value={mask.params.lineHeight ?? 1.2}
|
||||||
|
onPreview={previewNumberParam("lineHeight")}
|
||||||
|
onCommit={onCommit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getNumberParamDefinition({
|
function getNumberParamDefinition({
|
||||||
definition,
|
definition,
|
||||||
key,
|
key,
|
||||||
|
|
@ -603,13 +726,10 @@ function getNumberParamDefinition({
|
||||||
return param;
|
return param;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMaskNumber({
|
function getMaskNumber<
|
||||||
params,
|
TParams extends Mask["params"],
|
||||||
key,
|
TKey extends keyof TParams & string,
|
||||||
}: {
|
>({ params, key }: { params: TParams; key: TKey }): number {
|
||||||
params: Mask["params"];
|
|
||||||
key: string;
|
|
||||||
}): number {
|
|
||||||
const value = params[key];
|
const value = params[key];
|
||||||
|
|
||||||
if (typeof value !== "number") {
|
if (typeof value !== "number") {
|
||||||
|
|
@ -665,7 +785,9 @@ function MaskNumberField({
|
||||||
parse: (input) => {
|
parse: (input) => {
|
||||||
const parsed = parseFloat(input);
|
const parsed = parseFloat(input);
|
||||||
if (Number.isNaN(parsed)) return null;
|
if (Number.isNaN(parsed)) return null;
|
||||||
return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
|
return (
|
||||||
|
clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onPreview,
|
onPreview,
|
||||||
onCommit,
|
onCommit,
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,34 @@ import { cn } from "@/utils/ui";
|
||||||
const BAR_WIDTH = 2;
|
const BAR_WIDTH = 2;
|
||||||
const BAR_GAP = 1;
|
const BAR_GAP = 1;
|
||||||
const BAR_STEP = BAR_WIDTH + BAR_GAP;
|
const BAR_STEP = BAR_WIDTH + BAR_GAP;
|
||||||
|
export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
|
||||||
|
|
||||||
|
function sampleGain({
|
||||||
|
samples,
|
||||||
|
startFraction,
|
||||||
|
endFraction,
|
||||||
|
barIndex,
|
||||||
|
barCount,
|
||||||
|
}: {
|
||||||
|
samples: number[];
|
||||||
|
startFraction: number;
|
||||||
|
endFraction: number;
|
||||||
|
barIndex: number;
|
||||||
|
barCount: number;
|
||||||
|
}): number {
|
||||||
|
if (samples.length === 0) return 1;
|
||||||
|
const progress =
|
||||||
|
startFraction +
|
||||||
|
((barIndex + 0.5) / barCount) * (endFraction - startFraction);
|
||||||
|
const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
|
||||||
|
const lo = Math.floor(rawIndex);
|
||||||
|
const hi = Math.min(samples.length - 1, lo + 1);
|
||||||
|
return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
|
||||||
|
}
|
||||||
interface AudioWaveformProps {
|
interface AudioWaveformProps {
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
audioBuffer?: AudioBuffer;
|
audioBuffer?: AudioBuffer;
|
||||||
|
gainSamples?: number[];
|
||||||
color?: string;
|
color?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +44,7 @@ interface AudioWaveformProps {
|
||||||
export function AudioWaveform({
|
export function AudioWaveform({
|
||||||
audioUrl,
|
audioUrl,
|
||||||
audioBuffer,
|
audioBuffer,
|
||||||
|
gainSamples,
|
||||||
color = "rgba(255, 255, 255, 0.7)",
|
color = "rgba(255, 255, 255, 0.7)",
|
||||||
className = "",
|
className = "",
|
||||||
}: AudioWaveformProps) {
|
}: AudioWaveformProps) {
|
||||||
|
|
@ -26,6 +52,7 @@ export function AudioWaveform({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const bufferRef = useRef<AudioBuffer | null>(null);
|
const bufferRef = useRef<AudioBuffer | null>(null);
|
||||||
const globalMaxRef = useRef<number>(1);
|
const globalMaxRef = useRef<number>(1);
|
||||||
|
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
|
||||||
const scrollParentRef = useRef<HTMLElement | null>(null);
|
const scrollParentRef = useRef<HTMLElement | null>(null);
|
||||||
const heightRef = useRef<number>(0);
|
const heightRef = useRef<number>(0);
|
||||||
|
|
||||||
|
|
@ -99,20 +126,37 @@ export function AudioWaveform({
|
||||||
|
|
||||||
const maxBarHeight = height * 0.7;
|
const maxBarHeight = height * 0.7;
|
||||||
|
|
||||||
|
const samples = gainSamplesRef.current;
|
||||||
for (let i = 0; i < barCount; i++) {
|
for (let i = 0; i < barCount; i++) {
|
||||||
const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
|
const gain =
|
||||||
|
samples != null
|
||||||
|
? sampleGain({
|
||||||
|
samples,
|
||||||
|
startFraction,
|
||||||
|
endFraction,
|
||||||
|
barIndex: i,
|
||||||
|
barCount,
|
||||||
|
})
|
||||||
|
: 1;
|
||||||
|
const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
|
||||||
|
const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
|
||||||
const barH = Math.max(1, scaled * maxBarHeight);
|
const barH = Math.max(1, scaled * maxBarHeight);
|
||||||
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
|
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
|
||||||
}
|
}
|
||||||
}, [color]);
|
}, [color]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gainSamplesRef.current = gainSamples;
|
||||||
|
drawVisible();
|
||||||
|
}, [gainSamples, drawVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
let buffer = audioBuffer ?? null;
|
let buffer = audioBuffer ?? null;
|
||||||
|
|
||||||
if (!buffer && audioUrl) {
|
if (!buffer && audioUrl) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(audioUrl);
|
const resp = await fetch(audioUrl);
|
||||||
const arrayBuffer = await resp.arrayBuffer();
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||||
import { AudioWaveform } from "./audio-waveform";
|
import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
|
||||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||||
import {
|
import {
|
||||||
useKeyframeDrag,
|
useKeyframeDrag,
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
getSourceAudioActionLabel,
|
getSourceAudioActionLabel,
|
||||||
isSourceAudioSeparated,
|
isSourceAudioSeparated,
|
||||||
} from "@/lib/timeline/audio-separation";
|
} from "@/lib/timeline/audio-separation";
|
||||||
|
import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
|
||||||
import {
|
import {
|
||||||
getActionDefinition,
|
getActionDefinition,
|
||||||
type TAction,
|
type TAction,
|
||||||
|
|
@ -382,6 +383,7 @@ export function TimelineElement({
|
||||||
>
|
>
|
||||||
<ElementInner
|
<ElementInner
|
||||||
element={element}
|
element={element}
|
||||||
|
displayElement={renderElement}
|
||||||
track={track}
|
track={track}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isExpanded={expandedRows.length > 0}
|
isExpanded={expandedRows.length > 0}
|
||||||
|
|
@ -498,6 +500,7 @@ export function TimelineElement({
|
||||||
|
|
||||||
function ElementInner({
|
function ElementInner({
|
||||||
element,
|
element,
|
||||||
|
displayElement,
|
||||||
track,
|
track,
|
||||||
isSelected,
|
isSelected,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
|
@ -509,6 +512,7 @@ function ElementInner({
|
||||||
isDropTarget = false,
|
isDropTarget = false,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElementType;
|
element: TimelineElementType;
|
||||||
|
displayElement?: TimelineElementType;
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
|
@ -530,8 +534,10 @@ function ElementInner({
|
||||||
}) => void;
|
}) => void;
|
||||||
isDropTarget?: boolean;
|
isDropTarget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const visibleElement = displayElement ?? element;
|
||||||
const isReducedOpacity =
|
const isReducedOpacity =
|
||||||
(canElementBeHidden(element) && element.hidden) || isDropTarget;
|
(canElementBeHidden(visibleElement) && visibleElement.hidden) ||
|
||||||
|
isDropTarget;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0"
|
className="absolute top-0 bottom-0"
|
||||||
|
|
@ -576,7 +582,7 @@ function ElementInner({
|
||||||
style={{ height: `${baseTrackHeight}px` }}
|
style={{ height: `${baseTrackHeight}px` }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
|
<div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
|
||||||
<ElementContent element={element} track={track} />
|
<ElementContent element={visibleElement} track={track} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expandedContent}
|
{expandedContent}
|
||||||
|
|
@ -979,6 +985,11 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
||||||
const audioUrl =
|
const audioUrl =
|
||||||
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
|
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
|
||||||
const mediaLabel = mediaAsset?.name ?? element.name;
|
const mediaLabel = mediaAsset?.name ?? element.name;
|
||||||
|
const gainSamples = useMemo(
|
||||||
|
() =>
|
||||||
|
buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
|
||||||
|
[element],
|
||||||
|
);
|
||||||
|
|
||||||
if (audioBuffer || audioUrl) {
|
if (audioBuffer || audioUrl) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -986,6 +997,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
||||||
<AudioWaveform
|
<AudioWaveform
|
||||||
audioBuffer={audioBuffer}
|
audioBuffer={audioBuffer}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
|
gainSamples={gainSamples}
|
||||||
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
||||||
/>
|
/>
|
||||||
<MediaElementHeader name={mediaLabel} hasFade={false} />
|
<MediaElementHeader name={mediaLabel} hasFade={false} />
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,13 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import type { Command, CommandResult } from "@/lib/commands";
|
import type { Command, CommandResult } from "@/lib/commands";
|
||||||
|
import type { EditorSelectionSnapshot } from "@/lib/selection/editor-selection";
|
||||||
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
|
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
|
||||||
import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
|
import type { SceneTracks } from "@/lib/timeline/types";
|
||||||
|
|
||||||
interface CommandHistoryEntry {
|
interface CommandHistoryEntry {
|
||||||
command: Command;
|
command: Command;
|
||||||
previousSelection: ElementRef[];
|
previousSelection: EditorSelectionSnapshot;
|
||||||
selectionOverride?: ElementRef[];
|
selectionOverride?: EditorSelectionSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CommandManager {
|
export class CommandManager {
|
||||||
|
|
@ -19,7 +20,7 @@ export class CommandManager {
|
||||||
|
|
||||||
execute({ command }: { command: Command }): Command {
|
execute({ command }: { command: Command }): Command {
|
||||||
const beforeTracks = this.isRippleEnabled
|
const beforeTracks = this.isRippleEnabled
|
||||||
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
|
||||||
: null;
|
: null;
|
||||||
const previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = command.execute();
|
const result = command.execute();
|
||||||
|
|
@ -55,11 +56,11 @@ export class CommandManager {
|
||||||
// Only restore selection for commands that explicitly changed it.
|
// Only restore selection for commands that explicitly changed it.
|
||||||
// Commands without selection intent leave selection untouched,
|
// Commands without selection intent leave selection untouched,
|
||||||
// preserving any UI-driven selection changes (clicks, box select)
|
// preserving any UI-driven selection changes (clicks, box select)
|
||||||
// that happened between commands. Commands that remove elements
|
// that happened between commands. Commands that remove editor-owned
|
||||||
// must declare { select: [] } to clear stale refs.
|
// selection targets must declare a selection override to clear stale refs.
|
||||||
if (entry.selectionOverride !== undefined) {
|
if (entry.selectionOverride !== undefined) {
|
||||||
this.editor.selection.setSelectedElements({
|
this.editor.selection.restoreSnapshot({
|
||||||
elements: [...entry.previousSelection],
|
snapshot: entry.previousSelection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.redoStack.push(entry);
|
this.redoStack.push(entry);
|
||||||
|
|
@ -74,7 +75,7 @@ export class CommandManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeTracks = this.isRippleEnabled
|
const beforeTracks = this.isRippleEnabled
|
||||||
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
|
||||||
: null;
|
: null;
|
||||||
const previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = entry.command.redo();
|
const result = entry.command.redo();
|
||||||
|
|
@ -102,20 +103,19 @@ export class CommandManager {
|
||||||
this.redoStack = [];
|
this.redoStack = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSelectionSnapshot(): ElementRef[] {
|
private getSelectionSnapshot(): EditorSelectionSnapshot {
|
||||||
return [...this.editor.selection.getSelectedElements()];
|
return this.editor.selection.getSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySelectionOverride(
|
private applySelectionOverride(
|
||||||
result: CommandResult | undefined,
|
result: CommandResult | undefined,
|
||||||
): ElementRef[] | undefined {
|
): EditorSelectionSnapshot | undefined {
|
||||||
if (result?.select === undefined) {
|
if (!result?.selection) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
return this.editor.selection.applySelectionPatch({
|
||||||
const selectionOverride = [...result.select];
|
patch: result.selection,
|
||||||
this.editor.selection.setSelectedElements({ elements: selectionOverride });
|
});
|
||||||
return selectionOverride;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private runReactors(): void {
|
private runReactors(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,195 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||||
import type { ElementRef } from "@/lib/timeline/types";
|
import type {
|
||||||
|
EditorSelectionKind,
|
||||||
export class SelectionManager {
|
EditorSelectionPatch,
|
||||||
private selectedElements: ElementRef[] = [];
|
EditorSelectionSnapshot,
|
||||||
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
SelectedMaskPointSelection,
|
||||||
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
} from "@/lib/selection/editor-selection";
|
||||||
private listeners = new Set<() => void>();
|
import type { ElementRef } from "@/lib/timeline/types";
|
||||||
|
|
||||||
constructor(editor: EditorCore) {
|
export class SelectionManager {
|
||||||
void editor;
|
private selectedElements: ElementRef[] = [];
|
||||||
}
|
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
||||||
|
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
||||||
getSelectedElements(): ElementRef[] {
|
private selectedMaskPoints: SelectedMaskPointSelection | null = null;
|
||||||
return this.selectedElements;
|
private listeners = new Set<() => void>();
|
||||||
}
|
|
||||||
|
constructor(editor: EditorCore) {
|
||||||
getSelectedKeyframes(): SelectedKeyframeRef[] {
|
void editor;
|
||||||
return this.selectedKeyframes;
|
}
|
||||||
}
|
|
||||||
|
getSelectedElements(): ElementRef[] {
|
||||||
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
|
return this.selectedElements;
|
||||||
return this.keyframeSelectionAnchor;
|
}
|
||||||
}
|
|
||||||
|
getSelectedKeyframes(): SelectedKeyframeRef[] {
|
||||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
return this.selectedKeyframes;
|
||||||
this.selectedElements = elements;
|
}
|
||||||
this.selectedKeyframes = [];
|
|
||||||
this.keyframeSelectionAnchor = null;
|
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
|
||||||
this.notify();
|
return this.keyframeSelectionAnchor;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedKeyframes({
|
getSelectedMaskPointSelection(): SelectedMaskPointSelection | null {
|
||||||
keyframes,
|
return this.selectedMaskPoints;
|
||||||
anchorKeyframe,
|
}
|
||||||
}: {
|
|
||||||
keyframes: SelectedKeyframeRef[];
|
getActiveSelectionKind(): EditorSelectionKind | null {
|
||||||
anchorKeyframe?: SelectedKeyframeRef | null;
|
if ((this.selectedMaskPoints?.pointIds.length ?? 0) > 0) {
|
||||||
}): void {
|
return "mask-points";
|
||||||
this.selectedKeyframes = keyframes;
|
}
|
||||||
if (anchorKeyframe !== undefined) {
|
if (this.selectedKeyframes.length > 0) {
|
||||||
this.keyframeSelectionAnchor = anchorKeyframe;
|
return "keyframes";
|
||||||
} else if (keyframes.length === 0) {
|
}
|
||||||
this.keyframeSelectionAnchor = null;
|
if (this.selectedElements.length > 0) {
|
||||||
}
|
return "elements";
|
||||||
this.notify();
|
}
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
clearSelection(): void {
|
|
||||||
this.selectedElements = [];
|
getSnapshot(): EditorSelectionSnapshot {
|
||||||
this.selectedKeyframes = [];
|
return {
|
||||||
this.keyframeSelectionAnchor = null;
|
selectedElements: [...this.selectedElements],
|
||||||
this.notify();
|
selectedKeyframes: [...this.selectedKeyframes],
|
||||||
}
|
keyframeSelectionAnchor: this.keyframeSelectionAnchor,
|
||||||
|
selectedMaskPoints: this.selectedMaskPoints
|
||||||
clearKeyframeSelection(): void {
|
? {
|
||||||
this.selectedKeyframes = [];
|
...this.selectedMaskPoints,
|
||||||
this.keyframeSelectionAnchor = null;
|
pointIds: [...this.selectedMaskPoints.pointIds],
|
||||||
this.notify();
|
}
|
||||||
}
|
: null,
|
||||||
|
};
|
||||||
subscribe(listener: () => void): () => void {
|
}
|
||||||
this.listeners.add(listener);
|
|
||||||
return () => this.listeners.delete(listener);
|
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||||
}
|
this.selectedElements = elements;
|
||||||
|
this.selectedKeyframes = [];
|
||||||
private notify(): void {
|
this.keyframeSelectionAnchor = null;
|
||||||
this.listeners.forEach((fn) => {
|
this.selectedMaskPoints = null;
|
||||||
fn();
|
this.notify();
|
||||||
});
|
}
|
||||||
}
|
|
||||||
}
|
setSelectedKeyframes({
|
||||||
|
keyframes,
|
||||||
|
anchorKeyframe,
|
||||||
|
}: {
|
||||||
|
keyframes: SelectedKeyframeRef[];
|
||||||
|
anchorKeyframe?: SelectedKeyframeRef | null;
|
||||||
|
}): void {
|
||||||
|
this.selectedKeyframes = keyframes;
|
||||||
|
if (anchorKeyframe !== undefined) {
|
||||||
|
this.keyframeSelectionAnchor = anchorKeyframe;
|
||||||
|
} else if (keyframes.length === 0) {
|
||||||
|
this.keyframeSelectionAnchor = null;
|
||||||
|
}
|
||||||
|
this.selectedMaskPoints = null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedMaskPoints({
|
||||||
|
selection,
|
||||||
|
}: {
|
||||||
|
selection: SelectedMaskPointSelection | null;
|
||||||
|
}): void {
|
||||||
|
this.selectedMaskPoints =
|
||||||
|
selection && selection.pointIds.length > 0
|
||||||
|
? {
|
||||||
|
...selection,
|
||||||
|
pointIds: [...selection.pointIds],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
this.selectedKeyframes = [];
|
||||||
|
this.keyframeSelectionAnchor = null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
this.selectedElements = [];
|
||||||
|
this.selectedKeyframes = [];
|
||||||
|
this.keyframeSelectionAnchor = null;
|
||||||
|
this.selectedMaskPoints = null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearKeyframeSelection(): void {
|
||||||
|
this.selectedKeyframes = [];
|
||||||
|
this.keyframeSelectionAnchor = null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMaskPointSelection(): void {
|
||||||
|
if (!this.selectedMaskPoints) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectedMaskPoints = null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMostSpecificSelection(): boolean {
|
||||||
|
const activeSelectionKind = this.getActiveSelectionKind();
|
||||||
|
if (activeSelectionKind === "mask-points") {
|
||||||
|
this.clearMaskPointSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (activeSelectionKind === "keyframes") {
|
||||||
|
this.clearKeyframeSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (activeSelectionKind === "elements") {
|
||||||
|
this.setSelectedElements({ elements: [] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySelectionPatch({
|
||||||
|
patch,
|
||||||
|
}: {
|
||||||
|
patch: EditorSelectionPatch;
|
||||||
|
}): EditorSelectionSnapshot {
|
||||||
|
if (patch.selectedElements !== undefined) {
|
||||||
|
this.selectedElements = [...patch.selectedElements];
|
||||||
|
}
|
||||||
|
if (patch.selectedKeyframes !== undefined) {
|
||||||
|
this.selectedKeyframes = [...patch.selectedKeyframes];
|
||||||
|
}
|
||||||
|
if (patch.keyframeSelectionAnchor !== undefined) {
|
||||||
|
this.keyframeSelectionAnchor = patch.keyframeSelectionAnchor;
|
||||||
|
}
|
||||||
|
if (patch.selectedMaskPoints !== undefined) {
|
||||||
|
this.selectedMaskPoints = patch.selectedMaskPoints
|
||||||
|
? {
|
||||||
|
...patch.selectedMaskPoints,
|
||||||
|
pointIds: [...patch.selectedMaskPoints.pointIds],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSnapshot({ snapshot }: { snapshot: EditorSelectionSnapshot }): void {
|
||||||
|
this.selectedElements = [...snapshot.selectedElements];
|
||||||
|
this.selectedKeyframes = [...snapshot.selectedKeyframes];
|
||||||
|
this.keyframeSelectionAnchor = snapshot.keyframeSelectionAnchor;
|
||||||
|
this.selectedMaskPoints = snapshot.selectedMaskPoints
|
||||||
|
? {
|
||||||
|
...snapshot.selectedMaskPoints,
|
||||||
|
pointIds: [...snapshot.selectedMaskPoints.pointIds],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this.listeners.forEach((fn) => {
|
||||||
|
fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
|
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
import type {
|
import type {
|
||||||
SceneTracks,
|
SceneTracks,
|
||||||
|
|
@ -44,6 +45,8 @@ import {
|
||||||
RetimeKeyframeCommand,
|
RetimeKeyframeCommand,
|
||||||
UpdateScalarKeyframeCurveCommand,
|
UpdateScalarKeyframeCurveCommand,
|
||||||
AddClipEffectCommand,
|
AddClipEffectCommand,
|
||||||
|
DeleteCustomMaskPointsCommand,
|
||||||
|
InsertCustomMaskPointCommand,
|
||||||
RemoveClipEffectCommand,
|
RemoveClipEffectCommand,
|
||||||
UpdateClipEffectParamsCommand,
|
UpdateClipEffectParamsCommand,
|
||||||
ToggleClipEffectCommand,
|
ToggleClipEffectCommand,
|
||||||
|
|
@ -344,6 +347,55 @@ export class TimelineManager {
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteCustomMaskPoints({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
maskId,
|
||||||
|
pointIds,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
maskId: string;
|
||||||
|
pointIds: string[];
|
||||||
|
}): void {
|
||||||
|
if (pointIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const command = new DeleteCustomMaskPointsCommand({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
maskId,
|
||||||
|
pointIds,
|
||||||
|
});
|
||||||
|
this.editor.command.execute({ command });
|
||||||
|
}
|
||||||
|
|
||||||
|
insertCustomMaskPoint({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
maskId,
|
||||||
|
segmentIndex,
|
||||||
|
canvasPoint,
|
||||||
|
bounds,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
maskId: string;
|
||||||
|
segmentIndex: number;
|
||||||
|
canvasPoint: { x: number; y: number };
|
||||||
|
bounds: ElementBounds;
|
||||||
|
}): void {
|
||||||
|
const command = new InsertCustomMaskPointCommand({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
maskId,
|
||||||
|
segmentIndex,
|
||||||
|
canvasPoint,
|
||||||
|
bounds,
|
||||||
|
});
|
||||||
|
this.editor.command.execute({ command });
|
||||||
|
}
|
||||||
|
|
||||||
updateClipEffectParams({
|
updateClipEffectParams({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,27 @@ export function useEditorActions() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { selectedElements, setElementSelection } = useElementSelection();
|
const { selectedElements, setElementSelection } = useElementSelection();
|
||||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||||
|
const selectedMaskPointSelection = useEditor((e) =>
|
||||||
|
e.selection.getSelectedMaskPointSelection(),
|
||||||
|
);
|
||||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||||
const hasTimelineSelectionRef = useRef(false);
|
const hasTimelineSelectionRef = useRef(false);
|
||||||
const clearTimelineSelectionRef = useRef(() => {});
|
const clearTimelineSelectionRef = useRef(() => {});
|
||||||
|
const clearTimelineActiveSelectionRef = useRef(() => {});
|
||||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
||||||
const hasTimelineSelection =
|
const hasTimelineSelection =
|
||||||
selectedElements.length > 0 || selectedKeyframes.length > 0;
|
selectedElements.length > 0 ||
|
||||||
|
selectedKeyframes.length > 0 ||
|
||||||
|
selectedMaskPointSelection !== null;
|
||||||
|
|
||||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
hasTimelineSelectionRef.current = hasTimelineSelection;
|
||||||
clearTimelineSelectionRef.current = () => {
|
clearTimelineSelectionRef.current = () => {
|
||||||
setElementSelection({ elements: [] });
|
editor.selection.clearSelection();
|
||||||
clearKeyframeSelection();
|
};
|
||||||
|
clearTimelineActiveSelectionRef.current = () => {
|
||||||
|
editor.selection.clearMostSpecificSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!timelineScopeRef.current) {
|
if (!timelineScopeRef.current) {
|
||||||
|
|
@ -42,6 +50,9 @@ export function useEditorActions() {
|
||||||
clear: () => {
|
clear: () => {
|
||||||
clearTimelineSelectionRef.current();
|
clearTimelineSelectionRef.current();
|
||||||
},
|
},
|
||||||
|
clearActive: () => {
|
||||||
|
clearTimelineActiveSelectionRef.current();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,17 +268,36 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"delete-selected",
|
"delete-selected",
|
||||||
() => {
|
() => {
|
||||||
if (selectedKeyframes.length > 0) {
|
switch (editor.selection.getActiveSelectionKind()) {
|
||||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
case "mask-points":
|
||||||
clearKeyframeSelection();
|
if (!selectedMaskPointSelection) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
editor.timeline.deleteCustomMaskPoints({
|
||||||
|
trackId: selectedMaskPointSelection.trackId,
|
||||||
|
elementId: selectedMaskPointSelection.elementId,
|
||||||
|
maskId: selectedMaskPointSelection.maskId,
|
||||||
|
pointIds: selectedMaskPointSelection.pointIds,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "keyframes":
|
||||||
|
if (selectedKeyframes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||||
|
clearKeyframeSelection();
|
||||||
|
return;
|
||||||
|
case "elements":
|
||||||
|
if (selectedElements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.timeline.deleteElements({
|
||||||
|
elements: selectedElements,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (selectedElements.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.timeline.deleteElements({
|
|
||||||
elements: selectedElements,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -343,8 +373,7 @@ export function useEditorActions() {
|
||||||
"deselect-all",
|
"deselect-all",
|
||||||
() => {
|
() => {
|
||||||
if (!clearActiveScope()) {
|
if (!clearActiveScope()) {
|
||||||
setElementSelection({ elements: [] });
|
editor.selection.clearMostSpecificSelection();
|
||||||
clearKeyframeSelection();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,260 +1,633 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { masksRegistry } from "@/lib/masks";
|
import { masksRegistry } from "@/lib/masks";
|
||||||
import {
|
import {
|
||||||
getMaskHandlePositions,
|
parseCustomMaskHandleId,
|
||||||
getLineMaskLinePoints,
|
parseCustomMaskSegmentHandleId,
|
||||||
} from "@/lib/masks/handle-positions";
|
} from "@/lib/masks/custom-path";
|
||||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
import { appendPointToCustomMask } from "@/lib/masks/definitions/custom";
|
||||||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
import {
|
||||||
import {
|
getVisibleElementsWithBounds,
|
||||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
type ElementBounds,
|
||||||
type SnapLine,
|
} from "@/lib/preview/element-bounds";
|
||||||
} from "@/lib/preview/preview-snap";
|
import {
|
||||||
import type { ParamValues } from "@/lib/params";
|
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||||
import type {
|
type SnapLine,
|
||||||
BaseMaskParams,
|
} from "@/lib/preview/preview-snap";
|
||||||
MaskHandlePosition,
|
import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection";
|
||||||
MaskLinePoints,
|
import type { Mask, MaskInteractionResult } from "@/lib/masks/types";
|
||||||
} from "@/lib/masks/types";
|
import type { MaskableElement } from "@/lib/timeline";
|
||||||
import type { MaskableElement } from "@/lib/timeline";
|
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
|
||||||
|
interface DragState {
|
||||||
interface DragState {
|
trackId: string;
|
||||||
trackId: string;
|
elementId: string;
|
||||||
elementId: string;
|
handleId: string;
|
||||||
handleId: string;
|
startCanvasX: number;
|
||||||
startCanvasX: number;
|
startCanvasY: number;
|
||||||
startCanvasY: number;
|
startParams: Mask["params"];
|
||||||
startParams: BaseMaskParams & ParamValues;
|
}
|
||||||
}
|
|
||||||
|
interface PendingSegmentInsertState {
|
||||||
export function useMaskHandles({
|
trackId: string;
|
||||||
onSnapLinesChange,
|
elementId: string;
|
||||||
}: {
|
maskId: string;
|
||||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
segmentIndex: number;
|
||||||
}) {
|
startClientX: number;
|
||||||
const editor = useEditor();
|
startClientY: number;
|
||||||
const isShiftHeldRef = useShiftKey();
|
startCanvasX: number;
|
||||||
const viewport = usePreviewViewport();
|
startCanvasY: number;
|
||||||
const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
|
startParams: Mask["params"];
|
||||||
const dragStateRef = useRef<DragState | null>(null);
|
bounds: ElementBounds;
|
||||||
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
}
|
||||||
null,
|
|
||||||
);
|
const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
const tracks = useEditor(
|
function isMaskSelectionForElement({
|
||||||
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
trackId,
|
||||||
);
|
elementId,
|
||||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
maskId,
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
selection,
|
||||||
const canvasSize = useEditor(
|
}: {
|
||||||
(e) => e.project.getActive().settings.canvasSize,
|
trackId: string;
|
||||||
);
|
elementId: string;
|
||||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
maskId: string;
|
||||||
|
selection: SelectedMaskPointSelection | null;
|
||||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
}): boolean {
|
||||||
tracks,
|
if (!selection) {
|
||||||
currentTime,
|
return false;
|
||||||
canvasSize,
|
}
|
||||||
mediaAssets,
|
return (
|
||||||
});
|
selection.trackId === trackId &&
|
||||||
|
selection.elementId === elementId &&
|
||||||
const selectedWithMask =
|
selection.maskId === maskId
|
||||||
selectedElements.length === 1
|
);
|
||||||
? (() => {
|
}
|
||||||
const sel = selectedElements[0];
|
|
||||||
const entry = elementsWithBounds.find(
|
function replaceElementMask({
|
||||||
(item) =>
|
masks,
|
||||||
item.trackId === sel.trackId && item.elementId === sel.elementId,
|
updatedMask,
|
||||||
);
|
}: {
|
||||||
if (!entry) return null;
|
masks: MaskableElement["masks"];
|
||||||
const element = entry.element as MaskableElement;
|
updatedMask: Mask;
|
||||||
if (!element.masks?.length) return null;
|
}): Mask[] {
|
||||||
return { ...entry, element, mask: element.masks[0] };
|
return (masks ?? []).map((mask) =>
|
||||||
})()
|
mask.id === updatedMask.id ? updatedMask : mask,
|
||||||
: null;
|
);
|
||||||
|
}
|
||||||
const handlePositions: MaskHandlePosition[] = selectedWithMask
|
|
||||||
? (() => {
|
function withUpdatedMaskParams<TMask extends Mask>({
|
||||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
mask,
|
||||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
params,
|
||||||
const displayScale = (scaleX + scaleY) / 2;
|
}: {
|
||||||
return getMaskHandlePositions({
|
mask: TMask;
|
||||||
overlayShape: def.overlayShape,
|
params: TMask["params"];
|
||||||
features: def.features,
|
}): TMask {
|
||||||
params: selectedWithMask.mask.params,
|
return {
|
||||||
bounds: selectedWithMask.bounds,
|
...mask,
|
||||||
displayScale,
|
params,
|
||||||
});
|
};
|
||||||
})()
|
}
|
||||||
: [];
|
|
||||||
|
export function useMaskHandles({
|
||||||
const linePoints: MaskLinePoints | null =
|
onSnapLinesChange,
|
||||||
selectedWithMask?.mask.type === "split"
|
}: {
|
||||||
? getLineMaskLinePoints({
|
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||||
centerX: selectedWithMask.mask.params.centerX,
|
}) {
|
||||||
centerY: selectedWithMask.mask.params.centerY,
|
const editor = useEditor();
|
||||||
rotation: selectedWithMask.mask.params.rotation,
|
const isShiftHeldRef = useShiftKey();
|
||||||
bounds: selectedWithMask.bounds,
|
const viewport = usePreviewViewport();
|
||||||
})
|
const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
|
||||||
: null;
|
const dragStateRef = useRef<DragState | null>(null);
|
||||||
|
const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
|
||||||
const clearMaskHandleState = useCallback(() => {
|
null,
|
||||||
dragStateRef.current = null;
|
);
|
||||||
setActiveHandleId(null);
|
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
||||||
onSnapLinesChange?.([]);
|
null,
|
||||||
}, [onSnapLinesChange]);
|
);
|
||||||
|
|
||||||
const releaseCapturedPointer = useCallback(() => {
|
const tracks = useEditor(
|
||||||
const capture = captureRef.current;
|
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||||
if (!capture) return;
|
);
|
||||||
|
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||||
if (capture.element.hasPointerCapture(capture.pointerId)) {
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
capture.element.releasePointerCapture(capture.pointerId);
|
const canvasSize = useEditor(
|
||||||
}
|
(e) => e.project.getActive().settings.canvasSize,
|
||||||
|
);
|
||||||
captureRef.current = null;
|
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||||
}, []);
|
const selectedMaskPointSelection = useEditor((e) =>
|
||||||
|
e.selection.getSelectedMaskPointSelection(),
|
||||||
useEffect(() => {
|
);
|
||||||
if (!activeHandleId) return;
|
|
||||||
|
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||||
return registerCanceller({
|
tracks,
|
||||||
fn: () => {
|
currentTime,
|
||||||
editor.timeline.discardPreview();
|
canvasSize,
|
||||||
clearMaskHandleState();
|
mediaAssets,
|
||||||
releaseCapturedPointer();
|
});
|
||||||
},
|
|
||||||
});
|
const selectedWithMask =
|
||||||
}, [
|
selectedElements.length === 1
|
||||||
activeHandleId,
|
? (() => {
|
||||||
clearMaskHandleState,
|
const sel = selectedElements[0];
|
||||||
editor.timeline,
|
const entry = elementsWithBounds.find(
|
||||||
releaseCapturedPointer,
|
(item) =>
|
||||||
]);
|
item.trackId === sel.trackId && item.elementId === sel.elementId,
|
||||||
|
);
|
||||||
const handlePointerDown = useCallback(
|
if (!entry) return null;
|
||||||
({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
|
const element = entry.element as MaskableElement;
|
||||||
if (!selectedWithMask) return;
|
const masks = element.masks ?? [];
|
||||||
event.stopPropagation();
|
if (masks.length === 0) return null;
|
||||||
|
const activeMaskId = masks.some((mask) =>
|
||||||
const pos = viewport.screenToCanvas({
|
isMaskSelectionForElement({
|
||||||
clientX: event.clientX,
|
trackId: entry.trackId,
|
||||||
clientY: event.clientY,
|
elementId: entry.elementId,
|
||||||
});
|
maskId: mask.id,
|
||||||
if (!pos) return;
|
selection: selectedMaskPointSelection,
|
||||||
|
}),
|
||||||
dragStateRef.current = {
|
)
|
||||||
trackId: selectedWithMask.trackId,
|
? selectedMaskPointSelection?.maskId
|
||||||
elementId: selectedWithMask.elementId,
|
: masks[0].id;
|
||||||
handleId,
|
const mask =
|
||||||
startCanvasX: pos.x,
|
masks.find((candidate) => candidate.id === activeMaskId) ??
|
||||||
startCanvasY: pos.y,
|
masks[0];
|
||||||
startParams: { ...selectedWithMask.mask.params },
|
return { ...entry, element, mask };
|
||||||
};
|
})()
|
||||||
setActiveHandleId(handleId);
|
: null;
|
||||||
const captureTarget = event.currentTarget as HTMLElement;
|
|
||||||
captureTarget.setPointerCapture(event.pointerId);
|
const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
|
||||||
captureRef.current = {
|
selectedWithMask
|
||||||
element: captureTarget,
|
? (() => {
|
||||||
pointerId: event.pointerId,
|
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||||
};
|
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||||
},
|
const displayScale = (scaleX + scaleY) / 2;
|
||||||
[selectedWithMask, viewport],
|
return def.interaction.getInteraction({
|
||||||
);
|
params: selectedWithMask.mask.params,
|
||||||
|
bounds: selectedWithMask.bounds,
|
||||||
const handlePointerMove = useCallback(
|
displayScale,
|
||||||
({ event }: { event: React.PointerEvent }) => {
|
scaleX,
|
||||||
const drag = dragStateRef.current;
|
scaleY,
|
||||||
if (!drag || !selectedWithMask) return;
|
});
|
||||||
|
})()
|
||||||
const pos = viewport.screenToCanvas({
|
: { handles: [], overlays: [] };
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
const selectedPointIds = new Set(
|
||||||
});
|
selectedWithMask &&
|
||||||
if (!pos) return;
|
isMaskSelectionForElement({
|
||||||
|
trackId: selectedWithMask.trackId,
|
||||||
const deltaX = pos.x - drag.startCanvasX;
|
elementId: selectedWithMask.elementId,
|
||||||
const deltaY = pos.y - drag.startCanvasY;
|
maskId: selectedWithMask.mask.id,
|
||||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
selection: selectedMaskPointSelection,
|
||||||
|
})
|
||||||
const rawParams = def.computeParamUpdate({
|
? (selectedMaskPointSelection?.pointIds ?? [])
|
||||||
handleId: drag.handleId,
|
: [],
|
||||||
startParams: drag.startParams,
|
);
|
||||||
deltaX,
|
|
||||||
deltaY,
|
const handlePositions = baseHandlePositions.map((handle) => {
|
||||||
startCanvasX: drag.startCanvasX,
|
const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
|
||||||
startCanvasY: drag.startCanvasY,
|
if (!parsedHandle || parsedHandle.part !== "anchor") {
|
||||||
bounds: selectedWithMask.bounds,
|
return handle;
|
||||||
canvasSize,
|
}
|
||||||
});
|
return {
|
||||||
const proposedParams = { ...drag.startParams, ...rawParams };
|
...handle,
|
||||||
|
isSelected: selectedPointIds.has(parsedHandle.pointId),
|
||||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
};
|
||||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
});
|
||||||
});
|
|
||||||
const { params: nextParams, activeLines } = isShiftHeldRef.current
|
const customMaskPointIds =
|
||||||
? { params: proposedParams, activeLines: [] as SnapLine[] }
|
selectedWithMask?.mask.type === "custom"
|
||||||
: snapMaskInteraction({
|
? handlePositions
|
||||||
handleId: drag.handleId,
|
.filter((h) => h.kind === "point")
|
||||||
startParams: drag.startParams,
|
.map((h) => {
|
||||||
proposedParams,
|
const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
|
||||||
bounds: selectedWithMask.bounds,
|
return parsedHandle?.part === "anchor"
|
||||||
canvasSize,
|
? parsedHandle.pointId
|
||||||
snapThreshold,
|
: null;
|
||||||
});
|
})
|
||||||
|
.filter((id): id is string => id !== null)
|
||||||
onSnapLinesChange?.(activeLines);
|
: [];
|
||||||
|
const isCreatingCustomMask =
|
||||||
const updatedMask = {
|
selectedWithMask?.mask.type === "custom" &&
|
||||||
...selectedWithMask.mask,
|
(!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
|
||||||
params: nextParams,
|
|
||||||
};
|
useEffect(() => {
|
||||||
editor.timeline.previewElements({
|
if (!selectedMaskPointSelection) {
|
||||||
updates: [
|
return;
|
||||||
{
|
}
|
||||||
trackId: drag.trackId,
|
if (
|
||||||
elementId: drag.elementId,
|
!selectedWithMask ||
|
||||||
updates: {
|
selectedWithMask.mask.type !== "custom" ||
|
||||||
masks: [
|
!isMaskSelectionForElement({
|
||||||
updatedMask,
|
trackId: selectedWithMask.trackId,
|
||||||
...(selectedWithMask.element.masks?.slice(1) ?? []),
|
elementId: selectedWithMask.elementId,
|
||||||
],
|
maskId: selectedWithMask.mask.id,
|
||||||
} as Partial<MaskableElement>,
|
selection: selectedMaskPointSelection,
|
||||||
},
|
})
|
||||||
],
|
) {
|
||||||
});
|
editor.selection.clearMaskPointSelection();
|
||||||
},
|
return;
|
||||||
[
|
}
|
||||||
selectedWithMask,
|
|
||||||
canvasSize,
|
const availablePointIds = new Set(customMaskPointIds);
|
||||||
editor,
|
const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter(
|
||||||
isShiftHeldRef,
|
(pointId) => availablePointIds.has(pointId),
|
||||||
onSnapLinesChange,
|
);
|
||||||
viewport,
|
if (
|
||||||
],
|
nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length
|
||||||
);
|
) {
|
||||||
|
return;
|
||||||
const handlePointerUp = useCallback(() => {
|
}
|
||||||
if (dragStateRef.current) {
|
if (nextSelectedPointIds.length === 0) {
|
||||||
editor.timeline.commitPreview();
|
editor.selection.clearMaskPointSelection();
|
||||||
clearMaskHandleState();
|
return;
|
||||||
}
|
}
|
||||||
releaseCapturedPointer();
|
|
||||||
},
|
editor.selection.setSelectedMaskPoints({
|
||||||
[clearMaskHandleState, editor, releaseCapturedPointer],
|
selection: {
|
||||||
);
|
...selectedMaskPointSelection,
|
||||||
|
pointIds: nextSelectedPointIds,
|
||||||
return {
|
},
|
||||||
selectedWithMask,
|
});
|
||||||
handlePositions,
|
}, [
|
||||||
linePoints,
|
customMaskPointIds,
|
||||||
activeHandleId,
|
editor.selection,
|
||||||
handlePointerDown,
|
selectedMaskPointSelection,
|
||||||
handlePointerMove,
|
selectedWithMask,
|
||||||
handlePointerUp,
|
]);
|
||||||
};
|
|
||||||
}
|
const updateCustomMaskPointSelection = useCallback(
|
||||||
|
({
|
||||||
|
pointId,
|
||||||
|
toggleSelection,
|
||||||
|
}: {
|
||||||
|
pointId: string;
|
||||||
|
toggleSelection: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!selectedWithMask || selectedWithMask.mask.type !== "custom") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelectionForCurrentMask = isMaskSelectionForElement({
|
||||||
|
trackId: selectedWithMask.trackId,
|
||||||
|
elementId: selectedWithMask.elementId,
|
||||||
|
maskId: selectedWithMask.mask.id,
|
||||||
|
selection: selectedMaskPointSelection,
|
||||||
|
});
|
||||||
|
const currentPointIds = isSelectionForCurrentMask
|
||||||
|
? (selectedMaskPointSelection?.pointIds ?? [])
|
||||||
|
: [];
|
||||||
|
const nextPointIds = toggleSelection
|
||||||
|
? currentPointIds.includes(pointId)
|
||||||
|
? currentPointIds.filter(
|
||||||
|
(currentPointId) => currentPointId !== pointId,
|
||||||
|
)
|
||||||
|
: [...currentPointIds, pointId]
|
||||||
|
: [pointId];
|
||||||
|
|
||||||
|
if (nextPointIds.length === 0) {
|
||||||
|
editor.selection.clearMaskPointSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.selection.setSelectedMaskPoints({
|
||||||
|
selection: {
|
||||||
|
trackId: selectedWithMask.trackId,
|
||||||
|
elementId: selectedWithMask.elementId,
|
||||||
|
maskId: selectedWithMask.mask.id,
|
||||||
|
pointIds: nextPointIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editor.selection, selectedMaskPointSelection, selectedWithMask],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearMaskHandleState = useCallback(() => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
pendingSegmentInsertRef.current = null;
|
||||||
|
setActiveHandleId(null);
|
||||||
|
onSnapLinesChange?.([]);
|
||||||
|
}, [onSnapLinesChange]);
|
||||||
|
|
||||||
|
const releaseCapturedPointer = useCallback(() => {
|
||||||
|
const capture = captureRef.current;
|
||||||
|
if (!capture) return;
|
||||||
|
|
||||||
|
if (capture.element.hasPointerCapture(capture.pointerId)) {
|
||||||
|
capture.element.releasePointerCapture(capture.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
captureRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeHandleId) return;
|
||||||
|
|
||||||
|
return registerCanceller({
|
||||||
|
fn: () => {
|
||||||
|
editor.timeline.discardPreview();
|
||||||
|
clearMaskHandleState();
|
||||||
|
releaseCapturedPointer();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
activeHandleId,
|
||||||
|
clearMaskHandleState,
|
||||||
|
editor.timeline,
|
||||||
|
releaseCapturedPointer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
|
||||||
|
if (!selectedWithMask) return;
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
const parsedHandle =
|
||||||
|
selectedWithMask.mask.type === "custom"
|
||||||
|
? parseCustomMaskHandleId({ handleId })
|
||||||
|
: null;
|
||||||
|
const parsedSegmentHandle =
|
||||||
|
selectedWithMask.mask.type === "custom"
|
||||||
|
? parseCustomMaskSegmentHandleId({ handleId })
|
||||||
|
: null;
|
||||||
|
if (isCreatingCustomMask) {
|
||||||
|
const firstPointId = customMaskPointIds[0];
|
||||||
|
if (
|
||||||
|
firstPointId &&
|
||||||
|
handleId === `point:${firstPointId}:anchor` &&
|
||||||
|
customMaskPointIds.length >= 3 &&
|
||||||
|
selectedWithMask.mask.type === "custom"
|
||||||
|
) {
|
||||||
|
const updatedMask = withUpdatedMaskParams({
|
||||||
|
mask: selectedWithMask.mask,
|
||||||
|
params: {
|
||||||
|
...selectedWithMask.mask.params,
|
||||||
|
closed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId: selectedWithMask.trackId,
|
||||||
|
elementId: selectedWithMask.elementId,
|
||||||
|
patch: {
|
||||||
|
masks: replaceElementMask({
|
||||||
|
masks: selectedWithMask.element.masks,
|
||||||
|
updatedMask,
|
||||||
|
}),
|
||||||
|
} as Partial<MaskableElement>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = viewport.screenToCanvas({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") {
|
||||||
|
setActiveHandleId(handleId);
|
||||||
|
pendingSegmentInsertRef.current = {
|
||||||
|
trackId: selectedWithMask.trackId,
|
||||||
|
elementId: selectedWithMask.elementId,
|
||||||
|
maskId: selectedWithMask.mask.id,
|
||||||
|
segmentIndex: parsedSegmentHandle.segmentIndex,
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startClientY: event.clientY,
|
||||||
|
startCanvasX: pos.x,
|
||||||
|
startCanvasY: pos.y,
|
||||||
|
startParams: { ...selectedWithMask.mask.params },
|
||||||
|
bounds: selectedWithMask.bounds,
|
||||||
|
};
|
||||||
|
const captureTarget = event.currentTarget as HTMLElement;
|
||||||
|
captureTarget.setPointerCapture(event.pointerId);
|
||||||
|
captureRef.current = {
|
||||||
|
element: captureTarget,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedHandle?.part === "anchor") {
|
||||||
|
updateCustomMaskPointSelection({
|
||||||
|
pointId: parsedHandle.pointId,
|
||||||
|
toggleSelection: event.shiftKey,
|
||||||
|
});
|
||||||
|
if (event.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (selectedWithMask.mask.type === "custom") {
|
||||||
|
editor.selection.clearMaskPointSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
dragStateRef.current = {
|
||||||
|
trackId: selectedWithMask.trackId,
|
||||||
|
elementId: selectedWithMask.elementId,
|
||||||
|
handleId,
|
||||||
|
startCanvasX: pos.x,
|
||||||
|
startCanvasY: pos.y,
|
||||||
|
startParams: { ...selectedWithMask.mask.params },
|
||||||
|
};
|
||||||
|
setActiveHandleId(handleId);
|
||||||
|
const captureTarget = event.currentTarget as HTMLElement;
|
||||||
|
captureTarget.setPointerCapture(event.pointerId);
|
||||||
|
captureRef.current = {
|
||||||
|
element: captureTarget,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[
|
||||||
|
customMaskPointIds,
|
||||||
|
editor.selection,
|
||||||
|
editor.timeline,
|
||||||
|
isCreatingCustomMask,
|
||||||
|
selectedWithMask,
|
||||||
|
updateCustomMaskPointSelection,
|
||||||
|
viewport,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasPointerDown = useCallback(
|
||||||
|
({ event }: { event: React.PointerEvent }) => {
|
||||||
|
if (!selectedWithMask || !isCreatingCustomMask) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedWithMask.mask.type !== "custom") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
const pos = viewport.screenToCanvas({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
if (!pos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextParams = appendPointToCustomMask({
|
||||||
|
params: selectedWithMask.mask.params,
|
||||||
|
canvasPoint: pos,
|
||||||
|
bounds: selectedWithMask.bounds,
|
||||||
|
});
|
||||||
|
const updatedMask = withUpdatedMaskParams({
|
||||||
|
mask: selectedWithMask.mask,
|
||||||
|
params: nextParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId: selectedWithMask.trackId,
|
||||||
|
elementId: selectedWithMask.elementId,
|
||||||
|
patch: {
|
||||||
|
masks: replaceElementMask({
|
||||||
|
masks: selectedWithMask.element.masks,
|
||||||
|
updatedMask,
|
||||||
|
}),
|
||||||
|
} as Partial<MaskableElement>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editor.timeline, isCreatingCustomMask, selectedWithMask, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
({ event }: { event: React.PointerEvent }) => {
|
||||||
|
const pendingSegmentInsert = pendingSegmentInsertRef.current;
|
||||||
|
if (pendingSegmentInsert && !dragStateRef.current) {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
event.clientX - pendingSegmentInsert.startClientX,
|
||||||
|
event.clientY - pendingSegmentInsert.startClientY,
|
||||||
|
);
|
||||||
|
if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) {
|
||||||
|
dragStateRef.current = {
|
||||||
|
trackId: pendingSegmentInsert.trackId,
|
||||||
|
elementId: pendingSegmentInsert.elementId,
|
||||||
|
handleId: "position",
|
||||||
|
startCanvasX: pendingSegmentInsert.startCanvasX,
|
||||||
|
startCanvasY: pendingSegmentInsert.startCanvasY,
|
||||||
|
startParams: pendingSegmentInsert.startParams,
|
||||||
|
};
|
||||||
|
pendingSegmentInsertRef.current = null;
|
||||||
|
setActiveHandleId("position");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragStateRef.current;
|
||||||
|
if (!drag || !selectedWithMask) return;
|
||||||
|
|
||||||
|
const pos = viewport.screenToCanvas({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
const deltaX = pos.x - drag.startCanvasX;
|
||||||
|
const deltaY = pos.y - drag.startCanvasY;
|
||||||
|
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||||
|
|
||||||
|
const rawParams = def.computeParamUpdate({
|
||||||
|
handleId: drag.handleId,
|
||||||
|
startParams: drag.startParams,
|
||||||
|
deltaX,
|
||||||
|
deltaY,
|
||||||
|
startCanvasX: drag.startCanvasX,
|
||||||
|
startCanvasY: drag.startCanvasY,
|
||||||
|
bounds: selectedWithMask.bounds,
|
||||||
|
canvasSize,
|
||||||
|
});
|
||||||
|
const proposedParams = { ...drag.startParams, ...rawParams };
|
||||||
|
|
||||||
|
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||||
|
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||||
|
});
|
||||||
|
const { params: nextParams, activeLines } = isShiftHeldRef.current
|
||||||
|
? { params: proposedParams, activeLines: [] as SnapLine[] }
|
||||||
|
: (def.interaction.snap?.({
|
||||||
|
handleId: drag.handleId,
|
||||||
|
startParams: drag.startParams,
|
||||||
|
proposedParams,
|
||||||
|
bounds: selectedWithMask.bounds,
|
||||||
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
|
}) ?? { params: proposedParams, activeLines: [] as SnapLine[] });
|
||||||
|
|
||||||
|
onSnapLinesChange?.(activeLines);
|
||||||
|
|
||||||
|
const updatedMask = withUpdatedMaskParams({
|
||||||
|
mask: selectedWithMask.mask,
|
||||||
|
params: nextParams as typeof selectedWithMask.mask.params,
|
||||||
|
});
|
||||||
|
editor.timeline.previewElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId: drag.trackId,
|
||||||
|
elementId: drag.elementId,
|
||||||
|
updates: {
|
||||||
|
masks: replaceElementMask({
|
||||||
|
masks: selectedWithMask.element.masks,
|
||||||
|
updatedMask,
|
||||||
|
}),
|
||||||
|
} as Partial<MaskableElement>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
selectedWithMask,
|
||||||
|
canvasSize,
|
||||||
|
editor,
|
||||||
|
isShiftHeldRef,
|
||||||
|
onSnapLinesChange,
|
||||||
|
viewport,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(() => {
|
||||||
|
const pendingSegmentInsert = pendingSegmentInsertRef.current;
|
||||||
|
if (pendingSegmentInsert && !dragStateRef.current) {
|
||||||
|
editor.timeline.insertCustomMaskPoint({
|
||||||
|
trackId: pendingSegmentInsert.trackId,
|
||||||
|
elementId: pendingSegmentInsert.elementId,
|
||||||
|
maskId: pendingSegmentInsert.maskId,
|
||||||
|
segmentIndex: pendingSegmentInsert.segmentIndex,
|
||||||
|
canvasPoint: {
|
||||||
|
x: pendingSegmentInsert.startCanvasX,
|
||||||
|
y: pendingSegmentInsert.startCanvasY,
|
||||||
|
},
|
||||||
|
bounds: pendingSegmentInsert.bounds,
|
||||||
|
});
|
||||||
|
clearMaskHandleState();
|
||||||
|
releaseCapturedPointer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragStateRef.current) {
|
||||||
|
editor.timeline.commitPreview();
|
||||||
|
clearMaskHandleState();
|
||||||
|
}
|
||||||
|
releaseCapturedPointer();
|
||||||
|
}, [clearMaskHandleState, editor, releaseCapturedPointer]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedWithMask,
|
||||||
|
handlePositions,
|
||||||
|
overlays,
|
||||||
|
isCreatingCustomMask,
|
||||||
|
handleCanvasPointerDown,
|
||||||
|
activeHandleId,
|
||||||
|
handlePointerDown,
|
||||||
|
handlePointerMove,
|
||||||
|
handlePointerUp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,209 +1,209 @@
|
||||||
import type {
|
import type {
|
||||||
KeybindingConfig,
|
KeybindingConfig,
|
||||||
ShortcutKey,
|
ShortcutKey,
|
||||||
} from "@/lib/actions/keybinding";
|
} from "@/lib/actions/keybinding";
|
||||||
import type { TActionWithOptionalArgs } from "./types";
|
import type { TActionWithOptionalArgs } from "./types";
|
||||||
|
|
||||||
export type TActionCategory =
|
export type TActionCategory =
|
||||||
| "playback"
|
| "playback"
|
||||||
| "navigation"
|
| "navigation"
|
||||||
| "editing"
|
| "editing"
|
||||||
| "selection"
|
| "selection"
|
||||||
| "history"
|
| "history"
|
||||||
| "timeline"
|
| "timeline"
|
||||||
| "controls"
|
| "controls"
|
||||||
| "assets";
|
| "assets";
|
||||||
|
|
||||||
export interface TActionBaseDefinition {
|
export interface TActionBaseDefinition {
|
||||||
description: string;
|
description: string;
|
||||||
category: TActionCategory;
|
category: TActionCategory;
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TActionDefinition extends TActionBaseDefinition {
|
export interface TActionDefinition extends TActionBaseDefinition {
|
||||||
defaultShortcuts?: readonly ShortcutKey[];
|
defaultShortcuts?: readonly ShortcutKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ACTIONS = {
|
export const ACTIONS = {
|
||||||
"toggle-play": {
|
"toggle-play": {
|
||||||
description: "Play/Pause",
|
description: "Play/Pause",
|
||||||
category: "playback",
|
category: "playback",
|
||||||
},
|
},
|
||||||
"stop-playback": {
|
"stop-playback": {
|
||||||
description: "Stop playback",
|
description: "Stop playback",
|
||||||
category: "playback",
|
category: "playback",
|
||||||
},
|
},
|
||||||
"seek-forward": {
|
"seek-forward": {
|
||||||
description: "Seek forward 1 second",
|
description: "Seek forward 1 second",
|
||||||
category: "playback",
|
category: "playback",
|
||||||
args: { seconds: "number" },
|
args: { seconds: "number" },
|
||||||
},
|
},
|
||||||
"seek-backward": {
|
"seek-backward": {
|
||||||
description: "Seek backward 1 second",
|
description: "Seek backward 1 second",
|
||||||
category: "playback",
|
category: "playback",
|
||||||
args: { seconds: "number" },
|
args: { seconds: "number" },
|
||||||
},
|
},
|
||||||
"frame-step-forward": {
|
"frame-step-forward": {
|
||||||
description: "Frame step forward",
|
description: "Frame step forward",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
},
|
},
|
||||||
"frame-step-backward": {
|
"frame-step-backward": {
|
||||||
description: "Frame step backward",
|
description: "Frame step backward",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
},
|
},
|
||||||
"jump-forward": {
|
"jump-forward": {
|
||||||
description: "Jump forward 5 seconds",
|
description: "Jump forward 5 seconds",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
args: { seconds: "number" },
|
args: { seconds: "number" },
|
||||||
},
|
},
|
||||||
"jump-backward": {
|
"jump-backward": {
|
||||||
description: "Jump backward 5 seconds",
|
description: "Jump backward 5 seconds",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
args: { seconds: "number" },
|
args: { seconds: "number" },
|
||||||
},
|
},
|
||||||
"goto-start": {
|
"goto-start": {
|
||||||
description: "Go to timeline start",
|
description: "Go to timeline start",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
},
|
},
|
||||||
"goto-end": {
|
"goto-end": {
|
||||||
description: "Go to timeline end",
|
description: "Go to timeline end",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
},
|
},
|
||||||
split: {
|
split: {
|
||||||
description: "Split elements at playhead",
|
description: "Split elements at playhead",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"split-left": {
|
"split-left": {
|
||||||
description: "Split and remove left",
|
description: "Split and remove left",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"split-right": {
|
"split-right": {
|
||||||
description: "Split and remove right",
|
description: "Split and remove right",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"delete-selected": {
|
"delete-selected": {
|
||||||
description: "Delete selected elements",
|
description: "Delete current selection",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"copy-selected": {
|
"copy-selected": {
|
||||||
description: "Copy selected elements",
|
description: "Copy selected elements",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"paste-copied": {
|
"paste-copied": {
|
||||||
description: "Paste elements at playhead",
|
description: "Paste elements at playhead",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"toggle-snapping": {
|
"toggle-snapping": {
|
||||||
description: "Toggle snapping",
|
description: "Toggle snapping",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"toggle-ripple-editing": {
|
"toggle-ripple-editing": {
|
||||||
description: "Toggle ripple editing",
|
description: "Toggle ripple editing",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"toggle-source-audio": {
|
"toggle-source-audio": {
|
||||||
description: "Extract or recover source audio",
|
description: "Extract or recover source audio",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
},
|
},
|
||||||
"select-all": {
|
"select-all": {
|
||||||
description: "Select all elements",
|
description: "Select all elements",
|
||||||
category: "selection",
|
category: "selection",
|
||||||
},
|
},
|
||||||
"cancel-interaction": {
|
"cancel-interaction": {
|
||||||
description: "Cancel current interaction",
|
description: "Cancel current interaction",
|
||||||
category: "controls",
|
category: "controls",
|
||||||
},
|
},
|
||||||
"deselect-all": {
|
"deselect-all": {
|
||||||
description: "Deselect all elements",
|
description: "Deselect all elements",
|
||||||
category: "selection",
|
category: "selection",
|
||||||
},
|
},
|
||||||
"duplicate-selected": {
|
"duplicate-selected": {
|
||||||
description: "Duplicate selected element",
|
description: "Duplicate selected element",
|
||||||
category: "selection",
|
category: "selection",
|
||||||
},
|
},
|
||||||
"toggle-elements-muted-selected": {
|
"toggle-elements-muted-selected": {
|
||||||
description: "Mute/unmute selected elements",
|
description: "Mute/unmute selected elements",
|
||||||
category: "selection",
|
category: "selection",
|
||||||
},
|
},
|
||||||
"toggle-elements-visibility-selected": {
|
"toggle-elements-visibility-selected": {
|
||||||
description: "Show/hide selected elements",
|
description: "Show/hide selected elements",
|
||||||
category: "selection",
|
category: "selection",
|
||||||
},
|
},
|
||||||
"toggle-bookmark": {
|
"toggle-bookmark": {
|
||||||
description: "Toggle bookmark at playhead",
|
description: "Toggle bookmark at playhead",
|
||||||
category: "timeline",
|
category: "timeline",
|
||||||
},
|
},
|
||||||
undo: {
|
undo: {
|
||||||
description: "Undo",
|
description: "Undo",
|
||||||
category: "history",
|
category: "history",
|
||||||
},
|
},
|
||||||
redo: {
|
redo: {
|
||||||
description: "Redo",
|
description: "Redo",
|
||||||
category: "history",
|
category: "history",
|
||||||
},
|
},
|
||||||
"remove-media-asset": {
|
"remove-media-asset": {
|
||||||
description: "Remove media asset",
|
description: "Remove media asset",
|
||||||
category: "assets",
|
category: "assets",
|
||||||
args: { projectId: "string", assetId: "string" },
|
args: { projectId: "string", assetId: "string" },
|
||||||
},
|
},
|
||||||
"remove-media-assets": {
|
"remove-media-assets": {
|
||||||
description: "Remove media assets",
|
description: "Remove media assets",
|
||||||
category: "assets",
|
category: "assets",
|
||||||
args: { projectId: "string", assetIds: "string[]" },
|
args: { projectId: "string", assetIds: "string[]" },
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, TActionBaseDefinition>;
|
} as const satisfies Record<string, TActionBaseDefinition>;
|
||||||
|
|
||||||
export type TAction = keyof typeof ACTIONS;
|
export type TAction = keyof typeof ACTIONS;
|
||||||
|
|
||||||
const ACTION_DEFAULT_SHORTCUTS = {
|
const ACTION_DEFAULT_SHORTCUTS = {
|
||||||
"toggle-play": ["space", "k"],
|
"toggle-play": ["space", "k"],
|
||||||
"seek-forward": ["l"],
|
"seek-forward": ["l"],
|
||||||
"seek-backward": ["j"],
|
"seek-backward": ["j"],
|
||||||
"frame-step-forward": ["right"],
|
"frame-step-forward": ["right"],
|
||||||
"frame-step-backward": ["left"],
|
"frame-step-backward": ["left"],
|
||||||
"jump-forward": ["shift+right"],
|
"jump-forward": ["shift+right"],
|
||||||
"jump-backward": ["shift+left"],
|
"jump-backward": ["shift+left"],
|
||||||
"goto-start": ["home", "enter"],
|
"goto-start": ["home", "enter"],
|
||||||
"goto-end": ["end"],
|
"goto-end": ["end"],
|
||||||
split: ["s"],
|
split: ["s"],
|
||||||
"split-left": ["q"],
|
"split-left": ["q"],
|
||||||
"split-right": ["w"],
|
"split-right": ["w"],
|
||||||
"delete-selected": ["backspace", "delete"],
|
"delete-selected": ["backspace", "delete"],
|
||||||
"copy-selected": ["ctrl+c"],
|
"copy-selected": ["ctrl+c"],
|
||||||
"paste-copied": ["ctrl+v"],
|
"paste-copied": ["ctrl+v"],
|
||||||
"toggle-snapping": ["n"],
|
"toggle-snapping": ["n"],
|
||||||
"select-all": ["ctrl+a"],
|
"select-all": ["ctrl+a"],
|
||||||
"cancel-interaction": ["escape"],
|
"cancel-interaction": ["escape"],
|
||||||
"duplicate-selected": ["ctrl+d"],
|
"duplicate-selected": ["ctrl+d"],
|
||||||
undo: ["ctrl+z"],
|
undo: ["ctrl+z"],
|
||||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
redo: ["ctrl+shift+z", "ctrl+y"],
|
||||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
||||||
|
|
||||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
||||||
Record<TAction, readonly ShortcutKey[]>
|
Record<TAction, readonly ShortcutKey[]>
|
||||||
> = ACTION_DEFAULT_SHORTCUTS;
|
> = ACTION_DEFAULT_SHORTCUTS;
|
||||||
|
|
||||||
export function getActionDefinition({
|
export function getActionDefinition({
|
||||||
action,
|
action,
|
||||||
}: {
|
}: {
|
||||||
action: TAction;
|
action: TAction;
|
||||||
}): TActionDefinition {
|
}): TActionDefinition {
|
||||||
return {
|
return {
|
||||||
...ACTIONS[action],
|
...ACTIONS[action],
|
||||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultShortcuts(): KeybindingConfig {
|
export function getDefaultShortcuts(): KeybindingConfig {
|
||||||
const shortcuts: KeybindingConfig = {};
|
const shortcuts: KeybindingConfig = {};
|
||||||
|
|
||||||
for (const [action, defaultShortcuts] of Object.entries(
|
for (const [action, defaultShortcuts] of Object.entries(
|
||||||
ACTION_DEFAULT_SHORTCUTS,
|
ACTION_DEFAULT_SHORTCUTS,
|
||||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
||||||
for (const shortcut of defaultShortcuts) {
|
for (const shortcut of defaultShortcuts) {
|
||||||
shortcuts[shortcut] = action;
|
shortcuts[shortcut] = action;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortcuts;
|
return shortcuts;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,8 @@ title: "Title"
|
||||||
description: "Description"
|
description: "Description"
|
||||||
changes:
|
changes:
|
||||||
- type: new
|
- type: new
|
||||||
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
||||||
|
- type: new
|
||||||
|
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
|
||||||
|
- type: fixed
|
||||||
|
text: "Preview panning now keeps working while the Masks tab is open."
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ElementRef } from "@/lib/timeline/types";
|
import type { EditorSelectionPatch } from "@/lib/selection/editor-selection";
|
||||||
|
|
||||||
export interface CommandResult {
|
export interface CommandResult {
|
||||||
select?: ElementRef[];
|
selection?: EditorSelectionPatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class Command {
|
export abstract class Command {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,12 @@ export class DeleteElementsCommand extends Command {
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
select: [],
|
selection: {
|
||||||
|
selectedElements: [],
|
||||||
|
selectedKeyframes: [],
|
||||||
|
keyframeSelectionAnchor: null,
|
||||||
|
selectedMaskPoints: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,18 +102,25 @@ export class InsertElementCommand extends Command {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset?.type === "video" && asset?.fps) {
|
if (asset?.type === "video" && asset?.fps) {
|
||||||
editor.project.updateSettings({
|
editor.project.updateSettings({
|
||||||
settings: { fps: floatToFrameRate(asset.fps) },
|
settings: { fps: floatToFrameRate(asset.fps) },
|
||||||
pushHistory: false,
|
pushHistory: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
select: [{ trackId: targetTrackId, elementId: this.elementId }],
|
selection: {
|
||||||
|
selectedElements: [
|
||||||
|
{ trackId: targetTrackId, elementId: this.elementId },
|
||||||
|
],
|
||||||
|
selectedKeyframes: [],
|
||||||
|
keyframeSelectionAnchor: null,
|
||||||
|
selectedMaskPoints: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,7 +180,10 @@ export class InsertElementCommand extends Command {
|
||||||
|
|
||||||
if (element.type === "graphic") {
|
if (element.type === "graphic") {
|
||||||
registerDefaultGraphics();
|
registerDefaultGraphics();
|
||||||
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
|
if (
|
||||||
|
!element.definitionId ||
|
||||||
|
!graphicsRegistry.has(element.definitionId)
|
||||||
|
) {
|
||||||
console.error("Graphic element must have a valid definitionId");
|
console.error("Graphic element must have a valid definitionId");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -236,8 +246,8 @@ export class InsertElementCommand extends Command {
|
||||||
const targetTrack =
|
const targetTrack =
|
||||||
tracks.main.id === placement.trackId
|
tracks.main.id === placement.trackId
|
||||||
? tracks.main
|
? tracks.main
|
||||||
: tracks.overlay.find((track) => track.id === placement.trackId) ??
|
: (tracks.overlay.find((track) => track.id === placement.trackId) ??
|
||||||
tracks.audio.find((track) => track.id === placement.trackId);
|
tracks.audio.find((track) => track.id === placement.trackId));
|
||||||
if (!targetTrack) {
|
if (!targetTrack) {
|
||||||
console.error("Track not found:", placement.trackId);
|
console.error("Track not found:", placement.trackId);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -257,8 +267,7 @@ export class InsertElementCommand extends Command {
|
||||||
placementResult.kind === "existingTrack"
|
placementResult.kind === "existingTrack"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
startTime:
|
startTime: placementResult.adjustedStartTime ?? element.startTime,
|
||||||
placementResult.adjustedStartTime ?? element.startTime,
|
|
||||||
}
|
}
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { RemoveMaskCommand } from "./remove-mask";
|
||||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
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 { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
findClosestPointOnCustomMaskSegment,
|
||||||
|
getCustomMaskClosedStateAfterPointRemoval,
|
||||||
|
insertPointIntoCustomMaskSegment,
|
||||||
|
removeCustomMaskPoints,
|
||||||
|
} from "@/lib/masks/custom-path";
|
||||||
|
import {
|
||||||
|
appendPointToCustomMask,
|
||||||
|
customMaskDefinition,
|
||||||
|
insertPointOnCustomMaskSegment,
|
||||||
|
} from "@/lib/masks/definitions/custom";
|
||||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||||
|
import { textMaskDefinition } from "@/lib/masks/definitions/text";
|
||||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
import type {
|
||||||
|
CustomMaskParams,
|
||||||
|
RectangleMaskParams,
|
||||||
|
SplitMaskParams,
|
||||||
|
TextMaskParams,
|
||||||
|
} from "@/lib/masks/types";
|
||||||
|
|
||||||
const bounds: ElementBounds = {
|
const bounds: ElementBounds = {
|
||||||
cx: 200,
|
cx: 200,
|
||||||
|
|
@ -58,6 +75,78 @@ function buildRectangleParams(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTextMaskParams(
|
||||||
|
overrides: Partial<TextMaskParams> = {},
|
||||||
|
): TextMaskParams {
|
||||||
|
return {
|
||||||
|
feather: 0,
|
||||||
|
inverted: false,
|
||||||
|
strokeColor: "#ffffff",
|
||||||
|
strokeWidth: 0,
|
||||||
|
strokeAlign: "center",
|
||||||
|
content: "Mask",
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: "Arial",
|
||||||
|
fontWeight: "normal",
|
||||||
|
fontStyle: "normal",
|
||||||
|
textDecoration: "none",
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
centerX: 0,
|
||||||
|
centerY: 0,
|
||||||
|
rotation: 0,
|
||||||
|
scale: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCustomMaskParams(
|
||||||
|
overrides: Partial<CustomMaskParams> = {},
|
||||||
|
): CustomMaskParams {
|
||||||
|
return {
|
||||||
|
feather: 0,
|
||||||
|
inverted: false,
|
||||||
|
strokeColor: "#ffffff",
|
||||||
|
strokeWidth: 0,
|
||||||
|
strokeAlign: "center",
|
||||||
|
path: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
x: -0.2,
|
||||||
|
y: -0.1,
|
||||||
|
inX: 0,
|
||||||
|
inY: 0,
|
||||||
|
outX: 0,
|
||||||
|
outY: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b",
|
||||||
|
x: 0.2,
|
||||||
|
y: -0.1,
|
||||||
|
inX: 0,
|
||||||
|
inY: 0,
|
||||||
|
outX: 0,
|
||||||
|
outY: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c",
|
||||||
|
x: 0,
|
||||||
|
y: 0.2,
|
||||||
|
inX: 0,
|
||||||
|
inY: 0,
|
||||||
|
outX: 0,
|
||||||
|
outY: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
closed: true,
|
||||||
|
centerX: 0,
|
||||||
|
centerY: 0,
|
||||||
|
rotation: 0,
|
||||||
|
scale: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sortSegment(
|
function sortSegment(
|
||||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||||
|
|
@ -234,4 +323,165 @@ describe("mask snapping", () => {
|
||||||
expect(result.params.height).toBe(0.5);
|
expect(result.params.height).toBe(0.5);
|
||||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("snaps text mask movement using intrinsic text bounds", () => {
|
||||||
|
const params = buildTextMaskParams({
|
||||||
|
centerX: 0.03,
|
||||||
|
centerY: -0.04,
|
||||||
|
});
|
||||||
|
const result = textMaskDefinition.interaction.snap?.({
|
||||||
|
handleId: "position",
|
||||||
|
startParams: params,
|
||||||
|
proposedParams: params,
|
||||||
|
bounds,
|
||||||
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.params.centerX).toBe(0);
|
||||||
|
expect(result?.params.centerY).toBe(0);
|
||||||
|
expect(result?.activeLines).toEqual([
|
||||||
|
{ type: "vertical", position: 0 },
|
||||||
|
{ type: "horizontal", position: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("snaps custom mask movement using path geometry bounds", () => {
|
||||||
|
const params = buildCustomMaskParams({
|
||||||
|
centerX: 0.03,
|
||||||
|
centerY: -0.04,
|
||||||
|
});
|
||||||
|
const result = customMaskDefinition.interaction.snap?.({
|
||||||
|
handleId: "position",
|
||||||
|
startParams: params,
|
||||||
|
proposedParams: params,
|
||||||
|
bounds,
|
||||||
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.params.centerX).toBe(0);
|
||||||
|
expect(result?.params.centerY).toBe(0);
|
||||||
|
expect(result?.activeLines).toEqual([
|
||||||
|
{ type: "vertical", position: 0 },
|
||||||
|
{ type: "horizontal", position: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks blank text masks inactive", () => {
|
||||||
|
expect(
|
||||||
|
textMaskDefinition.isActive?.(
|
||||||
|
buildTextMaskParams({
|
||||||
|
content: " ",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom mask creation", () => {
|
||||||
|
test("anchors the first point at the click position", () => {
|
||||||
|
const params = buildCustomMaskParams({
|
||||||
|
path: [],
|
||||||
|
closed: false,
|
||||||
|
});
|
||||||
|
const next = appendPointToCustomMask({
|
||||||
|
params,
|
||||||
|
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
|
||||||
|
bounds,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.centerX).toBeCloseTo(0.1);
|
||||||
|
expect(next.centerY).toBeCloseTo(-0.1);
|
||||||
|
expect(next.rotation).toBe(0);
|
||||||
|
expect(next.scale).toBe(1);
|
||||||
|
expect(next.path).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom mask point deletion", () => {
|
||||||
|
test("removes the selected points by id", () => {
|
||||||
|
const points = buildCustomMaskParams().path;
|
||||||
|
const nextPoints = removeCustomMaskPoints({
|
||||||
|
points,
|
||||||
|
pointIds: ["b"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reopens a closed path once fewer than three points remain", () => {
|
||||||
|
const points = buildCustomMaskParams().path;
|
||||||
|
const nextPoints = removeCustomMaskPoints({
|
||||||
|
points,
|
||||||
|
pointIds: ["c"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getCustomMaskClosedStateAfterPointRemoval({
|
||||||
|
wasClosed: true,
|
||||||
|
remainingPointCount: nextPoints.length,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom mask point insertion", () => {
|
||||||
|
test("finds the closest point on the clicked segment", () => {
|
||||||
|
const params = buildCustomMaskParams();
|
||||||
|
const points = params.path;
|
||||||
|
const closestPoint = findClosestPointOnCustomMaskSegment({
|
||||||
|
points,
|
||||||
|
segmentIndex: 0,
|
||||||
|
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
||||||
|
centerX: params.centerX,
|
||||||
|
centerY: params.centerY,
|
||||||
|
rotation: params.rotation,
|
||||||
|
scale: params.scale,
|
||||||
|
bounds,
|
||||||
|
closed: params.closed,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(closestPoint).not.toBeNull();
|
||||||
|
expect(closestPoint?.t).toBeCloseTo(0.5, 1);
|
||||||
|
expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
|
||||||
|
expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("splits a segment into two segments at the insertion point", () => {
|
||||||
|
const points = buildCustomMaskParams().path;
|
||||||
|
const nextPoints = insertPointIntoCustomMaskSegment({
|
||||||
|
points,
|
||||||
|
segmentIndex: 0,
|
||||||
|
pointId: "new",
|
||||||
|
t: 0.5,
|
||||||
|
closed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
|
||||||
|
expect(nextPoints[1]).toMatchObject({
|
||||||
|
id: "new",
|
||||||
|
x: 0,
|
||||||
|
y: -0.1,
|
||||||
|
inX: 0,
|
||||||
|
inY: 0,
|
||||||
|
outX: 0,
|
||||||
|
outY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds updated custom mask params for a clicked segment", () => {
|
||||||
|
const result = insertPointOnCustomMaskSegment({
|
||||||
|
params: buildCustomMaskParams(),
|
||||||
|
segmentIndex: 0,
|
||||||
|
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
||||||
|
bounds,
|
||||||
|
pointId: "new",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
const nextPoints = result?.params.path ?? [];
|
||||||
|
expect(nextPoints).toHaveLength(4);
|
||||||
|
expect(nextPoints.some((point) => point.id === "new")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
import {
|
||||||
DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
|
DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
|
||||||
MIN_MASK_DIMENSION,
|
MIN_MASK_DIMENSION,
|
||||||
} from "@/lib/masks/dimensions";
|
} from "@/lib/masks/dimensions";
|
||||||
import { computeFeatherUpdate } from "../param-update";
|
import { computeFeatherUpdate } from "../param-update";
|
||||||
import type {
|
import type {
|
||||||
BaseMaskParams,
|
BaseMaskParams,
|
||||||
MaskDefaultContext,
|
MaskDefaultContext,
|
||||||
MaskParamUpdateArgs,
|
MaskFeatures,
|
||||||
RectangleMaskParams,
|
MaskInteractionDefinition,
|
||||||
} from "@/lib/masks/types";
|
MaskParamUpdateArgs,
|
||||||
import type {
|
RectangleMaskParams,
|
||||||
NumberParamDefinition,
|
} from "@/lib/masks/types";
|
||||||
ParamDefinition,
|
import type { NumberParamDefinition, ParamDefinition } from "@/lib/params";
|
||||||
ParamValues,
|
import {
|
||||||
} from "@/lib/params";
|
getBoxMaskHandlePositions,
|
||||||
|
getBoxMaskOverlays,
|
||||||
const PERCENTAGE_DISPLAY: Pick<
|
} from "@/lib/masks/handle-positions";
|
||||||
NumberParamDefinition,
|
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||||
"displayMultiplier" | "step"
|
|
||||||
> = {
|
const PERCENTAGE_DISPLAY: Pick<
|
||||||
displayMultiplier: 100,
|
NumberParamDefinition,
|
||||||
step: 1,
|
"displayMultiplier" | "step"
|
||||||
};
|
> = {
|
||||||
|
displayMultiplier: 100,
|
||||||
export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
|
step: 1,
|
||||||
keyof RectangleMaskParams & string
|
};
|
||||||
>[] = [
|
|
||||||
{
|
export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
|
||||||
key: "centerX",
|
keyof RectangleMaskParams & string
|
||||||
label: "X",
|
>[] = [
|
||||||
type: "number",
|
{
|
||||||
default: 0,
|
key: "centerX",
|
||||||
min: -100,
|
label: "X",
|
||||||
max: 100,
|
type: "number",
|
||||||
...PERCENTAGE_DISPLAY,
|
default: 0,
|
||||||
},
|
min: -100,
|
||||||
{
|
max: 100,
|
||||||
key: "centerY",
|
...PERCENTAGE_DISPLAY,
|
||||||
label: "Y",
|
},
|
||||||
type: "number",
|
{
|
||||||
default: 0,
|
key: "centerY",
|
||||||
min: -100,
|
label: "Y",
|
||||||
max: 100,
|
type: "number",
|
||||||
...PERCENTAGE_DISPLAY,
|
default: 0,
|
||||||
},
|
min: -100,
|
||||||
{
|
max: 100,
|
||||||
key: "width",
|
...PERCENTAGE_DISPLAY,
|
||||||
label: "Width",
|
},
|
||||||
type: "number",
|
{
|
||||||
default: 0.6,
|
key: "width",
|
||||||
min: 1,
|
label: "Width",
|
||||||
...PERCENTAGE_DISPLAY,
|
type: "number",
|
||||||
},
|
default: 0.6,
|
||||||
{
|
min: 1,
|
||||||
key: "height",
|
...PERCENTAGE_DISPLAY,
|
||||||
label: "Height",
|
},
|
||||||
type: "number",
|
{
|
||||||
default: 0.6,
|
key: "height",
|
||||||
min: 1,
|
label: "Height",
|
||||||
...PERCENTAGE_DISPLAY,
|
type: "number",
|
||||||
},
|
default: 0.6,
|
||||||
{
|
min: 1,
|
||||||
key: "rotation",
|
...PERCENTAGE_DISPLAY,
|
||||||
label: "Rotation",
|
},
|
||||||
type: "number",
|
{
|
||||||
default: 0,
|
key: "rotation",
|
||||||
min: 0,
|
label: "Rotation",
|
||||||
max: 360,
|
type: "number",
|
||||||
step: 1,
|
default: 0,
|
||||||
},
|
min: 0,
|
||||||
{
|
max: 360,
|
||||||
key: "scale",
|
step: 1,
|
||||||
label: "Scale",
|
},
|
||||||
type: "number",
|
{
|
||||||
default: 1,
|
key: "scale",
|
||||||
min: 1,
|
label: "Scale",
|
||||||
max: 500,
|
type: "number",
|
||||||
...PERCENTAGE_DISPLAY,
|
default: 1,
|
||||||
},
|
min: 1,
|
||||||
{
|
max: 500,
|
||||||
key: "strokeAlign",
|
...PERCENTAGE_DISPLAY,
|
||||||
label: "Stroke Align",
|
},
|
||||||
type: "select",
|
{
|
||||||
default: "center",
|
key: "strokeAlign",
|
||||||
options: [
|
label: "Stroke Align",
|
||||||
{ value: "inside", label: "Inside" },
|
type: "select",
|
||||||
{ value: "center", label: "Center" },
|
default: "center",
|
||||||
{ value: "outside", label: "Outside" },
|
options: [
|
||||||
],
|
{ value: "inside", label: "Inside" },
|
||||||
},
|
{ value: "center", label: "Center" },
|
||||||
];
|
{ value: "outside", label: "Outside" },
|
||||||
|
],
|
||||||
export function getDefaultBaseMaskParams(): BaseMaskParams {
|
},
|
||||||
return {
|
];
|
||||||
feather: 0,
|
|
||||||
inverted: false,
|
export function getDefaultBaseMaskParams(): BaseMaskParams {
|
||||||
strokeColor: "#ffffff",
|
return {
|
||||||
strokeWidth: 0,
|
feather: 0,
|
||||||
strokeAlign: "center",
|
inverted: false,
|
||||||
};
|
strokeColor: "#ffffff",
|
||||||
}
|
strokeWidth: 0,
|
||||||
|
strokeAlign: "center",
|
||||||
export function getStrokeOffset({
|
};
|
||||||
strokeAlign,
|
}
|
||||||
strokeWidth,
|
|
||||||
}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
|
export function getStrokeOffset({
|
||||||
if (strokeAlign === "inside") {
|
strokeAlign,
|
||||||
return -(strokeWidth / 2);
|
strokeWidth,
|
||||||
}
|
}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
|
||||||
|
if (strokeAlign === "inside") {
|
||||||
if (strokeAlign === "outside") {
|
return -(strokeWidth / 2);
|
||||||
return strokeWidth / 2;
|
}
|
||||||
}
|
|
||||||
|
if (strokeAlign === "outside") {
|
||||||
return 0;
|
return strokeWidth / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultSquareMaskParams({
|
return 0;
|
||||||
elementSize,
|
}
|
||||||
}: MaskDefaultContext): RectangleMaskParams {
|
|
||||||
const absWidth = Math.abs(elementSize?.width ?? 0);
|
export function getDefaultSquareMaskParams({
|
||||||
const absHeight = Math.abs(elementSize?.height ?? 0);
|
elementSize,
|
||||||
const shortSide = Math.min(absWidth, absHeight);
|
}: MaskDefaultContext): RectangleMaskParams {
|
||||||
const squareSide =
|
const absWidth = Math.abs(elementSize?.width ?? 0);
|
||||||
shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
|
const absHeight = Math.abs(elementSize?.height ?? 0);
|
||||||
const width =
|
const shortSide = Math.min(absWidth, absHeight);
|
||||||
absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
const squareSide =
|
||||||
const height =
|
shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
|
||||||
absHeight > 0
|
const width =
|
||||||
? squareSide / absHeight
|
absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
||||||
: DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
const height =
|
||||||
|
absHeight > 0
|
||||||
return {
|
? squareSide / absHeight
|
||||||
...getDefaultBaseMaskParams(),
|
: DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
|
||||||
centerX: 0,
|
|
||||||
centerY: 0,
|
return {
|
||||||
width,
|
...getDefaultBaseMaskParams(),
|
||||||
height,
|
centerX: 0,
|
||||||
rotation: 0,
|
centerY: 0,
|
||||||
scale: 1,
|
width,
|
||||||
};
|
height,
|
||||||
}
|
rotation: 0,
|
||||||
|
scale: 1,
|
||||||
export function getBoxLikeGeometry({
|
};
|
||||||
params,
|
}
|
||||||
width,
|
|
||||||
height,
|
export function getBoxLikeGeometry({
|
||||||
}: {
|
params,
|
||||||
params: RectangleMaskParams;
|
width,
|
||||||
width: number;
|
height,
|
||||||
height: number;
|
}: {
|
||||||
}) {
|
params: RectangleMaskParams;
|
||||||
return {
|
width: number;
|
||||||
centerX: width / 2 + params.centerX * width,
|
height: number;
|
||||||
centerY: height / 2 + params.centerY * height,
|
}) {
|
||||||
maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
|
return {
|
||||||
maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
|
centerX: width / 2 + params.centerX * width,
|
||||||
rotationRad: (params.rotation * Math.PI) / 180,
|
centerY: height / 2 + params.centerY * height,
|
||||||
};
|
maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
|
||||||
}
|
maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
|
||||||
|
rotationRad: (params.rotation * Math.PI) / 180,
|
||||||
export function computeBoxMaskParamUpdate({
|
};
|
||||||
handleId,
|
}
|
||||||
startParams,
|
|
||||||
deltaX,
|
export function buildBoxMaskInteraction({
|
||||||
deltaY,
|
sizeMode,
|
||||||
bounds,
|
buildOverlayPath,
|
||||||
}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues {
|
showBoundingBox = true,
|
||||||
if (handleId === "position") {
|
}: {
|
||||||
return {
|
sizeMode: MaskFeatures["sizeMode"];
|
||||||
centerX: startParams.centerX + deltaX / bounds.width,
|
buildOverlayPath?: (args: { width: number; height: number }) => string;
|
||||||
centerY: startParams.centerY + deltaY / bounds.height,
|
showBoundingBox?: boolean;
|
||||||
};
|
}): MaskInteractionDefinition<RectangleMaskParams> {
|
||||||
}
|
return {
|
||||||
|
getInteraction({ params, bounds, displayScale, scaleX, scaleY }) {
|
||||||
if (handleId === "rotation") {
|
return {
|
||||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
handles: getBoxMaskHandlePositions({
|
||||||
const newRotation = (startParams.rotation + currentAngle) % 360;
|
centerX: params.centerX,
|
||||||
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
|
centerY: params.centerY,
|
||||||
}
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
if (handleId === "feather") {
|
rotation: params.rotation,
|
||||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
feather: params.feather,
|
||||||
return computeFeatherUpdate({
|
sizeMode,
|
||||||
startFeather: startParams.feather,
|
bounds,
|
||||||
deltaX,
|
displayScale,
|
||||||
deltaY,
|
}),
|
||||||
directionX: -Math.sin(angleRad),
|
overlays: getBoxMaskOverlays({
|
||||||
directionY: Math.cos(angleRad),
|
params,
|
||||||
});
|
bounds,
|
||||||
}
|
pathData: buildOverlayPath?.({
|
||||||
|
width: params.width * bounds.width * scaleX,
|
||||||
const halfWidth = startParams.width * bounds.width;
|
height: params.height * bounds.height * scaleY,
|
||||||
const halfHeight = startParams.height * bounds.height;
|
}),
|
||||||
|
showBoundingBox,
|
||||||
if (handleId === "right" || handleId === "left") {
|
}),
|
||||||
const sign = handleId === "right" ? 1 : -1;
|
};
|
||||||
return {
|
},
|
||||||
width: Math.max(
|
snap(args) {
|
||||||
MIN_MASK_DIMENSION,
|
return snapMaskInteraction(args);
|
||||||
startParams.width + (sign * deltaX * 2) / bounds.width,
|
},
|
||||||
),
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
export function computeBoxMaskParamUpdate({
|
||||||
if (handleId === "bottom" || handleId === "top") {
|
handleId,
|
||||||
const sign = handleId === "bottom" ? 1 : -1;
|
startParams,
|
||||||
return {
|
deltaX,
|
||||||
height: Math.max(
|
deltaY,
|
||||||
MIN_MASK_DIMENSION,
|
bounds,
|
||||||
startParams.height + (sign * deltaY * 2) / bounds.height,
|
}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
|
||||||
),
|
if (handleId === "position") {
|
||||||
};
|
return {
|
||||||
}
|
centerX: startParams.centerX + deltaX / bounds.width,
|
||||||
|
centerY: startParams.centerY + deltaY / bounds.height,
|
||||||
if (
|
};
|
||||||
handleId === "top-left" ||
|
}
|
||||||
handleId === "top-right" ||
|
|
||||||
handleId === "bottom-left" ||
|
if (handleId === "rotation") {
|
||||||
handleId === "bottom-right"
|
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||||
) {
|
const newRotation = (startParams.rotation + currentAngle) % 360;
|
||||||
const signX = handleId.includes("right") ? 1 : -1;
|
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
|
||||||
const signY = handleId.includes("bottom") ? 1 : -1;
|
}
|
||||||
const distance = Math.sqrt(
|
|
||||||
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
|
if (handleId === "feather") {
|
||||||
);
|
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||||
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
return computeFeatherUpdate({
|
||||||
const scale = originalDistance > 0 ? distance / originalDistance : 1;
|
startFeather: startParams.feather,
|
||||||
return {
|
deltaX,
|
||||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
|
deltaY,
|
||||||
height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
|
directionX: -Math.sin(angleRad),
|
||||||
};
|
directionY: Math.cos(angleRad),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
if (handleId === "scale") {
|
|
||||||
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
|
const halfWidth = startParams.width * bounds.width;
|
||||||
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
const halfHeight = startParams.height * bounds.height;
|
||||||
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
|
|
||||||
return {
|
if (handleId === "right" || handleId === "left") {
|
||||||
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
|
const sign = handleId === "right" ? 1 : -1;
|
||||||
};
|
return {
|
||||||
}
|
width: Math.max(
|
||||||
|
MIN_MASK_DIMENSION,
|
||||||
return {};
|
startParams.width + (sign * deltaX * 2) / bounds.width,
|
||||||
}
|
),
|
||||||
|
};
|
||||||
export function rotatePoint({
|
}
|
||||||
x,
|
|
||||||
y,
|
if (handleId === "bottom" || handleId === "top") {
|
||||||
centerX,
|
const sign = handleId === "bottom" ? 1 : -1;
|
||||||
centerY,
|
return {
|
||||||
rotationRad,
|
height: Math.max(
|
||||||
}: {
|
MIN_MASK_DIMENSION,
|
||||||
x: number;
|
startParams.height + (sign * deltaY * 2) / bounds.height,
|
||||||
y: number;
|
),
|
||||||
centerX: number;
|
};
|
||||||
centerY: number;
|
}
|
||||||
rotationRad: number;
|
|
||||||
}) {
|
if (
|
||||||
const dx = x - centerX;
|
handleId === "top-left" ||
|
||||||
const dy = y - centerY;
|
handleId === "top-right" ||
|
||||||
const cos = Math.cos(rotationRad);
|
handleId === "bottom-left" ||
|
||||||
const sin = Math.sin(rotationRad);
|
handleId === "bottom-right"
|
||||||
|
) {
|
||||||
return {
|
const signX = handleId.includes("right") ? 1 : -1;
|
||||||
x: centerX + dx * cos - dy * sin,
|
const signY = handleId.includes("bottom") ? 1 : -1;
|
||||||
y: centerY + dx * sin + dy * cos,
|
const distance = Math.sqrt(
|
||||||
};
|
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
|
||||||
}
|
);
|
||||||
|
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
||||||
|
const scale = originalDistance > 0 ? distance / originalDistance : 1;
|
||||||
|
return {
|
||||||
|
width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
|
||||||
|
height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleId === "scale") {
|
||||||
|
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
|
||||||
|
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
||||||
|
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
|
||||||
|
return {
|
||||||
|
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotatePoint({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
rotationRad,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
rotationRad: number;
|
||||||
|
}) {
|
||||||
|
const dx = x - centerX;
|
||||||
|
const dy = y - centerY;
|
||||||
|
const cos = Math.cos(rotationRad);
|
||||||
|
const sin = Math.sin(rotationRad);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: centerX + dx * cos - dy * sin,
|
||||||
|
y: centerY + dx * sin + dy * cos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
} from "@/lib/masks/types";
|
} from "@/lib/masks/types";
|
||||||
import {
|
import {
|
||||||
BOX_LIKE_MASK_PARAMS,
|
BOX_LIKE_MASK_PARAMS,
|
||||||
|
buildBoxMaskInteraction,
|
||||||
computeBoxMaskParamUpdate,
|
computeBoxMaskParamUpdate,
|
||||||
getDefaultBaseMaskParams,
|
getDefaultBaseMaskParams,
|
||||||
getStrokeOffset,
|
getStrokeOffset,
|
||||||
|
|
@ -73,16 +74,18 @@ function buildBandPath({
|
||||||
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
type: "cinematic-bars",
|
type: "cinematic-bars",
|
||||||
name: "Cinematic Bars",
|
name: "Cinematic Bars",
|
||||||
overlayShape: "box",
|
|
||||||
buildOverlayPath({ width, height }) {
|
|
||||||
return `M 0,0 H ${width} V ${height} H 0 Z`;
|
|
||||||
},
|
|
||||||
features: {
|
features: {
|
||||||
hasPosition: true,
|
hasPosition: true,
|
||||||
hasRotation: true,
|
hasRotation: true,
|
||||||
sizeMode: "height-only",
|
sizeMode: "height-only",
|
||||||
},
|
},
|
||||||
params: BOX_LIKE_MASK_PARAMS,
|
params: BOX_LIKE_MASK_PARAMS,
|
||||||
|
interaction: buildBoxMaskInteraction({
|
||||||
|
sizeMode: "height-only",
|
||||||
|
buildOverlayPath({ width, height }) {
|
||||||
|
return `M 0,0 H ${width} V ${height} H 0 Z`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
buildDefault(context) {
|
buildDefault(context) {
|
||||||
return {
|
return {
|
||||||
type: "cinematic-bars",
|
type: "cinematic-bars",
|
||||||
|
|
|
||||||
|
|
@ -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 type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||||
import {
|
import {
|
||||||
BOX_LIKE_MASK_PARAMS,
|
BOX_LIKE_MASK_PARAMS,
|
||||||
|
buildBoxMaskInteraction,
|
||||||
computeBoxMaskParamUpdate,
|
computeBoxMaskParamUpdate,
|
||||||
getBoxLikeGeometry,
|
getBoxLikeGeometry,
|
||||||
getDefaultSquareMaskParams,
|
getDefaultSquareMaskParams,
|
||||||
|
|
@ -47,16 +48,18 @@ function buildDiamondPath({
|
||||||
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
type: "diamond",
|
type: "diamond",
|
||||||
name: "Diamond",
|
name: "Diamond",
|
||||||
overlayShape: "box",
|
|
||||||
buildOverlayPath({ width, height }) {
|
|
||||||
return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
|
|
||||||
},
|
|
||||||
features: {
|
features: {
|
||||||
hasPosition: true,
|
hasPosition: true,
|
||||||
hasRotation: true,
|
hasRotation: true,
|
||||||
sizeMode: "width-height",
|
sizeMode: "width-height",
|
||||||
},
|
},
|
||||||
params: BOX_LIKE_MASK_PARAMS,
|
params: BOX_LIKE_MASK_PARAMS,
|
||||||
|
interaction: buildBoxMaskInteraction({
|
||||||
|
sizeMode: "width-height",
|
||||||
|
buildOverlayPath({ width, height }) {
|
||||||
|
return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
buildDefault(context) {
|
buildDefault(context) {
|
||||||
return {
|
return {
|
||||||
type: "diamond",
|
type: "diamond",
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,75 @@
|
||||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||||
import {
|
import {
|
||||||
BOX_LIKE_MASK_PARAMS,
|
BOX_LIKE_MASK_PARAMS,
|
||||||
computeBoxMaskParamUpdate,
|
buildBoxMaskInteraction,
|
||||||
getBoxLikeGeometry,
|
computeBoxMaskParamUpdate,
|
||||||
getDefaultSquareMaskParams,
|
getBoxLikeGeometry,
|
||||||
getStrokeOffset,
|
getDefaultSquareMaskParams,
|
||||||
} from "./box-like";
|
getStrokeOffset,
|
||||||
|
} from "./box-like";
|
||||||
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
|
||||||
type: "ellipse",
|
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
name: "Ellipse",
|
type: "ellipse",
|
||||||
overlayShape: "box",
|
name: "Ellipse",
|
||||||
buildOverlayPath({ width, height }) {
|
features: {
|
||||||
const rx = Math.max((width - 1) / 2, 0);
|
hasPosition: true,
|
||||||
const ry = Math.max((height - 1) / 2, 0);
|
hasRotation: true,
|
||||||
const cx = width / 2;
|
sizeMode: "width-height",
|
||||||
const cy = height / 2;
|
},
|
||||||
return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
|
params: BOX_LIKE_MASK_PARAMS,
|
||||||
},
|
interaction: buildBoxMaskInteraction({
|
||||||
features: {
|
sizeMode: "width-height",
|
||||||
hasPosition: true,
|
buildOverlayPath({ width, height }) {
|
||||||
hasRotation: true,
|
const rx = Math.max((width - 1) / 2, 0);
|
||||||
sizeMode: "width-height",
|
const ry = Math.max((height - 1) / 2, 0);
|
||||||
},
|
const cx = width / 2;
|
||||||
params: BOX_LIKE_MASK_PARAMS,
|
const cy = height / 2;
|
||||||
buildDefault(context) {
|
return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
|
||||||
return {
|
},
|
||||||
type: "ellipse",
|
}),
|
||||||
params: getDefaultSquareMaskParams(context),
|
buildDefault(context) {
|
||||||
};
|
return {
|
||||||
},
|
type: "ellipse",
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
params: getDefaultSquareMaskParams(context),
|
||||||
renderer: {
|
};
|
||||||
buildPath({ resolvedParams, width, height }) {
|
},
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
renderer: {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
buildPath({ resolvedParams, width, height }) {
|
||||||
const path = new Path2D();
|
const params = resolvedParams as RectangleMaskParams;
|
||||||
path.ellipse(
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
centerX,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
centerY,
|
const path = new Path2D();
|
||||||
maskWidth / 2,
|
path.ellipse(
|
||||||
maskHeight / 2,
|
centerX,
|
||||||
rotationRad,
|
centerY,
|
||||||
0,
|
maskWidth / 2,
|
||||||
Math.PI * 2,
|
maskHeight / 2,
|
||||||
);
|
rotationRad,
|
||||||
return path;
|
0,
|
||||||
},
|
Math.PI * 2,
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
);
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
return path;
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
},
|
||||||
getBoxLikeGeometry({ params, width, height });
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
const offset = getStrokeOffset({
|
const params = resolvedParams as RectangleMaskParams;
|
||||||
strokeAlign: params.strokeAlign,
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
strokeWidth: params.strokeWidth,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
});
|
const offset = getStrokeOffset({
|
||||||
const path = new Path2D();
|
strokeAlign: params.strokeAlign,
|
||||||
path.ellipse(
|
strokeWidth: params.strokeWidth,
|
||||||
centerX,
|
});
|
||||||
centerY,
|
const path = new Path2D();
|
||||||
Math.max(1, maskWidth / 2 + offset),
|
path.ellipse(
|
||||||
Math.max(1, maskHeight / 2 + offset),
|
centerX,
|
||||||
rotationRad,
|
centerY,
|
||||||
0,
|
Math.max(1, maskWidth / 2 + offset),
|
||||||
Math.PI * 2,
|
Math.max(1, maskHeight / 2 + offset),
|
||||||
);
|
rotationRad,
|
||||||
return path;
|
0,
|
||||||
},
|
Math.PI * 2,
|
||||||
},
|
);
|
||||||
};
|
return path;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||||
import {
|
import {
|
||||||
BOX_LIKE_MASK_PARAMS,
|
BOX_LIKE_MASK_PARAMS,
|
||||||
|
buildBoxMaskInteraction,
|
||||||
computeBoxMaskParamUpdate,
|
computeBoxMaskParamUpdate,
|
||||||
getBoxLikeGeometry,
|
getBoxLikeGeometry,
|
||||||
getDefaultSquareMaskParams,
|
getDefaultSquareMaskParams,
|
||||||
|
|
@ -36,23 +37,23 @@ function buildHeartPath({
|
||||||
rotationRad,
|
rotationRad,
|
||||||
});
|
});
|
||||||
|
|
||||||
const start = toPoint({ localX: 0, localY: -halfHeight * 0.2 });
|
const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
|
||||||
const rightControl1 = toPoint({
|
const rightControl1 = toPoint({
|
||||||
localX: halfWidth,
|
localX: halfWidth,
|
||||||
localY: -halfHeight * 0.95,
|
localY: -halfHeight * 1.225,
|
||||||
});
|
});
|
||||||
const rightControl2 = toPoint({
|
const rightControl2 = toPoint({
|
||||||
localX: halfWidth,
|
localX: halfWidth,
|
||||||
localY: halfHeight * 0.15,
|
localY: -halfHeight * 0.125,
|
||||||
});
|
});
|
||||||
const bottom = toPoint({ localX: 0, localY: halfHeight });
|
const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
|
||||||
const leftControl1 = toPoint({
|
const leftControl1 = toPoint({
|
||||||
localX: -halfWidth,
|
localX: -halfWidth,
|
||||||
localY: halfHeight * 0.15,
|
localY: -halfHeight * 0.125,
|
||||||
});
|
});
|
||||||
const leftControl2 = toPoint({
|
const leftControl2 = toPoint({
|
||||||
localX: -halfWidth,
|
localX: -halfWidth,
|
||||||
localY: -halfHeight * 0.95,
|
localY: -halfHeight * 1.225,
|
||||||
});
|
});
|
||||||
|
|
||||||
const path = new Path2D();
|
const path = new Path2D();
|
||||||
|
|
@ -80,25 +81,27 @@ function buildHeartPath({
|
||||||
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
type: "heart",
|
type: "heart",
|
||||||
name: "Heart",
|
name: "Heart",
|
||||||
overlayShape: "box",
|
|
||||||
buildOverlayPath({ width, height }) {
|
|
||||||
const cx = width / 2;
|
|
||||||
const cy = height / 2;
|
|
||||||
const halfWidth = width / 2;
|
|
||||||
const halfHeight = height / 2;
|
|
||||||
return [
|
|
||||||
`M ${cx},${cy - halfHeight * 0.2}`,
|
|
||||||
`C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`,
|
|
||||||
`C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`,
|
|
||||||
"Z",
|
|
||||||
].join(" ");
|
|
||||||
},
|
|
||||||
features: {
|
features: {
|
||||||
hasPosition: true,
|
hasPosition: true,
|
||||||
hasRotation: true,
|
hasRotation: true,
|
||||||
sizeMode: "width-height",
|
sizeMode: "width-height",
|
||||||
},
|
},
|
||||||
params: BOX_LIKE_MASK_PARAMS,
|
params: BOX_LIKE_MASK_PARAMS,
|
||||||
|
interaction: buildBoxMaskInteraction({
|
||||||
|
sizeMode: "width-height",
|
||||||
|
buildOverlayPath({ width, height }) {
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const halfWidth = width / 2;
|
||||||
|
const halfHeight = height / 2;
|
||||||
|
return [
|
||||||
|
`M ${cx},${cy - halfHeight * 0.475}`,
|
||||||
|
`C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
|
||||||
|
`C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
|
||||||
|
"Z",
|
||||||
|
].join(" ");
|
||||||
|
},
|
||||||
|
}),
|
||||||
buildDefault(context) {
|
buildDefault(context) {
|
||||||
return {
|
return {
|
||||||
type: "heart",
|
type: "heart",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
|
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
|
||||||
import { masksRegistry, type MaskIconProps } from "../registry";
|
import { masksRegistry, type MaskIconProps } from "../registry";
|
||||||
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
|
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
|
||||||
|
import { customMaskDefinition } from "./custom";
|
||||||
import { diamondMaskDefinition } from "./diamond";
|
import { diamondMaskDefinition } from "./diamond";
|
||||||
import { ellipseMaskDefinition } from "./ellipse";
|
import { ellipseMaskDefinition } from "./ellipse";
|
||||||
import { heartMaskDefinition } from "./heart";
|
import { heartMaskDefinition } from "./heart";
|
||||||
import { rectangleMaskDefinition } from "./rectangle";
|
import { rectangleMaskDefinition } from "./rectangle";
|
||||||
import { splitMaskDefinition } from "./split";
|
import { splitMaskDefinition } from "./split";
|
||||||
import { starMaskDefinition } from "./star";
|
import { starMaskDefinition } from "./star";
|
||||||
|
import { textMaskDefinition } from "./text";
|
||||||
import {
|
import {
|
||||||
MinusSignIcon,
|
MinusSignIcon,
|
||||||
PanelRightDashedIcon,
|
PanelRightDashedIcon,
|
||||||
|
|
@ -15,6 +17,7 @@ import {
|
||||||
FavouriteIcon,
|
FavouriteIcon,
|
||||||
DiamondIcon,
|
DiamondIcon,
|
||||||
StarsIcon,
|
StarsIcon,
|
||||||
|
TextFontIcon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
|
|
||||||
function registerDefaultMask<TParams extends BaseMaskParams>({
|
function registerDefaultMask<TParams extends BaseMaskParams>({
|
||||||
|
|
@ -60,4 +63,12 @@ export function registerDefaultMasks(): void {
|
||||||
definition: starMaskDefinition,
|
definition: starMaskDefinition,
|
||||||
icon: { icon: StarsIcon },
|
icon: { icon: StarsIcon },
|
||||||
});
|
});
|
||||||
|
registerDefaultMask({
|
||||||
|
definition: textMaskDefinition,
|
||||||
|
icon: { icon: TextFontIcon },
|
||||||
|
});
|
||||||
|
registerDefaultMask({
|
||||||
|
definition: customMaskDefinition,
|
||||||
|
icon: { icon: SquareIcon },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,97 @@
|
||||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||||
import {
|
import {
|
||||||
BOX_LIKE_MASK_PARAMS,
|
BOX_LIKE_MASK_PARAMS,
|
||||||
computeBoxMaskParamUpdate,
|
buildBoxMaskInteraction,
|
||||||
getBoxLikeGeometry,
|
computeBoxMaskParamUpdate,
|
||||||
getDefaultSquareMaskParams,
|
getBoxLikeGeometry,
|
||||||
getStrokeOffset,
|
getDefaultSquareMaskParams,
|
||||||
rotatePoint,
|
getStrokeOffset,
|
||||||
} from "./box-like";
|
rotatePoint,
|
||||||
|
} from "./box-like";
|
||||||
function buildRectanglePath({
|
|
||||||
centerX,
|
function buildRectanglePath({
|
||||||
centerY,
|
centerX,
|
||||||
halfWidth,
|
centerY,
|
||||||
halfHeight,
|
halfWidth,
|
||||||
rotationRad,
|
halfHeight,
|
||||||
}: {
|
rotationRad,
|
||||||
centerX: number;
|
}: {
|
||||||
centerY: number;
|
centerX: number;
|
||||||
halfWidth: number;
|
centerY: number;
|
||||||
halfHeight: number;
|
halfWidth: number;
|
||||||
rotationRad: number;
|
halfHeight: number;
|
||||||
}): Path2D {
|
rotationRad: number;
|
||||||
const corners = [
|
}): Path2D {
|
||||||
{ x: centerX - halfWidth, y: centerY - halfHeight },
|
const corners = [
|
||||||
{ x: centerX + halfWidth, y: centerY - halfHeight },
|
{ x: centerX - halfWidth, y: centerY - halfHeight },
|
||||||
{ x: centerX + halfWidth, y: centerY + halfHeight },
|
{ x: centerX + halfWidth, y: centerY - halfHeight },
|
||||||
{ x: centerX - halfWidth, y: centerY + halfHeight },
|
{ x: centerX + halfWidth, y: centerY + halfHeight },
|
||||||
].map((point) =>
|
{ x: centerX - halfWidth, y: centerY + halfHeight },
|
||||||
rotatePoint({
|
].map((point) =>
|
||||||
...point,
|
rotatePoint({
|
||||||
centerX,
|
...point,
|
||||||
centerY,
|
centerX,
|
||||||
rotationRad,
|
centerY,
|
||||||
}),
|
rotationRad,
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
const path = new Path2D();
|
|
||||||
path.moveTo(corners[0].x, corners[0].y);
|
const path = new Path2D();
|
||||||
for (const corner of corners.slice(1)) {
|
path.moveTo(corners[0].x, corners[0].y);
|
||||||
path.lineTo(corner.x, corner.y);
|
for (const corner of corners.slice(1)) {
|
||||||
}
|
path.lineTo(corner.x, corner.y);
|
||||||
path.closePath();
|
}
|
||||||
return path;
|
path.closePath();
|
||||||
}
|
return path;
|
||||||
|
}
|
||||||
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
|
||||||
type: "rectangle",
|
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
name: "Rectangle",
|
type: "rectangle",
|
||||||
overlayShape: "box",
|
name: "Rectangle",
|
||||||
features: {
|
features: {
|
||||||
hasPosition: true,
|
hasPosition: true,
|
||||||
hasRotation: true,
|
hasRotation: true,
|
||||||
sizeMode: "width-height",
|
sizeMode: "width-height",
|
||||||
},
|
},
|
||||||
params: BOX_LIKE_MASK_PARAMS,
|
params: BOX_LIKE_MASK_PARAMS,
|
||||||
buildDefault(context) {
|
interaction: buildBoxMaskInteraction({
|
||||||
return {
|
sizeMode: "width-height",
|
||||||
type: "rectangle",
|
}),
|
||||||
params: getDefaultSquareMaskParams(context),
|
buildDefault(context) {
|
||||||
};
|
return {
|
||||||
},
|
type: "rectangle",
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
params: getDefaultSquareMaskParams(context),
|
||||||
renderer: {
|
};
|
||||||
buildPath({ resolvedParams, width, height }) {
|
},
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
renderer: {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
buildPath({ resolvedParams, width, height }) {
|
||||||
return buildRectanglePath({
|
const params = resolvedParams as RectangleMaskParams;
|
||||||
centerX,
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
centerY,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
halfWidth: maskWidth / 2,
|
return buildRectanglePath({
|
||||||
halfHeight: maskHeight / 2,
|
centerX,
|
||||||
rotationRad,
|
centerY,
|
||||||
});
|
halfWidth: maskWidth / 2,
|
||||||
},
|
halfHeight: maskHeight / 2,
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
rotationRad,
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
});
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
},
|
||||||
getBoxLikeGeometry({ params, width, height });
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
const offset = getStrokeOffset({
|
const params = resolvedParams as RectangleMaskParams;
|
||||||
strokeAlign: params.strokeAlign,
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
strokeWidth: params.strokeWidth,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
});
|
const offset = getStrokeOffset({
|
||||||
return buildRectanglePath({
|
strokeAlign: params.strokeAlign,
|
||||||
centerX,
|
strokeWidth: params.strokeWidth,
|
||||||
centerY,
|
});
|
||||||
halfWidth: Math.max(1, maskWidth / 2 + offset),
|
return buildRectanglePath({
|
||||||
halfHeight: Math.max(1, maskHeight / 2 + offset),
|
centerX,
|
||||||
rotationRad,
|
centerY,
|
||||||
});
|
halfWidth: Math.max(1, maskWidth / 2 + offset),
|
||||||
},
|
halfHeight: Math.max(1, maskHeight / 2 + offset),
|
||||||
},
|
rotationRad,
|
||||||
};
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,347 +1,382 @@
|
||||||
import { computeFeatherUpdate } from "../param-update";
|
import { computeFeatherUpdate } from "../param-update";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type {
|
||||||
import type {
|
MaskDefinition,
|
||||||
MaskDefinition,
|
MaskParamUpdateArgs,
|
||||||
MaskParamUpdateArgs,
|
SplitMaskParams,
|
||||||
SplitMaskParams,
|
} from "@/lib/masks/types";
|
||||||
} from "@/lib/masks/types";
|
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
|
||||||
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
|
import {
|
||||||
|
getLineMaskHandlePositions,
|
||||||
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
|
getLineMaskOverlay,
|
||||||
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
|
} from "@/lib/masks/handle-positions";
|
||||||
// exactly on the split line, which produces spurious midpoint vertices.
|
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||||
const NORMAL_SNAP_EPSILON = 1e-10;
|
|
||||||
|
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
|
||||||
// Guards against collinear vertices from float noise at canvas edges.
|
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
|
||||||
const MIN_POLYGON_AREA_PX = 0.5;
|
// exactly on the split line, which produces spurious midpoint vertices.
|
||||||
const INTERSECTION_EPSILON = 1e-6;
|
const NORMAL_SNAP_EPSILON = 1e-10;
|
||||||
|
|
||||||
function polygonArea({ vertices }: { vertices: [number, number][] }): number {
|
// Guards against collinear vertices from float noise at canvas edges.
|
||||||
let area = 0;
|
const MIN_POLYGON_AREA_PX = 0.5;
|
||||||
for (let i = 0; i < vertices.length; i++) {
|
const INTERSECTION_EPSILON = 1e-6;
|
||||||
const [x1, y1] = vertices[i];
|
|
||||||
const [x2, y2] = vertices[(i + 1) % vertices.length];
|
function polygonArea({ vertices }: { vertices: [number, number][] }): number {
|
||||||
area += x1 * y2 - x2 * y1;
|
let area = 0;
|
||||||
}
|
for (let i = 0; i < vertices.length; i++) {
|
||||||
return Math.abs(area) * 0.5;
|
const [x1, y1] = vertices[i];
|
||||||
}
|
const [x2, y2] = vertices[(i + 1) % vertices.length];
|
||||||
|
area += x1 * y2 - x2 * y1;
|
||||||
function splitLineGeometry({
|
}
|
||||||
centerX,
|
return Math.abs(area) * 0.5;
|
||||||
centerY,
|
}
|
||||||
rotation,
|
|
||||||
width,
|
function splitLineGeometry({
|
||||||
height,
|
centerX,
|
||||||
}: {
|
centerY,
|
||||||
centerX: number;
|
rotation,
|
||||||
centerY: number;
|
width,
|
||||||
rotation: number;
|
height,
|
||||||
width: number;
|
}: {
|
||||||
height: number;
|
centerX: number;
|
||||||
}): { normalX: number; normalY: number; lineX: number; lineY: number } {
|
centerY: number;
|
||||||
const angleRad = (rotation * Math.PI) / 180;
|
rotation: number;
|
||||||
const normalX =
|
width: number;
|
||||||
Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
|
height: number;
|
||||||
const normalY =
|
}): { normalX: number; normalY: number; lineX: number; lineY: number } {
|
||||||
Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
|
const angleRad = (rotation * Math.PI) / 180;
|
||||||
const lineX = width / 2 + centerX * width;
|
const normalX =
|
||||||
const lineY = height / 2 + centerY * height;
|
Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
|
||||||
return { normalX, normalY, lineX, lineY };
|
const normalY =
|
||||||
}
|
Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
|
||||||
|
const lineX = width / 2 + centerX * width;
|
||||||
function pointsEqual(
|
const lineY = height / 2 + centerY * height;
|
||||||
a: { x: number; y: number },
|
return { normalX, normalY, lineX, lineY };
|
||||||
b: { x: number; y: number },
|
}
|
||||||
): boolean {
|
|
||||||
return (
|
function pointsEqual(
|
||||||
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
|
a: { x: number; y: number },
|
||||||
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
|
b: { x: number; y: number },
|
||||||
);
|
): boolean {
|
||||||
}
|
return (
|
||||||
|
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
|
||||||
export function getSplitMaskStrokeSegment({
|
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
|
||||||
resolvedParams,
|
);
|
||||||
width,
|
}
|
||||||
height,
|
|
||||||
}: {
|
export function getSplitMaskStrokeSegment({
|
||||||
resolvedParams: unknown;
|
resolvedParams,
|
||||||
width: number;
|
width,
|
||||||
height: number;
|
height,
|
||||||
}): [{ x: number; y: number }, { x: number; y: number }] | null {
|
}: {
|
||||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
resolvedParams: unknown;
|
||||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
width: number;
|
||||||
centerX,
|
height: number;
|
||||||
centerY,
|
}): [{ x: number; y: number }, { x: number; y: number }] | null {
|
||||||
rotation,
|
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||||
width,
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||||
height,
|
centerX,
|
||||||
});
|
centerY,
|
||||||
|
rotation,
|
||||||
const edges: [number, number, number, number][] = [
|
width,
|
||||||
[0, 0, width, 0],
|
height,
|
||||||
[width, 0, width, height],
|
});
|
||||||
[width, height, 0, height],
|
|
||||||
[0, height, 0, 0],
|
const edges: [number, number, number, number][] = [
|
||||||
];
|
[0, 0, width, 0],
|
||||||
const intersections: { x: number; y: number }[] = [];
|
[width, 0, width, height],
|
||||||
|
[width, height, 0, height],
|
||||||
for (const [x1, y1, x2, y2] of edges) {
|
[0, height, 0, 0],
|
||||||
const hit = lineEdgeIntersection({
|
];
|
||||||
lineX,
|
const intersections: { x: number; y: number }[] = [];
|
||||||
lineY,
|
|
||||||
normalX,
|
for (const [x1, y1, x2, y2] of edges) {
|
||||||
normalY,
|
const hit = lineEdgeIntersection({
|
||||||
x1,
|
lineX,
|
||||||
y1,
|
lineY,
|
||||||
x2,
|
normalX,
|
||||||
y2,
|
normalY,
|
||||||
});
|
x1,
|
||||||
|
y1,
|
||||||
if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
|
x2,
|
||||||
continue;
|
y2,
|
||||||
}
|
});
|
||||||
|
|
||||||
intersections.push(hit);
|
if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
if (intersections.length !== 2) {
|
|
||||||
return null;
|
intersections.push(hit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [intersections[0], intersections[1]];
|
if (intersections.length !== 2) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
function computeSplitMaskParamUpdate({
|
|
||||||
handleId,
|
return [intersections[0], intersections[1]];
|
||||||
startParams,
|
}
|
||||||
deltaX,
|
|
||||||
deltaY,
|
function computeSplitMaskParamUpdate({
|
||||||
startCanvasX,
|
handleId,
|
||||||
startCanvasY,
|
startParams,
|
||||||
bounds,
|
deltaX,
|
||||||
canvasSize,
|
deltaY,
|
||||||
}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues {
|
startCanvasX,
|
||||||
if (handleId === "position") {
|
startCanvasY,
|
||||||
const rawX = startParams.centerX + deltaX / bounds.width;
|
bounds,
|
||||||
const rawY = startParams.centerY + deltaY / bounds.height;
|
canvasSize,
|
||||||
|
}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
|
||||||
const minX = -bounds.cx / bounds.width;
|
if (handleId === "position") {
|
||||||
const maxX = (canvasSize.width - bounds.cx) / bounds.width;
|
const rawX = startParams.centerX + deltaX / bounds.width;
|
||||||
const minY = -bounds.cy / bounds.height;
|
const rawY = startParams.centerY + deltaY / bounds.height;
|
||||||
const maxY = (canvasSize.height - bounds.cy) / bounds.height;
|
|
||||||
|
const minX = -bounds.cx / bounds.width;
|
||||||
return {
|
const maxX = (canvasSize.width - bounds.cx) / bounds.width;
|
||||||
centerX: Math.max(minX, Math.min(maxX, rawX)),
|
const minY = -bounds.cy / bounds.height;
|
||||||
centerY: Math.max(minY, Math.min(maxY, rawY)),
|
const maxY = (canvasSize.height - bounds.cy) / bounds.height;
|
||||||
};
|
|
||||||
}
|
return {
|
||||||
|
centerX: Math.max(minX, Math.min(maxX, rawX)),
|
||||||
if (handleId === "feather") {
|
centerY: Math.max(minY, Math.min(maxY, rawY)),
|
||||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
};
|
||||||
return computeFeatherUpdate({
|
}
|
||||||
startFeather: startParams.feather,
|
|
||||||
deltaX,
|
if (handleId === "feather") {
|
||||||
deltaY,
|
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||||
directionX: -Math.cos(angleRad),
|
return computeFeatherUpdate({
|
||||||
directionY: -Math.sin(angleRad),
|
startFeather: startParams.feather,
|
||||||
});
|
deltaX,
|
||||||
}
|
deltaY,
|
||||||
|
directionX: -Math.cos(angleRad),
|
||||||
if (handleId === "rotation") {
|
directionY: -Math.sin(angleRad),
|
||||||
const pivotX = bounds.cx + startParams.centerX * bounds.width;
|
});
|
||||||
const pivotY = bounds.cy + startParams.centerY * bounds.height;
|
}
|
||||||
const startAngle =
|
|
||||||
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
|
if (handleId === "rotation") {
|
||||||
Math.PI;
|
const pivotX = bounds.cx + startParams.centerX * bounds.width;
|
||||||
const currentAngle =
|
const pivotY = bounds.cy + startParams.centerY * bounds.height;
|
||||||
(Math.atan2(
|
const startAngle =
|
||||||
startCanvasY + deltaY - pivotY,
|
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
|
||||||
startCanvasX + deltaX - pivotX,
|
Math.PI;
|
||||||
) *
|
const currentAngle =
|
||||||
180) /
|
(Math.atan2(
|
||||||
Math.PI;
|
startCanvasY + deltaY - pivotY,
|
||||||
|
startCanvasX + deltaX - pivotX,
|
||||||
let deltaAngle = currentAngle - startAngle;
|
) *
|
||||||
if (deltaAngle > 180) deltaAngle -= 360;
|
180) /
|
||||||
if (deltaAngle < -180) deltaAngle += 360;
|
Math.PI;
|
||||||
|
|
||||||
return {
|
let deltaAngle = currentAngle - startAngle;
|
||||||
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
|
if (deltaAngle > 180) deltaAngle -= 360;
|
||||||
};
|
if (deltaAngle < -180) deltaAngle += 360;
|
||||||
}
|
|
||||||
|
return {
|
||||||
return {};
|
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
|
||||||
}
|
};
|
||||||
|
}
|
||||||
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
|
|
||||||
type: "split",
|
return {};
|
||||||
name: "Split",
|
}
|
||||||
overlayShape: "line",
|
|
||||||
features: {
|
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
|
||||||
hasPosition: true,
|
type: "split",
|
||||||
hasRotation: true,
|
name: "Split",
|
||||||
sizeMode: "none",
|
features: {
|
||||||
},
|
hasPosition: true,
|
||||||
buildDefault() {
|
hasRotation: true,
|
||||||
return {
|
sizeMode: "none",
|
||||||
type: "split",
|
},
|
||||||
params: {
|
interaction: {
|
||||||
feather: 0,
|
getInteraction({
|
||||||
inverted: false,
|
params,
|
||||||
strokeColor: "#ffffff",
|
bounds,
|
||||||
strokeWidth: 0,
|
displayScale,
|
||||||
strokeAlign: "center",
|
scaleX: _scaleX,
|
||||||
centerX: 0,
|
scaleY: _scaleY,
|
||||||
centerY: 0,
|
}) {
|
||||||
rotation: 0,
|
return {
|
||||||
},
|
handles: getLineMaskHandlePositions({
|
||||||
};
|
centerX: params.centerX,
|
||||||
},
|
centerY: params.centerY,
|
||||||
computeParamUpdate: computeSplitMaskParamUpdate,
|
rotation: params.rotation,
|
||||||
params: [
|
feather: params.feather,
|
||||||
{
|
bounds,
|
||||||
key: "centerX",
|
displayScale,
|
||||||
label: "X",
|
}),
|
||||||
type: "number",
|
overlays: [
|
||||||
default: 0,
|
getLineMaskOverlay({
|
||||||
min: -100,
|
centerX: params.centerX,
|
||||||
max: 100,
|
centerY: params.centerY,
|
||||||
step: 1,
|
rotation: params.rotation,
|
||||||
displayMultiplier: 100,
|
bounds,
|
||||||
},
|
}),
|
||||||
{
|
],
|
||||||
key: "centerY",
|
};
|
||||||
label: "Y",
|
},
|
||||||
type: "number",
|
snap(args) {
|
||||||
default: 0,
|
return snapMaskInteraction(args);
|
||||||
min: -100,
|
},
|
||||||
max: 100,
|
},
|
||||||
step: 1,
|
buildDefault() {
|
||||||
displayMultiplier: 100,
|
return {
|
||||||
},
|
type: "split",
|
||||||
{
|
params: {
|
||||||
key: "rotation",
|
feather: 0,
|
||||||
label: "Rotation",
|
inverted: false,
|
||||||
type: "number",
|
strokeColor: "#ffffff",
|
||||||
default: 0,
|
strokeWidth: 0,
|
||||||
min: 0,
|
strokeAlign: "center",
|
||||||
max: 360,
|
centerX: 0,
|
||||||
step: 1,
|
centerY: 0,
|
||||||
},
|
rotation: 0,
|
||||||
],
|
},
|
||||||
renderer: {
|
};
|
||||||
renderMask({ resolvedParams, ctx, width, height, feather }) {
|
},
|
||||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
computeParamUpdate: computeSplitMaskParamUpdate,
|
||||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
params: [
|
||||||
centerX,
|
{
|
||||||
centerY,
|
key: "centerX",
|
||||||
rotation,
|
label: "X",
|
||||||
width,
|
type: "number",
|
||||||
height,
|
default: 0,
|
||||||
});
|
min: -100,
|
||||||
|
max: 100,
|
||||||
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
|
step: 1,
|
||||||
const featherHalf = feather / 2;
|
displayMultiplier: 100,
|
||||||
const gradient = ctx.createLinearGradient(
|
},
|
||||||
lineX - normalX * featherHalf,
|
{
|
||||||
lineY - normalY * featherHalf,
|
key: "centerY",
|
||||||
lineX + normalX * featherHalf,
|
label: "Y",
|
||||||
lineY + normalY * featherHalf,
|
type: "number",
|
||||||
);
|
default: 0,
|
||||||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
min: -100,
|
||||||
gradient.addColorStop(1, "white");
|
max: 100,
|
||||||
|
step: 1,
|
||||||
ctx.fillStyle = gradient;
|
displayMultiplier: 100,
|
||||||
ctx.fillRect(0, 0, width, height);
|
},
|
||||||
},
|
{
|
||||||
|
key: "rotation",
|
||||||
buildPath({ resolvedParams, width, height }) {
|
label: "Rotation",
|
||||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
type: "number",
|
||||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
default: 0,
|
||||||
centerX,
|
min: 0,
|
||||||
centerY,
|
max: 360,
|
||||||
rotation,
|
step: 1,
|
||||||
width,
|
},
|
||||||
height,
|
],
|
||||||
});
|
renderer: {
|
||||||
|
renderMaskHandlesFeather: true,
|
||||||
const edges: [number, number, number, number][] = [
|
renderMask({ resolvedParams, ctx, width, height, feather }) {
|
||||||
[0, 0, width, 0],
|
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||||
[width, 0, width, height],
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||||
[width, height, 0, height],
|
centerX,
|
||||||
[0, height, 0, 0],
|
centerY,
|
||||||
];
|
rotation,
|
||||||
|
width,
|
||||||
const isInsideHalfPlane = (x: number, y: number) =>
|
height,
|
||||||
halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
|
});
|
||||||
|
|
||||||
const vertices: [number, number][] = [];
|
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
|
||||||
for (const [x1, y1, x2, y2] of edges) {
|
const featherHalf = feather / 2;
|
||||||
const isVertex1Inside = isInsideHalfPlane(x1, y1);
|
const gradient = ctx.createLinearGradient(
|
||||||
const isVertex2Inside = isInsideHalfPlane(x2, y2);
|
lineX - normalX * featherHalf,
|
||||||
|
lineY - normalY * featherHalf,
|
||||||
if (isVertex1Inside && isVertex2Inside) {
|
lineX + normalX * featherHalf,
|
||||||
vertices.push([x2, y2]);
|
lineY + normalY * featherHalf,
|
||||||
} else if (isVertex1Inside && !isVertex2Inside) {
|
);
|
||||||
const hit = lineEdgeIntersection({
|
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||||||
lineX,
|
gradient.addColorStop(1, "white");
|
||||||
lineY,
|
|
||||||
normalX,
|
ctx.fillStyle = gradient;
|
||||||
normalY,
|
ctx.fillRect(0, 0, width, height);
|
||||||
x1,
|
},
|
||||||
y1,
|
|
||||||
x2,
|
buildPath({ resolvedParams, width, height }) {
|
||||||
y2,
|
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||||
});
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||||
if (hit) vertices.push([hit.x, hit.y]);
|
centerX,
|
||||||
} else if (!isVertex1Inside && isVertex2Inside) {
|
centerY,
|
||||||
const hit = lineEdgeIntersection({
|
rotation,
|
||||||
lineX,
|
width,
|
||||||
lineY,
|
height,
|
||||||
normalX,
|
});
|
||||||
normalY,
|
|
||||||
x1,
|
const edges: [number, number, number, number][] = [
|
||||||
y1,
|
[0, 0, width, 0],
|
||||||
x2,
|
[width, 0, width, height],
|
||||||
y2,
|
[width, height, 0, height],
|
||||||
});
|
[0, height, 0, 0],
|
||||||
if (hit) {
|
];
|
||||||
vertices.push([hit.x, hit.y]);
|
|
||||||
vertices.push([x2, y2]);
|
const isInsideHalfPlane = (x: number, y: number) =>
|
||||||
}
|
halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
|
||||||
}
|
|
||||||
}
|
const vertices: [number, number][] = [];
|
||||||
|
for (const [x1, y1, x2, y2] of edges) {
|
||||||
if (
|
const isVertex1Inside = isInsideHalfPlane(x1, y1);
|
||||||
vertices.length < 3 ||
|
const isVertex2Inside = isInsideHalfPlane(x2, y2);
|
||||||
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
|
|
||||||
) {
|
if (isVertex1Inside && isVertex2Inside) {
|
||||||
return new Path2D();
|
vertices.push([x2, y2]);
|
||||||
}
|
} else if (isVertex1Inside && !isVertex2Inside) {
|
||||||
|
const hit = lineEdgeIntersection({
|
||||||
const path = new Path2D();
|
lineX,
|
||||||
path.moveTo(vertices[0][0], vertices[0][1]);
|
lineY,
|
||||||
for (let i = 1; i < vertices.length; i++) {
|
normalX,
|
||||||
path.lineTo(vertices[i][0], vertices[i][1]);
|
normalY,
|
||||||
}
|
x1,
|
||||||
path.closePath();
|
y1,
|
||||||
return path;
|
x2,
|
||||||
},
|
y2,
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
});
|
||||||
const segment = getSplitMaskStrokeSegment({
|
if (hit) vertices.push([hit.x, hit.y]);
|
||||||
resolvedParams,
|
} else if (!isVertex1Inside && isVertex2Inside) {
|
||||||
width,
|
const hit = lineEdgeIntersection({
|
||||||
height,
|
lineX,
|
||||||
});
|
lineY,
|
||||||
const path = new Path2D();
|
normalX,
|
||||||
|
normalY,
|
||||||
if (!segment) {
|
x1,
|
||||||
return path;
|
y1,
|
||||||
}
|
x2,
|
||||||
|
y2,
|
||||||
path.moveTo(segment[0].x, segment[0].y);
|
});
|
||||||
path.lineTo(segment[1].x, segment[1].y);
|
if (hit) {
|
||||||
return path;
|
vertices.push([hit.x, hit.y]);
|
||||||
},
|
vertices.push([x2, y2]);
|
||||||
},
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
vertices.length < 3 ||
|
||||||
|
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
|
||||||
|
) {
|
||||||
|
return new Path2D();
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = new Path2D();
|
||||||
|
path.moveTo(vertices[0][0], vertices[0][1]);
|
||||||
|
for (let i = 1; i < vertices.length; i++) {
|
||||||
|
path.lineTo(vertices[i][0], vertices[i][1]);
|
||||||
|
}
|
||||||
|
path.closePath();
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
|
const segment = getSplitMaskStrokeSegment({
|
||||||
|
resolvedParams,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
const path = new Path2D();
|
||||||
|
|
||||||
|
if (!segment) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.moveTo(segment[0].x, segment[0].y);
|
||||||
|
path.lineTo(segment[1].x, segment[1].y);
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||||
import {
|
import {
|
||||||
BOX_LIKE_MASK_PARAMS,
|
BOX_LIKE_MASK_PARAMS,
|
||||||
|
buildBoxMaskInteraction,
|
||||||
computeBoxMaskParamUpdate,
|
computeBoxMaskParamUpdate,
|
||||||
getBoxLikeGeometry,
|
getBoxLikeGeometry,
|
||||||
getDefaultSquareMaskParams,
|
getDefaultSquareMaskParams,
|
||||||
|
|
@ -87,16 +88,18 @@ function buildOverlayStarPath({
|
||||||
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
type: "star",
|
type: "star",
|
||||||
name: "Star",
|
name: "Star",
|
||||||
overlayShape: "box",
|
|
||||||
buildOverlayPath({ width, height }) {
|
|
||||||
return buildOverlayStarPath({ width, height });
|
|
||||||
},
|
|
||||||
features: {
|
features: {
|
||||||
hasPosition: true,
|
hasPosition: true,
|
||||||
hasRotation: true,
|
hasRotation: true,
|
||||||
sizeMode: "width-height",
|
sizeMode: "width-height",
|
||||||
},
|
},
|
||||||
params: BOX_LIKE_MASK_PARAMS,
|
params: BOX_LIKE_MASK_PARAMS,
|
||||||
|
interaction: buildBoxMaskInteraction({
|
||||||
|
sizeMode: "width-height",
|
||||||
|
buildOverlayPath({ width, height }) {
|
||||||
|
return buildOverlayStarPath({ width, height });
|
||||||
|
},
|
||||||
|
}),
|
||||||
buildDefault(context) {
|
buildDefault(context) {
|
||||||
return {
|
return {
|
||||||
type: "star",
|
type: "star",
|
||||||
|
|
|
||||||
|
|
@ -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 { ElementBounds } from "@/lib/preview/element-bounds";
|
||||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||||
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { RectangleMaskParams } from "@/lib/masks/types";
|
||||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
|
||||||
|
type CenterMaskParams = {
|
||||||
export function hasCenterParams(params: ParamValues): params is ParamValues & {
|
centerX: number;
|
||||||
centerX: number;
|
centerY: number;
|
||||||
centerY: number;
|
};
|
||||||
} {
|
|
||||||
return (
|
type SnapGeometryParams = CenterMaskParams & {
|
||||||
typeof params.centerX === "number" && typeof params.centerY === "number"
|
rotation?: number;
|
||||||
);
|
width?: number;
|
||||||
}
|
height?: number;
|
||||||
|
scale?: number;
|
||||||
export function isRectangleMaskParams(
|
};
|
||||||
params: ParamValues,
|
|
||||||
): params is RectangleMaskParams {
|
export function hasCenterParams(
|
||||||
return (
|
params: Partial<CenterMaskParams>,
|
||||||
hasCenterParams(params) &&
|
): params is CenterMaskParams {
|
||||||
typeof params.width === "number" &&
|
return (
|
||||||
typeof params.height === "number" &&
|
typeof params.centerX === "number" && typeof params.centerY === "number"
|
||||||
typeof params.rotation === "number" &&
|
);
|
||||||
typeof params.scale === "number"
|
}
|
||||||
);
|
|
||||||
}
|
export function isRectangleMaskParams(
|
||||||
|
params: SnapGeometryParams,
|
||||||
export function getMaskLocalCenter({
|
): params is RectangleMaskParams {
|
||||||
params,
|
return (
|
||||||
bounds,
|
hasCenterParams(params) &&
|
||||||
}: {
|
typeof params.width === "number" &&
|
||||||
params: ParamValues;
|
typeof params.height === "number" &&
|
||||||
bounds: ElementBounds;
|
typeof params.rotation === "number" &&
|
||||||
}): { x: number; y: number } | null {
|
typeof params.scale === "number"
|
||||||
if (!hasCenterParams(params)) return null;
|
);
|
||||||
|
}
|
||||||
return {
|
|
||||||
x: params.centerX * bounds.width,
|
export function getMaskLocalCenter({
|
||||||
y: params.centerY * bounds.height,
|
params,
|
||||||
};
|
bounds,
|
||||||
}
|
}: {
|
||||||
|
params: CenterMaskParams;
|
||||||
export function setMaskLocalCenter({
|
bounds: ElementBounds;
|
||||||
center,
|
}): { x: number; y: number } | null {
|
||||||
bounds,
|
if (!hasCenterParams(params)) return null;
|
||||||
}: {
|
|
||||||
center: { x: number; y: number };
|
return {
|
||||||
bounds: ElementBounds;
|
x: params.centerX * bounds.width,
|
||||||
}): Pick<ParamValues, "centerX" | "centerY"> {
|
y: params.centerY * bounds.height,
|
||||||
return {
|
};
|
||||||
centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
|
}
|
||||||
centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
|
|
||||||
};
|
export function setMaskLocalCenter({
|
||||||
}
|
center,
|
||||||
|
bounds,
|
||||||
export function getMaskSnapGeometry({
|
}: {
|
||||||
params,
|
center: { x: number; y: number };
|
||||||
bounds,
|
bounds: ElementBounds;
|
||||||
}: {
|
}): { centerX: number; centerY: number } {
|
||||||
params: ParamValues;
|
return {
|
||||||
bounds: ElementBounds;
|
centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
|
||||||
}): {
|
centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
|
||||||
position: { x: number; y: number };
|
};
|
||||||
size: { width: number; height: number };
|
}
|
||||||
rotation: number;
|
|
||||||
} | null {
|
export function getMaskSnapGeometry({
|
||||||
const position = getMaskLocalCenter({ params, bounds });
|
params,
|
||||||
if (!position) return null;
|
bounds,
|
||||||
|
}: {
|
||||||
if (isRectangleMaskParams(params)) {
|
params: SnapGeometryParams;
|
||||||
return {
|
bounds: ElementBounds;
|
||||||
position,
|
}): {
|
||||||
size: {
|
position: { x: number; y: number };
|
||||||
width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
|
size: { width: number; height: number };
|
||||||
height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
|
rotation: number;
|
||||||
},
|
} | null {
|
||||||
rotation: params.rotation,
|
const position = getMaskLocalCenter({ params, bounds });
|
||||||
};
|
if (!position) return null;
|
||||||
}
|
|
||||||
|
if (isRectangleMaskParams(params)) {
|
||||||
return {
|
return {
|
||||||
position,
|
position,
|
||||||
size: { width: 0, height: 0 },
|
size: {
|
||||||
rotation: typeof params.rotation === "number" ? params.rotation : 0,
|
width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
|
||||||
};
|
height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
|
||||||
}
|
},
|
||||||
|
rotation: params.rotation,
|
||||||
export function toGlobalMaskSnapLines({
|
};
|
||||||
lines,
|
}
|
||||||
bounds,
|
|
||||||
canvasSize,
|
return {
|
||||||
}: {
|
position,
|
||||||
lines: SnapLine[];
|
size: { width: 0, height: 0 },
|
||||||
bounds: ElementBounds;
|
rotation: typeof params.rotation === "number" ? params.rotation : 0,
|
||||||
canvasSize: { width: number; height: number };
|
};
|
||||||
}): SnapLine[] {
|
}
|
||||||
const centerX = bounds.cx - canvasSize.width / 2;
|
|
||||||
const centerY = bounds.cy - canvasSize.height / 2;
|
export function toGlobalMaskSnapLines({
|
||||||
|
lines,
|
||||||
return lines.map((line) =>
|
bounds,
|
||||||
line.type === "vertical"
|
canvasSize,
|
||||||
? {
|
}: {
|
||||||
type: "vertical" as const,
|
lines: SnapLine[];
|
||||||
position: centerX + line.position,
|
bounds: ElementBounds;
|
||||||
}
|
canvasSize: { width: number; height: number };
|
||||||
: {
|
}): SnapLine[] {
|
||||||
type: "horizontal" as const,
|
const centerX = bounds.cx - canvasSize.width / 2;
|
||||||
position: centerY + line.position,
|
const centerY = bounds.cy - canvasSize.height / 2;
|
||||||
},
|
|
||||||
);
|
return lines.map((line) =>
|
||||||
}
|
line.type === "vertical"
|
||||||
|
? {
|
||||||
|
type: "vertical" as const,
|
||||||
|
position: centerX + line.position,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "horizontal" as const,
|
||||||
|
position: centerY + line.position,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,382 +1,497 @@
|
||||||
import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
|
import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
|
||||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||||
import type {
|
import type {
|
||||||
MaskFeatures,
|
MaskFeatures,
|
||||||
MaskHandlePosition,
|
MaskHandlePosition,
|
||||||
MaskLinePoints,
|
MaskLineOverlay,
|
||||||
MaskOverlayShape,
|
MaskOverlay,
|
||||||
} from "@/lib/masks/types";
|
MaskRectOverlay,
|
||||||
import type { ParamValues } from "@/lib/params";
|
RectangleMaskParams,
|
||||||
|
MaskShapeOverlay,
|
||||||
const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
|
} from "@/lib/masks/types";
|
||||||
const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
|
|
||||||
const LINE_EXTENT_MULTIPLIER = 50;
|
const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||||
|
const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||||
const CURSOR = {
|
const LINE_EXTENT_MULTIPLIER = 50;
|
||||||
rotate: "cursor-crosshair",
|
|
||||||
resizeDiagonal: "cursor-nwse-resize",
|
const CURSOR = {
|
||||||
resizeHorizontal: "cursor-ew-resize",
|
rotate: "crosshair",
|
||||||
resizeVertical: "cursor-ns-resize",
|
resizeDiagonal: "nwse-resize",
|
||||||
} as const;
|
resizeHorizontal: "ew-resize",
|
||||||
|
resizeVertical: "ns-resize",
|
||||||
function getNumParam({
|
} as const;
|
||||||
params,
|
|
||||||
key,
|
/**
|
||||||
fallback,
|
* The renderer defines the split line as:
|
||||||
}: {
|
* - normal direction: (cos(rotation), sin(rotation))
|
||||||
params: ParamValues;
|
* - line direction (parallel to cut): (-sin(rotation), cos(rotation))
|
||||||
key: string;
|
* - reference point: (centerX * width, centerY * height) from element centre
|
||||||
fallback: number;
|
*
|
||||||
}): number {
|
* So rotation=0 → normal points right → line runs vertically.
|
||||||
const value = params[key];
|
*/
|
||||||
return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
|
export function getLineMaskLinePoints({
|
||||||
}
|
centerX,
|
||||||
|
centerY,
|
||||||
/**
|
rotation,
|
||||||
* The renderer defines the split line as:
|
bounds,
|
||||||
* - normal direction: (cos(rotation), sin(rotation))
|
}: {
|
||||||
* - line direction (parallel to cut): (-sin(rotation), cos(rotation))
|
centerX: number;
|
||||||
* - reference point: (centerX * width, centerY * height) from element centre
|
centerY: number;
|
||||||
*
|
rotation: number;
|
||||||
* So rotation=0 → normal points right → line runs vertically.
|
bounds: ElementBounds;
|
||||||
*/
|
}): { start: { x: number; y: number }; end: { x: number; y: number } } {
|
||||||
export function getLineMaskLinePoints({
|
const angleRad = (rotation * Math.PI) / 180;
|
||||||
centerX,
|
const normalX = Math.cos(angleRad);
|
||||||
centerY,
|
const normalY = Math.sin(angleRad);
|
||||||
rotation,
|
const lineDirX = -normalY;
|
||||||
bounds,
|
const lineDirY = normalX;
|
||||||
}: {
|
|
||||||
centerX: number;
|
const cx = bounds.cx + centerX * bounds.width;
|
||||||
centerY: number;
|
const cy = bounds.cy + centerY * bounds.height;
|
||||||
rotation: number;
|
|
||||||
bounds: ElementBounds;
|
const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
|
||||||
}): MaskLinePoints {
|
|
||||||
const angleRad = (rotation * Math.PI) / 180;
|
return {
|
||||||
const normalX = Math.cos(angleRad);
|
start: {
|
||||||
const normalY = Math.sin(angleRad);
|
x: cx - lineDirX * extent,
|
||||||
const lineDirX = -normalY;
|
y: cy - lineDirY * extent,
|
||||||
const lineDirY = normalX;
|
},
|
||||||
|
end: {
|
||||||
const cx = bounds.cx + centerX * bounds.width;
|
x: cx + lineDirX * extent,
|
||||||
const cy = bounds.cy + centerY * bounds.height;
|
y: cy + lineDirY * extent,
|
||||||
|
},
|
||||||
const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
|
};
|
||||||
|
}
|
||||||
return {
|
|
||||||
start: {
|
export function getLineMaskOverlay({
|
||||||
x: cx - lineDirX * extent,
|
centerX,
|
||||||
y: cy - lineDirY * extent,
|
centerY,
|
||||||
},
|
rotation,
|
||||||
end: {
|
bounds,
|
||||||
x: cx + lineDirX * extent,
|
handleId = "position",
|
||||||
y: cy + lineDirY * extent,
|
cursor = "move",
|
||||||
},
|
}: {
|
||||||
};
|
centerX: number;
|
||||||
}
|
centerY: number;
|
||||||
|
rotation: number;
|
||||||
export function getLineMaskHandlePositions({
|
bounds: ElementBounds;
|
||||||
centerX,
|
handleId?: string;
|
||||||
centerY,
|
cursor?: string;
|
||||||
rotation,
|
}): MaskLineOverlay {
|
||||||
feather,
|
const { start, end } = getLineMaskLinePoints({
|
||||||
bounds,
|
centerX,
|
||||||
displayScale,
|
centerY,
|
||||||
}: {
|
rotation,
|
||||||
centerX: number;
|
bounds,
|
||||||
centerY: number;
|
});
|
||||||
rotation: number;
|
|
||||||
feather: number;
|
return {
|
||||||
bounds: ElementBounds;
|
id: "line",
|
||||||
displayScale: number;
|
type: "line",
|
||||||
}): MaskHandlePosition[] {
|
start,
|
||||||
const angleRad = (rotation * Math.PI) / 180;
|
end,
|
||||||
const normalX = Math.cos(angleRad);
|
handleId,
|
||||||
const normalY = Math.sin(angleRad);
|
cursor,
|
||||||
|
};
|
||||||
const cx = bounds.cx + centerX * bounds.width;
|
}
|
||||||
const cy = bounds.cy + centerY * bounds.height;
|
|
||||||
|
export function getLineMaskHandlePositions({
|
||||||
const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
centerX,
|
||||||
const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
|
centerY,
|
||||||
|
rotation,
|
||||||
return [
|
feather,
|
||||||
{
|
bounds,
|
||||||
id: "rotation",
|
displayScale,
|
||||||
x: cx + normalX * iconOffsetCanvas,
|
}: {
|
||||||
y: cy + normalY * iconOffsetCanvas,
|
centerX: number;
|
||||||
cursor: CURSOR.rotate,
|
centerY: number;
|
||||||
},
|
rotation: number;
|
||||||
{
|
feather: number;
|
||||||
id: "feather",
|
bounds: ElementBounds;
|
||||||
x: cx - normalX * featherOffset,
|
displayScale: number;
|
||||||
y: cy - normalY * featherOffset,
|
}): MaskHandlePosition[] {
|
||||||
cursor: CURSOR.resizeHorizontal,
|
const angleRad = (rotation * Math.PI) / 180;
|
||||||
},
|
const normalX = Math.cos(angleRad);
|
||||||
];
|
const normalY = Math.sin(angleRad);
|
||||||
}
|
|
||||||
|
const cx = bounds.cx + centerX * bounds.width;
|
||||||
function rotatePoint({
|
const cy = bounds.cy + centerY * bounds.height;
|
||||||
localX,
|
|
||||||
localY,
|
const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
||||||
cx,
|
const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
|
||||||
cy,
|
|
||||||
angleRad,
|
return [
|
||||||
}: {
|
{
|
||||||
localX: number;
|
id: "rotation",
|
||||||
localY: number;
|
x: cx + normalX * iconOffsetCanvas,
|
||||||
cx: number;
|
y: cy + normalY * iconOffsetCanvas,
|
||||||
cy: number;
|
cursor: CURSOR.rotate,
|
||||||
angleRad: number;
|
kind: "icon",
|
||||||
}): { x: number; y: number } {
|
icon: "rotate",
|
||||||
const cos = Math.cos(angleRad);
|
},
|
||||||
const sin = Math.sin(angleRad);
|
{
|
||||||
return {
|
id: "feather",
|
||||||
x: cx + localX * cos - localY * sin,
|
x: cx - normalX * featherOffset,
|
||||||
y: cy + localX * sin + localY * cos,
|
y: cy - normalY * featherOffset,
|
||||||
};
|
cursor: CURSOR.resizeHorizontal,
|
||||||
}
|
kind: "icon",
|
||||||
|
icon: "feather",
|
||||||
export function getBoxMaskHandlePositions({
|
},
|
||||||
centerX,
|
];
|
||||||
centerY,
|
}
|
||||||
width,
|
|
||||||
height,
|
function rotatePoint({
|
||||||
rotation,
|
localX,
|
||||||
feather,
|
localY,
|
||||||
sizeMode,
|
cx,
|
||||||
bounds,
|
cy,
|
||||||
displayScale,
|
angleRad,
|
||||||
}: {
|
}: {
|
||||||
centerX: number;
|
localX: number;
|
||||||
centerY: number;
|
localY: number;
|
||||||
width: number;
|
cx: number;
|
||||||
height: number;
|
cy: number;
|
||||||
rotation: number;
|
angleRad: number;
|
||||||
feather: number;
|
}): { x: number; y: number } {
|
||||||
sizeMode: MaskFeatures["sizeMode"];
|
const cos = Math.cos(angleRad);
|
||||||
bounds: ElementBounds;
|
const sin = Math.sin(angleRad);
|
||||||
displayScale: number;
|
return {
|
||||||
}): MaskHandlePosition[] {
|
x: cx + localX * cos - localY * sin,
|
||||||
const cx = bounds.cx + centerX * bounds.width;
|
y: cy + localX * sin + localY * cos,
|
||||||
const cy = bounds.cy + centerY * bounds.height;
|
};
|
||||||
const angleRad = (rotation * Math.PI) / 180;
|
}
|
||||||
const halfWidth = (width * bounds.width) / 2;
|
|
||||||
const halfHeight = (height * bounds.height) / 2;
|
export function getBoxMaskHandlePositions({
|
||||||
|
centerX,
|
||||||
const handles: MaskHandlePosition[] = [];
|
centerY,
|
||||||
const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
width,
|
||||||
|
height,
|
||||||
const rotHandle = rotatePoint({
|
rotation,
|
||||||
localX: 0,
|
feather,
|
||||||
localY: -halfHeight - handleOffsetCanvas,
|
sizeMode,
|
||||||
cx,
|
showScaleHandle = true,
|
||||||
cy,
|
bounds,
|
||||||
angleRad,
|
displayScale,
|
||||||
});
|
}: {
|
||||||
handles.push({
|
centerX: number;
|
||||||
id: "rotation",
|
centerY: number;
|
||||||
x: rotHandle.x,
|
width: number;
|
||||||
y: rotHandle.y,
|
height: number;
|
||||||
cursor: CURSOR.rotate,
|
rotation: number;
|
||||||
});
|
feather: number;
|
||||||
|
sizeMode: MaskFeatures["sizeMode"];
|
||||||
const featherHandle = rotatePoint({
|
showScaleHandle?: boolean;
|
||||||
localX: 0,
|
bounds: ElementBounds;
|
||||||
localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
|
displayScale: number;
|
||||||
cx,
|
}): MaskHandlePosition[] {
|
||||||
cy,
|
const cx = bounds.cx + centerX * bounds.width;
|
||||||
angleRad,
|
const cy = bounds.cy + centerY * bounds.height;
|
||||||
});
|
const angleRad = (rotation * Math.PI) / 180;
|
||||||
handles.push({
|
const halfWidth = (width * bounds.width) / 2;
|
||||||
id: "feather",
|
const halfHeight = (height * bounds.height) / 2;
|
||||||
x: featherHandle.x,
|
|
||||||
y: featherHandle.y,
|
const handles: MaskHandlePosition[] = [];
|
||||||
cursor: CURSOR.resizeVertical,
|
const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
|
||||||
});
|
|
||||||
|
const rotHandle = rotatePoint({
|
||||||
if (sizeMode === "width-height") {
|
localX: 0,
|
||||||
const corners = [
|
localY: -halfHeight - handleOffsetCanvas,
|
||||||
{ localX: -halfWidth, localY: -halfHeight, id: "top-left" },
|
cx,
|
||||||
{ localX: halfWidth, localY: -halfHeight, id: "top-right" },
|
cy,
|
||||||
{ localX: halfWidth, localY: halfHeight, id: "bottom-right" },
|
angleRad,
|
||||||
{ localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
|
});
|
||||||
];
|
handles.push({
|
||||||
for (const { localX, localY, id } of corners) {
|
id: "rotation",
|
||||||
const point = rotatePoint({ localX, localY, cx, cy, angleRad });
|
x: rotHandle.x,
|
||||||
handles.push({
|
y: rotHandle.y,
|
||||||
id,
|
cursor: CURSOR.rotate,
|
||||||
x: point.x,
|
kind: "icon",
|
||||||
y: point.y,
|
icon: "rotate",
|
||||||
cursor: CURSOR.resizeDiagonal,
|
});
|
||||||
});
|
|
||||||
}
|
const featherHandle = rotatePoint({
|
||||||
const right = rotatePoint({
|
localX: 0,
|
||||||
localX: halfWidth,
|
localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
|
||||||
localY: 0,
|
cx,
|
||||||
cx,
|
cy,
|
||||||
cy,
|
angleRad,
|
||||||
angleRad,
|
});
|
||||||
});
|
handles.push({
|
||||||
const left = rotatePoint({
|
id: "feather",
|
||||||
localX: -halfWidth,
|
x: featherHandle.x,
|
||||||
localY: 0,
|
y: featherHandle.y,
|
||||||
cx,
|
cursor: CURSOR.resizeVertical,
|
||||||
cy,
|
kind: "icon",
|
||||||
angleRad,
|
icon: "feather",
|
||||||
});
|
});
|
||||||
const bottom = rotatePoint({
|
|
||||||
localX: 0,
|
if (sizeMode === "width-height") {
|
||||||
localY: halfHeight,
|
const corners = [
|
||||||
cx,
|
{ localX: -halfWidth, localY: -halfHeight, id: "top-left" },
|
||||||
cy,
|
{ localX: halfWidth, localY: -halfHeight, id: "top-right" },
|
||||||
angleRad,
|
{ localX: halfWidth, localY: halfHeight, id: "bottom-right" },
|
||||||
});
|
{ localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
|
||||||
handles.push({
|
];
|
||||||
id: "left",
|
for (const { localX, localY, id } of corners) {
|
||||||
x: left.x,
|
const point = rotatePoint({ localX, localY, cx, cy, angleRad });
|
||||||
y: left.y,
|
handles.push({
|
||||||
cursor: CURSOR.resizeHorizontal,
|
id,
|
||||||
});
|
x: point.x,
|
||||||
handles.push({
|
y: point.y,
|
||||||
id: "right",
|
cursor: CURSOR.resizeDiagonal,
|
||||||
x: right.x,
|
kind: "corner",
|
||||||
y: right.y,
|
});
|
||||||
cursor: CURSOR.resizeHorizontal,
|
}
|
||||||
});
|
const right = rotatePoint({
|
||||||
handles.push({
|
localX: halfWidth,
|
||||||
id: "bottom",
|
localY: 0,
|
||||||
x: bottom.x,
|
cx,
|
||||||
y: bottom.y,
|
cy,
|
||||||
cursor: CURSOR.resizeVertical,
|
angleRad,
|
||||||
});
|
});
|
||||||
} else if (sizeMode === "height-only") {
|
const left = rotatePoint({
|
||||||
const top = rotatePoint({
|
localX: -halfWidth,
|
||||||
localX: 0,
|
localY: 0,
|
||||||
localY: -halfHeight,
|
cx,
|
||||||
cx,
|
cy,
|
||||||
cy,
|
angleRad,
|
||||||
angleRad,
|
});
|
||||||
});
|
const bottom = rotatePoint({
|
||||||
const bottom = rotatePoint({
|
localX: 0,
|
||||||
localX: 0,
|
localY: halfHeight,
|
||||||
localY: halfHeight,
|
cx,
|
||||||
cx,
|
cy,
|
||||||
cy,
|
angleRad,
|
||||||
angleRad,
|
});
|
||||||
});
|
handles.push({
|
||||||
handles.push({
|
id: "left",
|
||||||
id: "top",
|
x: left.x,
|
||||||
x: top.x,
|
y: left.y,
|
||||||
y: top.y,
|
cursor: CURSOR.resizeHorizontal,
|
||||||
cursor: CURSOR.resizeVertical,
|
kind: "edge",
|
||||||
});
|
edgeAxis: "horizontal",
|
||||||
handles.push({
|
rotation,
|
||||||
id: "bottom",
|
});
|
||||||
x: bottom.x,
|
handles.push({
|
||||||
y: bottom.y,
|
id: "right",
|
||||||
cursor: CURSOR.resizeVertical,
|
x: right.x,
|
||||||
});
|
y: right.y,
|
||||||
} else if (sizeMode === "width-only") {
|
cursor: CURSOR.resizeHorizontal,
|
||||||
const left = rotatePoint({
|
kind: "edge",
|
||||||
localX: -halfWidth,
|
edgeAxis: "horizontal",
|
||||||
localY: 0,
|
rotation,
|
||||||
cx,
|
});
|
||||||
cy,
|
handles.push({
|
||||||
angleRad,
|
id: "bottom",
|
||||||
});
|
x: bottom.x,
|
||||||
const right = rotatePoint({
|
y: bottom.y,
|
||||||
localX: halfWidth,
|
cursor: CURSOR.resizeVertical,
|
||||||
localY: 0,
|
kind: "edge",
|
||||||
cx,
|
edgeAxis: "vertical",
|
||||||
cy,
|
rotation,
|
||||||
angleRad,
|
});
|
||||||
});
|
} else if (sizeMode === "height-only") {
|
||||||
handles.push({
|
const top = rotatePoint({
|
||||||
id: "left",
|
localX: 0,
|
||||||
x: left.x,
|
localY: -halfHeight,
|
||||||
y: left.y,
|
cx,
|
||||||
cursor: CURSOR.resizeHorizontal,
|
cy,
|
||||||
});
|
angleRad,
|
||||||
handles.push({
|
});
|
||||||
id: "right",
|
const bottom = rotatePoint({
|
||||||
x: right.x,
|
localX: 0,
|
||||||
y: right.y,
|
localY: halfHeight,
|
||||||
cursor: CURSOR.resizeHorizontal,
|
cx,
|
||||||
});
|
cy,
|
||||||
} else if (sizeMode === "uniform") {
|
angleRad,
|
||||||
const point = rotatePoint({
|
});
|
||||||
localX: halfWidth,
|
handles.push({
|
||||||
localY: halfHeight,
|
id: "top",
|
||||||
cx,
|
x: top.x,
|
||||||
cy,
|
y: top.y,
|
||||||
angleRad,
|
cursor: CURSOR.resizeVertical,
|
||||||
});
|
kind: "edge",
|
||||||
handles.push({
|
edgeAxis: "vertical",
|
||||||
id: "scale",
|
rotation,
|
||||||
x: point.x,
|
});
|
||||||
y: point.y,
|
handles.push({
|
||||||
cursor: CURSOR.resizeDiagonal,
|
id: "bottom",
|
||||||
});
|
x: bottom.x,
|
||||||
}
|
y: bottom.y,
|
||||||
|
cursor: CURSOR.resizeVertical,
|
||||||
return handles;
|
kind: "edge",
|
||||||
}
|
edgeAxis: "vertical",
|
||||||
|
rotation,
|
||||||
type MaskHandleResolver = (args: {
|
});
|
||||||
features: MaskFeatures;
|
} else if (sizeMode === "width-only") {
|
||||||
params: ParamValues;
|
const left = rotatePoint({
|
||||||
bounds: ElementBounds;
|
localX: -halfWidth,
|
||||||
displayScale: number;
|
localY: 0,
|
||||||
}) => MaskHandlePosition[];
|
cx,
|
||||||
|
cy,
|
||||||
const rectangleHandles: MaskHandleResolver = ({
|
angleRad,
|
||||||
features,
|
});
|
||||||
params,
|
const right = rotatePoint({
|
||||||
bounds,
|
localX: halfWidth,
|
||||||
displayScale,
|
localY: 0,
|
||||||
}) =>
|
cx,
|
||||||
getBoxMaskHandlePositions({
|
cy,
|
||||||
centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
|
angleRad,
|
||||||
centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
|
});
|
||||||
width: getNumParam({ params, key: "width", fallback: 1 }),
|
handles.push({
|
||||||
height: getNumParam({ params, key: "height", fallback: 1 }),
|
id: "left",
|
||||||
rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
|
x: left.x,
|
||||||
feather: getNumParam({ params, key: "feather", fallback: 0 }),
|
y: left.y,
|
||||||
sizeMode: features.sizeMode,
|
cursor: CURSOR.resizeHorizontal,
|
||||||
bounds,
|
kind: "edge",
|
||||||
displayScale,
|
edgeAxis: "horizontal",
|
||||||
});
|
rotation,
|
||||||
|
});
|
||||||
const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = {
|
handles.push({
|
||||||
line: ({ params, bounds, displayScale }) =>
|
id: "right",
|
||||||
getLineMaskHandlePositions({
|
x: right.x,
|
||||||
centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
|
y: right.y,
|
||||||
centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
|
cursor: CURSOR.resizeHorizontal,
|
||||||
rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
|
kind: "edge",
|
||||||
feather: getNumParam({ params, key: "feather", fallback: 0 }),
|
edgeAxis: "horizontal",
|
||||||
bounds,
|
rotation,
|
||||||
displayScale,
|
});
|
||||||
}),
|
} else if (sizeMode === "uniform" && showScaleHandle) {
|
||||||
box: rectangleHandles,
|
const point = rotatePoint({
|
||||||
};
|
localX: halfWidth,
|
||||||
|
localY: halfHeight,
|
||||||
export function getMaskHandlePositions({
|
cx,
|
||||||
overlayShape,
|
cy,
|
||||||
features,
|
angleRad,
|
||||||
params,
|
});
|
||||||
bounds,
|
handles.push({
|
||||||
displayScale,
|
id: "scale",
|
||||||
}: {
|
x: point.x,
|
||||||
overlayShape: MaskOverlayShape;
|
y: point.y,
|
||||||
features: MaskFeatures;
|
cursor: CURSOR.resizeDiagonal,
|
||||||
params: ParamValues;
|
kind: "corner",
|
||||||
bounds: ElementBounds;
|
});
|
||||||
displayScale: number;
|
}
|
||||||
}): MaskHandlePosition[] {
|
|
||||||
return HANDLE_RESOLVERS[overlayShape]({
|
return handles;
|
||||||
features,
|
}
|
||||||
params,
|
|
||||||
bounds,
|
export function getBoxMaskRectOverlay({
|
||||||
displayScale,
|
centerX,
|
||||||
});
|
centerY,
|
||||||
}
|
width,
|
||||||
|
height,
|
||||||
|
rotation,
|
||||||
|
bounds,
|
||||||
|
handleId = "position",
|
||||||
|
cursor = "move",
|
||||||
|
dashed = false,
|
||||||
|
}: {
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
bounds: ElementBounds;
|
||||||
|
handleId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
dashed?: boolean;
|
||||||
|
}): MaskRectOverlay {
|
||||||
|
return {
|
||||||
|
id: "bounding-box",
|
||||||
|
type: "rect",
|
||||||
|
center: {
|
||||||
|
x: bounds.cx + centerX * bounds.width,
|
||||||
|
y: bounds.cy + centerY * bounds.height,
|
||||||
|
},
|
||||||
|
width: width * bounds.width,
|
||||||
|
height: height * bounds.height,
|
||||||
|
rotation,
|
||||||
|
handleId,
|
||||||
|
cursor,
|
||||||
|
dashed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBoxMaskShapeOverlay({
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rotation,
|
||||||
|
bounds,
|
||||||
|
pathData,
|
||||||
|
handleId = "position",
|
||||||
|
cursor = "move",
|
||||||
|
}: {
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
bounds: ElementBounds;
|
||||||
|
pathData: string;
|
||||||
|
handleId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
}): MaskShapeOverlay {
|
||||||
|
return {
|
||||||
|
id: "shape-outline",
|
||||||
|
type: "shape",
|
||||||
|
center: {
|
||||||
|
x: bounds.cx + centerX * bounds.width,
|
||||||
|
y: bounds.cy + centerY * bounds.height,
|
||||||
|
},
|
||||||
|
width: width * bounds.width,
|
||||||
|
height: height * bounds.height,
|
||||||
|
rotation,
|
||||||
|
pathData,
|
||||||
|
handleId,
|
||||||
|
cursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBoxMaskOverlays({
|
||||||
|
params,
|
||||||
|
bounds,
|
||||||
|
pathData,
|
||||||
|
showBoundingBox = true,
|
||||||
|
}: {
|
||||||
|
params: Pick<
|
||||||
|
RectangleMaskParams,
|
||||||
|
"centerX" | "centerY" | "width" | "height" | "rotation"
|
||||||
|
>;
|
||||||
|
bounds: ElementBounds;
|
||||||
|
pathData?: string;
|
||||||
|
showBoundingBox?: boolean;
|
||||||
|
}): MaskOverlay[] {
|
||||||
|
const overlays: MaskOverlay[] = [];
|
||||||
|
if (showBoundingBox) {
|
||||||
|
overlays.push(
|
||||||
|
getBoxMaskRectOverlay({
|
||||||
|
centerX: params.centerX,
|
||||||
|
centerY: params.centerY,
|
||||||
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
|
rotation: params.rotation,
|
||||||
|
bounds,
|
||||||
|
dashed: Boolean(pathData),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathData) {
|
||||||
|
overlays.push(
|
||||||
|
getBoxMaskShapeOverlay({
|
||||||
|
centerX: params.centerX,
|
||||||
|
centerY: params.centerY,
|
||||||
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
|
rotation: params.rotation,
|
||||||
|
bounds,
|
||||||
|
pathData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlays;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,26 @@
|
||||||
import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
|
import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
|
||||||
import { masksRegistry } from "@/lib/masks";
|
|
||||||
import type { ParamValues } from "@/lib/params";
|
export function computeFeatherUpdate({
|
||||||
import type {
|
startFeather,
|
||||||
BaseMaskParams,
|
deltaX,
|
||||||
MaskParamUpdateArgs,
|
deltaY,
|
||||||
MaskType,
|
directionX,
|
||||||
} from "@/lib/masks/types";
|
directionY,
|
||||||
|
}: {
|
||||||
function compactMaskParamValues({
|
startFeather: number;
|
||||||
params,
|
deltaX: number;
|
||||||
}: {
|
deltaY: number;
|
||||||
params: Partial<ParamValues>;
|
directionX: number;
|
||||||
}): ParamValues {
|
directionY: number;
|
||||||
const nextParams: ParamValues = {};
|
}): { feather: number } {
|
||||||
for (const [key, value] of Object.entries(params)) {
|
const projection = deltaX * directionX + deltaY * directionY;
|
||||||
if (value !== undefined) {
|
return {
|
||||||
nextParams[key] = value;
|
feather: Math.max(
|
||||||
}
|
0,
|
||||||
}
|
Math.min(
|
||||||
return nextParams;
|
MAX_FEATHER,
|
||||||
}
|
Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
|
||||||
|
),
|
||||||
export function computeFeatherUpdate({
|
),
|
||||||
startFeather,
|
};
|
||||||
deltaX,
|
}
|
||||||
deltaY,
|
|
||||||
directionX,
|
|
||||||
directionY,
|
|
||||||
}: {
|
|
||||||
startFeather: number;
|
|
||||||
deltaX: number;
|
|
||||||
deltaY: number;
|
|
||||||
directionX: number;
|
|
||||||
directionY: number;
|
|
||||||
}): ParamValues {
|
|
||||||
const projection = deltaX * directionX + deltaY * directionY;
|
|
||||||
return {
|
|
||||||
feather: Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(
|
|
||||||
MAX_FEATHER,
|
|
||||||
Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeMaskParamUpdate({
|
|
||||||
maskType,
|
|
||||||
...args
|
|
||||||
}: {
|
|
||||||
maskType: MaskType;
|
|
||||||
} & MaskParamUpdateArgs<BaseMaskParams & ParamValues>): ParamValues {
|
|
||||||
const definition = masksRegistry.get(maskType);
|
|
||||||
return compactMaskParamValues({
|
|
||||||
params: definition.computeParamUpdate(args),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { MAX_FEATHER } from "@/lib/masks/feather";
|
import { MAX_FEATHER } from "@/lib/masks/feather";
|
||||||
import type { ParamDefinition } from "@/lib/params";
|
import type { ParamDefinition } from "@/lib/params";
|
||||||
import type { BaseMaskParams, MaskDefinition, MaskType } from "@/lib/masks/types";
|
import type {
|
||||||
|
BaseMaskParams,
|
||||||
|
MaskDefaultContext,
|
||||||
|
MaskDefinition,
|
||||||
|
MaskInteractionResult,
|
||||||
|
MaskSnapArgs,
|
||||||
|
MaskSnapResult,
|
||||||
|
MaskType,
|
||||||
|
} from "@/lib/masks/types";
|
||||||
import type { HugeiconsIconProps } from "@hugeicons/react";
|
import type { HugeiconsIconProps } from "@hugeicons/react";
|
||||||
import { DefinitionRegistry } from "@/lib/registry";
|
import { DefinitionRegistry } from "@/lib/registry";
|
||||||
|
|
||||||
|
|
@ -39,9 +47,33 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type RegisteredMaskDefinition = MaskDefinition<BaseMaskParams> & {
|
export interface RegisteredMaskDefinition {
|
||||||
|
type: MaskType;
|
||||||
|
name: string;
|
||||||
|
features: MaskDefinition<BaseMaskParams>["features"];
|
||||||
|
params: ParamDefinition<string>[];
|
||||||
|
renderer: MaskDefinition<BaseMaskParams>["renderer"];
|
||||||
|
interaction: {
|
||||||
|
getInteraction(args: {
|
||||||
|
params: BaseMaskParams;
|
||||||
|
bounds: Parameters<
|
||||||
|
MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
|
||||||
|
>[0]["bounds"];
|
||||||
|
displayScale: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
}): MaskInteractionResult;
|
||||||
|
snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
|
||||||
|
};
|
||||||
|
isActive?: (params: BaseMaskParams) => boolean;
|
||||||
|
buildDefault(
|
||||||
|
context: MaskDefaultContext,
|
||||||
|
): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
|
||||||
|
computeParamUpdate(
|
||||||
|
args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
|
||||||
|
): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
|
||||||
icon: MaskIconProps;
|
icon: MaskIconProps;
|
||||||
};
|
}
|
||||||
|
|
||||||
export class MasksRegistry extends DefinitionRegistry<
|
export class MasksRegistry extends DefinitionRegistry<
|
||||||
MaskType,
|
MaskType,
|
||||||
|
|
@ -59,8 +91,28 @@ export class MasksRegistry extends DefinitionRegistry<
|
||||||
icon: MaskIconProps;
|
icon: MaskIconProps;
|
||||||
}): void {
|
}): void {
|
||||||
const withBaseParams: RegisteredMaskDefinition = {
|
const withBaseParams: RegisteredMaskDefinition = {
|
||||||
...definition,
|
type: definition.type,
|
||||||
|
name: definition.name,
|
||||||
|
features: definition.features,
|
||||||
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
|
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
|
||||||
|
renderer: definition.renderer,
|
||||||
|
interaction: {
|
||||||
|
getInteraction(args) {
|
||||||
|
return definition.interaction.getInteraction(args as never);
|
||||||
|
},
|
||||||
|
snap: definition.interaction.snap
|
||||||
|
? (args) => definition.interaction.snap?.(args as never) as never
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
isActive: definition.isActive
|
||||||
|
? (params) => definition.isActive?.(params as TParams) ?? true
|
||||||
|
: undefined,
|
||||||
|
buildDefault(context) {
|
||||||
|
return definition.buildDefault(context);
|
||||||
|
},
|
||||||
|
computeParamUpdate(args) {
|
||||||
|
return definition.computeParamUpdate(args as never);
|
||||||
|
},
|
||||||
icon,
|
icon,
|
||||||
};
|
};
|
||||||
this.register(definition.type, withBaseParams);
|
this.register(definition.type, withBaseParams);
|
||||||
|
|
|
||||||
|
|
@ -1,335 +1,336 @@
|
||||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||||
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
||||||
import {
|
import {
|
||||||
snapPosition,
|
snapPosition,
|
||||||
snapRotation,
|
snapRotation,
|
||||||
snapScale,
|
snapScale,
|
||||||
snapScaleAxes,
|
snapScaleAxes,
|
||||||
type ScaleEdgePreference,
|
type ScaleEdgePreference,
|
||||||
type SnapLine,
|
type SnapLine,
|
||||||
} from "@/lib/preview/preview-snap";
|
} from "@/lib/preview/preview-snap";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||||
import {
|
import {
|
||||||
isRectangleMaskParams,
|
isRectangleMaskParams,
|
||||||
getMaskSnapGeometry,
|
getMaskSnapGeometry,
|
||||||
setMaskLocalCenter,
|
setMaskLocalCenter,
|
||||||
toGlobalMaskSnapLines,
|
toGlobalMaskSnapLines,
|
||||||
} from "./geometry";
|
} from "./geometry";
|
||||||
|
|
||||||
type MaskSnapResult = {
|
type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
|
||||||
params: ParamValues;
|
|
||||||
activeLines: SnapLine[];
|
type MaskSnapResult<TParams extends SharedMaskParams> = {
|
||||||
};
|
params: TParams;
|
||||||
|
activeLines: SnapLine[];
|
||||||
const CORNER_SIZE_HANDLES = new Set([
|
};
|
||||||
"top-left",
|
|
||||||
"top-right",
|
const CORNER_SIZE_HANDLES = new Set([
|
||||||
"bottom-left",
|
"top-left",
|
||||||
"bottom-right",
|
"top-right",
|
||||||
]);
|
"bottom-left",
|
||||||
|
"bottom-right",
|
||||||
function getClampedRatio({
|
]);
|
||||||
next,
|
|
||||||
base,
|
function getClampedRatio({
|
||||||
}: {
|
next,
|
||||||
next: number;
|
base,
|
||||||
base: number;
|
}: {
|
||||||
}): number {
|
next: number;
|
||||||
return (
|
base: number;
|
||||||
Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
|
}): number {
|
||||||
);
|
return (
|
||||||
}
|
Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
|
||||||
|
);
|
||||||
function getPreferredEdges({
|
}
|
||||||
handleId,
|
|
||||||
}: {
|
function getPreferredEdges({
|
||||||
handleId: string;
|
handleId,
|
||||||
}): ScaleEdgePreference | undefined {
|
}: {
|
||||||
const preferredEdges = {
|
handleId: string;
|
||||||
left:
|
}): ScaleEdgePreference | undefined {
|
||||||
handleId === "left" ||
|
const preferredEdges = {
|
||||||
handleId === "top-left" ||
|
left:
|
||||||
handleId === "bottom-left",
|
handleId === "left" ||
|
||||||
right:
|
handleId === "top-left" ||
|
||||||
handleId === "right" ||
|
handleId === "bottom-left",
|
||||||
handleId === "top-right" ||
|
right:
|
||||||
handleId === "bottom-right",
|
handleId === "right" ||
|
||||||
top:
|
handleId === "top-right" ||
|
||||||
handleId === "top" || handleId === "top-left" || handleId === "top-right",
|
handleId === "bottom-right",
|
||||||
bottom:
|
top:
|
||||||
handleId === "bottom" ||
|
handleId === "top" || handleId === "top-left" || handleId === "top-right",
|
||||||
handleId === "bottom-left" ||
|
bottom:
|
||||||
handleId === "bottom-right",
|
handleId === "bottom" ||
|
||||||
} satisfies ScaleEdgePreference;
|
handleId === "bottom-left" ||
|
||||||
|
handleId === "bottom-right",
|
||||||
return Object.values(preferredEdges).some(Boolean)
|
} satisfies ScaleEdgePreference;
|
||||||
? preferredEdges
|
|
||||||
: undefined;
|
return Object.values(preferredEdges).some(Boolean)
|
||||||
}
|
? preferredEdges
|
||||||
|
: undefined;
|
||||||
function snapMaskPosition({
|
}
|
||||||
proposedParams,
|
|
||||||
bounds,
|
function snapMaskPosition({
|
||||||
canvasSize,
|
proposedParams,
|
||||||
snapThreshold,
|
bounds,
|
||||||
}: {
|
canvasSize,
|
||||||
proposedParams: ParamValues;
|
snapThreshold,
|
||||||
bounds: ElementBounds;
|
}: {
|
||||||
canvasSize: { width: number; height: number };
|
proposedParams: SharedMaskParams;
|
||||||
snapThreshold: { x: number; y: number };
|
bounds: ElementBounds;
|
||||||
}): MaskSnapResult {
|
canvasSize: { width: number; height: number };
|
||||||
const geometry = getMaskSnapGeometry({
|
snapThreshold: { x: number; y: number };
|
||||||
params: proposedParams,
|
}): MaskSnapResult<SharedMaskParams> {
|
||||||
bounds,
|
const geometry = getMaskSnapGeometry({
|
||||||
});
|
params: proposedParams,
|
||||||
if (!geometry) {
|
bounds,
|
||||||
return { params: proposedParams, activeLines: [] };
|
});
|
||||||
}
|
if (!geometry) {
|
||||||
|
return { params: proposedParams, activeLines: [] };
|
||||||
const { snappedPosition, activeLines } = snapPosition({
|
}
|
||||||
proposedPosition: geometry.position,
|
|
||||||
canvasSize: bounds,
|
const { snappedPosition, activeLines } = snapPosition({
|
||||||
elementSize: geometry.size,
|
proposedPosition: geometry.position,
|
||||||
rotation: geometry.rotation,
|
canvasSize: bounds,
|
||||||
snapThreshold,
|
elementSize: geometry.size,
|
||||||
});
|
rotation: geometry.rotation,
|
||||||
|
snapThreshold,
|
||||||
return {
|
});
|
||||||
params: {
|
|
||||||
...proposedParams,
|
return {
|
||||||
...setMaskLocalCenter({
|
params: {
|
||||||
center: snappedPosition,
|
...proposedParams,
|
||||||
bounds,
|
...setMaskLocalCenter({
|
||||||
}),
|
center: snappedPosition,
|
||||||
},
|
bounds,
|
||||||
activeLines: toGlobalMaskSnapLines({
|
}),
|
||||||
lines: activeLines,
|
},
|
||||||
bounds,
|
activeLines: toGlobalMaskSnapLines({
|
||||||
canvasSize,
|
lines: activeLines,
|
||||||
}),
|
bounds,
|
||||||
};
|
canvasSize,
|
||||||
}
|
}),
|
||||||
|
};
|
||||||
function snapMaskRotation({
|
}
|
||||||
proposedParams,
|
|
||||||
}: {
|
function snapMaskRotation({
|
||||||
proposedParams: ParamValues;
|
proposedParams,
|
||||||
}): MaskSnapResult {
|
}: {
|
||||||
if (typeof proposedParams.rotation !== "number") {
|
proposedParams: SharedMaskParams;
|
||||||
return { params: proposedParams, activeLines: [] };
|
}): MaskSnapResult<SharedMaskParams> {
|
||||||
}
|
if (typeof proposedParams.rotation !== "number") {
|
||||||
|
return { params: proposedParams, activeLines: [] };
|
||||||
const { snappedRotation } = snapRotation({
|
}
|
||||||
proposedRotation: proposedParams.rotation,
|
|
||||||
});
|
const { snappedRotation } = snapRotation({
|
||||||
|
proposedRotation: proposedParams.rotation,
|
||||||
return {
|
});
|
||||||
params: {
|
|
||||||
...proposedParams,
|
return {
|
||||||
rotation: snappedRotation,
|
params: {
|
||||||
},
|
...proposedParams,
|
||||||
activeLines: [],
|
rotation: snappedRotation,
|
||||||
};
|
},
|
||||||
}
|
activeLines: [],
|
||||||
|
};
|
||||||
function snapBoxMaskSize({
|
}
|
||||||
handleId,
|
|
||||||
startParams,
|
function snapBoxMaskSize({
|
||||||
proposedParams,
|
handleId,
|
||||||
bounds,
|
startParams,
|
||||||
canvasSize,
|
proposedParams,
|
||||||
snapThreshold,
|
bounds,
|
||||||
}: {
|
canvasSize,
|
||||||
handleId: string;
|
snapThreshold,
|
||||||
startParams: ParamValues;
|
}: {
|
||||||
proposedParams: ParamValues;
|
handleId: string;
|
||||||
bounds: ElementBounds;
|
startParams: SharedMaskParams;
|
||||||
canvasSize: { width: number; height: number };
|
proposedParams: SharedMaskParams;
|
||||||
snapThreshold: { x: number; y: number };
|
bounds: ElementBounds;
|
||||||
}): MaskSnapResult {
|
canvasSize: { width: number; height: number };
|
||||||
if (
|
snapThreshold: { x: number; y: number };
|
||||||
!isRectangleMaskParams(startParams) ||
|
}): MaskSnapResult<SharedMaskParams> {
|
||||||
!isRectangleMaskParams(proposedParams)
|
if (
|
||||||
) {
|
!isRectangleMaskParams(startParams) ||
|
||||||
return { params: proposedParams, activeLines: [] };
|
!isRectangleMaskParams(proposedParams)
|
||||||
}
|
) {
|
||||||
|
return { params: proposedParams, activeLines: [] };
|
||||||
const geometry = getMaskSnapGeometry({
|
}
|
||||||
params: proposedParams,
|
|
||||||
bounds,
|
const geometry = getMaskSnapGeometry({
|
||||||
});
|
params: proposedParams,
|
||||||
if (!geometry) {
|
bounds,
|
||||||
return { params: proposedParams, activeLines: [] };
|
});
|
||||||
}
|
if (!geometry) {
|
||||||
|
return { params: proposedParams, activeLines: [] };
|
||||||
const localCanvasSize = bounds;
|
}
|
||||||
const baseWidth =
|
|
||||||
Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
|
const localCanvasSize = bounds;
|
||||||
const baseHeight =
|
const baseWidth =
|
||||||
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
|
Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
|
||||||
const preferredEdges = getPreferredEdges({ handleId });
|
const baseHeight =
|
||||||
|
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
|
||||||
if (handleId === "right" || handleId === "left") {
|
const preferredEdges = getPreferredEdges({ handleId });
|
||||||
const proposedScaleX = getClampedRatio({
|
|
||||||
next: proposedParams.width,
|
if (handleId === "right" || handleId === "left") {
|
||||||
base: startParams.width,
|
const proposedScaleX = getClampedRatio({
|
||||||
});
|
next: proposedParams.width,
|
||||||
const { x } = snapScaleAxes({
|
base: startParams.width,
|
||||||
proposedScaleX,
|
});
|
||||||
proposedScaleY: 1,
|
const { x } = snapScaleAxes({
|
||||||
position: geometry.position,
|
proposedScaleX,
|
||||||
baseWidth,
|
proposedScaleY: 1,
|
||||||
baseHeight,
|
position: geometry.position,
|
||||||
rotation: proposedParams.rotation,
|
baseWidth,
|
||||||
canvasSize: localCanvasSize,
|
baseHeight,
|
||||||
snapThreshold,
|
rotation: proposedParams.rotation,
|
||||||
preferredEdges,
|
canvasSize: localCanvasSize,
|
||||||
});
|
snapThreshold,
|
||||||
|
preferredEdges,
|
||||||
return {
|
});
|
||||||
params: {
|
|
||||||
...proposedParams,
|
return {
|
||||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
|
params: {
|
||||||
},
|
...proposedParams,
|
||||||
activeLines: toGlobalMaskSnapLines({
|
width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
|
||||||
lines: x.activeLines,
|
},
|
||||||
bounds,
|
activeLines: toGlobalMaskSnapLines({
|
||||||
canvasSize,
|
lines: x.activeLines,
|
||||||
}),
|
bounds,
|
||||||
};
|
canvasSize,
|
||||||
}
|
}),
|
||||||
|
};
|
||||||
if (handleId === "top" || handleId === "bottom") {
|
}
|
||||||
const proposedScaleY = getClampedRatio({
|
|
||||||
next: proposedParams.height,
|
if (handleId === "top" || handleId === "bottom") {
|
||||||
base: startParams.height,
|
const proposedScaleY = getClampedRatio({
|
||||||
});
|
next: proposedParams.height,
|
||||||
const { y } = snapScaleAxes({
|
base: startParams.height,
|
||||||
proposedScaleX: 1,
|
});
|
||||||
proposedScaleY,
|
const { y } = snapScaleAxes({
|
||||||
position: geometry.position,
|
proposedScaleX: 1,
|
||||||
baseWidth,
|
proposedScaleY,
|
||||||
baseHeight,
|
position: geometry.position,
|
||||||
rotation: proposedParams.rotation,
|
baseWidth,
|
||||||
canvasSize: localCanvasSize,
|
baseHeight,
|
||||||
snapThreshold,
|
rotation: proposedParams.rotation,
|
||||||
preferredEdges,
|
canvasSize: localCanvasSize,
|
||||||
});
|
snapThreshold,
|
||||||
|
preferredEdges,
|
||||||
return {
|
});
|
||||||
params: {
|
|
||||||
...proposedParams,
|
return {
|
||||||
height: Math.max(
|
params: {
|
||||||
MIN_MASK_DIMENSION,
|
...proposedParams,
|
||||||
startParams.height * y.snappedScale,
|
height: Math.max(
|
||||||
),
|
MIN_MASK_DIMENSION,
|
||||||
},
|
startParams.height * y.snappedScale,
|
||||||
activeLines: toGlobalMaskSnapLines({
|
),
|
||||||
lines: y.activeLines,
|
},
|
||||||
bounds,
|
activeLines: toGlobalMaskSnapLines({
|
||||||
canvasSize,
|
lines: y.activeLines,
|
||||||
}),
|
bounds,
|
||||||
};
|
canvasSize,
|
||||||
}
|
}),
|
||||||
|
};
|
||||||
if (handleId === "scale") {
|
}
|
||||||
const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
|
|
||||||
const proposedScale = getClampedRatio({
|
if (handleId === "scale") {
|
||||||
next: proposedParams.scale,
|
const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
|
||||||
base: startParams.scale,
|
const proposedScale = getClampedRatio({
|
||||||
});
|
next: proposedParams.scale,
|
||||||
const { snappedScale, activeLines } = snapScale({
|
base: startParams.scale,
|
||||||
proposedScale,
|
});
|
||||||
position: geometry.position,
|
const { snappedScale, activeLines } = snapScale({
|
||||||
baseWidth: baseWidth * baseScale,
|
proposedScale,
|
||||||
baseHeight: baseHeight * baseScale,
|
position: geometry.position,
|
||||||
rotation: proposedParams.rotation,
|
baseWidth: baseWidth * baseScale,
|
||||||
canvasSize: localCanvasSize,
|
baseHeight: baseHeight * baseScale,
|
||||||
snapThreshold,
|
rotation: proposedParams.rotation,
|
||||||
preferredEdges,
|
canvasSize: localCanvasSize,
|
||||||
});
|
snapThreshold,
|
||||||
|
preferredEdges,
|
||||||
return {
|
});
|
||||||
params: {
|
|
||||||
...proposedParams,
|
return {
|
||||||
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
|
params: {
|
||||||
},
|
...proposedParams,
|
||||||
activeLines: toGlobalMaskSnapLines({
|
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
|
||||||
lines: activeLines,
|
},
|
||||||
bounds,
|
activeLines: toGlobalMaskSnapLines({
|
||||||
canvasSize,
|
lines: activeLines,
|
||||||
}),
|
bounds,
|
||||||
};
|
canvasSize,
|
||||||
}
|
}),
|
||||||
|
};
|
||||||
if (CORNER_SIZE_HANDLES.has(handleId)) {
|
}
|
||||||
const proposedScale = getClampedRatio({
|
|
||||||
next: proposedParams.width,
|
if (CORNER_SIZE_HANDLES.has(handleId)) {
|
||||||
base: startParams.width,
|
const proposedScale = getClampedRatio({
|
||||||
});
|
next: proposedParams.width,
|
||||||
const { snappedScale, activeLines } = snapScale({
|
base: startParams.width,
|
||||||
proposedScale,
|
});
|
||||||
position: geometry.position,
|
const { snappedScale, activeLines } = snapScale({
|
||||||
baseWidth,
|
proposedScale,
|
||||||
baseHeight,
|
position: geometry.position,
|
||||||
rotation: proposedParams.rotation,
|
baseWidth,
|
||||||
canvasSize: localCanvasSize,
|
baseHeight,
|
||||||
snapThreshold,
|
rotation: proposedParams.rotation,
|
||||||
preferredEdges,
|
canvasSize: localCanvasSize,
|
||||||
});
|
snapThreshold,
|
||||||
|
preferredEdges,
|
||||||
return {
|
});
|
||||||
params: {
|
|
||||||
...proposedParams,
|
return {
|
||||||
width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
|
params: {
|
||||||
height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
|
...proposedParams,
|
||||||
},
|
width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
|
||||||
activeLines: toGlobalMaskSnapLines({
|
height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
|
||||||
lines: activeLines,
|
},
|
||||||
bounds,
|
activeLines: toGlobalMaskSnapLines({
|
||||||
canvasSize,
|
lines: activeLines,
|
||||||
}),
|
bounds,
|
||||||
};
|
canvasSize,
|
||||||
}
|
}),
|
||||||
|
};
|
||||||
return { params: proposedParams, activeLines: [] };
|
}
|
||||||
}
|
|
||||||
|
return { params: proposedParams, activeLines: [] };
|
||||||
export function snapMaskInteraction({
|
}
|
||||||
handleId,
|
|
||||||
startParams,
|
export function snapMaskInteraction<TParams extends SharedMaskParams>({
|
||||||
proposedParams,
|
handleId,
|
||||||
bounds,
|
startParams,
|
||||||
canvasSize,
|
proposedParams,
|
||||||
snapThreshold,
|
bounds,
|
||||||
}: {
|
canvasSize,
|
||||||
handleId: string;
|
snapThreshold,
|
||||||
startParams: ParamValues;
|
}: {
|
||||||
proposedParams: ParamValues;
|
handleId: string;
|
||||||
bounds: ElementBounds;
|
startParams: TParams;
|
||||||
canvasSize: { width: number; height: number };
|
proposedParams: TParams;
|
||||||
snapThreshold: { x: number; y: number };
|
bounds: ElementBounds;
|
||||||
}): MaskSnapResult {
|
canvasSize: { width: number; height: number };
|
||||||
if (handleId === "position") {
|
snapThreshold: { x: number; y: number };
|
||||||
return snapMaskPosition({
|
}): MaskSnapResult<TParams> {
|
||||||
proposedParams,
|
if (handleId === "position") {
|
||||||
bounds,
|
return snapMaskPosition({
|
||||||
canvasSize,
|
proposedParams,
|
||||||
snapThreshold,
|
bounds,
|
||||||
});
|
canvasSize,
|
||||||
}
|
snapThreshold,
|
||||||
|
}) as MaskSnapResult<TParams>;
|
||||||
if (handleId === "rotation") {
|
}
|
||||||
return snapMaskRotation({ proposedParams });
|
|
||||||
}
|
if (handleId === "rotation") {
|
||||||
|
return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
|
||||||
return snapBoxMaskSize({
|
}
|
||||||
handleId,
|
|
||||||
startParams,
|
return snapBoxMaskSize({
|
||||||
proposedParams,
|
handleId,
|
||||||
bounds,
|
startParams,
|
||||||
canvasSize,
|
proposedParams,
|
||||||
snapThreshold,
|
bounds,
|
||||||
});
|
canvasSize,
|
||||||
}
|
snapThreshold,
|
||||||
|
}) as MaskSnapResult<TParams>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||||
|
import type { ParamDefinition } from "@/lib/params";
|
||||||
|
import type { CustomMaskPathPoint } from "@/lib/masks/custom-path";
|
||||||
|
import type {
|
||||||
|
TextDecoration,
|
||||||
|
TextFontStyle,
|
||||||
|
TextFontWeight,
|
||||||
|
} from "@/lib/text/primitives";
|
||||||
|
|
||||||
export type MaskType =
|
export type MaskType =
|
||||||
| "split"
|
| "split"
|
||||||
|
|
@ -8,9 +15,11 @@ export type MaskType =
|
||||||
| "ellipse"
|
| "ellipse"
|
||||||
| "heart"
|
| "heart"
|
||||||
| "diamond"
|
| "diamond"
|
||||||
| "star";
|
| "star"
|
||||||
|
| "text"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
export interface BaseMaskParams extends ParamValues {
|
export interface BaseMaskParams {
|
||||||
feather: number;
|
feather: number;
|
||||||
inverted: boolean;
|
inverted: boolean;
|
||||||
strokeColor: string;
|
strokeColor: string;
|
||||||
|
|
@ -33,6 +42,30 @@ export interface RectangleMaskParams extends BaseMaskParams {
|
||||||
scale: number;
|
scale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextMaskParams extends BaseMaskParams {
|
||||||
|
content: string;
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fontWeight: TextFontWeight;
|
||||||
|
fontStyle: TextFontStyle;
|
||||||
|
textDecoration: TextDecoration;
|
||||||
|
letterSpacing: number;
|
||||||
|
lineHeight: number;
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
rotation: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomMaskParams extends BaseMaskParams {
|
||||||
|
path: CustomMaskPathPoint[];
|
||||||
|
closed: boolean;
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
rotation: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SplitMask {
|
export interface SplitMask {
|
||||||
id: string;
|
id: string;
|
||||||
type: "split";
|
type: "split";
|
||||||
|
|
@ -75,6 +108,18 @@ export interface StarMask {
|
||||||
params: RectangleMaskParams;
|
params: RectangleMaskParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextMask {
|
||||||
|
id: string;
|
||||||
|
type: "text";
|
||||||
|
params: TextMaskParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomMask {
|
||||||
|
id: string;
|
||||||
|
type: "custom";
|
||||||
|
params: CustomMaskParams;
|
||||||
|
}
|
||||||
|
|
||||||
export type Mask =
|
export type Mask =
|
||||||
| SplitMask
|
| SplitMask
|
||||||
| CinematicBarsMask
|
| CinematicBarsMask
|
||||||
|
|
@ -82,14 +127,16 @@ export type Mask =
|
||||||
| EllipseMask
|
| EllipseMask
|
||||||
| HeartMask
|
| HeartMask
|
||||||
| DiamondMask
|
| DiamondMask
|
||||||
| StarMask;
|
| StarMask
|
||||||
|
| TextMask
|
||||||
|
| CustomMask;
|
||||||
|
|
||||||
export interface MaskRenderer {
|
export interface MaskRenderer {
|
||||||
buildPath(params: {
|
buildPath?: (params: {
|
||||||
resolvedParams: unknown;
|
resolvedParams: unknown;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}): Path2D;
|
}) => Path2D;
|
||||||
buildStrokePath?: (params: {
|
buildStrokePath?: (params: {
|
||||||
resolvedParams: unknown;
|
resolvedParams: unknown;
|
||||||
width: number;
|
width: number;
|
||||||
|
|
@ -103,33 +150,94 @@ export interface MaskRenderer {
|
||||||
height: number;
|
height: number;
|
||||||
feather: number;
|
feather: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
renderMaskHandlesFeather?: boolean;
|
||||||
|
renderStroke?: (params: {
|
||||||
|
resolvedParams: unknown;
|
||||||
|
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MaskOverlayShape = "line" | "box";
|
|
||||||
|
|
||||||
export interface MaskFeatures {
|
export interface MaskFeatures {
|
||||||
hasPosition: boolean;
|
hasPosition: boolean;
|
||||||
hasRotation: boolean;
|
hasRotation: boolean;
|
||||||
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
|
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MaskHandleIcon = "rotate" | "feather";
|
||||||
|
|
||||||
|
export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
|
||||||
|
|
||||||
export interface MaskHandlePosition {
|
export interface MaskHandlePosition {
|
||||||
id: string;
|
id: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
|
kind: MaskHandleKind;
|
||||||
|
isSelected?: boolean;
|
||||||
|
edgeAxis?: "horizontal" | "vertical";
|
||||||
|
rotation?: number;
|
||||||
|
icon?: MaskHandleIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaskLinePoints {
|
export interface MaskLineOverlay {
|
||||||
|
id: string;
|
||||||
|
type: "line";
|
||||||
start: { x: number; y: number };
|
start: { x: number; y: number };
|
||||||
end: { x: number; y: number };
|
end: { x: number; y: number };
|
||||||
|
cursor?: string;
|
||||||
|
handleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MaskRectOverlay {
|
||||||
|
id: string;
|
||||||
|
type: "rect";
|
||||||
|
center: { x: number; y: number };
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
dashed?: boolean;
|
||||||
|
cursor?: string;
|
||||||
|
handleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskShapeOverlay {
|
||||||
|
id: string;
|
||||||
|
type: "shape";
|
||||||
|
center: { x: number; y: number };
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
pathData: string;
|
||||||
|
cursor?: string;
|
||||||
|
handleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskCanvasPathOverlay {
|
||||||
|
id: string;
|
||||||
|
type: "canvas-path";
|
||||||
|
pathData: string;
|
||||||
|
coordinateSpace?: "canvas" | "overlay";
|
||||||
|
cursor?: string;
|
||||||
|
handleId?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
strokeOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaskOverlay =
|
||||||
|
| MaskLineOverlay
|
||||||
|
| MaskRectOverlay
|
||||||
|
| MaskShapeOverlay
|
||||||
|
| MaskCanvasPathOverlay;
|
||||||
|
|
||||||
export interface MaskDefaultContext {
|
export interface MaskDefaultContext {
|
||||||
elementSize?: { width: number; height: number };
|
elementSize?: { width: number; height: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
export interface MaskParamUpdateArgs<
|
||||||
|
TParams extends BaseMaskParams = BaseMaskParams,
|
||||||
|
> {
|
||||||
handleId: string;
|
handleId: string;
|
||||||
startParams: TParams;
|
startParams: TParams;
|
||||||
deltaX: number;
|
deltaX: number;
|
||||||
|
|
@ -140,14 +248,51 @@ export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskPa
|
||||||
canvasSize: { width: number; height: number };
|
canvasSize: { width: number; height: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
|
export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||||
|
handleId: string;
|
||||||
|
startParams: TParams;
|
||||||
|
proposedParams: TParams;
|
||||||
|
bounds: ElementBounds;
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
snapThreshold: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskSnapResult<
|
||||||
|
TParams extends BaseMaskParams = BaseMaskParams,
|
||||||
|
> {
|
||||||
|
params: TParams;
|
||||||
|
activeLines: SnapLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskInteractionResult {
|
||||||
|
handles: MaskHandlePosition[];
|
||||||
|
overlays: MaskOverlay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskInteractionDefinition<
|
||||||
|
TParams extends BaseMaskParams = BaseMaskParams,
|
||||||
|
> {
|
||||||
|
getInteraction(args: {
|
||||||
|
params: TParams;
|
||||||
|
bounds: ElementBounds;
|
||||||
|
displayScale: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
}): MaskInteractionResult;
|
||||||
|
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskDefinition<
|
||||||
|
TParams extends BaseMaskParams = BaseMaskParams,
|
||||||
|
> {
|
||||||
type: MaskType;
|
type: MaskType;
|
||||||
name: string;
|
name: string;
|
||||||
overlayShape: MaskOverlayShape;
|
|
||||||
features: MaskFeatures;
|
features: MaskFeatures;
|
||||||
params: ParamDefinition<keyof TParams & string>[];
|
params: ParamDefinition<keyof TParams & string>[];
|
||||||
renderer: MaskRenderer;
|
renderer: MaskRenderer;
|
||||||
buildOverlayPath?: (params: { width: number; height: number }) => string;
|
interaction: MaskInteractionDefinition<TParams>;
|
||||||
|
/** When defined and returning false, the mask is not applied and the element renders fully visible. */
|
||||||
|
isActive?: (params: TParams) => boolean;
|
||||||
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
|
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
|
||||||
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
|
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export type ScopeEntry = {
|
||||||
hasSelection: () => boolean;
|
hasSelection: () => boolean;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
};
|
clearActive?: () => void;
|
||||||
|
};
|
||||||
let activeScope: ScopeEntry | null = null;
|
|
||||||
|
let activeScope: ScopeEntry | null = null;
|
||||||
export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
|
|
||||||
if (activeScope && activeScope !== entry) {
|
export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
|
||||||
activeScope.clear();
|
if (activeScope && activeScope !== entry) {
|
||||||
}
|
activeScope.clear();
|
||||||
|
}
|
||||||
activeScope = entry;
|
|
||||||
|
activeScope = entry;
|
||||||
return () => {
|
|
||||||
if (activeScope === entry) {
|
return () => {
|
||||||
activeScope = null;
|
if (activeScope === entry) {
|
||||||
}
|
activeScope = null;
|
||||||
};
|
}
|
||||||
}
|
};
|
||||||
|
}
|
||||||
export function clearActiveScope(): boolean {
|
|
||||||
if (!activeScope?.hasSelection()) {
|
export function clearActiveScope(): boolean {
|
||||||
return false;
|
if (!activeScope?.hasSelection()) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
activeScope.clear();
|
|
||||||
return true;
|
(activeScope.clearActive ?? activeScope.clear)();
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { CORNER_RADIUS_MIN } from "@/lib/text/background";
|
import { CORNER_RADIUS_MIN } from "@/lib/text/background";
|
||||||
import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography";
|
|
||||||
import { resolveNumberAtTime } from "@/lib/animation";
|
import { resolveNumberAtTime } from "@/lib/animation";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||||
import type { TextBackground, TextElement } from "@/lib/timeline";
|
import type { TextBackground, TextElement } from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
measureTextBlock,
|
|
||||||
setCanvasLetterSpacing,
|
|
||||||
getTextVisualRect,
|
getTextVisualRect,
|
||||||
type TextBlockMeasurement,
|
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
|
import {
|
||||||
|
measureTextLayout,
|
||||||
|
type MeasuredTextLayout,
|
||||||
|
} from "./primitives";
|
||||||
|
|
||||||
export interface ResolvedTextBackground extends TextBackground {
|
export interface ResolvedTextBackground extends TextBackground {
|
||||||
paddingX: number;
|
paddingX: number;
|
||||||
|
|
@ -18,15 +18,7 @@ export interface ResolvedTextBackground extends TextBackground {
|
||||||
cornerRadius: number;
|
cornerRadius: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeasuredTextElement {
|
export interface MeasuredTextElement extends MeasuredTextLayout {
|
||||||
scaledFontSize: number;
|
|
||||||
fontString: string;
|
|
||||||
letterSpacing: number;
|
|
||||||
lineHeightPx: number;
|
|
||||||
lines: string[];
|
|
||||||
lineMetrics: TextMetrics[];
|
|
||||||
block: TextBlockMeasurement;
|
|
||||||
fontSizeRatio: number;
|
|
||||||
resolvedBackground: ResolvedTextBackground;
|
resolvedBackground: ResolvedTextBackground;
|
||||||
visualRect: { left: number; top: number; width: number; height: number };
|
visualRect: { left: number; top: number; width: number; height: number };
|
||||||
}
|
}
|
||||||
|
|
@ -75,28 +67,20 @@ export function measureTextElement({
|
||||||
localTime: number;
|
localTime: number;
|
||||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||||
}): MeasuredTextElement {
|
}): MeasuredTextElement {
|
||||||
const scaledFontSize =
|
const measuredLayout = measureTextLayout({
|
||||||
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
text: {
|
||||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
content: element.content,
|
||||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
fontSize: element.fontSize,
|
||||||
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
|
fontFamily: element.fontFamily,
|
||||||
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
fontWeight: element.fontWeight,
|
||||||
const letterSpacing = element.letterSpacing ?? 0;
|
fontStyle: element.fontStyle,
|
||||||
const lineHeightPx =
|
textAlign: element.textAlign,
|
||||||
scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
|
textDecoration: element.textDecoration,
|
||||||
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
|
letterSpacing: element.letterSpacing,
|
||||||
const lines = element.content.split("\n");
|
lineHeight: element.lineHeight,
|
||||||
|
},
|
||||||
ctx.save();
|
canvasHeight,
|
||||||
ctx.font = fontString;
|
ctx,
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
|
|
||||||
const lineMetrics = lines.map((line) => ctx.measureText(line));
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
const block = measureTextBlock({
|
|
||||||
lineMetrics,
|
|
||||||
lineHeightPx,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const bg = element.background;
|
const bg = element.background;
|
||||||
|
|
@ -136,20 +120,13 @@ export function measureTextElement({
|
||||||
|
|
||||||
const visualRect = getTextVisualRect({
|
const visualRect = getTextVisualRect({
|
||||||
textAlign: element.textAlign,
|
textAlign: element.textAlign,
|
||||||
block,
|
block: measuredLayout.block,
|
||||||
background: resolvedBackground,
|
background: resolvedBackground,
|
||||||
fontSizeRatio,
|
fontSizeRatio: measuredLayout.fontSizeRatio,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scaledFontSize,
|
...measuredLayout,
|
||||||
fontString,
|
|
||||||
letterSpacing,
|
|
||||||
lineHeightPx,
|
|
||||||
lines,
|
|
||||||
lineMetrics,
|
|
||||||
block,
|
|
||||||
fontSizeRatio,
|
|
||||||
resolvedBackground,
|
resolvedBackground,
|
||||||
visualRect,
|
visualRect,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
return dBToLinear(resolvedDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildWaveformGainSamples({
|
||||||
|
element,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
element: AudioCapableElement;
|
||||||
|
count: number;
|
||||||
|
}): number[] {
|
||||||
|
const durationSeconds = element.duration / TICKS_PER_SECOND;
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const localTime = ((i + 0.5) / count) * durationSeconds;
|
||||||
|
return resolveEffectiveAudioGain({ element, localTime });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAudioGainAutomation({
|
export function buildAudioGainAutomation({
|
||||||
element,
|
element,
|
||||||
trackMuted = false,
|
trackMuted = false,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
type TextElement,
|
type TextElement,
|
||||||
type SceneTracks,
|
type SceneTracks,
|
||||||
type TimelineElement,
|
type TimelineElement,
|
||||||
type TimelineTrack,
|
|
||||||
type AudioElement,
|
type AudioElement,
|
||||||
type VideoElement,
|
type VideoElement,
|
||||||
type ImageElement,
|
type ImageElement,
|
||||||
|
|
@ -414,6 +413,13 @@ export function getElementFontFamilies({
|
||||||
if (element.type === "text" && element.fontFamily) {
|
if (element.type === "text" && element.fontFamily) {
|
||||||
families.add(element.fontFamily);
|
families.add(element.fontFamily);
|
||||||
}
|
}
|
||||||
|
if ("masks" in element) {
|
||||||
|
for (const mask of element.masks ?? []) {
|
||||||
|
if (mask.type === "text" && mask.params.fontFamily) {
|
||||||
|
families.add(mask.params.fontFamily);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...families];
|
return [...families];
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,11 @@ function buildMaskArtifacts({
|
||||||
}
|
}
|
||||||
|
|
||||||
const definition = masksRegistry.get(mask.type);
|
const definition = masksRegistry.get(mask.type);
|
||||||
|
|
||||||
|
if (definition.isActive?.(mask.params) === false) {
|
||||||
|
return { mask: null, strokeLayer: null };
|
||||||
|
}
|
||||||
|
|
||||||
const elementMaskCanvas = createOffscreenCanvas({
|
const elementMaskCanvas = createOffscreenCanvas({
|
||||||
width: Math.round(transform.width),
|
width: Math.round(transform.width),
|
||||||
height: Math.round(transform.height),
|
height: Math.round(transform.height),
|
||||||
|
|
@ -416,7 +421,12 @@ function buildMaskArtifacts({
|
||||||
|
|
||||||
let strokePath: Path2D | null = null;
|
let strokePath: Path2D | null = null;
|
||||||
let feather = mask.params.feather;
|
let feather = mask.params.feather;
|
||||||
if (mask.params.feather > 0 && definition.renderer.renderMask) {
|
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
|
||||||
|
const shouldRenderMaskDirectly =
|
||||||
|
canRenderMaskDirectly &&
|
||||||
|
(!definition.renderer.buildPath ||
|
||||||
|
(mask.params.feather > 0 && definition.renderer.renderMaskHandlesFeather));
|
||||||
|
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
|
||||||
definition.renderer.renderMask({
|
definition.renderer.renderMask({
|
||||||
resolvedParams: mask.params,
|
resolvedParams: mask.params,
|
||||||
ctx: elementMaskCtx,
|
ctx: elementMaskCtx,
|
||||||
|
|
@ -424,13 +434,18 @@ function buildMaskArtifacts({
|
||||||
height: Math.round(transform.height),
|
height: Math.round(transform.height),
|
||||||
feather: mask.params.feather,
|
feather: mask.params.feather,
|
||||||
});
|
});
|
||||||
feather = 0;
|
if (definition.renderer.renderMaskHandlesFeather) {
|
||||||
|
feather = 0;
|
||||||
|
}
|
||||||
strokePath = definition.renderer.buildStrokePath?.({
|
strokePath = definition.renderer.buildStrokePath?.({
|
||||||
resolvedParams: mask.params,
|
resolvedParams: mask.params,
|
||||||
width: transform.width,
|
width: transform.width,
|
||||||
height: transform.height,
|
height: transform.height,
|
||||||
}) ?? null;
|
}) ?? null;
|
||||||
} else {
|
} else {
|
||||||
|
if (!definition.renderer.buildPath) {
|
||||||
|
return { mask: null, strokeLayer: null };
|
||||||
|
}
|
||||||
const path2d = definition.renderer.buildPath({
|
const path2d = definition.renderer.buildPath({
|
||||||
resolvedParams: mask.params,
|
resolvedParams: mask.params,
|
||||||
width: transform.width,
|
width: transform.width,
|
||||||
|
|
@ -472,7 +487,7 @@ function buildMaskArtifacts({
|
||||||
});
|
});
|
||||||
|
|
||||||
let strokeLayer: FrameItemDescriptor | null = null;
|
let strokeLayer: FrameItemDescriptor | null = null;
|
||||||
if (mask.params.strokeWidth > 0 && strokePath) {
|
if (mask.params.strokeWidth > 0 && (strokePath || definition.renderer.renderStroke)) {
|
||||||
const strokeCanvas = createOffscreenCanvas({
|
const strokeCanvas = createOffscreenCanvas({
|
||||||
width: Math.round(transform.width),
|
width: Math.round(transform.width),
|
||||||
height: Math.round(transform.height),
|
height: Math.round(transform.height),
|
||||||
|
|
@ -482,9 +497,18 @@ function buildMaskArtifacts({
|
||||||
| OffscreenCanvasRenderingContext2D
|
| OffscreenCanvasRenderingContext2D
|
||||||
| null;
|
| null;
|
||||||
if (strokeCtx) {
|
if (strokeCtx) {
|
||||||
strokeCtx.strokeStyle = mask.params.strokeColor;
|
if (definition.renderer.renderStroke) {
|
||||||
strokeCtx.lineWidth = mask.params.strokeWidth;
|
definition.renderer.renderStroke({
|
||||||
strokeCtx.stroke(strokePath);
|
resolvedParams: mask.params,
|
||||||
|
ctx: strokeCtx,
|
||||||
|
width: transform.width,
|
||||||
|
height: transform.height,
|
||||||
|
});
|
||||||
|
} else if (strokePath) {
|
||||||
|
strokeCtx.strokeStyle = mask.params.strokeColor;
|
||||||
|
strokeCtx.lineWidth = mask.params.strokeWidth;
|
||||||
|
strokeCtx.stroke(strokePath);
|
||||||
|
}
|
||||||
|
|
||||||
const fullStrokeCanvas = createOffscreenCanvas({
|
const fullStrokeCanvas = createOffscreenCanvas({
|
||||||
width: renderer.width,
|
width: renderer.width,
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,9 @@ import type { TextElement } from "@/lib/timeline";
|
||||||
import type { EffectPass } from "@/lib/effects/types";
|
import type { EffectPass } from "@/lib/effects/types";
|
||||||
import type { Transform } from "@/lib/rendering";
|
import type { Transform } from "@/lib/rendering";
|
||||||
import {
|
import {
|
||||||
CORNER_RADIUS_MAX,
|
drawMeasuredTextLayout,
|
||||||
CORNER_RADIUS_MIN,
|
} from "@/lib/text/primitives";
|
||||||
} from "@/lib/text/background";
|
|
||||||
import {
|
|
||||||
drawTextDecoration,
|
|
||||||
getTextBackgroundRect,
|
|
||||||
setCanvasLetterSpacing,
|
|
||||||
} from "@/lib/text/layout";
|
|
||||||
import type { MeasuredTextElement } from "@/lib/text/measure-element";
|
import type { MeasuredTextElement } from "@/lib/text/measure-element";
|
||||||
import { clamp } from "@/utils/math";
|
|
||||||
|
|
||||||
export type TextNodeParams = TextElement & {
|
export type TextNodeParams = TextElement & {
|
||||||
canvasCenter: { x: number; y: number };
|
canvasCenter: { x: number; y: number };
|
||||||
|
|
@ -46,22 +39,6 @@ export function renderTextToContext({
|
||||||
const x = resolved.transform.position.x + node.params.canvasCenter.x;
|
const x = resolved.transform.position.x + node.params.canvasCenter.x;
|
||||||
const y = resolved.transform.position.y + node.params.canvasCenter.y;
|
const y = resolved.transform.position.y + node.params.canvasCenter.y;
|
||||||
const baseline = node.params.textBaseline ?? "middle";
|
const baseline = node.params.textBaseline ?? "middle";
|
||||||
const {
|
|
||||||
scaledFontSize,
|
|
||||||
fontString,
|
|
||||||
letterSpacing,
|
|
||||||
lineHeightPx,
|
|
||||||
lines,
|
|
||||||
lineMetrics,
|
|
||||||
block,
|
|
||||||
fontSizeRatio,
|
|
||||||
resolvedBackground,
|
|
||||||
} = resolved.measuredText;
|
|
||||||
const lineCount = lines.length;
|
|
||||||
const resolvedBackgroundWithColor = {
|
|
||||||
...resolvedBackground,
|
|
||||||
color: resolved.backgroundColor,
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(x, y);
|
ctx.translate(x, y);
|
||||||
|
|
@ -70,60 +47,14 @@ export function renderTextToContext({
|
||||||
ctx.rotate((resolved.transform.rotate * Math.PI) / 180);
|
ctx.rotate((resolved.transform.rotate * Math.PI) / 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.font = fontString;
|
drawMeasuredTextLayout({
|
||||||
ctx.textAlign = node.params.textAlign;
|
ctx,
|
||||||
ctx.textBaseline = baseline;
|
layout: resolved.measuredText,
|
||||||
ctx.fillStyle = resolved.textColor;
|
textColor: resolved.textColor,
|
||||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
|
background: resolved.measuredText.resolvedBackground,
|
||||||
|
backgroundColor: resolved.backgroundColor,
|
||||||
if (
|
textBaseline: baseline,
|
||||||
node.params.background.enabled &&
|
});
|
||||||
node.params.background.color &&
|
|
||||||
node.params.background.color !== "transparent" &&
|
|
||||||
lineCount > 0
|
|
||||||
) {
|
|
||||||
const backgroundRect = getTextBackgroundRect({
|
|
||||||
textAlign: node.params.textAlign,
|
|
||||||
block,
|
|
||||||
background: resolvedBackgroundWithColor,
|
|
||||||
fontSizeRatio,
|
|
||||||
});
|
|
||||||
if (backgroundRect) {
|
|
||||||
const p =
|
|
||||||
clamp({
|
|
||||||
value: resolvedBackgroundWithColor.cornerRadius,
|
|
||||||
min: CORNER_RADIUS_MIN,
|
|
||||||
max: CORNER_RADIUS_MAX,
|
|
||||||
}) / 100;
|
|
||||||
const radius =
|
|
||||||
(Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
|
|
||||||
ctx.fillStyle = resolvedBackgroundWithColor.color;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.roundRect(
|
|
||||||
backgroundRect.left,
|
|
||||||
backgroundRect.top,
|
|
||||||
backgroundRect.width,
|
|
||||||
backgroundRect.height,
|
|
||||||
radius,
|
|
||||||
);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.fillStyle = resolved.textColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < lineCount; index++) {
|
|
||||||
const lineY = index * lineHeightPx - block.visualCenterOffset;
|
|
||||||
ctx.fillText(lines[index], 0, lineY);
|
|
||||||
drawTextDecoration({
|
|
||||||
ctx,
|
|
||||||
textDecoration: node.params.textDecoration ?? "none",
|
|
||||||
lineWidth: lineMetrics[index].width,
|
|
||||||
lineY,
|
|
||||||
metrics: lineMetrics[index],
|
|
||||||
scaledFontSize,
|
|
||||||
textAlign: node.params.textAlign,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { V23toV24Migration } from "./v23-to-v24";
|
||||||
import { V24toV25Migration } from "./v24-to-v25";
|
import { V24toV25Migration } from "./v24-to-v25";
|
||||||
import { V25toV26Migration } from "./v25-to-v26";
|
import { V25toV26Migration } from "./v25-to-v26";
|
||||||
|
import { V26toV27Migration } from "./v26-to-v27";
|
||||||
export { runStorageMigrations } from "./runner";
|
export { runStorageMigrations } from "./runner";
|
||||||
export type { MigrationProgress } from "./runner";
|
export type { MigrationProgress } from "./runner";
|
||||||
|
|
||||||
export const CURRENT_PROJECT_VERSION = 26;
|
export const CURRENT_PROJECT_VERSION = 27;
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
new V0toV1Migration(),
|
new V0toV1Migration(),
|
||||||
|
|
@ -57,4 +58,5 @@ export const migrations = [
|
||||||
new V23toV24Migration(),
|
new V23toV24Migration(),
|
||||||
new V24toV25Migration(),
|
new V24toV25Migration(),
|
||||||
new V25toV26Migration(),
|
new V25toV26Migration(),
|
||||||
|
new V26toV27Migration(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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