mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: text / custom masks, fix preview pan, refactor selection
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { ColorPickerContent } from "@/components/ui/color-picker";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||
@@ -24,6 +26,9 @@ const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
const CUSTOM_COLOR_SWATCH_BACKGROUND =
|
||||
"conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
@@ -88,7 +93,7 @@ const BackgroundPreviews = memo(
|
||||
onSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
backgrounds: readonly string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
onSelect: (bg: string) => void;
|
||||
@@ -102,7 +107,7 @@ const BackgroundPreviews = memo(
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
@@ -133,10 +138,51 @@ const BackgroundPreviews = memo(
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function CustomColorPreview({
|
||||
currentBackgroundColor,
|
||||
isSelected,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
currentBackgroundColor: string;
|
||||
isSelected: boolean;
|
||||
onPreview: (color: string) => void;
|
||||
onCommit: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
type="button"
|
||||
aria-label="Pick a custom background color"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0"
|
||||
style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
|
||||
/>
|
||||
<span
|
||||
className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
|
||||
style={{ backgroundColor: currentBackgroundColor }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<ColorPickerContent
|
||||
value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
|
||||
onChange={(color) => onPreview(`#${color}`)}
|
||||
onChangeEnd={(color) => onCommit(`#${color}`)}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const COLOR_SECTIONS = [
|
||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||
{ id: "colors", title: "Colors", backgrounds: colors, useBackgroundColor: true, showCustomPicker: true },
|
||||
{ id: "pattern-craft", title: "Pattern craft", backgrounds: patternCraftGradients, showCustomPicker: false },
|
||||
{ id: "syntax-ui", title: "Syntax UI", backgrounds: syntaxUIGradients, showCustomPicker: false },
|
||||
] as const;
|
||||
|
||||
export function BackgroundContent() {
|
||||
@@ -152,10 +198,21 @@ export function BackgroundContent() {
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
const previewBackgroundColor = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
pushHistory: false,
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const commitBackgroundColor = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
pushHistory: true,
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
@@ -173,6 +230,17 @@ export function BackgroundContent() {
|
||||
? (activeProject.settings.background as { color: string }).color
|
||||
: DEFAULT_BACKGROUND_COLOR;
|
||||
|
||||
const hasPresetColorMatch = colors.some(
|
||||
(color) => color.toLowerCase() === currentBackgroundColor.toLowerCase(),
|
||||
);
|
||||
|
||||
const handlePresetColorSelect = useCallback(
|
||||
(color: string) => {
|
||||
void commitBackgroundColor(color);
|
||||
},
|
||||
[commitBackgroundColor],
|
||||
);
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
@@ -203,21 +271,29 @@ export function BackgroundContent() {
|
||||
</Section>
|
||||
{COLOR_SECTIONS.map((section) => (
|
||||
<Section
|
||||
key={section.title}
|
||||
key={section.id}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
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 as string[]}
|
||||
backgrounds={section.backgrounds}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
onSelect={handleColorSelect}
|
||||
onSelect={handlePresetColorSelect}
|
||||
useBackgroundColor={
|
||||
"useBackgroundColor" in section
|
||||
? section.useBackgroundColor
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -13,10 +13,10 @@ export const LINE_HIT_AREA_SIZE = 48;
|
||||
|
||||
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
|
||||
const normalized = ((angleDeg % 180) + 180) % 180;
|
||||
if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
|
||||
if (normalized < 67.5) return "cursor-nwse-resize";
|
||||
if (normalized < 112.5) return "cursor-ns-resize";
|
||||
return "cursor-nesw-resize";
|
||||
if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
|
||||
if (normalized < 67.5) return "nwse-resize";
|
||||
if (normalized < 112.5) return "ns-resize";
|
||||
return "nesw-resize";
|
||||
}
|
||||
|
||||
export function HandleButton({
|
||||
@@ -43,7 +43,6 @@ export function HandleButton({
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center outline-none",
|
||||
cursor,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
@@ -52,6 +51,7 @@ export function HandleButton({
|
||||
width: hitAreaSize,
|
||||
height: hitAreaSize,
|
||||
pointerEvents: "auto",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -95,6 +95,40 @@ export function CornerHandle({
|
||||
);
|
||||
}
|
||||
|
||||
export function CircleHandle({
|
||||
cursor,
|
||||
screen,
|
||||
size = HANDLE_SIZE,
|
||||
isSelected = false,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
cursor?: string;
|
||||
screen: { x: number; y: number };
|
||||
size?: number;
|
||||
isSelected?: boolean;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function EdgeHandle({
|
||||
edge,
|
||||
screen,
|
||||
@@ -188,7 +222,7 @@ export function BoundingBoxOutline({
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute overflow-visible", cursor)}
|
||||
className="absolute overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
@@ -199,6 +233,7 @@ export function BoundingBoxOutline({
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -244,7 +279,7 @@ export function ShapeOutline({
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute overflow-visible", cursor)}
|
||||
className="absolute overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
@@ -255,6 +290,7 @@ export function ShapeOutline({
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -273,15 +309,73 @@ export function ShapeOutline({
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasPathOutline({
|
||||
pathData,
|
||||
translateX = 0,
|
||||
translateY = 0,
|
||||
scaleX = 1,
|
||||
scaleY = 1,
|
||||
cursor,
|
||||
strokeWidth = 1,
|
||||
strokeOpacity = 0.75,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
pathData: string;
|
||||
translateX?: number;
|
||||
translateY?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
cursor?: string;
|
||||
strokeWidth?: number;
|
||||
strokeOpacity?: number;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<g
|
||||
transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
|
||||
>
|
||||
<path
|
||||
d={pathData}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeOpacity={strokeOpacity}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineOverlay({
|
||||
start,
|
||||
end,
|
||||
cursor,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
cursor?: string;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
@@ -310,6 +404,7 @@ export function LineOverlay({
|
||||
top: cy - LINE_HIT_AREA_SIZE / 2,
|
||||
height: LINE_HIT_AREA_SIZE,
|
||||
pointerEvents: "auto",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useMaskHandles } from "@/hooks/use-mask-handles";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
CornerHandle,
|
||||
CircleHandle,
|
||||
CanvasPathOutline,
|
||||
EdgeHandle,
|
||||
IconHandle,
|
||||
LineOverlay,
|
||||
BoundingBoxOutline,
|
||||
ShapeOutline,
|
||||
} from "./handle-primitives";
|
||||
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
function hasRectangleOutlineParams(
|
||||
params: ParamValues,
|
||||
): params is RectangleMaskParams {
|
||||
return (
|
||||
typeof params.centerX === "number" &&
|
||||
typeof params.centerY === "number" &&
|
||||
typeof params.width === "number" &&
|
||||
typeof params.height === "number"
|
||||
);
|
||||
}
|
||||
const CUSTOM_MASK_ANCHOR_SIZE = 7;
|
||||
const CUSTOM_MASK_TANGENT_SIZE = 6;
|
||||
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
export function MaskHandles({
|
||||
onSnapLinesChange,
|
||||
@@ -36,7 +28,9 @@ export function MaskHandles({
|
||||
const {
|
||||
selectedWithMask,
|
||||
handlePositions,
|
||||
linePoints,
|
||||
overlays,
|
||||
isCreatingCustomMask,
|
||||
handleCanvasPointerDown,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
@@ -56,96 +50,158 @@ export function MaskHandles({
|
||||
canvasY,
|
||||
});
|
||||
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
const { bounds } = selectedWithMask;
|
||||
const maskRotation = selectedWithMask.mask.params.rotation;
|
||||
|
||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||
const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
|
||||
|
||||
const rectangleOutlineProps = hasRectangleOutlineParams(
|
||||
selectedWithMask.mask.params,
|
||||
)
|
||||
? {
|
||||
center: toOverlay({
|
||||
canvasX:
|
||||
bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
|
||||
canvasY:
|
||||
bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
|
||||
}),
|
||||
outlineWidth:
|
||||
selectedWithMask.mask.params.width * bounds.width * scaleX,
|
||||
outlineHeight:
|
||||
selectedWithMask.mask.params.height * bounds.height * scaleY,
|
||||
rotation: maskRotation,
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
if (viewport.handlePanPointerMove({ event })) {
|
||||
return;
|
||||
}
|
||||
: null;
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent) =>
|
||||
handlePointerMove({ event });
|
||||
const onPointerUp = () => handlePointerUp();
|
||||
};
|
||||
const onPointerUp = (event: React.PointerEvent) => {
|
||||
if (viewport.handlePanPointerUp({ event })) {
|
||||
return;
|
||||
}
|
||||
|
||||
handlePointerUp();
|
||||
};
|
||||
const handleMaskPointerDown = ({
|
||||
event,
|
||||
handleId,
|
||||
}: {
|
||||
event: React.PointerEvent;
|
||||
handleId: string;
|
||||
}) => {
|
||||
if (viewport.handlePanPointerDown({ event })) {
|
||||
return;
|
||||
}
|
||||
|
||||
handlePointerDown({ event, handleId });
|
||||
};
|
||||
const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => {
|
||||
if (viewport.handlePanPointerDown({ event })) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleCanvasPointerDown({ event });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
{def.overlayShape === "line" && linePoints && (
|
||||
{isCreatingCustomMask ? (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-auto"
|
||||
style={{ cursor: PEN_CURSOR }}
|
||||
onPointerDown={handleCanvasOverlayPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
/>
|
||||
) : null}
|
||||
{overlays.map((overlay) => {
|
||||
const overlayHandleId = overlay.handleId;
|
||||
const pointerHandlers = overlayHandleId
|
||||
? {
|
||||
onPointerDown: (event: React.PointerEvent) =>
|
||||
handleMaskPointerDown({ event, handleId: overlayHandleId }),
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}
|
||||
: {};
|
||||
|
||||
if (overlay.type === "line") {
|
||||
return (
|
||||
<LineOverlay
|
||||
key={overlay.id}
|
||||
cursor={overlay.cursor}
|
||||
start={toOverlay({
|
||||
canvasX: linePoints.start.x,
|
||||
canvasY: linePoints.start.y,
|
||||
canvasX: overlay.start.x,
|
||||
canvasY: overlay.start.y,
|
||||
})}
|
||||
end={toOverlay({
|
||||
canvasX: linePoints.end.x,
|
||||
canvasY: linePoints.end.y,
|
||||
canvasX: overlay.end.x,
|
||||
canvasY: overlay.end.y,
|
||||
})}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
)}
|
||||
{def.overlayShape === "box" && rectangleOutlineProps && (
|
||||
def.buildOverlayPath ? (
|
||||
<>
|
||||
<BoundingBoxOutline {...rectangleOutlineProps} dashed />
|
||||
<ShapeOutline
|
||||
{...rectangleOutlineProps}
|
||||
pathData={def.buildOverlayPath({
|
||||
width: rectangleOutlineProps.outlineWidth,
|
||||
height: rectangleOutlineProps.outlineHeight,
|
||||
})}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
);
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
if (overlay.type === "rect") {
|
||||
return (
|
||||
<BoundingBoxOutline
|
||||
{...rectangleOutlineProps}
|
||||
cursor="cursor-move"
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
key={overlay.id}
|
||||
center={toOverlay({
|
||||
canvasX: overlay.center.x,
|
||||
canvasY: overlay.center.y,
|
||||
})}
|
||||
outlineWidth={overlay.width * scaleX}
|
||||
outlineHeight={overlay.height * scaleY}
|
||||
rotation={overlay.rotation}
|
||||
cursor={overlay.cursor}
|
||||
dashed={overlay.dashed}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
if (overlay.type === "shape") {
|
||||
return (
|
||||
<ShapeOutline
|
||||
key={overlay.id}
|
||||
center={toOverlay({
|
||||
canvasX: overlay.center.x,
|
||||
canvasY: overlay.center.y,
|
||||
})}
|
||||
outlineWidth={overlay.width * scaleX}
|
||||
outlineHeight={overlay.height * scaleY}
|
||||
rotation={overlay.rotation}
|
||||
pathData={overlay.pathData}
|
||||
cursor={overlay.cursor}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (overlay.type === "canvas-path") {
|
||||
return (
|
||||
<CanvasPathOutline
|
||||
key={overlay.id}
|
||||
pathData={overlay.pathData}
|
||||
translateX={
|
||||
overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0
|
||||
}
|
||||
translateY={
|
||||
overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0
|
||||
}
|
||||
scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1}
|
||||
scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1}
|
||||
cursor={overlay.cursor}
|
||||
strokeWidth={overlay.strokeWidth}
|
||||
strokeOpacity={overlay.strokeOpacity}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{handlePositions.map((handle) => {
|
||||
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
|
||||
|
||||
if (handle.id === "rotation") {
|
||||
if (handle.kind === "icon" && handle.icon === "rotate") {
|
||||
return (
|
||||
<IconHandle
|
||||
key={handle.id}
|
||||
icon={Rotate01Icon}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -153,14 +209,14 @@ export function MaskHandles({
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.id === "feather") {
|
||||
if (handle.kind === "icon" && handle.icon === "feather") {
|
||||
return (
|
||||
<IconHandle
|
||||
key={handle.id}
|
||||
icon={FeatherIcon}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -168,15 +224,15 @@ export function MaskHandles({
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.id === "right" || handle.id === "left") {
|
||||
if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
|
||||
return (
|
||||
<EdgeHandle
|
||||
key={handle.id}
|
||||
edge="right"
|
||||
screen={screen}
|
||||
rotation={maskRotation}
|
||||
rotation={handle.rotation ?? 0}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -184,15 +240,15 @@ export function MaskHandles({
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.id === "bottom" || handle.id === "top") {
|
||||
if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
|
||||
return (
|
||||
<EdgeHandle
|
||||
key={handle.id}
|
||||
edge="bottom"
|
||||
screen={screen}
|
||||
rotation={maskRotation}
|
||||
rotation={handle.rotation ?? 0}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -200,19 +256,33 @@ export function MaskHandles({
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
handle.id === "top-left" ||
|
||||
handle.id === "top-right" ||
|
||||
handle.id === "bottom-left" ||
|
||||
handle.id === "bottom-right" ||
|
||||
handle.id === "scale"
|
||||
) {
|
||||
if (handle.kind === "point" || handle.kind === "tangent") {
|
||||
return (
|
||||
<CircleHandle
|
||||
key={handle.id}
|
||||
screen={screen}
|
||||
size={
|
||||
handle.kind === "tangent"
|
||||
? CUSTOM_MASK_TANGENT_SIZE
|
||||
: CUSTOM_MASK_ANCHOR_SIZE
|
||||
}
|
||||
isSelected={handle.isSelected}
|
||||
onPointerDown={(event) =>
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.kind === "corner") {
|
||||
return (
|
||||
<CornerHandle
|
||||
key={handle.id}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -226,7 +296,7 @@ export function MaskHandles({
|
||||
cursor={handle.cursor}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
handleMaskPointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { TextElement } from "@/lib/timeline";
|
||||
import {
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/lib/text/typography";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveTextLayout } from "@/lib/text/primitives";
|
||||
|
||||
export function TextEditOverlay({
|
||||
trackId,
|
||||
@@ -82,20 +80,29 @@ export function TextEditOverlay({
|
||||
});
|
||||
|
||||
const { x: displayScaleX } = viewport.getDisplayScale();
|
||||
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
|
||||
const resolvedTextLayout = resolveTextLayout({
|
||||
text: {
|
||||
content: element.content,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
textDecoration: element.textDecoration,
|
||||
letterSpacing: element.letterSpacing,
|
||||
lineHeight: element.lineHeight,
|
||||
},
|
||||
canvasHeight: canvasSize.height,
|
||||
});
|
||||
|
||||
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const canvasLetterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
const lineHeightPx = resolvedTextLayout.lineHeightPx;
|
||||
|
||||
const bg = element.background;
|
||||
const shouldShowBackground =
|
||||
bg.enabled && bg.color && bg.color !== "transparent";
|
||||
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
|
||||
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
|
||||
const canvasPaddingX = shouldShowBackground
|
||||
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
|
||||
: 0;
|
||||
@@ -123,10 +130,10 @@ export function TextEditOverlay({
|
||||
aria-label="Edit text"
|
||||
className="cursor-text select-text outline-none whitespace-pre"
|
||||
style={{
|
||||
fontSize: scaledFontSize,
|
||||
fontSize: resolvedTextLayout.scaledFontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
|
||||
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
|
||||
textAlign: element.textAlign,
|
||||
letterSpacing: `${canvasLetterSpacing}px`,
|
||||
lineHeight,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { MaskableElement } from "@/lib/timeline";
|
||||
import type { Mask, MaskType } from "@/lib/masks/types";
|
||||
import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
|
||||
import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
|
||||
import type {
|
||||
NumberParamDefinition,
|
||||
SelectParamDefinition,
|
||||
} from "@/lib/params";
|
||||
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
@@ -10,14 +13,17 @@ import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Delete02Icon,
|
||||
FeatherIcon,
|
||||
PlusSignIcon,
|
||||
RotateClockwiseIcon,
|
||||
TextFontIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -32,6 +38,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -52,7 +59,12 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
|
||||
import {
|
||||
OcMirrorIcon,
|
||||
OcShapesIcon,
|
||||
OcTextHeightIcon,
|
||||
OcTextWidthIcon,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
type MasksTabProps = {
|
||||
@@ -78,6 +90,10 @@ type PreviewParamHandler = (
|
||||
|
||||
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
|
||||
|
||||
function isTextMask(mask: Mask): mask is TextMask {
|
||||
return mask.type === "text";
|
||||
}
|
||||
|
||||
export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||
const editor = useEditor();
|
||||
const { renderElement, previewUpdates, commit } =
|
||||
@@ -370,12 +386,23 @@ function MaskParamsFields({
|
||||
previewParam(key)(value);
|
||||
const previewStrokeColor = previewParam("strokeColor");
|
||||
const strokeAlignParam = definition.params.find(
|
||||
(param): param is SelectParamDefinition =>
|
||||
(param): param is SelectParamDefinition<string> =>
|
||||
param.key === "strokeAlign" && param.type === "select",
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionFields>
|
||||
{isTextMask(mask) ? (
|
||||
<TextMaskFields
|
||||
mask={mask}
|
||||
previewParam={previewParam}
|
||||
onCommit={onCommit}
|
||||
fontSizeParam={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "fontSize",
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{definition.features.hasPosition &&
|
||||
"centerX" in mask.params &&
|
||||
"centerY" in mask.params && (
|
||||
@@ -491,7 +518,9 @@ function MaskParamsFields({
|
||||
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
|
||||
<SectionField label="Scale">
|
||||
<MaskNumberField
|
||||
icon="S"
|
||||
icon={
|
||||
isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
|
||||
}
|
||||
param={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "scale",
|
||||
@@ -587,6 +616,100 @@ function MaskParamsFields({
|
||||
);
|
||||
}
|
||||
|
||||
const LETTER_SPACING_PARAM: NumberParamDefinition = {
|
||||
key: "letterSpacing",
|
||||
label: "Letter spacing",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 500,
|
||||
step: 1,
|
||||
};
|
||||
|
||||
const LINE_HEIGHT_PARAM: NumberParamDefinition = {
|
||||
key: "lineHeight",
|
||||
label: "Line height",
|
||||
type: "number",
|
||||
default: 1.2,
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
step: 0.1,
|
||||
};
|
||||
|
||||
function TextMaskFields({
|
||||
mask,
|
||||
previewParam,
|
||||
onCommit,
|
||||
fontSizeParam,
|
||||
}: {
|
||||
mask: TextMask;
|
||||
previewParam: PreviewParamHandler;
|
||||
onCommit: () => void;
|
||||
fontSizeParam: NumberParamDefinition;
|
||||
}) {
|
||||
const content = usePropertyDraft({
|
||||
displayValue: mask.params.content,
|
||||
parse: (input) => input,
|
||||
onPreview: (value) => previewParam("content")(value),
|
||||
onCommit,
|
||||
});
|
||||
|
||||
const previewNumberParam = (key: string) => (value: number) =>
|
||||
previewParam(key)(value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionField label="Content">
|
||||
<Textarea
|
||||
value={content.displayValue}
|
||||
className="min-h-20"
|
||||
onFocus={content.onFocus}
|
||||
onChange={content.onChange}
|
||||
onBlur={content.onBlur}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Font">
|
||||
<FontPicker
|
||||
defaultValue={mask.params.fontFamily}
|
||||
onValueChange={(value) => {
|
||||
previewParam("fontFamily")(value);
|
||||
onCommit();
|
||||
}}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Size">
|
||||
<MaskNumberField
|
||||
icon={<HugeiconsIcon icon={TextFontIcon} />}
|
||||
param={fontSizeParam}
|
||||
value={mask.params.fontSize}
|
||||
onPreview={previewNumberParam("fontSize")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Spacing">
|
||||
<div className="flex items-start gap-2">
|
||||
<MaskNumberField
|
||||
className="w-1/2"
|
||||
icon={<OcTextWidthIcon size={14} />}
|
||||
param={LETTER_SPACING_PARAM}
|
||||
value={mask.params.letterSpacing ?? 0}
|
||||
onPreview={previewNumberParam("letterSpacing")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
<MaskNumberField
|
||||
className="w-1/2"
|
||||
icon={<OcTextHeightIcon size={14} />}
|
||||
param={LINE_HEIGHT_PARAM}
|
||||
value={mask.params.lineHeight ?? 1.2}
|
||||
onPreview={previewNumberParam("lineHeight")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getNumberParamDefinition({
|
||||
definition,
|
||||
key,
|
||||
@@ -603,13 +726,10 @@ function getNumberParamDefinition({
|
||||
return param;
|
||||
}
|
||||
|
||||
function getMaskNumber({
|
||||
params,
|
||||
key,
|
||||
}: {
|
||||
params: Mask["params"];
|
||||
key: string;
|
||||
}): number {
|
||||
function getMaskNumber<
|
||||
TParams extends Mask["params"],
|
||||
TKey extends keyof TParams & string,
|
||||
>({ params, key }: { params: TParams; key: TKey }): number {
|
||||
const value = params[key];
|
||||
|
||||
if (typeof value !== "number") {
|
||||
@@ -665,7 +785,9 @@ function MaskNumberField({
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
|
||||
return (
|
||||
clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
|
||||
);
|
||||
},
|
||||
onPreview,
|
||||
onCommit,
|
||||
|
||||
@@ -9,9 +9,34 @@ import { cn } from "@/utils/ui";
|
||||
const BAR_WIDTH = 2;
|
||||
const BAR_GAP = 1;
|
||||
const BAR_STEP = BAR_WIDTH + BAR_GAP;
|
||||
export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
|
||||
|
||||
function sampleGain({
|
||||
samples,
|
||||
startFraction,
|
||||
endFraction,
|
||||
barIndex,
|
||||
barCount,
|
||||
}: {
|
||||
samples: number[];
|
||||
startFraction: number;
|
||||
endFraction: number;
|
||||
barIndex: number;
|
||||
barCount: number;
|
||||
}): number {
|
||||
if (samples.length === 0) return 1;
|
||||
const progress =
|
||||
startFraction +
|
||||
((barIndex + 0.5) / barCount) * (endFraction - startFraction);
|
||||
const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
|
||||
const lo = Math.floor(rawIndex);
|
||||
const hi = Math.min(samples.length - 1, lo + 1);
|
||||
return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
|
||||
}
|
||||
interface AudioWaveformProps {
|
||||
audioUrl?: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
gainSamples?: number[];
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
@@ -19,6 +44,7 @@ interface AudioWaveformProps {
|
||||
export function AudioWaveform({
|
||||
audioUrl,
|
||||
audioBuffer,
|
||||
gainSamples,
|
||||
color = "rgba(255, 255, 255, 0.7)",
|
||||
className = "",
|
||||
}: AudioWaveformProps) {
|
||||
@@ -26,6 +52,7 @@ export function AudioWaveform({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bufferRef = useRef<AudioBuffer | null>(null);
|
||||
const globalMaxRef = useRef<number>(1);
|
||||
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
|
||||
const scrollParentRef = useRef<HTMLElement | null>(null);
|
||||
const heightRef = useRef<number>(0);
|
||||
|
||||
@@ -99,13 +126,30 @@ export function AudioWaveform({
|
||||
|
||||
const maxBarHeight = height * 0.7;
|
||||
|
||||
const samples = gainSamplesRef.current;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
|
||||
const gain =
|
||||
samples != null
|
||||
? sampleGain({
|
||||
samples,
|
||||
startFraction,
|
||||
endFraction,
|
||||
barIndex: i,
|
||||
barCount,
|
||||
})
|
||||
: 1;
|
||||
const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
|
||||
const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
|
||||
const barH = Math.max(1, scaled * maxBarHeight);
|
||||
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
useEffect(() => {
|
||||
gainSamplesRef.current = gainSamples;
|
||||
drawVisible();
|
||||
}, [gainSamples, drawVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import { AudioWaveform } from "./audio-waveform";
|
||||
import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
import {
|
||||
useKeyframeDrag,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
@@ -382,6 +383,7 @@ export function TimelineElement({
|
||||
>
|
||||
<ElementInner
|
||||
element={element}
|
||||
displayElement={renderElement}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isExpanded={expandedRows.length > 0}
|
||||
@@ -498,6 +500,7 @@ export function TimelineElement({
|
||||
|
||||
function ElementInner({
|
||||
element,
|
||||
displayElement,
|
||||
track,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
@@ -509,6 +512,7 @@ function ElementInner({
|
||||
isDropTarget = false,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
displayElement?: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isExpanded: boolean;
|
||||
@@ -530,8 +534,10 @@ function ElementInner({
|
||||
}) => void;
|
||||
isDropTarget?: boolean;
|
||||
}) {
|
||||
const visibleElement = displayElement ?? element;
|
||||
const isReducedOpacity =
|
||||
(canElementBeHidden(element) && element.hidden) || isDropTarget;
|
||||
(canElementBeHidden(visibleElement) && visibleElement.hidden) ||
|
||||
isDropTarget;
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
@@ -576,7 +582,7 @@ function ElementInner({
|
||||
style={{ height: `${baseTrackHeight}px` }}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
|
||||
<ElementContent element={element} track={track} />
|
||||
<ElementContent element={visibleElement} track={track} />
|
||||
</div>
|
||||
</div>
|
||||
{expandedContent}
|
||||
@@ -979,6 +985,11 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
||||
const audioUrl =
|
||||
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
|
||||
const mediaLabel = mediaAsset?.name ?? element.name;
|
||||
const gainSamples = useMemo(
|
||||
() =>
|
||||
buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
|
||||
[element],
|
||||
);
|
||||
|
||||
if (audioBuffer || audioUrl) {
|
||||
return (
|
||||
@@ -986,6 +997,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
||||
<AudioWaveform
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
gainSamples={gainSamples}
|
||||
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
||||
/>
|
||||
<MediaElementHeader name={mediaLabel} hasFade={false} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { type ComponentProps, forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Input } from "./input";
|
||||
import {
|
||||
@@ -28,15 +28,33 @@ import {
|
||||
parseHexAlpha,
|
||||
} from "@/utils/color";
|
||||
|
||||
interface ColorPickerProps {
|
||||
const CHECKERBOARD_STYLE = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
|
||||
`,
|
||||
backgroundSize: "8px 8px",
|
||||
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
|
||||
backgroundColor: "#fff",
|
||||
} as const;
|
||||
|
||||
interface ColorPickerContentProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onChangeEnd?: (value: string) => void;
|
||||
className?: string;
|
||||
side?: ComponentProps<typeof PopoverContent>["side"];
|
||||
align?: ComponentProps<typeof PopoverContent>["align"];
|
||||
}
|
||||
|
||||
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
|
||||
function ColorPickerContent({
|
||||
value = "FFFFFF",
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
side = "left",
|
||||
align = "center",
|
||||
}: ColorPickerContentProps) {
|
||||
const [isDragging, setIsDragging] = useState<
|
||||
"saturation" | "hue" | "opacity" | null
|
||||
>(null);
|
||||
@@ -55,19 +73,6 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
|
||||
const [h, s, v] = hexToHsv({ hex: rgbValue });
|
||||
|
||||
const handleEyeDropper = async () => {
|
||||
if (!isEyeDropperSupported || !EyeDropper) return;
|
||||
try {
|
||||
const dropper = new EyeDropper();
|
||||
const result = await dropper.open();
|
||||
const hex = result.sRGBHex.replace("#", "").toLowerCase();
|
||||
const finalHex = appendAlpha({ rgbHex: hex, alpha });
|
||||
onChange?.(finalHex);
|
||||
onChangeEnd?.(finalHex);
|
||||
} catch {
|
||||
// user cancelled the picker
|
||||
}
|
||||
};
|
||||
const hueDiff = Math.abs(h - internalHue);
|
||||
const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
|
||||
const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
|
||||
@@ -77,18 +82,18 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
}, [value, colorFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
if (isDragging === "saturation" && saturationRef.current) {
|
||||
const rect = saturationRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientY - rect.top) / rect.height),
|
||||
Math.min(1, (event.clientY - rect.top) / rect.height),
|
||||
);
|
||||
const newHex = appendAlpha({
|
||||
rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
|
||||
@@ -102,7 +107,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
const rect = hueRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const newH = x * 360;
|
||||
setInternalHue(newH);
|
||||
@@ -120,7 +125,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
const rect = opacityRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
|
||||
latestDragColorRef.current = newHex;
|
||||
@@ -146,14 +151,28 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
}
|
||||
}, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
|
||||
|
||||
const handleSaturationMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const handleEyeDropper = async () => {
|
||||
if (!isEyeDropperSupported || !EyeDropper) return;
|
||||
try {
|
||||
const dropper = new EyeDropper();
|
||||
const result = await dropper.open();
|
||||
const hex = result.sRGBHex.replace("#", "").toLowerCase();
|
||||
const finalHex = appendAlpha({ rgbHex: hex, alpha });
|
||||
onChange?.(finalHex);
|
||||
onChangeEnd?.(finalHex);
|
||||
} catch {
|
||||
// user cancelled the picker
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaturationMouseDown = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const saturationElement = saturationRef.current;
|
||||
if (!saturationElement) return;
|
||||
setIsDragging("saturation");
|
||||
const rect = saturationElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
||||
const newHex = appendAlpha({
|
||||
rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
|
||||
alpha,
|
||||
@@ -162,13 +181,13 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
onChange?.(newHex);
|
||||
};
|
||||
|
||||
const handleHueMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const handleHueMouseDown = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const hueElement = hueRef.current;
|
||||
if (!hueElement) return;
|
||||
setIsDragging("hue");
|
||||
const rect = hueElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const newH = x * 360;
|
||||
setInternalHue(newH);
|
||||
if (s > 0) {
|
||||
@@ -181,31 +200,28 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const handleOpacityMouseDown = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const opacityElement = opacityRef.current;
|
||||
if (!opacityElement) return;
|
||||
setIsDragging("opacity");
|
||||
const rect = opacityElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(
|
||||
colorFormat === "hex"
|
||||
? e.target.value.replace("#", "")
|
||||
: e.target.value,
|
||||
? event.target.value.replace("#", "")
|
||||
: event.target.value,
|
||||
);
|
||||
};
|
||||
|
||||
const commitInputValue = () => {
|
||||
const parsed = parseColorInput({
|
||||
input: inputValue,
|
||||
format: colorFormat,
|
||||
});
|
||||
const parsed = parseColorInput({ input: inputValue, format: colorFormat });
|
||||
if (parsed) {
|
||||
const nextHex = appendAlpha({ rgbHex: parsed, alpha });
|
||||
onChange?.(nextHex);
|
||||
@@ -224,14 +240,12 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
commitInputValue();
|
||||
};
|
||||
const handleInputBlur = () => commitInputValue();
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
commitInputValue();
|
||||
e.currentTarget.blur();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,63 +272,11 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
|
||||
};
|
||||
|
||||
const checkerboardStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
|
||||
`,
|
||||
backgroundSize: "8px 8px",
|
||||
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
|
||||
backgroundColor: "#fff",
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 dark:invert"
|
||||
style={checkerboardStyle}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: `#${value}` }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<div className="flex flex-1 items-center">
|
||||
<Input
|
||||
className={cn(
|
||||
"border-0! bg-transparent p-0 ring-0! ring-offset-0!",
|
||||
colorFormat === "hex" && "uppercase",
|
||||
)}
|
||||
size="sm"
|
||||
containerClassName="w-full"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PopoverContent
|
||||
className="w-64 px-0 select-none flex flex-col gap-3 py-2"
|
||||
side="left"
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={8}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -391,7 +353,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
type="button"
|
||||
onMouseDown={handleOpacityMouseDown}
|
||||
>
|
||||
<div className="absolute inset-0 dark:invert" style={checkerboardStyle} />
|
||||
<div className="absolute inset-0 dark:invert" style={CHECKERBOARD_STYLE} />
|
||||
<div
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
@@ -410,7 +372,9 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={colorFormat}
|
||||
onValueChange={(value) => setColorFormat(value as ColorFormat)}
|
||||
onValueChange={(selectedFormat) =>
|
||||
setColorFormat(selectedFormat as ColorFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger variant="outline" className="min-w-18 max-w-18">
|
||||
<SelectValue />
|
||||
@@ -438,6 +402,132 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onChangeEnd?: (value: string) => void;
|
||||
className?: string;
|
||||
contentSide?: ComponentProps<typeof PopoverContent>["side"];
|
||||
contentAlign?: ComponentProps<typeof PopoverContent>["align"];
|
||||
}
|
||||
|
||||
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
value = "FFFFFF",
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
contentSide,
|
||||
contentAlign,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { alpha } = parseHexAlpha({ hex: value });
|
||||
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
const commitInputValue = (raw: string) => {
|
||||
const input = raw.replace("#", "");
|
||||
const parsed = parseColorInput({ input, format: "hex" });
|
||||
if (parsed) {
|
||||
const nextHex = appendAlpha({ rgbHex: parsed, alpha });
|
||||
onChange?.(nextHex);
|
||||
onChangeEnd?.(nextHex);
|
||||
return;
|
||||
}
|
||||
const extracted = extractColorFromText({ text: input });
|
||||
if (extracted) {
|
||||
const hasExplicitAlpha = extracted.length > 6;
|
||||
const finalHex = hasExplicitAlpha
|
||||
? extracted
|
||||
: appendAlpha({ rgbHex: extracted, alpha });
|
||||
onChange?.(finalHex);
|
||||
onChangeEnd?.(finalHex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value.replace("#", ""));
|
||||
};
|
||||
|
||||
const handleInputBlur = () => commitInputValue(inputValue);
|
||||
|
||||
const handleInputKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === "Enter") {
|
||||
commitInputValue(inputValue);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pastedText = event.clipboardData.getData("text");
|
||||
const extractedHex = extractColorFromText({ text: pastedText });
|
||||
if (!extractedHex) return;
|
||||
event.preventDefault();
|
||||
const hasExplicitAlpha = extractedHex.length > 6;
|
||||
const finalHex = hasExplicitAlpha
|
||||
? extractedHex
|
||||
: appendAlpha({ rgbHex: extractedHex, alpha });
|
||||
onChange?.(finalHex);
|
||||
onChangeEnd?.(finalHex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-accent flex h-7 border flex-1 items-center gap-2 rounded-md px-[0.45rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="size-4.5 relative cursor-pointer overflow-hidden rounded-sm border hover:ring-1 hover:ring-foreground/20"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 dark:invert"
|
||||
style={CHECKERBOARD_STYLE}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: `#${value}` }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<div className="flex flex-1 items-center">
|
||||
<Input
|
||||
className="border-0! bg-transparent p-0 ring-0! ring-offset-0! uppercase"
|
||||
size="sm"
|
||||
containerClassName="w-full"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ColorPickerContent
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChangeEnd={onChangeEnd}
|
||||
side={contentSide}
|
||||
align={contentAlign}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
@@ -454,9 +544,10 @@ const ColorCircle = ({
|
||||
color?: string;
|
||||
}) => (
|
||||
<div
|
||||
className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${
|
||||
size === "sm" ? "size-3" : "size-4"
|
||||
}`}
|
||||
className={cn(
|
||||
"pointer-events-none absolute rounded-full border-3 border-white shadow-lg",
|
||||
size === "sm" ? "size-3" : "size-4",
|
||||
)}
|
||||
style={{
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
@@ -466,4 +557,4 @@ const ColorCircle = ({
|
||||
/>
|
||||
);
|
||||
|
||||
export { ColorPicker };
|
||||
export { ColorPicker, ColorPickerContent };
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { Command, CommandResult } from "@/lib/commands";
|
||||
import type { EditorSelectionSnapshot } from "@/lib/selection/editor-selection";
|
||||
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
|
||||
import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
|
||||
import type { SceneTracks } from "@/lib/timeline/types";
|
||||
|
||||
interface CommandHistoryEntry {
|
||||
command: Command;
|
||||
previousSelection: ElementRef[];
|
||||
selectionOverride?: ElementRef[];
|
||||
previousSelection: EditorSelectionSnapshot;
|
||||
selectionOverride?: EditorSelectionSnapshot;
|
||||
}
|
||||
|
||||
export class CommandManager {
|
||||
@@ -19,7 +20,7 @@ export class CommandManager {
|
||||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
const beforeTracks = this.isRippleEnabled
|
||||
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
|
||||
: null;
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = command.execute();
|
||||
@@ -55,11 +56,11 @@ export class CommandManager {
|
||||
// Only restore selection for commands that explicitly changed it.
|
||||
// Commands without selection intent leave selection untouched,
|
||||
// preserving any UI-driven selection changes (clicks, box select)
|
||||
// that happened between commands. Commands that remove elements
|
||||
// must declare { select: [] } to clear stale refs.
|
||||
// that happened between commands. Commands that remove editor-owned
|
||||
// selection targets must declare a selection override to clear stale refs.
|
||||
if (entry.selectionOverride !== undefined) {
|
||||
this.editor.selection.setSelectedElements({
|
||||
elements: [...entry.previousSelection],
|
||||
this.editor.selection.restoreSnapshot({
|
||||
snapshot: entry.previousSelection,
|
||||
});
|
||||
}
|
||||
this.redoStack.push(entry);
|
||||
@@ -74,7 +75,7 @@ export class CommandManager {
|
||||
}
|
||||
|
||||
const beforeTracks = this.isRippleEnabled
|
||||
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
|
||||
: null;
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = entry.command.redo();
|
||||
@@ -102,20 +103,19 @@ export class CommandManager {
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
private getSelectionSnapshot(): ElementRef[] {
|
||||
return [...this.editor.selection.getSelectedElements()];
|
||||
private getSelectionSnapshot(): EditorSelectionSnapshot {
|
||||
return this.editor.selection.getSnapshot();
|
||||
}
|
||||
|
||||
private applySelectionOverride(
|
||||
result: CommandResult | undefined,
|
||||
): ElementRef[] | undefined {
|
||||
if (result?.select === undefined) {
|
||||
): EditorSelectionSnapshot | undefined {
|
||||
if (!result?.selection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectionOverride = [...result.select];
|
||||
this.editor.selection.setSelectedElements({ elements: selectionOverride });
|
||||
return selectionOverride;
|
||||
return this.editor.selection.applySelectionPatch({
|
||||
patch: result.selection,
|
||||
});
|
||||
}
|
||||
|
||||
private runReactors(): void {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
import type {
|
||||
EditorSelectionKind,
|
||||
EditorSelectionPatch,
|
||||
EditorSelectionSnapshot,
|
||||
SelectedMaskPointSelection,
|
||||
} from "@/lib/selection/editor-selection";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
||||
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
||||
private selectedMaskPoints: SelectedMaskPointSelection | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(editor: EditorCore) {
|
||||
@@ -24,10 +31,42 @@ export class SelectionManager {
|
||||
return this.keyframeSelectionAnchor;
|
||||
}
|
||||
|
||||
getSelectedMaskPointSelection(): SelectedMaskPointSelection | null {
|
||||
return this.selectedMaskPoints;
|
||||
}
|
||||
|
||||
getActiveSelectionKind(): EditorSelectionKind | null {
|
||||
if ((this.selectedMaskPoints?.pointIds.length ?? 0) > 0) {
|
||||
return "mask-points";
|
||||
}
|
||||
if (this.selectedKeyframes.length > 0) {
|
||||
return "keyframes";
|
||||
}
|
||||
if (this.selectedElements.length > 0) {
|
||||
return "elements";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSnapshot(): EditorSelectionSnapshot {
|
||||
return {
|
||||
selectedElements: [...this.selectedElements],
|
||||
selectedKeyframes: [...this.selectedKeyframes],
|
||||
keyframeSelectionAnchor: this.keyframeSelectionAnchor,
|
||||
selectedMaskPoints: this.selectedMaskPoints
|
||||
? {
|
||||
...this.selectedMaskPoints,
|
||||
pointIds: [...this.selectedMaskPoints.pointIds],
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||
this.selectedElements = elements;
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.selectedMaskPoints = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
@@ -44,6 +83,24 @@ export class SelectionManager {
|
||||
} 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();
|
||||
}
|
||||
|
||||
@@ -51,6 +108,7 @@ export class SelectionManager {
|
||||
this.selectedElements = [];
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.selectedMaskPoints = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
@@ -60,6 +118,70 @@ export class SelectionManager {
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
SceneTracks,
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
RetimeKeyframeCommand,
|
||||
UpdateScalarKeyframeCurveCommand,
|
||||
AddClipEffectCommand,
|
||||
DeleteCustomMaskPointsCommand,
|
||||
InsertCustomMaskPointCommand,
|
||||
RemoveClipEffectCommand,
|
||||
UpdateClipEffectParamsCommand,
|
||||
ToggleClipEffectCommand,
|
||||
@@ -344,6 +347,55 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
deleteCustomMaskPoints({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}): void {
|
||||
if (pointIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const command = new DeleteCustomMaskPointsCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
pointIds,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
insertCustomMaskPoint({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): void {
|
||||
const command = new InsertCustomMaskPointCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateClipEffectParams({
|
||||
trackId,
|
||||
elementId,
|
||||
|
||||
@@ -21,19 +21,27 @@ export function useEditorActions() {
|
||||
const editor = useEditor();
|
||||
const { selectedElements, setElementSelection } = useElementSelection();
|
||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||
const selectedMaskPointSelection = useEditor((e) =>
|
||||
e.selection.getSelectedMaskPointSelection(),
|
||||
);
|
||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||
const hasTimelineSelectionRef = useRef(false);
|
||||
const clearTimelineSelectionRef = useRef(() => {});
|
||||
const clearTimelineActiveSelectionRef = useRef(() => {});
|
||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
||||
const hasTimelineSelection =
|
||||
selectedElements.length > 0 || selectedKeyframes.length > 0;
|
||||
selectedElements.length > 0 ||
|
||||
selectedKeyframes.length > 0 ||
|
||||
selectedMaskPointSelection !== null;
|
||||
|
||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
||||
clearTimelineSelectionRef.current = () => {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearSelection();
|
||||
};
|
||||
clearTimelineActiveSelectionRef.current = () => {
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
};
|
||||
|
||||
if (!timelineScopeRef.current) {
|
||||
@@ -42,6 +50,9 @@ export function useEditorActions() {
|
||||
clear: () => {
|
||||
clearTimelineSelectionRef.current();
|
||||
},
|
||||
clearActive: () => {
|
||||
clearTimelineActiveSelectionRef.current();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,17 +268,36 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedKeyframes.length > 0) {
|
||||
switch (editor.selection.getActiveSelectionKind()) {
|
||||
case "mask-points":
|
||||
if (!selectedMaskPointSelection) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteCustomMaskPoints({
|
||||
trackId: selectedMaskPointSelection.trackId,
|
||||
elementId: selectedMaskPointSelection.elementId,
|
||||
maskId: selectedMaskPointSelection.maskId,
|
||||
pointIds: selectedMaskPointSelection.pointIds,
|
||||
});
|
||||
return;
|
||||
case "keyframes":
|
||||
if (selectedKeyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||
clearKeyframeSelection();
|
||||
return;
|
||||
}
|
||||
case "elements":
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -343,8 +373,7 @@ export function useEditorActions() {
|
||||
"deselect-all",
|
||||
() => {
|
||||
if (!clearActiveScope()) {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
|
||||
@@ -4,21 +4,20 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import {
|
||||
getMaskHandlePositions,
|
||||
getLineMaskLinePoints,
|
||||
} from "@/lib/masks/handle-positions";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
||||
parseCustomMaskHandleId,
|
||||
parseCustomMaskSegmentHandleId,
|
||||
} from "@/lib/masks/custom-path";
|
||||
import { appendPointToCustomMask } from "@/lib/masks/definitions/custom";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementBounds,
|
||||
} from "@/lib/preview/element-bounds";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskHandlePosition,
|
||||
MaskLinePoints,
|
||||
} from "@/lib/masks/types";
|
||||
import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection";
|
||||
import type { Mask, MaskInteractionResult } from "@/lib/masks/types";
|
||||
import type { MaskableElement } from "@/lib/timeline";
|
||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||
|
||||
@@ -28,7 +27,68 @@ interface DragState {
|
||||
handleId: string;
|
||||
startCanvasX: number;
|
||||
startCanvasY: number;
|
||||
startParams: BaseMaskParams & ParamValues;
|
||||
startParams: Mask["params"];
|
||||
}
|
||||
|
||||
interface PendingSegmentInsertState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startCanvasX: number;
|
||||
startCanvasY: number;
|
||||
startParams: Mask["params"];
|
||||
bounds: ElementBounds;
|
||||
}
|
||||
|
||||
const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
function isMaskSelectionForElement({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
selection,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
selection: SelectedMaskPointSelection | null;
|
||||
}): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
selection.trackId === trackId &&
|
||||
selection.elementId === elementId &&
|
||||
selection.maskId === maskId
|
||||
);
|
||||
}
|
||||
|
||||
function replaceElementMask({
|
||||
masks,
|
||||
updatedMask,
|
||||
}: {
|
||||
masks: MaskableElement["masks"];
|
||||
updatedMask: Mask;
|
||||
}): Mask[] {
|
||||
return (masks ?? []).map((mask) =>
|
||||
mask.id === updatedMask.id ? updatedMask : mask,
|
||||
);
|
||||
}
|
||||
|
||||
function withUpdatedMaskParams<TMask extends Mask>({
|
||||
mask,
|
||||
params,
|
||||
}: {
|
||||
mask: TMask;
|
||||
params: TMask["params"];
|
||||
}): TMask {
|
||||
return {
|
||||
...mask,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMaskHandles({
|
||||
@@ -41,6 +101,9 @@ export function useMaskHandles({
|
||||
const viewport = usePreviewViewport();
|
||||
const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
|
||||
null,
|
||||
);
|
||||
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -54,6 +117,9 @@ export function useMaskHandles({
|
||||
(e) => e.project.getActive().settings.canvasSize,
|
||||
);
|
||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||
const selectedMaskPointSelection = useEditor((e) =>
|
||||
e.selection.getSelectedMaskPointSelection(),
|
||||
);
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
@@ -72,38 +138,174 @@ export function useMaskHandles({
|
||||
);
|
||||
if (!entry) return null;
|
||||
const element = entry.element as MaskableElement;
|
||||
if (!element.masks?.length) return null;
|
||||
return { ...entry, element, mask: element.masks[0] };
|
||||
const masks = element.masks ?? [];
|
||||
if (masks.length === 0) return null;
|
||||
const activeMaskId = masks.some((mask) =>
|
||||
isMaskSelectionForElement({
|
||||
trackId: entry.trackId,
|
||||
elementId: entry.elementId,
|
||||
maskId: mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
}),
|
||||
)
|
||||
? selectedMaskPointSelection?.maskId
|
||||
: masks[0].id;
|
||||
const mask =
|
||||
masks.find((candidate) => candidate.id === activeMaskId) ??
|
||||
masks[0];
|
||||
return { ...entry, element, mask };
|
||||
})()
|
||||
: null;
|
||||
|
||||
const handlePositions: MaskHandlePosition[] = selectedWithMask
|
||||
const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
|
||||
selectedWithMask
|
||||
? (() => {
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||
const displayScale = (scaleX + scaleY) / 2;
|
||||
return getMaskHandlePositions({
|
||||
overlayShape: def.overlayShape,
|
||||
features: def.features,
|
||||
return def.interaction.getInteraction({
|
||||
params: selectedWithMask.mask.params,
|
||||
bounds: selectedWithMask.bounds,
|
||||
displayScale,
|
||||
scaleX,
|
||||
scaleY,
|
||||
});
|
||||
})()
|
||||
: [];
|
||||
: { handles: [], overlays: [] };
|
||||
|
||||
const linePoints: MaskLinePoints | null =
|
||||
selectedWithMask?.mask.type === "split"
|
||||
? getLineMaskLinePoints({
|
||||
centerX: selectedWithMask.mask.params.centerX,
|
||||
centerY: selectedWithMask.mask.params.centerY,
|
||||
rotation: selectedWithMask.mask.params.rotation,
|
||||
bounds: selectedWithMask.bounds,
|
||||
const selectedPointIds = new Set(
|
||||
selectedWithMask &&
|
||||
isMaskSelectionForElement({
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
})
|
||||
? (selectedMaskPointSelection?.pointIds ?? [])
|
||||
: [],
|
||||
);
|
||||
|
||||
const handlePositions = baseHandlePositions.map((handle) => {
|
||||
const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
|
||||
if (!parsedHandle || parsedHandle.part !== "anchor") {
|
||||
return handle;
|
||||
}
|
||||
return {
|
||||
...handle,
|
||||
isSelected: selectedPointIds.has(parsedHandle.pointId),
|
||||
};
|
||||
});
|
||||
|
||||
const customMaskPointIds =
|
||||
selectedWithMask?.mask.type === "custom"
|
||||
? handlePositions
|
||||
.filter((h) => h.kind === "point")
|
||||
.map((h) => {
|
||||
const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
|
||||
return parsedHandle?.part === "anchor"
|
||||
? parsedHandle.pointId
|
||||
: null;
|
||||
})
|
||||
.filter((id): id is string => id !== null)
|
||||
: [];
|
||||
const isCreatingCustomMask =
|
||||
selectedWithMask?.mask.type === "custom" &&
|
||||
(!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMaskPointSelection) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!selectedWithMask ||
|
||||
selectedWithMask.mask.type !== "custom" ||
|
||||
!isMaskSelectionForElement({
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
})
|
||||
) {
|
||||
editor.selection.clearMaskPointSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const availablePointIds = new Set(customMaskPointIds);
|
||||
const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter(
|
||||
(pointId) => availablePointIds.has(pointId),
|
||||
);
|
||||
if (
|
||||
nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (nextSelectedPointIds.length === 0) {
|
||||
editor.selection.clearMaskPointSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.selection.setSelectedMaskPoints({
|
||||
selection: {
|
||||
...selectedMaskPointSelection,
|
||||
pointIds: nextSelectedPointIds,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
customMaskPointIds,
|
||||
editor.selection,
|
||||
selectedMaskPointSelection,
|
||||
selectedWithMask,
|
||||
]);
|
||||
|
||||
const updateCustomMaskPointSelection = useCallback(
|
||||
({
|
||||
pointId,
|
||||
toggleSelection,
|
||||
}: {
|
||||
pointId: string;
|
||||
toggleSelection: boolean;
|
||||
}) => {
|
||||
if (!selectedWithMask || selectedWithMask.mask.type !== "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelectionForCurrentMask = isMaskSelectionForElement({
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
selection: selectedMaskPointSelection,
|
||||
});
|
||||
const currentPointIds = isSelectionForCurrentMask
|
||||
? (selectedMaskPointSelection?.pointIds ?? [])
|
||||
: [];
|
||||
const nextPointIds = toggleSelection
|
||||
? currentPointIds.includes(pointId)
|
||||
? currentPointIds.filter(
|
||||
(currentPointId) => currentPointId !== pointId,
|
||||
)
|
||||
: [...currentPointIds, pointId]
|
||||
: [pointId];
|
||||
|
||||
if (nextPointIds.length === 0) {
|
||||
editor.selection.clearMaskPointSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.selection.setSelectedMaskPoints({
|
||||
selection: {
|
||||
trackId: selectedWithMask.trackId,
|
||||
elementId: selectedWithMask.elementId,
|
||||
maskId: selectedWithMask.mask.id,
|
||||
pointIds: nextPointIds,
|
||||
},
|
||||
});
|
||||
},
|
||||
[editor.selection, selectedMaskPointSelection, selectedWithMask],
|
||||
);
|
||||
|
||||
const clearMaskHandleState = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
pendingSegmentInsertRef.current = null;
|
||||
setActiveHandleId(null);
|
||||
onSnapLinesChange?.([]);
|
||||
}, [onSnapLinesChange]);
|
||||
@@ -139,7 +341,48 @@ export function useMaskHandles({
|
||||
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,
|
||||
@@ -147,6 +390,41 @@ export function useMaskHandles({
|
||||
});
|
||||
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,
|
||||
@@ -163,11 +441,88 @@ export function useMaskHandles({
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithMask, viewport],
|
||||
[
|
||||
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;
|
||||
|
||||
@@ -198,31 +553,31 @@ export function useMaskHandles({
|
||||
});
|
||||
const { params: nextParams, activeLines } = isShiftHeldRef.current
|
||||
? { params: proposedParams, activeLines: [] as SnapLine[] }
|
||||
: snapMaskInteraction({
|
||||
: (def.interaction.snap?.({
|
||||
handleId: drag.handleId,
|
||||
startParams: drag.startParams,
|
||||
proposedParams,
|
||||
bounds: selectedWithMask.bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
}) ?? { params: proposedParams, activeLines: [] as SnapLine[] });
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
const updatedMask = {
|
||||
...selectedWithMask.mask,
|
||||
params: nextParams,
|
||||
};
|
||||
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: [
|
||||
masks: replaceElementMask({
|
||||
masks: selectedWithMask.element.masks,
|
||||
updatedMask,
|
||||
...(selectedWithMask.element.masks?.slice(1) ?? []),
|
||||
],
|
||||
}),
|
||||
} as Partial<MaskableElement>,
|
||||
},
|
||||
],
|
||||
@@ -239,19 +594,37 @@ export function useMaskHandles({
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
}, [clearMaskHandleState, editor, releaseCapturedPointer]);
|
||||
|
||||
return {
|
||||
selectedWithMask,
|
||||
handlePositions,
|
||||
linePoints,
|
||||
overlays,
|
||||
isCreatingCustomMask,
|
||||
handleCanvasPointerDown,
|
||||
activeHandleId,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ACTIONS = {
|
||||
category: "editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete selected elements",
|
||||
description: "Delete current selection",
|
||||
category: "editing",
|
||||
},
|
||||
"copy-selected": {
|
||||
|
||||
@@ -7,3 +7,7 @@ description: "Description"
|
||||
changes:
|
||||
- type: new
|
||||
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
||||
- type: new
|
||||
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
|
||||
- type: fixed
|
||||
text: "Preview panning now keeps working while the Masks tab is open."
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
import type { EditorSelectionPatch } from "@/lib/selection/editor-selection";
|
||||
|
||||
export interface CommandResult {
|
||||
select?: ElementRef[];
|
||||
selection?: EditorSelectionPatch;
|
||||
}
|
||||
|
||||
export abstract class Command {
|
||||
|
||||
@@ -54,7 +54,12 @@ export class DeleteElementsCommand extends Command {
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
return {
|
||||
select: [],
|
||||
selection: {
|
||||
selectedElements: [],
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,14 @@ export class InsertElementCommand extends Command {
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
return {
|
||||
select: [{ trackId: targetTrackId, elementId: this.elementId }],
|
||||
selection: {
|
||||
selectedElements: [
|
||||
{ trackId: targetTrackId, elementId: this.elementId },
|
||||
],
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,7 +180,10 @@ export class InsertElementCommand extends Command {
|
||||
|
||||
if (element.type === "graphic") {
|
||||
registerDefaultGraphics();
|
||||
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
|
||||
if (
|
||||
!element.definitionId ||
|
||||
!graphicsRegistry.has(element.definitionId)
|
||||
) {
|
||||
console.error("Graphic element must have a valid definitionId");
|
||||
return false;
|
||||
}
|
||||
@@ -236,8 +246,8 @@ export class InsertElementCommand extends Command {
|
||||
const targetTrack =
|
||||
tracks.main.id === placement.trackId
|
||||
? tracks.main
|
||||
: tracks.overlay.find((track) => track.id === placement.trackId) ??
|
||||
tracks.audio.find((track) => track.id === placement.trackId);
|
||||
: (tracks.overlay.find((track) => track.id === placement.trackId) ??
|
||||
tracks.audio.find((track) => track.id === placement.trackId));
|
||||
if (!targetTrack) {
|
||||
console.error("Track not found:", placement.trackId);
|
||||
return null;
|
||||
@@ -257,8 +267,7 @@ export class InsertElementCommand extends Command {
|
||||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime ?? element.startTime,
|
||||
startTime: placementResult.adjustedStartTime ?? element.startTime,
|
||||
}
|
||||
: element;
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import {
|
||||
getCustomMaskClosedStateAfterPointRemoval,
|
||||
removeCustomMaskPoints,
|
||||
} from "@/lib/masks/custom-path";
|
||||
import type { CustomMask } from "@/lib/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/lib/timeline";
|
||||
|
||||
function deletePointsFromCustomMask({
|
||||
mask,
|
||||
pointIds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
pointIds: string[];
|
||||
}): CustomMask {
|
||||
const points = mask.params.path;
|
||||
const nextPoints = removeCustomMaskPoints({ points, pointIds });
|
||||
if (nextPoints.length === points.length) {
|
||||
return mask;
|
||||
}
|
||||
|
||||
return {
|
||||
...mask,
|
||||
params: {
|
||||
...mask.params,
|
||||
path: nextPoints,
|
||||
closed: getCustomMaskClosedStateAfterPointRemoval({
|
||||
wasClosed: mask.params.closed,
|
||||
remainingPointCount: nextPoints.length,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deletePointsFromElementMask({
|
||||
element,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}): { element: MaskableElement; didDeletePoints: boolean } {
|
||||
const currentMasks = element.masks ?? [];
|
||||
let didDeletePoints = false;
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const nextMask = deletePointsFromCustomMask({
|
||||
mask,
|
||||
pointIds,
|
||||
});
|
||||
didDeletePoints ||= nextMask !== mask;
|
||||
return nextMask;
|
||||
});
|
||||
|
||||
return {
|
||||
element: didDeletePoints ? { ...element, masks: nextMasks } : element,
|
||||
didDeletePoints,
|
||||
};
|
||||
}
|
||||
|
||||
export class DeleteCustomMaskPointsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
private readonly pointIds: string[];
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
this.pointIds = pointIds;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let didDeletePoints = false;
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
const result = deletePointsFromElementMask({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
pointIds: this.pointIds,
|
||||
});
|
||||
didDeletePoints ||= result.didDeletePoints;
|
||||
return result.element;
|
||||
},
|
||||
});
|
||||
|
||||
if (didDeletePoints) {
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return {
|
||||
selection: {
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
|
||||
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
|
||||
export { RemoveMaskCommand } from "./remove-mask";
|
||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { insertPointOnCustomMaskSegment } from "@/lib/masks/definitions/custom";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { CustomMask } from "@/lib/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/lib/timeline";
|
||||
|
||||
function insertPointIntoCustomMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): { mask: CustomMask; insertedPointId: string | null } {
|
||||
const result = insertPointOnCustomMaskSegment({
|
||||
params: mask.params,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
if (!result) {
|
||||
return {
|
||||
mask,
|
||||
insertedPointId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mask: {
|
||||
...mask,
|
||||
params: result.params,
|
||||
},
|
||||
insertedPointId: result.pointId,
|
||||
};
|
||||
}
|
||||
|
||||
function insertPointIntoElementMask({
|
||||
element,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): {
|
||||
element: MaskableElement;
|
||||
didInsertPoint: boolean;
|
||||
insertedPointId: string | null;
|
||||
} {
|
||||
const currentMasks = element.masks ?? [];
|
||||
let insertedPointId: string | null = null;
|
||||
let didInsertPoint = false;
|
||||
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const result = insertPointIntoCustomMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
if (result.insertedPointId) {
|
||||
insertedPointId = result.insertedPointId;
|
||||
didInsertPoint = true;
|
||||
}
|
||||
return result.mask;
|
||||
});
|
||||
|
||||
return {
|
||||
element: didInsertPoint ? { ...element, masks: nextMasks } : element,
|
||||
didInsertPoint,
|
||||
insertedPointId,
|
||||
};
|
||||
}
|
||||
|
||||
export class InsertCustomMaskPointCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
private readonly segmentIndex: number;
|
||||
private readonly canvasPoint: { x: number; y: number };
|
||||
private readonly bounds: ElementBounds;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
this.segmentIndex = segmentIndex;
|
||||
this.canvasPoint = canvasPoint;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let didInsertPoint = false;
|
||||
let insertedPointId: string | null = null;
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
const result = insertPointIntoElementMask({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
segmentIndex: this.segmentIndex,
|
||||
canvasPoint: this.canvasPoint,
|
||||
bounds: this.bounds,
|
||||
});
|
||||
didInsertPoint ||= result.didInsertPoint;
|
||||
insertedPointId ??= result.insertedPointId;
|
||||
return result.element;
|
||||
},
|
||||
});
|
||||
|
||||
if (!didInsertPoint || !insertedPointId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return {
|
||||
selection: {
|
||||
selectedMaskPoints: {
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
maskId: this.maskId,
|
||||
pointIds: [insertedPointId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
findClosestPointOnCustomMaskSegment,
|
||||
getCustomMaskClosedStateAfterPointRemoval,
|
||||
insertPointIntoCustomMaskSegment,
|
||||
removeCustomMaskPoints,
|
||||
} from "@/lib/masks/custom-path";
|
||||
import {
|
||||
appendPointToCustomMask,
|
||||
customMaskDefinition,
|
||||
insertPointOnCustomMaskSegment,
|
||||
} from "@/lib/masks/definitions/custom";
|
||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||
import { textMaskDefinition } from "@/lib/masks/definitions/text";
|
||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
import type {
|
||||
CustomMaskParams,
|
||||
RectangleMaskParams,
|
||||
SplitMaskParams,
|
||||
TextMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
|
||||
const bounds: ElementBounds = {
|
||||
cx: 200,
|
||||
@@ -58,6 +75,78 @@ function buildRectangleParams(
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextMaskParams(
|
||||
overrides: Partial<TextMaskParams> = {},
|
||||
): TextMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
content: "Mask",
|
||||
fontSize: 15,
|
||||
fontFamily: "Arial",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomMaskParams(
|
||||
overrides: Partial<CustomMaskParams> = {},
|
||||
): CustomMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
path: [
|
||||
{
|
||||
id: "a",
|
||||
x: -0.2,
|
||||
y: -0.1,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
x: 0.2,
|
||||
y: -0.1,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
x: 0,
|
||||
y: 0.2,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
],
|
||||
closed: true,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSegment(
|
||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||
@@ -234,4 +323,165 @@ describe("mask snapping", () => {
|
||||
expect(result.params.height).toBe(0.5);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
|
||||
test("snaps text mask movement using intrinsic text bounds", () => {
|
||||
const params = buildTextMaskParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
});
|
||||
const result = textMaskDefinition.interaction.snap?.({
|
||||
handleId: "position",
|
||||
startParams: params,
|
||||
proposedParams: params,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result?.params.centerX).toBe(0);
|
||||
expect(result?.params.centerY).toBe(0);
|
||||
expect(result?.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps custom mask movement using path geometry bounds", () => {
|
||||
const params = buildCustomMaskParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
});
|
||||
const result = customMaskDefinition.interaction.snap?.({
|
||||
handleId: "position",
|
||||
startParams: params,
|
||||
proposedParams: params,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result?.params.centerX).toBe(0);
|
||||
expect(result?.params.centerY).toBe(0);
|
||||
expect(result?.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("marks blank text masks inactive", () => {
|
||||
expect(
|
||||
textMaskDefinition.isActive?.(
|
||||
buildTextMaskParams({
|
||||
content: " ",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom mask creation", () => {
|
||||
test("anchors the first point at the click position", () => {
|
||||
const params = buildCustomMaskParams({
|
||||
path: [],
|
||||
closed: false,
|
||||
});
|
||||
const next = appendPointToCustomMask({
|
||||
params,
|
||||
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
|
||||
bounds,
|
||||
});
|
||||
|
||||
expect(next.centerX).toBeCloseTo(0.1);
|
||||
expect(next.centerY).toBeCloseTo(-0.1);
|
||||
expect(next.rotation).toBe(0);
|
||||
expect(next.scale).toBe(1);
|
||||
expect(next.path).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom mask point deletion", () => {
|
||||
test("removes the selected points by id", () => {
|
||||
const points = buildCustomMaskParams().path;
|
||||
const nextPoints = removeCustomMaskPoints({
|
||||
points,
|
||||
pointIds: ["b"],
|
||||
});
|
||||
|
||||
expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
test("reopens a closed path once fewer than three points remain", () => {
|
||||
const points = buildCustomMaskParams().path;
|
||||
const nextPoints = removeCustomMaskPoints({
|
||||
points,
|
||||
pointIds: ["c"],
|
||||
});
|
||||
|
||||
expect(
|
||||
getCustomMaskClosedStateAfterPointRemoval({
|
||||
wasClosed: true,
|
||||
remainingPointCount: nextPoints.length,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom mask point insertion", () => {
|
||||
test("finds the closest point on the clicked segment", () => {
|
||||
const params = buildCustomMaskParams();
|
||||
const points = params.path;
|
||||
const closestPoint = findClosestPointOnCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex: 0,
|
||||
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
closed: params.closed,
|
||||
});
|
||||
|
||||
expect(closestPoint).not.toBeNull();
|
||||
expect(closestPoint?.t).toBeCloseTo(0.5, 1);
|
||||
expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
|
||||
expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
|
||||
});
|
||||
|
||||
test("splits a segment into two segments at the insertion point", () => {
|
||||
const points = buildCustomMaskParams().path;
|
||||
const nextPoints = insertPointIntoCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex: 0,
|
||||
pointId: "new",
|
||||
t: 0.5,
|
||||
closed: true,
|
||||
});
|
||||
|
||||
expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
|
||||
expect(nextPoints[1]).toMatchObject({
|
||||
id: "new",
|
||||
x: 0,
|
||||
y: -0.1,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("builds updated custom mask params for a clicked segment", () => {
|
||||
const result = insertPointOnCustomMaskSegment({
|
||||
params: buildCustomMaskParams(),
|
||||
segmentIndex: 0,
|
||||
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
||||
bounds,
|
||||
pointId: "new",
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const nextPoints = result?.params.path ?? [];
|
||||
expect(nextPoints).toHaveLength(4);
|
||||
expect(nextPoints.some((point) => point.id === "new")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,842 @@
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
|
||||
export interface CustomMaskPathPoint {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
inX: number;
|
||||
inY: number;
|
||||
outX: number;
|
||||
outY: number;
|
||||
}
|
||||
|
||||
export type CustomMaskHandlePart = "anchor" | "in" | "out";
|
||||
|
||||
function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.id === "string" &&
|
||||
typeof candidate.x === "number" &&
|
||||
typeof candidate.y === "number" &&
|
||||
typeof candidate.inX === "number" &&
|
||||
typeof candidate.inY === "number" &&
|
||||
typeof candidate.outX === "number" &&
|
||||
typeof candidate.outY === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function parseCustomMaskHandleId({
|
||||
handleId,
|
||||
}: {
|
||||
handleId: string;
|
||||
}): { pointId: string; part: CustomMaskHandlePart } | null {
|
||||
const match = /^point:(.+):(anchor|in|out)$/.exec(handleId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pointId: match[1],
|
||||
part: match[2] as CustomMaskHandlePart,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCustomMaskSegmentHandleId({
|
||||
handleId,
|
||||
}: {
|
||||
handleId: string;
|
||||
}): { segmentIndex: number } | null {
|
||||
const match = /^segment:(\d+)$/.exec(handleId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
segmentIndex: Number.parseInt(match[1], 10),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCustomMaskPath({
|
||||
path,
|
||||
}: {
|
||||
path: string;
|
||||
}): CustomMaskPathPoint[] {
|
||||
if (!path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(path);
|
||||
return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeCustomMaskPath({
|
||||
points,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
}): string {
|
||||
return JSON.stringify(points);
|
||||
}
|
||||
|
||||
export function removeCustomMaskPoints({
|
||||
points,
|
||||
pointIds,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
pointIds: string[];
|
||||
}): CustomMaskPathPoint[] {
|
||||
if (pointIds.length === 0) {
|
||||
return points;
|
||||
}
|
||||
const pointIdsToRemove = new Set(pointIds);
|
||||
return points.filter((point) => !pointIdsToRemove.has(point.id));
|
||||
}
|
||||
|
||||
export function getCustomMaskClosedStateAfterPointRemoval({
|
||||
wasClosed,
|
||||
remainingPointCount,
|
||||
}: {
|
||||
wasClosed: boolean;
|
||||
remainingPointCount: number;
|
||||
}): boolean {
|
||||
return wasClosed && remainingPointCount >= 3;
|
||||
}
|
||||
|
||||
function rotatePoint({
|
||||
x,
|
||||
y,
|
||||
rotationDegrees,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
rotationDegrees: number;
|
||||
}): { x: number; y: number } {
|
||||
const angleRad = (rotationDegrees * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
return {
|
||||
x: x * cos - y * sin,
|
||||
y: x * sin + y * cos,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomMaskCenterCanvasPoint({
|
||||
centerX,
|
||||
centerY,
|
||||
bounds,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
bounds: ElementBounds;
|
||||
}): { x: number; y: number } {
|
||||
return {
|
||||
x: bounds.cx + centerX * bounds.width,
|
||||
y: bounds.cy + centerY * bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function customMaskLocalPointToCanvas({
|
||||
point,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
}: {
|
||||
point: { x: number; y: number };
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
}): { x: number; y: number } {
|
||||
const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
|
||||
const scaledLocal = {
|
||||
x: point.x * bounds.width * scale,
|
||||
y: point.y * bounds.height * scale,
|
||||
};
|
||||
const rotated = rotatePoint({
|
||||
x: scaledLocal.x,
|
||||
y: scaledLocal.y,
|
||||
rotationDegrees: rotation,
|
||||
});
|
||||
|
||||
return {
|
||||
x: center.x + rotated.x,
|
||||
y: center.y + rotated.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function customMaskCanvasPointToLocal({
|
||||
point,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
}: {
|
||||
point: { x: number; y: number };
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
}): { x: number; y: number } {
|
||||
const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
|
||||
const translated = {
|
||||
x: point.x - center.x,
|
||||
y: point.y - center.y,
|
||||
};
|
||||
const rotated = rotatePoint({
|
||||
x: translated.x,
|
||||
y: translated.y,
|
||||
rotationDegrees: -rotation,
|
||||
});
|
||||
|
||||
return {
|
||||
x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale),
|
||||
y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomMaskCanvasGeometry({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
}) {
|
||||
const anchors = points.map((point) => ({
|
||||
id: point.id,
|
||||
anchor: customMaskLocalPointToCanvas({
|
||||
point: { x: point.x, y: point.y },
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
}),
|
||||
inHandle: customMaskLocalPointToCanvas({
|
||||
point: { x: point.x + point.inX, y: point.y + point.inY },
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
}),
|
||||
outHandle: customMaskLocalPointToCanvas({
|
||||
point: { x: point.x + point.outX, y: point.y + point.outY },
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
}),
|
||||
}));
|
||||
|
||||
const geometryPoints = anchors.flatMap((point) => [
|
||||
point.anchor,
|
||||
point.inHandle,
|
||||
point.outHandle,
|
||||
]);
|
||||
if (geometryPoints.length === 0) {
|
||||
return {
|
||||
anchors,
|
||||
bounds: null,
|
||||
};
|
||||
}
|
||||
|
||||
const geometryBounds = getCanvasPointBounds({
|
||||
points: geometryPoints,
|
||||
});
|
||||
|
||||
return {
|
||||
anchors,
|
||||
bounds: geometryBounds,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CanvasPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
} | null {
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [firstPoint, ...restPoints] = points;
|
||||
let minX = firstPoint.x;
|
||||
let maxX = firstPoint.x;
|
||||
let minY = firstPoint.y;
|
||||
let maxY = firstPoint.y;
|
||||
|
||||
for (const point of restPoints) {
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
|
||||
return {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
width: Math.max(1, maxX - minX),
|
||||
height: Math.max(1, maxY - minY),
|
||||
centerX: (minX + maxX) / 2,
|
||||
centerY: (minY + maxY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomMaskCanvasSegment {
|
||||
index: number;
|
||||
startPointId: string;
|
||||
endPointId: string;
|
||||
start: CanvasPoint;
|
||||
startOut: CanvasPoint;
|
||||
endIn: CanvasPoint;
|
||||
end: CanvasPoint;
|
||||
pathData: string;
|
||||
}
|
||||
|
||||
function clampUnit(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number {
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint {
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * t,
|
||||
y: a.y + (b.y - a.y) * t,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateCubicBezier({
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
t,
|
||||
}: {
|
||||
p0: CanvasPoint;
|
||||
p1: CanvasPoint;
|
||||
p2: CanvasPoint;
|
||||
p3: CanvasPoint;
|
||||
t: number;
|
||||
}): CanvasPoint {
|
||||
const oneMinusT = 1 - t;
|
||||
return {
|
||||
x:
|
||||
oneMinusT ** 3 * p0.x +
|
||||
3 * oneMinusT ** 2 * t * p1.x +
|
||||
3 * oneMinusT * t ** 2 * p2.x +
|
||||
t ** 3 * p3.x,
|
||||
y:
|
||||
oneMinusT ** 3 * p0.y +
|
||||
3 * oneMinusT ** 2 * t * p1.y +
|
||||
3 * oneMinusT * t ** 2 * p2.y +
|
||||
t ** 3 * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function getCustomMaskSegmentIndices({
|
||||
points,
|
||||
segmentIndex,
|
||||
closed,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
segmentIndex: number;
|
||||
closed: boolean;
|
||||
}): { startIndex: number; endIndex: number } | null {
|
||||
const segmentCount = getCustomMaskSegmentCount({ points, closed });
|
||||
if (segmentIndex < 0 || segmentIndex >= segmentCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startIndex: segmentIndex,
|
||||
endIndex: (segmentIndex + 1) % points.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomMaskSegmentCount({
|
||||
points,
|
||||
closed,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
closed: boolean;
|
||||
}): number {
|
||||
if (points.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
return closed ? points.length : points.length - 1;
|
||||
}
|
||||
|
||||
export function getCustomMaskCanvasSegments({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
closed,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
closed: boolean;
|
||||
}): CustomMaskCanvasSegment[] {
|
||||
const geometry = getCustomMaskCanvasGeometry({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
const segmentCount = getCustomMaskSegmentCount({ points, closed });
|
||||
|
||||
return Array.from({ length: segmentCount }, (_, segmentIndex) => {
|
||||
const start = geometry.anchors[segmentIndex];
|
||||
const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length];
|
||||
return {
|
||||
index: segmentIndex,
|
||||
startPointId: start.id,
|
||||
endPointId: end.id,
|
||||
start: start.anchor,
|
||||
startOut: start.outHandle,
|
||||
endIn: end.inHandle,
|
||||
end: end.anchor,
|
||||
pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function findClosestPointOnCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
closed,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
segmentIndex: number;
|
||||
canvasPoint: CanvasPoint;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
closed: boolean;
|
||||
}): { t: number; point: CanvasPoint } | null {
|
||||
const segment = getCustomMaskCanvasSegments({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
closed,
|
||||
}).find((candidate) => candidate.index === segmentIndex);
|
||||
if (!segment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampleCount = 24;
|
||||
let bestT = 0;
|
||||
let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start);
|
||||
|
||||
for (let step = 0; step <= sampleCount; step++) {
|
||||
const t = step / sampleCount;
|
||||
const point = evaluateCubicBezier({
|
||||
p0: segment.start,
|
||||
p1: segment.startOut,
|
||||
p2: segment.endIn,
|
||||
p3: segment.end,
|
||||
t,
|
||||
});
|
||||
const distanceSquared = getDistanceSquared(canvasPoint, point);
|
||||
if (distanceSquared < bestDistanceSquared) {
|
||||
bestDistanceSquared = distanceSquared;
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
|
||||
let searchStep = 1 / sampleCount;
|
||||
for (let iteration = 0; iteration < 8; iteration++) {
|
||||
const candidates = [bestT - searchStep, bestT, bestT + searchStep]
|
||||
.map(clampUnit)
|
||||
.map((t) => ({
|
||||
t,
|
||||
point: evaluateCubicBezier({
|
||||
p0: segment.start,
|
||||
p1: segment.startOut,
|
||||
p2: segment.endIn,
|
||||
p3: segment.end,
|
||||
t,
|
||||
}),
|
||||
}));
|
||||
for (const candidate of candidates) {
|
||||
const distanceSquared = getDistanceSquared(canvasPoint, candidate.point);
|
||||
if (distanceSquared < bestDistanceSquared) {
|
||||
bestDistanceSquared = distanceSquared;
|
||||
bestT = candidate.t;
|
||||
}
|
||||
}
|
||||
searchStep /= 2;
|
||||
}
|
||||
|
||||
const clampedT = Math.min(0.999, Math.max(0.001, bestT));
|
||||
return {
|
||||
t: clampedT,
|
||||
point: evaluateCubicBezier({
|
||||
p0: segment.start,
|
||||
p1: segment.startOut,
|
||||
p2: segment.endIn,
|
||||
p3: segment.end,
|
||||
t: clampedT,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function insertPointIntoCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex,
|
||||
pointId,
|
||||
t,
|
||||
closed,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
segmentIndex: number;
|
||||
pointId: string;
|
||||
t: number;
|
||||
closed: boolean;
|
||||
}): CustomMaskPathPoint[] {
|
||||
const indices = getCustomMaskSegmentIndices({
|
||||
points,
|
||||
segmentIndex,
|
||||
closed,
|
||||
});
|
||||
if (!indices) {
|
||||
return points;
|
||||
}
|
||||
|
||||
const startPoint = points[indices.startIndex];
|
||||
const endPoint = points[indices.endIndex];
|
||||
const clampedT = Math.min(0.999, Math.max(0.001, t));
|
||||
const p0 = { x: startPoint.x, y: startPoint.y };
|
||||
const p1 = {
|
||||
x: startPoint.x + startPoint.outX,
|
||||
y: startPoint.y + startPoint.outY,
|
||||
};
|
||||
const p2 = {
|
||||
x: endPoint.x + endPoint.inX,
|
||||
y: endPoint.y + endPoint.inY,
|
||||
};
|
||||
const p3 = { x: endPoint.x, y: endPoint.y };
|
||||
const p01 = lerpPoint(p0, p1, clampedT);
|
||||
const p12 = lerpPoint(p1, p2, clampedT);
|
||||
const p23 = lerpPoint(p2, p3, clampedT);
|
||||
const p012 = lerpPoint(p01, p12, clampedT);
|
||||
const p123 = lerpPoint(p12, p23, clampedT);
|
||||
const splitPoint = lerpPoint(p012, p123, clampedT);
|
||||
|
||||
const nextPoints = [...points];
|
||||
nextPoints[indices.startIndex] = {
|
||||
...startPoint,
|
||||
outX: p01.x - startPoint.x,
|
||||
outY: p01.y - startPoint.y,
|
||||
};
|
||||
nextPoints[indices.endIndex] = {
|
||||
...endPoint,
|
||||
inX: p23.x - endPoint.x,
|
||||
inY: p23.y - endPoint.y,
|
||||
};
|
||||
nextPoints.splice(indices.endIndex, 0, {
|
||||
id: pointId,
|
||||
x: splitPoint.x,
|
||||
y: splitPoint.y,
|
||||
inX: p012.x - splitPoint.x,
|
||||
inY: p012.y - splitPoint.y,
|
||||
outX: p123.x - splitPoint.x,
|
||||
outY: p123.y - splitPoint.y,
|
||||
});
|
||||
return nextPoints;
|
||||
}
|
||||
|
||||
export function getCustomMaskLocalBounds({
|
||||
points,
|
||||
bounds,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
bounds: ElementBounds;
|
||||
}) {
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = points.flatMap((point) => [
|
||||
{ x: point.x * bounds.width, y: point.y * bounds.height },
|
||||
{
|
||||
x: (point.x + point.inX) * bounds.width,
|
||||
y: (point.y + point.inY) * bounds.height,
|
||||
},
|
||||
{
|
||||
x: (point.x + point.outX) * bounds.width,
|
||||
y: (point.y + point.outY) * bounds.height,
|
||||
},
|
||||
]);
|
||||
|
||||
const localBounds = getCanvasPointBounds({
|
||||
points: values,
|
||||
});
|
||||
if (!localBounds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
width: localBounds.width,
|
||||
height: localBounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function recenterCustomMaskPath({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
}) {
|
||||
if (points.length === 0) {
|
||||
return { centerX, centerY, points };
|
||||
}
|
||||
|
||||
const geometry = getCustomMaskCanvasGeometry({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
if (!geometry.bounds) {
|
||||
return { centerX, centerY, points };
|
||||
}
|
||||
|
||||
const nextCenterCanvas = {
|
||||
x: geometry.bounds.centerX,
|
||||
y: geometry.bounds.centerY,
|
||||
};
|
||||
const nextCenterLocal = {
|
||||
x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width,
|
||||
y:
|
||||
bounds.height === 0
|
||||
? 0
|
||||
: (nextCenterCanvas.y - bounds.cy) / bounds.height,
|
||||
};
|
||||
|
||||
const nextPoints = geometry.anchors.map((point) => {
|
||||
const anchor = customMaskCanvasPointToLocal({
|
||||
point: point.anchor,
|
||||
centerX: nextCenterLocal.x,
|
||||
centerY: nextCenterLocal.y,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
const inHandle = customMaskCanvasPointToLocal({
|
||||
point: point.inHandle,
|
||||
centerX: nextCenterLocal.x,
|
||||
centerY: nextCenterLocal.y,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
const outHandle = customMaskCanvasPointToLocal({
|
||||
point: point.outHandle,
|
||||
centerX: nextCenterLocal.x,
|
||||
centerY: nextCenterLocal.y,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
x: anchor.x,
|
||||
y: anchor.y,
|
||||
inX: inHandle.x - anchor.x,
|
||||
inY: inHandle.y - anchor.y,
|
||||
outX: outHandle.x - anchor.x,
|
||||
outY: outHandle.y - anchor.y,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
centerX: nextCenterLocal.x,
|
||||
centerY: nextCenterLocal.y,
|
||||
points: nextPoints,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomMaskPath2D({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
closed,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
closed: boolean;
|
||||
}): Path2D {
|
||||
const path = new Path2D();
|
||||
if (points.length === 0) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const geometry = getCustomMaskCanvasGeometry({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
const anchors = geometry.anchors;
|
||||
path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y);
|
||||
|
||||
for (let index = 1; index < anchors.length; index++) {
|
||||
const previous = anchors[index - 1];
|
||||
const current = anchors[index];
|
||||
path.bezierCurveTo(
|
||||
previous.outHandle.x,
|
||||
previous.outHandle.y,
|
||||
current.inHandle.x,
|
||||
current.inHandle.y,
|
||||
current.anchor.x,
|
||||
current.anchor.y,
|
||||
);
|
||||
}
|
||||
|
||||
if (closed && anchors.length > 1) {
|
||||
const last = anchors[anchors.length - 1];
|
||||
const first = anchors[0];
|
||||
path.bezierCurveTo(
|
||||
last.outHandle.x,
|
||||
last.outHandle.y,
|
||||
first.inHandle.x,
|
||||
first.inHandle.y,
|
||||
first.anchor.x,
|
||||
first.anchor.y,
|
||||
);
|
||||
path.closePath();
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export function buildCustomMaskSvgPath({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
closed,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
bounds: ElementBounds;
|
||||
closed: boolean;
|
||||
}): string {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const geometry = getCustomMaskCanvasGeometry({
|
||||
points,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
const anchors = geometry.anchors;
|
||||
const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`];
|
||||
|
||||
for (let index = 1; index < anchors.length; index++) {
|
||||
const previous = anchors[index - 1];
|
||||
const current = anchors[index];
|
||||
segments.push(
|
||||
`C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (closed && anchors.length > 1) {
|
||||
const last = anchors[anchors.length - 1];
|
||||
const first = anchors[0];
|
||||
segments.push(
|
||||
`C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`,
|
||||
);
|
||||
segments.push("Z");
|
||||
}
|
||||
|
||||
return segments.join(" ");
|
||||
}
|
||||
@@ -6,14 +6,17 @@ import { computeFeatherUpdate } from "../param-update";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskDefaultContext,
|
||||
MaskFeatures,
|
||||
MaskInteractionDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
RectangleMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import type {
|
||||
NumberParamDefinition,
|
||||
ParamDefinition,
|
||||
ParamValues,
|
||||
} from "@/lib/params";
|
||||
import type { NumberParamDefinition, ParamDefinition } from "@/lib/params";
|
||||
import {
|
||||
getBoxMaskHandlePositions,
|
||||
getBoxMaskOverlays,
|
||||
} from "@/lib/masks/handle-positions";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
|
||||
const PERCENTAGE_DISPLAY: Pick<
|
||||
NumberParamDefinition,
|
||||
@@ -160,13 +163,53 @@ export function getBoxLikeGeometry({
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBoxMaskInteraction({
|
||||
sizeMode,
|
||||
buildOverlayPath,
|
||||
showBoundingBox = true,
|
||||
}: {
|
||||
sizeMode: MaskFeatures["sizeMode"];
|
||||
buildOverlayPath?: (args: { width: number; height: number }) => string;
|
||||
showBoundingBox?: boolean;
|
||||
}): MaskInteractionDefinition<RectangleMaskParams> {
|
||||
return {
|
||||
getInteraction({ params, bounds, displayScale, scaleX, scaleY }) {
|
||||
return {
|
||||
handles: getBoxMaskHandlePositions({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
rotation: params.rotation,
|
||||
feather: params.feather,
|
||||
sizeMode,
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
overlays: getBoxMaskOverlays({
|
||||
params,
|
||||
bounds,
|
||||
pathData: buildOverlayPath?.({
|
||||
width: params.width * bounds.width * scaleX,
|
||||
height: params.height * bounds.height * scaleY,
|
||||
}),
|
||||
showBoundingBox,
|
||||
}),
|
||||
};
|
||||
},
|
||||
snap(args) {
|
||||
return snapMaskInteraction(args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function computeBoxMaskParamUpdate({
|
||||
handleId,
|
||||
startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
bounds,
|
||||
}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues {
|
||||
}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
|
||||
if (handleId === "position") {
|
||||
return {
|
||||
centerX: startParams.centerX + deltaX / bounds.width,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getDefaultBaseMaskParams,
|
||||
getStrokeOffset,
|
||||
@@ -73,16 +74,18 @@ function buildBandPath({
|
||||
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "cinematic-bars",
|
||||
name: "Cinematic Bars",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M 0,0 H ${width} V ${height} H 0 Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "height-only",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "height-only",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M 0,0 H ${width} V ${height} H 0 Z`;
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "cinematic-bars",
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type {
|
||||
CustomMask,
|
||||
CustomMaskParams,
|
||||
MaskDefinition,
|
||||
MaskHandlePosition,
|
||||
MaskOverlay,
|
||||
MaskParamUpdateArgs,
|
||||
} from "@/lib/masks/types";
|
||||
import {
|
||||
buildCustomMaskPath2D,
|
||||
buildCustomMaskSvgPath,
|
||||
customMaskCanvasPointToLocal,
|
||||
findClosestPointOnCustomMaskSegment,
|
||||
getCustomMaskCanvasSegments,
|
||||
getCustomMaskCanvasGeometry,
|
||||
getCustomMaskLocalBounds,
|
||||
getCustomMaskSegmentCount,
|
||||
insertPointIntoCustomMaskSegment,
|
||||
parseCustomMaskHandleId,
|
||||
recenterCustomMaskPath,
|
||||
type CustomMaskPathPoint,
|
||||
} from "@/lib/masks/custom-path";
|
||||
import { getBoxMaskHandlePositions } from "@/lib/masks/handle-positions";
|
||||
import { computeFeatherUpdate } from "@/lib/masks/param-update";
|
||||
import {
|
||||
setMaskLocalCenter,
|
||||
toGlobalMaskSnapLines,
|
||||
} from "@/lib/masks/geometry";
|
||||
import {
|
||||
snapPosition,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
|
||||
const PERCENTAGE_DISPLAY = {
|
||||
displayMultiplier: 100,
|
||||
step: 1,
|
||||
} as const;
|
||||
|
||||
const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
|
||||
{
|
||||
key: "centerX",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "centerY",
|
||||
label: "Y",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "scale",
|
||||
label: "Scale",
|
||||
type: "number",
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 500,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
];
|
||||
|
||||
function getCustomMaskDisplayHandles({
|
||||
params,
|
||||
displayScale,
|
||||
bounds,
|
||||
}: {
|
||||
params: CustomMaskParams;
|
||||
displayScale: number;
|
||||
bounds: ElementBounds;
|
||||
}): {
|
||||
handles: MaskHandlePosition[];
|
||||
overlays: MaskOverlay[];
|
||||
} {
|
||||
const points = params.path;
|
||||
const geometry = getCustomMaskCanvasGeometry({
|
||||
points,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
});
|
||||
|
||||
const handles: MaskHandlePosition[] = [];
|
||||
const overlays: MaskOverlay[] = [];
|
||||
|
||||
if (points.length > 0) {
|
||||
overlays.push({
|
||||
id: "path",
|
||||
type: "canvas-path",
|
||||
pathData: buildCustomMaskSvgPath({
|
||||
points,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
closed: params.closed,
|
||||
}),
|
||||
coordinateSpace: "canvas",
|
||||
});
|
||||
}
|
||||
|
||||
if (params.closed) {
|
||||
const segmentStrokeWidth = 12;
|
||||
overlays.push(
|
||||
...getCustomMaskCanvasSegments({
|
||||
points,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
closed: true,
|
||||
}).map((segment) => ({
|
||||
id: `segment:${segment.index}`,
|
||||
type: "canvas-path" as const,
|
||||
pathData: segment.pathData,
|
||||
coordinateSpace: "canvas" as const,
|
||||
handleId: `segment:${segment.index}`,
|
||||
cursor: PEN_CURSOR,
|
||||
strokeOpacity: 0,
|
||||
strokeWidth: segmentStrokeWidth,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const localBounds = getCustomMaskLocalBounds({ points, bounds });
|
||||
if (params.closed && localBounds) {
|
||||
handles.push(
|
||||
...getBoxMaskHandlePositions({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
width: (localBounds.width * params.scale) / bounds.width,
|
||||
height: (localBounds.height * params.scale) / bounds.height,
|
||||
rotation: params.rotation,
|
||||
feather: params.feather,
|
||||
sizeMode: "uniform",
|
||||
showScaleHandle: false,
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
geometry.anchors.forEach((point) => {
|
||||
handles.push({
|
||||
id: `point:${point.id}:anchor`,
|
||||
x: point.anchor.x,
|
||||
y: point.anchor.y,
|
||||
cursor: params.closed ? "move" : "pointer",
|
||||
kind: "point",
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
handles,
|
||||
overlays,
|
||||
};
|
||||
}
|
||||
|
||||
function updateCustomMaskPoint({
|
||||
points,
|
||||
pointId,
|
||||
updater,
|
||||
}: {
|
||||
points: CustomMaskPathPoint[];
|
||||
pointId: string;
|
||||
updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint;
|
||||
}) {
|
||||
return points.map((point) => (point.id === pointId ? updater(point) : point));
|
||||
}
|
||||
|
||||
function computeCustomMaskParamUpdate({
|
||||
handleId,
|
||||
startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
startCanvasX,
|
||||
startCanvasY,
|
||||
bounds,
|
||||
}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
|
||||
if (handleId === "position") {
|
||||
return {
|
||||
centerX: startParams.centerX + deltaX / bounds.width,
|
||||
centerY: startParams.centerY + deltaY / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
const pivotX = bounds.cx + startParams.centerX * bounds.width;
|
||||
const pivotY = bounds.cy + startParams.centerY * bounds.height;
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const startAngle =
|
||||
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
|
||||
Math.PI;
|
||||
const currentAngle =
|
||||
(Math.atan2(
|
||||
startCanvasY + deltaY - pivotY,
|
||||
startCanvasX + deltaX - pivotX,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
|
||||
let deltaAngle = currentAngle - startAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
return {
|
||||
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "feather") {
|
||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||
return computeFeatherUpdate({
|
||||
startFeather: startParams.feather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX: -Math.sin(angleRad),
|
||||
directionY: Math.cos(angleRad),
|
||||
});
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const startDistance = Math.hypot(
|
||||
startCanvasX - pivotX,
|
||||
startCanvasY - pivotY,
|
||||
);
|
||||
const currentDistance = Math.hypot(
|
||||
startCanvasX + deltaX - pivotX,
|
||||
startCanvasY + deltaY - pivotY,
|
||||
);
|
||||
const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
|
||||
return {
|
||||
scale: Math.max(0.01, startParams.scale * scaleFactor),
|
||||
};
|
||||
}
|
||||
|
||||
const parsedHandle = parseCustomMaskHandleId({ handleId });
|
||||
if (!parsedHandle) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const points = startParams.path;
|
||||
const currentPoint = {
|
||||
x: startCanvasX + deltaX,
|
||||
y: startCanvasY + deltaY,
|
||||
};
|
||||
const localPoint = customMaskCanvasPointToLocal({
|
||||
point: currentPoint,
|
||||
centerX: startParams.centerX,
|
||||
centerY: startParams.centerY,
|
||||
rotation: startParams.rotation,
|
||||
scale: startParams.scale,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return {
|
||||
path: updateCustomMaskPoint({
|
||||
points,
|
||||
pointId: parsedHandle.pointId,
|
||||
updater: (point) => {
|
||||
if (parsedHandle.part === "anchor") {
|
||||
return {
|
||||
...point,
|
||||
x: localPoint.x,
|
||||
y: localPoint.y,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedHandle.part === "in") {
|
||||
return {
|
||||
...point,
|
||||
inX: localPoint.x - point.x,
|
||||
inY: localPoint.y - point.y,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...point,
|
||||
outX: localPoint.x - point.x,
|
||||
outY: localPoint.y - point.y,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
|
||||
type: "custom",
|
||||
name: "Custom",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "uniform",
|
||||
},
|
||||
params: CUSTOM_MASK_PARAMS,
|
||||
interaction: {
|
||||
getInteraction({
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
scaleX: _scaleX,
|
||||
scaleY: _scaleY,
|
||||
}) {
|
||||
return getCustomMaskDisplayHandles({ params, bounds, displayScale });
|
||||
},
|
||||
snap({
|
||||
handleId,
|
||||
proposedParams,
|
||||
startParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}) {
|
||||
const points = startParams.path;
|
||||
const localBounds = getCustomMaskLocalBounds({ points, bounds });
|
||||
if (!startParams.closed || !localBounds) {
|
||||
return {
|
||||
params: proposedParams,
|
||||
activeLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: proposedParams.centerX * bounds.width,
|
||||
y: proposedParams.centerY * bounds.height,
|
||||
};
|
||||
|
||||
if (handleId === "position") {
|
||||
const { snappedPosition, activeLines } = snapPosition({
|
||||
proposedPosition: position,
|
||||
canvasSize: bounds,
|
||||
elementSize: {
|
||||
width: localBounds.width * proposedParams.scale,
|
||||
height: localBounds.height * proposedParams.scale,
|
||||
},
|
||||
rotation: proposedParams.rotation,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
...setMaskLocalCenter({
|
||||
center: snappedPosition,
|
||||
bounds,
|
||||
}),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const { snappedRotation } = snapRotation({
|
||||
proposedRotation: proposedParams.rotation,
|
||||
});
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
rotation: snappedRotation,
|
||||
},
|
||||
activeLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const { snappedScale, activeLines } = snapScale({
|
||||
proposedScale: proposedParams.scale,
|
||||
position,
|
||||
baseWidth: localBounds.width,
|
||||
baseHeight: localBounds.height,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: bounds,
|
||||
snapThreshold,
|
||||
preferredEdges: {
|
||||
right: true,
|
||||
bottom: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
scale: Math.max(0.01, snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
params: proposedParams,
|
||||
activeLines: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
buildDefault(): Omit<CustomMask, "id"> {
|
||||
return {
|
||||
type: "custom",
|
||||
params: {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
path: [],
|
||||
closed: false,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeCustomMaskParamUpdate,
|
||||
isActive(params) {
|
||||
return params.closed;
|
||||
},
|
||||
renderer: {
|
||||
buildPath({ resolvedParams, width, height }) {
|
||||
const params = resolvedParams as CustomMaskParams;
|
||||
const points = params.path;
|
||||
if (!params.closed) {
|
||||
return new Path2D();
|
||||
}
|
||||
|
||||
return buildCustomMaskPath2D({
|
||||
points,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds: {
|
||||
cx: width / 2,
|
||||
cy: height / 2,
|
||||
width,
|
||||
height,
|
||||
rotation: 0,
|
||||
},
|
||||
closed: true,
|
||||
});
|
||||
},
|
||||
renderStroke({ resolvedParams, ctx, width, height }) {
|
||||
const params = resolvedParams as CustomMaskParams;
|
||||
if (!params.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = params.path;
|
||||
const path = buildCustomMaskPath2D({
|
||||
points,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds: {
|
||||
cx: width / 2,
|
||||
cy: height / 2,
|
||||
width,
|
||||
height,
|
||||
rotation: 0,
|
||||
},
|
||||
closed: true,
|
||||
});
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = params.strokeColor;
|
||||
ctx.lineWidth = params.strokeWidth;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke(path);
|
||||
|
||||
if (params.strokeAlign === "inside") {
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill(path);
|
||||
}
|
||||
|
||||
if (params.strokeAlign === "outside") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill(path);
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function appendPointToCustomMask({
|
||||
params,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
params: CustomMaskParams;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): CustomMaskParams {
|
||||
const points = params.path;
|
||||
|
||||
if (points.length === 0) {
|
||||
return {
|
||||
...params,
|
||||
centerX:
|
||||
bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width,
|
||||
centerY:
|
||||
bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
path: [
|
||||
{
|
||||
id: generateUUID(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const localPoint = customMaskCanvasPointToLocal({
|
||||
point: canvasPoint,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
});
|
||||
const nextPoints = [
|
||||
...points,
|
||||
{
|
||||
id: generateUUID(),
|
||||
x: localPoint.x,
|
||||
y: localPoint.y,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0,
|
||||
outY: 0,
|
||||
},
|
||||
];
|
||||
const recentered = recenterCustomMaskPath({
|
||||
points: nextPoints,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return {
|
||||
...params,
|
||||
centerX: recentered.centerX,
|
||||
centerY: recentered.centerY,
|
||||
path: recentered.points,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertPointOnCustomMaskSegment({
|
||||
params,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
pointId = generateUUID(),
|
||||
}: {
|
||||
params: CustomMaskParams;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
pointId?: string;
|
||||
}): { params: CustomMaskParams; pointId: string } | null {
|
||||
const points = params.path;
|
||||
if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closestPoint = findClosestPointOnCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
closed: params.closed,
|
||||
});
|
||||
if (!closestPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextPoints = insertPointIntoCustomMaskSegment({
|
||||
points,
|
||||
segmentIndex,
|
||||
pointId,
|
||||
t: closestPoint.t,
|
||||
closed: params.closed,
|
||||
});
|
||||
if (nextPoints.length === points.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recentered = recenterCustomMaskPath({
|
||||
points: nextPoints,
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
scale: params.scale,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return {
|
||||
pointId,
|
||||
params: {
|
||||
...params,
|
||||
centerX: recentered.centerX,
|
||||
centerY: recentered.centerY,
|
||||
path: recentered.points,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
@@ -47,16 +48,18 @@ function buildDiamondPath({
|
||||
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "diamond",
|
||||
name: "Diamond",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "diamond",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
@@ -10,7 +11,14 @@ import {
|
||||
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "ellipse",
|
||||
name: "Ellipse",
|
||||
overlayShape: "box",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const rx = Math.max((width - 1) / 2, 0);
|
||||
const ry = Math.max((height - 1) / 2, 0);
|
||||
@@ -18,12 +26,7 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
const cy = height / 2;
|
||||
return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "ellipse",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
@@ -36,23 +37,23 @@ function buildHeartPath({
|
||||
rotationRad,
|
||||
});
|
||||
|
||||
const start = toPoint({ localX: 0, localY: -halfHeight * 0.2 });
|
||||
const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
|
||||
const rightControl1 = toPoint({
|
||||
localX: halfWidth,
|
||||
localY: -halfHeight * 0.95,
|
||||
localY: -halfHeight * 1.225,
|
||||
});
|
||||
const rightControl2 = toPoint({
|
||||
localX: halfWidth,
|
||||
localY: halfHeight * 0.15,
|
||||
localY: -halfHeight * 0.125,
|
||||
});
|
||||
const bottom = toPoint({ localX: 0, localY: halfHeight });
|
||||
const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
|
||||
const leftControl1 = toPoint({
|
||||
localX: -halfWidth,
|
||||
localY: halfHeight * 0.15,
|
||||
localY: -halfHeight * 0.125,
|
||||
});
|
||||
const leftControl2 = toPoint({
|
||||
localX: -halfWidth,
|
||||
localY: -halfHeight * 0.95,
|
||||
localY: -halfHeight * 1.225,
|
||||
});
|
||||
|
||||
const path = new Path2D();
|
||||
@@ -80,25 +81,27 @@ function buildHeartPath({
|
||||
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "heart",
|
||||
name: "Heart",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return [
|
||||
`M ${cx},${cy - halfHeight * 0.2}`,
|
||||
`C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`,
|
||||
`C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return [
|
||||
`M ${cx},${cy - halfHeight * 0.475}`,
|
||||
`C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
|
||||
`C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "heart",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
|
||||
import { masksRegistry, type MaskIconProps } from "../registry";
|
||||
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
|
||||
import { customMaskDefinition } from "./custom";
|
||||
import { diamondMaskDefinition } from "./diamond";
|
||||
import { ellipseMaskDefinition } from "./ellipse";
|
||||
import { heartMaskDefinition } from "./heart";
|
||||
import { rectangleMaskDefinition } from "./rectangle";
|
||||
import { splitMaskDefinition } from "./split";
|
||||
import { starMaskDefinition } from "./star";
|
||||
import { textMaskDefinition } from "./text";
|
||||
import {
|
||||
MinusSignIcon,
|
||||
PanelRightDashedIcon,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
FavouriteIcon,
|
||||
DiamondIcon,
|
||||
StarsIcon,
|
||||
TextFontIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
||||
function registerDefaultMask<TParams extends BaseMaskParams>({
|
||||
@@ -60,4 +63,12 @@ export function registerDefaultMasks(): void {
|
||||
definition: starMaskDefinition,
|
||||
icon: { icon: StarsIcon },
|
||||
});
|
||||
registerDefaultMask({
|
||||
definition: textMaskDefinition,
|
||||
icon: { icon: TextFontIcon },
|
||||
});
|
||||
registerDefaultMask({
|
||||
definition: customMaskDefinition,
|
||||
icon: { icon: SquareIcon },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
@@ -47,13 +48,15 @@ function buildRectanglePath({
|
||||
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "rectangle",
|
||||
name: "Rectangle",
|
||||
overlayShape: "box",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "rectangle",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { computeFeatherUpdate } from "../param-update";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
MaskDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
SplitMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
|
||||
import {
|
||||
getLineMaskHandlePositions,
|
||||
getLineMaskOverlay,
|
||||
} from "@/lib/masks/handle-positions";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
|
||||
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
|
||||
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
|
||||
@@ -120,7 +124,7 @@ function computeSplitMaskParamUpdate({
|
||||
startCanvasY,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues {
|
||||
}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
|
||||
if (handleId === "position") {
|
||||
const rawX = startParams.centerX + deltaX / bounds.width;
|
||||
const rawY = startParams.centerY + deltaY / bounds.height;
|
||||
@@ -176,12 +180,42 @@ function computeSplitMaskParamUpdate({
|
||||
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
|
||||
type: "split",
|
||||
name: "Split",
|
||||
overlayShape: "line",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "none",
|
||||
},
|
||||
interaction: {
|
||||
getInteraction({
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
scaleX: _scaleX,
|
||||
scaleY: _scaleY,
|
||||
}) {
|
||||
return {
|
||||
handles: getLineMaskHandlePositions({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
feather: params.feather,
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
overlays: [
|
||||
getLineMaskOverlay({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
rotation: params.rotation,
|
||||
bounds,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
snap(args) {
|
||||
return snapMaskInteraction(args);
|
||||
},
|
||||
},
|
||||
buildDefault() {
|
||||
return {
|
||||
type: "split",
|
||||
@@ -230,6 +264,7 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
|
||||
},
|
||||
],
|
||||
renderer: {
|
||||
renderMaskHandlesFeather: true,
|
||||
renderMask({ resolvedParams, ctx, width, height, feather }) {
|
||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
buildBoxMaskInteraction,
|
||||
computeBoxMaskParamUpdate,
|
||||
getBoxLikeGeometry,
|
||||
getDefaultSquareMaskParams,
|
||||
@@ -87,16 +88,18 @@ function buildOverlayStarPath({
|
||||
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||
type: "star",
|
||||
name: "Star",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return buildOverlayStarPath({ width, height });
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
interaction: buildBoxMaskInteraction({
|
||||
sizeMode: "width-height",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return buildOverlayStarPath({ width, height });
|
||||
},
|
||||
}),
|
||||
buildDefault(context) {
|
||||
return {
|
||||
type: "star",
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type {
|
||||
MaskDefinition,
|
||||
MaskParamUpdateArgs,
|
||||
TextMask,
|
||||
TextMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/lib/text/typography";
|
||||
import {
|
||||
drawMeasuredTextLayout,
|
||||
measureTextLayout,
|
||||
strokeMeasuredTextLayout,
|
||||
} from "@/lib/text/primitives";
|
||||
import { getTextMeasurementContext } from "@/lib/text/measure-element";
|
||||
import { getTextVisualRect } from "@/lib/text/layout";
|
||||
import {
|
||||
getBoxMaskHandlePositions,
|
||||
getBoxMaskRectOverlay,
|
||||
} from "@/lib/masks/handle-positions";
|
||||
import { computeFeatherUpdate } from "@/lib/masks/param-update";
|
||||
import {
|
||||
setMaskLocalCenter,
|
||||
toGlobalMaskSnapLines,
|
||||
} from "@/lib/masks/geometry";
|
||||
import {
|
||||
snapPosition,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
type ScaleEdgePreference,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
|
||||
const PERCENTAGE_DISPLAY = {
|
||||
displayMultiplier: 100,
|
||||
step: 1,
|
||||
} as const;
|
||||
|
||||
const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign;
|
||||
|
||||
const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
|
||||
{
|
||||
key: "centerX",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "centerY",
|
||||
label: "Y",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
{
|
||||
key: "fontSize",
|
||||
label: "Size",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.element.fontSize,
|
||||
min: MIN_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "scale",
|
||||
label: "Scale",
|
||||
type: "number",
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 500,
|
||||
...PERCENTAGE_DISPLAY,
|
||||
},
|
||||
];
|
||||
|
||||
function measureTextMask({
|
||||
params,
|
||||
height,
|
||||
}: {
|
||||
params: TextMaskParams;
|
||||
height: number;
|
||||
}) {
|
||||
const layout = measureTextLayout({
|
||||
text: {
|
||||
content: params.content,
|
||||
fontSize: params.fontSize,
|
||||
fontFamily: params.fontFamily,
|
||||
fontWeight: params.fontWeight,
|
||||
fontStyle: params.fontStyle,
|
||||
textAlign: TEXT_MASK_ALIGNMENT,
|
||||
textDecoration: params.textDecoration,
|
||||
letterSpacing: params.letterSpacing,
|
||||
lineHeight: params.lineHeight,
|
||||
},
|
||||
canvasHeight: height,
|
||||
ctx: getTextMeasurementContext(),
|
||||
});
|
||||
const visualRect = getTextVisualRect({
|
||||
textAlign: layout.textAlign,
|
||||
block: layout.block,
|
||||
background: { enabled: false, color: "transparent" },
|
||||
fontSizeRatio: layout.fontSizeRatio,
|
||||
});
|
||||
|
||||
return {
|
||||
layout,
|
||||
intrinsicWidth: Math.max(1, visualRect.width),
|
||||
intrinsicHeight: Math.max(1, visualRect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function getScalePreferredEdges({
|
||||
handleId,
|
||||
}: {
|
||||
handleId: string;
|
||||
}): ScaleEdgePreference | undefined {
|
||||
if (handleId !== "scale") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
right: true,
|
||||
bottom: true,
|
||||
};
|
||||
}
|
||||
|
||||
function computeTextMaskParamUpdate({
|
||||
handleId,
|
||||
startParams,
|
||||
deltaX,
|
||||
deltaY,
|
||||
startCanvasX,
|
||||
startCanvasY,
|
||||
bounds,
|
||||
}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
|
||||
if (handleId === "position") {
|
||||
return {
|
||||
centerX: startParams.centerX + deltaX / bounds.width,
|
||||
centerY: startParams.centerY + deltaY / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
const pivotX = bounds.cx + startParams.centerX * bounds.width;
|
||||
const pivotY = bounds.cy + startParams.centerY * bounds.height;
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const startAngle =
|
||||
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
|
||||
Math.PI;
|
||||
const currentAngle =
|
||||
(Math.atan2(
|
||||
startCanvasY + deltaY - pivotY,
|
||||
startCanvasX + deltaX - pivotX,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
|
||||
let deltaAngle = currentAngle - startAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
return {
|
||||
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "feather") {
|
||||
const angleRad = (startParams.rotation * Math.PI) / 180;
|
||||
return computeFeatherUpdate({
|
||||
startFeather: startParams.feather,
|
||||
deltaX,
|
||||
deltaY,
|
||||
directionX: -Math.sin(angleRad),
|
||||
directionY: Math.cos(angleRad),
|
||||
});
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const startDistance = Math.hypot(
|
||||
startCanvasX - pivotX,
|
||||
startCanvasY - pivotY,
|
||||
);
|
||||
const currentDistance = Math.hypot(
|
||||
startCanvasX + deltaX - pivotX,
|
||||
startCanvasY + deltaY - pivotY,
|
||||
);
|
||||
const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
|
||||
return {
|
||||
scale: Math.max(0.01, startParams.scale * scaleFactor),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
|
||||
type: "text",
|
||||
name: "Text",
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "uniform",
|
||||
},
|
||||
params: TEXT_MASK_PARAMS,
|
||||
interaction: {
|
||||
getInteraction({
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
scaleX: _scaleX,
|
||||
scaleY: _scaleY,
|
||||
}) {
|
||||
const { intrinsicWidth, intrinsicHeight } = measureTextMask({
|
||||
params,
|
||||
height: bounds.height,
|
||||
});
|
||||
const width = (intrinsicWidth * params.scale) / bounds.width;
|
||||
const height = (intrinsicHeight * params.scale) / bounds.height;
|
||||
return {
|
||||
handles: getBoxMaskHandlePositions({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
width,
|
||||
height,
|
||||
rotation: params.rotation,
|
||||
feather: params.feather,
|
||||
sizeMode: "uniform",
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
overlays: [
|
||||
getBoxMaskRectOverlay({
|
||||
centerX: params.centerX,
|
||||
centerY: params.centerY,
|
||||
width,
|
||||
height,
|
||||
rotation: params.rotation,
|
||||
bounds,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
snap({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}) {
|
||||
const { intrinsicWidth, intrinsicHeight } = measureTextMask({
|
||||
params: startParams,
|
||||
height: bounds.height,
|
||||
});
|
||||
const position = {
|
||||
x: proposedParams.centerX * bounds.width,
|
||||
y: proposedParams.centerY * bounds.height,
|
||||
};
|
||||
|
||||
if (handleId === "position") {
|
||||
const { snappedPosition, activeLines } = snapPosition({
|
||||
proposedPosition: position,
|
||||
canvasSize: bounds,
|
||||
elementSize: {
|
||||
width: intrinsicWidth * proposedParams.scale,
|
||||
height: intrinsicHeight * proposedParams.scale,
|
||||
},
|
||||
rotation: proposedParams.rotation,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
...setMaskLocalCenter({
|
||||
center: snappedPosition,
|
||||
bounds,
|
||||
}),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
const { snappedRotation } = snapRotation({
|
||||
proposedRotation: proposedParams.rotation,
|
||||
});
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
rotation: snappedRotation,
|
||||
},
|
||||
activeLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId === "scale") {
|
||||
const { snappedScale, activeLines } = snapScale({
|
||||
proposedScale: proposedParams.scale,
|
||||
position,
|
||||
baseWidth: intrinsicWidth,
|
||||
baseHeight: intrinsicHeight,
|
||||
rotation: proposedParams.rotation,
|
||||
canvasSize: bounds,
|
||||
snapThreshold,
|
||||
preferredEdges: getScalePreferredEdges({ handleId }),
|
||||
});
|
||||
|
||||
return {
|
||||
params: {
|
||||
...proposedParams,
|
||||
scale: Math.max(0.01, snappedScale),
|
||||
},
|
||||
activeLines: toGlobalMaskSnapLines({
|
||||
lines: activeLines,
|
||||
bounds,
|
||||
canvasSize,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
params: proposedParams,
|
||||
activeLines: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
buildDefault(): Omit<TextMask, "id"> {
|
||||
return {
|
||||
type: "text",
|
||||
params: {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
content: "Mask",
|
||||
fontSize: DEFAULTS.text.element.fontSize,
|
||||
fontFamily: DEFAULTS.text.element.fontFamily,
|
||||
fontWeight: DEFAULTS.text.element.fontWeight,
|
||||
fontStyle: DEFAULTS.text.element.fontStyle,
|
||||
textDecoration: DEFAULTS.text.element.textDecoration,
|
||||
letterSpacing: DEFAULTS.text.letterSpacing,
|
||||
lineHeight: DEFAULTS.text.lineHeight,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
computeParamUpdate: computeTextMaskParamUpdate,
|
||||
isActive(params) {
|
||||
return params.content.trim().length > 0;
|
||||
},
|
||||
renderer: {
|
||||
renderMask({ resolvedParams, ctx, width, height }) {
|
||||
const params = resolvedParams as TextMaskParams;
|
||||
const { layout } = measureTextMask({ params, height });
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
width / 2 + params.centerX * width,
|
||||
height / 2 + params.centerY * height,
|
||||
);
|
||||
ctx.scale(params.scale, params.scale);
|
||||
if (params.rotation) {
|
||||
ctx.rotate((params.rotation * Math.PI) / 180);
|
||||
}
|
||||
drawMeasuredTextLayout({
|
||||
ctx,
|
||||
layout,
|
||||
textColor: "#ffffff",
|
||||
background: null,
|
||||
});
|
||||
ctx.restore();
|
||||
},
|
||||
renderStroke({ resolvedParams, ctx, width, height }) {
|
||||
const params = resolvedParams as TextMaskParams;
|
||||
const { layout } = measureTextMask({ params, height });
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
width / 2 + params.centerX * width,
|
||||
height / 2 + params.centerY * height,
|
||||
);
|
||||
ctx.scale(params.scale, params.scale);
|
||||
if (params.rotation) {
|
||||
ctx.rotate((params.rotation * Math.PI) / 180);
|
||||
}
|
||||
|
||||
strokeMeasuredTextLayout({
|
||||
ctx,
|
||||
layout,
|
||||
strokeColor: params.strokeColor,
|
||||
strokeWidth: params.strokeWidth,
|
||||
});
|
||||
|
||||
if (params.strokeAlign === "inside") {
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
drawMeasuredTextLayout({
|
||||
ctx,
|
||||
layout,
|
||||
textColor: "#ffffff",
|
||||
background: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (params.strokeAlign === "outside") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
drawMeasuredTextLayout({
|
||||
ctx,
|
||||
layout,
|
||||
textColor: "#ffffff",
|
||||
background: null,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,20 +1,30 @@
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
||||
|
||||
export function hasCenterParams(params: ParamValues): params is ParamValues & {
|
||||
type CenterMaskParams = {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
} {
|
||||
};
|
||||
|
||||
type SnapGeometryParams = CenterMaskParams & {
|
||||
rotation?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
export function hasCenterParams(
|
||||
params: Partial<CenterMaskParams>,
|
||||
): params is CenterMaskParams {
|
||||
return (
|
||||
typeof params.centerX === "number" && typeof params.centerY === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isRectangleMaskParams(
|
||||
params: ParamValues,
|
||||
params: SnapGeometryParams,
|
||||
): params is RectangleMaskParams {
|
||||
return (
|
||||
hasCenterParams(params) &&
|
||||
@@ -29,7 +39,7 @@ export function getMaskLocalCenter({
|
||||
params,
|
||||
bounds,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
params: CenterMaskParams;
|
||||
bounds: ElementBounds;
|
||||
}): { x: number; y: number } | null {
|
||||
if (!hasCenterParams(params)) return null;
|
||||
@@ -46,7 +56,7 @@ export function setMaskLocalCenter({
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): Pick<ParamValues, "centerX" | "centerY"> {
|
||||
}): { centerX: number; centerY: number } {
|
||||
return {
|
||||
centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
|
||||
centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
|
||||
@@ -57,7 +67,7 @@ export function getMaskSnapGeometry({
|
||||
params,
|
||||
bounds,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
params: SnapGeometryParams;
|
||||
bounds: ElementBounds;
|
||||
}): {
|
||||
position: { x: number; y: number };
|
||||
@@ -109,4 +119,3 @@ export function toGlobalMaskSnapLines({
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,35 +3,24 @@ import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type {
|
||||
MaskFeatures,
|
||||
MaskHandlePosition,
|
||||
MaskLinePoints,
|
||||
MaskOverlayShape,
|
||||
MaskLineOverlay,
|
||||
MaskOverlay,
|
||||
MaskRectOverlay,
|
||||
RectangleMaskParams,
|
||||
MaskShapeOverlay,
|
||||
} from "@/lib/masks/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
|
||||
const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||
const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
|
||||
const LINE_EXTENT_MULTIPLIER = 50;
|
||||
|
||||
const CURSOR = {
|
||||
rotate: "cursor-crosshair",
|
||||
resizeDiagonal: "cursor-nwse-resize",
|
||||
resizeHorizontal: "cursor-ew-resize",
|
||||
resizeVertical: "cursor-ns-resize",
|
||||
rotate: "crosshair",
|
||||
resizeDiagonal: "nwse-resize",
|
||||
resizeHorizontal: "ew-resize",
|
||||
resizeVertical: "ns-resize",
|
||||
} as const;
|
||||
|
||||
function getNumParam({
|
||||
params,
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
key: string;
|
||||
fallback: number;
|
||||
}): number {
|
||||
const value = params[key];
|
||||
return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer defines the split line as:
|
||||
* - normal direction: (cos(rotation), sin(rotation))
|
||||
@@ -50,7 +39,7 @@ export function getLineMaskLinePoints({
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
}): MaskLinePoints {
|
||||
}): { start: { x: number; y: number }; end: { x: number; y: number } } {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const normalX = Math.cos(angleRad);
|
||||
const normalY = Math.sin(angleRad);
|
||||
@@ -74,6 +63,38 @@ export function getLineMaskLinePoints({
|
||||
};
|
||||
}
|
||||
|
||||
export function getLineMaskOverlay({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
bounds,
|
||||
handleId = "position",
|
||||
cursor = "move",
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
handleId?: string;
|
||||
cursor?: string;
|
||||
}): MaskLineOverlay {
|
||||
const { start, end } = getLineMaskLinePoints({
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return {
|
||||
id: "line",
|
||||
type: "line",
|
||||
start,
|
||||
end,
|
||||
handleId,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLineMaskHandlePositions({
|
||||
centerX,
|
||||
centerY,
|
||||
@@ -105,12 +126,16 @@ export function getLineMaskHandlePositions({
|
||||
x: cx + normalX * iconOffsetCanvas,
|
||||
y: cy + normalY * iconOffsetCanvas,
|
||||
cursor: CURSOR.rotate,
|
||||
kind: "icon",
|
||||
icon: "rotate",
|
||||
},
|
||||
{
|
||||
id: "feather",
|
||||
x: cx - normalX * featherOffset,
|
||||
y: cy - normalY * featherOffset,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "icon",
|
||||
icon: "feather",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -144,6 +169,7 @@ export function getBoxMaskHandlePositions({
|
||||
rotation,
|
||||
feather,
|
||||
sizeMode,
|
||||
showScaleHandle = true,
|
||||
bounds,
|
||||
displayScale,
|
||||
}: {
|
||||
@@ -154,6 +180,7 @@ export function getBoxMaskHandlePositions({
|
||||
rotation: number;
|
||||
feather: number;
|
||||
sizeMode: MaskFeatures["sizeMode"];
|
||||
showScaleHandle?: boolean;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}): MaskHandlePosition[] {
|
||||
@@ -178,6 +205,8 @@ export function getBoxMaskHandlePositions({
|
||||
x: rotHandle.x,
|
||||
y: rotHandle.y,
|
||||
cursor: CURSOR.rotate,
|
||||
kind: "icon",
|
||||
icon: "rotate",
|
||||
});
|
||||
|
||||
const featherHandle = rotatePoint({
|
||||
@@ -192,6 +221,8 @@ export function getBoxMaskHandlePositions({
|
||||
x: featherHandle.x,
|
||||
y: featherHandle.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "icon",
|
||||
icon: "feather",
|
||||
});
|
||||
|
||||
if (sizeMode === "width-height") {
|
||||
@@ -208,6 +239,7 @@ export function getBoxMaskHandlePositions({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
cursor: CURSOR.resizeDiagonal,
|
||||
kind: "corner",
|
||||
});
|
||||
}
|
||||
const right = rotatePoint({
|
||||
@@ -236,18 +268,27 @@ export function getBoxMaskHandlePositions({
|
||||
x: left.x,
|
||||
y: left.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "right",
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "bottom",
|
||||
x: bottom.x,
|
||||
y: bottom.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "edge",
|
||||
edgeAxis: "vertical",
|
||||
rotation,
|
||||
});
|
||||
} else if (sizeMode === "height-only") {
|
||||
const top = rotatePoint({
|
||||
@@ -269,12 +310,18 @@ export function getBoxMaskHandlePositions({
|
||||
x: top.x,
|
||||
y: top.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "edge",
|
||||
edgeAxis: "vertical",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "bottom",
|
||||
x: bottom.x,
|
||||
y: bottom.y,
|
||||
cursor: CURSOR.resizeVertical,
|
||||
kind: "edge",
|
||||
edgeAxis: "vertical",
|
||||
rotation,
|
||||
});
|
||||
} else if (sizeMode === "width-only") {
|
||||
const left = rotatePoint({
|
||||
@@ -296,14 +343,20 @@ export function getBoxMaskHandlePositions({
|
||||
x: left.x,
|
||||
y: left.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
handles.push({
|
||||
id: "right",
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
cursor: CURSOR.resizeHorizontal,
|
||||
kind: "edge",
|
||||
edgeAxis: "horizontal",
|
||||
rotation,
|
||||
});
|
||||
} else if (sizeMode === "uniform") {
|
||||
} else if (sizeMode === "uniform" && showScaleHandle) {
|
||||
const point = rotatePoint({
|
||||
localX: halfWidth,
|
||||
localY: halfHeight,
|
||||
@@ -316,67 +369,129 @@ export function getBoxMaskHandlePositions({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
cursor: CURSOR.resizeDiagonal,
|
||||
kind: "corner",
|
||||
});
|
||||
}
|
||||
|
||||
return handles;
|
||||
}
|
||||
|
||||
type MaskHandleResolver = (args: {
|
||||
features: MaskFeatures;
|
||||
params: ParamValues;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}) => MaskHandlePosition[];
|
||||
|
||||
const rectangleHandles: MaskHandleResolver = ({
|
||||
features,
|
||||
params,
|
||||
export function getBoxMaskRectOverlay({
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
bounds,
|
||||
displayScale,
|
||||
}) =>
|
||||
getBoxMaskHandlePositions({
|
||||
centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
|
||||
centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
|
||||
width: getNumParam({ params, key: "width", fallback: 1 }),
|
||||
height: getNumParam({ params, key: "height", fallback: 1 }),
|
||||
rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
|
||||
feather: getNumParam({ params, key: "feather", fallback: 0 }),
|
||||
sizeMode: features.sizeMode,
|
||||
bounds,
|
||||
displayScale,
|
||||
});
|
||||
|
||||
const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = {
|
||||
line: ({ params, bounds, displayScale }) =>
|
||||
getLineMaskHandlePositions({
|
||||
centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
|
||||
centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
|
||||
rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
|
||||
feather: getNumParam({ params, key: "feather", fallback: 0 }),
|
||||
bounds,
|
||||
displayScale,
|
||||
}),
|
||||
box: rectangleHandles,
|
||||
};
|
||||
|
||||
export function getMaskHandlePositions({
|
||||
overlayShape,
|
||||
features,
|
||||
params,
|
||||
bounds,
|
||||
displayScale,
|
||||
handleId = "position",
|
||||
cursor = "move",
|
||||
dashed = false,
|
||||
}: {
|
||||
overlayShape: MaskOverlayShape;
|
||||
features: MaskFeatures;
|
||||
params: ParamValues;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
}): MaskHandlePosition[] {
|
||||
return HANDLE_RESOLVERS[overlayShape]({
|
||||
features,
|
||||
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,
|
||||
displayScale,
|
||||
});
|
||||
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,25 +1,4 @@
|
||||
import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskParamUpdateArgs,
|
||||
MaskType,
|
||||
} from "@/lib/masks/types";
|
||||
|
||||
function compactMaskParamValues({
|
||||
params,
|
||||
}: {
|
||||
params: Partial<ParamValues>;
|
||||
}): ParamValues {
|
||||
const nextParams: ParamValues = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) {
|
||||
nextParams[key] = value;
|
||||
}
|
||||
}
|
||||
return nextParams;
|
||||
}
|
||||
|
||||
export function computeFeatherUpdate({
|
||||
startFeather,
|
||||
@@ -33,7 +12,7 @@ export function computeFeatherUpdate({
|
||||
deltaY: number;
|
||||
directionX: number;
|
||||
directionY: number;
|
||||
}): ParamValues {
|
||||
}): { feather: number } {
|
||||
const projection = deltaX * directionX + deltaY * directionY;
|
||||
return {
|
||||
feather: Math.max(
|
||||
@@ -45,15 +24,3 @@ export function computeFeatherUpdate({
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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 type { ParamDefinition } from "@/lib/params";
|
||||
import type { BaseMaskParams, MaskDefinition, MaskType } from "@/lib/masks/types";
|
||||
import type {
|
||||
BaseMaskParams,
|
||||
MaskDefaultContext,
|
||||
MaskDefinition,
|
||||
MaskInteractionResult,
|
||||
MaskSnapArgs,
|
||||
MaskSnapResult,
|
||||
MaskType,
|
||||
} from "@/lib/masks/types";
|
||||
import type { HugeiconsIconProps } from "@hugeicons/react";
|
||||
import { DefinitionRegistry } from "@/lib/registry";
|
||||
|
||||
@@ -39,9 +47,33 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
|
||||
},
|
||||
];
|
||||
|
||||
export type RegisteredMaskDefinition = MaskDefinition<BaseMaskParams> & {
|
||||
icon: MaskIconProps;
|
||||
export interface RegisteredMaskDefinition {
|
||||
type: MaskType;
|
||||
name: string;
|
||||
features: MaskDefinition<BaseMaskParams>["features"];
|
||||
params: ParamDefinition<string>[];
|
||||
renderer: MaskDefinition<BaseMaskParams>["renderer"];
|
||||
interaction: {
|
||||
getInteraction(args: {
|
||||
params: BaseMaskParams;
|
||||
bounds: Parameters<
|
||||
MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
|
||||
>[0]["bounds"];
|
||||
displayScale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}): MaskInteractionResult;
|
||||
snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
|
||||
};
|
||||
isActive?: (params: BaseMaskParams) => boolean;
|
||||
buildDefault(
|
||||
context: MaskDefaultContext,
|
||||
): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
|
||||
computeParamUpdate(
|
||||
args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
|
||||
): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
|
||||
icon: MaskIconProps;
|
||||
}
|
||||
|
||||
export class MasksRegistry extends DefinitionRegistry<
|
||||
MaskType,
|
||||
@@ -59,8 +91,28 @@ export class MasksRegistry extends DefinitionRegistry<
|
||||
icon: MaskIconProps;
|
||||
}): void {
|
||||
const withBaseParams: RegisteredMaskDefinition = {
|
||||
...definition,
|
||||
type: definition.type,
|
||||
name: definition.name,
|
||||
features: definition.features,
|
||||
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
|
||||
renderer: definition.renderer,
|
||||
interaction: {
|
||||
getInteraction(args) {
|
||||
return definition.interaction.getInteraction(args as never);
|
||||
},
|
||||
snap: definition.interaction.snap
|
||||
? (args) => definition.interaction.snap?.(args as never) as never
|
||||
: undefined,
|
||||
},
|
||||
isActive: definition.isActive
|
||||
? (params) => definition.isActive?.(params as TParams) ?? true
|
||||
: undefined,
|
||||
buildDefault(context) {
|
||||
return definition.buildDefault(context);
|
||||
},
|
||||
computeParamUpdate(args) {
|
||||
return definition.computeParamUpdate(args as never);
|
||||
},
|
||||
icon,
|
||||
};
|
||||
this.register(definition.type, withBaseParams);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
isRectangleMaskParams,
|
||||
getMaskSnapGeometry,
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
toGlobalMaskSnapLines,
|
||||
} from "./geometry";
|
||||
|
||||
type MaskSnapResult = {
|
||||
params: ParamValues;
|
||||
type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
|
||||
|
||||
type MaskSnapResult<TParams extends SharedMaskParams> = {
|
||||
params: TParams;
|
||||
activeLines: SnapLine[];
|
||||
};
|
||||
|
||||
@@ -73,11 +75,11 @@ function snapMaskPosition({
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
}: {
|
||||
proposedParams: ParamValues;
|
||||
proposedParams: SharedMaskParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult {
|
||||
}): MaskSnapResult<SharedMaskParams> {
|
||||
const geometry = getMaskSnapGeometry({
|
||||
params: proposedParams,
|
||||
bounds,
|
||||
@@ -113,8 +115,8 @@ function snapMaskPosition({
|
||||
function snapMaskRotation({
|
||||
proposedParams,
|
||||
}: {
|
||||
proposedParams: ParamValues;
|
||||
}): MaskSnapResult {
|
||||
proposedParams: SharedMaskParams;
|
||||
}): MaskSnapResult<SharedMaskParams> {
|
||||
if (typeof proposedParams.rotation !== "number") {
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
@@ -141,12 +143,12 @@ function snapBoxMaskSize({
|
||||
snapThreshold,
|
||||
}: {
|
||||
handleId: string;
|
||||
startParams: ParamValues;
|
||||
proposedParams: ParamValues;
|
||||
startParams: SharedMaskParams;
|
||||
proposedParams: SharedMaskParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult {
|
||||
}): MaskSnapResult<SharedMaskParams> {
|
||||
if (
|
||||
!isRectangleMaskParams(startParams) ||
|
||||
!isRectangleMaskParams(proposedParams)
|
||||
@@ -295,7 +297,7 @@ function snapBoxMaskSize({
|
||||
return { params: proposedParams, activeLines: [] };
|
||||
}
|
||||
|
||||
export function snapMaskInteraction({
|
||||
export function snapMaskInteraction<TParams extends SharedMaskParams>({
|
||||
handleId,
|
||||
startParams,
|
||||
proposedParams,
|
||||
@@ -304,23 +306,23 @@ export function snapMaskInteraction({
|
||||
snapThreshold,
|
||||
}: {
|
||||
handleId: string;
|
||||
startParams: ParamValues;
|
||||
proposedParams: ParamValues;
|
||||
startParams: TParams;
|
||||
proposedParams: TParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): MaskSnapResult {
|
||||
}): MaskSnapResult<TParams> {
|
||||
if (handleId === "position") {
|
||||
return snapMaskPosition({
|
||||
proposedParams,
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
}) as MaskSnapResult<TParams>;
|
||||
}
|
||||
|
||||
if (handleId === "rotation") {
|
||||
return snapMaskRotation({ proposedParams });
|
||||
return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
|
||||
}
|
||||
|
||||
return snapBoxMaskSize({
|
||||
@@ -330,6 +332,5 @@ export function snapMaskInteraction({
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
}) as MaskSnapResult<TParams>;
|
||||
}
|
||||
|
||||
|
||||
+159
-14
@@ -1,5 +1,12 @@
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { CustomMaskPathPoint } from "@/lib/masks/custom-path";
|
||||
import type {
|
||||
TextDecoration,
|
||||
TextFontStyle,
|
||||
TextFontWeight,
|
||||
} from "@/lib/text/primitives";
|
||||
|
||||
export type MaskType =
|
||||
| "split"
|
||||
@@ -8,9 +15,11 @@ export type MaskType =
|
||||
| "ellipse"
|
||||
| "heart"
|
||||
| "diamond"
|
||||
| "star";
|
||||
| "star"
|
||||
| "text"
|
||||
| "custom";
|
||||
|
||||
export interface BaseMaskParams extends ParamValues {
|
||||
export interface BaseMaskParams {
|
||||
feather: number;
|
||||
inverted: boolean;
|
||||
strokeColor: string;
|
||||
@@ -33,6 +42,30 @@ export interface RectangleMaskParams extends BaseMaskParams {
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface TextMaskParams extends BaseMaskParams {
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: TextFontWeight;
|
||||
fontStyle: TextFontStyle;
|
||||
textDecoration: TextDecoration;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface CustomMaskParams extends BaseMaskParams {
|
||||
path: CustomMaskPathPoint[];
|
||||
closed: boolean;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface SplitMask {
|
||||
id: string;
|
||||
type: "split";
|
||||
@@ -75,6 +108,18 @@ export interface StarMask {
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface TextMask {
|
||||
id: string;
|
||||
type: "text";
|
||||
params: TextMaskParams;
|
||||
}
|
||||
|
||||
export interface CustomMask {
|
||||
id: string;
|
||||
type: "custom";
|
||||
params: CustomMaskParams;
|
||||
}
|
||||
|
||||
export type Mask =
|
||||
| SplitMask
|
||||
| CinematicBarsMask
|
||||
@@ -82,14 +127,16 @@ export type Mask =
|
||||
| EllipseMask
|
||||
| HeartMask
|
||||
| DiamondMask
|
||||
| StarMask;
|
||||
| StarMask
|
||||
| TextMask
|
||||
| CustomMask;
|
||||
|
||||
export interface MaskRenderer {
|
||||
buildPath(params: {
|
||||
buildPath?: (params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Path2D;
|
||||
}) => Path2D;
|
||||
buildStrokePath?: (params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
@@ -103,33 +150,94 @@ export interface MaskRenderer {
|
||||
height: number;
|
||||
feather: number;
|
||||
}) => void;
|
||||
renderMaskHandlesFeather?: boolean;
|
||||
renderStroke?: (params: {
|
||||
resolvedParams: unknown;
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export type MaskOverlayShape = "line" | "box";
|
||||
|
||||
export interface MaskFeatures {
|
||||
hasPosition: boolean;
|
||||
hasRotation: boolean;
|
||||
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
|
||||
}
|
||||
|
||||
export type MaskHandleIcon = "rotate" | "feather";
|
||||
|
||||
export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
|
||||
|
||||
export interface MaskHandlePosition {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
cursor: string;
|
||||
kind: MaskHandleKind;
|
||||
isSelected?: boolean;
|
||||
edgeAxis?: "horizontal" | "vertical";
|
||||
rotation?: number;
|
||||
icon?: MaskHandleIcon;
|
||||
}
|
||||
|
||||
export interface MaskLinePoints {
|
||||
export interface MaskLineOverlay {
|
||||
id: string;
|
||||
type: "line";
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export interface MaskRectOverlay {
|
||||
id: string;
|
||||
type: "rect";
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
dashed?: boolean;
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export interface MaskShapeOverlay {
|
||||
id: string;
|
||||
type: "shape";
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
pathData: string;
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export interface MaskCanvasPathOverlay {
|
||||
id: string;
|
||||
type: "canvas-path";
|
||||
pathData: string;
|
||||
coordinateSpace?: "canvas" | "overlay";
|
||||
cursor?: string;
|
||||
handleId?: string;
|
||||
strokeWidth?: number;
|
||||
strokeOpacity?: number;
|
||||
}
|
||||
|
||||
export type MaskOverlay =
|
||||
| MaskLineOverlay
|
||||
| MaskRectOverlay
|
||||
| MaskShapeOverlay
|
||||
| MaskCanvasPathOverlay;
|
||||
|
||||
export interface MaskDefaultContext {
|
||||
elementSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
export interface MaskParamUpdateArgs<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
handleId: string;
|
||||
startParams: TParams;
|
||||
deltaX: number;
|
||||
@@ -140,14 +248,51 @@ export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskPa
|
||||
canvasSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
handleId: string;
|
||||
startParams: TParams;
|
||||
proposedParams: TParams;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface MaskSnapResult<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
params: TParams;
|
||||
activeLines: SnapLine[];
|
||||
}
|
||||
|
||||
export interface MaskInteractionResult {
|
||||
handles: MaskHandlePosition[];
|
||||
overlays: MaskOverlay[];
|
||||
}
|
||||
|
||||
export interface MaskInteractionDefinition<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
getInteraction(args: {
|
||||
params: TParams;
|
||||
bounds: ElementBounds;
|
||||
displayScale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}): MaskInteractionResult;
|
||||
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
|
||||
}
|
||||
|
||||
export interface MaskDefinition<
|
||||
TParams extends BaseMaskParams = BaseMaskParams,
|
||||
> {
|
||||
type: MaskType;
|
||||
name: string;
|
||||
overlayShape: MaskOverlayShape;
|
||||
features: MaskFeatures;
|
||||
params: ParamDefinition<keyof TParams & string>[];
|
||||
renderer: MaskRenderer;
|
||||
buildOverlayPath?: (params: { width: number; height: number }) => string;
|
||||
interaction: MaskInteractionDefinition<TParams>;
|
||||
/** When defined and returning false, the mask is not applied and the element renders fully visible. */
|
||||
isActive?: (params: TParams) => boolean;
|
||||
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
|
||||
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
|
||||
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
export interface SelectedMaskPointSelection {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}
|
||||
|
||||
export interface EditorSelectionSnapshot {
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
keyframeSelectionAnchor: SelectedKeyframeRef | null;
|
||||
selectedMaskPoints: SelectedMaskPointSelection | null;
|
||||
}
|
||||
|
||||
export interface EditorSelectionPatch {
|
||||
selectedElements?: ElementRef[];
|
||||
selectedKeyframes?: SelectedKeyframeRef[];
|
||||
keyframeSelectionAnchor?: SelectedKeyframeRef | null;
|
||||
selectedMaskPoints?: SelectedMaskPointSelection | null;
|
||||
}
|
||||
|
||||
export type EditorSelectionKind = "mask-points" | "keyframes" | "elements";
|
||||
@@ -1,6 +1,7 @@
|
||||
export type ScopeEntry = {
|
||||
hasSelection: () => boolean;
|
||||
clear: () => void;
|
||||
clearActive?: () => void;
|
||||
};
|
||||
|
||||
let activeScope: ScopeEntry | null = null;
|
||||
@@ -24,6 +25,6 @@ export function clearActiveScope(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeScope.clear();
|
||||
(activeScope.clearActive ?? activeScope.clear)();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { CORNER_RADIUS_MIN } from "@/lib/text/background";
|
||||
import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography";
|
||||
import { resolveNumberAtTime } from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import type { TextBackground, TextElement } from "@/lib/timeline";
|
||||
import {
|
||||
measureTextBlock,
|
||||
setCanvasLetterSpacing,
|
||||
getTextVisualRect,
|
||||
type TextBlockMeasurement,
|
||||
} from "./layout";
|
||||
import {
|
||||
measureTextLayout,
|
||||
type MeasuredTextLayout,
|
||||
} from "./primitives";
|
||||
|
||||
export interface ResolvedTextBackground extends TextBackground {
|
||||
paddingX: number;
|
||||
@@ -18,15 +18,7 @@ export interface ResolvedTextBackground extends TextBackground {
|
||||
cornerRadius: number;
|
||||
}
|
||||
|
||||
export interface MeasuredTextElement {
|
||||
scaledFontSize: number;
|
||||
fontString: string;
|
||||
letterSpacing: number;
|
||||
lineHeightPx: number;
|
||||
lines: string[];
|
||||
lineMetrics: TextMetrics[];
|
||||
block: TextBlockMeasurement;
|
||||
fontSizeRatio: number;
|
||||
export interface MeasuredTextElement extends MeasuredTextLayout {
|
||||
resolvedBackground: ResolvedTextBackground;
|
||||
visualRect: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
@@ -75,28 +67,20 @@ export function measureTextElement({
|
||||
localTime: number;
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
}): MeasuredTextElement {
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
|
||||
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeightPx =
|
||||
scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
|
||||
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
|
||||
const lines = element.content.split("\n");
|
||||
|
||||
ctx.save();
|
||||
ctx.font = fontString;
|
||||
ctx.textBaseline = "middle";
|
||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
|
||||
const lineMetrics = lines.map((line) => ctx.measureText(line));
|
||||
ctx.restore();
|
||||
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
const measuredLayout = measureTextLayout({
|
||||
text: {
|
||||
content: element.content,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
textDecoration: element.textDecoration,
|
||||
letterSpacing: element.letterSpacing,
|
||||
lineHeight: element.lineHeight,
|
||||
},
|
||||
canvasHeight,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const bg = element.background;
|
||||
@@ -136,20 +120,13 @@ export function measureTextElement({
|
||||
|
||||
const visualRect = getTextVisualRect({
|
||||
textAlign: element.textAlign,
|
||||
block,
|
||||
block: measuredLayout.block,
|
||||
background: resolvedBackground,
|
||||
fontSizeRatio,
|
||||
fontSizeRatio: measuredLayout.fontSizeRatio,
|
||||
});
|
||||
|
||||
return {
|
||||
scaledFontSize,
|
||||
fontString,
|
||||
letterSpacing,
|
||||
lineHeightPx,
|
||||
lines,
|
||||
lineMetrics,
|
||||
block,
|
||||
fontSizeRatio,
|
||||
...measuredLayout,
|
||||
resolvedBackground,
|
||||
visualRect,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { TextCanvasContext, TextBlockMeasurement } from "@/lib/text/layout";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "./background";
|
||||
import {
|
||||
drawTextDecoration,
|
||||
getTextBackgroundRect,
|
||||
measureTextBlock,
|
||||
setCanvasLetterSpacing,
|
||||
} from "./layout";
|
||||
import { FONT_SIZE_SCALE_REFERENCE } from "./typography";
|
||||
|
||||
export type TextAlign = "left" | "center" | "right";
|
||||
export type TextFontWeight = "normal" | "bold";
|
||||
export type TextFontStyle = "normal" | "italic";
|
||||
export type TextDecoration = "none" | "underline" | "line-through";
|
||||
|
||||
export interface TextLayoutParams {
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: TextFontWeight;
|
||||
fontStyle: TextFontStyle;
|
||||
textAlign: TextAlign;
|
||||
textDecoration?: TextDecoration;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedTextLayout {
|
||||
scaledFontSize: number;
|
||||
fontString: string;
|
||||
letterSpacing: number;
|
||||
lineHeightPx: number;
|
||||
fontSizeRatio: number;
|
||||
textAlign: TextAlign;
|
||||
textDecoration: TextDecoration;
|
||||
}
|
||||
|
||||
export interface MeasuredTextLayout extends ResolvedTextLayout {
|
||||
lines: string[];
|
||||
lineMetrics: TextMetrics[];
|
||||
block: TextBlockMeasurement;
|
||||
}
|
||||
|
||||
export interface ResolvedTextBackgroundLike {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
cornerRadius: number;
|
||||
}
|
||||
|
||||
export function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
|
||||
return `"${fontFamily.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function buildTextFontString({
|
||||
fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
scaledFontSize,
|
||||
}: {
|
||||
fontFamily: string;
|
||||
fontWeight: TextFontWeight;
|
||||
fontStyle: TextFontStyle;
|
||||
scaledFontSize: number;
|
||||
}): string {
|
||||
return `${fontStyle} ${fontWeight} ${scaledFontSize}px ${quoteFontFamily({ fontFamily })}, sans-serif`;
|
||||
}
|
||||
|
||||
export function resolveTextLayout({
|
||||
text,
|
||||
canvasHeight,
|
||||
}: {
|
||||
text: TextLayoutParams;
|
||||
canvasHeight: number;
|
||||
}): ResolvedTextLayout {
|
||||
const scaledFontSize =
|
||||
text.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
const fontWeight = text.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = text.fontStyle === "italic" ? "italic" : "normal";
|
||||
const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
|
||||
const lineHeightPx =
|
||||
scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
|
||||
const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize;
|
||||
|
||||
return {
|
||||
scaledFontSize,
|
||||
fontString: buildTextFontString({
|
||||
fontFamily: text.fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
scaledFontSize,
|
||||
}),
|
||||
letterSpacing,
|
||||
lineHeightPx,
|
||||
fontSizeRatio,
|
||||
textAlign: text.textAlign,
|
||||
textDecoration: text.textDecoration ?? "none",
|
||||
};
|
||||
}
|
||||
|
||||
export function measureTextLayout({
|
||||
text,
|
||||
canvasHeight,
|
||||
ctx,
|
||||
}: {
|
||||
text: TextLayoutParams;
|
||||
canvasHeight: number;
|
||||
ctx: TextCanvasContext;
|
||||
}): MeasuredTextLayout {
|
||||
const resolvedLayout = resolveTextLayout({ text, canvasHeight });
|
||||
const lines = text.content.split("\n");
|
||||
|
||||
ctx.save();
|
||||
ctx.font = resolvedLayout.fontString;
|
||||
ctx.textBaseline = "middle";
|
||||
setCanvasLetterSpacing({
|
||||
ctx,
|
||||
letterSpacingPx: resolvedLayout.letterSpacing,
|
||||
});
|
||||
const lineMetrics = lines.map((line) => ctx.measureText(line));
|
||||
ctx.restore();
|
||||
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx: resolvedLayout.lineHeightPx,
|
||||
});
|
||||
|
||||
return {
|
||||
...resolvedLayout,
|
||||
lines,
|
||||
lineMetrics,
|
||||
block,
|
||||
};
|
||||
}
|
||||
|
||||
export function drawMeasuredTextLayout({
|
||||
ctx,
|
||||
layout,
|
||||
textColor,
|
||||
background,
|
||||
backgroundColor,
|
||||
textBaseline = "middle",
|
||||
}: {
|
||||
ctx: TextCanvasContext;
|
||||
layout: MeasuredTextLayout;
|
||||
textColor: string;
|
||||
background?: ResolvedTextBackgroundLike | null;
|
||||
backgroundColor?: string;
|
||||
textBaseline?: CanvasTextBaseline;
|
||||
}): void {
|
||||
ctx.font = layout.fontString;
|
||||
ctx.textAlign = layout.textAlign;
|
||||
ctx.textBaseline = textBaseline;
|
||||
ctx.fillStyle = textColor;
|
||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
|
||||
|
||||
if (
|
||||
background?.enabled &&
|
||||
backgroundColor &&
|
||||
backgroundColor !== "transparent" &&
|
||||
layout.lines.length > 0
|
||||
) {
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: layout.textAlign,
|
||||
block: layout.block,
|
||||
background: {
|
||||
...background,
|
||||
color: backgroundColor,
|
||||
},
|
||||
fontSizeRatio: layout.fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
const p =
|
||||
clamp({
|
||||
value: background.cornerRadius,
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
}) / 100;
|
||||
const radius =
|
||||
(Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
backgroundRect.left,
|
||||
backgroundRect.top,
|
||||
backgroundRect.width,
|
||||
backgroundRect.height,
|
||||
radius,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = textColor;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < layout.lines.length; index++) {
|
||||
const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
|
||||
ctx.fillText(layout.lines[index], 0, lineY);
|
||||
drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration: layout.textDecoration,
|
||||
lineWidth: layout.lineMetrics[index].width,
|
||||
lineY,
|
||||
metrics: layout.lineMetrics[index],
|
||||
scaledFontSize: layout.scaledFontSize,
|
||||
textAlign: layout.textAlign,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function strokeMeasuredTextLayout({
|
||||
ctx,
|
||||
layout,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
textBaseline = "middle",
|
||||
}: {
|
||||
ctx: TextCanvasContext;
|
||||
layout: MeasuredTextLayout;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
textBaseline?: CanvasTextBaseline;
|
||||
}): void {
|
||||
ctx.font = layout.fontString;
|
||||
ctx.textAlign = layout.textAlign;
|
||||
ctx.textBaseline = textBaseline;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
|
||||
|
||||
for (let index = 0; index < layout.lines.length; index++) {
|
||||
const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
|
||||
ctx.strokeText(layout.lines[index], 0, lineY);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,20 @@ export function resolveEffectiveAudioGain({
|
||||
return dBToLinear(resolvedDb);
|
||||
}
|
||||
|
||||
export function buildWaveformGainSamples({
|
||||
element,
|
||||
count,
|
||||
}: {
|
||||
element: AudioCapableElement;
|
||||
count: number;
|
||||
}): number[] {
|
||||
const durationSeconds = element.duration / TICKS_PER_SECOND;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const localTime = ((i + 0.5) / count) * durationSeconds;
|
||||
return resolveEffectiveAudioGain({ element, localTime });
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAudioGainAutomation({
|
||||
element,
|
||||
trackMuted = false,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
type TextElement,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
type TimelineTrack,
|
||||
type AudioElement,
|
||||
type VideoElement,
|
||||
type ImageElement,
|
||||
@@ -414,6 +413,13 @@ export function getElementFontFamilies({
|
||||
if (element.type === "text" && element.fontFamily) {
|
||||
families.add(element.fontFamily);
|
||||
}
|
||||
if ("masks" in element) {
|
||||
for (const mask of element.masks ?? []) {
|
||||
if (mask.type === "text" && mask.params.fontFamily) {
|
||||
families.add(mask.params.fontFamily);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...families];
|
||||
|
||||
@@ -401,6 +401,11 @@ function buildMaskArtifacts({
|
||||
}
|
||||
|
||||
const definition = masksRegistry.get(mask.type);
|
||||
|
||||
if (definition.isActive?.(mask.params) === false) {
|
||||
return { mask: null, strokeLayer: null };
|
||||
}
|
||||
|
||||
const elementMaskCanvas = createOffscreenCanvas({
|
||||
width: Math.round(transform.width),
|
||||
height: Math.round(transform.height),
|
||||
@@ -416,7 +421,12 @@ function buildMaskArtifacts({
|
||||
|
||||
let strokePath: Path2D | null = null;
|
||||
let feather = mask.params.feather;
|
||||
if (mask.params.feather > 0 && definition.renderer.renderMask) {
|
||||
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
|
||||
const shouldRenderMaskDirectly =
|
||||
canRenderMaskDirectly &&
|
||||
(!definition.renderer.buildPath ||
|
||||
(mask.params.feather > 0 && definition.renderer.renderMaskHandlesFeather));
|
||||
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
|
||||
definition.renderer.renderMask({
|
||||
resolvedParams: mask.params,
|
||||
ctx: elementMaskCtx,
|
||||
@@ -424,13 +434,18 @@ function buildMaskArtifacts({
|
||||
height: Math.round(transform.height),
|
||||
feather: mask.params.feather,
|
||||
});
|
||||
if (definition.renderer.renderMaskHandlesFeather) {
|
||||
feather = 0;
|
||||
}
|
||||
strokePath = definition.renderer.buildStrokePath?.({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
}) ?? null;
|
||||
} else {
|
||||
if (!definition.renderer.buildPath) {
|
||||
return { mask: null, strokeLayer: null };
|
||||
}
|
||||
const path2d = definition.renderer.buildPath({
|
||||
resolvedParams: mask.params,
|
||||
width: transform.width,
|
||||
@@ -472,7 +487,7 @@ function buildMaskArtifacts({
|
||||
});
|
||||
|
||||
let strokeLayer: FrameItemDescriptor | null = null;
|
||||
if (mask.params.strokeWidth > 0 && strokePath) {
|
||||
if (mask.params.strokeWidth > 0 && (strokePath || definition.renderer.renderStroke)) {
|
||||
const strokeCanvas = createOffscreenCanvas({
|
||||
width: Math.round(transform.width),
|
||||
height: Math.round(transform.height),
|
||||
@@ -482,9 +497,18 @@ function buildMaskArtifacts({
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (strokeCtx) {
|
||||
if (definition.renderer.renderStroke) {
|
||||
definition.renderer.renderStroke({
|
||||
resolvedParams: mask.params,
|
||||
ctx: strokeCtx,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
});
|
||||
} else if (strokePath) {
|
||||
strokeCtx.strokeStyle = mask.params.strokeColor;
|
||||
strokeCtx.lineWidth = mask.params.strokeWidth;
|
||||
strokeCtx.stroke(strokePath);
|
||||
}
|
||||
|
||||
const fullStrokeCanvas = createOffscreenCanvas({
|
||||
width: renderer.width,
|
||||
|
||||
@@ -3,16 +3,9 @@ import type { TextElement } from "@/lib/timeline";
|
||||
import type { EffectPass } from "@/lib/effects/types";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/lib/text/background";
|
||||
import {
|
||||
drawTextDecoration,
|
||||
getTextBackgroundRect,
|
||||
setCanvasLetterSpacing,
|
||||
} from "@/lib/text/layout";
|
||||
drawMeasuredTextLayout,
|
||||
} from "@/lib/text/primitives";
|
||||
import type { MeasuredTextElement } from "@/lib/text/measure-element";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
export type TextNodeParams = TextElement & {
|
||||
canvasCenter: { x: number; y: number };
|
||||
@@ -46,22 +39,6 @@ export function renderTextToContext({
|
||||
const x = resolved.transform.position.x + node.params.canvasCenter.x;
|
||||
const y = resolved.transform.position.y + node.params.canvasCenter.y;
|
||||
const baseline = node.params.textBaseline ?? "middle";
|
||||
const {
|
||||
scaledFontSize,
|
||||
fontString,
|
||||
letterSpacing,
|
||||
lineHeightPx,
|
||||
lines,
|
||||
lineMetrics,
|
||||
block,
|
||||
fontSizeRatio,
|
||||
resolvedBackground,
|
||||
} = resolved.measuredText;
|
||||
const lineCount = lines.length;
|
||||
const resolvedBackgroundWithColor = {
|
||||
...resolvedBackground,
|
||||
color: resolved.backgroundColor,
|
||||
};
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
@@ -70,60 +47,14 @@ export function renderTextToContext({
|
||||
ctx.rotate((resolved.transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
|
||||
ctx.font = fontString;
|
||||
ctx.textAlign = node.params.textAlign;
|
||||
ctx.textBaseline = baseline;
|
||||
ctx.fillStyle = resolved.textColor;
|
||||
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
|
||||
|
||||
if (
|
||||
node.params.background.enabled &&
|
||||
node.params.background.color &&
|
||||
node.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: node.params.textAlign,
|
||||
block,
|
||||
background: resolvedBackgroundWithColor,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
const p =
|
||||
clamp({
|
||||
value: resolvedBackgroundWithColor.cornerRadius,
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
}) / 100;
|
||||
const radius =
|
||||
(Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
|
||||
ctx.fillStyle = resolvedBackgroundWithColor.color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
backgroundRect.left,
|
||||
backgroundRect.top,
|
||||
backgroundRect.width,
|
||||
backgroundRect.height,
|
||||
radius,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = resolved.textColor;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < lineCount; index++) {
|
||||
const lineY = index * lineHeightPx - block.visualCenterOffset;
|
||||
ctx.fillText(lines[index], 0, lineY);
|
||||
drawTextDecoration({
|
||||
drawMeasuredTextLayout({
|
||||
ctx,
|
||||
textDecoration: node.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[index].width,
|
||||
lineY,
|
||||
metrics: lineMetrics[index],
|
||||
scaledFontSize,
|
||||
textAlign: node.params.textAlign,
|
||||
layout: resolved.measuredText,
|
||||
textColor: resolved.textColor,
|
||||
background: resolved.measuredText.resolvedBackground,
|
||||
backgroundColor: resolved.backgroundColor,
|
||||
textBaseline: baseline,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV26ToV27 } from "../transformers/v26-to-v27";
|
||||
|
||||
describe("V26 to V27 Migration", () => {
|
||||
test("converts custom mask paths from JSON strings to typed point arrays", () => {
|
||||
const result = transformProjectV26ToV27({
|
||||
project: {
|
||||
id: "project-v26-custom-mask",
|
||||
version: 26,
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-1",
|
||||
tracks: {
|
||||
main: {
|
||||
id: "track-1",
|
||||
type: "video",
|
||||
elements: [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "image",
|
||||
masks: [
|
||||
{
|
||||
id: "mask-custom",
|
||||
type: "custom",
|
||||
params: {
|
||||
path: JSON.stringify([
|
||||
{
|
||||
id: "point-1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0.1,
|
||||
outY: 0,
|
||||
},
|
||||
]),
|
||||
closed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mask-rectangle",
|
||||
type: "rectangle",
|
||||
params: {
|
||||
width: 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overlay: [],
|
||||
audio: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(27);
|
||||
|
||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
||||
const tracks = scenes[0].tracks as Record<string, unknown>;
|
||||
const mainTrack = tracks.main as Record<string, unknown>;
|
||||
const elements = mainTrack.elements as Array<Record<string, unknown>>;
|
||||
const masks = elements[0].masks as Array<Record<string, unknown>>;
|
||||
const customParams = masks[0].params as Record<string, unknown>;
|
||||
const rectangleParams = masks[1].params as Record<string, unknown>;
|
||||
|
||||
expect(customParams.path).toEqual([
|
||||
{
|
||||
id: "point-1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
inX: 0,
|
||||
inY: 0,
|
||||
outX: 0.1,
|
||||
outY: 0,
|
||||
},
|
||||
]);
|
||||
expect(rectangleParams.width).toBe(0.5);
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,11 @@ import { V22toV23Migration } from "./v22-to-v23";
|
||||
import { V23toV24Migration } from "./v23-to-v24";
|
||||
import { V24toV25Migration } from "./v24-to-v25";
|
||||
import { V25toV26Migration } from "./v25-to-v26";
|
||||
import { V26toV27Migration } from "./v26-to-v27";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 26;
|
||||
export const CURRENT_PROJECT_VERSION = 27;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -57,4 +58,5 @@ export const migrations = [
|
||||
new V23toV24Migration(),
|
||||
new V24toV25Migration(),
|
||||
new V25toV26Migration(),
|
||||
new V26toV27Migration(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { parseCustomMaskPath } from "@/lib/masks/custom-path";
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV26ToV27({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
if (!getProjectId({ project })) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
const version = project.version;
|
||||
if (typeof version !== "number") {
|
||||
return { project, skipped: true, reason: "invalid version" };
|
||||
}
|
||||
if (version >= 27) {
|
||||
return { project, skipped: true, reason: "already v27" };
|
||||
}
|
||||
if (version !== 26) {
|
||||
return { project, skipped: true, reason: "not v26" };
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migrateProject({ project }),
|
||||
version: 27,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProject({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
if (!Array.isArray(project.scenes)) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
scenes: project.scenes.map((scene) => migrateScene({ scene })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateScene({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene) || !isRecord(scene.tracks)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
const tracks = scene.tracks;
|
||||
const nextTracks: ProjectRecord = { ...tracks };
|
||||
|
||||
if (isRecord(tracks.main)) {
|
||||
nextTracks.main = migrateTrack({ track: tracks.main });
|
||||
}
|
||||
|
||||
if (Array.isArray(tracks.overlay)) {
|
||||
nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
|
||||
}
|
||||
|
||||
if (Array.isArray(tracks.audio)) {
|
||||
nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
|
||||
}
|
||||
|
||||
return {
|
||||
...scene,
|
||||
tracks: nextTracks,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateTrack({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track) || !Array.isArray(track.elements)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) => migrateElement({ element })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateElement({ element }: { element: unknown }): unknown {
|
||||
if (!isRecord(element) || !Array.isArray(element.masks)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
masks: element.masks.map((mask) => migrateMask({ mask })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateMask({ mask }: { mask: unknown }): unknown {
|
||||
if (!isRecord(mask) || mask.type !== "custom" || !isRecord(mask.params)) {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const path = mask.params.path;
|
||||
if (typeof path !== "string") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
return {
|
||||
...mask,
|
||||
params: {
|
||||
...mask.params,
|
||||
path: parseCustomMaskPath({ path }),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV26ToV27 } from "./transformers/v26-to-v27";
|
||||
|
||||
export class V26toV27Migration extends StorageMigration {
|
||||
from = 26;
|
||||
to = 27;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV26ToV27({ project });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user