summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx542
-rw-r--r--apps/web/src/components/editor/panels/preview/cursors.ts21
-rw-r--r--apps/web/src/components/editor/panels/preview/handle-primitives.tsx759
-rw-r--r--apps/web/src/components/editor/panels/preview/mask-handles.tsx264
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx33
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx148
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx48
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx18
8 files changed, 1140 insertions, 693 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
index 98847453..099e335a 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/cursors.ts b/apps/web/src/components/editor/panels/preview/cursors.ts
new file mode 100644
index 00000000..f04dbef0
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/cursors.ts
@@ -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,
+});
diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
index d4de3332..33baf9fc 100644
--- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
+++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
@@ -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,
+ }}
+ />
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
index 0c11f27a..d32b6ff9 100644
--- a/apps/web/src/components/editor/panels/preview/mask-handles.tsx
+++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
@@ -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}
- />
- </>
- ) : (
+ ) : 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
- {...rectangleOutlineProps}
- cursor="cursor-move"
- onPointerDown={(event) =>
- handlePointerDown({ event, handleId: "position" })
+ 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
}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
+ 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,35 @@ 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) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ 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) =>
- handlePointerDown({ event, handleId: handle.id })
+ handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -200,19 +276,13 @@ 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 === "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}
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
index 44281ade..eabbde8a 100644
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -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,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
index cfc69720..090fce64 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -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,
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
index a481f1c2..430575e1 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -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();
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 644dbc56..9283c651 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -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} />