Files
Recordly/src/components/video-editor/AnnotationSettingsPanel.tsx
T

696 lines
21 KiB
TypeScript

import { ToggleButton } from "@heroui/react";
import { Card } from "@heroui/react";
import { TextArea } from "@/components/ui/input";
import {
AlignCenterHorizontal as AlignCenter,
AlignLeft,
AlignRight,
TextB as Bold,
ImageSquare as ImageIcon,
Info,
TextItalic as Italic,
BoundingBox as SquareDashed,
Trash as Trash2,
TextT as Type,
TextUnderline as Underline,
UploadSimple as Upload,
} from "@phosphor-icons/react";
import { ColorControl, ColorPalette } from "@/components/ui/color-picker";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@/components/ui/toast";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { ChoiceGroup, ChoiceItem } from "@/components/ui/choice-group";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { useScopedT } from "../../contexts/I18nContext";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
onContentChange: (content: string) => void;
onTypeChange: (type: AnnotationType) => void;
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
onFigureDataChange?: (figureData: FigureData) => void;
onBlurIntensityChange?: (intensity: number) => void;
onBlurColorChange?: (color: string) => void;
onDelete: () => void;
}
export const FONT_FAMILY_VALUES = [
{ value: "system-ui, -apple-system, sans-serif", labelKey: "fontStyles.classic" },
{ value: "Georgia, serif", labelKey: "fontStyles.editor" },
{ value: "Impact, Arial Black, sans-serif", labelKey: "fontStyles.strong" },
{ value: "Courier New, monospace", labelKey: "fontStyles.typewriter" },
{ value: "Brush Script MT, cursive", labelKey: "fontStyles.deco" },
{ value: "Arial, sans-serif", labelKey: "fontStyles.simple" },
{ value: "Verdana, sans-serif", labelKey: "fontStyles.modern" },
{ value: "Trebuchet MS, sans-serif", labelKey: "fontStyles.clean" },
];
export const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
export function AnnotationSettingsPanel({
annotation,
onContentChange,
onTypeChange,
onStyleChange,
onFigureDataChange,
onBlurIntensityChange,
onBlurColorChange,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT("editor");
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
const fontFamilies = useMemo(
() => FONT_FAMILY_VALUES.map((f) => ({ value: f.value, label: t(f.labelKey) })),
[t],
);
// Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
}, []);
const colorPalette = [
"#FF0000", // Red
"#FFD700", // Yellow/Gold
"#00FF00", // Green
"#FFFFFF", // White
"#0000FF", // Blue
"#FF6B00", // Orange
"#9B59B6", // Purple
"#E91E63", // Pink
"#00BCD4", // Cyan
"#FF5722", // Deep Orange
"#8BC34A", // Light Green
"#FFC107", // Amber
"#2563EB", // Brand Blue
"#000000", // Black
"#607D8B", // Blue Grey
"#795548", // Brown
];
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// Validate file type
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
toast.error(t("annotations.imageUploadError"), {
description: t("annotations.imageUploadErrorDescription"),
});
event.target.value = "";
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success(t("annotations.imageUploadSuccess"));
}
};
reader.onerror = () => {
toast.error(t("annotations.imageUploadFailed"), {
description: t("annotations.imageUploadFailedDescription"),
});
};
reader.readAsDataURL(file);
event.target.value = "";
};
return (
<Card className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden rounded-none bg-transparent p-0 shadow-none">
<div className="flex-1 min-h-0 px-5 pb-6 pt-1 overflow-y-auto custom-scrollbar">
<div className="mb-6">
{/* Type Selector */}
<div className="space-y-4">
<ChoiceGroup
aria-label="Annotation type"
value={annotation.type}
onValueChange={(value) => onTypeChange(value as AnnotationType)}
className="grid grid-cols-4 gap-2"
>
<ChoiceItem
value="text"
aria-label={t("annotations.text")}
className="min-w-0 gap-1 px-1 text-xs"
>
<Type className="w-4 h-4" />
{t("annotations.text")}
</ChoiceItem>
<ChoiceItem
value="image"
aria-label={t("annotations.image")}
className="min-w-0 gap-1 px-1 text-xs"
>
<ImageIcon className="w-4 h-4" />
{t("annotations.image")}
</ChoiceItem>
<ChoiceItem
value="figure"
aria-label={t("annotations.arrow")}
className="min-w-0 gap-1 px-1 text-xs"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
d="M4 12h16m0 0l-6-6m6 6l-6 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{t("annotations.arrow")}
</ChoiceItem>
<ChoiceItem
value="blur"
aria-label={t("annotations.blur")}
className="min-w-0 gap-1 px-1 text-xs"
>
<SquareDashed className="w-4 h-4" />
{t("annotations.blur")}
</ChoiceItem>
</ChoiceGroup>
{/* Text Content */}
{annotation.type === "text" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textContent")}
</label>
<TextArea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={t("annotations.textPlaceholder")}
rows={3}
className="w-full px-3 py-2 text-sm resize-none"
/>
</div>
{/* Styling Controls */}
<div className="space-y-4">
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.fontStyle")}
</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) =>
onStyleChange({ fontFamily: value })
}
>
<SelectTrigger className="w-full h-9 text-xs">
<SelectValue
placeholder={t("annotations.selectStyle")}
/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{!fontFamilies.some(
(font) =>
font.value ===
annotation.style.fontFamily,
) &&
!customFonts.some(
(font) =>
font.fontFamily ===
annotation.style.fontFamily,
) && (
<SelectItem
value={annotation.style.fontFamily}
>
{annotation.style.fontFamily
.split(",")[0]
.replace(/"/g, "")}
</SelectItem>
)}
{fontFamilies.map((font) => (
<SelectItem
key={font.value}
value={font.value}
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Custom Fonts
</div>
{customFonts.map((font) => (
<SelectItem
key={font.id}
value={font.fontFamily}
style={{
fontFamily: font.fontFamily,
}}
>
{font.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.size")}
</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) =>
onStyleChange({ fontSize: parseInt(value) })
}
>
<SelectTrigger className="w-full h-9 text-xs">
<SelectValue
placeholder={t("annotations.size")}
/>
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{FONT_SIZES.map((size) => (
<SelectItem
key={size}
value={size.toString()}
>
{size}px
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Add Custom Font Button */}
<div>
<AddCustomFontDialog
onFontAdded={(font) => {
setCustomFonts(getCustomFonts());
onStyleChange({ fontFamily: font.fontFamily });
}}
/>
</div>
{/* Formatting Toggles */}
<div className="flex flex-wrap items-center justify-between gap-2">
<ToggleGroup
type="multiple"
value={[
...(annotation.style.fontWeight === "bold"
? ["bold"]
: []),
...(annotation.style.fontStyle === "italic"
? ["italic"]
: []),
...(annotation.style.textDecoration === "underline"
? ["underline"]
: []),
]}
size="sm"
className="justify-start"
>
<ToggleGroupItem
value="bold"
aria-label={t("annotations.toggleBold")}
onClick={() =>
onStyleChange({
fontWeight:
annotation.style.fontWeight === "bold"
? "normal"
: "bold",
})
}
className="h-8 w-8 min-w-8 p-0"
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label={t("annotations.toggleItalic")}
onClick={() =>
onStyleChange({
fontStyle:
annotation.style.fontStyle === "italic"
? "normal"
: "italic",
})
}
className="h-8 w-8 min-w-8 p-0"
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label={t("annotations.toggleUnderline")}
onClick={() =>
onStyleChange({
textDecoration:
annotation.style.textDecoration ===
"underline"
? "none"
: "underline",
})
}
className="h-8 w-8 min-w-8 p-0"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
type="single"
value={annotation.style.textAlign}
size="sm"
className="justify-start"
>
<ToggleGroupItem
value="left"
aria-label={t("annotations.alignLeft")}
onClick={() => onStyleChange({ textAlign: "left" })}
className="h-8 w-8 min-w-8 p-0"
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="center"
aria-label={t("annotations.alignCenter")}
onClick={() =>
onStyleChange({ textAlign: "center" })
}
className="h-8 w-8 min-w-8 p-0"
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="right"
aria-label={t("annotations.alignRight")}
onClick={() =>
onStyleChange({ textAlign: "right" })
}
className="h-8 w-8 min-w-8 p-0"
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textColor")}
</label>
<ColorControl
value={annotation.style.color}
label={t("annotations.textColor")}
onChange={(color) => onStyleChange({ color })}
colors={colorPalette}
compact
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.background")}
</label>
<ColorControl
value={annotation.style.backgroundColor}
label={t("annotations.background")}
onChange={(color) =>
onStyleChange({ backgroundColor: color })
}
colors={colorPalette}
compact
onClear={() =>
onStyleChange({
backgroundColor: "transparent",
})
}
/>
</div>
</div>
</div>
</div>
)}
{/* Image Upload */}
{annotation.type === "image" && (
<div className="space-y-4">
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full gap-2 py-8"
>
<Upload className="w-5 h-5" />
{t("annotations.uploadImage")}
</Button>
{annotation.content &&
annotation.content.startsWith("data:image") && (
<div className="rounded-lg border border-foreground/10 overflow-hidden bg-foreground/5 p-2">
<img
src={annotation.content}
alt="Uploaded annotation"
className="w-full h-auto rounded-md"
/>
</div>
)}
<p className="text-xs text-muted-foreground/70 text-center leading-relaxed">
{t("annotations.supportedFormats")}
</p>
</div>
)}
{annotation.type === "figure" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground mb-3 block">
{t("annotations.arrowDirection")}
</label>
<div className="grid grid-cols-4 gap-2">
{(
[
"up",
"down",
"left",
"right",
"up-right",
"up-left",
"down-right",
"down-left",
] as ArrowDirection[]
).map((direction) => {
const ArrowComponent = getArrowComponent(direction);
return (
<ToggleButton
isSelected={
annotation.figureData?.arrowDirection ===
direction
}
key={direction}
onClick={() => {
const newFigureData: FigureData = {
...annotation.figureData!,
arrowDirection: direction,
};
onFigureDataChange?.(newFigureData);
}}
aria-label={t(
"annotations.arrowDirectionOption",
"Arrow direction: {{direction}}",
{ direction: direction.replace(/-/g, " ") },
)}
className={cn(
"h-16 flex items-center justify-center p-2",
)}
>
<ArrowComponent
color={
annotation.figureData
?.arrowDirection === direction
? "#ffffff"
: "#94a3b8"
}
strokeWidth={3}
/>
</ToggleButton>
);
})}
</div>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.strokeWidth", undefined, {
width: annotation.figureData?.strokeWidth || 4,
})}
</label>
<Slider
aria-label={t("annotations.strokeWidth", undefined, {
width: annotation.figureData?.strokeWidth || 4,
})}
value={[annotation.figureData?.strokeWidth || 4]}
onValueChange={([value]) => {
const newFigureData: FigureData = {
...annotation.figureData!,
strokeWidth: value,
};
onFigureDataChange?.(newFigureData);
}}
min={1}
max={6}
step={1}
className="my-3 w-full"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.arrowColor")}
</label>
<ColorControl
value={annotation.figureData?.color || "#2563EB"}
label={t("annotations.arrowColor")}
onChange={(color) =>
onFigureDataChange?.({
...annotation.figureData!,
color,
})
}
colors={colorPalette}
compact
/>
</div>
</div>
)}
{annotation.type === "blur" && (
<div className="space-y-4">
<div className="flex flex-col items-center">
<div className="w-full space-y-5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{t("annotations.blurStrength", undefined, {
strength: annotation.blurIntensity ?? 20,
})}
</span>
</div>
<Slider
aria-label={t("annotations.blurStrength", undefined, {
strength: annotation.blurIntensity ?? 20,
})}
value={[annotation.blurIntensity ?? 20]}
onValueChange={([value]) =>
onBlurIntensityChange?.(value)
}
min={1}
max={100}
step={1}
className="w-full"
/>
</div>
<div className="w-full space-y-5 mt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{t(
"annotations.solidColor",
"Solid Color (Censorship)",
)}
</span>
</div>
<div className="flex flex-col gap-3">
<Button
variant="secondary"
aria-pressed={
!annotation.blurColor ||
annotation.blurColor === "transparent"
}
onClick={() => onBlurColorChange?.("")}
>
{t("annotations.none", "None")}
</Button>
<ColorPalette
color={annotation.blurColor || "transparent"}
colors={colorPalette}
onChange={({ hex }) => onBlurColorChange?.(hex)}
/>
<ColorControl
label={t("annotations.customColor", "Custom color")}
value={annotation.blurColor || "#000000"}
onChange={(color) => onBlurColorChange?.(color)}
/>
</div>
</div>
</div>
</div>
)}
</div>
<details className="mt-4 text-muted-foreground">
<summary className="flex cursor-pointer items-center gap-2 py-2">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">
{t("annotations.shortcutsAndTips")}
</span>
</summary>
<ul className="text-xs text-muted-foreground space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotations.tipSelectAnnotation")}</li>
<li>{t("annotations.tipCycleForward")}</li>
<li>{t("annotations.tipCycleBackward")}</li>
</ul>
</details>
</div>
</div>
<div className="shrink-0 px-5 py-4">
<Button
onClick={onDelete}
variant="destructive-soft"
size="sm"
className="w-full gap-2"
>
<Trash2 className="w-4 h-4" />
{t("annotations.deleteAnnotation")}
</Button>
</div>
</Card>
);
}