final annotation preview and export

This commit is contained in:
Siddharth
2025-12-01 11:20:05 -07:00
parent 6ac712eaac
commit 262745a97f
9 changed files with 663 additions and 400 deletions
@@ -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>
+194
View File
@@ -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;
}
}
+14 -17
View File
@@ -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;
+1 -11
View File
@@ -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,
};
+315
View File
@@ -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;
}
}
}
+27 -2
View File
@@ -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 {
+7 -1
View File
@@ -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();