Merge branch 'main' into main

This commit is contained in:
Sid
2025-12-04 16:46:16 -08:00
committed by GitHub
33 changed files with 2980 additions and 437 deletions
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }
@@ -0,0 +1,218 @@
import { useRef } from "react";
import { Rnd } from "react-rnd";
import type { AnnotationRegion } from "./types";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
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
}
export function AnnotationOverlay({
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 isDraggingRef = useRef(false);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || 'right';
const color = annotation.figureData?.color || '#34B27B';
const strokeWidth = annotation.figureData?.strokeWidth || 4;
const ArrowComponent = getArrowComponent(direction);
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const renderContent = () => {
switch (annotation.type) {
case 'text':
return (
<div
className="w-full h-full flex items-center p-2 overflow-hidden"
style={{
justifyContent: annotation.style.textAlign === 'left' ? 'flex-start' :
annotation.style.textAlign === 'right' ? 'flex-end' : 'center',
alignItems: 'center',
}}
>
<span
style={{
color: annotation.style.color,
backgroundColor: annotation.style.backgroundColor,
fontSize: `${annotation.style.fontSize}px`,
fontFamily: annotation.style.fontFamily,
fontWeight: annotation.style.fontWeight,
fontStyle: annotation.style.fontStyle,
textDecoration: annotation.style.textDecoration,
textAlign: annotation.style.textAlign,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
padding: '0.1em 0.2em',
borderRadius: '4px',
lineHeight: '1.4',
}}
>
{annotation.content}
</span>
</div>
);
case 'image':
if (annotation.content && annotation.content.startsWith('data:image')) {
return (
<img
src={annotation.content}
alt="Annotation"
className="w-full h-full object-contain"
draggable={false}
/>
);
}
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No image
</div>
);
case 'figure':
if (!annotation.figureData) {
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No arrow data
</div>
);
}
return (
<div className="w-full h-full flex items-center justify-center p-2">
{renderArrow()}
</div>
);
default:
return null;
}
};
return (
<Rnd
position={{ x, y }}
size={{ width, height }}
onDragStart={() => {
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-[#34B27B] 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(52, 178, 123, 0.8)' : 'none',
backgroundColor: isSelected ? 'rgba(52, 178, 123, 0.1)' : 'transparent',
boxShadow: isSelected ? '0 0 0 1px rgba(52, 178, 123, 0.35)' : 'none',
}}
enableResizing={isSelected}
disableDragging={!isSelected}
resizeHandleStyles={{
topLeft: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
left: '-6px',
top: '-6px',
cursor: 'nwse-resize',
},
topRight: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
right: '-6px',
top: '-6px',
cursor: 'nesw-resize',
},
bottomLeft: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
left: '-6px',
bottom: '-6px',
cursor: 'nesw-resize',
},
bottomRight: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
right: '-6px',
bottom: '-6px',
cursor: 'nwse-resize',
},
}}
>
<div
className={cn(
"w-full h-full rounded-lg",
annotation.type === 'text' && "bg-transparent",
annotation.type === 'image' && "bg-transparent",
annotation.type === 'figure' && "bg-transparent",
isSelected && "shadow-lg"
)}
>
{renderContent()}
</div>
</Rnd>
);
}
@@ -0,0 +1,477 @@
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 } 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, 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 { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
onContentChange: (content: string) => void;
onTypeChange: (type: AnnotationType) => void;
onStyleChange: (style: Partial<AnnotationRegion['style']>) => void;
onFigureDataChange?: (figureData: FigureData) => void;
onDelete: () => void;
}
const FONT_FAMILIES = [
{ value: 'system-ui, -apple-system, sans-serif', label: 'Classic' },
{ value: 'Georgia, serif', label: 'Editor' },
{ value: 'Impact, Arial Black, sans-serif', label: 'Strong' },
{ value: 'Courier New, monospace', label: 'Typewriter' },
{ value: 'Brush Script MT, cursive', label: 'Deco' },
{ value: 'Arial, sans-serif', label: 'Simple' },
{ value: 'Verdana, sans-serif', label: 'Modern' },
{ value: 'Trebuchet MS, sans-serif', label: 'Clean' },
];
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,
}: AnnotationSettingsPanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [textColorHsva, setTextColorHsva] = useState(hexToHsva(annotation.style.color));
const [bgColorHsva, setBgColorHsva] = useState(hexToHsva(annotation.style.backgroundColor || '#00000000'));
const [figureColorHsva, setFigureColorHsva] = useState(
hexToHsva(annotation.figureData?.color || '#34B27B')
);
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// Validate file type
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error('Invalid file type', {
description: 'Please upload a JPG, PNG, GIF, or WebP image file.',
});
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success('Image uploaded successfully!');
}
};
reader.onerror = () => {
toast.error('Failed to upload image', {
description: 'There was an error reading the file.',
});
};
reader.readAsDataURL(file);
event.target.value = '';
};
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
Active
</span>
</div>
{/* Type Selector */}
<Tabs value={annotation.type} onValueChange={(value) => onTypeChange(value as AnnotationType)} className="mb-6">
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<TabsTrigger value="text" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<Type className="w-4 h-4" />
Text
</TabsTrigger>
<TabsTrigger value="image" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<ImageIcon className="w-4 h-4" />
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">
<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>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
<textarea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder="Enter your text..."
rows={5}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
/>
</div>
{/* Styling Controls */}
<div className="space-y-4">
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Font Style</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) => onStyleChange({ fontFamily: value })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Select style" />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
{FONT_FAMILIES.map((font) => (
<SelectItem key={font.value} value={font.value} style={{ fontFamily: font.value }}>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Size" />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
{FONT_SIZES.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}px
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Formatting Toggles */}
<div className="flex items-center justify-between gap-2">
<ToggleGroup type="multiple" className="justify-start bg-white/5 p-1 rounded-lg border border-white/5">
<ToggleGroupItem
value="bold"
aria-label="Toggle bold"
data-state={annotation.style.fontWeight === 'bold' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontWeight: annotation.style.fontWeight === 'bold' ? 'normal' : 'bold' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label="Toggle italic"
data-state={annotation.style.fontStyle === 'italic' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontStyle: annotation.style.fontStyle === 'italic' ? 'normal' : 'italic' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label="Toggle underline"
data-state={annotation.style.textDecoration === 'underline' ? 'on' : 'off'}
onClick={() => onStyleChange({ textDecoration: annotation.style.textDecoration === 'underline' ? 'none' : 'underline' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" value={annotation.style.textAlign} className="justify-start bg-white/5 p-1 rounded-lg border border-white/5">
<ToggleGroupItem
value="left"
aria-label="Align left"
onClick={() => onStyleChange({ textAlign: 'left' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="center"
aria-label="Align center"
onClick={() => onStyleChange({ textAlign: 'center' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="right"
aria-label="Align right"
onClick={() => onStyleChange({ textAlign: 'right' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Color</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: annotation.style.color }}
/>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.color}
</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={textColorHsva}
disableAlpha={true}
onChange={(color) => {
setTextColorHsva(color.hsva);
onStyleChange({ color: hsvaToHex(color.hsva) });
}}
style={{ width: '100%', borderRadius: '8px' }}
/>
</div>
</PopoverContent>
</Popover>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Background</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20 relative overflow-hidden"
>
<div className="absolute inset-0 checkerboard-bg opacity-50" />
<div
className="absolute inset-0"
style={{ backgroundColor: annotation.style.backgroundColor }}
/>
</div>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.backgroundColor === 'transparent' ? 'None' : 'Color'}
</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={bgColorHsva}
onChange={(color) => {
setBgColorHsva(color.hsva);
onStyleChange({ backgroundColor: hsvaToHex(color.hsva) });
}}
style={{ width: '100%', borderRadius: '8px' }}
/>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
onStyleChange({ backgroundColor: 'transparent' });
setBgColorHsva({ h: 0, s: 0, v: 0, a: 0 });
}}
>
Clear Background
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</TabsContent>
{/* Image Upload */}
<TabsContent value="image" className="mt-0 space-y-4">
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
>
<Upload className="w-5 h-5" />
Upload Image
</Button>
{annotation.content && annotation.content.startsWith('data:image') && (
<div className="rounded-lg border border-white/10 overflow-hidden bg-white/5 p-2">
<img
src={annotation.content}
alt="Uploaded annotation"
className="w-full h-auto rounded-md"
/>
</div>
)}
<p className="text-xs text-slate-500 text-center leading-relaxed">
Supported formats: JPG, PNG, GIF, WebP
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<div>
<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!,
strokeWidth: value,
};
onFigureDataChange?.(newFigureData);
}}
min={1}
max={6}
step={1}
className="w-full"
/>
</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>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
Delete Annotation
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">Shortcuts & Tips</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>Move playhead to overlapping annotation section and select an item.</li>
<li>Use <kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to cycle through overlapping items.</li>
<li>Use <kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Shift+Tab</kbd> to cycle backwards.</li>
</ul>
</div>
</div>
</div>
);
}
+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;
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
interface CropRegion {
x: number; // 0-1 normalized
@@ -18,7 +18,7 @@ interface CropControlProps {
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
export function CropControl({ videoElement, cropRegion, onCropChange, aspectRatio }: CropControlProps) {
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
+1 -7
View File
@@ -114,13 +114,7 @@ export function ExportDialog({
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Current Frame</div>
<div className="text-slate-200 font-mono text-lg font-medium">
{progress.currentFrame} <span className="text-slate-500 text-sm">/ {progress.totalFrames}</span>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Status</div>
<div className="text-slate-200 font-medium text-sm flex items-center gap-2 h-[28px]">
@@ -33,6 +33,10 @@ export function KeyboardShortcutsHelp() {
<span className="text-slate-400">Add Zoom</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Z</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Annotation</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">A</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Keyframe</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
@@ -53,6 +57,10 @@ export function KeyboardShortcutsHelp() {
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pause/Play</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
</div>
</div>
</div>
</div>
+112 -16
View File
@@ -11,10 +11,12 @@ import { hsvaToHex } from '@uiw/color-convert';
import { Trash2, Download, Crop, X, Bug, Upload } from "lucide-react";
import { GiHearts } from "react-icons/gi";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion } from "./types";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportQuality } from "@/lib/exporter";
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
@@ -66,7 +68,16 @@ interface SettingsPanelProps {
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
videoElement?: HTMLVideoElement | null;
exportQuality?: ExportQuality;
onExportQualityChange?: (quality: ExportQuality) => void;
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
onAnnotationContentChange?: (id: string, content: string) => void;
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
onAnnotationDelete?: (id: string) => void;
}
export default SettingsPanel;
@@ -80,7 +91,38 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, motionBlurEnabled = true, onMotionBlurChange, borderRadius = 0, onBorderRadiusChange, padding = 50, onPaddingChange, cropRegion, onCropChange, aspectRatio, videoElement, onExport }: SettingsPanelProps) {
export function SettingsPanel({
selected,
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomId,
onZoomDelete,
shadowIntensity = 0,
onShadowChange,
showBlur,
onBlurChange,
motionBlurEnabled = true,
onMotionBlurChange,
borderRadius = 0,
onBorderRadiusChange,
padding = 50,
onPaddingChange,
cropRegion,
onCropChange,
aspectRatio,
videoElement,
exportQuality = 'good',
onExportQualityChange,
onExport,
selectedAnnotationId,
annotationRegions = [],
onAnnotationContentChange,
onAnnotationTypeChange,
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
}: SettingsPanelProps) {
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -156,6 +198,25 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
}
};
// Find selected annotation
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find(a => a.id === selectedAnnotationId)
: null;
// If an annotation is selected, show annotation settings instead
if (selectedAnnotation && onAnnotationContentChange && onAnnotationTypeChange && onAnnotationStyleChange && onAnnotationDelete) {
return (
<AnnotationSettingsPanel
annotation={selectedAnnotation}
onContentChange={(content) => onAnnotationContentChange(selectedAnnotation.id, content)}
onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)}
onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)}
onFigureDataChange={onAnnotationFigureDataChange ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) : undefined}
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
@@ -232,10 +293,10 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</div>
<div className="mb-6">
<div className="grid grid-cols-2 gap-3">
<div className="mb-4">
<div className="grid grid-cols-2 gap-2.5">
{/* Drop Shadow Slider */}
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
<div className="p-2.5 rounded-xl bg-white/5 border border-white/5 space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Shadow</div>
<span className="text-[10px] text-slate-400 font-mono">{Math.round(shadowIntensity * 100)}%</span>
@@ -250,7 +311,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
/>
</div>
{/* Corner Roundness Slider */}
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
<div className="p-2.5 rounded-xl bg-white/5 border border-white/5 space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Roundness</div>
<span className="text-[10px] text-slate-400 font-mono">{borderRadius}px</span>
@@ -265,7 +326,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
/>
</div>
{/* Padding Slider */}
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
<div className="p-2.5 rounded-xl bg-white/5 border border-white/5 space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Padding</div>
<span className="text-[10px] text-slate-400 font-mono">{padding}%</span>
@@ -282,18 +343,15 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</div>
<div className="mb-6">
<div className="mb-4">
<Button
onClick={() => setShowCropDropdown(!showCropDropdown)}
variant="outline"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white h-11 transition-all"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white h-9 transition-all"
>
<Crop className="w-4 h-4" />
Crop Video
</Button>
<p className="text-[10px] text-slate-500 text-center mt-3 px-4 leading-relaxed">
If the preview looks weirdly positioned or doesn't load, try force reloading the app a few times till it works.
</p>
</div>
{showCropDropdown && cropRegion && onCropChange && (
@@ -344,7 +402,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</TabsList>
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-2">
<TabsContent value="image" className="mt-0 space-y-3">
<TabsContent value="image" className="mt-0 space-y-3 px-2">
{/* Upload Button */}
<input
type="file"
@@ -422,7 +480,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</TabsContent>
<TabsContent value="color" className="mt-0">
<TabsContent value="color" className="mt-0 px-2">
<div className="p-1">
<Colorful
color={hsva}
@@ -436,7 +494,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</TabsContent>
<TabsContent value="gradient" className="mt-0">
<TabsContent value="gradient" className="mt-0 px-2">
<div className="grid grid-cols-6 gap-2.5">
{GRADIENTS.map((g, idx) => (
<div
@@ -458,7 +516,45 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</Tabs>
<div className="mt-6 pt-6 border-t border-white/5">
<div className="mt-4 pt-4 border-t border-white/5">
<div className="mb-2 text-xs font-medium text-slate-400">Export Quality</div>
{/* Export Quality Button Group */}
<div className="mb-2.5 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<button
onClick={() => onExportQualityChange?.('medium')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'medium'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Medium
</button>
<button
onClick={() => onExportQualityChange?.('good')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'good'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Good
</button>
<button
onClick={() => onExportQualityChange?.('source')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'source'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Source
</button>
</div>
<Button
type="button"
size="lg"
+291 -53
View File
@@ -16,13 +16,19 @@ import {
DEFAULT_ZOOM_DEPTH,
clampFocusToDepth,
DEFAULT_CROP_REGION,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_FIGURE_DATA,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
type TrimRegion,
type AnnotationRegion,
type CropRegion,
type FigureData,
} from "./types";
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
const WALLPAPER_COUNT = 18;
@@ -46,15 +52,20 @@ export default function VideoEditor() {
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef<VideoExporter | null>(null);
// Helper to convert file path to proper file:// URL
@@ -118,7 +129,18 @@ export default function VideoEditor() {
const handleSelectTrim = useCallback((id: string | null) => {
setSelectedTrimId(id);
if (id) setSelectedZoomId(null);
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
}
}, []);
const handleSelectAnnotation = useCallback((id: string | null) => {
setSelectedAnnotationId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
}
}, []);
const handleZoomAdded = useCallback((span: Span) => {
@@ -130,10 +152,10 @@ 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);
setSelectedAnnotationId(null);
}, []);
const handleTrimAdded = useCallback((span: Span) => {
@@ -143,14 +165,13 @@ 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);
setSelectedAnnotationId(null);
}, []);
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
@@ -165,7 +186,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
@@ -208,7 +228,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);
@@ -216,13 +235,169 @@ 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);
}
}, [selectedTrimId]);
const handleAnnotationAdded = useCallback((span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
const newRegion: AnnotationRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
type: 'text',
content: 'Enter text...',
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
setAnnotationRegions((prev) => [...prev, newRegion]);
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
}, []);
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleAnnotationDelete = useCallback((id: string) => {
setAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId]);
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
// Store content in type-specific fields
if (region.type === 'text') {
return { ...region, content, textContent: content };
} else if (region.type === 'image') {
return { ...region, content, imageContent: content };
} else {
return { ...region, content };
}
});
return updated;
});
}, []);;
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
const updatedRegion = { ...region, type };
// Restore content from type-specific storage
if (type === 'text') {
updatedRegion.content = region.textContent || 'Enter text...';
} else if (type === 'image') {
updatedRegion.content = region.imageContent || '';
} else if (type === 'figure') {
updatedRegion.content = '';
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
}
return updatedRegion;
});
return updated;
});
}, []);
const handleAnnotationStyleChange = useCallback((id: string, style: Partial<AnnotationRegion['style']>) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, style: { ...region.style, ...style } }
: region,
),
);
}, []);
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, figureData }
: region,
),
);
}, []);
const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, position }
: region,
),
);
}, []);
const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, size }
: region,
),
);
}, []);
// Global Tab prevention
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
// Allow tab only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();
}
if (e.key === ' ' || e.code === 'Space') {
// Allow space only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();
const playback = videoPlaybackRef.current;
if (playback?.video) {
if (playback.video.paused) {
playback.play().catch(console.error);
} else {
playback.pause();
}
}
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, []);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
setSelectedZoomId(null);
@@ -235,6 +410,12 @@ export default function VideoEditor() {
}
}, [selectedTrimId, trimRegions]);
useEffect(() => {
if (selectedAnnotationId && !annotationRegions.some((region) => region.id === selectedAnnotationId)) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId, annotationRegions]);
const handleExport = useCallback(async () => {
if (!videoPath) {
toast.error('No video loaded');
@@ -269,58 +450,91 @@ export default function VideoEditor() {
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
let exportWidth: number = sourceWidth;
let exportHeight: number = sourceHeight;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
// Iterate down from baseWidth to find exact match
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
if (exportQuality === 'source') {
// Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
// Iterate down from baseWidth to find exact match
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
// Iterate down from baseHeight to find exact match
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
// Iterate down from baseHeight to find exact match
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
// Use quality-based target resolution
const targetHeight = exportQuality === 'medium' ? 720 : 1080;
// Calculate dimensions maintaining aspect ratio
exportHeight = Math.floor(targetHeight / 2) * 2; // Ensure even
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; // Ensure even
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000; // 10 Mbps for 720p
} else if (totalPixels <= 1920 * 1080) {
bitrate = 20_000_000; // 20 Mbps for 1080p
} else {
bitrate = 30_000_000;
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
let bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
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,
@@ -339,6 +553,9 @@ export default function VideoEditor() {
borderRadius,
padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -379,7 +596,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, exportQuality]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
@@ -433,6 +650,7 @@ export default function VideoEditor() {
videoPath={videoPath || ''}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
@@ -449,6 +667,11 @@ export default function VideoEditor() {
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
/>
</div>
</div>
@@ -490,6 +713,12 @@ export default function VideoEditor() {
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
/>
@@ -520,7 +749,16 @@ export default function VideoEditor() {
onCropChange={setCropRegion}
aspectRatio={aspectRatio}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
onExport={handleExport}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
/>
</div>
+97 -14
View File
@@ -2,7 +2,7 @@ import type React from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
import { getAssetPath } from "@/lib/assetPath";
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion } from "./types";
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
import { clamp01 } from "./videoPlayback/mathUtils";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
@@ -12,11 +12,13 @@ import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/la
import { applyZoomTransform } from "./videoPlayback/zoomTransform";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
import { AnnotationOverlay } from "./AnnotationOverlay";
interface VideoPlaybackProps {
videoPath: string;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
currentTime: number;
onPlayStateChange: (playing: boolean) => void;
onError: (error: string) => void;
wallpaper?: string;
@@ -34,6 +36,11 @@ interface VideoPlaybackProps {
cropRegion?: import('./types').CropRegion;
trimRegions?: TrimRegion[];
aspectRatio: AspectRatio;
annotationRegions?: AnnotationRegion[];
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
}
export interface VideoPlaybackRef {
@@ -41,6 +48,7 @@ export interface VideoPlaybackRef {
app: Application | null;
videoSprite: Sprite | null;
videoContainer: Container | null;
containerRef: React.RefObject<HTMLDivElement>;
play: () => Promise<void>;
pause: () => void;
}
@@ -49,6 +57,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoPath,
onDurationChange,
onTimeUpdate,
currentTime,
onPlayStateChange,
onError,
wallpaper,
@@ -66,6 +75,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
cropRegion,
trimRegions = [],
aspectRatio,
annotationRegions = [],
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -98,6 +112,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const motionBlurEnabledRef = useRef(motionBlurEnabled);
const videoReadyRafRef = useRef<number | null>(null);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -196,15 +211,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;
@@ -480,6 +493,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
video.pause();
video.currentTime = 0;
allowPlaybackRef.current = false;
lockedVideoDimensionsRef.current = null;
setVideoReady(false);
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
}, [videoPath]);
@@ -691,13 +710,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
video.pause();
allowPlaybackRef.current = false;
currentTimeRef.current = 0;
// hacky fix: To ensure video is fully ready for PixiJS
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
const waitForRenderableFrame = () => {
const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0;
const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
if (hasDimensions && hasData) {
videoReadyRafRef.current = null;
setVideoReady(true);
});
});
return;
}
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
};
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
@@ -744,6 +774,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
return () => { mounted = false }
}, [wallpaper])
useEffect(() => {
return () => {
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
};
}, [])
const isImageUrl = Boolean(resolvedWallpaper && (resolvedWallpaper.startsWith('file://') || resolvedWallpaper.startsWith('http') || resolvedWallpaper.startsWith('/') || resolvedWallpaper.startsWith('data:')))
const backgroundStyle = isImageUrl
? { backgroundImage: `url(${resolvedWallpaper || ''})` }
@@ -784,6 +823,50 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: 'none', pointerEvents: 'none' }}
/>
{(() => {
const filtered = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== 'number' || typeof annotation.endMs !== 'number') return false;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
});
// Sort by z-index (lowest to highest) so higher z-index renders on top
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && sorted.length > 1) {
// Find current index and cycle to next
const currentIndex = sorted.findIndex(a => a.id === clickedId);
const nextIndex = (currentIndex + 1) % sorted.length;
onSelectAnnotation(sorted[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
return sorted.map((annotation) => (
<AnnotationOverlay
key={annotation.id}
annotation={annotation}
isSelected={annotation.id === selectedAnnotationId}
containerWidth={overlayRef.current?.clientWidth || 800}
containerHeight={overlayRef.current?.clientHeight || 600}
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
onClick={handleAnnotationClick}
zIndex={annotation.zIndex}
isSelectedBoost={annotation.id === selectedAnnotationId}
/>
));
})()}
</div>
)}
<video
+25 -6
View File
@@ -1,7 +1,7 @@
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
import { ZoomIn, Scissors } from "lucide-react";
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -12,7 +12,7 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
variant?: 'zoom' | 'trim';
variant?: 'zoom' | 'trim' | 'annotation';
}
// Map zoom depth to multiplier labels
@@ -32,7 +32,8 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
variant = 'zoom'
variant = 'zoom',
children
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
@@ -41,8 +42,19 @@ export default function Item({
});
const isZoom = variant === 'zoom';
const glassClass = isZoom ? glassStyles.glassGreen : glassStyles.glassRed;
const endCapColor = isZoom ? '#21916A' : '#ef4444';
const isTrim = variant === 'trim';
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: glassStyles.glassYellow;
const endCapColor = isZoom
? '#21916A'
: isTrim
? '#ef4444'
: '#B4A046';
return (
<div
@@ -85,13 +97,20 @@ export default function Item({
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : (
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
Trim
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{children}
</span>
</>
)}
</div>
</div>
@@ -50,6 +50,32 @@
z-index: 10;
}
.glassYellow {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(180, 160, 70, 0.15);
border: 1px solid rgba(180, 160, 70, 0.3);
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassYellow:hover {
background: rgba(180, 160, 70, 0.25);
border-color: rgba(180, 160, 70, 0.5);
box-shadow: 0 4px 20px 0 rgba(180, 160, 70, 0.2) inset;
}
.glassYellow.selected {
background: rgba(180, 160, 70, 0.35);
border-color: #B4A046;
box-shadow: 0 0 0 1px #B4A046, 0 4px 20px 0 rgba(180, 160, 70, 0.3) inset;
z-index: 10;
}
.zoomEndCap {
position: absolute;
top: 0;
@@ -62,7 +88,11 @@
}
.glassGreen:hover .zoomEndCap,
.glassGreen.selected .zoomEndCap {
.glassGreen.selected .zoomEndCap,
.glassRed:hover .zoomEndCap,
.glassRed.selected .zoomEndCap,
.glassYellow:hover .zoomEndCap,
.glassYellow.selected .zoomEndCap {
opacity: 1;
}
@@ -72,9 +102,10 @@
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
}
.zoomEndCap.right {
right: 0;
cursor: ew-resize;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
}
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTimelineContext } from "dnd-timeline";
import { Button } from "@/components/ui/button";
import { Plus, Scissors, ZoomIn, ChevronDown, Check } from "lucide-react";
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import TimelineWrapper from "./TimelineWrapper";
@@ -9,7 +9,7 @@ import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion, TrimRegion } from "../types";
import type { ZoomRegion, TrimRegion, AnnotationRegion } from "../types";
import { v4 as uuidv4 } from 'uuid';
import {
DropdownMenu,
@@ -22,6 +22,7 @@ import { formatShortcut } from "@/utils/platformUtils";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -35,13 +36,18 @@ interface TimelineEditorProps {
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
// Trim props
trimRegions?: TrimRegion[];
onTrimAdded?: (span: Span) => void;
onTrimSpanChange?: (id: string, span: Span) => void;
onTrimDelete?: (id: string) => void;
selectedTrimId?: string | null;
onSelectTrim?: (id: string | null) => void;
annotationRegions?: AnnotationRegion[];
onAnnotationAdded?: (span: Span) => void;
onAnnotationSpanChange?: (id: string, span: Span) => void;
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
}
@@ -60,7 +66,7 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
variant: 'zoom' | 'trim';
variant: 'zoom' | 'trim' | 'annotation';
}
const SCALE_CANDIDATES = [
@@ -150,13 +156,50 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) {
function PlaybackCursor({
currentTimeMs,
videoDurationMs
videoDurationMs,
onSeek,
timelineRef,
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
timelineRef: React.RefObject<HTMLDivElement>;
}) {
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current || !onSeek) return;
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
// Allow dragging outside to 0 or max, but clamp the value
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
onSeek(absoluteMs / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = '';
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'ew-resize';
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
};
}, [isDragging, onSeek, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]);
if (videoDurationMs <= 0 || currentTimeMs < 0) {
return null;
@@ -172,22 +215,27 @@ function PlaybackCursor({
return (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
className="absolute top-0 bottom-0 z-50 group/cursor"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
pointerEvents: 'none', // Allow clicks to pass through to timeline, but we'll enable pointer events on the handle
}}
>
<div
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)]"
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(52,178,123,0.7)] transition-shadow"
style={{
[sideProperty]: `${offset}px`,
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent timeline click
setIsDragging(true);
}}
>
<div
className="absolute -top-1 left-1/2 -translate-x-1/2"
style={{ width: '12px', height: '12px' }}
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
style={{ width: '16px', height: '16px' }}
>
<div className="w-full h-full bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
</div>
</div>
</div>
@@ -319,8 +367,10 @@ function Timeline({
onSeek,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
@@ -329,10 +379,18 @@ function Timeline({
onSeek?: (time: number) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
const setRefs = useCallback((node: HTMLDivElement | null) => {
setTimelineRef(node);
localTimelineRef.current = node;
}, [setTimelineRef]);
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
@@ -341,6 +399,7 @@ function Timeline({
// This is handled by event propagation - items stop propagation
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -352,21 +411,27 @@ function Timeline({
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
}, [onSeek, onSelectZoom, onSelectTrim, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
return (
<div
ref={setTimelineRef}
ref={setRefs}
style={style}
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
onClick={handleTimelineClick}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
<PlaybackCursor
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}
onSeek={onSeek}
timelineRef={localTimelineRef}
/>
<Row id={ZOOM_ROW_ID}>
{zoomItems.map((item) => (
@@ -400,6 +465,22 @@ function Timeline({
</Item>
))}
</Row>
<Row id={ANNOTATION_ROW_ID}>
{annotationItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedAnnotationId}
onSelect={() => onSelectAnnotation?.(item.id)}
variant="annotation"
>
{item.label}
</Item>
))}
</Row>
</div>
);
}
@@ -420,6 +501,12 @@ export default function TimelineEditor({
onTrimDelete,
selectedTrimId,
onSelectTrim,
annotationRegions = [],
onAnnotationAdded,
onAnnotationSpanChange,
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
@@ -476,6 +563,12 @@ export default function TimelineEditor({
onSelectTrim(null);
}, [selectedTrimId, onTrimDelete, onSelectTrim]);
const deleteSelectedAnnotation = useCallback(() => {
if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
onAnnotationDelete(selectedAnnotationId);
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
useEffect(() => {
setRange(createInitialRange(totalMs));
}, [totalMs]);
@@ -508,12 +601,17 @@ export default function TimelineEditor({
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
}, [zoomRegions, trimRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
}, [zoomRegions, trimRegions, annotationRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
const isTrimItem = trimRegions.some(r => r.id === excludeId);
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
if (isAnnotationItem) {
return false;
}
// Helper to check overlap against a specific set of regions
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
@@ -535,8 +633,9 @@ export default function TimelineEditor({
if (isTrimItem) {
return checkOverlap(trimRegions);
}
return false;
}, [zoomRegions, trimRegions]);
}, [zoomRegions, trimRegions, annotationRegions]);
const handleAddZoom = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
@@ -598,10 +697,25 @@ export default function TimelineEditor({
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]);
// Listen for F key to add keyframe, Z key to add zoom, T key to add trim, Ctrl+D to remove selected keyframe or zoom item
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
if (defaultDuration <= 0) {
return;
}
// Multiple annotations can exist at the same timestamp
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
@@ -615,6 +729,32 @@ export default function TimelineEditor({
if (e.key === 't' || e.key === 'T') {
handleAddTrim();
}
if (e.key === 'a' || e.key === 'A') {
handleAddAnnotation();
}
// Tab: Cycle through overlapping annotations at current time
if (e.key === 'Tab' && annotationRegions.length > 0) {
const currentTimeMs = Math.round(currentTime * 1000);
const overlapping = annotationRegions
.filter(a => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
.sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index
if (overlapping.length > 0) {
e.preventDefault();
if (!selectedAnnotationId || !overlapping.some(a => a.id === selectedAnnotationId)) {
onSelectAnnotation?.(overlapping[0].id);
} else {
// Cycle to next annotation
const currentIndex = overlapping.findIndex(a => a.id === selectedAnnotationId);
const nextIndex = e.shiftKey
? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward
: (currentIndex + 1) % overlapping.length; // Tab = forward
onSelectAnnotation?.(overlapping[nextIndex].id);
}
}
}
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
@@ -622,12 +762,14 @@ export default function TimelineEditor({
deleteSelectedZoom();
} else if (selectedTrimId) {
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [addKeyframe, handleAddZoom, handleAddTrim, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, selectedKeyframeId, selectedZoomId, selectedTrimId]);
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
const clampedRange = useMemo<Range>(() => {
if (totalMs === 0) {
@@ -658,8 +800,30 @@ export default function TimelineEditor({
variant: 'trim',
}));
return [...zooms, ...trims];
}, [zoomRegions, trimRegions]);
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
let label: string;
if (region.type === 'text') {
// Show text preview
const preview = region.content.trim() || 'Empty text';
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
} else if (region.type === 'image') {
label = 'Image';
} else {
label = 'Annotation';
}
return {
id: region.id,
rowId: ANNOTATION_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label,
variant: 'annotation',
};
});
return [...zooms, ...trims, ...annotations];
}, [zoomRegions, trimRegions, annotationRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom or trim item
@@ -667,8 +831,10 @@ export default function TimelineEditor({
onZoomSpanChange(id, span);
} else if (trimRegions.some(r => r.id === id)) {
onTrimSpanChange?.(id, span);
} else if (annotationRegions.some(r => r.id === id)) {
onAnnotationSpanChange?.(id, span);
}
}, [zoomRegions, trimRegions, onZoomSpanChange, onTrimSpanChange]);
}, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
if (!videoDuration || videoDuration === 0) {
return (
@@ -706,6 +872,15 @@ export default function TimelineEditor({
>
<Scissors className="w-4 h-4" />
</Button>
<Button
onClick={handleAddAnnotation}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
title="Add Annotation (A)"
>
<MessageSquare className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
@@ -771,8 +946,10 @@ export default function TimelineEditor({
onSeek={onSeek}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
/>
</TimelineWrapper>
</div>
+79 -4
View File
@@ -19,11 +19,86 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = 'text' | 'image' | 'figure';
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
export interface FigureData {
arrowDirection: ArrowDirection;
color: string;
strokeWidth: number;
}
export interface AnnotationPosition {
x: number;
y: number;
}
export interface AnnotationSize {
width: number;
height: number;
}
export interface AnnotationTextStyle {
color: string;
backgroundColor: string;
fontSize: number; // pixels
fontFamily: string;
fontWeight: 'normal' | 'bold';
fontStyle: 'normal' | 'italic';
textDecoration: 'none' | 'underline';
textAlign: 'left' | 'center' | 'right';
}
export interface AnnotationRegion {
id: string;
startMs: number;
endMs: number;
type: AnnotationType;
content: string; // Legacy - still used for current type
textContent?: string; // Separate storage for text
imageContent?: string; // Separate storage for image data URL
position: AnnotationPosition;
size: AnnotationSize;
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
x: 50,
y: 50,
};
export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = {
width: 30,
height: 20,
};
export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
color: '#ffffff',
backgroundColor: 'transparent',
fontSize: 32,
fontFamily: 'Inter',
fontWeight: 'bold',
fontStyle: 'normal',
textDecoration: 'none',
textAlign: 'center',
};
export const DEFAULT_FIGURE_DATA: FigureData = {
arrowDirection: 'right',
color: '#34B27B',
strokeWidth: 4,
};
export interface CropRegion {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
x: number;
y: number;
width: number;
height: number;
}
export const DEFAULT_CROP_REGION: CropRegion = {
@@ -1,5 +1,4 @@
import { Application, Sprite, Graphics } from 'pixi.js';
import { VIEWPORT_SCALE } from "./constants";
import type { CropRegion } from '../types';
interface LayoutParams {