mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: Add drawing annotation functionality to comments, including a new canvas component and API integration for creation, retrieval, and updates.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Undo2, Trash2, Minus, Plus } from 'lucide-react';
|
||||
|
||||
export interface AnnotationStroke {
|
||||
points: { x: number; y: number }[];
|
||||
color: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface AnnotationCanvasHandle {
|
||||
getStrokes: () => AnnotationStroke[];
|
||||
}
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
mode: 'draw' | 'view';
|
||||
strokes?: AnnotationStroke[];
|
||||
onConfirm?: (strokes: AnnotationStroke[]) => void;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void; // For view mode, close overlay
|
||||
}
|
||||
|
||||
const COLORS = ['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#007AFF', '#AF52DE', '#FFFFFF'];
|
||||
const DEFAULT_COLOR = '#FF3B30';
|
||||
const DEFAULT_WIDTH = 3;
|
||||
const MIN_WIDTH = 1;
|
||||
const MAX_WIDTH = 10;
|
||||
|
||||
// Reference canvas width for stroke scaling
|
||||
const REF_WIDTH = 1000;
|
||||
|
||||
export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCanvasProps>(function AnnotationCanvas({ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss }, ref) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [strokes, setStrokes] = useState<AnnotationStroke[]>(initialStrokes || []);
|
||||
const [currentStroke, setCurrentStroke] = useState<AnnotationStroke | null>(null);
|
||||
const [color, setColor] = useState(DEFAULT_COLOR);
|
||||
const [width, setWidth] = useState(DEFAULT_WIDTH);
|
||||
const isDrawingRef = useRef(false);
|
||||
|
||||
// Expose getStrokes so parent can grab current drawing without confirm
|
||||
useImperativeHandle(ref, () => ({
|
||||
getStrokes: () => strokes,
|
||||
}), [strokes]);
|
||||
|
||||
// Render all strokes
|
||||
const renderStrokes = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, strokeList: AnnotationStroke[], active?: AnnotationStroke | null) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const scale = canvas.width / REF_WIDTH;
|
||||
|
||||
const draw = (s: AnnotationStroke) => {
|
||||
if (s.points.length < 2) return;
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = s.width * scale;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.points[0].x * canvas.width, s.points[0].y * canvas.height);
|
||||
for (let i = 1; i < s.points.length; i++) {
|
||||
ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height);
|
||||
}
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
strokeList.forEach(draw);
|
||||
if (active) draw(active);
|
||||
}, []);
|
||||
|
||||
// Resize canvas to match container
|
||||
useEffect(() => {
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) renderStrokes(ctx, canvas, strokes, currentStroke);
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
return () => window.removeEventListener('resize', resizeCanvas);
|
||||
}, [strokes, currentStroke, renderStrokes]);
|
||||
|
||||
// Re-render on stroke changes
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
renderStrokes(ctx, canvas, strokes, currentStroke);
|
||||
}, [strokes, currentStroke, renderStrokes]);
|
||||
|
||||
const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let clientX: number, clientY: number;
|
||||
if ('touches' in e) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
return {
|
||||
x: (clientX - rect.left) / rect.width,
|
||||
y: (clientY - rect.top) / rect.height,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (mode !== 'draw') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pt = getPoint(e);
|
||||
if (!pt) return;
|
||||
isDrawingRef.current = true;
|
||||
setCurrentStroke({ points: [pt], color, width });
|
||||
}, [mode, color, width, getPoint]);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (mode !== 'draw' || !isDrawingRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pt = getPoint(e);
|
||||
if (!pt) return;
|
||||
setCurrentStroke(prev => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, points: [...prev.points, pt] };
|
||||
});
|
||||
}, [mode, getPoint]);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (mode !== 'draw' || !isDrawingRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDrawingRef.current = false;
|
||||
setCurrentStroke(prev => {
|
||||
if (prev && prev.points.length >= 2) {
|
||||
setStrokes(s => [...s, prev]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [mode]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
setStrokes(prev => prev.slice(0, -1));
|
||||
}, []);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setStrokes([]);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (strokes.length === 0) return;
|
||||
onConfirm?.(strokes);
|
||||
}, [strokes, onConfirm]);
|
||||
|
||||
// View mode: click to dismiss
|
||||
const handleViewClick = useCallback((e: React.MouseEvent) => {
|
||||
if (mode === 'view') {
|
||||
e.stopPropagation();
|
||||
onDismiss?.();
|
||||
}
|
||||
}, [mode, onDismiss]);
|
||||
|
||||
if (mode === 'view') {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-20 cursor-pointer"
|
||||
onClick={handleViewClick}
|
||||
title="Click to dismiss annotation"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-20"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full cursor-crosshair"
|
||||
onMouseDown={handlePointerDown}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseUp={handlePointerUp}
|
||||
onMouseLeave={handlePointerUp}
|
||||
onTouchStart={handlePointerDown}
|
||||
onTouchMove={handlePointerMove}
|
||||
onTouchEnd={handlePointerUp}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-30">
|
||||
{/* Colors */}
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: color === c ? 'white' : 'transparent',
|
||||
boxShadow: color === c ? `0 0 0 2px ${c}` : 'none',
|
||||
}}
|
||||
onClick={() => setColor(c)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Brush size */}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.max(MIN_WIDTH, w - 1))}>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums w-4 text-center">{width}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.min(MAX_WIDTH, w + 1))}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Actions */}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleUndo} disabled={strokes.length === 0} title="Undo">
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleClear} disabled={strokes.length === 0} title="Clear all">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
||||
import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas';
|
||||
import { Linkify } from '@/components/linkify';
|
||||
|
||||
interface Version {
|
||||
@@ -105,6 +106,7 @@ interface Comment {
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
annotationData: string | null;
|
||||
isResolved: boolean;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
@@ -116,6 +118,7 @@ interface Comment {
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
annotationData: string | null;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
guestName: string | null;
|
||||
@@ -236,11 +239,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
const [editTagId, setEditTagId] = useState<string | null>(null);
|
||||
const [editAnnotationData, setEditAnnotationData] = useState<string | null | undefined>(undefined);
|
||||
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
|
||||
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
|
||||
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
||||
const isMutatingRef = useRef(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
// Annotation state
|
||||
const [isAnnotating, setIsAnnotating] = useState(false);
|
||||
const [annotationStrokes, setAnnotationStrokes] = useState<AnnotationStroke[] | null>(null);
|
||||
const [viewingAnnotation, setViewingAnnotation] = useState<AnnotationStroke[] | null>(null);
|
||||
const annotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
|
||||
|
||||
const [guestName, setGuestName] = useState('');
|
||||
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
|
||||
|
||||
@@ -833,10 +845,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleSeekToTimestamp = useCallback((timestamp: number) => {
|
||||
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
|
||||
setCurrentTime(timestamp);
|
||||
if (playerRef.current?.seekTo) {
|
||||
playerRef.current.seekTo(timestamp, true);
|
||||
playerRef.current.pauseVideo();
|
||||
}
|
||||
// Show annotation overlay if present
|
||||
if (annotation) {
|
||||
try {
|
||||
const strokes = JSON.parse(annotation) as AnnotationStroke[];
|
||||
setViewingAnnotation(strokes);
|
||||
} catch {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -905,10 +929,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}, [isDragging, currentTime, handleSeekToTimestamp]);
|
||||
|
||||
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
|
||||
if (!voiceData && !imageBlob && !commentText.trim()) return;
|
||||
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating) return;
|
||||
if (!activeVersion) return;
|
||||
|
||||
// Auto-capture strokes from canvas if still in draw mode
|
||||
let effectiveStrokes = annotationStrokes;
|
||||
if (isAnnotating && annotationCanvasRef.current) {
|
||||
const canvasStrokes = annotationCanvasRef.current.getStrokes();
|
||||
if (canvasStrokes.length > 0) {
|
||||
effectiveStrokes = canvasStrokes;
|
||||
}
|
||||
}
|
||||
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
|
||||
const optimisticComment: Comment = {
|
||||
id: tempId,
|
||||
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
|
||||
@@ -916,6 +950,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
|
||||
annotationData: serializedAnnotation,
|
||||
isResolved: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
@@ -941,6 +976,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
|
||||
setAudioBlob(null);
|
||||
setImageBlob(null);
|
||||
setAnnotationStrokes(null);
|
||||
setIsAnnotating(false);
|
||||
setViewingAnnotation(effectiveStrokes || null);
|
||||
|
||||
setIsSubmittingComment(true);
|
||||
isMutatingRef.current = true;
|
||||
@@ -973,6 +1011,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
...(imageData && { imageUrl: imageData.url }),
|
||||
...(isGuest && guestName && { guestName }),
|
||||
...(selectedTagId && { tagId: selectedTagId }),
|
||||
...(serializedAnnotation && { annotationData: serializedAnnotation }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1022,7 +1061,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsUploadingImage(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob]);
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]);
|
||||
|
||||
const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -1374,6 +1413,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
|
||||
annotationData: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
guestName: isGuest ? guestName : null,
|
||||
@@ -1614,12 +1654,23 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]);
|
||||
|
||||
const handleEditComment = useCallback(async (commentId: string) => {
|
||||
if (!editText.trim()) return;
|
||||
if (!editText.trim() && !editAnnotationData) return;
|
||||
setIsSubmittingEdit(true);
|
||||
isMutatingRef.current = true;
|
||||
|
||||
// Auto-capture strokes from edit canvas if still drawing
|
||||
let finalAnnotationData = editAnnotationData;
|
||||
if (isEditingAnnotation && editAnnotationCanvasRef.current) {
|
||||
const strokes = editAnnotationCanvasRef.current.getStrokes();
|
||||
if (strokes.length > 0) {
|
||||
finalAnnotationData = JSON.stringify(strokes);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = { content: editText };
|
||||
if (editTagId !== undefined) body.tagId = editTagId;
|
||||
if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData;
|
||||
const res = await fetch(`/api/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1636,7 +1687,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
? {
|
||||
...v,
|
||||
comments: v.comments.map((c) => {
|
||||
if (c.id === commentId) return { ...c, content: editText.trim(), tag: editTagId !== undefined ? editedTag : c.tag };
|
||||
if (c.id === commentId) return { ...c, content: editText.trim(), tag: editTagId !== undefined ? editedTag : c.tag, annotationData: finalAnnotationData !== undefined ? finalAnnotationData : c.annotationData };
|
||||
return {
|
||||
...c,
|
||||
replies: (c.replies || []).map((r) =>
|
||||
@@ -1652,6 +1703,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(null);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
// Update the viewing overlay if it was showing this annotation
|
||||
if (finalAnnotationData !== undefined && finalAnnotationData) {
|
||||
try {
|
||||
setViewingAnnotation(JSON.parse(finalAnnotationData));
|
||||
} catch { /* ignore parse errors */ }
|
||||
} else if (finalAnnotationData === null) {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to edit comment:', err);
|
||||
@@ -1659,7 +1720,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsSubmittingEdit(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [editText, editTagId, activeVersionId, availableTags]);
|
||||
}, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags]);
|
||||
|
||||
const handleDeleteComment = useCallback(async (commentId: string) => {
|
||||
setDeletingCommentId(commentId);
|
||||
@@ -2258,6 +2319,47 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Annotation canvas overlay – drawing mode */}
|
||||
{isAnnotating && (
|
||||
<AnnotationCanvas
|
||||
ref={annotationCanvasRef}
|
||||
mode="draw"
|
||||
onConfirm={(strokes) => {
|
||||
setAnnotationStrokes(strokes);
|
||||
setIsAnnotating(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsAnnotating(false);
|
||||
setAnnotationStrokes(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Annotation canvas overlay – viewing mode */}
|
||||
{viewingAnnotation && !isAnnotating && !isEditingAnnotation && (
|
||||
<AnnotationCanvas
|
||||
mode="view"
|
||||
strokes={viewingAnnotation}
|
||||
onDismiss={() => setViewingAnnotation(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Annotation canvas overlay – edit annotation mode */}
|
||||
{isEditingAnnotation && (
|
||||
<AnnotationCanvas
|
||||
ref={editAnnotationCanvasRef}
|
||||
mode="draw"
|
||||
strokes={editAnnotationData ? (() => { try { return JSON.parse(editAnnotationData); } catch { return undefined; } })() : (() => { const c = comments.find(c => c.id === editingCommentId); if (c?.annotationData) { try { return JSON.parse(c.annotationData); } catch { return undefined; } } return undefined; })()}
|
||||
onConfirm={(strokes) => {
|
||||
setEditAnnotationData(JSON.stringify(strokes));
|
||||
setIsEditingAnnotation(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsEditingAnnotation(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2382,7 +2484,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
key={comment.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSeekToTimestamp(comment.timestamp);
|
||||
handleSeekToTimestamp(comment.timestamp, comment.annotationData);
|
||||
}}
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
|
||||
style={{
|
||||
@@ -2465,7 +2567,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
||||
onClick={() => handleSeekToTimestamp(comment.timestamp, comment.annotationData)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||
title="Jump to this timestamp"
|
||||
>
|
||||
@@ -2545,10 +2647,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(null);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleEditComment(comment.id)}
|
||||
@@ -2560,11 +2664,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => { setEditingCommentId(null); setEditText(''); setEditTagId(null); }}
|
||||
onClick={() => { setEditingCommentId(null); setEditText(''); setEditTagId(null); setEditAnnotationData(undefined); setIsEditingAnnotation(false); }}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={comment.annotationData || editAnnotationData ? 'default' : 'outline'}
|
||||
className={`h-7 w-7 ${comment.annotationData || editAnnotationData ? 'bg-violet-500 hover:bg-violet-600' : ''}`}
|
||||
onClick={() => {
|
||||
if (playerRef.current?.pauseVideo) playerRef.current.pauseVideo();
|
||||
setIsEditingAnnotation(true);
|
||||
setIsAnnotating(false);
|
||||
}}
|
||||
title={comment.annotationData ? 'Redraw annotation' : 'Add annotation'}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{availableTags.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -2661,6 +2778,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(comment.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{comment.annotationData && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-violet-500 text-white shrink-0 flex items-center gap-1">
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
Annotated
|
||||
</span>
|
||||
)}
|
||||
{comment.tag && (
|
||||
<span
|
||||
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0"
|
||||
@@ -3128,6 +3251,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(annotationStrokes || isAnnotating) && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 mb-2 rounded-md bg-violet-500/10 border border-violet-500/30">
|
||||
<Pencil className="h-3.5 w-3.5 text-violet-500 shrink-0" />
|
||||
<span className="text-xs text-violet-400 font-medium">Annotation attached</span>
|
||||
<button
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => setAnnotationStrokes(null)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{imageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -3160,7 +3295,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => handleAddComment()}
|
||||
disabled={(!commentText.trim() && !imageBlob) || isSubmittingComment || isUploadingImage}
|
||||
disabled={(!commentText.trim() && !imageBlob && !annotationStrokes) || isSubmittingComment || isUploadingImage}
|
||||
>
|
||||
{isSubmittingComment || isUploadingImage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -3184,6 +3319,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={annotationStrokes ? 'default' : 'outline'}
|
||||
className={annotationStrokes ? 'bg-violet-500 hover:bg-violet-600' : ''}
|
||||
onClick={() => {
|
||||
if (isAnnotating) return;
|
||||
// Pause video when opening annotation tool
|
||||
if (playerRef.current?.pauseVideo) {
|
||||
playerRef.current.pauseVideo();
|
||||
}
|
||||
setIsAnnotating(true);
|
||||
}}
|
||||
title={annotationStrokes ? 'Annotation added ✓ (click to redraw)' : 'Draw annotation on video'}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
|
||||
Reference in New Issue
Block a user