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

This commit is contained in:
Maze Winther
2026-04-15 15:45:50 +02:00
parent 66e4367bc4
commit c46d28891f
53 changed files with 8082 additions and 3636 deletions
@@ -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} />
+197 -106
View File
@@ -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 };
+17 -17
View File
@@ -1,12 +1,13 @@
import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
import type { EditorSelectionSnapshot } from "@/lib/selection/editor-selection";
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
import type { SceneTracks } from "@/lib/timeline/types";
interface CommandHistoryEntry {
command: Command;
previousSelection: ElementRef[];
selectionOverride?: ElementRef[];
previousSelection: EditorSelectionSnapshot;
selectionOverride?: EditorSelectionSnapshot;
}
export class CommandManager {
@@ -19,7 +20,7 @@ export class CommandManager {
execute({ command }: { command: Command }): Command {
const beforeTracks = this.isRippleEnabled
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null;
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
@@ -55,11 +56,11 @@ export class CommandManager {
// Only restore selection for commands that explicitly changed it.
// Commands without selection intent leave selection untouched,
// preserving any UI-driven selection changes (clicks, box select)
// that happened between commands. Commands that remove elements
// must declare { select: [] } to clear stale refs.
// that happened between commands. Commands that remove editor-owned
// selection targets must declare a selection override to clear stale refs.
if (entry.selectionOverride !== undefined) {
this.editor.selection.setSelectedElements({
elements: [...entry.previousSelection],
this.editor.selection.restoreSnapshot({
snapshot: entry.previousSelection,
});
}
this.redoStack.push(entry);
@@ -74,7 +75,7 @@ export class CommandManager {
}
const beforeTracks = this.isRippleEnabled
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
? (this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null)
: null;
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
@@ -102,20 +103,19 @@ export class CommandManager {
this.redoStack = [];
}
private getSelectionSnapshot(): ElementRef[] {
return [...this.editor.selection.getSelectedElements()];
private getSelectionSnapshot(): EditorSelectionSnapshot {
return this.editor.selection.getSnapshot();
}
private applySelectionOverride(
result: CommandResult | undefined,
): ElementRef[] | undefined {
if (result?.select === undefined) {
): EditorSelectionSnapshot | undefined {
if (!result?.selection) {
return undefined;
}
const selectionOverride = [...result.select];
this.editor.selection.setSelectedElements({ elements: selectionOverride });
return selectionOverride;
return this.editor.selection.applySelectionPatch({
patch: result.selection,
});
}
private runReactors(): void {
@@ -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,
+413 -40
View File
@@ -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,
+1 -1
View File
@@ -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."
+2 -2
View File
@@ -1,7 +1,7 @@
import type { ElementRef } from "@/lib/timeline/types";
import type { EditorSelectionPatch } from "@/lib/selection/editor-selection";
export interface CommandResult {
select?: ElementRef[];
selection?: EditorSelectionPatch;
}
export abstract class Command {
@@ -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);
}
}
}
+251 -1
View File
@@ -1,9 +1,26 @@
import { describe, expect, test } from "bun:test";
import {
findClosestPointOnCustomMaskSegment,
getCustomMaskClosedStateAfterPointRemoval,
insertPointIntoCustomMaskSegment,
removeCustomMaskPoints,
} from "@/lib/masks/custom-path";
import {
appendPointToCustomMask,
customMaskDefinition,
insertPointOnCustomMaskSegment,
} from "@/lib/masks/definitions/custom";
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
import { textMaskDefinition } from "@/lib/masks/definitions/text";
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
import { snapMaskInteraction } from "@/lib/masks/snap";
import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
import type {
CustomMaskParams,
RectangleMaskParams,
SplitMaskParams,
TextMaskParams,
} from "@/lib/masks/types";
const bounds: ElementBounds = {
cx: 200,
@@ -58,6 +75,78 @@ function buildRectangleParams(
};
}
function buildTextMaskParams(
overrides: Partial<TextMaskParams> = {},
): TextMaskParams {
return {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
strokeAlign: "center",
content: "Mask",
fontSize: 15,
fontFamily: "Arial",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
letterSpacing: 0,
lineHeight: 1.2,
centerX: 0,
centerY: 0,
rotation: 0,
scale: 1,
...overrides,
};
}
function buildCustomMaskParams(
overrides: Partial<CustomMaskParams> = {},
): CustomMaskParams {
return {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
strokeAlign: "center",
path: [
{
id: "a",
x: -0.2,
y: -0.1,
inX: 0,
inY: 0,
outX: 0,
outY: 0,
},
{
id: "b",
x: 0.2,
y: -0.1,
inX: 0,
inY: 0,
outX: 0,
outY: 0,
},
{
id: "c",
x: 0,
y: 0.2,
inX: 0,
inY: 0,
outX: 0,
outY: 0,
},
],
closed: true,
centerX: 0,
centerY: 0,
rotation: 0,
scale: 1,
...overrides,
};
}
function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] {
@@ -234,4 +323,165 @@ describe("mask snapping", () => {
expect(result.params.height).toBe(0.5);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
test("snaps text mask movement using intrinsic text bounds", () => {
const params = buildTextMaskParams({
centerX: 0.03,
centerY: -0.04,
});
const result = textMaskDefinition.interaction.snap?.({
handleId: "position",
startParams: params,
proposedParams: params,
bounds,
canvasSize,
snapThreshold,
});
expect(result?.params.centerX).toBe(0);
expect(result?.params.centerY).toBe(0);
expect(result?.activeLines).toEqual([
{ type: "vertical", position: 0 },
{ type: "horizontal", position: 0 },
]);
});
test("snaps custom mask movement using path geometry bounds", () => {
const params = buildCustomMaskParams({
centerX: 0.03,
centerY: -0.04,
});
const result = customMaskDefinition.interaction.snap?.({
handleId: "position",
startParams: params,
proposedParams: params,
bounds,
canvasSize,
snapThreshold,
});
expect(result?.params.centerX).toBe(0);
expect(result?.params.centerY).toBe(0);
expect(result?.activeLines).toEqual([
{ type: "vertical", position: 0 },
{ type: "horizontal", position: 0 },
]);
});
test("marks blank text masks inactive", () => {
expect(
textMaskDefinition.isActive?.(
buildTextMaskParams({
content: " ",
}),
),
).toBe(false);
});
});
describe("custom mask creation", () => {
test("anchors the first point at the click position", () => {
const params = buildCustomMaskParams({
path: [],
closed: false,
});
const next = appendPointToCustomMask({
params,
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
bounds,
});
expect(next.centerX).toBeCloseTo(0.1);
expect(next.centerY).toBeCloseTo(-0.1);
expect(next.rotation).toBe(0);
expect(next.scale).toBe(1);
expect(next.path).toHaveLength(1);
});
});
describe("custom mask point deletion", () => {
test("removes the selected points by id", () => {
const points = buildCustomMaskParams().path;
const nextPoints = removeCustomMaskPoints({
points,
pointIds: ["b"],
});
expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
});
test("reopens a closed path once fewer than three points remain", () => {
const points = buildCustomMaskParams().path;
const nextPoints = removeCustomMaskPoints({
points,
pointIds: ["c"],
});
expect(
getCustomMaskClosedStateAfterPointRemoval({
wasClosed: true,
remainingPointCount: nextPoints.length,
}),
).toBe(false);
});
});
describe("custom mask point insertion", () => {
test("finds the closest point on the clicked segment", () => {
const params = buildCustomMaskParams();
const points = params.path;
const closestPoint = findClosestPointOnCustomMaskSegment({
points,
segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
closed: params.closed,
});
expect(closestPoint).not.toBeNull();
expect(closestPoint?.t).toBeCloseTo(0.5, 1);
expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
});
test("splits a segment into two segments at the insertion point", () => {
const points = buildCustomMaskParams().path;
const nextPoints = insertPointIntoCustomMaskSegment({
points,
segmentIndex: 0,
pointId: "new",
t: 0.5,
closed: true,
});
expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
expect(nextPoints[1]).toMatchObject({
id: "new",
x: 0,
y: -0.1,
inX: 0,
inY: 0,
outX: 0,
outY: 0,
});
});
test("builds updated custom mask params for a clicked segment", () => {
const result = insertPointOnCustomMaskSegment({
params: buildCustomMaskParams(),
segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
bounds,
pointId: "new",
});
expect(result).not.toBeNull();
const nextPoints = result?.params.path ?? [];
expect(nextPoints).toHaveLength(4);
expect(nextPoints.some((point) => point.id === "new")).toBe(true);
});
});
+842
View File
@@ -0,0 +1,842 @@
import type { ElementBounds } from "@/lib/preview/element-bounds";
export interface CustomMaskPathPoint {
id: string;
x: number;
y: number;
inX: number;
inY: number;
outX: number;
outY: number;
}
export type CustomMaskHandlePart = "anchor" | "in" | "out";
function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.x === "number" &&
typeof candidate.y === "number" &&
typeof candidate.inX === "number" &&
typeof candidate.inY === "number" &&
typeof candidate.outX === "number" &&
typeof candidate.outY === "number"
);
}
export function parseCustomMaskHandleId({
handleId,
}: {
handleId: string;
}): { pointId: string; part: CustomMaskHandlePart } | null {
const match = /^point:(.+):(anchor|in|out)$/.exec(handleId);
if (!match) {
return null;
}
return {
pointId: match[1],
part: match[2] as CustomMaskHandlePart,
};
}
export function parseCustomMaskSegmentHandleId({
handleId,
}: {
handleId: string;
}): { segmentIndex: number } | null {
const match = /^segment:(\d+)$/.exec(handleId);
if (!match) {
return null;
}
return {
segmentIndex: Number.parseInt(match[1], 10),
};
}
export function parseCustomMaskPath({
path,
}: {
path: string;
}): CustomMaskPathPoint[] {
if (!path) {
return [];
}
try {
const parsed = JSON.parse(path);
return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : [];
} catch {
return [];
}
}
export function serializeCustomMaskPath({
points,
}: {
points: CustomMaskPathPoint[];
}): string {
return JSON.stringify(points);
}
export function removeCustomMaskPoints({
points,
pointIds,
}: {
points: CustomMaskPathPoint[];
pointIds: string[];
}): CustomMaskPathPoint[] {
if (pointIds.length === 0) {
return points;
}
const pointIdsToRemove = new Set(pointIds);
return points.filter((point) => !pointIdsToRemove.has(point.id));
}
export function getCustomMaskClosedStateAfterPointRemoval({
wasClosed,
remainingPointCount,
}: {
wasClosed: boolean;
remainingPointCount: number;
}): boolean {
return wasClosed && remainingPointCount >= 3;
}
function rotatePoint({
x,
y,
rotationDegrees,
}: {
x: number;
y: number;
rotationDegrees: number;
}): { x: number; y: number } {
const angleRad = (rotationDegrees * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
return {
x: x * cos - y * sin,
y: x * sin + y * cos,
};
}
export function getCustomMaskCenterCanvasPoint({
centerX,
centerY,
bounds,
}: {
centerX: number;
centerY: number;
bounds: ElementBounds;
}): { x: number; y: number } {
return {
x: bounds.cx + centerX * bounds.width,
y: bounds.cy + centerY * bounds.height,
};
}
export function customMaskLocalPointToCanvas({
point,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
point: { x: number; y: number };
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}): { x: number; y: number } {
const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
const scaledLocal = {
x: point.x * bounds.width * scale,
y: point.y * bounds.height * scale,
};
const rotated = rotatePoint({
x: scaledLocal.x,
y: scaledLocal.y,
rotationDegrees: rotation,
});
return {
x: center.x + rotated.x,
y: center.y + rotated.y,
};
}
export function customMaskCanvasPointToLocal({
point,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
point: { x: number; y: number };
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}): { x: number; y: number } {
const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
const translated = {
x: point.x - center.x,
y: point.y - center.y,
};
const rotated = rotatePoint({
x: translated.x,
y: translated.y,
rotationDegrees: -rotation,
});
return {
x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale),
y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale),
};
}
export function getCustomMaskCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
points: CustomMaskPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}) {
const anchors = points.map((point) => ({
id: point.id,
anchor: customMaskLocalPointToCanvas({
point: { x: point.x, y: point.y },
centerX,
centerY,
rotation,
scale,
bounds,
}),
inHandle: customMaskLocalPointToCanvas({
point: { x: point.x + point.inX, y: point.y + point.inY },
centerX,
centerY,
rotation,
scale,
bounds,
}),
outHandle: customMaskLocalPointToCanvas({
point: { x: point.x + point.outX, y: point.y + point.outY },
centerX,
centerY,
rotation,
scale,
bounds,
}),
}));
const geometryPoints = anchors.flatMap((point) => [
point.anchor,
point.inHandle,
point.outHandle,
]);
if (geometryPoints.length === 0) {
return {
anchors,
bounds: null,
};
}
const geometryBounds = getCanvasPointBounds({
points: geometryPoints,
});
return {
anchors,
bounds: geometryBounds,
};
}
export interface CanvasPoint {
x: number;
y: number;
}
function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): {
minX: number;
maxX: number;
minY: number;
maxY: number;
width: number;
height: number;
centerX: number;
centerY: number;
} | null {
if (points.length === 0) {
return null;
}
const [firstPoint, ...restPoints] = points;
let minX = firstPoint.x;
let maxX = firstPoint.x;
let minY = firstPoint.y;
let maxY = firstPoint.y;
for (const point of restPoints) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return {
minX,
maxX,
minY,
maxY,
width: Math.max(1, maxX - minX),
height: Math.max(1, maxY - minY),
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
};
}
export interface CustomMaskCanvasSegment {
index: number;
startPointId: string;
endPointId: string;
start: CanvasPoint;
startOut: CanvasPoint;
endIn: CanvasPoint;
end: CanvasPoint;
pathData: string;
}
function clampUnit(value: number): number {
return Math.min(1, Math.max(0, value));
}
function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
}
function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
};
}
function evaluateCubicBezier({
p0,
p1,
p2,
p3,
t,
}: {
p0: CanvasPoint;
p1: CanvasPoint;
p2: CanvasPoint;
p3: CanvasPoint;
t: number;
}): CanvasPoint {
const oneMinusT = 1 - t;
return {
x:
oneMinusT ** 3 * p0.x +
3 * oneMinusT ** 2 * t * p1.x +
3 * oneMinusT * t ** 2 * p2.x +
t ** 3 * p3.x,
y:
oneMinusT ** 3 * p0.y +
3 * oneMinusT ** 2 * t * p1.y +
3 * oneMinusT * t ** 2 * p2.y +
t ** 3 * p3.y,
};
}
function getCustomMaskSegmentIndices({
points,
segmentIndex,
closed,
}: {
points: CustomMaskPathPoint[];
segmentIndex: number;
closed: boolean;
}): { startIndex: number; endIndex: number } | null {
const segmentCount = getCustomMaskSegmentCount({ points, closed });
if (segmentIndex < 0 || segmentIndex >= segmentCount) {
return null;
}
return {
startIndex: segmentIndex,
endIndex: (segmentIndex + 1) % points.length,
};
}
export function getCustomMaskSegmentCount({
points,
closed,
}: {
points: CustomMaskPathPoint[];
closed: boolean;
}): number {
if (points.length < 2) {
return 0;
}
return closed ? points.length : points.length - 1;
}
export function getCustomMaskCanvasSegments({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: CustomMaskPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): CustomMaskCanvasSegment[] {
const geometry = getCustomMaskCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
const segmentCount = getCustomMaskSegmentCount({ points, closed });
return Array.from({ length: segmentCount }, (_, segmentIndex) => {
const start = geometry.anchors[segmentIndex];
const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length];
return {
index: segmentIndex,
startPointId: start.id,
endPointId: end.id,
start: start.anchor,
startOut: start.outHandle,
endIn: end.inHandle,
end: end.anchor,
pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`,
};
});
}
export function findClosestPointOnCustomMaskSegment({
points,
segmentIndex,
canvasPoint,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: CustomMaskPathPoint[];
segmentIndex: number;
canvasPoint: CanvasPoint;
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): { t: number; point: CanvasPoint } | null {
const segment = getCustomMaskCanvasSegments({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}).find((candidate) => candidate.index === segmentIndex);
if (!segment) {
return null;
}
const sampleCount = 24;
let bestT = 0;
let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start);
for (let step = 0; step <= sampleCount; step++) {
const t = step / sampleCount;
const point = evaluateCubicBezier({
p0: segment.start,
p1: segment.startOut,
p2: segment.endIn,
p3: segment.end,
t,
});
const distanceSquared = getDistanceSquared(canvasPoint, point);
if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared;
bestT = t;
}
}
let searchStep = 1 / sampleCount;
for (let iteration = 0; iteration < 8; iteration++) {
const candidates = [bestT - searchStep, bestT, bestT + searchStep]
.map(clampUnit)
.map((t) => ({
t,
point: evaluateCubicBezier({
p0: segment.start,
p1: segment.startOut,
p2: segment.endIn,
p3: segment.end,
t,
}),
}));
for (const candidate of candidates) {
const distanceSquared = getDistanceSquared(canvasPoint, candidate.point);
if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared;
bestT = candidate.t;
}
}
searchStep /= 2;
}
const clampedT = Math.min(0.999, Math.max(0.001, bestT));
return {
t: clampedT,
point: evaluateCubicBezier({
p0: segment.start,
p1: segment.startOut,
p2: segment.endIn,
p3: segment.end,
t: clampedT,
}),
};
}
export function insertPointIntoCustomMaskSegment({
points,
segmentIndex,
pointId,
t,
closed,
}: {
points: CustomMaskPathPoint[];
segmentIndex: number;
pointId: string;
t: number;
closed: boolean;
}): CustomMaskPathPoint[] {
const indices = getCustomMaskSegmentIndices({
points,
segmentIndex,
closed,
});
if (!indices) {
return points;
}
const startPoint = points[indices.startIndex];
const endPoint = points[indices.endIndex];
const clampedT = Math.min(0.999, Math.max(0.001, t));
const p0 = { x: startPoint.x, y: startPoint.y };
const p1 = {
x: startPoint.x + startPoint.outX,
y: startPoint.y + startPoint.outY,
};
const p2 = {
x: endPoint.x + endPoint.inX,
y: endPoint.y + endPoint.inY,
};
const p3 = { x: endPoint.x, y: endPoint.y };
const p01 = lerpPoint(p0, p1, clampedT);
const p12 = lerpPoint(p1, p2, clampedT);
const p23 = lerpPoint(p2, p3, clampedT);
const p012 = lerpPoint(p01, p12, clampedT);
const p123 = lerpPoint(p12, p23, clampedT);
const splitPoint = lerpPoint(p012, p123, clampedT);
const nextPoints = [...points];
nextPoints[indices.startIndex] = {
...startPoint,
outX: p01.x - startPoint.x,
outY: p01.y - startPoint.y,
};
nextPoints[indices.endIndex] = {
...endPoint,
inX: p23.x - endPoint.x,
inY: p23.y - endPoint.y,
};
nextPoints.splice(indices.endIndex, 0, {
id: pointId,
x: splitPoint.x,
y: splitPoint.y,
inX: p012.x - splitPoint.x,
inY: p012.y - splitPoint.y,
outX: p123.x - splitPoint.x,
outY: p123.y - splitPoint.y,
});
return nextPoints;
}
export function getCustomMaskLocalBounds({
points,
bounds,
}: {
points: CustomMaskPathPoint[];
bounds: ElementBounds;
}) {
if (points.length === 0) {
return null;
}
const values = points.flatMap((point) => [
{ x: point.x * bounds.width, y: point.y * bounds.height },
{
x: (point.x + point.inX) * bounds.width,
y: (point.y + point.inY) * bounds.height,
},
{
x: (point.x + point.outX) * bounds.width,
y: (point.y + point.outY) * bounds.height,
},
]);
const localBounds = getCanvasPointBounds({
points: values,
});
if (!localBounds) {
return null;
}
return {
width: localBounds.width,
height: localBounds.height,
};
}
export function recenterCustomMaskPath({
points,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
points: CustomMaskPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}) {
if (points.length === 0) {
return { centerX, centerY, points };
}
const geometry = getCustomMaskCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
if (!geometry.bounds) {
return { centerX, centerY, points };
}
const nextCenterCanvas = {
x: geometry.bounds.centerX,
y: geometry.bounds.centerY,
};
const nextCenterLocal = {
x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width,
y:
bounds.height === 0
? 0
: (nextCenterCanvas.y - bounds.cy) / bounds.height,
};
const nextPoints = geometry.anchors.map((point) => {
const anchor = customMaskCanvasPointToLocal({
point: point.anchor,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
rotation,
scale,
bounds,
});
const inHandle = customMaskCanvasPointToLocal({
point: point.inHandle,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
rotation,
scale,
bounds,
});
const outHandle = customMaskCanvasPointToLocal({
point: point.outHandle,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
rotation,
scale,
bounds,
});
return {
id: point.id,
x: anchor.x,
y: anchor.y,
inX: inHandle.x - anchor.x,
inY: inHandle.y - anchor.y,
outX: outHandle.x - anchor.x,
outY: outHandle.y - anchor.y,
};
});
return {
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
points: nextPoints,
};
}
export function buildCustomMaskPath2D({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: CustomMaskPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): Path2D {
const path = new Path2D();
if (points.length === 0) {
return path;
}
const geometry = getCustomMaskCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
const anchors = geometry.anchors;
path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y);
for (let index = 1; index < anchors.length; index++) {
const previous = anchors[index - 1];
const current = anchors[index];
path.bezierCurveTo(
previous.outHandle.x,
previous.outHandle.y,
current.inHandle.x,
current.inHandle.y,
current.anchor.x,
current.anchor.y,
);
}
if (closed && anchors.length > 1) {
const last = anchors[anchors.length - 1];
const first = anchors[0];
path.bezierCurveTo(
last.outHandle.x,
last.outHandle.y,
first.inHandle.x,
first.inHandle.y,
first.anchor.x,
first.anchor.y,
);
path.closePath();
}
return path;
}
export function buildCustomMaskSvgPath({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: CustomMaskPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): string {
if (points.length === 0) {
return "";
}
const geometry = getCustomMaskCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
const anchors = geometry.anchors;
const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`];
for (let index = 1; index < anchors.length; index++) {
const previous = anchors[index - 1];
const current = anchors[index];
segments.push(
`C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`,
);
}
if (closed && anchors.length > 1) {
const last = anchors[anchors.length - 1];
const first = anchors[0];
segments.push(
`C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`,
);
segments.push("Z");
}
return segments.join(" ");
}
+49 -6
View File
@@ -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",
+10 -7
View File
@@ -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",
+22 -19
View File
@@ -1,6 +1,7 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
@@ -36,23 +37,23 @@ function buildHeartPath({
rotationRad,
});
const start = toPoint({ localX: 0, localY: -halfHeight * 0.2 });
const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
const rightControl1 = toPoint({
localX: halfWidth,
localY: -halfHeight * 0.95,
localY: -halfHeight * 1.225,
});
const rightControl2 = toPoint({
localX: halfWidth,
localY: halfHeight * 0.15,
localY: -halfHeight * 0.125,
});
const bottom = toPoint({ localX: 0, localY: halfHeight });
const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
const leftControl1 = toPoint({
localX: -halfWidth,
localY: halfHeight * 0.15,
localY: -halfHeight * 0.125,
});
const leftControl2 = toPoint({
localX: -halfWidth,
localY: -halfHeight * 0.95,
localY: -halfHeight * 1.225,
});
const path = new Path2D();
@@ -80,25 +81,27 @@ function buildHeartPath({
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "heart",
name: "Heart",
overlayShape: "box",
buildOverlayPath({ width, height }) {
const cx = width / 2;
const cy = height / 2;
const halfWidth = width / 2;
const halfHeight = height / 2;
return [
`M ${cx},${cy - halfHeight * 0.2}`,
`C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`,
`C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`,
"Z",
].join(" ");
},
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
interaction: buildBoxMaskInteraction({
sizeMode: "width-height",
buildOverlayPath({ width, height }) {
const cx = width / 2;
const cy = height / 2;
const halfWidth = width / 2;
const halfHeight = height / 2;
return [
`M ${cx},${cy - halfHeight * 0.475}`,
`C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
`C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
"Z",
].join(" ");
},
}),
buildDefault(context) {
return {
type: "heart",
@@ -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",
+38 -3
View File
@@ -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({
+7 -4
View File
@@ -1,6 +1,7 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
@@ -87,16 +88,18 @@ function buildOverlayStarPath({
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "star",
name: "Star",
overlayShape: "box",
buildOverlayPath({ width, height }) {
return buildOverlayStarPath({ width, height });
},
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
interaction: buildBoxMaskInteraction({
sizeMode: "width-height",
buildOverlayPath({ width, height }) {
return buildOverlayStarPath({ width, height });
},
}),
buildDefault(context) {
return {
type: "star",
+438
View File
@@ -0,0 +1,438 @@
import type { ParamDefinition } from "@/lib/params";
import type {
MaskDefinition,
MaskParamUpdateArgs,
TextMask,
TextMaskParams,
} from "@/lib/masks/types";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/lib/text/typography";
import {
drawMeasuredTextLayout,
measureTextLayout,
strokeMeasuredTextLayout,
} from "@/lib/text/primitives";
import { getTextMeasurementContext } from "@/lib/text/measure-element";
import { getTextVisualRect } from "@/lib/text/layout";
import {
getBoxMaskHandlePositions,
getBoxMaskRectOverlay,
} from "@/lib/masks/handle-positions";
import { computeFeatherUpdate } from "@/lib/masks/param-update";
import {
setMaskLocalCenter,
toGlobalMaskSnapLines,
} from "@/lib/masks/geometry";
import {
snapPosition,
snapRotation,
snapScale,
type ScaleEdgePreference,
} from "@/lib/preview/preview-snap";
const PERCENTAGE_DISPLAY = {
displayMultiplier: 100,
step: 1,
} as const;
const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign;
const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
{
key: "centerX",
label: "X",
type: "number",
default: 0,
min: -100,
max: 100,
...PERCENTAGE_DISPLAY,
},
{
key: "centerY",
label: "Y",
type: "number",
default: 0,
min: -100,
max: 100,
...PERCENTAGE_DISPLAY,
},
{
key: "fontSize",
label: "Size",
type: "number",
default: DEFAULTS.text.element.fontSize,
min: MIN_FONT_SIZE,
max: MAX_FONT_SIZE,
step: 1,
},
{
key: "rotation",
label: "Rotation",
type: "number",
default: 0,
min: 0,
max: 360,
step: 1,
},
{
key: "scale",
label: "Scale",
type: "number",
default: 1,
min: 1,
max: 500,
...PERCENTAGE_DISPLAY,
},
];
function measureTextMask({
params,
height,
}: {
params: TextMaskParams;
height: number;
}) {
const layout = measureTextLayout({
text: {
content: params.content,
fontSize: params.fontSize,
fontFamily: params.fontFamily,
fontWeight: params.fontWeight,
fontStyle: params.fontStyle,
textAlign: TEXT_MASK_ALIGNMENT,
textDecoration: params.textDecoration,
letterSpacing: params.letterSpacing,
lineHeight: params.lineHeight,
},
canvasHeight: height,
ctx: getTextMeasurementContext(),
});
const visualRect = getTextVisualRect({
textAlign: layout.textAlign,
block: layout.block,
background: { enabled: false, color: "transparent" },
fontSizeRatio: layout.fontSizeRatio,
});
return {
layout,
intrinsicWidth: Math.max(1, visualRect.width),
intrinsicHeight: Math.max(1, visualRect.height),
};
}
function getScalePreferredEdges({
handleId,
}: {
handleId: string;
}): ScaleEdgePreference | undefined {
if (handleId !== "scale") {
return undefined;
}
return {
right: true,
bottom: true,
};
}
function computeTextMaskParamUpdate({
handleId,
startParams,
deltaX,
deltaY,
startCanvasX,
startCanvasY,
bounds,
}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
if (handleId === "position") {
return {
centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
};
}
const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height;
if (handleId === "rotation") {
const startAngle =
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
Math.PI;
const currentAngle =
(Math.atan2(
startCanvasY + deltaY - pivotY,
startCanvasX + deltaX - pivotX,
) *
180) /
Math.PI;
let deltaAngle = currentAngle - startAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;
return {
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
};
}
if (handleId === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
deltaX,
deltaY,
directionX: -Math.sin(angleRad),
directionY: Math.cos(angleRad),
});
}
if (handleId === "scale") {
const startDistance = Math.hypot(
startCanvasX - pivotX,
startCanvasY - pivotY,
);
const currentDistance = Math.hypot(
startCanvasX + deltaX - pivotX,
startCanvasY + deltaY - pivotY,
);
const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
return {
scale: Math.max(0.01, startParams.scale * scaleFactor),
};
}
return {};
}
export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
type: "text",
name: "Text",
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "uniform",
},
params: TEXT_MASK_PARAMS,
interaction: {
getInteraction({
params,
bounds,
displayScale,
scaleX: _scaleX,
scaleY: _scaleY,
}) {
const { intrinsicWidth, intrinsicHeight } = measureTextMask({
params,
height: bounds.height,
});
const width = (intrinsicWidth * params.scale) / bounds.width;
const height = (intrinsicHeight * params.scale) / bounds.height;
return {
handles: getBoxMaskHandlePositions({
centerX: params.centerX,
centerY: params.centerY,
width,
height,
rotation: params.rotation,
feather: params.feather,
sizeMode: "uniform",
bounds,
displayScale,
}),
overlays: [
getBoxMaskRectOverlay({
centerX: params.centerX,
centerY: params.centerY,
width,
height,
rotation: params.rotation,
bounds,
}),
],
};
},
snap({
handleId,
startParams,
proposedParams,
bounds,
canvasSize,
snapThreshold,
}) {
const { intrinsicWidth, intrinsicHeight } = measureTextMask({
params: startParams,
height: bounds.height,
});
const position = {
x: proposedParams.centerX * bounds.width,
y: proposedParams.centerY * bounds.height,
};
if (handleId === "position") {
const { snappedPosition, activeLines } = snapPosition({
proposedPosition: position,
canvasSize: bounds,
elementSize: {
width: intrinsicWidth * proposedParams.scale,
height: intrinsicHeight * proposedParams.scale,
},
rotation: proposedParams.rotation,
snapThreshold,
});
return {
params: {
...proposedParams,
...setMaskLocalCenter({
center: snappedPosition,
bounds,
}),
},
activeLines: toGlobalMaskSnapLines({
lines: activeLines,
bounds,
canvasSize,
}),
};
}
if (handleId === "rotation") {
const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation,
});
return {
params: {
...proposedParams,
rotation: snappedRotation,
},
activeLines: [],
};
}
if (handleId === "scale") {
const { snappedScale, activeLines } = snapScale({
proposedScale: proposedParams.scale,
position,
baseWidth: intrinsicWidth,
baseHeight: intrinsicHeight,
rotation: proposedParams.rotation,
canvasSize: bounds,
snapThreshold,
preferredEdges: getScalePreferredEdges({ handleId }),
});
return {
params: {
...proposedParams,
scale: Math.max(0.01, snappedScale),
},
activeLines: toGlobalMaskSnapLines({
lines: activeLines,
bounds,
canvasSize,
}),
};
}
return {
params: proposedParams,
activeLines: [],
};
},
},
buildDefault(): Omit<TextMask, "id"> {
return {
type: "text",
params: {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
strokeAlign: "center",
content: "Mask",
fontSize: DEFAULTS.text.element.fontSize,
fontFamily: DEFAULTS.text.element.fontFamily,
fontWeight: DEFAULTS.text.element.fontWeight,
fontStyle: DEFAULTS.text.element.fontStyle,
textDecoration: DEFAULTS.text.element.textDecoration,
letterSpacing: DEFAULTS.text.letterSpacing,
lineHeight: DEFAULTS.text.lineHeight,
centerX: 0,
centerY: 0,
rotation: 0,
scale: 1,
},
};
},
computeParamUpdate: computeTextMaskParamUpdate,
isActive(params) {
return params.content.trim().length > 0;
},
renderer: {
renderMask({ resolvedParams, ctx, width, height }) {
const params = resolvedParams as TextMaskParams;
const { layout } = measureTextMask({ params, height });
ctx.save();
ctx.translate(
width / 2 + params.centerX * width,
height / 2 + params.centerY * height,
);
ctx.scale(params.scale, params.scale);
if (params.rotation) {
ctx.rotate((params.rotation * Math.PI) / 180);
}
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
ctx.restore();
},
renderStroke({ resolvedParams, ctx, width, height }) {
const params = resolvedParams as TextMaskParams;
const { layout } = measureTextMask({ params, height });
ctx.save();
ctx.translate(
width / 2 + params.centerX * width,
height / 2 + params.centerY * height,
);
ctx.scale(params.scale, params.scale);
if (params.rotation) {
ctx.rotate((params.rotation * Math.PI) / 180);
}
strokeMeasuredTextLayout({
ctx,
layout,
strokeColor: params.strokeColor,
strokeWidth: params.strokeWidth,
});
if (params.strokeAlign === "inside") {
ctx.globalCompositeOperation = "destination-in";
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
}
if (params.strokeAlign === "outside") {
ctx.globalCompositeOperation = "destination-out";
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
}
ctx.restore();
},
},
};
+17 -8
View File
@@ -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({
},
);
}
+189 -74
View File
@@ -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 -34
View File
@@ -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),
});
}
+56 -4
View File
@@ -1,6 +1,14 @@
import { MAX_FEATHER } from "@/lib/masks/feather";
import type { ParamDefinition } from "@/lib/params";
import type { BaseMaskParams, MaskDefinition, MaskType } from "@/lib/masks/types";
import type {
BaseMaskParams,
MaskDefaultContext,
MaskDefinition,
MaskInteractionResult,
MaskSnapArgs,
MaskSnapResult,
MaskType,
} from "@/lib/masks/types";
import type { HugeiconsIconProps } from "@hugeicons/react";
import { DefinitionRegistry } from "@/lib/registry";
@@ -39,9 +47,33 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
},
];
export type RegisteredMaskDefinition = MaskDefinition<BaseMaskParams> & {
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);
+19 -18
View File
@@ -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
View File
@@ -1,5 +1,12 @@
import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { ParamDefinition, ParamValues } from "@/lib/params";
import type { SnapLine } from "@/lib/preview/preview-snap";
import type { ParamDefinition } from "@/lib/params";
import type { CustomMaskPathPoint } from "@/lib/masks/custom-path";
import type {
TextDecoration,
TextFontStyle,
TextFontWeight,
} from "@/lib/text/primitives";
export type MaskType =
| "split"
@@ -8,9 +15,11 @@ export type MaskType =
| "ellipse"
| "heart"
| "diamond"
| "star";
| "star"
| "text"
| "custom";
export interface BaseMaskParams extends ParamValues {
export interface BaseMaskParams {
feather: number;
inverted: boolean;
strokeColor: string;
@@ -33,6 +42,30 @@ export interface RectangleMaskParams extends BaseMaskParams {
scale: number;
}
export interface TextMaskParams extends BaseMaskParams {
content: string;
fontSize: number;
fontFamily: string;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
textDecoration: TextDecoration;
letterSpacing: number;
lineHeight: number;
centerX: number;
centerY: number;
rotation: number;
scale: number;
}
export interface CustomMaskParams extends BaseMaskParams {
path: CustomMaskPathPoint[];
closed: boolean;
centerX: number;
centerY: number;
rotation: number;
scale: number;
}
export interface SplitMask {
id: string;
type: "split";
@@ -75,6 +108,18 @@ export interface StarMask {
params: RectangleMaskParams;
}
export interface TextMask {
id: string;
type: "text";
params: TextMaskParams;
}
export interface CustomMask {
id: string;
type: "custom";
params: CustomMaskParams;
}
export type Mask =
| SplitMask
| CinematicBarsMask
@@ -82,14 +127,16 @@ export type Mask =
| EllipseMask
| HeartMask
| DiamondMask
| StarMask;
| StarMask
| TextMask
| CustomMask;
export interface MaskRenderer {
buildPath(params: {
buildPath?: (params: {
resolvedParams: unknown;
width: number;
height: number;
}): Path2D;
}) => Path2D;
buildStrokePath?: (params: {
resolvedParams: unknown;
width: number;
@@ -103,33 +150,94 @@ export interface MaskRenderer {
height: number;
feather: number;
}) => void;
renderMaskHandlesFeather?: boolean;
renderStroke?: (params: {
resolvedParams: unknown;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number;
height: number;
}) => void;
}
export type MaskOverlayShape = "line" | "box";
export interface MaskFeatures {
hasPosition: boolean;
hasRotation: boolean;
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
}
export type MaskHandleIcon = "rotate" | "feather";
export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
export interface MaskHandlePosition {
id: string;
x: number;
y: number;
cursor: string;
kind: MaskHandleKind;
isSelected?: boolean;
edgeAxis?: "horizontal" | "vertical";
rotation?: number;
icon?: MaskHandleIcon;
}
export interface MaskLinePoints {
export interface MaskLineOverlay {
id: string;
type: "line";
start: { x: number; y: number };
end: { x: number; y: number };
cursor?: string;
handleId?: string;
}
export interface MaskRectOverlay {
id: string;
type: "rect";
center: { x: number; y: number };
width: number;
height: number;
rotation: number;
dashed?: boolean;
cursor?: string;
handleId?: string;
}
export interface MaskShapeOverlay {
id: string;
type: "shape";
center: { x: number; y: number };
width: number;
height: number;
rotation: number;
pathData: string;
cursor?: string;
handleId?: string;
}
export interface MaskCanvasPathOverlay {
id: string;
type: "canvas-path";
pathData: string;
coordinateSpace?: "canvas" | "overlay";
cursor?: string;
handleId?: string;
strokeWidth?: number;
strokeOpacity?: number;
}
export type MaskOverlay =
| MaskLineOverlay
| MaskRectOverlay
| MaskShapeOverlay
| MaskCanvasPathOverlay;
export interface MaskDefaultContext {
elementSize?: { width: number; height: number };
}
export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
export interface MaskParamUpdateArgs<
TParams extends BaseMaskParams = BaseMaskParams,
> {
handleId: string;
startParams: TParams;
deltaX: number;
@@ -140,14 +248,51 @@ export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskPa
canvasSize: { width: number; height: number };
}
export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
handleId: string;
startParams: TParams;
proposedParams: TParams;
bounds: ElementBounds;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
}
export interface MaskSnapResult<
TParams extends BaseMaskParams = BaseMaskParams,
> {
params: TParams;
activeLines: SnapLine[];
}
export interface MaskInteractionResult {
handles: MaskHandlePosition[];
overlays: MaskOverlay[];
}
export interface MaskInteractionDefinition<
TParams extends BaseMaskParams = BaseMaskParams,
> {
getInteraction(args: {
params: TParams;
bounds: ElementBounds;
displayScale: number;
scaleX: number;
scaleY: number;
}): MaskInteractionResult;
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
}
export interface MaskDefinition<
TParams extends BaseMaskParams = BaseMaskParams,
> {
type: MaskType;
name: string;
overlayShape: MaskOverlayShape;
features: MaskFeatures;
params: ParamDefinition<keyof TParams & string>[];
renderer: MaskRenderer;
buildOverlayPath?: (params: { width: number; height: number }) => string;
interaction: MaskInteractionDefinition<TParams>;
/** When defined and returning false, the mask is not applied and the element renders fully visible. */
isActive?: (params: TParams) => boolean;
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
}
@@ -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";
+2 -1
View File
@@ -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;
}
+22 -45
View File
@@ -1,14 +1,14 @@
import { CORNER_RADIUS_MIN } from "@/lib/text/background";
import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography";
import { resolveNumberAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type { TextBackground, TextElement } from "@/lib/timeline";
import {
measureTextBlock,
setCanvasLetterSpacing,
getTextVisualRect,
type TextBlockMeasurement,
} from "./layout";
import {
measureTextLayout,
type MeasuredTextLayout,
} from "./primitives";
export interface ResolvedTextBackground extends TextBackground {
paddingX: number;
@@ -18,15 +18,7 @@ export interface ResolvedTextBackground extends TextBackground {
cornerRadius: number;
}
export interface MeasuredTextElement {
scaledFontSize: number;
fontString: string;
letterSpacing: number;
lineHeightPx: number;
lines: string[];
lineMetrics: TextMetrics[];
block: TextBlockMeasurement;
fontSizeRatio: number;
export interface MeasuredTextElement extends MeasuredTextLayout {
resolvedBackground: ResolvedTextBackground;
visualRect: { left: number; top: number; width: number; height: number };
}
@@ -75,28 +67,20 @@ export function measureTextElement({
localTime: number;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}): MeasuredTextElement {
const scaledFontSize =
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
const letterSpacing = element.letterSpacing ?? 0;
const lineHeightPx =
scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
const lines = element.content.split("\n");
ctx.save();
ctx.font = fontString;
ctx.textBaseline = "middle";
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
const lineMetrics = lines.map((line) => ctx.measureText(line));
ctx.restore();
const block = measureTextBlock({
lineMetrics,
lineHeightPx,
const measuredLayout = measureTextLayout({
text: {
content: element.content,
fontSize: element.fontSize,
fontFamily: element.fontFamily,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textAlign: element.textAlign,
textDecoration: element.textDecoration,
letterSpacing: element.letterSpacing,
lineHeight: element.lineHeight,
},
canvasHeight,
ctx,
});
const bg = element.background;
@@ -136,20 +120,13 @@ export function measureTextElement({
const visualRect = getTextVisualRect({
textAlign: element.textAlign,
block,
block: measuredLayout.block,
background: resolvedBackground,
fontSizeRatio,
fontSizeRatio: measuredLayout.fontSizeRatio,
});
return {
scaledFontSize,
fontString,
letterSpacing,
lineHeightPx,
lines,
lineMetrics,
block,
fontSizeRatio,
...measuredLayout,
resolvedBackground,
visualRect,
};
+241
View File
@@ -0,0 +1,241 @@
import type { TextCanvasContext, TextBlockMeasurement } from "@/lib/text/layout";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { clamp } from "@/utils/math";
import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "./background";
import {
drawTextDecoration,
getTextBackgroundRect,
measureTextBlock,
setCanvasLetterSpacing,
} from "./layout";
import { FONT_SIZE_SCALE_REFERENCE } from "./typography";
export type TextAlign = "left" | "center" | "right";
export type TextFontWeight = "normal" | "bold";
export type TextFontStyle = "normal" | "italic";
export type TextDecoration = "none" | "underline" | "line-through";
export interface TextLayoutParams {
content: string;
fontSize: number;
fontFamily: string;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
textAlign: TextAlign;
textDecoration?: TextDecoration;
letterSpacing?: number;
lineHeight?: number;
}
export interface ResolvedTextLayout {
scaledFontSize: number;
fontString: string;
letterSpacing: number;
lineHeightPx: number;
fontSizeRatio: number;
textAlign: TextAlign;
textDecoration: TextDecoration;
}
export interface MeasuredTextLayout extends ResolvedTextLayout {
lines: string[];
lineMetrics: TextMetrics[];
block: TextBlockMeasurement;
}
export interface ResolvedTextBackgroundLike {
enabled: boolean;
color: string;
paddingX: number;
paddingY: number;
offsetX: number;
offsetY: number;
cornerRadius: number;
}
export function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
export function buildTextFontString({
fontFamily,
fontWeight,
fontStyle,
scaledFontSize,
}: {
fontFamily: string;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
scaledFontSize: number;
}): string {
return `${fontStyle} ${fontWeight} ${scaledFontSize}px ${quoteFontFamily({ fontFamily })}, sans-serif`;
}
export function resolveTextLayout({
text,
canvasHeight,
}: {
text: TextLayoutParams;
canvasHeight: number;
}): ResolvedTextLayout {
const scaledFontSize =
text.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const fontWeight = text.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = text.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
const lineHeightPx =
scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize;
return {
scaledFontSize,
fontString: buildTextFontString({
fontFamily: text.fontFamily,
fontWeight,
fontStyle,
scaledFontSize,
}),
letterSpacing,
lineHeightPx,
fontSizeRatio,
textAlign: text.textAlign,
textDecoration: text.textDecoration ?? "none",
};
}
export function measureTextLayout({
text,
canvasHeight,
ctx,
}: {
text: TextLayoutParams;
canvasHeight: number;
ctx: TextCanvasContext;
}): MeasuredTextLayout {
const resolvedLayout = resolveTextLayout({ text, canvasHeight });
const lines = text.content.split("\n");
ctx.save();
ctx.font = resolvedLayout.fontString;
ctx.textBaseline = "middle";
setCanvasLetterSpacing({
ctx,
letterSpacingPx: resolvedLayout.letterSpacing,
});
const lineMetrics = lines.map((line) => ctx.measureText(line));
ctx.restore();
const block = measureTextBlock({
lineMetrics,
lineHeightPx: resolvedLayout.lineHeightPx,
});
return {
...resolvedLayout,
lines,
lineMetrics,
block,
};
}
export function drawMeasuredTextLayout({
ctx,
layout,
textColor,
background,
backgroundColor,
textBaseline = "middle",
}: {
ctx: TextCanvasContext;
layout: MeasuredTextLayout;
textColor: string;
background?: ResolvedTextBackgroundLike | null;
backgroundColor?: string;
textBaseline?: CanvasTextBaseline;
}): void {
ctx.font = layout.fontString;
ctx.textAlign = layout.textAlign;
ctx.textBaseline = textBaseline;
ctx.fillStyle = textColor;
setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
if (
background?.enabled &&
backgroundColor &&
backgroundColor !== "transparent" &&
layout.lines.length > 0
) {
const backgroundRect = getTextBackgroundRect({
textAlign: layout.textAlign,
block: layout.block,
background: {
...background,
color: backgroundColor,
},
fontSizeRatio: layout.fontSizeRatio,
});
if (backgroundRect) {
const p =
clamp({
value: background.cornerRadius,
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
}) / 100;
const radius =
(Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
ctx.fillStyle = backgroundColor;
ctx.beginPath();
ctx.roundRect(
backgroundRect.left,
backgroundRect.top,
backgroundRect.width,
backgroundRect.height,
radius,
);
ctx.fill();
ctx.fillStyle = textColor;
}
}
for (let index = 0; index < layout.lines.length; index++) {
const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
ctx.fillText(layout.lines[index], 0, lineY);
drawTextDecoration({
ctx,
textDecoration: layout.textDecoration,
lineWidth: layout.lineMetrics[index].width,
lineY,
metrics: layout.lineMetrics[index],
scaledFontSize: layout.scaledFontSize,
textAlign: layout.textAlign,
});
}
}
export function strokeMeasuredTextLayout({
ctx,
layout,
strokeColor,
strokeWidth,
textBaseline = "middle",
}: {
ctx: TextCanvasContext;
layout: MeasuredTextLayout;
strokeColor: string;
strokeWidth: number;
textBaseline?: CanvasTextBaseline;
}): void {
ctx.font = layout.fontString;
ctx.textAlign = layout.textAlign;
ctx.textBaseline = textBaseline;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
for (let index = 0; index < layout.lines.length; index++) {
const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
ctx.strokeText(layout.lines[index], 0, lineY);
}
}
+14
View File
@@ -57,6 +57,20 @@ export function resolveEffectiveAudioGain({
return dBToLinear(resolvedDb);
}
export function buildWaveformGainSamples({
element,
count,
}: {
element: AudioCapableElement;
count: number;
}): number[] {
const durationSeconds = element.duration / TICKS_PER_SECOND;
return Array.from({ length: count }, (_, i) => {
const localTime = ((i + 0.5) / count) * durationSeconds;
return resolveEffectiveAudioGain({ element, localTime });
});
}
export function buildAudioGainAutomation({
element,
trackMuted = false,
+7 -1
View File
@@ -15,7 +15,6 @@ import {
type TextElement,
type SceneTracks,
type TimelineElement,
type TimelineTrack,
type AudioElement,
type VideoElement,
type ImageElement,
@@ -414,6 +413,13 @@ export function getElementFontFamilies({
if (element.type === "text" && element.fontFamily) {
families.add(element.fontFamily);
}
if ("masks" in element) {
for (const mask of element.masks ?? []) {
if (mask.type === "text" && mask.params.fontFamily) {
families.add(mask.params.fontFamily);
}
}
}
}
}
return [...families];
@@ -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 });
}
}