diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 3260a542..00bad96f 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -1,219 +1,231 @@ import { useRef } from "react"; import { Rnd } from "react-rnd"; -import type { AnnotationRegion } from "./types"; import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; +import type { AnnotationRegion } from "./types"; interface AnnotationOverlayProps { - annotation: AnnotationRegion; - isSelected: boolean; - containerWidth: number; - containerHeight: number; - onPositionChange: (id: string, position: { x: number; y: number }) => void; - onSizeChange: (id: string, size: { width: number; height: number }) => void; - onClick: (id: string) => void; - zIndex: number; - isSelectedBoost: boolean; // Boost z-index when selected for easy editing + annotation: AnnotationRegion; + isSelected: boolean; + containerWidth: number; + containerHeight: number; + onPositionChange: (id: string, position: { x: number; y: number }) => void; + onSizeChange: (id: string, size: { width: number; height: number }) => void; + onClick: (id: string) => void; + zIndex: number; + isSelectedBoost: boolean; // Boost z-index when selected for easy editing } export function AnnotationOverlay({ - annotation, - isSelected, - containerWidth, - containerHeight, - onPositionChange, - onSizeChange, - onClick, - zIndex, - isSelectedBoost, + annotation, + isSelected, + containerWidth, + containerHeight, + onPositionChange, + onSizeChange, + onClick, + zIndex, + isSelectedBoost, }: AnnotationOverlayProps) { - const x = (annotation.position.x / 100) * containerWidth; - const y = (annotation.position.y / 100) * containerHeight; - const width = (annotation.size.width / 100) * containerWidth; - const height = (annotation.size.height / 100) * containerHeight; + const x = (annotation.position.x / 100) * containerWidth; + const y = (annotation.position.y / 100) * containerHeight; + const width = (annotation.size.width / 100) * containerWidth; + const height = (annotation.size.height / 100) * containerHeight; - const isDraggingRef = useRef(false); + const isDraggingRef = useRef(false); - const renderArrow = () => { - const direction = annotation.figureData?.arrowDirection || 'right'; - const color = annotation.figureData?.color || '#2563EB'; - const strokeWidth = annotation.figureData?.strokeWidth || 4; + const renderArrow = () => { + const direction = annotation.figureData?.arrowDirection || "right"; + const color = annotation.figureData?.color || "#2563EB"; + const strokeWidth = annotation.figureData?.strokeWidth || 4; - const ArrowComponent = getArrowComponent(direction); - return ; - }; + const ArrowComponent = getArrowComponent(direction); + return ; + }; - const renderContent = () => { - switch (annotation.type) { - case 'text': - return ( -
- - {annotation.content} - -
- ); + const renderContent = () => { + switch (annotation.type) { + case "text": + return ( +
+ + {annotation.content} + +
+ ); - case 'image': - if (annotation.content && annotation.content.startsWith('data:image')) { - return ( - Annotation - ); - } - return ( -
- No image -
- ); + case "image": + if (annotation.content && annotation.content.startsWith("data:image")) { + return ( + Annotation + ); + } + return ( +
+ No image +
+ ); - case 'figure': - if (!annotation.figureData) { - return ( -
- No arrow data -
- ); - } + case "figure": + if (!annotation.figureData) { + return ( +
+ No arrow data +
+ ); + } - return ( -
- {renderArrow()} -
- ); + return ( +
{renderArrow()}
+ ); - default: - return null; - } - }; + case "blur": + return ( +
+ ); - return ( - { - isDraggingRef.current = true; - }} - onDragStop={(_e, d) => { - const xPercent = (d.x / containerWidth) * 100; - const yPercent = (d.y / containerHeight) * 100; - onPositionChange(annotation.id, { x: xPercent, y: yPercent }); - - // Reset dragging flag after a short delay to prevent click event - setTimeout(() => { - isDraggingRef.current = false; - }, 100); - }} - onResizeStop={(_e, _direction, ref, _delta, position) => { - const xPercent = (position.x / containerWidth) * 100; - const yPercent = (position.y / containerHeight) * 100; - const widthPercent = (ref.offsetWidth / containerWidth) * 100; - const heightPercent = (ref.offsetHeight / containerHeight) * 100; - onPositionChange(annotation.id, { x: xPercent, y: yPercent }); - onSizeChange(annotation.id, { width: widthPercent, height: heightPercent }); - }} - onClick={() => { - if (isDraggingRef.current) return; - onClick(annotation.id); - }} - bounds="parent" - className={cn( - "cursor-move transition-all", - isSelected && "ring-2 ring-[#2563EB] ring-offset-2 ring-offset-transparent" - )} - style={{ - zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top - pointerEvents: isSelected ? 'auto' : 'none', - border: isSelected ? '2px solid rgba(37, 99, 235, 0.8)' : 'none', - backgroundColor: isSelected ? 'rgba(37, 99, 235, 0.1)' : 'transparent', - boxShadow: isSelected ? '0 0 0 1px rgba(37, 99, 235, 0.35)' : 'none', - }} - enableResizing={isSelected} - disableDragging={!isSelected} - resizeHandleStyles={{ - topLeft: { - width: '12px', - height: '12px', - backgroundColor: isSelected ? 'white' : 'transparent', - border: isSelected ? '2px solid #2563EB' : 'none', - borderRadius: '50%', - left: '-6px', - top: '-6px', - cursor: 'nwse-resize', - }, - topRight: { - width: '12px', - height: '12px', - backgroundColor: isSelected ? 'white' : 'transparent', - border: isSelected ? '2px solid #2563EB' : 'none', - borderRadius: '50%', - right: '-6px', - top: '-6px', - cursor: 'nesw-resize', - }, - bottomLeft: { - width: '12px', - height: '12px', - backgroundColor: isSelected ? 'white' : 'transparent', - border: isSelected ? '2px solid #2563EB' : 'none', - borderRadius: '50%', - left: '-6px', - bottom: '-6px', - cursor: 'nesw-resize', - }, - bottomRight: { - width: '12px', - height: '12px', - backgroundColor: isSelected ? 'white' : 'transparent', - border: isSelected ? '2px solid #2563EB' : 'none', - borderRadius: '50%', - right: '-6px', - bottom: '-6px', - cursor: 'nwse-resize', - }, - }} - > -
- {renderContent()} -
-
- ); -} + default: + return null; + } + }; + return ( + { + isDraggingRef.current = true; + }} + onDragStop={(_e, d) => { + const xPercent = (d.x / containerWidth) * 100; + const yPercent = (d.y / containerHeight) * 100; + onPositionChange(annotation.id, { x: xPercent, y: yPercent }); + + // Reset dragging flag after a short delay to prevent click event + setTimeout(() => { + isDraggingRef.current = false; + }, 100); + }} + onResizeStop={(_e, _direction, ref, _delta, position) => { + const xPercent = (position.x / containerWidth) * 100; + const yPercent = (position.y / containerHeight) * 100; + const widthPercent = (ref.offsetWidth / containerWidth) * 100; + const heightPercent = (ref.offsetHeight / containerHeight) * 100; + onPositionChange(annotation.id, { x: xPercent, y: yPercent }); + onSizeChange(annotation.id, { width: widthPercent, height: heightPercent }); + }} + onClick={() => { + if (isDraggingRef.current) return; + onClick(annotation.id); + }} + bounds="parent" + className={cn( + "cursor-move transition-all", + isSelected && "ring-2 ring-[#2563EB] ring-offset-2 ring-offset-transparent", + )} + style={{ + zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top + pointerEvents: isSelected ? "auto" : "none", + border: isSelected ? "2px solid rgba(37, 99, 235, 0.8)" : "none", + backgroundColor: isSelected ? "rgba(37, 99, 235, 0.1)" : "transparent", + boxShadow: isSelected ? "0 0 0 1px rgba(37, 99, 235, 0.35)" : "none", + }} + enableResizing={isSelected} + disableDragging={!isSelected} + resizeHandleStyles={{ + topLeft: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + left: "-6px", + top: "-6px", + cursor: "nwse-resize", + }, + topRight: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + right: "-6px", + top: "-6px", + cursor: "nesw-resize", + }, + bottomLeft: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + left: "-6px", + bottom: "-6px", + cursor: "nesw-resize", + }, + bottomRight: { + width: "12px", + height: "12px", + backgroundColor: isSelected ? "white" : "transparent", + border: isSelected ? "2px solid #2563EB" : "none", + borderRadius: "50%", + right: "-6px", + bottom: "-6px", + cursor: "nwse-resize", + }, + }} + > +
+ {renderContent()} +
+
+ ); +} diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 2cddf970..1f6a43b3 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -1,529 +1,667 @@ -import { useRef, useState, useEffect, useMemo } 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 } from "lucide-react"; +import Block from "@uiw/react-color-block"; +import { + AlignCenter, + AlignLeft, + AlignRight, + Bold, + ChevronDown, + Image as ImageIcon, + Info, + Italic, + Trash2, + Type, + Underline, + Upload, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import Block from '@uiw/react-color-block'; -import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types"; +import { Button } from "@/components/ui/button"; 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; import { cn } from "@/lib/utils"; -import { getArrowComponent } from "./ArrowSvgs"; -import { AddCustomFontDialog } from "./AddCustomFontDialog"; -import { getCustomFonts, type CustomFont } from "@/lib/customFonts"; 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) => void; - onFigureDataChange?: (figureData: FigureData) => void; - onDelete: () => void; + annotation: AnnotationRegion; + onContentChange: (content: string) => void; + onTypeChange: (type: AnnotationType) => void; + onStyleChange: (style: Partial) => void; + onFigureDataChange?: (figureData: FigureData) => void; + onBlurIntensityChange?: (intensity: number) => 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' }, + { 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, - onDelete, + annotation, + onContentChange, + onTypeChange, + onStyleChange, + onFigureDataChange, + onBlurIntensityChange, + onDelete, }: AnnotationSettingsPanelProps) { - const t = useScopedT('editor'); - const fileInputRef = useRef(null); - const [customFonts, setCustomFonts] = useState([]); - - 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) => { - 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 ( -
-
-
- {t('annotations.settings')} - - {t('annotations.active')} - -
- - {/* Type Selector */} - onTypeChange(value as AnnotationType)} className="mb-6"> - - - - {t('annotations.text')} - - - - {t('annotations.image')} - - - - - - {t('annotations.arrow')} - - - - {/* Text Content */} - -
- -