Initial commit
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
MouseEvent,
|
||||
} from "react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { saveAnnotation } from "@/actions/annotations";
|
||||
import { addComment } from "@/actions/comments";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
versionId: string;
|
||||
frameNumber: number;
|
||||
fps: number;
|
||||
isAnnotating: boolean;
|
||||
showAnnotations: boolean;
|
||||
existingAnnotations?: unknown[];
|
||||
onAnnotationSaved?: (frameNumber: number) => void;
|
||||
}
|
||||
|
||||
type DrawingState = {
|
||||
isDrawing: boolean;
|
||||
currentShape: AnnotationShape | null;
|
||||
shapes: AnnotationShape[];
|
||||
};
|
||||
|
||||
export function AnnotationCanvas({
|
||||
versionId,
|
||||
frameNumber,
|
||||
fps,
|
||||
isAnnotating,
|
||||
showAnnotations,
|
||||
existingAnnotations = [],
|
||||
onAnnotationSaved,
|
||||
}: AnnotationCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [drawingState, setDrawingState] = useState<DrawingState>({
|
||||
isDrawing: false,
|
||||
currentShape: null,
|
||||
shapes: [],
|
||||
});
|
||||
const drawingStateRef = useRef(drawingState);
|
||||
drawingStateRef.current = drawingState;
|
||||
|
||||
// Per-frame saved shapes — persists across frame navigation so annotations
|
||||
// remain visible without depending on a parent API refetch.
|
||||
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, AnnotationShape[]>>(() => {
|
||||
const map: Record<number, AnnotationShape[]> = {};
|
||||
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||
const shapes = a.drawingData?.shapes ?? [];
|
||||
map[a.frameNumber] = [...(map[a.frameNumber] ?? []), ...shapes];
|
||||
}
|
||||
return map;
|
||||
});
|
||||
// Ref so redraw closure can read latest without being recreated
|
||||
const savedShapesByFrameRef = useRef(savedShapesByFrame);
|
||||
savedShapesByFrameRef.current = savedShapesByFrame;
|
||||
|
||||
// Track frames that already have an annotation comment this session,
|
||||
// initialized from existing annotations so we don't double-comment on refresh.
|
||||
const [annotationCommentedFrames, setAnnotationCommentedFrames] = useState<Set<number>>(() => {
|
||||
const frames = new Set<number>();
|
||||
for (const a of (existingAnnotations as { frameNumber: number }[])) {
|
||||
frames.add(a.frameNumber);
|
||||
}
|
||||
return frames;
|
||||
});
|
||||
|
||||
// Sync savedShapesByFrame when the existingAnnotations prop changes (e.g. after parent API refresh).
|
||||
// MERGE server shapes with locally-drawn shapes (deduplicate by shape id) so locally-drawn
|
||||
// annotations are never wiped by a new array reference from the parent.
|
||||
const prevExistingLengthRef = useRef(existingAnnotations.length);
|
||||
const prevExistingRef = useRef(existingAnnotations);
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevExistingRef.current === existingAnnotations ||
|
||||
prevExistingLengthRef.current === existingAnnotations.length
|
||||
) {
|
||||
prevExistingRef.current = existingAnnotations;
|
||||
prevExistingLengthRef.current = existingAnnotations.length;
|
||||
return;
|
||||
}
|
||||
prevExistingRef.current = existingAnnotations;
|
||||
prevExistingLengthRef.current = existingAnnotations.length;
|
||||
|
||||
setSavedShapesByFrame((prev) => {
|
||||
// Build map from server data
|
||||
const serverMap: Record<number, AnnotationShape[]> = {};
|
||||
const serverShapeIds = new Set<string>();
|
||||
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||
const shapes = a.drawingData?.shapes ?? [];
|
||||
serverMap[a.frameNumber] = [...(serverMap[a.frameNumber] ?? []), ...shapes];
|
||||
for (const s of shapes) serverShapeIds.add(s.id);
|
||||
}
|
||||
// Merge: keep local shapes not yet in server data (pending save race)
|
||||
const merged = { ...serverMap };
|
||||
for (const [frame, shapes] of Object.entries(prev)) {
|
||||
for (const s of shapes) {
|
||||
if (!serverShapeIds.has(s.id)) {
|
||||
const f = Number(frame);
|
||||
merged[f] = [...(merged[f] ?? []), s];
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
// Also update frames that now have server-side annotation comments
|
||||
setAnnotationCommentedFrames((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const a of (existingAnnotations as { frameNumber: number }[])) {
|
||||
next.add(a.frameNumber);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [existingAnnotations]);
|
||||
|
||||
|
||||
// ── Coordinate normalization ─────────────────────────────────────────────
|
||||
const toNormalized = (e: MouseEvent): AnnotationPoint => {
|
||||
const canvas = canvasRef.current!;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
const toPixel = (p: AnnotationPoint, w: number, h: number) => ({
|
||||
x: p.x * w,
|
||||
y: p.y * h,
|
||||
});
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
const renderShape = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
shape: AnnotationShape,
|
||||
w: number,
|
||||
h: number
|
||||
) => {
|
||||
if (shape.points.length === 0) return;
|
||||
|
||||
ctx.strokeStyle = shape.color;
|
||||
ctx.lineWidth = shape.strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.globalAlpha = 0.85;
|
||||
|
||||
const pts = shape.points.map((p) => toPixel(p, w, h));
|
||||
|
||||
switch (shape.tool) {
|
||||
case "freehand": {
|
||||
if (pts.length < 2) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i].x, pts[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case "rectangle": {
|
||||
if (pts.length < 2) return;
|
||||
const x = Math.min(pts[0].x, pts[pts.length - 1].x);
|
||||
const y = Math.min(pts[0].y, pts[pts.length - 1].y);
|
||||
const width = Math.abs(pts[pts.length - 1].x - pts[0].x);
|
||||
const height = Math.abs(pts[pts.length - 1].y - pts[0].y);
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case "circle": {
|
||||
if (pts.length < 2) return;
|
||||
const cx = pts[0].x;
|
||||
const cy = pts[0].y;
|
||||
const dx = pts[pts.length - 1].x - cx;
|
||||
const dy = pts[pts.length - 1].y - cy;
|
||||
const radius = Math.sqrt(dx * dx + dy * dy);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
if (pts.length < 2) return;
|
||||
const start = pts[0];
|
||||
const end = pts[pts.length - 1];
|
||||
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
||||
const arrowLen = Math.max(10, shape.strokeWidth * 5);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x, start.y);
|
||||
ctx.lineTo(end.x, end.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Arrowhead
|
||||
ctx.fillStyle = shape.color;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(end.x, end.y);
|
||||
ctx.lineTo(
|
||||
end.x - arrowLen * Math.cos(angle - Math.PI / 7),
|
||||
end.y - arrowLen * Math.sin(angle - Math.PI / 7)
|
||||
);
|
||||
ctx.lineTo(
|
||||
end.x - arrowLen * Math.cos(angle + Math.PI / 7),
|
||||
end.y - arrowLen * Math.sin(angle + Math.PI / 7)
|
||||
);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (!showAnnotations) return;
|
||||
|
||||
// Draw saved shapes for this frame (seeded from DB + newly saved this session)
|
||||
(savedShapesByFrameRef.current[frameNumber] ?? []).forEach((shape) => {
|
||||
renderShape(ctx, shape, w, h);
|
||||
});
|
||||
|
||||
// Draw current (in-progress) shape
|
||||
const current = drawingStateRef.current.currentShape;
|
||||
if (current && drawingStateRef.current.isDrawing) {
|
||||
renderShape(ctx, current, w, h);
|
||||
}
|
||||
}, [frameNumber, showAnnotations, renderShape]);
|
||||
|
||||
// Resize observer — stable, only recreated when redraw changes (which is rare now)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
redraw();
|
||||
});
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
// Redraw whenever frame, saved shapes map, in-progress shape, or visibility changes
|
||||
useEffect(() => {
|
||||
redraw();
|
||||
}, [redraw, savedShapesByFrame, drawingState.currentShape, showAnnotations]);
|
||||
|
||||
// ── Mouse events ─────────────────────────────────────────────────────────
|
||||
const handleMouseDown = useCallback(
|
||||
(e: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isAnnotating) return;
|
||||
const point = toNormalized(e);
|
||||
const newShape: AnnotationShape = {
|
||||
id: uuidv4(),
|
||||
tool: selectedTool,
|
||||
points: [point],
|
||||
color: selectedColor,
|
||||
strokeWidth,
|
||||
frameNumber,
|
||||
};
|
||||
setDrawingState((prev) => ({
|
||||
...prev,
|
||||
isDrawing: true,
|
||||
currentShape: newShape,
|
||||
}));
|
||||
},
|
||||
[isAnnotating, selectedTool, selectedColor, strokeWidth, frameNumber]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingStateRef.current.isDrawing || !drawingStateRef.current.currentShape) return;
|
||||
const point = toNormalized(e);
|
||||
setDrawingState((prev) => ({
|
||||
...prev,
|
||||
currentShape: prev.currentShape
|
||||
? { ...prev.currentShape, points: [...prev.currentShape.points, point] }
|
||||
: null,
|
||||
}));
|
||||
redraw();
|
||||
},
|
||||
[redraw]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(async () => {
|
||||
const state = drawingStateRef.current;
|
||||
if (!state.isDrawing || !state.currentShape) return;
|
||||
|
||||
const finishedShape = state.currentShape;
|
||||
// Clear the in-progress drawing state
|
||||
setDrawingState((prev) => ({
|
||||
...prev,
|
||||
isDrawing: false,
|
||||
currentShape: null,
|
||||
shapes: [],
|
||||
}));
|
||||
|
||||
// Immediately persist shape into the per-frame cache so it survives frame navigation
|
||||
setSavedShapesByFrame((prev) => ({
|
||||
...prev,
|
||||
[frameNumber]: [...(prev[frameNumber] ?? []), finishedShape],
|
||||
}));
|
||||
|
||||
// Save to DB
|
||||
try {
|
||||
const canvas = canvasRef.current;
|
||||
const drawingData: AnnotationDrawingData = {
|
||||
shapes: [finishedShape],
|
||||
canvasWidth: canvas?.offsetWidth ?? 1920,
|
||||
canvasHeight: canvas?.offsetHeight ?? 1080,
|
||||
version: "1.0",
|
||||
};
|
||||
await saveAnnotation({
|
||||
versionId,
|
||||
frameNumber,
|
||||
drawingData,
|
||||
color: selectedColor,
|
||||
});
|
||||
|
||||
// Create a companion comment ONCE per frame so the annotation appears in the panel.
|
||||
// Additional strokes on the same frame update the existing comment count silently.
|
||||
if (!annotationCommentedFrames.has(frameNumber)) {
|
||||
await addComment({
|
||||
versionId,
|
||||
frameNumber,
|
||||
timestamp: frameNumber / fps,
|
||||
text: `✏️ Annotation at frame ${frameNumber}`,
|
||||
});
|
||||
setAnnotationCommentedFrames((prev) => new Set([...prev, frameNumber]));
|
||||
}
|
||||
|
||||
onAnnotationSaved?.(frameNumber);
|
||||
} catch {
|
||||
toast({
|
||||
title: "Failed to save annotation",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
|
||||
style={{
|
||||
cursor: isAnnotating ? "crosshair" : "default",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnnotationTool } from "@/types";
|
||||
import {
|
||||
Pencil,
|
||||
ArrowUpRight,
|
||||
Square,
|
||||
Circle,
|
||||
Minus,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const TOOLS: { tool: AnnotationTool; icon: React.ElementType; label: string }[] = [
|
||||
{ tool: "freehand", icon: Pencil, label: "Freehand" },
|
||||
{ tool: "arrow", icon: ArrowUpRight, label: "Arrow" },
|
||||
{ tool: "rectangle", icon: Square, label: "Rectangle" },
|
||||
{ tool: "circle", icon: Circle, label: "Circle" },
|
||||
];
|
||||
|
||||
const COLORS = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#f59e0b", label: "Amber" },
|
||||
{ value: "#22c55e", label: "Green" },
|
||||
{ value: "#3b82f6", label: "Blue" },
|
||||
{ value: "#a855f7", label: "Purple" },
|
||||
{ value: "#ffffff", label: "White" },
|
||||
{ value: "#000000", label: "Black" },
|
||||
];
|
||||
|
||||
export function AnnotationTools() {
|
||||
const {
|
||||
isAnnotating,
|
||||
selectedTool,
|
||||
selectedColor,
|
||||
strokeWidth,
|
||||
setSelectedTool,
|
||||
setSelectedColor,
|
||||
setStrokeWidth,
|
||||
setAnnotating,
|
||||
} = useReviewStore();
|
||||
|
||||
if (!isAnnotating) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-zinc-900/95 border-b border-white/5">
|
||||
{/* Tool selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
{TOOLS.map(({ tool, icon: Icon, label }) => (
|
||||
<Tooltip key={tool}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"text-zinc-400 hover:text-white",
|
||||
selectedTool === tool &&
|
||||
"bg-primary/20 text-primary hover:bg-primary/30"
|
||||
)}
|
||||
onClick={() => setSelectedTool(tool)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-white/10" />
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<Tooltip key={c.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"h-5 w-5 rounded-full border-2 transition-transform hover:scale-110",
|
||||
selectedColor === c.value
|
||||
? "border-white scale-110"
|
||||
: "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: c.value }}
|
||||
onClick={() => setSelectedColor(c.value)}
|
||||
title={c.label}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{c.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-white/10" />
|
||||
|
||||
{/* Stroke width */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white h-6 w-6"
|
||||
onClick={() => setStrokeWidth(Math.max(1, strokeWidth - 1))}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Thinner</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className="rounded-full bg-current"
|
||||
style={{
|
||||
width: Math.min(16, strokeWidth * 3),
|
||||
height: Math.min(16, strokeWidth * 3),
|
||||
backgroundColor: selectedColor,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white h-6 w-6"
|
||||
onClick={() => setStrokeWidth(Math.min(8, strokeWidth + 1))}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Thicker</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<span className="text-xs text-zinc-500 font-mono w-4">{strokeWidth}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user