final annotation preview and export
This commit is contained in:
@@ -2,14 +2,7 @@ import { useRef } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FaArrowUp, FaArrowDown, FaArrowLeft, FaArrowRight,
|
||||
FaCircle, FaSquare, FaStar, FaHeart, FaPlay
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
BsArrowUpRight, BsArrowDownRight, BsArrowDownLeft, BsArrowUpLeft
|
||||
} from "react-icons/bs";
|
||||
import { BiRectangle } from "react-icons/bi";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -39,96 +32,15 @@ export function AnnotationOverlay({
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
console.log('[AnnotationOverlay] Rendering:', {
|
||||
id: annotation.id,
|
||||
type: annotation.type,
|
||||
content: annotation.content.substring(0, 30),
|
||||
position: annotation.position,
|
||||
size: annotation.size,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
calculatedPixels: { x, y, width, height },
|
||||
isSelected
|
||||
});
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || 'right';
|
||||
const color = annotation.figureData?.color || '#34B27B';
|
||||
const iconProps = {
|
||||
style: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color,
|
||||
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.3))'
|
||||
}
|
||||
};
|
||||
|
||||
switch (direction) {
|
||||
case 'up': return <FaArrowUp {...iconProps} />;
|
||||
case 'down': return <FaArrowDown {...iconProps} />;
|
||||
case 'left': return <FaArrowLeft {...iconProps} />;
|
||||
case 'right': return <FaArrowRight {...iconProps} />;
|
||||
case 'up-right': return <BsArrowUpRight {...iconProps} />;
|
||||
case 'up-left': return <BsArrowUpLeft {...iconProps} />;
|
||||
case 'down-right': return <BsArrowDownRight {...iconProps} />;
|
||||
case 'down-left': return <BsArrowDownLeft {...iconProps} />;
|
||||
default: return <FaArrowRight {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderShape = () => {
|
||||
const shapeType = annotation.figureData?.shapeType || 'circle';
|
||||
const color = annotation.figureData?.color || '#34B27B';
|
||||
const filled = annotation.figureData?.filled ?? true;
|
||||
const strokeWidth = annotation.figureData?.strokeWidth || 4;
|
||||
|
||||
const shapeStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: filled ? color : 'transparent',
|
||||
stroke: color,
|
||||
strokeWidth: filled ? 0 : strokeWidth,
|
||||
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.3))'
|
||||
};
|
||||
|
||||
const IconComponent = (() => {
|
||||
switch (shapeType) {
|
||||
case 'circle': return FaCircle;
|
||||
case 'square': return FaSquare;
|
||||
case 'triangle': return FaPlay;
|
||||
case 'rectangle': return BiRectangle;
|
||||
case 'star': return FaStar;
|
||||
case 'heart': return FaHeart;
|
||||
default: return FaCircle;
|
||||
}
|
||||
})();
|
||||
|
||||
return filled ? (
|
||||
<IconComponent style={shapeStyle} />
|
||||
) : (
|
||||
<svg viewBox="0 0 100 100" style={{ width: '100%', height: '100%' }}>
|
||||
{shapeType === 'circle' && (
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke={color} strokeWidth={strokeWidth} />
|
||||
)}
|
||||
{shapeType === 'square' && (
|
||||
<rect x="10" y="10" width="80" height="80" fill="none" stroke={color} strokeWidth={strokeWidth} />
|
||||
)}
|
||||
{shapeType === 'rectangle' && (
|
||||
<rect x="5" y="20" width="90" height="60" fill="none" stroke={color} strokeWidth={strokeWidth} />
|
||||
)}
|
||||
{shapeType === 'triangle' && (
|
||||
<polygon points="50,10 90,90 10,90" fill="none" stroke={color} strokeWidth={strokeWidth} />
|
||||
)}
|
||||
{shapeType === 'star' && (
|
||||
<polygon points="50,5 61,38 95,38 68,59 79,92 50,71 21,92 32,59 5,38 39,38" fill="none" stroke={color} strokeWidth={strokeWidth} />
|
||||
)}
|
||||
{shapeType === 'heart' && (
|
||||
<path d="M50,85 C50,85 10,60 10,35 C10,20 20,10 30,10 C40,10 50,20 50,20 C50,20 60,10 70,10 C80,10 90,20 90,35 C90,60 50,85 50,85 Z" fill="none" stroke={color} strokeWidth={strokeWidth} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
const ArrowComponent = getArrowComponent(direction);
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
@@ -188,39 +100,16 @@ export function AnnotationOverlay({
|
||||
if (!annotation.figureData) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No figure data
|
||||
No arrow data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const figureType = annotation.figureData.figureType;
|
||||
|
||||
if (figureType === 'arrow') {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
{renderArrow()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (figureType === 'shape') {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
{renderShape()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (figureType === 'emoji') {
|
||||
const emojiSize = annotation.figureData.emojiSize || 64;
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center" style={{ fontSize: `${emojiSize}px` }}>
|
||||
{annotation.figureData.emoji || '😊'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
{renderArrow()}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info, Shapes, Smile } from "lucide-react";
|
||||
import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Colorful from '@uiw/react-color-colorful';
|
||||
import { hsvaToHex, hexToHsva } from '@uiw/color-convert';
|
||||
import type { AnnotationRegion, AnnotationType, FigureType, ArrowDirection, ShapeType, FigureData } from "./types";
|
||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import EmojiPicker, { EmojiClickData } from 'emoji-picker-react';
|
||||
import {
|
||||
FaArrowUp, FaArrowDown, FaArrowLeft, FaArrowRight,
|
||||
FaCircle, FaSquare, FaStar, FaHeart
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
BsArrowUpRight, BsArrowDownRight, BsArrowDownLeft, BsArrowUpLeft
|
||||
} from "react-icons/bs";
|
||||
import { FaPlay } from "react-icons/fa";
|
||||
import { BiRectangle } from "react-icons/bi";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
|
||||
interface AnnotationSettingsPanelProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -59,7 +49,6 @@ export function AnnotationSettingsPanel({
|
||||
const [figureColorHsva, setFigureColorHsva] = useState(
|
||||
hexToHsva(annotation.figureData?.color || '#34B27B')
|
||||
);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
|
||||
|
||||
@@ -121,8 +110,10 @@ export function AnnotationSettingsPanel({
|
||||
Image
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="figure" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
|
||||
<Shapes className="w-4 h-4" />
|
||||
Figure
|
||||
<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>
|
||||
Arrow
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -365,240 +356,97 @@ export function AnnotationSettingsPanel({
|
||||
|
||||
<TabsContent value="figure" className="mt-0 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Figure Type</label>
|
||||
<Tabs
|
||||
value={annotation.figureData?.figureType || 'arrow'}
|
||||
onValueChange={(value) => {
|
||||
<label className="text-xs font-medium text-slate-200 mb-3 block">Arrow Direction</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 (
|
||||
<button
|
||||
key={direction}
|
||||
onClick={() => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
arrowDirection: direction,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex items-center justify-center transition-all p-2",
|
||||
annotation.figureData?.arrowDirection === direction
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<ArrowComponent
|
||||
color={annotation.figureData?.arrowDirection === direction ? "#ffffff" : "#94a3b8"}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
|
||||
</label>
|
||||
<Slider
|
||||
value={[annotation.figureData?.strokeWidth || 4]}
|
||||
onValueChange={([value]) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
figureType: value as FigureType,
|
||||
strokeWidth: value,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
min={1}
|
||||
max={6}
|
||||
step={1}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-3 bg-white/5 border border-white/5 p-1 h-auto rounded-lg">
|
||||
<TabsTrigger value="arrow" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-md transition-all gap-1.5 text-xs">
|
||||
<FaArrowRight className="w-3 h-3" />
|
||||
Arrow
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shape" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-md transition-all gap-1.5 text-xs">
|
||||
<FaCircle className="w-3 h-3" />
|
||||
Shape
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="emoji" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-md transition-all gap-1.5 text-xs">
|
||||
<Smile className="w-3 h-3" />
|
||||
Emoji
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="arrow" className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-3 block">Arrows</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ value: 'up' as ArrowDirection, icon: FaArrowUp },
|
||||
{ value: 'down' as ArrowDirection, icon: FaArrowDown },
|
||||
{ value: 'left' as ArrowDirection, icon: FaArrowLeft },
|
||||
{ value: 'right' as ArrowDirection, icon: FaArrowRight },
|
||||
{ value: 'up-right' as ArrowDirection, icon: BsArrowUpRight },
|
||||
{ value: 'up-left' as ArrowDirection, icon: BsArrowUpLeft },
|
||||
{ value: 'down-right' as ArrowDirection, icon: BsArrowDownRight },
|
||||
{ value: 'down-left' as ArrowDirection, icon: BsArrowDownLeft },
|
||||
].map(({ value, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
arrowDirection: value,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex flex-col items-center justify-center gap-1 transition-all",
|
||||
annotation.figureData?.arrowDirection === value
|
||||
? "bg-[#34B27B] border-[#34B27B] text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shape" className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-3 block">Shape Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: 'circle' as ShapeType, icon: FaCircle, label: 'Circle' },
|
||||
{ value: 'square' as ShapeType, icon: FaSquare, label: 'Square' },
|
||||
{ value: 'rectangle' as ShapeType, icon: BiRectangle, label: 'Rectangle' },
|
||||
{ value: 'triangle' as ShapeType, icon: FaPlay, label: 'Triangle' },
|
||||
{ value: 'star' as ShapeType, icon: FaStar, label: 'Star' },
|
||||
{ value: 'heart' as ShapeType, icon: FaHeart, label: 'Heart' },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
shapeType: value,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex flex-col items-center justify-center gap-1 transition-all",
|
||||
annotation.figureData?.shapeType === value
|
||||
? "bg-[#34B27B] border-[#34B27B] text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<label className="text-xs font-medium text-slate-200">Filled</label>
|
||||
<Switch
|
||||
checked={annotation.figureData?.filled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
filled: checked,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!annotation.figureData?.filled && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
|
||||
</label>
|
||||
<Slider
|
||||
value={[annotation.figureData?.strokeWidth || 4]}
|
||||
onValueChange={([value]) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
strokeWidth: value,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emoji" className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Selected Emoji</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-16 bg-white/5 border border-white/10 rounded-lg flex items-center justify-center text-4xl">
|
||||
{annotation.figureData?.emoji || '😊'}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
variant="outline"
|
||||
className="h-16 px-6 bg-[#34B27B] text-white border-[#34B27B] hover:bg-[#2a9163] hover:border-[#2a9163]"
|
||||
>
|
||||
{showEmojiPicker ? 'Close' : 'Pick'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Emoji Size: {annotation.figureData?.emojiSize || 64}px
|
||||
</label>
|
||||
<Slider
|
||||
value={[annotation.figureData?.emojiSize || 64]}
|
||||
onValueChange={([value]) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
emojiSize: value,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
min={16}
|
||||
max={200}
|
||||
step={4}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showEmojiPicker && (
|
||||
<div className="border border-white/10 rounded-lg overflow-hidden">
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emojiData: EmojiClickData) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
emoji: emojiData.emoji,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
width="100%"
|
||||
height={300}
|
||||
searchPlaceHolder="Search emoji..."
|
||||
previewConfig={{ showPreview: false }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{annotation.figureData?.figureType !== 'emoji' && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Color</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-10 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10"
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: annotation.figureData?.color || '#34B27B' }}
|
||||
/>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{annotation.figureData?.color || '#34B27B'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={figureColorHsva}
|
||||
disableAlpha={true}
|
||||
onChange={(color) => {
|
||||
setFigureColorHsva(color.hsva);
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
color: hsvaToHex(color.hsva),
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-10 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10"
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: annotation.figureData?.color || '#34B27B' }}
|
||||
/>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{annotation.figureData?.color || '#34B27B'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={figureColorHsva}
|
||||
disableAlpha={true}
|
||||
onChange={(color) => {
|
||||
setFigureColorHsva(color.hsva);
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
color: hsvaToHex(color.hsva),
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ArrowDirection } from './types';
|
||||
|
||||
interface ArrowSvgProps {
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG arrow components for 8 directions.
|
||||
* These match the visual style of the previous icon-based arrows but use
|
||||
* pure SVG paths for easy replication in export.
|
||||
*/
|
||||
|
||||
export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function getArrowComponent(direction: ArrowDirection) {
|
||||
switch (direction) {
|
||||
case 'up': return ArrowUp;
|
||||
case 'down': return ArrowDown;
|
||||
case 'left': return ArrowLeft;
|
||||
case 'right': return ArrowRight;
|
||||
case 'up-right': return ArrowUpRight;
|
||||
case 'up-left': return ArrowUpLeft;
|
||||
case 'down-right': return ArrowDownRight;
|
||||
case 'down-left': return ArrowDownLeft;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,6 @@ export default function VideoEditor() {
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
focus: { cx: 0.5, cy: 0.5 },
|
||||
};
|
||||
console.log('Zoom region added:', newRegion);
|
||||
setZoomRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
@@ -165,7 +164,6 @@ export default function VideoEditor() {
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
};
|
||||
console.log('Trim region added:', newRegion);
|
||||
setTrimRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
@@ -173,7 +171,6 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
|
||||
console.log('Zoom span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
|
||||
setZoomRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
@@ -188,7 +185,6 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const handleTrimSpanChange = useCallback((id: string, span: Span) => {
|
||||
console.log('Trim span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
|
||||
setTrimRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
@@ -231,7 +227,6 @@ export default function VideoEditor() {
|
||||
}, [selectedZoomId]);
|
||||
|
||||
const handleZoomDelete = useCallback((id: string) => {
|
||||
console.log('Zoom region deleted:', id);
|
||||
setZoomRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedZoomId === id) {
|
||||
setSelectedZoomId(null);
|
||||
@@ -239,7 +234,6 @@ export default function VideoEditor() {
|
||||
}, [selectedZoomId]);
|
||||
|
||||
const handleTrimDelete = useCallback((id: string) => {
|
||||
console.log('Trim region deleted:', id);
|
||||
setTrimRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedTrimId === id) {
|
||||
setSelectedTrimId(null);
|
||||
@@ -260,7 +254,6 @@ export default function VideoEditor() {
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
};
|
||||
console.log('Annotation region added:', newRegion);
|
||||
setAnnotationRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
@@ -268,7 +261,6 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
|
||||
console.log('Annotation span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
@@ -283,7 +275,6 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const handleAnnotationDelete = useCallback((id: string) => {
|
||||
console.log('Annotation region deleted:', id);
|
||||
setAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
@@ -291,7 +282,6 @@ export default function VideoEditor() {
|
||||
}, [selectedAnnotationId]);
|
||||
|
||||
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
|
||||
console.log('[VideoEditor] Annotation content changed:', { id, content });
|
||||
setAnnotationRegions((prev) => {
|
||||
const updated = prev.map((region) => {
|
||||
if (region.id !== id) return region;
|
||||
@@ -305,13 +295,11 @@ export default function VideoEditor() {
|
||||
return { ...region, content };
|
||||
}
|
||||
});
|
||||
console.log('[VideoEditor] Updated annotation regions:', updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
}, []);;
|
||||
|
||||
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => {
|
||||
console.log('[VideoEditor] Annotation type changed:', { id, type });
|
||||
setAnnotationRegions((prev) => {
|
||||
const updated = prev.map((region) => {
|
||||
if (region.id !== id) return region;
|
||||
@@ -332,13 +320,11 @@ export default function VideoEditor() {
|
||||
|
||||
return updatedRegion;
|
||||
});
|
||||
console.log('[VideoEditor] Updated annotation regions after type change:', updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAnnotationStyleChange = useCallback((id: string, style: Partial<AnnotationRegion['style']>) => {
|
||||
console.log('Annotation style changed:', { id, style });
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
@@ -349,7 +335,6 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
|
||||
console.log('Annotation figure data changed:', { id, figureData });
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
@@ -500,6 +485,15 @@ export default function VideoEditor() {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
|
||||
// Get preview CONTAINER dimensions for scaling
|
||||
// Annotations render in HTML overlay matching container, not PixiJS canvas
|
||||
const playbackRef = videoPlaybackRef.current;
|
||||
const containerElement = playbackRef?.containerRef?.current;
|
||||
const previewWidth = containerElement?.clientWidth || 1920;
|
||||
const previewHeight = containerElement?.clientHeight || 1080;
|
||||
|
||||
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
width: exportWidth,
|
||||
@@ -517,6 +511,9 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -557,7 +554,7 @@ export default function VideoEditor() {
|
||||
setIsExporting(false);
|
||||
exporterRef.current = null;
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, isPlaying, aspectRatio]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio]);
|
||||
|
||||
const handleCancelExport = useCallback(() => {
|
||||
if (exporterRef.current) {
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface VideoPlaybackRef {
|
||||
app: Application | null;
|
||||
videoSprite: Sprite | null;
|
||||
videoContainer: Container | null;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => void;
|
||||
}
|
||||
@@ -209,15 +210,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
app: appRef.current,
|
||||
videoSprite: videoSpriteRef.current,
|
||||
videoContainer: videoContainerRef.current,
|
||||
containerRef,
|
||||
play: async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) {
|
||||
allowPlaybackRef.current = false;
|
||||
return;
|
||||
}
|
||||
allowPlaybackRef.current = true;
|
||||
const vid = videoRef.current;
|
||||
if (!vid) return;
|
||||
try {
|
||||
await video.play();
|
||||
allowPlaybackRef.current = true;
|
||||
await vid.play();
|
||||
} catch (error) {
|
||||
allowPlaybackRef.current = false;
|
||||
throw error;
|
||||
|
||||
@@ -21,19 +21,12 @@ export interface TrimRegion {
|
||||
|
||||
export type AnnotationType = 'text' | 'image' | 'figure';
|
||||
|
||||
export type FigureType = 'arrow' | 'shape' | 'emoji';
|
||||
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
|
||||
export type ShapeType = 'circle' | 'square' | 'rectangle' | 'triangle' | 'star' | 'heart';
|
||||
|
||||
export interface FigureData {
|
||||
figureType: FigureType;
|
||||
arrowDirection?: ArrowDirection;
|
||||
shapeType?: ShapeType;
|
||||
emoji?: string;
|
||||
emojiSize?: number;
|
||||
arrowDirection: ArrowDirection;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
filled: boolean;
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
@@ -94,12 +87,9 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
|
||||
};
|
||||
|
||||
export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
figureType: 'arrow',
|
||||
arrowDirection: 'right',
|
||||
color: '#34B27B',
|
||||
strokeWidth: 4,
|
||||
filled: true,
|
||||
emojiSize: 64,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { AnnotationRegion, ArrowDirection } from '@/components/video-editor/types';
|
||||
|
||||
// SVG path data for each arrow direction
|
||||
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
|
||||
'up': [
|
||||
'M 50 20 L 50 80',
|
||||
'M 50 20 L 35 35',
|
||||
'M 50 20 L 65 35',
|
||||
],
|
||||
'down': [
|
||||
'M 50 20 L 50 80',
|
||||
'M 50 80 L 35 65',
|
||||
'M 50 80 L 65 65',
|
||||
],
|
||||
'left': [
|
||||
'M 80 50 L 20 50',
|
||||
'M 20 50 L 35 35',
|
||||
'M 20 50 L 35 65',
|
||||
],
|
||||
'right': [
|
||||
'M 20 50 L 80 50',
|
||||
'M 80 50 L 65 35',
|
||||
'M 80 50 L 65 65',
|
||||
],
|
||||
'up-right': [
|
||||
'M 25 75 L 75 25',
|
||||
'M 75 25 L 60 30',
|
||||
'M 75 25 L 70 40',
|
||||
],
|
||||
'up-left': [
|
||||
'M 75 75 L 25 25',
|
||||
'M 25 25 L 40 30',
|
||||
'M 25 25 L 30 40',
|
||||
],
|
||||
'down-right': [
|
||||
'M 25 25 L 75 75',
|
||||
'M 75 75 L 70 60',
|
||||
'M 75 75 L 60 70',
|
||||
],
|
||||
'down-left': [
|
||||
'M 75 25 L 25 75',
|
||||
'M 25 75 L 30 60',
|
||||
'M 25 75 L 40 70',
|
||||
],
|
||||
};
|
||||
|
||||
function parseSvgPath(pathString: string, scaleX: number, scaleY: number): Array<{ cmd: string; args: number[] }> {
|
||||
const commands: Array<{ cmd: string; args: number[] }> = [];
|
||||
const parts = pathString.trim().split(/\s+/);
|
||||
|
||||
let i = 0;
|
||||
while (i < parts.length) {
|
||||
const cmd = parts[i];
|
||||
if (cmd === 'M' || cmd === 'L') {
|
||||
const x = parseFloat(parts[i + 1]) * scaleX;
|
||||
const y = parseFloat(parts[i + 2]) * scaleY;
|
||||
commands.push({ cmd, args: [x, y] });
|
||||
i += 3;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
|
||||
function renderArrow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
direction: ArrowDirection,
|
||||
color: string,
|
||||
strokeWidth: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
_scaleFactor: number
|
||||
) {
|
||||
const paths = ARROW_PATHS[direction];
|
||||
if (!paths) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
const padding = 8 * _scaleFactor;
|
||||
const availableWidth = Math.max(0, width - padding * 2);
|
||||
const availableHeight = Math.max(0, height - padding * 2);
|
||||
|
||||
const scale = Math.min(availableWidth / 100, availableHeight / 100);
|
||||
|
||||
const offsetX = padding + (availableWidth - 100 * scale) / 2;
|
||||
const offsetY = padding + (availableHeight - 100 * scale) / 2;
|
||||
|
||||
// Apply centering offset
|
||||
ctx.translate(offsetX, offsetY);
|
||||
|
||||
// Apply shadow filter
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.shadowBlur = 8 * scale;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 4 * scale;
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = strokeWidth * scale;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Draw all paths as a single shape to avoid overlapping shadows/strokes
|
||||
ctx.beginPath();
|
||||
|
||||
for (const pathString of paths) {
|
||||
const commands = parseSvgPath(pathString, scale, scale);
|
||||
|
||||
|
||||
for (const { cmd, args } of commands) {
|
||||
if (cmd === 'M') {
|
||||
ctx.moveTo(args[0], args[1]);
|
||||
} else if (cmd === 'L') {
|
||||
ctx.lineTo(args[0], args[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scaleFactor: number
|
||||
) {
|
||||
const style = annotation.style;
|
||||
|
||||
ctx.save();
|
||||
|
||||
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||
const scaledFontSize = style.fontSize * scaleFactor;
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const containerPadding = 8 * scaleFactor;
|
||||
|
||||
let textX = x;
|
||||
let textY = y + height / 2;
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
textX = x + width / 2;
|
||||
ctx.textAlign = 'center';
|
||||
} else if (style.textAlign === 'right') {
|
||||
textX = x + width - containerPadding;
|
||||
ctx.textAlign = 'right';
|
||||
} else {
|
||||
textX = x + containerPadding;
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
const lines = annotation.content.split('\n');
|
||||
const lineHeight = scaledFontSize * 1.4;
|
||||
|
||||
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const currentY = startY + index * lineHeight;
|
||||
|
||||
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
|
||||
const metrics = ctx.measureText(line);
|
||||
const verticalPadding = scaledFontSize * 0.1;
|
||||
const horizontalPadding = scaledFontSize * 0.2;
|
||||
const borderRadius = 4 * scaleFactor;
|
||||
|
||||
let bgX = textX - horizontalPadding;
|
||||
const bgWidth = metrics.width + horizontalPadding * 2;
|
||||
|
||||
const contentHeight = scaledFontSize * 1.4;
|
||||
const bgHeight = contentHeight + verticalPadding * 2;
|
||||
const bgY = currentY - bgHeight / 2;
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
bgX = textX - bgWidth / 2;
|
||||
} else if (style.textAlign === 'right') {
|
||||
bgX = textX - bgWidth;
|
||||
}
|
||||
|
||||
ctx.fillStyle = style.backgroundColor;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.fillStyle = style.color;
|
||||
ctx.fillText(line, textX, currentY);
|
||||
|
||||
if (style.textDecoration === 'underline') {
|
||||
const metrics = ctx.measureText(line);
|
||||
let underlineX = textX;
|
||||
const underlineY = currentY + scaledFontSize * 0.15;
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
underlineX = textX - metrics.width / 2;
|
||||
} else if (style.textAlign === 'right') {
|
||||
underlineX = textX - metrics.width;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = style.color;
|
||||
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(underlineX, underlineY);
|
||||
ctx.lineTo(underlineX + metrics.width, underlineY);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
async function renderImage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> {
|
||||
if (!annotation.content || !annotation.content.startsWith('data:image')) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Preserve aspect ratio - contain the image within the bounds
|
||||
const imgAspect = img.width / img.height;
|
||||
const boxAspect = width / height;
|
||||
|
||||
let drawWidth = width;
|
||||
let drawHeight = height;
|
||||
let drawX = x;
|
||||
let drawY = y;
|
||||
|
||||
if (imgAspect > boxAspect) {
|
||||
|
||||
drawHeight = width / imgAspect;
|
||||
drawY = y + (height - drawHeight) / 2;
|
||||
} else {
|
||||
drawWidth = height * imgAspect;
|
||||
drawX = x + (width - drawWidth) / 2;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('[AnnotationRenderer] Failed to load image annotation');
|
||||
resolve();
|
||||
};
|
||||
img.src = annotation.content;
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderAnnotations(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotations: AnnotationRegion[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
currentTimeMs: number,
|
||||
scaleFactor: number = 1.0
|
||||
): Promise<void> {
|
||||
// Filter active annotations at current time
|
||||
const activeAnnotations = annotations.filter(
|
||||
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs
|
||||
);
|
||||
|
||||
// Sort by z-index (lower first, so higher z-index draws on top)
|
||||
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
for (const annotation of sortedAnnotations) {
|
||||
const x = (annotation.position.x / 100) * canvasWidth;
|
||||
const y = (annotation.position.y / 100) * canvasHeight;
|
||||
const width = (annotation.size.width / 100) * canvasWidth;
|
||||
const height = (annotation.size.height / 100) * canvasHeight;
|
||||
|
||||
switch (annotation.type) {
|
||||
case 'text':
|
||||
renderText(ctx, annotation, x, y, width, height, scaleFactor);
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
await renderImage(ctx, annotation, x, y, width, height);
|
||||
break;
|
||||
|
||||
case 'figure':
|
||||
if (annotation.figureData) {
|
||||
renderArrow(
|
||||
ctx,
|
||||
annotation.figureData.arrowDirection,
|
||||
annotation.figureData.color,
|
||||
annotation.figureData.strokeWidth,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
scaleFactor
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
|
||||
import type { ZoomRegion, CropRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
|
||||
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
|
||||
import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA, VIEWPORT_SCALE } from '@/components/video-editor/videoPlayback/constants';
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from '@/components/video-editor/videoPlayback/constants';
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils';
|
||||
import { renderAnnotations } from './annotationRenderer';
|
||||
|
||||
interface FrameRenderConfig {
|
||||
width: number;
|
||||
@@ -20,6 +21,9 @@ interface FrameRenderConfig {
|
||||
cropRegion: CropRegion;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
}
|
||||
|
||||
interface AnimationState {
|
||||
@@ -297,6 +301,27 @@ export class FrameRenderer {
|
||||
|
||||
// Composite with shadows to final output canvas
|
||||
this.compositeWithShadows();
|
||||
|
||||
// Render annotations on top if present
|
||||
if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) {
|
||||
// Calculate scale factor based on export vs preview dimensions
|
||||
const previewWidth = this.config.previewWidth || 1920;
|
||||
const previewHeight = this.config.previewHeight || 1080;
|
||||
const scaleX = this.config.width / previewWidth;
|
||||
const scaleY = this.config.height / previewHeight;
|
||||
const scaleFactor = (scaleX + scaleY) / 2;
|
||||
|
||||
|
||||
|
||||
await renderAnnotations(
|
||||
this.compositeCtx,
|
||||
this.config.annotationRegions,
|
||||
this.config.width,
|
||||
this.config.height,
|
||||
timeMs,
|
||||
scaleFactor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private updateLayout(): void {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
|
||||
import { VideoFileDecoder } from './videoDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import { VideoMuxer } from './muxer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
|
||||
interface VideoExporterConfig extends ExportConfig {
|
||||
videoUrl: string;
|
||||
@@ -17,6 +17,9 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -94,6 +97,9 @@ export class VideoExporter {
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user