diff --git a/ROADMAP.md b/ROADMAP.md index 1b02892..7333957 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,11 +120,18 @@ The highest-impact remaining feature. CG production review is fundamentally visu The review tool lives at its own dedicated page (`/projects/[projectId]/deliverables/[deliverableId]/review`) and is also accessible from the stage detail sheet via a "Review" button. It's the primary interface for inspecting renders, comparing revisions, annotating feedback, and making approve/reject decisions. -**Existing infrastructure to build on:** -- `Revision` model exists with `attachments Json?` field (currently unused) -- Revision CRUD API + hooks + service already built -- Stage detail sheet has a tabbed interface (Revisions + Comments) -- No image upload, storage, or display infrastructure exists yet +**Infrastructure built so far:** +- Review page at `/projects/[projectId]/deliverables/[deliverableId]/review` with image viewer, upload, gallery +- Canvas-based image viewer with zoom/pan/minimap, retina support +- Image upload API with PNG alpha compositing + TIFF conversion (sharp) +- `Revision.attachments` JSON stores `{ referenceImage, currentImage }` with metadata +- Comparison viewer with 4 modes: side-by-side, wipe, overlay, toggle +- SVG annotation layer with 7 tools (rect, ellipse, arrow, freehand, text, pin, screenshot paste) +- Annotation model in Prisma schema (requires `db push` to sync) +- Annotation API: GET/POST `/api/revisions/[id]/annotations`, PATCH/DELETE `/api/revisions/[id]/annotations/[id]` +- Annotations linked to comments (transactional create), undo/redo stack +- Screenshot paste: Cmd+V pastes clipboard image as draggable/resizable callout +- "Review" button on stage cards in deliverable detail page **New dependency for all stages:** `sharp` (server-side PNG alpha compositing + image processing) @@ -165,7 +172,7 @@ The foundation. A dedicated review page with a high-fidelity image viewer and th --- -#### A2 — Version Comparison `[ ]` +#### A2 — Version Comparison `[x]` Compare two revisions of the same deliverable stage. This is the daily workhorse — producers and artists check what changed between rounds. @@ -188,7 +195,7 @@ Compare two revisions of the same deliverable stage. This is the daily workhorse --- -#### A3 — Annotations `[ ]` +#### A3 — Annotations `[x]` Draw pixel-accurate annotations directly on images. Each annotation is anchored to image coordinates so it stays accurate at any zoom level, and is linked to a comment for context. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a662f5c..9f40426 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -697,7 +697,7 @@ model SearchLog { @@map("search_logs") } -// ─── Enums (from feature branch) ──────────────────────── +// ─── Annotations (Visual Review) ──────────────────────── enum AnnotationType { RECTANGLE diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx index e64e23a..38294bb 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -8,6 +8,7 @@ import { ChevronLeft, ChevronRight, Upload, + Columns2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -21,9 +22,15 @@ import { import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { StageStatusBadge } from "@/components/stages/stage-status-badge"; -import { ImageViewer } from "@/components/review/image-viewer"; +import { ImageViewer, type ImageViewerState } from "@/components/review/image-viewer"; +import { ComparisonViewer } from "@/components/review/comparison-viewer"; +import { + ComparisonToolbar, + type ComparisonMode, +} from "@/components/review/comparison-toolbar"; import { ImageUploadZone } from "@/components/review/image-upload-zone"; import { ImageGallery } from "@/components/review/image-gallery"; +import { AnnotationLayer } from "@/components/review/annotation-layer"; import { useDeliverable } from "@/hooks/use-deliverables"; import { useRevisions } from "@/hooks/use-revisions"; import { useQueryClient } from "@tanstack/react-query"; @@ -70,6 +77,13 @@ export default function ReviewPage() { const [uploadPanelOpen, setUploadPanelOpen] = useState(false); const [activeImageUrl, setActiveImageUrl] = useState(null); + // ── Comparison mode state ──────────────────────────────────────────── + const [comparisonActive, setComparisonActive] = useState(false); + const [comparisonMode, setComparisonMode] = + useState("side-by-side"); + const [leftRevisionKey, setLeftRevisionKey] = useState(""); + const [rightRevisionKey, setRightRevisionKey] = useState(""); + const stages = useMemo(() => { if (!deliverable?.stages) return []; return [...deliverable.stages].sort( @@ -132,6 +146,48 @@ export default function ReviewPage() { return images; }, [revisions]); + // Build revision options for comparison dropdowns + const revisionOptions = useMemo(() => { + return galleryImages.map((img) => ({ + revisionId: img.revisionId, + roundNumber: img.roundNumber, + type: img.type, + label: `R${img.roundNumber} — ${img.type === "reference" ? "Reference" : "Render"}`, + url: img.url, + })); + }, [galleryImages]); + + // Auto-select comparison revisions: previous round vs current + useEffect(() => { + if (!comparisonActive || revisionOptions.length < 2) return; + if (leftRevisionKey && rightRevisionKey) return; + + // Default: second-to-last as A, latest as B + const latest = revisionOptions[revisionOptions.length - 1]; + const previous = + revisionOptions.length >= 2 + ? revisionOptions[revisionOptions.length - 2] + : latest; + + setLeftRevisionKey(`${previous.revisionId}-${previous.type}`); + setRightRevisionKey(`${latest.revisionId}-${latest.type}`); + }, [comparisonActive, revisionOptions, leftRevisionKey, rightRevisionKey]); + + // Resolve selected revision keys to URLs + const leftSrc = useMemo(() => { + const opt = revisionOptions.find( + (o) => `${o.revisionId}-${o.type}` === leftRevisionKey + ); + return opt?.url ?? null; + }, [revisionOptions, leftRevisionKey]); + + const rightSrc = useMemo(() => { + const opt = revisionOptions.find( + (o) => `${o.revisionId}-${o.type}` === rightRevisionKey + ); + return opt?.url ?? null; + }, [revisionOptions, rightRevisionKey]); + // Auto-select the latest current image useEffect(() => { if (!activeImageUrl && galleryImages.length > 0) { @@ -140,6 +196,12 @@ export default function ReviewPage() { } }, [galleryImages, activeImageUrl]); + // Find the revision ID for the currently active image (for annotations) + const activeRevisionId = useMemo(() => { + const match = galleryImages.find((img) => img.url === activeImageUrl); + return match?.revisionId ?? null; + }, [galleryImages, activeImageUrl]); + // Latest revision for upload panel const latestRevision = revisions[0] as any | undefined; const latestAttachments = @@ -161,11 +223,64 @@ export default function ReviewPage() { if (newIdx >= 0 && newIdx < stages.length) { setSelectedStageId(stages[newIdx].id); setActiveImageUrl(null); + // Reset comparison state when changing stages + setLeftRevisionKey(""); + setRightRevisionKey(""); } }, [stageIdx, stages] ); + const handleEnterComparison = useCallback(() => { + setComparisonActive(true); + // Reset revision keys so auto-select picks up + setLeftRevisionKey(""); + setRightRevisionKey(""); + }, []); + + const handleExitComparison = useCallback(() => { + setComparisonActive(false); + }, []); + + // ── Keyboard shortcuts for comparison modes ──────────────────────────── + useEffect(() => { + if (!comparisonActive) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + + switch (e.key) { + case "1": + e.preventDefault(); + setComparisonMode("side-by-side"); + break; + case "2": + e.preventDefault(); + setComparisonMode("wipe"); + break; + case "3": + e.preventDefault(); + setComparisonMode("overlay"); + break; + case "4": + e.preventDefault(); + setComparisonMode("toggle"); + break; + case "Escape": + e.preventDefault(); + handleExitComparison(); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [comparisonActive, handleExitComparison]); + if (delLoading) { return (
@@ -234,6 +349,19 @@ export default function ReviewPage() { + {/* Compare toggle */} + {!comparisonActive && galleryImages.length >= 2 && ( + + )} + {/* Upload panel trigger */} @@ -288,11 +416,49 @@ export default function ReviewPage() { )}
- {/* ── Image viewer ─────────────────────────────────────────── */} - + {/* ── Comparison toolbar (when active) ──────────────────────── */} + {comparisonActive && ( + + )} + + {/* ── Image viewer / Comparison viewer ─────────────────────── */} + {comparisonActive ? ( + + ) : ( + ( + + )} + /> + )} {/* ── Gallery strip ────────────────────────────────────────── */} - {galleryImages.length > 0 && ( + {!comparisonActive && galleryImages.length > 0 && (
}; + +// PATCH /api/revisions/:revisionId/annotations/:annotationId +export async function PATCH(request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { annotationId } = await params; + const body = await request.json(); + const parsed = updateAnnotationSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const annotation = await updateAnnotation( + annotationId, + session!.user!.id!, + parsed.data + ); + return NextResponse.json(annotation); + } catch (e) { + if (e instanceof Error && e.message === "Annotation not found") { + return notFound(e.message); + } + if (e instanceof Error && e.message === "Not authorized") { + return NextResponse.json({ error: e.message }, { status: 403 }); + } + return serverError(e); + } +} + +// DELETE /api/revisions/:revisionId/annotations/:annotationId +export async function DELETE(_request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { annotationId } = await params; + + const result = await deleteAnnotation(annotationId, session!.user!.id!); + return NextResponse.json(result); + } catch (e) { + if (e instanceof Error && e.message === "Annotation not found") { + return notFound(e.message); + } + if (e instanceof Error && e.message === "Not authorized") { + return NextResponse.json({ error: e.message }, { status: 403 }); + } + return serverError(e); + } +} diff --git a/src/app/api/revisions/[revisionId]/annotations/route.ts b/src/app/api/revisions/[revisionId]/annotations/route.ts new file mode 100644 index 0000000..a0846f2 --- /dev/null +++ b/src/app/api/revisions/[revisionId]/annotations/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils"; +import { prisma } from "@/lib/prisma"; +import { createAnnotationSchema } from "@/lib/validators/annotation"; +import { listAnnotations, createAnnotation } from "@/lib/services/annotation-service"; + +type Params = { params: Promise<{ revisionId: string }> }; + +// GET /api/revisions/:revisionId/annotations +export async function GET(_request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { revisionId } = await params; + + const revision = await prisma.revision.findUnique({ + where: { id: revisionId }, + }); + if (!revision) return notFound("Revision not found"); + + const annotations = await listAnnotations(revisionId); + return NextResponse.json(annotations); + } catch (e) { + return serverError(e); + } +} + +// POST /api/revisions/:revisionId/annotations +export async function POST(request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { revisionId } = await params; + + const revision = await prisma.revision.findUnique({ + where: { id: revisionId }, + }); + if (!revision) return notFound("Revision not found"); + + const body = await request.json(); + const parsed = createAnnotationSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const annotation = await createAnnotation( + revisionId, + session!.user!.id!, + parsed.data + ); + return NextResponse.json(annotation, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/review/annotation-layer.tsx b/src/components/review/annotation-layer.tsx new file mode 100644 index 0000000..54a67e1 --- /dev/null +++ b/src/components/review/annotation-layer.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { + AnnotationRenderer, + type AnnotationShape, +} from "@/components/review/annotation-renderer"; +import { ScreenshotCallout } from "@/components/review/screenshot-callout"; +import { AnnotationTools } from "@/components/review/annotation-tools"; +import { useAnnotationState } from "@/hooks/use-annotation-state"; + +// ── Types ────────────────────────────────────────────── + +export interface AnnotationLayerProps { + revisionId: string | null; + stageId: string | null; + zoom: number; + panX: number; + panY: number; + containerWidth: number; + containerHeight: number; + imageDimensions: { width: number; height: number } | null; +} + +// ── Component ────────────────────────────────────────── + +export function AnnotationLayer({ + revisionId, + stageId, + zoom, + panX, + panY, + containerWidth, + containerHeight, + imageDimensions, +}: AnnotationLayerProps) { + const ann = useAnnotationState(revisionId, stageId); + + // Clipboard paste handler for screenshots + useEffect(() => { + const handlePaste = async (e: ClipboardEvent) => { + if (!revisionId || !stageId) return; + + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const file = item.getAsFile(); + if (!file) continue; + await ann.handleScreenshotPaste( + file, + containerWidth, + containerHeight, + panX, + panY, + zoom, + imageDimensions + ); + break; + } + } + }; + + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [revisionId, stageId, panX, panY, zoom, containerWidth, containerHeight, imageDimensions, ann]); + + // Cursor style + const cursorStyle = useMemo(() => { + if (ann.activeTool === "select") return "default"; + if (ann.activeTool === "text") return "text"; + return "crosshair"; + }, [ann.activeTool]); + + // Don't render overlay when there's no revision + if (!revisionId || !imageDimensions) return null; + + return ( + <> + {/* ── Annotation toolbar (floating inside viewport, top-center) ── */} +
+
+ ann.setVisible((v: boolean) => !v)} + hasSelection={!!ann.selectedId} + onDeleteSelection={ann.handleDeleteSelection} + /> +
+
+ + {/* ── SVG overlay ────────────────────────────────── */} + ann.handleMouseDown(e, panX, panY, zoom)} + onMouseMove={(e) => ann.handleMouseMove(e, panX, panY, zoom)} + onMouseUp={ann.handleMouseUp} + > + {ann.visible && ( + + {/* Persisted annotations */} + {ann.annotationShapes + .filter((a: AnnotationShape) => a.type !== "SCREENSHOT") + .map((a: AnnotationShape) => ( + + ))} + + {/* Screenshot callouts */} + {ann.screenshotAnnotations.map((a: any) => { + const d = a.data ?? {}; + return ( + + ); + })} + + {/* Drawing preview */} + {ann.drawingPreview && ( + + + + )} + + )} + + + {/* ── Floating text input ────────────────────────── */} + {ann.textInput && ( +
+ ann.setTextValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + ann.commitTextAnnotation(); + } + if (e.key === "Escape") { + ann.setTextInput(null); + ann.setTextValue(""); + } + }} + onBlur={ann.commitTextAnnotation} + className="h-7 min-w-[120px] border-[var(--primary)] bg-[var(--card)] text-xs" + placeholder="Type label..." + autoFocus + /> +
+ )} + + ); +} diff --git a/src/components/review/annotation-renderer.tsx b/src/components/review/annotation-renderer.tsx new file mode 100644 index 0000000..f5c0fcc --- /dev/null +++ b/src/components/review/annotation-renderer.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { memo } from "react"; +import type { AnnotationTypeValue } from "@/lib/validators/annotation"; + +export interface AnnotationShape { + id: string; + type: AnnotationTypeValue; + data: { + x?: number; + y?: number; + width?: number; + height?: number; + endX?: number; + endY?: number; + points?: { x: number; y: number }[]; + text?: string; + color?: string; + strokeWidth?: number; + imageUrl?: string; + }; + imageX: number; + imageY: number; + isSelected?: boolean; + onClick?: (id: string) => void; +} + +interface AnnotationRendererProps { + annotation: AnnotationShape; +} + +function buildArrowHeadPath( + startX: number, + startY: number, + endX: number, + endY: number, + headLen: number +): string { + const angle = Math.atan2(endY - startY, endX - startX); + const a1x = endX - headLen * Math.cos(angle - Math.PI / 6); + const a1y = endY - headLen * Math.sin(angle - Math.PI / 6); + const a2x = endX - headLen * Math.cos(angle + Math.PI / 6); + const a2y = endY - headLen * Math.sin(angle + Math.PI / 6); + return `M${a1x},${a1y} L${endX},${endY} L${a2x},${a2y}`; +} + +function simplifyPoints( + points: { x: number; y: number }[], + tolerance: number +): { x: number; y: number }[] { + if (points.length <= 2) return points; + + // Douglas-Peucker simplification + let maxDist = 0; + let maxIdx = 0; + const first = points[0]; + const last = points[points.length - 1]; + + for (let i = 1; i < points.length - 1; i++) { + const dist = perpendicularDist(points[i], first, last); + if (dist > maxDist) { + maxDist = dist; + maxIdx = i; + } + } + + if (maxDist > tolerance) { + const left = simplifyPoints(points.slice(0, maxIdx + 1), tolerance); + const right = simplifyPoints(points.slice(maxIdx), tolerance); + return left.slice(0, -1).concat(right); + } + + return [first, last]; +} + +function perpendicularDist( + point: { x: number; y: number }, + lineStart: { x: number; y: number }, + lineEnd: { x: number; y: number } +): number { + const dx = lineEnd.x - lineStart.x; + const dy = lineEnd.y - lineStart.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) { + const px = point.x - lineStart.x; + const py = point.y - lineStart.y; + return Math.sqrt(px * px + py * py); + } + const num = Math.abs( + dy * point.x - dx * point.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x + ); + return num / Math.sqrt(lenSq); +} + +export function pointsToPath(points: { x: number; y: number }[]): string { + if (points.length === 0) return ""; + const simplified = simplifyPoints(points, 1.5); + const parts = [`M${simplified[0].x},${simplified[0].y}`]; + for (let i = 1; i < simplified.length; i++) { + parts.push(`L${simplified[i].x},${simplified[i].y}`); + } + return parts.join(" "); +} + +export const AnnotationRenderer = memo(function AnnotationRenderer({ + annotation, +}: AnnotationRendererProps) { + const { type, data, isSelected, onClick, id } = annotation; + const color = data.color || "#EE5540"; + const strokeWidth = data.strokeWidth || 2; + const selectionExtra = isSelected ? 2 : 0; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(id); + }; + + const sharedProps = { + stroke: color, + strokeWidth: strokeWidth + selectionExtra, + fill: "none", + cursor: "pointer" as const, + onClick: handleClick, + style: isSelected + ? { filter: "drop-shadow(0 0 3px rgba(255,255,255,0.6))" } + : undefined, + }; + + switch (type) { + case "RECTANGLE": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const w = data.width ?? 0; + const h = data.height ?? 0; + return ( + + ); + } + + case "ELLIPSE": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const w = data.width ?? 0; + const h = data.height ?? 0; + const cx = x + w / 2; + const cy = y + h / 2; + return ( + + ); + } + + case "ARROW": { + const x1 = data.x ?? 0; + const y1 = data.y ?? 0; + const x2 = data.endX ?? x1; + const y2 = data.endY ?? y1; + const headLen = Math.max(8, strokeWidth * 4); + return ( + + + + + ); + } + + case "FREEHAND": { + const points = data.points ?? []; + if (points.length === 0) return null; + const d = pointsToPath(points); + return ( + + ); + } + + case "TEXT": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const text = data.text ?? ""; + return ( + + {/* Text background for readability */} + + {text} + + + {text} + + + ); + } + + case "PIN": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const r = 6; + return ( + + {/* Pin drop shadow */} + + {/* Pin outer ring */} + + {/* Pin inner dot */} + + + ); + } + + case "SCREENSHOT": { + // Screenshots are rendered by the ScreenshotCallout component via foreignObject + return null; + } + + default: + return null; + } +}); diff --git a/src/components/review/annotation-tools.tsx b/src/components/review/annotation-tools.tsx new file mode 100644 index 0000000..99e97e4 --- /dev/null +++ b/src/components/review/annotation-tools.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { + Square, + Circle, + ArrowUpRight, + Pencil, + Type, + MapPin, + Undo2, + Redo2, + Eye, + EyeOff, + Trash2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export type AnnotationTool = + | "select" + | "rectangle" + | "ellipse" + | "arrow" + | "freehand" + | "text" + | "pin"; + +const PRESET_COLORS = [ + { label: "Red", value: "#EE5540" }, + { label: "Blue", value: "#3B82F6" }, + { label: "Green", value: "#22C55E" }, + { label: "Yellow", value: "#EAB308" }, + { label: "White", value: "#FFFFFF" }, + { label: "Orange", value: "#F97316" }, + { label: "Purple", value: "#A855F7" }, + { label: "Cyan", value: "#06B6D4" }, +]; + +interface AnnotationToolsProps { + activeTool: AnnotationTool; + onToolChange: (tool: AnnotationTool) => void; + color: string; + onColorChange: (color: string) => void; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + visible: boolean; + onToggleVisibility: () => void; + hasSelection: boolean; + onDeleteSelection: () => void; +} + +const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [ + { id: "rectangle", icon: Square, label: "Rectangle", shortcut: "R" }, + { id: "ellipse", icon: Circle, label: "Ellipse", shortcut: "E" }, + { id: "arrow", icon: ArrowUpRight, label: "Arrow", shortcut: "A" }, + { id: "freehand", icon: Pencil, label: "Freehand", shortcut: "F" }, + { id: "text", icon: Type, label: "Text label", shortcut: "T" }, + { id: "pin", icon: MapPin, label: "Pin", shortcut: "P" }, +]; + +export function AnnotationTools({ + activeTool, + onToolChange, + color, + onColorChange, + canUndo, + canRedo, + onUndo, + onRedo, + visible, + onToggleVisibility, + hasSelection, + onDeleteSelection, +}: AnnotationToolsProps) { + return ( +
+ {/* Drawing tools */} + {tools.map((tool) => { + const Icon = tool.icon; + const isActive = activeTool === tool.id; + return ( + + + + + + {tool.label} + {tool.shortcut && ( + + {tool.shortcut} + + )} + + + ); + })} + + + + {/* Color picker */} + + + + + +

+ Stroke Color +

+
+ {PRESET_COLORS.map((c) => ( +
+
+
+ + + + {/* Undo / Redo */} + + + + + + Undo Cmd+Z + + + + + + + + + Redo Cmd+Shift+Z + + + + + + {/* Visibility toggle */} + + + + + + {visible ? "Hide annotations" : "Show annotations"} + + + + {/* Delete selection */} + {hasSelection && ( + + + + + + Delete selected + Del + + + )} +
+ ); +} diff --git a/src/components/review/comparison-toolbar.tsx b/src/components/review/comparison-toolbar.tsx new file mode 100644 index 0000000..99f8355 --- /dev/null +++ b/src/components/review/comparison-toolbar.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { + Columns2, + SplitSquareHorizontal, + Layers, + ToggleLeft, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export type ComparisonMode = "side-by-side" | "wipe" | "overlay" | "toggle"; + +interface RevisionOption { + revisionId: string; + roundNumber: number; + type: "reference" | "current"; + label: string; + url: string; +} + +interface ComparisonToolbarProps { + mode: ComparisonMode; + onModeChange: (mode: ComparisonMode) => void; + revisionOptions: RevisionOption[]; + leftRevisionKey: string; + rightRevisionKey: string; + onLeftChange: (key: string) => void; + onRightChange: (key: string) => void; + onExit: () => void; +} + +const MODE_CONFIG: { + value: ComparisonMode; + label: string; + icon: typeof Columns2; + shortcut: string; +}[] = [ + { value: "side-by-side", label: "Side by Side", icon: Columns2, shortcut: "1" }, + { value: "wipe", label: "A/B Wipe", icon: SplitSquareHorizontal, shortcut: "2" }, + { value: "overlay", label: "Overlay", icon: Layers, shortcut: "3" }, + { value: "toggle", label: "Toggle", icon: ToggleLeft, shortcut: "4" }, +]; + +export function ComparisonToolbar({ + mode, + onModeChange, + revisionOptions, + leftRevisionKey, + rightRevisionKey, + onLeftChange, + onRightChange, + onExit, +}: ComparisonToolbarProps) { + return ( +
+
+ {/* Mode selector */} +
+ {MODE_CONFIG.map(({ value, label, icon: Icon, shortcut }) => ( + + + + + + {label} {shortcut} + + + ))} +
+ +
+ + {/* Revision selectors */} +
+
+ + A + + +
+ +
+ + B + + +
+
+
+ + {/* Exit button */} + + + + + + Exit comparison mode Esc + + +
+ ); +} diff --git a/src/components/review/comparison-viewer.tsx b/src/components/review/comparison-viewer.tsx new file mode 100644 index 0000000..cb99a8f --- /dev/null +++ b/src/components/review/comparison-viewer.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, ImageIcon } from "lucide-react"; +import { useImageViewer } from "@/hooks/use-image-viewer"; +import { ZoomControls } from "@/components/review/zoom-controls"; +import { WipeDivider } from "@/components/review/wipe-divider"; +import { OverlayControls } from "@/components/review/overlay-controls"; +import type { ComparisonMode } from "@/components/review/comparison-toolbar"; + +interface ComparisonViewerProps { + leftSrc: string | null; + rightSrc: string | null; + mode: ComparisonMode; + className?: string; +} + +const ZOOM_STEP = 1.15; +const MIN_ZOOM = 0.05; +const MAX_ZOOM = 5; + +/** + * Comparison viewer that renders two images in the selected comparison mode. + * Manages shared zoom/pan state so both images stay synced. + */ +export function ComparisonViewer({ + leftSrc, + rightSrc, + mode, + className, +}: ComparisonViewerProps) { + // ── Shared zoom/pan state ────────────────────────────────────────────── + const [zoom, setZoomState] = useState(1); + const [panX, setPanX] = useState(0); + const [panY, setPanY] = useState(0); + const zoomRef = useRef(zoom); + const panRef = useRef({ x: panX, y: panY }); + zoomRef.current = zoom; + panRef.current = { x: panX, y: panY }; + + // ── Overlay opacity ──────────────────────────────────────────────────── + const [overlayOpacity, setOverlayOpacity] = useState(50); + + // ── Toggle state ─────────────────────────────────────────────────────── + const [toggleShowRight, setToggleShowRight] = useState(false); + + // ── Canvas-based viewers for side-by-side mode ───────────────────────── + const leftViewer = useImageViewer(); + const rightViewer = useImageViewer(); + + // Load images into canvas viewers when sources change + useEffect(() => { + if (leftSrc && mode === "side-by-side") { + leftViewer.loadImage(leftSrc); + } + }, [leftSrc, mode]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (rightSrc && mode === "side-by-side") { + rightViewer.loadImage(rightSrc); + } + }, [rightSrc, mode]); // eslint-disable-line react-hooks/exhaustive-deps + + // Sync right viewer to left viewer state in side-by-side mode + useEffect(() => { + if (mode !== "side-by-side") return; + const { zoom: lz, panX: lx, panY: ly } = leftViewer.state; + rightViewer.setPan(lx, ly); + // We need to set zoom without re-centering, so set pan after zoom + if (Math.abs(rightViewer.state.zoom - lz) > 0.001) { + rightViewer.setZoom(lz); + rightViewer.setPan(lx, ly); + } + }, [leftViewer.state.zoom, leftViewer.state.panX, leftViewer.state.panY, mode]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Shared pan handler for non-side-by-side modes ────────────────────── + const handlePan = useCallback((newPanX: number, newPanY: number) => { + setPanX(newPanX); + setPanY(newPanY); + }, []); + + const handleZoom = useCallback( + (newZoom: number, centerX: number, centerY: number) => { + const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom)); + const oldZoom = zoomRef.current; + const scale = clamped / oldZoom; + const newPanX = centerX - (centerX - panRef.current.x) * scale; + const newPanY = centerY - (centerY - panRef.current.y) * scale; + setZoomState(clamped); + setPanX(newPanX); + setPanY(newPanY); + }, + [] + ); + + // ── Fit images to container on mode change or source change ──────────── + const sharedContainerRef = useRef(null); + + const fitSharedView = useCallback(() => { + const container = sharedContainerRef.current; + if (!container) return; + // We don't know image dimensions until loaded, use a reasonable default + const rect = container.getBoundingClientRect(); + setZoomState(1); + setPanX(rect.width / 2); + setPanY(rect.height / 2); + }, []); + + useEffect(() => { + if (mode !== "side-by-side") { + fitSharedView(); + } + }, [mode, leftSrc, rightSrc]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Toggle keyboard shortcut (Space) ─────────────────────────────────── + useEffect(() => { + if (mode !== "toggle") return; + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + if (e.code === "Space") { + e.preventDefault(); + setToggleShowRight((prev) => !prev); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [mode]); + + // ── Shared pan/zoom interaction for overlay, toggle, wipe containers ─── + const isPanning = useRef(false); + const lastMouse = useRef({ x: 0, y: 0 }); + + const handleContainerPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + isPanning.current = true; + lastMouse.current = { x: e.clientX, y: e.clientY }; + }, []); + + const handleContainerPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isPanning.current) return; + const dx = e.clientX - lastMouse.current.x; + const dy = e.clientY - lastMouse.current.y; + lastMouse.current = { x: e.clientX, y: e.clientY }; + setPanX((prev) => prev + dx); + setPanY((prev) => prev + dy); + }, + [] + ); + + const handleContainerPointerUp = useCallback(() => { + isPanning.current = false; + }, []); + + const handleContainerWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const container = sharedContainerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + handleZoom(zoomRef.current * factor, mouseX, mouseY); + }, + [handleZoom] + ); + + const imgTransform = `translate(${panX}px, ${panY}px) scale(${zoom})`; + + // ── Empty state ──────────────────────────────────────────────────────── + if (!leftSrc || !rightSrc) { + return ( +
+
+
+ +

+ Select two revisions to compare +

+
+
+
+ ); + } + + // ── Side-by-side mode ────────────────────────────────────────────────── + if (mode === "side-by-side") { + return ( +
+ {/* Shared zoom controls */} +
+ +
+ +
+ {/* Left pane */} +
+
+ A +
+
+ {leftViewer.isLoading && ( +
+ +
+ )} + +
+
+ + {/* Right pane */} +
+
+ B +
+
+ {rightViewer.isLoading && ( +
+ +
+ )} + +
+
+
+
+ ); + } + + // ── Wipe mode ────────────────────────────────────────────────────────── + if (mode === "wipe") { + return ( +
+ {/* Zoom controls */} +
+ handleZoom(zoom * ZOOM_STEP, 0, 0)} + onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)} + onFitToView={fitSharedView} + onZoomToPreset={() => {}} + /> +
+ +
+ +
+
+ ); + } + + // ── Overlay mode ─────────────────────────────────────────────────────── + if (mode === "overlay") { + return ( +
+ {/* Zoom controls */} +
+ handleZoom(zoom * ZOOM_STEP, 0, 0)} + onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)} + onFitToView={fitSharedView} + onZoomToPreset={() => {}} + /> +
+ +
+ {/* Base image (A) */} + Version A + + {/* Overlay image (B) */} + Version B + + {/* Labels */} +
+ A +
+
+ B ({overlayOpacity}%) +
+ + +
+
+ ); + } + + // ── Toggle mode ──────────────────────────────────────────────────────── + return ( +
+ {/* Zoom controls */} +
+ handleZoom(zoom * ZOOM_STEP, 0, 0)} + onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)} + onFitToView={fitSharedView} + onZoomToPreset={() => {}} + /> + + Press Space to toggle + +
+ +
setToggleShowRight((prev) => !prev)} + onPointerDown={handleContainerPointerDown} + onPointerMove={handleContainerPointerMove} + onPointerUp={handleContainerPointerUp} + onPointerLeave={handleContainerPointerUp} + onWheel={handleContainerWheel} + > + {/* Image A */} + Version A + + {/* Image B */} + Version B + + {/* Active label */} +
+ {toggleShowRight ? "B" : "A"} +
+
+
+ ); +} diff --git a/src/components/review/image-viewer.tsx b/src/components/review/image-viewer.tsx index f62a7a9..83e2564 100644 --- a/src/components/review/image-viewer.tsx +++ b/src/components/review/image-viewer.tsx @@ -6,16 +6,31 @@ import { ZoomControls } from "@/components/review/zoom-controls"; import { Minimap } from "@/components/review/minimap"; import { ImageIcon, Loader2 } from "lucide-react"; +export interface ImageViewerState { + zoom: number; + panX: number; + panY: number; + containerWidth: number; + containerHeight: number; + imageDimensions: { width: number; height: number } | null; +} + interface ImageViewerProps { src: string | null; imageDimensionsOverride?: { width: number; height: number } | null; className?: string; + /** Render prop for overlays positioned inside the canvas viewport */ + renderOverlay?: (state: ImageViewerState) => React.ReactNode; + /** Render prop for extra toolbar items (placed after zoom controls) */ + renderToolbar?: (state: ImageViewerState) => React.ReactNode; } export function ImageViewer({ src, imageDimensionsOverride, className, + renderOverlay, + renderToolbar, }: ImageViewerProps) { const viewer = useImageViewer(); @@ -32,6 +47,15 @@ export function ImageViewer({ const containerH = containerRect?.height ?? 0; const dims = imageDimensionsOverride ?? viewer.imageDimensions; + const viewerState: ImageViewerState = { + zoom: viewer.state.zoom, + panX: viewer.state.panX, + panY: viewer.state.panY, + containerWidth: containerW, + containerHeight: containerH, + imageDimensions: dims, + }; + return (
{/* Toolbar */} @@ -51,6 +75,9 @@ export function ImageViewer({ )}
+ {/* Extra toolbar (annotation tools) */} + {renderToolbar?.(viewerState)} + {/* Pixel info */} {viewer.pixelInfo && (
@@ -94,6 +121,9 @@ export function ImageViewer({ className="block h-full w-full" /> + {/* Annotation overlay */} + {renderOverlay?.(viewerState)} + {/* Minimap */} {src && dims && ( void; +} + +export function OverlayControls({ + opacity, + onOpacityChange, +}: OverlayControlsProps) { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onOpacityChange(Number(e.target.value)); + }, + [onOpacityChange] + ); + + return ( +
+ + Opacity + + + + {opacity}% + +
+ ); +} diff --git a/src/components/review/screenshot-callout.tsx b/src/components/review/screenshot-callout.tsx new file mode 100644 index 0000000..3f56181 --- /dev/null +++ b/src/components/review/screenshot-callout.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +interface ScreenshotCalloutProps { + id: string; + imageUrl: string; + x: number; + y: number; + width: number; + height: number; + isSelected: boolean; + zoom: number; + onSelect: (id: string) => void; + onMove: (id: string, x: number, y: number) => void; + onResize: (id: string, width: number, height: number) => void; +} + +const MIN_SIZE = 40; + +export function ScreenshotCallout({ + id, + imageUrl, + x, + y, + width, + height, + isSelected, + zoom, + onSelect, + onMove, + onResize, +}: ScreenshotCalloutProps) { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const dragStart = useRef({ mouseX: 0, mouseY: 0, originX: x, originY: y }); + const resizeStart = useRef({ mouseX: 0, mouseY: 0, originW: width, originH: height }); + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onSelect(id); + setIsDragging(true); + dragStart.current = { mouseX: e.clientX, mouseY: e.clientY, originX: x, originY: y }; + + const handleMove = (me: MouseEvent) => { + const dx = (me.clientX - dragStart.current.mouseX) / zoom; + const dy = (me.clientY - dragStart.current.mouseY) / zoom; + onMove(id, dragStart.current.originX + dx, dragStart.current.originY + dy); + }; + + const handleUp = () => { + setIsDragging(false); + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [id, x, y, zoom, onSelect, onMove] + ); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsResizing(true); + resizeStart.current = { + mouseX: e.clientX, + mouseY: e.clientY, + originW: width, + originH: height, + }; + + const handleMove = (me: MouseEvent) => { + const dx = (me.clientX - resizeStart.current.mouseX) / zoom; + const dy = (me.clientY - resizeStart.current.mouseY) / zoom; + const newW = Math.max(MIN_SIZE, resizeStart.current.originW + dx); + const newH = Math.max(MIN_SIZE, resizeStart.current.originH + dy); + onResize(id, newW, newH); + }; + + const handleUp = () => { + setIsResizing(false); + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [id, width, height, zoom, onResize] + ); + + const borderWidth = 2; + + return ( + +
{ + e.stopPropagation(); + onSelect(id); + }} + style={{ + position: "relative", + width: width, + height: height, + border: `${borderWidth}px solid ${isSelected ? "#fff" : "rgba(0,0,0,0.6)"}`, + boxShadow: isSelected + ? "0 0 0 1px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.5)" + : "0 2px 8px rgba(0,0,0,0.4)", + borderRadius: "3px", + cursor: isDragging ? "grabbing" : "grab", + userSelect: "none", + overflow: "hidden", + }} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Screenshot annotation + + {/* Resize handle — bottom-right corner */} + {isSelected && ( +
+ )} +
+ + ); +} diff --git a/src/components/review/wipe-divider.tsx b/src/components/review/wipe-divider.tsx new file mode 100644 index 0000000..602bed3 --- /dev/null +++ b/src/components/review/wipe-divider.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +interface WipeDividerProps { + /** Left image (A) source URL */ + leftSrc: string; + /** Right image (B) source URL */ + rightSrc: string; + /** Shared zoom level */ + zoom: number; + /** Shared pan X offset */ + panX: number; + /** Shared pan Y offset */ + panY: number; + /** Called when the user drags to pan */ + onPan: (panX: number, panY: number) => void; + /** Called when the user scrolls to zoom */ + onZoom: (newZoom: number, centerX: number, centerY: number) => void; +} + +const ZOOM_STEP = 1.15; + +export function WipeDivider({ + leftSrc, + rightSrc, + zoom, + panX, + panY, + onPan, + onZoom, +}: WipeDividerProps) { + const containerRef = useRef(null); + const [dividerPercent, setDividerPercent] = useState(50); + const isDraggingDivider = useRef(false); + const isPanning = useRef(false); + const lastMouse = useRef({ x: 0, y: 0 }); + + const handleDividerPointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingDivider.current = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (isDraggingDivider.current) { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setDividerPercent(pct); + return; + } + + if (isPanning.current) { + const dx = e.clientX - lastMouse.current.x; + const dy = e.clientY - lastMouse.current.y; + lastMouse.current = { x: e.clientX, y: e.clientY }; + onPan(panX + dx, panY + dy); + } + }, + [panX, panY, onPan] + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + // Only start pan if not on the divider handle + if (isDraggingDivider.current) return; + if (e.button !== 0) return; + isPanning.current = true; + lastMouse.current = { x: e.clientX, y: e.clientY }; + }, + [] + ); + + const handlePointerUp = useCallback(() => { + isDraggingDivider.current = false; + isPanning.current = false; + }, []); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + onZoom(zoom * factor, mouseX, mouseY); + }, + [zoom, onZoom] + ); + + const imgTransform = `translate(${panX}px, ${panY}px) scale(${zoom})`; + + return ( +
+ {/* Left image (A) — full frame, visible behind the right clipped layer */} + Version A + + {/* Right image (B) — clipped to reveal from divider rightward */} +
+ Version B +
+ + {/* Divider handle */} +
+ {/* Line */} +
+ + {/* Drag handle */} +
+
+
+
+
+
+
+ + {/* Labels */} +
+ A +
+
+ B +
+
+ ); +} diff --git a/src/hooks/use-annotation-state.ts b/src/hooks/use-annotation-state.ts new file mode 100644 index 0000000..b5ed469 --- /dev/null +++ b/src/hooks/use-annotation-state.ts @@ -0,0 +1,512 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { AnnotationShape } from "@/components/review/annotation-renderer"; +import type { AnnotationTool } from "@/components/review/annotation-tools"; +import { + useAnnotations, + useCreateAnnotation, + useDeleteAnnotation, + useUpdateAnnotation, +} from "@/hooks/use-annotations"; +import type { AnnotationTypeValue, CreateAnnotationInput } from "@/lib/validators/annotation"; + +// ── Types ────────────────────────────────────────────── + +interface DrawingState { + type: AnnotationTypeValue; + startX: number; + startY: number; + currentX: number; + currentY: number; + points: { x: number; y: number }[]; +} + +interface UndoEntry { + action: "create" | "delete"; + annotationId: string; + input?: CreateAnnotationInput; +} + +const TOOL_TO_TYPE: Record, AnnotationTypeValue> = { + rectangle: "RECTANGLE", + ellipse: "ELLIPSE", + arrow: "ARROW", + freehand: "FREEHAND", + text: "TEXT", + pin: "PIN", +}; + +function screenToImage( + screenX: number, + screenY: number, + panX: number, + panY: number, + zoom: number +): { x: number; y: number } { + return { + x: (screenX - panX) / zoom, + y: (screenY - panY) / zoom, + }; +} + +// ── Hook ─────────────────────────────────────────────── + +export function useAnnotationState( + revisionId: string | null, + stageId: string | null +) { + const [activeTool, setActiveTool] = useState("select"); + const [color, setColor] = useState("#EE5540"); + const [visible, setVisible] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [drawing, setDrawing] = useState(null); + const [textInput, setTextInput] = useState<{ + x: number; + y: number; + imgX: number; + imgY: number; + } | null>(null); + const [textValue, setTextValue] = useState(""); + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + + const svgRef = useRef(null); + const textInputRef = useRef(null); + + // Data hooks + const { data: annotationsRaw } = useAnnotations(revisionId); + const annotations = (annotationsRaw as any[]) ?? []; + const createMutation = useCreateAnnotation(revisionId); + const deleteMutation = useDeleteAnnotation(revisionId); + const updateMutation = useUpdateAnnotation(revisionId); + + // Derived shapes + const annotationShapes: AnnotationShape[] = useMemo(() => { + return annotations.map((a: any) => ({ + id: a.id, + type: a.type as AnnotationTypeValue, + data: a.data ?? {}, + imageX: a.imageX, + imageY: a.imageY, + isSelected: a.id === selectedId, + onClick: (id: string) => setSelectedId(id), + })); + }, [annotations, selectedId]); + + const screenshotAnnotations = useMemo(() => { + return annotations.filter((a: any) => a.type === "SCREENSHOT"); + }, [annotations]); + + // Save annotation + const saveAnnotation = useCallback( + ( + type: AnnotationTypeValue, + data: Record, + imgX: number, + imgY: number + ) => { + if (!revisionId || !stageId) return; + + const typeLabel = type.charAt(0) + type.slice(1).toLowerCase(); + const commentContent = `${typeLabel} annotation at (${Math.round(imgX)}, ${Math.round(imgY)})`; + + const input: CreateAnnotationInput = { + type, + data: data as any, + imageX: imgX, + imageY: imgY, + commentContent, + stageId, + }; + + createMutation.mutate(input, { + onSuccess: (result: any) => { + setUndoStack((prev) => [...prev, { action: "create", annotationId: result.id, input }]); + setRedoStack([]); + }, + onError: (err) => { + toast.error(`Failed to save annotation: ${err.message}`); + }, + }); + }, + [revisionId, stageId, createMutation] + ); + + // Get image coordinates from screen event + const getImageCoords = useCallback( + (e: React.MouseEvent, panX: number, panY: number, zoom: number) => { + const svgEl = svgRef.current; + if (!svgEl) return { x: 0, y: 0 }; + const rect = svgEl.getBoundingClientRect(); + const screenX = e.clientX - rect.left; + const screenY = e.clientY - rect.top; + return screenToImage(screenX, screenY, panX, panY, zoom); + }, + [] + ); + + // Mouse handlers (need zoom/pan passed in) + const handleMouseDown = useCallback( + (e: React.MouseEvent, panX: number, panY: number, zoom: number) => { + if (activeTool === "select") { + setSelectedId(null); + return; + } + + const img = getImageCoords(e, panX, panY, zoom); + + if (activeTool === "text") { + const svgEl = svgRef.current; + if (!svgEl) return; + const rect = svgEl.getBoundingClientRect(); + setTextInput({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + imgX: img.x, + imgY: img.y, + }); + setTextValue(""); + setTimeout(() => textInputRef.current?.focus(), 0); + return; + } + + if (activeTool === "pin") { + saveAnnotation("PIN", { x: img.x, y: img.y, color }, img.x, img.y); + return; + } + + const type = TOOL_TO_TYPE[activeTool]; + setDrawing({ + type, + startX: img.x, + startY: img.y, + currentX: img.x, + currentY: img.y, + points: [{ x: img.x, y: img.y }], + }); + }, + [activeTool, getImageCoords, color, saveAnnotation] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent, panX: number, panY: number, zoom: number) => { + if (!drawing) return; + const img = getImageCoords(e, panX, panY, zoom); + setDrawing((prev) => { + if (!prev) return prev; + return { + ...prev, + currentX: img.x, + currentY: img.y, + points: + prev.type === "FREEHAND" + ? [...prev.points, { x: img.x, y: img.y }] + : prev.points, + }; + }); + }, + [drawing, getImageCoords] + ); + + const handleMouseUp = useCallback(() => { + if (!drawing) return; + + const { type, startX, startY, currentX, currentY, points } = drawing; + let data: Record = { color }; + + switch (type) { + case "RECTANGLE": + case "ELLIPSE": + data = { + ...data, + x: Math.min(startX, currentX), + y: Math.min(startY, currentY), + width: Math.abs(currentX - startX), + height: Math.abs(currentY - startY), + }; + break; + case "ARROW": + data = { ...data, x: startX, y: startY, endX: currentX, endY: currentY }; + break; + case "FREEHAND": + data = { ...data, points }; + break; + } + + // Discard tiny accidental clicks + if (type === "RECTANGLE" || type === "ELLIPSE") { + if (Math.abs(currentX - startX) < 3 && Math.abs(currentY - startY) < 3) { + setDrawing(null); + return; + } + } + if (type === "ARROW") { + const dist = Math.sqrt((currentX - startX) ** 2 + (currentY - startY) ** 2); + if (dist < 3) { + setDrawing(null); + return; + } + } + if (type === "FREEHAND" && points.length < 3) { + setDrawing(null); + return; + } + + saveAnnotation(type, data, startX, startY); + setDrawing(null); + }, [drawing, color, saveAnnotation]); + + // Text commit + const commitTextAnnotation = useCallback(() => { + if (!textInput || !textValue.trim()) { + setTextInput(null); + setTextValue(""); + return; + } + saveAnnotation( + "TEXT", + { x: textInput.imgX, y: textInput.imgY, text: textValue.trim(), color }, + textInput.imgX, + textInput.imgY + ); + setTextInput(null); + setTextValue(""); + }, [textInput, textValue, color, saveAnnotation]); + + // Undo / Redo + const handleUndo = useCallback(() => { + const last = undoStack[undoStack.length - 1]; + if (!last) return; + setUndoStack((prev) => prev.slice(0, -1)); + if (last.action === "create") { + deleteMutation.mutate(last.annotationId, { + onSuccess: () => setRedoStack((prev) => [...prev, last]), + }); + } + }, [undoStack, deleteMutation]); + + const handleRedo = useCallback(() => { + const last = redoStack[redoStack.length - 1]; + if (!last || !last.input) return; + setRedoStack((prev) => prev.slice(0, -1)); + if (last.action === "create") { + createMutation.mutate(last.input, { + onSuccess: (result: any) => { + setUndoStack((prev) => [ + ...prev, + { action: "create", annotationId: result.id, input: last.input }, + ]); + }, + }); + } + }, [redoStack, createMutation]); + + // Delete selection + const handleDeleteSelection = useCallback(() => { + if (!selectedId) return; + deleteMutation.mutate(selectedId, { + onSuccess: () => { + setSelectedId(null); + toast.success("Annotation deleted"); + }, + onError: (err) => toast.error(`Failed to delete: ${err.message}`), + }); + }, [selectedId, deleteMutation]); + + // Screenshot move/resize + const handleScreenshotMove = useCallback( + (id: string, newX: number, newY: number) => { + const ann = annotations.find((a: any) => a.id === id); + if (!ann) return; + const newData = { ...(ann.data as any), x: newX, y: newY }; + updateMutation.mutate({ annotationId: id, data: { data: newData, imageX: newX, imageY: newY } }); + }, + [annotations, updateMutation] + ); + + const handleScreenshotResize = useCallback( + (id: string, newW: number, newH: number) => { + const ann = annotations.find((a: any) => a.id === id); + if (!ann) return; + const newData = { ...(ann.data as any), width: newW, height: newH }; + updateMutation.mutate({ annotationId: id, data: { data: newData } }); + }, + [annotations, updateMutation] + ); + + // Screenshot paste + const handleScreenshotPaste = useCallback( + async ( + file: File, + containerWidth: number, + containerHeight: number, + panX: number, + panY: number, + zoom: number, + imageDimensions: { width: number; height: number } | null + ) => { + if (!revisionId || !stageId) return; + + const formData = new FormData(); + formData.append("file", file); + formData.append("type", "current"); + + try { + const uploadRes = await fetch( + `/api/stages/${stageId}/revisions/${revisionId}/upload`, + { method: "POST", body: formData } + ); + + if (!uploadRes.ok) { + const errBody = await uploadRes.json().catch(() => ({})); + toast.error(errBody.error || "Failed to upload screenshot"); + return; + } + + const uploaded = await uploadRes.json(); + const centerImg = screenToImage( + containerWidth / 2, + containerHeight / 2, + panX, + panY, + zoom + ); + + const defaultWidth = Math.min(300, (imageDimensions?.width ?? 600) * 0.3); + const defaultHeight = defaultWidth * 0.75; + + saveAnnotation( + "SCREENSHOT", + { + x: centerImg.x - defaultWidth / 2, + y: centerImg.y - defaultHeight / 2, + width: defaultWidth, + height: defaultHeight, + imageUrl: uploaded.url, + color, + }, + centerImg.x, + centerImg.y + ); + + toast.success("Screenshot pasted"); + } catch { + toast.error("Failed to paste screenshot"); + } + }, + [revisionId, stageId, color, saveAnnotation] + ); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + + if (!e.metaKey && !e.ctrlKey) { + switch (e.key.toLowerCase()) { + case "v": setActiveTool("select"); return; + case "r": setActiveTool("rectangle"); return; + case "e": setActiveTool("ellipse"); return; + case "a": setActiveTool("arrow"); return; + case "f": setActiveTool("freehand"); return; + case "t": setActiveTool("text"); return; + case "p": setActiveTool("pin"); return; + case "delete": + case "backspace": + if (selectedId) { e.preventDefault(); handleDeleteSelection(); } + return; + case "escape": + setSelectedId(null); + setActiveTool("select"); + setTextInput(null); + setDrawing(null); + return; + } + } + + if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { + e.preventDefault(); + handleRedo(); + return; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedId, handleDeleteSelection, handleUndo, handleRedo]); + + // Drawing preview + const drawingPreview = useMemo((): AnnotationShape | null => { + if (!drawing) return null; + const { type, startX, startY, currentX, currentY, points } = drawing; + const data: Record = { color }; + + switch (type) { + case "RECTANGLE": + case "ELLIPSE": + data.x = Math.min(startX, currentX); + data.y = Math.min(startY, currentY); + data.width = Math.abs(currentX - startX); + data.height = Math.abs(currentY - startY); + break; + case "ARROW": + data.x = startX; data.y = startY; + data.endX = currentX; data.endY = currentY; + break; + case "FREEHAND": + data.points = points; + break; + } + + return { id: "__drawing__", type, data, imageX: startX, imageY: startY }; + }, [drawing, color]); + + return { + // Tool state + activeTool, + setActiveTool, + color, + setColor, + visible, + setVisible, + selectedId, + setSelectedId, + drawing, + textInput, + setTextInput, + textValue, + setTextValue, + // Refs + svgRef, + textInputRef, + // Derived + annotationShapes, + screenshotAnnotations, + drawingPreview, + // Undo/redo + canUndo: undoStack.length > 0, + canRedo: redoStack.length > 0, + handleUndo, + handleRedo, + // Actions + handleMouseDown, + handleMouseMove, + handleMouseUp, + commitTextAnnotation, + handleDeleteSelection, + handleScreenshotMove, + handleScreenshotResize, + handleScreenshotPaste, + }; +} diff --git a/src/hooks/use-annotations.ts b/src/hooks/use-annotations.ts new file mode 100644 index 0000000..5454800 --- /dev/null +++ b/src/hooks/use-annotations.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation"; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + return res.json(); +} + +export function useAnnotations(revisionId: string | null) { + return useQuery({ + queryKey: ["annotations", revisionId], + queryFn: () => fetchJson(`/api/revisions/${revisionId}/annotations`), + enabled: !!revisionId, + }); +} + +export function useCreateAnnotation(revisionId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateAnnotationInput) => + fetchJson(`/api/revisions/${revisionId}/annotations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] }); + }, + }); +} + +export function useUpdateAnnotation(revisionId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + annotationId, + data, + }: { + annotationId: string; + data: UpdateAnnotationInput; + }) => + fetchJson(`/api/revisions/${revisionId}/annotations/${annotationId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] }); + }, + }); +} + +export function useDeleteAnnotation(revisionId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (annotationId: string) => + fetchJson(`/api/revisions/${revisionId}/annotations/${annotationId}`, { + method: "DELETE", + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] }); + }, + }); +} diff --git a/src/lib/services/annotation-service.ts b/src/lib/services/annotation-service.ts new file mode 100644 index 0000000..842dbea --- /dev/null +++ b/src/lib/services/annotation-service.ts @@ -0,0 +1,115 @@ +import { prisma } from "@/lib/prisma"; +import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation"; + +/** + * List all annotations for a revision, including the linked comment + author. + */ +export async function listAnnotations(revisionId: string) { + return prisma.annotation.findMany({ + where: { revisionId }, + include: { + comment: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + createdBy: { select: { id: true, name: true, email: true, image: true } }, + }, + orderBy: { createdAt: "asc" }, + }); +} + +/** + * Create an annotation and its linked comment in a single transaction. + * The comment is created on the stage associated with the revision. + */ +export async function createAnnotation( + revisionId: string, + userId: string, + input: CreateAnnotationInput +) { + return prisma.$transaction(async (tx) => { + // Create the linked comment on the stage + const comment = await tx.comment.create({ + data: { + deliverableStageId: input.stageId, + authorId: userId, + content: input.commentContent, + }, + }); + + // Create the annotation linked to the comment and revision + const annotation = await tx.annotation.create({ + data: { + revisionId, + commentId: comment.id, + type: input.type, + data: input.data as any, + imageX: input.imageX, + imageY: input.imageY, + createdById: userId, + }, + include: { + comment: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + createdBy: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + + return annotation; + }); +} + +/** + * Update annotation data (position, shape data). + */ +export async function updateAnnotation( + annotationId: string, + userId: string, + input: UpdateAnnotationInput +) { + const annotation = await prisma.annotation.findUnique({ + where: { id: annotationId }, + }); + + if (!annotation) throw new Error("Annotation not found"); + if (annotation.createdById !== userId) throw new Error("Not authorized"); + + return prisma.annotation.update({ + where: { id: annotationId }, + data: { + ...(input.data !== undefined && { data: input.data as any }), + ...(input.imageX !== undefined && { imageX: input.imageX }), + ...(input.imageY !== undefined && { imageY: input.imageY }), + }, + include: { + comment: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + createdBy: { select: { id: true, name: true, email: true, image: true } }, + }, + }); +} + +/** + * Delete an annotation and its linked comment (cascade via Comment relation). + */ +export async function deleteAnnotation(annotationId: string, userId: string) { + const annotation = await prisma.annotation.findUnique({ + where: { id: annotationId }, + select: { id: true, commentId: true, createdById: true }, + }); + + if (!annotation) throw new Error("Annotation not found"); + if (annotation.createdById !== userId) throw new Error("Not authorized"); + + // Deleting the comment cascades to the annotation + await prisma.comment.delete({ where: { id: annotation.commentId } }); + + return { ok: true }; +} diff --git a/src/lib/validators/annotation.ts b/src/lib/validators/annotation.ts new file mode 100644 index 0000000..5659e57 --- /dev/null +++ b/src/lib/validators/annotation.ts @@ -0,0 +1,50 @@ +import { z } from "zod/v4"; + +const annotationTypeEnum = z.enum([ + "RECTANGLE", + "ELLIPSE", + "ARROW", + "FREEHAND", + "TEXT", + "PIN", + "SCREENSHOT", +]); + +export type AnnotationTypeValue = z.infer; + +/** + * Shape data varies by annotation type. We use a loose Json schema + * here and validate more specifically in the service layer if needed. + */ +const annotationDataSchema = z.object({ + x: z.number().optional(), + y: z.number().optional(), + width: z.number().optional(), + height: z.number().optional(), + endX: z.number().optional(), + endY: z.number().optional(), + points: z.array(z.object({ x: z.number(), y: z.number() })).optional(), + text: z.string().optional(), + color: z.string().optional(), + strokeWidth: z.number().optional(), + imageUrl: z.string().optional(), +}); + +export const createAnnotationSchema = z.object({ + type: annotationTypeEnum, + data: annotationDataSchema, + imageX: z.number(), + imageY: z.number(), + commentContent: z.string().min(1, "Comment text is required"), + stageId: z.string().min(1, "Stage ID is required"), +}); + +export type CreateAnnotationInput = z.infer; + +export const updateAnnotationSchema = z.object({ + data: annotationDataSchema.optional(), + imageX: z.number().optional(), + imageY: z.number().optional(), +}); + +export type UpdateAnnotationInput = z.infer;