feat: add version comparison (A2) and annotation system (A3)

A2 — Version Comparison:
- 4 comparison modes: side-by-side, A-B wipe slider, overlay with
  opacity, toggle with crossfade
- Synced zoom/pan across all modes
- Revision selectors for left/right image
- Keyboard shortcuts: 1-4 switch modes, Escape exits

A3 — Annotations:
- SVG overlay with 7 annotation types: rectangle, ellipse, arrow,
  freehand, text, pin, screenshot paste (Cmd+V)
- All annotations anchored to image coordinates (accurate at any zoom)
- Annotation model added to Prisma schema (requires db push)
- CRUD API routes at /api/revisions/[id]/annotations
- Annotations linked to comments (transactional create)
- Screenshot callouts: draggable, resizable with corner handles
- Undo/redo stack, color picker, visibility toggle
- Floating toolbar with backdrop blur

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leivur R. Djurhuus 2026-03-14 14:46:51 -05:00 committed by Leivur Djurhuus
parent 1fa8803bfc
commit eba5e30c98
18 changed files with 2725 additions and 12 deletions

View file

@ -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.

View file

@ -697,7 +697,7 @@ model SearchLog {
@@map("search_logs")
}
// ─── Enums (from feature branch) ────────────────────────
// ─── Annotations (Visual Review) ────────────────────────
enum AnnotationType {
RECTANGLE

View file

@ -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<string | null>(null);
// ── Comparison mode state ────────────────────────────────────────────
const [comparisonActive, setComparisonActive] = useState(false);
const [comparisonMode, setComparisonMode] =
useState<ComparisonMode>("side-by-side");
const [leftRevisionKey, setLeftRevisionKey] = useState<string>("");
const [rightRevisionKey, setRightRevisionKey] = useState<string>("");
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 (
<div className="flex h-full flex-col">
@ -234,6 +349,19 @@ export default function ReviewPage() {
<Separator orientation="vertical" className="h-5" />
{/* Compare toggle */}
{!comparisonActive && galleryImages.length >= 2 && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={handleEnterComparison}
>
<Columns2 className="mr-1 h-3 w-3" />
Compare
</Button>
)}
{/* Upload panel trigger */}
<Sheet open={uploadPanelOpen} onOpenChange={setUploadPanelOpen}>
<SheetTrigger asChild>
@ -288,11 +416,49 @@ export default function ReviewPage() {
)}
</div>
{/* ── Image viewer ─────────────────────────────────────────── */}
<ImageViewer src={activeImageUrl} className="flex-1" />
{/* ── Comparison toolbar (when active) ──────────────────────── */}
{comparisonActive && (
<ComparisonToolbar
mode={comparisonMode}
onModeChange={setComparisonMode}
revisionOptions={revisionOptions}
leftRevisionKey={leftRevisionKey}
rightRevisionKey={rightRevisionKey}
onLeftChange={setLeftRevisionKey}
onRightChange={setRightRevisionKey}
onExit={handleExitComparison}
/>
)}
{/* ── Image viewer / Comparison viewer ─────────────────────── */}
{comparisonActive ? (
<ComparisonViewer
leftSrc={leftSrc}
rightSrc={rightSrc}
mode={comparisonMode}
className="flex-1"
/>
) : (
<ImageViewer
src={activeImageUrl}
className="flex-1"
renderOverlay={(vs: ImageViewerState) => (
<AnnotationLayer
revisionId={activeRevisionId}
stageId={selectedStageId}
zoom={vs.zoom}
panX={vs.panX}
panY={vs.panY}
containerWidth={vs.containerWidth}
containerHeight={vs.containerHeight}
imageDimensions={vs.imageDimensions}
/>
)}
/>
)}
{/* ── Gallery strip ────────────────────────────────────────── */}
{galleryImages.length > 0 && (
{!comparisonActive && galleryImages.length > 0 && (
<div className="border-t px-3 py-1.5">
<ImageGallery
images={galleryImages}

View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
import { updateAnnotationSchema } from "@/lib/validators/annotation";
import { updateAnnotation, deleteAnnotation } from "@/lib/services/annotation-service";
type Params = { params: Promise<{ revisionId: string; annotationId: string }> };
// 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);
}
}

View file

@ -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);
}
}

View file

@ -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) ── */}
<div className="absolute left-1/2 top-2 z-30 -translate-x-1/2">
<div className="flex items-center rounded-md border bg-[var(--card)]/90 px-2 py-1 shadow-lg backdrop-blur-sm">
<AnnotationTools
activeTool={ann.activeTool}
onToolChange={ann.setActiveTool}
color={ann.color}
onColorChange={ann.setColor}
canUndo={ann.canUndo}
canRedo={ann.canRedo}
onUndo={ann.handleUndo}
onRedo={ann.handleRedo}
visible={ann.visible}
onToggleVisibility={() => ann.setVisible((v: boolean) => !v)}
hasSelection={!!ann.selectedId}
onDeleteSelection={ann.handleDeleteSelection}
/>
</div>
</div>
{/* ── SVG overlay ────────────────────────────────── */}
<svg
ref={ann.svgRef}
className="absolute inset-0 z-20"
width={containerWidth}
height={containerHeight}
style={{
cursor: cursorStyle,
pointerEvents:
ann.activeTool === "select" && !ann.selectedId ? "none" : "auto",
}}
onMouseDown={(e) => ann.handleMouseDown(e, panX, panY, zoom)}
onMouseMove={(e) => ann.handleMouseMove(e, panX, panY, zoom)}
onMouseUp={ann.handleMouseUp}
>
{ann.visible && (
<g transform={`translate(${panX}, ${panY}) scale(${zoom})`}>
{/* Persisted annotations */}
{ann.annotationShapes
.filter((a: AnnotationShape) => a.type !== "SCREENSHOT")
.map((a: AnnotationShape) => (
<AnnotationRenderer key={a.id} annotation={a} />
))}
{/* Screenshot callouts */}
{ann.screenshotAnnotations.map((a: any) => {
const d = a.data ?? {};
return (
<ScreenshotCallout
key={a.id}
id={a.id}
imageUrl={d.imageUrl ?? ""}
x={d.x ?? 0}
y={d.y ?? 0}
width={d.width ?? 200}
height={d.height ?? 150}
isSelected={a.id === ann.selectedId}
zoom={zoom}
onSelect={ann.setSelectedId}
onMove={ann.handleScreenshotMove}
onResize={ann.handleScreenshotResize}
/>
);
})}
{/* Drawing preview */}
{ann.drawingPreview && (
<g opacity={0.8}>
<AnnotationRenderer annotation={ann.drawingPreview} />
</g>
)}
</g>
)}
</svg>
{/* ── Floating text input ────────────────────────── */}
{ann.textInput && (
<div
className="absolute z-50"
style={{
left: ann.textInput.x,
top: ann.textInput.y,
transform: "translate(-4px, -14px)",
}}
>
<Input
ref={ann.textInputRef}
value={ann.textValue}
onChange={(e) => 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
/>
</div>
)}
</>
);
}

View file

@ -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 (
<rect
x={Math.min(x, x + w)}
y={Math.min(y, y + h)}
width={Math.abs(w)}
height={Math.abs(h)}
rx={2}
{...sharedProps}
/>
);
}
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 (
<ellipse
cx={cx}
cy={cy}
rx={Math.abs(w / 2)}
ry={Math.abs(h / 2)}
{...sharedProps}
/>
);
}
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 (
<g onClick={handleClick} cursor="pointer">
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
{...sharedProps}
/>
<path
d={buildArrowHeadPath(x1, y1, x2, y2, headLen)}
stroke={color}
strokeWidth={strokeWidth + selectionExtra}
fill="none"
strokeLinejoin="round"
strokeLinecap="round"
/>
</g>
);
}
case "FREEHAND": {
const points = data.points ?? [];
if (points.length === 0) return null;
const d = pointsToPath(points);
return (
<path
d={d}
strokeLinecap="round"
strokeLinejoin="round"
{...sharedProps}
/>
);
}
case "TEXT": {
const x = data.x ?? 0;
const y = data.y ?? 0;
const text = data.text ?? "";
return (
<g onClick={handleClick} cursor="pointer">
{/* Text background for readability */}
<text
x={x}
y={y}
fill="black"
stroke="black"
strokeWidth={4}
fontSize={14}
fontFamily="Inter, sans-serif"
paintOrder="stroke"
strokeLinejoin="round"
style={{ userSelect: "none" }}
>
{text}
</text>
<text
x={x}
y={y}
fill={color}
fontSize={14}
fontFamily="Inter, sans-serif"
style={{
userSelect: "none",
...(isSelected
? { filter: "drop-shadow(0 0 3px rgba(255,255,255,0.6))" }
: {}),
}}
>
{text}
</text>
</g>
);
}
case "PIN": {
const x = data.x ?? 0;
const y = data.y ?? 0;
const r = 6;
return (
<g onClick={handleClick} cursor="pointer">
{/* Pin drop shadow */}
<circle cx={x} cy={y} r={r + 2} fill="rgba(0,0,0,0.4)" />
{/* Pin outer ring */}
<circle
cx={x}
cy={y}
r={r}
fill={color}
stroke="white"
strokeWidth={2 + selectionExtra}
style={isSelected
? { filter: "drop-shadow(0 0 3px rgba(255,255,255,0.6))" }
: undefined}
/>
{/* Pin inner dot */}
<circle cx={x} cy={y} r={2} fill="white" />
</g>
);
}
case "SCREENSHOT": {
// Screenshots are rendered by the ScreenshotCallout component via foreignObject
return null;
}
default:
return null;
}
});

View file

@ -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 (
<div className="flex items-center gap-1">
{/* Drawing tools */}
{tools.map((tool) => {
const Icon = tool.icon;
const isActive = activeTool === tool.id;
return (
<Tooltip key={tool.id}>
<TooltipTrigger asChild>
<Button
size="sm"
variant={isActive ? "default" : "ghost"}
className={cn(
"h-7 w-7 p-0",
isActive && "bg-[var(--primary)] text-white"
)}
onClick={() => onToolChange(tool.id)}
>
<Icon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{tool.label}
{tool.shortcut && (
<kbd className="ml-1.5 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">
{tool.shortcut}
</kbd>
)}
</TooltipContent>
</Tooltip>
);
})}
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Color picker */}
<Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost" className="h-7 w-7 p-0">
<div
className="h-4 w-4 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2"
side="bottom"
align="start"
>
<p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Stroke Color
</p>
<div className="grid grid-cols-4 gap-1">
{PRESET_COLORS.map((c) => (
<button
key={c.value}
className={cn(
"h-6 w-6 rounded-full border-2 transition-transform hover:scale-110",
color === c.value
? "border-white scale-110"
: "border-transparent"
)}
style={{ backgroundColor: c.value }}
onClick={() => onColorChange(c.value)}
title={c.label}
/>
))}
</div>
</PopoverContent>
</Popover>
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Undo / Redo */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
disabled={!canUndo}
onClick={onUndo}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Undo <kbd className="ml-1 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">Cmd+Z</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
disabled={!canRedo}
onClick={onRedo}
>
<Redo2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Redo <kbd className="ml-1 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">Cmd+Shift+Z</kbd>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Visibility toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onToggleVisibility}
>
{visible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{visible ? "Hide annotations" : "Show annotations"}
</TooltipContent>
</Tooltip>
{/* Delete selection */}
{hasSelection && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-red-400 hover:text-red-300"
onClick={onDeleteSelection}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Delete selected
<kbd className="ml-1 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">Del</kbd>
</TooltipContent>
</Tooltip>
)}
</div>
);
}

View file

@ -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 (
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
<div className="flex items-center gap-3">
{/* Mode selector */}
<div className="flex items-center gap-0.5 rounded-md border bg-[var(--card)] p-0.5">
{MODE_CONFIG.map(({ value, label, icon: Icon, shortcut }) => (
<Tooltip key={value}>
<TooltipTrigger asChild>
<Button
size="sm"
variant={mode === value ? "default" : "ghost"}
className="h-7 w-7 p-0"
onClick={() => onModeChange(value)}
>
<Icon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{label} <kbd className="ml-1 rounded bg-[var(--border)] px-1 font-mono text-[10px]">{shortcut}</kbd>
</TooltipContent>
</Tooltip>
))}
</div>
<div className="mx-0.5 h-4 w-px bg-[var(--border)]" />
{/* Revision selectors */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
A
</span>
<Select value={leftRevisionKey} onValueChange={onLeftChange}>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
{revisionOptions.map((opt) => (
<SelectItem
key={`${opt.revisionId}-${opt.type}`}
value={`${opt.revisionId}-${opt.type}`}
className="text-xs"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
B
</span>
<Select value={rightRevisionKey} onValueChange={onRightChange}>
<SelectTrigger className="h-7 w-[160px] text-xs">
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
{revisionOptions.map((opt) => (
<SelectItem
key={`${opt.revisionId}-${opt.type}`}
value={`${opt.revisionId}-${opt.type}`}
className="text-xs"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Exit button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs text-[var(--muted-foreground)]"
onClick={onExit}
>
<X className="h-3.5 w-3.5" />
Exit Compare
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Exit comparison mode <kbd className="ml-1 rounded bg-[var(--border)] px-1 font-mono text-[10px]">Esc</kbd>
</TooltipContent>
</Tooltip>
</div>
);
}

View file

@ -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<HTMLDivElement | null>(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 (
<div className={`relative flex flex-col ${className ?? ""}`}>
<div className="flex flex-1 items-center justify-center bg-[#1a1a1a] text-[var(--muted-foreground)]">
<div className="flex flex-col items-center gap-2">
<ImageIcon className="h-12 w-12 opacity-30" />
<p className="text-sm">
Select two revisions to compare
</p>
</div>
</div>
</div>
);
}
// ── Side-by-side mode ──────────────────────────────────────────────────
if (mode === "side-by-side") {
return (
<div className={`relative flex flex-col ${className ?? ""}`}>
{/* Shared zoom controls */}
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
<ZoomControls
zoom={leftViewer.state.zoom}
onZoomIn={leftViewer.zoomIn}
onZoomOut={leftViewer.zoomOut}
onFitToView={leftViewer.fitToView}
onZoomToPreset={leftViewer.zoomToPreset}
/>
</div>
<div className="flex flex-1">
{/* Left pane */}
<div className="relative flex-1 overflow-hidden border-r border-[var(--border)]">
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
A
</div>
<div
ref={leftViewer.containerRef}
className="h-full w-full overflow-hidden bg-[#1a1a1a]"
>
{leftViewer.isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[var(--muted-foreground)]" />
</div>
)}
<canvas
ref={leftViewer.canvasRef}
className="block h-full w-full"
/>
</div>
</div>
{/* Right pane */}
<div className="relative flex-1 overflow-hidden">
<div className="absolute right-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
B
</div>
<div
ref={rightViewer.containerRef}
className="h-full w-full overflow-hidden bg-[#1a1a1a]"
>
{rightViewer.isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[var(--muted-foreground)]" />
</div>
)}
<canvas
ref={rightViewer.canvasRef}
className="block h-full w-full"
/>
</div>
</div>
</div>
</div>
);
}
// ── Wipe mode ──────────────────────────────────────────────────────────
if (mode === "wipe") {
return (
<div className={`relative flex flex-col ${className ?? ""}`}>
{/* Zoom controls */}
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
<ZoomControls
zoom={zoom}
onZoomIn={() => handleZoom(zoom * ZOOM_STEP, 0, 0)}
onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)}
onFitToView={fitSharedView}
onZoomToPreset={() => {}}
/>
</div>
<div className="flex-1" ref={sharedContainerRef}>
<WipeDivider
leftSrc={leftSrc}
rightSrc={rightSrc}
zoom={zoom}
panX={panX}
panY={panY}
onPan={handlePan}
onZoom={handleZoom}
/>
</div>
</div>
);
}
// ── Overlay mode ───────────────────────────────────────────────────────
if (mode === "overlay") {
return (
<div className={`relative flex flex-col ${className ?? ""}`}>
{/* Zoom controls */}
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
<ZoomControls
zoom={zoom}
onZoomIn={() => handleZoom(zoom * ZOOM_STEP, 0, 0)}
onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)}
onFitToView={fitSharedView}
onZoomToPreset={() => {}}
/>
</div>
<div
ref={sharedContainerRef}
className="relative flex-1 cursor-grab overflow-hidden bg-[#1a1a1a] active:cursor-grabbing"
onPointerDown={handleContainerPointerDown}
onPointerMove={handleContainerPointerMove}
onPointerUp={handleContainerPointerUp}
onPointerLeave={handleContainerPointerUp}
onWheel={handleContainerWheel}
>
{/* Base image (A) */}
<img
src={leftSrc}
alt="Version A"
draggable={false}
className="pointer-events-none absolute left-0 top-0 origin-top-left select-none"
style={{ transform: imgTransform }}
/>
{/* Overlay image (B) */}
<img
src={rightSrc}
alt="Version B"
draggable={false}
className="pointer-events-none absolute left-0 top-0 origin-top-left select-none"
style={{
transform: imgTransform,
opacity: overlayOpacity / 100,
}}
/>
{/* Labels */}
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
A
</div>
<div className="absolute right-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
B ({overlayOpacity}%)
</div>
<OverlayControls
opacity={overlayOpacity}
onOpacityChange={setOverlayOpacity}
/>
</div>
</div>
);
}
// ── Toggle mode ────────────────────────────────────────────────────────
return (
<div className={`relative flex flex-col ${className ?? ""}`}>
{/* Zoom controls */}
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
<ZoomControls
zoom={zoom}
onZoomIn={() => handleZoom(zoom * ZOOM_STEP, 0, 0)}
onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)}
onFitToView={fitSharedView}
onZoomToPreset={() => {}}
/>
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
Press <kbd className="rounded bg-[var(--border)] px-1.5 py-0.5">Space</kbd> to toggle
</span>
</div>
<div
ref={sharedContainerRef}
className="relative flex-1 cursor-grab overflow-hidden bg-[#1a1a1a] active:cursor-grabbing"
onClick={() => setToggleShowRight((prev) => !prev)}
onPointerDown={handleContainerPointerDown}
onPointerMove={handleContainerPointerMove}
onPointerUp={handleContainerPointerUp}
onPointerLeave={handleContainerPointerUp}
onWheel={handleContainerWheel}
>
{/* Image A */}
<img
src={leftSrc}
alt="Version A"
draggable={false}
className="pointer-events-none absolute left-0 top-0 origin-top-left select-none transition-opacity duration-200"
style={{
transform: imgTransform,
opacity: toggleShowRight ? 0 : 1,
}}
/>
{/* Image B */}
<img
src={rightSrc}
alt="Version B"
draggable={false}
className="pointer-events-none absolute left-0 top-0 origin-top-left select-none transition-opacity duration-200"
style={{
transform: imgTransform,
opacity: toggleShowRight ? 1 : 0,
}}
/>
{/* Active label */}
<div className="absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded bg-black/60 px-3 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm transition-all">
{toggleShowRight ? "B" : "A"}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className={`relative flex flex-col ${className ?? ""}`}>
{/* Toolbar */}
@ -51,6 +75,9 @@ export function ImageViewer({
)}
</div>
{/* Extra toolbar (annotation tools) */}
{renderToolbar?.(viewerState)}
{/* Pixel info */}
{viewer.pixelInfo && (
<div className="flex items-center gap-2 font-mono text-[10px] text-[var(--muted-foreground)]">
@ -94,6 +121,9 @@ export function ImageViewer({
className="block h-full w-full"
/>
{/* Annotation overlay */}
{renderOverlay?.(viewerState)}
{/* Minimap */}
{src && dims && (
<Minimap

View file

@ -0,0 +1,42 @@
"use client";
import { useCallback } from "react";
interface OverlayControlsProps {
opacity: number;
onOpacityChange: (opacity: number) => void;
}
export function OverlayControls({
opacity,
onOpacityChange,
}: OverlayControlsProps) {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onOpacityChange(Number(e.target.value));
},
[onOpacityChange]
);
return (
<div className="absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 items-center gap-3 rounded-lg border bg-[var(--card)]/90 px-4 py-2 shadow-lg backdrop-blur-sm">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Opacity
</span>
<input
type="range"
min={0}
max={100}
value={opacity}
onChange={handleChange}
className="h-1 w-32 cursor-pointer appearance-none rounded-full bg-[var(--border)] accent-[var(--primary)]
[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-[var(--primary)]"
/>
<span className="min-w-[32px] text-right font-mono text-[10px] text-[var(--muted-foreground)]">
{opacity}%
</span>
</div>
);
}

View file

@ -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 (
<foreignObject
x={x}
y={y}
width={width + borderWidth * 2}
height={height + borderWidth * 2}
style={{ overflow: "visible" }}
>
<div
onMouseDown={handleDragStart}
onClick={(e) => {
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 */}
<img
src={imageUrl}
alt="Screenshot annotation"
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
/>
{/* Resize handle — bottom-right corner */}
{isSelected && (
<div
onMouseDown={handleResizeStart}
style={{
position: "absolute",
right: -4,
bottom: -4,
width: 10,
height: 10,
background: "white",
border: "1px solid rgba(0,0,0,0.3)",
borderRadius: 2,
cursor: "nwse-resize",
}}
/>
)}
</div>
</foreignObject>
);
}

View file

@ -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<HTMLDivElement | null>(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 (
<div
ref={containerRef}
className="relative h-full w-full cursor-grab overflow-hidden bg-[#1a1a1a] active:cursor-grabbing"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
>
{/* Left image (A) — full frame, visible behind the right clipped layer */}
<img
src={leftSrc}
alt="Version A"
draggable={false}
className="pointer-events-none absolute left-0 top-0 origin-top-left select-none"
style={{ transform: imgTransform }}
/>
{/* Right image (B) — clipped to reveal from divider rightward */}
<div
className="absolute inset-0"
style={{
clipPath: `inset(0 0 0 ${dividerPercent}%)`,
}}
>
<img
src={rightSrc}
alt="Version B"
draggable={false}
className="pointer-events-none absolute left-0 top-0 origin-top-left select-none"
style={{ transform: imgTransform }}
/>
</div>
{/* Divider handle */}
<div
className="absolute top-0 z-10 h-full"
style={{ left: `${dividerPercent}%`, transform: "translateX(-50%)" }}
>
{/* Line */}
<div className="h-full w-px bg-white/80 shadow-[0_0_4px_rgba(0,0,0,0.5)]" />
{/* Drag handle */}
<div
className="absolute left-1/2 top-1/2 z-20 flex h-10 w-6 -translate-x-1/2 -translate-y-1/2 cursor-col-resize items-center justify-center rounded-full border border-white/30 bg-[var(--card)]/90 shadow-lg backdrop-blur-sm"
onPointerDown={handleDividerPointerDown}
>
<div className="flex gap-0.5">
<div className="h-4 w-px rounded-full bg-[var(--muted-foreground)]" />
<div className="h-4 w-px rounded-full bg-[var(--muted-foreground)]" />
</div>
</div>
</div>
{/* Labels */}
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
A
</div>
<div className="absolute right-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
B
</div>
</div>
);
}

View file

@ -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<Exclude<AnnotationTool, "select">, 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<AnnotationTool>("select");
const [color, setColor] = useState("#EE5540");
const [visible, setVisible] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drawing, setDrawing] = useState<DrawingState | null>(null);
const [textInput, setTextInput] = useState<{
x: number;
y: number;
imgX: number;
imgY: number;
} | null>(null);
const [textValue, setTextValue] = useState("");
const [undoStack, setUndoStack] = useState<UndoEntry[]>([]);
const [redoStack, setRedoStack] = useState<UndoEntry[]>([]);
const svgRef = useRef<SVGSVGElement>(null);
const textInputRef = useRef<HTMLInputElement>(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<string, any>,
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<string, any> = { 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<string, any> = { 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,
};
}

View file

@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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] });
},
});
}

View file

@ -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 };
}

View file

@ -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<typeof annotationTypeEnum>;
/**
* 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<typeof createAnnotationSchema>;
export const updateAnnotationSchema = z.object({
data: annotationDataSchema.optional(),
imageX: z.number().optional(),
imageY: z.number().optional(),
});
export type UpdateAnnotationInput = z.infer<typeof updateAnnotationSchema>;