diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e3f8de6..222cfb1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -148,7 +148,7 @@ model User { feedbackAssigned FeedbackItem[] @relation("FeedbackAssignee") feedbackResolved FeedbackItem[] @relation("FeedbackResolver") feedbackVerified FeedbackItem[] @relation("FeedbackVerifier") - colorProbes ColorProbe[] + colorProbes ColorProbe[] @relation("ColorProbeCreator") reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator") reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider") @@ -744,27 +744,6 @@ model Annotation { @@map("annotations") } -// ─── Color Probe ──────────────────────────────────────── - -model ColorProbe { - id String @id @default(cuid()) - revisionId String - revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade) - index Int - workingX Float - workingY Float - referenceX Float - referenceY Float - createdById String - createdBy User @relation(fields: [createdById], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([revisionId, index]) - @@index([revisionId]) - @@map("color_probes") -} - // ─── Feedback Item ────────────────────────────────────── model FeedbackItem { @@ -855,3 +834,24 @@ model ReviewSessionItem { @@index([sessionId]) @@map("review_session_items") } + +// ─── Color Probes (CMF Eyedropper) ───────────────────── + +model ColorProbe { + id String @id @default(cuid()) + revisionId String + revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade) + index Int // 1-12, display order + workingX Float // image-space coordinates on working image + workingY Float + referenceX Float // image-space coordinates on reference (defaults to same as working) + referenceY Float + createdById String + createdBy User @relation("ColorProbeCreator", fields: [createdById], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([revisionId, index]) + @@index([revisionId]) + @@map("color_probes") +} diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx index e6eb2fc..2831fdf 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -227,6 +227,18 @@ export default function ReviewPage() { return match?.revisionId ?? null; }, [galleryImages, activeImageUrl]); + // ── Image URLs for CMF probe sampling ────────────────────────────── + const { workingImageUrl, referenceImageUrl } = useMemo(() => { + if (!activeRevisionId) return { workingImageUrl: null, referenceImageUrl: null }; + const rev = revisions.find((r: any) => r.id === activeRevisionId); + if (!rev) return { workingImageUrl: null, referenceImageUrl: null }; + const att = rev.attachments as RevisionAttachments | null; + return { + workingImageUrl: att?.currentImage?.url ?? null, + referenceImageUrl: att?.referenceImage?.url ?? null, + }; + }, [activeRevisionId, revisions]); + // ── Delete annotation (from feedback sidebar) ───────────────────── const deleteAnnotationMutation = useDeleteAnnotation(activeRevisionId); const handleDeleteAnnotation = useCallback( @@ -549,6 +561,8 @@ export default function ReviewPage() { containerHeight={vs.containerHeight} imageDimensions={vs.imageDimensions} hoveredAnnotationId={hoveredAnnotationId} + workingImageUrl={workingImageUrl} + referenceImageUrl={referenceImageUrl} /> )} /> diff --git a/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts b/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts new file mode 100644 index 0000000..b039f84 --- /dev/null +++ b/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { updateColorProbeSchema } from "@/lib/validators/color-probe"; +import { + updateColorProbe, + deleteColorProbe, +} from "@/lib/services/color-probe-service"; + +interface RouteContext { + params: Promise<{ revisionId: string; probeId: string }>; +} + +export async function PATCH(req: Request, ctx: RouteContext) { + try { + const { session, error } = await getAuthSession(); + if (error) return error; + + const { probeId } = await ctx.params; + const body = await req.json(); + const parsed = updateColorProbeSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.message); + } + + const probe = await updateColorProbe(probeId, parsed.data); + return NextResponse.json(probe); + } catch (err) { + return serverError(err); + } +} + +export async function DELETE(_req: Request, ctx: RouteContext) { + try { + const { session, error } = await getAuthSession(); + if (error) return error; + + const { probeId } = await ctx.params; + await deleteColorProbe(probeId); + return NextResponse.json({ ok: true }); + } catch (err) { + return serverError(err); + } +} diff --git a/src/app/api/revisions/[revisionId]/color-probes/route.ts b/src/app/api/revisions/[revisionId]/color-probes/route.ts new file mode 100644 index 0000000..829971f --- /dev/null +++ b/src/app/api/revisions/[revisionId]/color-probes/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils"; +import { prisma } from "@/lib/prisma"; +import { createColorProbeSchema } from "@/lib/validators/color-probe"; +import { + listColorProbes, + createColorProbe, + clearColorProbes, +} from "@/lib/services/color-probe-service"; + +interface RouteContext { + params: Promise<{ revisionId: string }>; +} + +export async function GET(_req: Request, ctx: RouteContext) { + try { + const { session, error } = await getAuthSession(); + if (error) return error; + + const { revisionId } = await ctx.params; + const revision = await prisma.revision.findUnique({ where: { id: revisionId } }); + if (!revision) return notFound("Revision not found"); + + const probes = await listColorProbes(revisionId); + return NextResponse.json(probes); + } catch (err) { + return serverError(err); + } +} + +export async function POST(req: Request, ctx: RouteContext) { + try { + const { session, error } = await getAuthSession(); + if (error) return error; + + const { revisionId } = await ctx.params; + const revision = await prisma.revision.findUnique({ where: { id: revisionId } }); + if (!revision) return notFound("Revision not found"); + + const body = await req.json(); + const parsed = createColorProbeSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.message); + } + + const probe = await createColorProbe(revisionId, session!.user.id, parsed.data); + return NextResponse.json(probe, { status: 201 }); + } catch (err) { + return serverError(err); + } +} + +export async function DELETE(_req: Request, ctx: RouteContext) { + try { + const { session, error } = await getAuthSession(); + if (error) return error; + + const { revisionId } = await ctx.params; + await clearColorProbes(revisionId); + return NextResponse.json({ ok: true }); + } catch (err) { + return serverError(err); + } +} diff --git a/src/app/api/stages/[stageId]/revisions/route.ts b/src/app/api/stages/[stageId]/revisions/route.ts index 7cc57af..d983074 100644 --- a/src/app/api/stages/[stageId]/revisions/route.ts +++ b/src/app/api/stages/[stageId]/revisions/route.ts @@ -37,7 +37,7 @@ export async function POST(request: Request, { params }: Params) { return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); } - const revision = await createRevision(stageId, parsed.data); + const revision = await createRevision(stageId, parsed.data, session!.user.id); return NextResponse.json(revision, { status: 201 }); } catch (e) { return serverError(e); diff --git a/src/components/review/annotation-layer.tsx b/src/components/review/annotation-layer.tsx index cad42d7..51f232d 100644 --- a/src/components/review/annotation-layer.tsx +++ b/src/components/review/annotation-layer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useCallback, useRef } from "react"; +import { useEffect, useMemo, useCallback, useRef, useState } from "react"; import { Send, X } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -9,6 +9,7 @@ import { type AnnotationShape, } from "@/components/review/annotation-renderer"; import { AnnotationTools } from "@/components/review/annotation-tools"; +import { ColorProbeLayer } from "@/components/review/color-probe-layer"; import { useAnnotationState } from "@/hooks/use-annotation-state"; // ── Types ────────────────────────────────────────────── @@ -25,6 +26,10 @@ export interface AnnotationLayerProps { readOnly?: boolean; /** ID of annotation to highlight (when hovering feedback item) */ hoveredAnnotationId?: string | null; + /** Working image URL for CMF probe sampling */ + workingImageUrl?: string | null; + /** Reference image URL for CMF probe sampling */ + referenceImageUrl?: string | null; } // ── Component ────────────────────────────────────────── @@ -40,8 +45,11 @@ export function AnnotationLayer({ imageDimensions, readOnly = false, hoveredAnnotationId, + workingImageUrl, + referenceImageUrl, }: AnnotationLayerProps) { const ann = useAnnotationState(revisionId, stageId); + const [probesVisible, setProbesVisible] = useState(true); // Drag-to-move annotations — works in select mode, dragging any annotation const handleAnnotationDragStart = useCallback( @@ -141,6 +149,8 @@ export function AnnotationLayer({ onToggleVisibility={() => ann.setVisible((v: boolean) => !v)} hasSelection={!!ann.selectedId} onDeleteSelection={ann.handleDeleteSelection} + probesVisible={probesVisible} + onToggleProbes={() => setProbesVisible((v) => !v)} /> @@ -362,6 +372,22 @@ export function AnnotationLayer({ )} + {/* ── CMF Color Probe Layer ────────────────────── */} + {!readOnly && ( + + )} + {/* ── Annotation comment popover ────────────────── */} {!readOnly && ann.pendingAnnotation && (
void; hasSelection: boolean; onDeleteSelection: () => void; + /** CMF eyedropper probe visibility toggle */ + probesVisible?: boolean; + onToggleProbes?: () => void; } const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [ @@ -69,6 +74,7 @@ const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut? { 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" }, + { id: "eyedropper", icon: Pipette, label: "CMF Eyedropper", shortcut: "D" }, ]; export function AnnotationTools({ @@ -84,6 +90,8 @@ export function AnnotationTools({ onToggleVisibility, hasSelection, onDeleteSelection, + probesVisible, + onToggleProbes, }: AnnotationToolsProps) { return (
@@ -217,6 +225,31 @@ export function AnnotationTools({ + {/* CMF probes visibility toggle */} + {onToggleProbes && ( + + + + + + {probesVisible ? "Hide CMF probes" : "Show CMF probes"} + + + )} + {/* Delete selection */} {hasSelection && ( diff --git a/src/components/review/color-probe-layer.tsx b/src/components/review/color-probe-layer.tsx new file mode 100644 index 0000000..fc395bb --- /dev/null +++ b/src/components/review/color-probe-layer.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ColorProbeMarker, ColorProbeGhostMarker } from "@/components/review/color-probe-marker"; +import { ColorProbePanel } from "@/components/review/color-probe-panel"; +import { + useColorProbes, + useCreateColorProbe, + useUpdateColorProbe, + useDeleteColorProbe, + useClearColorProbes, +} from "@/hooks/use-color-probes"; +import { + rgbToHsb, + compareHsb, + sampleFromImage, + type ProbeResult, +} from "@/lib/utils/color"; + +export interface ColorProbeLayerProps { + revisionId: string | null; + /** Whether the eyedropper tool is active */ + active: boolean; + /** Whether probes are visible */ + visible: boolean; + /** Viewport state */ + zoom: number; + panX: number; + panY: number; + containerWidth: number; + containerHeight: number; + /** Working image URL (the current render) */ + workingImageUrl: string | null; + /** Reference image URL */ + referenceImageUrl: string | null; +} + +const MAX_PROBES = 12; + +export function ColorProbeLayer({ + revisionId, + active, + visible, + zoom, + panX, + panY, + containerWidth, + containerHeight, + workingImageUrl, + referenceImageUrl, +}: ColorProbeLayerProps) { + const { data: probes = [] } = useColorProbes(revisionId); + const createProbe = useCreateColorProbe(revisionId); + const updateProbe = useUpdateColorProbe(revisionId); + const deleteProbe = useDeleteColorProbe(revisionId); + const clearProbes = useClearColorProbes(revisionId); + + const [selectedProbeId, setSelectedProbeId] = useState(null); + + // Loaded image refs for sampling + const workingImgRef = useRef(null); + const referenceImgRef = useRef(null); + + // Load working image + useEffect(() => { + if (!workingImageUrl) { + workingImgRef.current = null; + return; + } + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { workingImgRef.current = img; }; + img.src = workingImageUrl; + }, [workingImageUrl]); + + // Load reference image + useEffect(() => { + if (!referenceImageUrl) { + referenceImgRef.current = null; + return; + } + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { referenceImgRef.current = img; }; + img.src = referenceImageUrl; + }, [referenceImageUrl]); + + // Compute probe results by sampling both images + const probeResults = useMemo(() => { + const results = new Map(); + const workingImg = workingImgRef.current; + const refImg = referenceImgRef.current; + + if (!workingImg || !refImg) return results; + + for (const probe of probes) { + const workingSample = sampleFromImage(workingImg, probe.workingX, probe.workingY); + const refSample = sampleFromImage(refImg, probe.referenceX, probe.referenceY); + + if (workingSample && refSample) { + const workingHsb = rgbToHsb(workingSample); + const refHsb = rgbToHsb(refSample); + results.set(probe.index, compareHsb(workingHsb, refHsb)); + } + } + + return results; + // Re-sample whenever probes or images change + }, [probes, workingImageUrl, referenceImageUrl]); // eslint-disable-line react-hooks/exhaustive-deps + + // Handle click to place a new probe + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!active || !revisionId) return; + if (probes.length >= MAX_PROBES) return; + + const svg = e.currentTarget; + const rect = svg.getBoundingClientRect(); + const imgX = (e.clientX - rect.left - panX) / zoom; + const imgY = (e.clientY - rect.top - panY) / zoom; + + // Find next available index + const usedIndices = new Set(probes.map((p) => p.index)); + let nextIndex = 1; + while (usedIndices.has(nextIndex) && nextIndex <= MAX_PROBES) { + nextIndex++; + } + if (nextIndex > MAX_PROBES) return; + + createProbe.mutate({ + index: nextIndex, + workingX: Math.round(imgX), + workingY: Math.round(imgY), + referenceX: Math.round(imgX), + referenceY: Math.round(imgY), + }); + }, + [active, revisionId, probes, panX, panY, zoom, createProbe] + ); + + // Drag handler for working-side probe + const handleProbeDrag = useCallback( + (probeId: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setSelectedProbeId(probeId); + + const probe = probes.find((p) => p.id === probeId); + if (!probe) return; + + let lastX = e.clientX; + let lastY = e.clientY; + + const handleMove = (me: MouseEvent) => { + const dx = (me.clientX - lastX) / zoom; + const dy = (me.clientY - lastY) / zoom; + lastX = me.clientX; + lastY = me.clientY; + + // We update the offset relative to original — accumulate in a ref is better + // but for simplicity, we do a debounced update on mouseup + probe.workingX += dx; + probe.workingY += dy; + // Also move reference if not Alt-dragged + if (!me.altKey) { + probe.referenceX += dx; + probe.referenceY += dy; + } + }; + + const handleUp = () => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + updateProbe.mutate({ + probeId, + data: { + workingX: Math.round(probe.workingX), + workingY: Math.round(probe.workingY), + referenceX: Math.round(probe.referenceX), + referenceY: Math.round(probe.referenceY), + }, + }); + }; + + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [probes, zoom, updateProbe] + ); + + // Drag handler for reference-side ghost marker (Alt+drag to offset) + const handleRefDrag = useCallback( + (probeId: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const probe = probes.find((p) => p.id === probeId); + if (!probe) return; + + let lastX = e.clientX; + let lastY = e.clientY; + + const handleMove = (me: MouseEvent) => { + const dx = (me.clientX - lastX) / zoom; + const dy = (me.clientY - lastY) / zoom; + lastX = me.clientX; + lastY = me.clientY; + probe.referenceX += dx; + probe.referenceY += dy; + }; + + const handleUp = () => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + updateProbe.mutate({ + probeId, + data: { + referenceX: Math.round(probe.referenceX), + referenceY: Math.round(probe.referenceY), + }, + }); + }; + + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [probes, zoom, updateProbe] + ); + + // Delete probe on right-click + const handleContextMenu = useCallback( + (probeId: string, e: React.MouseEvent) => { + e.preventDefault(); + deleteProbe.mutate(probeId); + }, + [deleteProbe] + ); + + // Delete key handler + useEffect(() => { + if (!selectedProbeId) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Delete" || e.key === "Backspace") { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + deleteProbe.mutate(selectedProbeId); + setSelectedProbeId(null); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedProbeId, deleteProbe]); + + if (!visible || !revisionId) return null; + + // Check if any reference point is offset from working point + const hasOffsets = probes.some( + (p) => + Math.abs(p.referenceX - p.workingX) > 1 || + Math.abs(p.referenceY - p.workingY) > 1 + ); + + return ( + <> + {/* SVG overlay for probe markers */} + + + {/* Ghost markers for offset reference points */} + {hasOffsets && + probes.map((probe) => { + const isOffset = + Math.abs(probe.referenceX - probe.workingX) > 1 || + Math.abs(probe.referenceY - probe.workingY) > 1; + if (!isOffset) return null; + return ( + e.preventDefault()}> + {/* Connecting line */} + + handleRefDrag(probe.id, e)} + /> + + ); + })} + + {/* Working-side probe markers */} + {probes.map((probe) => ( + handleContextMenu(probe.id, e)} + > + handleProbeDrag(probe.id, e)} + /> + + ))} + + + + {/* Summary panel — bottom-left */} + {probes.length > 0 && ( +
+ clearProbes.mutate()} + isClearing={clearProbes.isPending} + /> +
+ )} + + ); +} diff --git a/src/components/review/color-probe-marker.tsx b/src/components/review/color-probe-marker.tsx new file mode 100644 index 0000000..170a232 --- /dev/null +++ b/src/components/review/color-probe-marker.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useState } from "react"; +import type { ProbeResult } from "@/lib/utils/color"; + +interface ColorProbeMarkerProps { + index: number; + x: number; + y: number; + result: ProbeResult | null; + isSelected?: boolean; + onDragStart?: (e: React.MouseEvent) => void; +} + +const STATUS_COLORS = { + pass: "#22C55E", + warn: "#F97316", + fail: "#EF4444", + unknown: "#6B7280", +} as const; + +const MARKER_RADIUS = 8; + +export function ColorProbeMarker({ + index, + x, + y, + result, + isSelected, + onDragStart, +}: ColorProbeMarkerProps) { + const [hovered, setHovered] = useState(false); + const status = result?.status ?? "unknown"; + const color = STATUS_COLORS[status]; + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onMouseDown={onDragStart} + > + {/* Outer ring */} + + {/* Background circle */} + + {/* Index number */} + + {index} + + + {/* Status pill — positioned to the right */} + + {/* Compact pill (always visible) */} + + + {/* Expanded detail panel on hover */} + {hovered && result && ( + + {/* Panel background */} + + {/* Header */} + + PROBE {index} + + + + {status.toUpperCase()} + + + {/* H row */} + + {/* S row */} + + {/* B row */} + + + )} + + + ); +} + +function HsbRow({ + label, + working, + reference, + delta, + pass, + y, +}: { + label: string; + working: string; + reference: string; + delta: string; + pass: boolean; + y: number; +}) { + return ( + + + {label}: + + + {working} + + + → + + + {reference} + + + {delta} + + + {pass ? "✓" : "✗"} + + + ); +} + +/** + * Ghost marker shown on the reference image for an offset probe. + */ +export function ColorProbeGhostMarker({ + index, + x, + y, + onDragStart, +}: { + index: number; + x: number; + y: number; + onDragStart?: (e: React.MouseEvent) => void; +}) { + return ( + + + + {index} + + + ); +} diff --git a/src/components/review/color-probe-panel.tsx b/src/components/review/color-probe-panel.tsx new file mode 100644 index 0000000..58c553e --- /dev/null +++ b/src/components/review/color-probe-panel.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { Trash2, Pipette } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { ProbeResult } from "@/lib/utils/color"; + +interface ColorProbePanelProps { + probeResults: Map; + probeCount: number; + onClearAll: () => void; + isClearing?: boolean; +} + +const STATUS_COLORS = { + pass: "#22C55E", + warn: "#F97316", + fail: "#EF4444", +} as const; + +export function ColorProbePanel({ + probeResults, + probeCount, + onClearAll, + isClearing, +}: ColorProbePanelProps) { + if (probeCount === 0) return null; + + const passCount = Array.from(probeResults.values()).filter( + (r) => r.status === "pass" + ).length; + const failCount = Array.from(probeResults.values()).filter( + (r) => r.status === "fail" + ).length; + const warnCount = Array.from(probeResults.values()).filter( + (r) => r.status === "warn" + ).length; + + return ( +
+ {/* Header */} +
+
+ + + CMF Probes + +
+ + + + + + Clear all probes + + +
+ + {/* Summary stats */} +
+ + {probeCount} of 12 + +
+ {passCount > 0 && ( + + {passCount} + + )} + {warnCount > 0 && ( + + {warnCount} + + )} + {failCount > 0 && ( + + {failCount} + + )} +
+
+ + {/* Probe rows */} +
+ {Array.from({ length: probeCount }, (_, i) => i + 1).map((idx) => { + const result = probeResults.get(idx); + if (!result) return null; + const status = result.status; + const color = STATUS_COLORS[status]; + + return ( +
+ {/* Index */} + + {idx} + + + {/* Status dot */} +
+ + {/* Deltas */} +
+ + + +
+
+ ); + })} +
+
+ ); +} + +function DeltaValue({ + label, + value, + unit, + pass, +}: { + label: string; + value: number; + unit: string; + pass: boolean; +}) { + return ( + + {label} + {value.toFixed(1)} + {unit} + + ); +} diff --git a/src/hooks/use-annotation-state.ts b/src/hooks/use-annotation-state.ts index 869d6f4..52f95ee 100644 --- a/src/hooks/use-annotation-state.ts +++ b/src/hooks/use-annotation-state.ts @@ -40,7 +40,7 @@ interface UndoEntry { input?: CreateAnnotationInput; } -const TOOL_TO_TYPE: Record, AnnotationTypeValue> = { +const TOOL_TO_TYPE: Record, AnnotationTypeValue> = { rectangle: "RECTANGLE", ellipse: "ELLIPSE", arrow: "ARROW", @@ -230,7 +230,7 @@ export function useAnnotationState( // Mouse handlers (need zoom/pan passed in) const handleMouseDown = useCallback( (e: React.MouseEvent, panX: number, panY: number, zoom: number) => { - if (activeTool === "select") { + if (activeTool === "select" || activeTool === "eyedropper") { setSelectedId(null); return; } @@ -553,6 +553,7 @@ export function useAnnotationState( case "f": setActiveTool("freehand"); return; case "t": setActiveTool("text"); return; case "p": setActiveTool("pin"); return; + case "d": setActiveTool("eyedropper"); return; case "delete": case "backspace": if (selectedId) { e.preventDefault(); handleDeleteSelection(); } diff --git a/src/hooks/use-color-probes.ts b/src/hooks/use-color-probes.ts new file mode 100644 index 0000000..ab48c4c --- /dev/null +++ b/src/hooks/use-color-probes.ts @@ -0,0 +1,100 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe"; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `Request failed: ${res.status}`); + } + return res.json(); +} + +export interface ColorProbe { + id: string; + revisionId: string; + index: number; + workingX: number; + workingY: number; + referenceX: number; + referenceY: number; + createdById: string; + createdBy: { id: string; name: string | null }; + createdAt: string; + updatedAt: string; +} + +export function useColorProbes(revisionId: string | null) { + return useQuery({ + queryKey: ["color-probes", revisionId], + queryFn: () => fetchJson(`/api/revisions/${revisionId}/color-probes`), + enabled: !!revisionId, + }); +} + +export function useCreateColorProbe(revisionId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateColorProbeInput) => + fetchJson(`/api/revisions/${revisionId}/color-probes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["color-probes", revisionId] }); + }, + }); +} + +export function useUpdateColorProbe(revisionId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + probeId, + data, + }: { + probeId: string; + data: UpdateColorProbeInput; + }) => + fetchJson( + `/api/revisions/${revisionId}/color-probes/${probeId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + } + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["color-probes", revisionId] }); + }, + }); +} + +export function useDeleteColorProbe(revisionId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (probeId: string) => + fetchJson(`/api/revisions/${revisionId}/color-probes/${probeId}`, { + method: "DELETE", + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["color-probes", revisionId] }); + }, + }); +} + +export function useClearColorProbes(revisionId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + fetchJson(`/api/revisions/${revisionId}/color-probes`, { + method: "DELETE", + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["color-probes", revisionId] }); + }, + }); +} diff --git a/src/lib/services/color-probe-service.ts b/src/lib/services/color-probe-service.ts new file mode 100644 index 0000000..317efdc --- /dev/null +++ b/src/lib/services/color-probe-service.ts @@ -0,0 +1,105 @@ +import { prisma } from "@/lib/prisma"; +import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe"; + +/** + * List all color probes for a revision, ordered by index. + */ +export async function listColorProbes(revisionId: string) { + return prisma.colorProbe.findMany({ + where: { revisionId }, + orderBy: { index: "asc" }, + include: { createdBy: { select: { id: true, name: true } } }, + }); +} + +/** + * Create a new color probe on a revision. + */ +export async function createColorProbe( + revisionId: string, + userId: string, + input: CreateColorProbeInput +) { + return prisma.colorProbe.create({ + data: { + revisionId, + createdById: userId, + index: input.index, + workingX: input.workingX, + workingY: input.workingY, + referenceX: input.referenceX, + referenceY: input.referenceY, + }, + include: { createdBy: { select: { id: true, name: true } } }, + }); +} + +/** + * Update a color probe's position. + */ +export async function updateColorProbe( + probeId: string, + input: UpdateColorProbeInput +) { + return prisma.colorProbe.update({ + where: { id: probeId }, + data: { + ...(input.workingX !== undefined && { workingX: input.workingX }), + ...(input.workingY !== undefined && { workingY: input.workingY }), + ...(input.referenceX !== undefined && { referenceX: input.referenceX }), + ...(input.referenceY !== undefined && { referenceY: input.referenceY }), + }, + include: { createdBy: { select: { id: true, name: true } } }, + }); +} + +/** + * Delete a single color probe. + */ +export async function deleteColorProbe(probeId: string) { + await prisma.colorProbe.delete({ where: { id: probeId } }); + return { ok: true }; +} + +/** + * Delete all color probes for a revision (wipe). + */ +export async function clearColorProbes(revisionId: string) { + await prisma.colorProbe.deleteMany({ where: { revisionId } }); + return { ok: true }; +} + +/** + * Copy probes from one revision to another (for R1 → R2 carry-over). + * Keeps the same coordinates and indices. + */ +export async function copyProbes( + fromRevisionId: string, + toRevisionId: string, + userId: string +) { + const existing = await prisma.colorProbe.findMany({ + where: { revisionId: fromRevisionId }, + orderBy: { index: "asc" }, + }); + + if (existing.length === 0) return []; + + const created = await prisma.$transaction( + existing.map((probe) => + prisma.colorProbe.create({ + data: { + revisionId: toRevisionId, + createdById: userId, + index: probe.index, + workingX: probe.workingX, + workingY: probe.workingY, + referenceX: probe.referenceX, + referenceY: probe.referenceY, + }, + }) + ) + ); + + return created; +} diff --git a/src/lib/services/revision-service.ts b/src/lib/services/revision-service.ts index 3b0303b..9d42d80 100644 --- a/src/lib/services/revision-service.ts +++ b/src/lib/services/revision-service.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import type { CreateRevisionInput, UpdateRevisionInput } from "@/lib/validators/revision"; import type { RevisionStatus } from "@/generated/prisma/client"; +import { copyProbes } from "@/lib/services/color-probe-service"; /** * Create a new revision round for a stage. @@ -8,7 +9,8 @@ import type { RevisionStatus } from "@/generated/prisma/client"; */ export async function createRevision( stageId: string, - data: CreateRevisionInput + data: CreateRevisionInput, + userId?: string ) { const lastRevision = await prisma.revision.findFirst({ where: { deliverableStageId: stageId }, @@ -17,7 +19,7 @@ export async function createRevision( const roundNumber = (lastRevision?.roundNumber ?? 0) + 1; - return prisma.revision.create({ + const revision = await prisma.revision.create({ data: { deliverableStageId: stageId, roundNumber, @@ -27,6 +29,17 @@ export async function createRevision( attachments: data.attachments ?? undefined, }, }); + + // Copy color probes from previous revision (non-blocking) + if (lastRevision && userId) { + try { + await copyProbes(lastRevision.id, revision.id, userId); + } catch { + // Non-critical: probes are a convenience, not a requirement + } + } + + return revision; } /** diff --git a/src/lib/utils/color.ts b/src/lib/utils/color.ts new file mode 100644 index 0000000..bf27fa3 --- /dev/null +++ b/src/lib/utils/color.ts @@ -0,0 +1,209 @@ +/** + * Color utilities for CMF eyedropper probes. + * RGB ↔ HSB conversion, 5×5 pixel sampling, and delta calculation. + */ + +export interface HSB { + h: number; // 0-360 degrees + s: number; // 0-100 percent + b: number; // 0-100 percent +} + +export interface RGB { + r: number; // 0-255 + g: number; // 0-255 + b: number; // 0-255 +} + +export interface ProbeResult { + working: HSB; + reference: HSB; + delta: { h: number; s: number; b: number }; + status: "pass" | "warn" | "fail"; +} + +// CMF tolerances +const TOLERANCE = { h: 4, s: 3, b: 3 }; +// Warn when within 1.5× tolerance +const WARN_MULTIPLIER = 1.5; + +/** + * Convert RGB to HSB (same as Photoshop's HSB). + */ +export function rgbToHsb(rgb: RGB): HSB { + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + + // Brightness + const brightness = max * 100; + + // Saturation + const saturation = max === 0 ? 0 : (delta / max) * 100; + + // Hue + let hue = 0; + if (delta !== 0) { + if (max === r) { + hue = ((g - b) / delta) % 6; + } else if (max === g) { + hue = (b - r) / delta + 2; + } else { + hue = (r - g) / delta + 4; + } + hue *= 60; + if (hue < 0) hue += 360; + } + + return { + h: Math.round(hue * 10) / 10, + s: Math.round(saturation * 10) / 10, + b: Math.round(brightness * 10) / 10, + }; +} + +/** + * Calculate hue delta accounting for circular wraparound. + * e.g., 358° vs 2° = Δ4°, not Δ356°. + */ +export function hueDelta(h1: number, h2: number): number { + const diff = Math.abs(h1 - h2); + return Math.min(diff, 360 - diff); +} + +/** + * Compare two HSB values and return status. + */ +export function compareHsb(working: HSB, reference: HSB): ProbeResult { + const delta = { + h: hueDelta(working.h, reference.h), + s: Math.abs(working.s - reference.s), + b: Math.abs(working.b - reference.b), + }; + + const hFail = delta.h > TOLERANCE.h; + const sFail = delta.s > TOLERANCE.s; + const bFail = delta.b > TOLERANCE.b; + + const hWarn = delta.h > TOLERANCE.h / WARN_MULTIPLIER; + const sWarn = delta.s > TOLERANCE.s / WARN_MULTIPLIER; + const bWarn = delta.b > TOLERANCE.b / WARN_MULTIPLIER; + + let status: ProbeResult["status"] = "pass"; + if (hFail || sFail || bFail) { + status = "fail"; + } else if (hWarn || sWarn || bWarn) { + status = "warn"; + } + + return { working, reference, delta, status }; +} + +/** + * Sample a 5×5 pixel area from a canvas, averaging RGB values. + * Coordinates are in image space. + */ +export function samplePixels( + canvas: HTMLCanvasElement, + imageX: number, + imageY: number, + zoom: number, + panX: number, + panY: number +): RGB | null { + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const dpr = window.devicePixelRatio || 1; + + // Sample 5×5 grid centered on the point + let totalR = 0; + let totalG = 0; + let totalB = 0; + let count = 0; + + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const imgPx = imageX + dx; + const imgPy = imageY + dy; + + // Convert image coords to canvas pixel coords + const screenX = (panX + imgPx * zoom) * dpr; + const screenY = (panY + imgPy * zoom) * dpr; + + // Bounds check + if (screenX < 0 || screenY < 0 || screenX >= canvas.width || screenY >= canvas.height) { + continue; + } + + const pixel = ctx.getImageData(Math.round(screenX), Math.round(screenY), 1, 1).data; + totalR += pixel[0]; + totalG += pixel[1]; + totalB += pixel[2]; + count++; + } + } + + if (count === 0) return null; + + return { + r: Math.round(totalR / count), + g: Math.round(totalG / count), + b: Math.round(totalB / count), + }; +} + +/** + * Sample pixels from an image URL by drawing to an offscreen canvas. + * Used when we don't have a visible canvas (e.g., reference image in comparison modes). + */ +export function sampleFromImage( + image: HTMLImageElement, + imageX: number, + imageY: number +): RGB | null { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + // Draw white background then image (matches main viewer behavior) + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0); + + let totalR = 0; + let totalG = 0; + let totalB = 0; + let count = 0; + + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const px = Math.round(imageX + dx); + const py = Math.round(imageY + dy); + + if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) { + continue; + } + + const pixel = ctx.getImageData(px, py, 1, 1).data; + totalR += pixel[0]; + totalG += pixel[1]; + totalB += pixel[2]; + count++; + } + } + + if (count === 0) return null; + + return { + r: Math.round(totalR / count), + g: Math.round(totalG / count), + b: Math.round(totalB / count), + }; +} diff --git a/src/lib/validators/color-probe.ts b/src/lib/validators/color-probe.ts new file mode 100644 index 0000000..a17be76 --- /dev/null +++ b/src/lib/validators/color-probe.ts @@ -0,0 +1,20 @@ +import { z } from "zod/v4"; + +export const createColorProbeSchema = z.object({ + index: z.number().int().min(1).max(12), + workingX: z.number(), + workingY: z.number(), + referenceX: z.number(), + referenceY: z.number(), +}); + +export type CreateColorProbeInput = z.infer; + +export const updateColorProbeSchema = z.object({ + workingX: z.number().optional(), + workingY: z.number().optional(), + referenceX: z.number().optional(), + referenceY: z.number().optional(), +}); + +export type UpdateColorProbeInput = z.infer;