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
@@ -1,233 +1,309 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
import {
BACKGROUND_BLUR_INTENSITY_PRESETS,
DEFAULT_BACKGROUND_BLUR_INTENSITY,
} from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import { cn } from "@/utils/ui";
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
width: 1920,
height: 1080,
} as const;
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const renderPreview = () => {
if (!canvasRef.current) return;
effectPreviewService.renderPreview({
effectType: "blur",
params: { intensity: blur.value },
targetCanvas: canvasRef.current,
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
});
};
renderPreview();
return effectPreviewService.onPreviewImageReady({
callback: renderPreview,
});
}, [blur.value]);
return (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span>
</div>
</button>
);
},
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
onSelect,
useBackgroundColor = false,
}: {
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
onSelect: (bg: string) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg) => (
<button
key={bg}
className={cn(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => onSelect(bg)}
type="button"
aria-label={`Select background ${bg}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
onSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
const COLOR_SECTIONS = [
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
{ title: "Pattern craft", backgrounds: patternCraftGradients },
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
] as const;
export function BackgroundContent() {
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const handleBlurSelect = useCallback(
async (blurIntensity: number) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const handleColorSelect = useCallback(
async (color: string) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
});
},
[editor.project],
);
const isBlurBackground = activeProject.settings.background.type === "blur";
const isColorBackground = activeProject.settings.background.type === "color";
const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color
: DEFAULT_BACKGROUND_COLOR;
const blurPreviews = useMemo(
() =>
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect(blur.value)}
/>
)),
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
return (
<div className="flex flex-col">
<Section
collapsible
defaultOpen={true}
sectionKey="background-blur"
showTopBorder={false}
>
<SectionHeader>
<SectionTitle>Blur</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
</SectionContent>
</Section>
{COLOR_SECTIONS.map((section) => (
<Section
key={section.title}
collapsible
defaultOpen={false}
sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
>
<SectionHeader>
<SectionTitle>{section.title}</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">
<BackgroundPreviews
backgrounds={section.backgrounds as string[]}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
onSelect={handleColorSelect}
useBackgroundColor={
"useBackgroundColor" in section
? section.useBackgroundColor
: false
}
/>
</div>
</SectionContent>
</Section>
))}
</div>
);
}
"use client";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { ColorPickerContent } from "@/components/ui/color-picker";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import {
BACKGROUND_BLUR_INTENSITY_PRESETS,
DEFAULT_BACKGROUND_BLUR_INTENSITY,
} from "@/lib/background/blur";
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import { cn } from "@/utils/ui";
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
width: 1920,
height: 1080,
} as const;
const CUSTOM_COLOR_SWATCH_BACKGROUND =
"conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const renderPreview = () => {
if (!canvasRef.current) return;
effectPreviewService.renderPreview({
effectType: "blur",
params: { intensity: blur.value },
targetCanvas: canvasRef.current,
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
});
};
renderPreview();
return effectPreviewService.onPreviewImageReady({
callback: renderPreview,
});
}, [blur.value]);
return (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span>
</div>
</button>
);
},
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
onSelect,
useBackgroundColor = false,
}: {
backgrounds: readonly string[];
currentBackgroundColor: string;
isColorBackground: boolean;
onSelect: (bg: string) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg) => (
<button
key={bg}
className={cn(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => onSelect(bg)}
type="button"
aria-label={`Select background ${bg}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
onSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
function CustomColorPreview({
currentBackgroundColor,
isSelected,
onPreview,
onCommit,
}: {
currentBackgroundColor: string;
isSelected: boolean;
onPreview: (color: string) => void;
onCommit: (color: string) => void;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
type="button"
aria-label="Pick a custom background color"
>
<span
className="absolute inset-0"
style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
/>
<span
className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
style={{ backgroundColor: currentBackgroundColor }}
/>
</button>
</PopoverTrigger>
<ColorPickerContent
value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
onChange={(color) => onPreview(`#${color}`)}
onChangeEnd={(color) => onCommit(`#${color}`)}
/>
</Popover>
);
}
const COLOR_SECTIONS = [
{ id: "colors", title: "Colors", backgrounds: colors, useBackgroundColor: true, showCustomPicker: true },
{ id: "pattern-craft", title: "Pattern craft", backgrounds: patternCraftGradients, showCustomPicker: false },
{ id: "syntax-ui", title: "Syntax UI", backgrounds: syntaxUIGradients, showCustomPicker: false },
] as const;
export function BackgroundContent() {
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const handleBlurSelect = useCallback(
async (blurIntensity: number) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const previewBackgroundColor = useCallback(
async (color: string) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
pushHistory: false,
});
},
[editor.project],
);
const commitBackgroundColor = useCallback(
async (color: string) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
pushHistory: true,
});
},
[editor.project],
);
const isBlurBackground = activeProject.settings.background.type === "blur";
const isColorBackground = activeProject.settings.background.type === "color";
const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color
: DEFAULT_BACKGROUND_COLOR;
const hasPresetColorMatch = colors.some(
(color) => color.toLowerCase() === currentBackgroundColor.toLowerCase(),
);
const handlePresetColorSelect = useCallback(
(color: string) => {
void commitBackgroundColor(color);
},
[commitBackgroundColor],
);
const blurPreviews = useMemo(
() =>
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect(blur.value)}
/>
)),
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
return (
<div className="flex flex-col">
<Section
collapsible
defaultOpen={true}
sectionKey="background-blur"
showTopBorder={false}
>
<SectionHeader>
<SectionTitle>Blur</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
</SectionContent>
</Section>
{COLOR_SECTIONS.map((section) => (
<Section
key={section.id}
collapsible
defaultOpen={false}
sectionKey={`settings:background-${section.id}`}
>
<SectionHeader>
<SectionTitle>{section.title}</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex flex-wrap gap-2">
{section.showCustomPicker ? (
<CustomColorPreview
currentBackgroundColor={currentBackgroundColor}
isSelected={isColorBackground && !hasPresetColorMatch}
onPreview={previewBackgroundColor}
onCommit={commitBackgroundColor}
/>
) : null}
<BackgroundPreviews
backgrounds={section.backgrounds}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
onSelect={handlePresetColorSelect}
useBackgroundColor={
"useBackgroundColor" in section
? section.useBackgroundColor
: false
}
/>
</div>
</SectionContent>
</Section>
))}
</div>
);
}
@@ -0,0 +1,21 @@
function svgCursor({
svg,
hotspotX,
hotspotY,
}: {
svg: string;
hotspotX: number;
hotspotY: number;
}): string {
return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hotspotX} ${hotspotY}, crosshair`;
}
/** Hotspot is at the nib tip, which is where anchor points land. */
export const PEN_CURSOR = svgCursor({
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M 1 1 L 5 2 L 13 10 L 10 13 L 2 5 Z" fill="white" stroke="#111" stroke-width="1" stroke-linejoin="round"/>
<path d="M 1 1 L 5 2 L 2 5 Z" fill="#111"/>
</svg>`,
hotspotX: 1,
hotspotY: 1,
});
@@ -1,332 +1,427 @@
"use client";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
export const HANDLE_SIZE = 10;
export const HANDLE_HIT_AREA_SIZE = 18;
export const ICON_HANDLE_RADIUS = 10;
export const EDGE_HANDLE_THIN_SIZE = 6;
export const EDGE_HANDLE_THICK_SIZE = 14;
export const LINE_HIT_AREA_SIZE = 48;
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
const normalized = ((angleDeg % 180) + 180) % 180;
if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
if (normalized < 67.5) return "cursor-nwse-resize";
if (normalized < 112.5) return "cursor-ns-resize";
return "cursor-nesw-resize";
}
export function HandleButton({
screen,
cursor,
hitAreaSize,
className,
onPointerDown,
onPointerMove,
onPointerUp,
children,
}: {
screen: { x: number; y: number };
cursor?: string;
hitAreaSize: number;
className?: string;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={cn(
"absolute flex items-center justify-center outline-none",
cursor,
className,
)}
style={{
left: screen.x - hitAreaSize / 2,
top: screen.y - hitAreaSize / 2,
width: hitAreaSize,
height: hitAreaSize,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
{children}
</button>
);
}
export function CornerHandle({
cursor,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/>
</HandleButton>
);
}
export function EdgeHandle({
edge,
screen,
rotation,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
edge: "right" | "left" | "bottom";
screen: { x: number; y: number };
rotation: number;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
const isHorizontalEdge = edge === "right" || edge === "left";
const width = isHorizontalEdge
? EDGE_HANDLE_THIN_SIZE
: EDGE_HANDLE_THICK_SIZE;
const height = isHorizontalEdge
? EDGE_HANDLE_THICK_SIZE
: EDGE_HANDLE_THIN_SIZE;
const cursor = getResizeCursor({
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
});
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width, height, transform: `rotate(${rotation}deg)` }}
/>
</HandleButton>
);
}
export function IconHandle({
icon,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
icon: IconSvgElement;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
hitAreaSize={ICON_HANDLE_RADIUS * 2}
className="rounded-full bg-white text-black shadow-sm"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
</HandleButton>
);
}
export function BoundingBoxOutline({
center,
outlineWidth,
outlineHeight,
rotation,
cursor,
dashed = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
cursor?: string;
dashed?: boolean;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className={cn("absolute overflow-visible", cursor)}
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<rect
x={0.5}
y={0.5}
width={Math.max(outlineWidth - 1, 0)}
height={Math.max(outlineHeight - 1, 0)}
fill="transparent"
stroke="white"
strokeDasharray={dashed ? "4 4" : undefined}
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function ShapeOutline({
center,
outlineWidth,
outlineHeight,
rotation,
pathData,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
pathData: string;
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className={cn("absolute overflow-visible", cursor)}
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function LineOverlay({
start,
end,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
start: { x: number; y: number };
end: { x: number; y: number };
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const cx = (start.x + end.x) / 2;
const cy = (start.y + end.y) / 2;
const sharedStyle = {
left: cx - length / 2,
width: length,
transform: `rotate(${angleDeg}deg)`,
transformOrigin: "center center",
};
return (
<>
{onPointerDown && (
<div
className="absolute"
style={{
...sharedStyle,
top: cy - LINE_HIT_AREA_SIZE / 2,
height: LINE_HIT_AREA_SIZE,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
/>
)}
<div
className="pointer-events-none absolute"
style={{
...sharedStyle,
top: cy - 0.5,
height: 1,
backgroundColor: "white",
opacity: 0.75,
}}
/>
</>
);
}
"use client";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
export const HANDLE_SIZE = 10;
export const HANDLE_HIT_AREA_SIZE = 18;
export const ICON_HANDLE_RADIUS = 10;
export const EDGE_HANDLE_THIN_SIZE = 6;
export const EDGE_HANDLE_THICK_SIZE = 14;
export const LINE_HIT_AREA_SIZE = 48;
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
const normalized = ((angleDeg % 180) + 180) % 180;
if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
if (normalized < 67.5) return "nwse-resize";
if (normalized < 112.5) return "ns-resize";
return "nesw-resize";
}
export function HandleButton({
screen,
cursor,
hitAreaSize,
className,
onPointerDown,
onPointerMove,
onPointerUp,
children,
}: {
screen: { x: number; y: number };
cursor?: string;
hitAreaSize: number;
className?: string;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={cn(
"absolute flex items-center justify-center outline-none",
className,
)}
style={{
left: screen.x - hitAreaSize / 2,
top: screen.y - hitAreaSize / 2,
width: hitAreaSize,
height: hitAreaSize,
pointerEvents: "auto",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
{children}
</button>
);
}
export function CornerHandle({
cursor,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/>
</HandleButton>
);
}
export function CircleHandle({
cursor,
screen,
size = HANDLE_SIZE,
isSelected = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
size?: number;
isSelected?: boolean;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
style={{ width: size, height: size }}
/>
</HandleButton>
);
}
export function EdgeHandle({
edge,
screen,
rotation,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
edge: "right" | "left" | "bottom";
screen: { x: number; y: number };
rotation: number;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
const isHorizontalEdge = edge === "right" || edge === "left";
const width = isHorizontalEdge
? EDGE_HANDLE_THIN_SIZE
: EDGE_HANDLE_THICK_SIZE;
const height = isHorizontalEdge
? EDGE_HANDLE_THICK_SIZE
: EDGE_HANDLE_THIN_SIZE;
const cursor = getResizeCursor({
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
});
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width, height, transform: `rotate(${rotation}deg)` }}
/>
</HandleButton>
);
}
export function IconHandle({
icon,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
icon: IconSvgElement;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
hitAreaSize={ICON_HANDLE_RADIUS * 2}
className="rounded-full bg-white text-black shadow-sm"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
</HandleButton>
);
}
export function BoundingBoxOutline({
center,
outlineWidth,
outlineHeight,
rotation,
cursor,
dashed = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
cursor?: string;
dashed?: boolean;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute overflow-visible"
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<rect
x={0.5}
y={0.5}
width={Math.max(outlineWidth - 1, 0)}
height={Math.max(outlineHeight - 1, 0)}
fill="transparent"
stroke="white"
strokeDasharray={dashed ? "4 4" : undefined}
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function ShapeOutline({
center,
outlineWidth,
outlineHeight,
rotation,
pathData,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
pathData: string;
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute overflow-visible"
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function CanvasPathOutline({
pathData,
translateX = 0,
translateY = 0,
scaleX = 1,
scaleY = 1,
cursor,
strokeWidth = 1,
strokeOpacity = 0.75,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
pathData: string;
translateX?: number;
translateY?: number;
scaleX?: number;
scaleY?: number;
cursor?: string;
strokeWidth?: number;
strokeOpacity?: number;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute inset-0 overflow-visible"
aria-hidden="true"
focusable="false"
style={{
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<g
transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeWidth={strokeWidth}
strokeOpacity={strokeOpacity}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
/>
</g>
</svg>
);
}
export function LineOverlay({
start,
end,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
start: { x: number; y: number };
end: { x: number; y: number };
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const cx = (start.x + end.x) / 2;
const cy = (start.y + end.y) / 2;
const sharedStyle = {
left: cx - length / 2,
width: length,
transform: `rotate(${angleDeg}deg)`,
transformOrigin: "center center",
};
return (
<>
{onPointerDown && (
<div
className="absolute"
style={{
...sharedStyle,
top: cy - LINE_HIT_AREA_SIZE / 2,
height: LINE_HIT_AREA_SIZE,
pointerEvents: "auto",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
/>
)}
<div
className="pointer-events-none absolute"
style={{
...sharedStyle,
top: cy - 0.5,
height: 1,
backgroundColor: "white",
opacity: 0.75,
}}
/>
</>
);
}
@@ -1,31 +1,23 @@
"use client";
import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useMaskHandles } from "@/hooks/use-mask-handles";
import { masksRegistry } from "@/lib/masks";
import type { SnapLine } from "@/lib/preview/preview-snap";
import type { ParamValues } from "@/lib/params";
import type { RectangleMaskParams } from "@/lib/masks/types";
import {
CornerHandle,
CircleHandle,
CanvasPathOutline,
EdgeHandle,
IconHandle,
LineOverlay,
BoundingBoxOutline,
ShapeOutline,
} from "./handle-primitives";
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
function hasRectangleOutlineParams(
params: ParamValues,
): params is RectangleMaskParams {
return (
typeof params.centerX === "number" &&
typeof params.centerY === "number" &&
typeof params.width === "number" &&
typeof params.height === "number"
);
}
const CUSTOM_MASK_ANCHOR_SIZE = 7;
const CUSTOM_MASK_TANGENT_SIZE = 6;
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
export function MaskHandles({
onSnapLinesChange,
@@ -36,7 +28,9 @@ export function MaskHandles({
const {
selectedWithMask,
handlePositions,
linePoints,
overlays,
isCreatingCustomMask,
handleCanvasPointerDown,
handlePointerDown,
handlePointerMove,
handlePointerUp,
@@ -56,96 +50,158 @@ export function MaskHandles({
canvasY,
});
const def = masksRegistry.get(selectedWithMask.mask.type);
const { bounds } = selectedWithMask;
const maskRotation = selectedWithMask.mask.params.rotation;
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
const rectangleOutlineProps = hasRectangleOutlineParams(
selectedWithMask.mask.params,
)
? {
center: toOverlay({
canvasX:
bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
canvasY:
bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
}),
outlineWidth:
selectedWithMask.mask.params.width * bounds.width * scaleX,
outlineHeight:
selectedWithMask.mask.params.height * bounds.height * scaleY,
rotation: maskRotation,
}
: null;
const onPointerMove = (event: React.PointerEvent) => {
if (viewport.handlePanPointerMove({ event })) {
return;
}
const onPointerMove = (event: React.PointerEvent) =>
handlePointerMove({ event });
const onPointerUp = () => handlePointerUp();
};
const onPointerUp = (event: React.PointerEvent) => {
if (viewport.handlePanPointerUp({ event })) {
return;
}
handlePointerUp();
};
const handleMaskPointerDown = ({
event,
handleId,
}: {
event: React.PointerEvent;
handleId: string;
}) => {
if (viewport.handlePanPointerDown({ event })) {
return;
}
handlePointerDown({ event, handleId });
};
const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => {
if (viewport.handlePanPointerDown({ event })) {
return;
}
handleCanvasPointerDown({ event });
};
return (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
{def.overlayShape === "line" && linePoints && (
<LineOverlay
start={toOverlay({
canvasX: linePoints.start.x,
canvasY: linePoints.start.y,
})}
end={toOverlay({
canvasX: linePoints.end.x,
canvasY: linePoints.end.y,
})}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" })
}
{isCreatingCustomMask ? (
<div
className="absolute inset-0 pointer-events-auto"
style={{ cursor: PEN_CURSOR }}
onPointerDown={handleCanvasOverlayPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
)}
{def.overlayShape === "box" && rectangleOutlineProps && (
def.buildOverlayPath ? (
<>
<BoundingBoxOutline {...rectangleOutlineProps} dashed />
<ShapeOutline
{...rectangleOutlineProps}
pathData={def.buildOverlayPath({
width: rectangleOutlineProps.outlineWidth,
height: rectangleOutlineProps.outlineHeight,
})}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
</>
) : (
<BoundingBoxOutline
{...rectangleOutlineProps}
cursor="cursor-move"
onPointerDown={(event) =>
handlePointerDown({ event, handleId: "position" })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
) : null}
{overlays.map((overlay) => {
const overlayHandleId = overlay.handleId;
const pointerHandlers = overlayHandleId
? {
onPointerDown: (event: React.PointerEvent) =>
handleMaskPointerDown({ event, handleId: overlayHandleId }),
onPointerMove,
onPointerUp,
}
: {};
if (overlay.type === "line") {
return (
<LineOverlay
key={overlay.id}
cursor={overlay.cursor}
start={toOverlay({
canvasX: overlay.start.x,
canvasY: overlay.start.y,
})}
end={toOverlay({
canvasX: overlay.end.x,
canvasY: overlay.end.y,
})}
{...pointerHandlers}
/>
)
)}
);
}
if (overlay.type === "rect") {
return (
<BoundingBoxOutline
key={overlay.id}
center={toOverlay({
canvasX: overlay.center.x,
canvasY: overlay.center.y,
})}
outlineWidth={overlay.width * scaleX}
outlineHeight={overlay.height * scaleY}
rotation={overlay.rotation}
cursor={overlay.cursor}
dashed={overlay.dashed}
{...pointerHandlers}
/>
);
}
if (overlay.type === "shape") {
return (
<ShapeOutline
key={overlay.id}
center={toOverlay({
canvasX: overlay.center.x,
canvasY: overlay.center.y,
})}
outlineWidth={overlay.width * scaleX}
outlineHeight={overlay.height * scaleY}
rotation={overlay.rotation}
pathData={overlay.pathData}
cursor={overlay.cursor}
{...pointerHandlers}
/>
);
}
if (overlay.type === "canvas-path") {
return (
<CanvasPathOutline
key={overlay.id}
pathData={overlay.pathData}
translateX={
overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0
}
translateY={
overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0
}
scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1}
scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1}
cursor={overlay.cursor}
strokeWidth={overlay.strokeWidth}
strokeOpacity={overlay.strokeOpacity}
{...pointerHandlers}
/>
);
}
return null;
})}
{handlePositions.map((handle) => {
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
if (handle.id === "rotation") {
if (handle.kind === "icon" && handle.icon === "rotate") {
return (
<IconHandle
key={handle.id}
icon={Rotate01Icon}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -153,14 +209,14 @@ export function MaskHandles({
);
}
if (handle.id === "feather") {
if (handle.kind === "icon" && handle.icon === "feather") {
return (
<IconHandle
key={handle.id}
icon={FeatherIcon}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -168,15 +224,15 @@ export function MaskHandles({
);
}
if (handle.id === "right" || handle.id === "left") {
if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
return (
<EdgeHandle
key={handle.id}
edge="right"
screen={screen}
rotation={maskRotation}
rotation={handle.rotation ?? 0}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -184,15 +240,15 @@ export function MaskHandles({
);
}
if (handle.id === "bottom" || handle.id === "top") {
if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
return (
<EdgeHandle
key={handle.id}
edge="bottom"
screen={screen}
rotation={maskRotation}
rotation={handle.rotation ?? 0}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -200,19 +256,33 @@ export function MaskHandles({
);
}
if (
handle.id === "top-left" ||
handle.id === "top-right" ||
handle.id === "bottom-left" ||
handle.id === "bottom-right" ||
handle.id === "scale"
) {
if (handle.kind === "point" || handle.kind === "tangent") {
return (
<CircleHandle
key={handle.id}
screen={screen}
size={
handle.kind === "tangent"
? CUSTOM_MASK_TANGENT_SIZE
: CUSTOM_MASK_ANCHOR_SIZE
}
isSelected={handle.isSelected}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.kind === "corner") {
return (
<CornerHandle
key={handle.id}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -226,7 +296,7 @@ export function MaskHandles({
cursor={handle.cursor}
screen={screen}
onPointerDown={(event) =>
handlePointerDown({ event, handleId: handle.id })
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -4,14 +4,12 @@ import { useCallback, useEffect, useRef } from "react";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
import type { TextElement } from "@/lib/timeline";
import {
FONT_SIZE_SCALE_REFERENCE,
} from "@/lib/text/typography";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
getElementLocalTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { resolveTextLayout } from "@/lib/text/primitives";
export function TextEditOverlay({
trackId,
@@ -82,20 +80,29 @@ export function TextEditOverlay({
});
const { x: displayScaleX } = viewport.getDisplayScale();
const scaledFontSize =
element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
const resolvedTextLayout = resolveTextLayout({
text: {
content: element.content,
fontSize: element.fontSize,
fontFamily: element.fontFamily,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textAlign: element.textAlign,
textDecoration: element.textDecoration,
letterSpacing: element.letterSpacing,
lineHeight: element.lineHeight,
},
canvasHeight: canvasSize.height,
});
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const canvasLetterSpacing = element.letterSpacing ?? 0;
const lineHeightPx = scaledFontSize * lineHeight;
const lineHeightPx = resolvedTextLayout.lineHeightPx;
const bg = element.background;
const shouldShowBackground =
bg.enabled && bg.color && bg.color !== "transparent";
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
const canvasPaddingX = shouldShowBackground
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
: 0;
@@ -123,10 +130,10 @@ export function TextEditOverlay({
aria-label="Edit text"
className="cursor-text select-text outline-none whitespace-pre"
style={{
fontSize: scaledFontSize,
fontSize: resolvedTextLayout.scaledFontSize,
fontFamily: element.fontFamily,
fontWeight,
fontStyle,
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
textAlign: element.textAlign,
letterSpacing: `${canvasLetterSpacing}px`,
lineHeight,
@@ -1,8 +1,11 @@
"use client";
import type { MaskableElement } from "@/lib/timeline";
import type { Mask, MaskType } from "@/lib/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
import type {
NumberParamDefinition,
SelectParamDefinition,
} from "@/lib/params";
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
import { useEditor } from "@/hooks/use-editor";
import { useElementPreview } from "@/hooks/use-element-preview";
@@ -10,14 +13,17 @@ import { useMenuPreview } from "@/hooks/use-menu-preview";
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Delete02Icon,
FeatherIcon,
PlusSignIcon,
RotateClockwiseIcon,
TextFontIcon,
} from "@hugeicons/core-free-icons";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
import { FontPicker } from "@/components/ui/font-picker";
import {
DropdownMenu,
DropdownMenuContent,
@@ -32,6 +38,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
@@ -52,7 +59,12 @@ import {
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import {
OcMirrorIcon,
OcShapesIcon,
OcTextHeightIcon,
OcTextWidthIcon,
} from "@/components/icons";
import { cn } from "@/utils/ui";
type MasksTabProps = {
@@ -78,6 +90,10 @@ type PreviewParamHandler = (
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
function isTextMask(mask: Mask): mask is TextMask {
return mask.type === "text";
}
export function MasksTab({ element, trackId }: MasksTabProps) {
const editor = useEditor();
const { renderElement, previewUpdates, commit } =
@@ -370,12 +386,23 @@ function MaskParamsFields({
previewParam(key)(value);
const previewStrokeColor = previewParam("strokeColor");
const strokeAlignParam = definition.params.find(
(param): param is SelectParamDefinition =>
(param): param is SelectParamDefinition<string> =>
param.key === "strokeAlign" && param.type === "select",
);
return (
<SectionFields>
{isTextMask(mask) ? (
<TextMaskFields
mask={mask}
previewParam={previewParam}
onCommit={onCommit}
fontSizeParam={getNumberParamDefinition({
definition,
key: "fontSize",
})}
/>
) : null}
{definition.features.hasPosition &&
"centerX" in mask.params &&
"centerY" in mask.params && (
@@ -491,7 +518,9 @@ function MaskParamsFields({
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
<SectionField label="Scale">
<MaskNumberField
icon="S"
icon={
isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
}
param={getNumberParamDefinition({
definition,
key: "scale",
@@ -587,6 +616,100 @@ function MaskParamsFields({
);
}
const LETTER_SPACING_PARAM: NumberParamDefinition = {
key: "letterSpacing",
label: "Letter spacing",
type: "number",
default: 0,
min: -100,
max: 500,
step: 1,
};
const LINE_HEIGHT_PARAM: NumberParamDefinition = {
key: "lineHeight",
label: "Line height",
type: "number",
default: 1.2,
min: 0.1,
max: 10,
step: 0.1,
};
function TextMaskFields({
mask,
previewParam,
onCommit,
fontSizeParam,
}: {
mask: TextMask;
previewParam: PreviewParamHandler;
onCommit: () => void;
fontSizeParam: NumberParamDefinition;
}) {
const content = usePropertyDraft({
displayValue: mask.params.content,
parse: (input) => input,
onPreview: (value) => previewParam("content")(value),
onCommit,
});
const previewNumberParam = (key: string) => (value: number) =>
previewParam(key)(value);
return (
<>
<SectionField label="Content">
<Textarea
value={content.displayValue}
className="min-h-20"
onFocus={content.onFocus}
onChange={content.onChange}
onBlur={content.onBlur}
/>
</SectionField>
<SectionField label="Font">
<FontPicker
defaultValue={mask.params.fontFamily}
onValueChange={(value) => {
previewParam("fontFamily")(value);
onCommit();
}}
/>
</SectionField>
<SectionField label="Size">
<MaskNumberField
icon={<HugeiconsIcon icon={TextFontIcon} />}
param={fontSizeParam}
value={mask.params.fontSize}
onPreview={previewNumberParam("fontSize")}
onCommit={onCommit}
/>
</SectionField>
<SectionField label="Spacing">
<div className="flex items-start gap-2">
<MaskNumberField
className="w-1/2"
icon={<OcTextWidthIcon size={14} />}
param={LETTER_SPACING_PARAM}
value={mask.params.letterSpacing ?? 0}
onPreview={previewNumberParam("letterSpacing")}
onCommit={onCommit}
/>
<MaskNumberField
className="w-1/2"
icon={<OcTextHeightIcon size={14} />}
param={LINE_HEIGHT_PARAM}
value={mask.params.lineHeight ?? 1.2}
onPreview={previewNumberParam("lineHeight")}
onCommit={onCommit}
/>
</div>
</SectionField>
</>
);
}
function getNumberParamDefinition({
definition,
key,
@@ -603,13 +726,10 @@ function getNumberParamDefinition({
return param;
}
function getMaskNumber({
params,
key,
}: {
params: Mask["params"];
key: string;
}): number {
function getMaskNumber<
TParams extends Mask["params"],
TKey extends keyof TParams & string,
>({ params, key }: { params: TParams; key: TKey }): number {
const value = params[key];
if (typeof value !== "number") {
@@ -665,7 +785,9 @@ function MaskNumberField({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
return (
clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
);
},
onPreview,
onCommit,
@@ -9,9 +9,34 @@ import { cn } from "@/utils/ui";
const BAR_WIDTH = 2;
const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP;
export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
function sampleGain({
samples,
startFraction,
endFraction,
barIndex,
barCount,
}: {
samples: number[];
startFraction: number;
endFraction: number;
barIndex: number;
barCount: number;
}): number {
if (samples.length === 0) return 1;
const progress =
startFraction +
((barIndex + 0.5) / barCount) * (endFraction - startFraction);
const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
const lo = Math.floor(rawIndex);
const hi = Math.min(samples.length - 1, lo + 1);
return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
}
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
gainSamples?: number[];
color?: string;
className?: string;
}
@@ -19,6 +44,7 @@ interface AudioWaveformProps {
export function AudioWaveform({
audioUrl,
audioBuffer,
gainSamples,
color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
@@ -26,6 +52,7 @@ export function AudioWaveform({
const containerRef = useRef<HTMLDivElement>(null);
const bufferRef = useRef<AudioBuffer | null>(null);
const globalMaxRef = useRef<number>(1);
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
const scrollParentRef = useRef<HTMLElement | null>(null);
const heightRef = useRef<number>(0);
@@ -99,20 +126,37 @@ export function AudioWaveform({
const maxBarHeight = height * 0.7;
const samples = gainSamplesRef.current;
for (let i = 0; i < barCount; i++) {
const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
const gain =
samples != null
? sampleGain({
samples,
startFraction,
endFraction,
barIndex: i,
barCount,
})
: 1;
const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
const barH = Math.max(1, scaled * maxBarHeight);
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
}
}, [color]);
useEffect(() => {
gainSamplesRef.current = gainSamples;
drawVisible();
}, [gainSamples, drawVisible]);
useEffect(() => {
let isCancelled = false;
async function load() {
let buffer = audioBuffer ?? null;
if (!buffer && audioUrl) {
if (!buffer && audioUrl) {
try {
const resp = await fetch(audioUrl);
const arrayBuffer = await resp.arrayBuffer();
@@ -2,7 +2,7 @@
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import { AudioWaveform } from "./audio-waveform";
import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
import { useElementPreview } from "@/hooks/use-element-preview";
import {
useKeyframeDrag,
@@ -44,6 +44,7 @@ import {
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
import {
getActionDefinition,
type TAction,
@@ -382,6 +383,7 @@ export function TimelineElement({
>
<ElementInner
element={element}
displayElement={renderElement}
track={track}
isSelected={isSelected}
isExpanded={expandedRows.length > 0}
@@ -498,6 +500,7 @@ export function TimelineElement({
function ElementInner({
element,
displayElement,
track,
isSelected,
isExpanded,
@@ -509,6 +512,7 @@ function ElementInner({
isDropTarget = false,
}: {
element: TimelineElementType;
displayElement?: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
isExpanded: boolean;
@@ -530,8 +534,10 @@ function ElementInner({
}) => void;
isDropTarget?: boolean;
}) {
const visibleElement = displayElement ?? element;
const isReducedOpacity =
(canElementBeHidden(element) && element.hidden) || isDropTarget;
(canElementBeHidden(visibleElement) && visibleElement.hidden) ||
isDropTarget;
return (
<div
className="absolute top-0 bottom-0"
@@ -576,7 +582,7 @@ function ElementInner({
style={{ height: `${baseTrackHeight}px` }}
>
<div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
<ElementContent element={element} track={track} />
<ElementContent element={visibleElement} track={track} />
</div>
</div>
{expandedContent}
@@ -979,6 +985,11 @@ function AudioElementContent({ element }: { element: AudioElement }) {
const audioUrl =
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
const mediaLabel = mediaAsset?.name ?? element.name;
const gainSamples = useMemo(
() =>
buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
[element],
);
if (audioBuffer || audioUrl) {
return (
@@ -986,6 +997,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
<AudioWaveform
audioBuffer={audioBuffer}
audioUrl={audioUrl}
gainSamples={gainSamples}
color={TIMELINE_TRACK_THEME.audio.waveformColor}
/>
<MediaElementHeader name={mediaLabel} hasFade={false} />
File diff suppressed because it is too large Load Diff