mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: text / custom masks, fix preview pan, refactor selection
This commit is contained in:
@@ -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
Reference in New Issue
Block a user