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:
parent
1fa8803bfc
commit
eba5e30c98
18 changed files with 2725 additions and 12 deletions
21
ROADMAP.md
21
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -697,7 +697,7 @@ model SearchLog {
|
|||
@@map("search_logs")
|
||||
}
|
||||
|
||||
// ─── Enums (from feature branch) ────────────────────────
|
||||
// ─── Annotations (Visual Review) ────────────────────────
|
||||
|
||||
enum AnnotationType {
|
||||
RECTANGLE
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
58
src/app/api/revisions/[revisionId]/annotations/route.ts
Normal file
58
src/app/api/revisions/[revisionId]/annotations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
191
src/components/review/annotation-layer.tsx
Normal file
191
src/components/review/annotation-layer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
280
src/components/review/annotation-renderer.tsx
Normal file
280
src/components/review/annotation-renderer.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
241
src/components/review/annotation-tools.tsx
Normal file
241
src/components/review/annotation-tools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/review/comparison-toolbar.tsx
Normal file
160
src/components/review/comparison-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
403
src/components/review/comparison-viewer.tsx
Normal file
403
src/components/review/comparison-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
42
src/components/review/overlay-controls.tsx
Normal file
42
src/components/review/overlay-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/review/screenshot-callout.tsx
Normal file
160
src/components/review/screenshot-callout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/components/review/wipe-divider.tsx
Normal file
167
src/components/review/wipe-divider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
512
src/hooks/use-annotation-state.ts
Normal file
512
src/hooks/use-annotation-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
73
src/hooks/use-annotations.ts
Normal file
73
src/hooks/use-annotations.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
115
src/lib/services/annotation-service.ts
Normal file
115
src/lib/services/annotation-service.ts
Normal 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 };
|
||||
}
|
||||
50
src/lib/validators/annotation.ts
Normal file
50
src/lib/validators/annotation.ts
Normal 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>;
|
||||
Loading…
Add table
Reference in a new issue