From 36cbd997f7adab1fa7cb8909a349a462340fde91 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Tue, 17 Mar 2026 20:39:07 -0500 Subject: [PATCH] eyedropper tweaks --- .../[deliverableId]/review/page.tsx | 6 +- src/components/review/annotation-layer.tsx | 79 +++++++-- src/components/review/annotation-tools.tsx | 51 ++++++ src/components/review/color-probe-layer.tsx | 150 ++++++++++++++---- src/components/review/color-probe-marker.tsx | 60 +++++++ 5 files changed, 304 insertions(+), 42 deletions(-) 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 f0c787e..f82a2e3 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -90,6 +90,9 @@ export default function ReviewPage() { // ── Annotation hover highlight (from feedback sidebar) ────────────── const [hoveredAnnotationId, setHoveredAnnotationId] = useState(null); + // ── Eyedropper image override (swaps canvas between working/reference) ── + const [imageOverride, setImageOverride] = useState(null); + // ── Comparison mode state ──────────────────────────────────────────── const [comparisonActive, setComparisonActive] = useState(false); const [comparisonMode, setComparisonMode] = @@ -566,7 +569,7 @@ export default function ReviewPage() { /> ) : ( ( )} /> diff --git a/src/components/review/annotation-layer.tsx b/src/components/review/annotation-layer.tsx index 866d9d5..a5441c7 100644 --- a/src/components/review/annotation-layer.tsx +++ b/src/components/review/annotation-layer.tsx @@ -9,7 +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 { ColorProbeLayer, type EyedropperViewingImage } from "@/components/review/color-probe-layer"; import { useAnnotationState } from "@/hooks/use-annotation-state"; // ── Types ────────────────────────────────────────────── @@ -30,6 +30,8 @@ export interface AnnotationLayerProps { workingImageUrl?: string | null; /** Reference image URL for CMF probe sampling */ referenceImageUrl?: string | null; + /** Callback to override the displayed image (pass URL or null to reset) */ + onImageOverride?: (url: string | null) => void; } // ── Component ────────────────────────────────────────── @@ -47,21 +49,73 @@ export function AnnotationLayer({ hoveredAnnotationId, workingImageUrl, referenceImageUrl, + onImageOverride, }: AnnotationLayerProps) { const ann = useAnnotationState(revisionId, stageId); const [probesVisible, setProbesVisible] = useState(true); + const [eyedropperViewingImage, setEyedropperViewingImage] = + useState("working"); - // Drag-to-move annotations — works in select mode, dragging any annotation + // When eyedropper viewing image changes, notify parent to swap the canvas image + const handleViewingImageChange = useCallback( + (image: EyedropperViewingImage) => { + setEyedropperViewingImage(image); + if (onImageOverride) { + if (image === "reference" && referenceImageUrl) { + onImageOverride(referenceImageUrl); + } else { + onImageOverride(null); + } + } + }, + [onImageOverride, referenceImageUrl] + ); + + // Reset viewing image when tool changes away from eyedropper + useEffect(() => { + if (ann.activeTool !== "eyedropper") { + if (eyedropperViewingImage !== "working") { + setEyedropperViewingImage("working"); + onImageOverride?.(null); + } + } + }, [ann.activeTool]); // eslint-disable-line react-hooks/exhaustive-deps + + // Tab key to toggle W/R when eyedropper active + useEffect(() => { + if (ann.activeTool !== "eyedropper") return; + if (!referenceImageUrl) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + if (e.key === "Tab") { + e.preventDefault(); + setEyedropperViewingImage((prev) => { + const next = prev === "working" ? "reference" : "working"; + if (onImageOverride) { + onImageOverride(next === "reference" ? referenceImageUrl : null); + } + return next; + }); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ann.activeTool, referenceImageUrl, onImageOverride]); + + // Drag-to-move annotations — works in move mode const handleAnnotationDragStart = useCallback( (annotationId: string, e: React.MouseEvent) => { if (readOnly) return; - // Only allow moving in select mode if (ann.activeTool !== "move") return; e.preventDefault(); e.stopPropagation(); - // Select the annotation ann.setSelectedId(annotationId); let lastX = e.clientX; @@ -88,7 +142,7 @@ export function AnnotationLayer({ [readOnly, ann.activeTool, ann.setSelectedId, ann.handleAnnotationMove, zoom] ); - // Clipboard paste handler for screenshots (disabled in readOnly mode) + // Clipboard paste handler for screenshots useEffect(() => { const handlePaste = async (e: ClipboardEvent) => { if (readOnly) return; @@ -130,6 +184,9 @@ export function AnnotationLayer({ // Don't render overlay when there's no revision if (!revisionId || !imageDimensions) return null; + const isEyedropperActive = ann.activeTool === "eyedropper"; + const hasReferenceImage = !!referenceImageUrl; + return ( <> {/* ── Annotation toolbar (floating inside viewport, top-center) ── */} @@ -151,6 +208,10 @@ export function AnnotationLayer({ onDeleteSelection={ann.handleDeleteSelection} probesVisible={probesVisible} onToggleProbes={() => setProbesVisible((v) => !v)} + eyedropperActive={isEyedropperActive} + hasReferenceImage={hasReferenceImage} + viewingImage={eyedropperViewingImage} + onViewingImageChange={handleViewingImageChange} /> @@ -164,8 +225,6 @@ export function AnnotationLayer({ height={containerHeight} style={{ cursor: readOnly ? "default" : cursorStyle, - // In select mode: SVG background is transparent to clicks, but painted shapes capture events. - // In drawing modes: full SVG captures all events for drawing. pointerEvents: readOnly ? "none" : ann.activeTool === "move" ? "none" : "auto", @@ -233,7 +292,6 @@ export function AnnotationLayer({ const imgY = d.y ?? 0; const w = d.width ?? 200; const h = d.height ?? 150; - // Transform image coords to screen coords const screenLeft = panX + imgX * zoom; const screenTop = panY + imgY * zoom; const screenW = w * zoom; @@ -363,7 +421,6 @@ export function AnnotationLayer({ } }} onBlur={() => { - // Delay to avoid premature commit when focus shifts during render setTimeout(() => ann.commitTextAnnotation(), 150); }} className="h-7 min-w-[180px] border-[var(--primary)] bg-[var(--card)] text-sm" @@ -377,7 +434,7 @@ export function AnnotationLayer({ {!readOnly && ( )} diff --git a/src/components/review/annotation-tools.tsx b/src/components/review/annotation-tools.tsx index 70691b2..1657160 100644 --- a/src/components/review/annotation-tools.tsx +++ b/src/components/review/annotation-tools.tsx @@ -28,6 +28,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import type { EyedropperViewingImage } from "@/components/review/color-probe-layer"; export type AnnotationTool = | "move" @@ -66,6 +67,11 @@ interface AnnotationToolsProps { /** CMF eyedropper probe visibility toggle */ probesVisible?: boolean; onToggleProbes?: () => void; + /** Eyedropper image toggle props */ + eyedropperActive?: boolean; + hasReferenceImage?: boolean; + viewingImage?: EyedropperViewingImage; + onViewingImageChange?: (image: EyedropperViewingImage) => void; } const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [ @@ -94,6 +100,10 @@ export function AnnotationTools({ onDeleteSelection, probesVisible, onToggleProbes, + eyedropperActive, + hasReferenceImage, + viewingImage = "working", + onViewingImageChange, }: AnnotationToolsProps) { return (
@@ -128,6 +138,47 @@ export function AnnotationTools({ ); })} + {/* ── Working / Reference toggle (when eyedropper active) ── */} + {eyedropperActive && hasReferenceImage && onViewingImageChange && ( + <> + + + +
+ + +
+
+ + Toggle Working / Reference + + Tab + + +
+ + )} + {/* Color picker */} diff --git a/src/components/review/color-probe-layer.tsx b/src/components/review/color-probe-layer.tsx index b3780c5..2bf38eb 100644 --- a/src/components/review/color-probe-layer.tsx +++ b/src/components/review/color-probe-layer.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ColorProbeMarker, ColorProbeGhostMarker } from "@/components/review/color-probe-marker"; +import { ColorProbeMarker, ColorProbeGhostMarker, PendingProbeMarker } from "@/components/review/color-probe-marker"; import { ColorProbePanel } from "@/components/review/color-probe-panel"; import { useColorProbes, @@ -17,6 +17,8 @@ import { type ProbeResult, } from "@/lib/utils/color"; +export type EyedropperViewingImage = "working" | "reference"; + export interface ColorProbeLayerProps { revisionId: string | null; /** Whether the eyedropper tool is active (allows placing new probes) */ @@ -35,6 +37,16 @@ export interface ColorProbeLayerProps { workingImageUrl: string | null; /** Reference image URL */ referenceImageUrl: string | null; + /** Which image is currently displayed on the canvas */ + viewingImage: EyedropperViewingImage; + /** Request to change which image the canvas shows */ + onViewingImageChange: (image: EyedropperViewingImage) => void; +} + +interface PendingProbe { + workingX: number; + workingY: number; + index: number; } const MAX_PROBES = 12; @@ -51,6 +63,8 @@ export function ColorProbeLayer({ containerHeight, workingImageUrl, referenceImageUrl, + viewingImage, + onViewingImageChange, }: ColorProbeLayerProps) { const { data: probes = [] } = useColorProbes(revisionId); const createProbe = useCreateColorProbe(revisionId); @@ -59,6 +73,7 @@ export function ColorProbeLayer({ const clearProbes = useClearColorProbes(revisionId); const [selectedProbeId, setSelectedProbeId] = useState(null); + const [pendingProbe, setPendingProbe] = useState(null); // Loaded image refs for sampling const workingImgRef = useRef(null); @@ -88,6 +103,14 @@ export function ColorProbeLayer({ img.src = referenceImageUrl; }, [referenceImageUrl]); + // Cancel pending probe when tool deactivates + useEffect(() => { + if (!active && pendingProbe) { + setPendingProbe(null); + onViewingImageChange("working"); + } + }, [active]); // eslint-disable-line react-hooks/exhaustive-deps + // Compute probe results by sampling both images const probeResults = useMemo(() => { const results = new Map(); @@ -111,36 +134,70 @@ export function ColorProbeLayer({ // Re-sample whenever probes or images change }, [probes, workingImageUrl, referenceImageUrl]); // eslint-disable-line react-hooks/exhaustive-deps - // Handle click to place a new probe + // Handle click — two-step probe creation 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; + if (!pendingProbe) { + // ── Step 1: Place working point ── + if (probes.length >= MAX_PROBES) return; - createProbe.mutate({ - index: nextIndex, - workingX: Math.round(imgX), - workingY: Math.round(imgY), - referenceX: Math.round(imgX), - referenceY: Math.round(imgY), - }); + // 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; + + setPendingProbe({ + workingX: Math.round(imgX), + workingY: Math.round(imgY), + index: nextIndex, + }); + + // Auto-swap to reference image if available + if (referenceImageUrl) { + onViewingImageChange("reference"); + } + } else { + // ── Step 2: Place reference point ── + createProbe.mutate({ + index: pendingProbe.index, + workingX: pendingProbe.workingX, + workingY: pendingProbe.workingY, + referenceX: Math.round(imgX), + referenceY: Math.round(imgY), + }); + + setPendingProbe(null); + onViewingImageChange("working"); + } }, - [active, revisionId, probes, panX, panY, zoom, createProbe] + [active, revisionId, probes, panX, panY, zoom, pendingProbe, referenceImageUrl, createProbe, onViewingImageChange] ); + // Cancel pending probe on Escape + useEffect(() => { + if (!pendingProbe) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setPendingProbe(null); + onViewingImageChange("working"); + } + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [pendingProbe, onViewingImageChange]); + // Drag handler for working-side probe const handleProbeDrag = useCallback( (probeId: string, e: React.MouseEvent) => { @@ -160,11 +217,8 @@ export function ColorProbeLayer({ 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; @@ -191,7 +245,7 @@ export function ColorProbeLayer({ [probes, zoom, updateProbe] ); - // Drag handler for reference-side ghost marker (Alt+drag to offset) + // Drag handler for reference-side ghost marker const handleRefDrag = useCallback( (probeId: string, e: React.MouseEvent) => { e.preventDefault(); @@ -268,8 +322,7 @@ export function ColorProbeLayer({ return ( <> - {/* SVG overlay for probe markers */} - {/* Clickable overlay for placing new probes — only in eyedropper mode */} + {/* Clickable overlay for placing probes — only in eyedropper mode */} {active && ( )} - {/* Probe markers SVG — markers are always rendered but only interactive - in eyedropper/move modes. The SVG root is pointer-events:none; - each marker group sets pointer-events="auto" via SVG attribute, - which lets them receive events independently. */} + {/* Probe markers SVG */} - {/* Render markers in SCREEN space (zoom-independent size) */} - {/* Ghost markers + connecting lines for offset reference points */} {hasOffsets && probes.map((probe) => { @@ -299,7 +347,6 @@ export function ColorProbeLayer({ Math.abs(probe.referenceX - probe.workingX) > 1 || Math.abs(probe.referenceY - probe.workingY) > 1; if (!isOffset) return null; - // Convert image coords to screen coords const wsx = panX + probe.workingX * zoom; const wsy = panY + probe.workingY * zoom; const rsx = panX + probe.referenceX * zoom; @@ -348,8 +395,49 @@ export function ColorProbeLayer({ ); })} + + {/* Pending probe marker (shown as ghost on reference image) */} + {pendingProbe && ( + + )} + {/* Step indicator when pending probe exists */} + {active && pendingProbe && ( +
+
+

+ Click the reference image to set comparison point +

+

+ Press Esc to cancel +

+
+
+ )} + + {/* Image label badge */} + {active && ( +
+
+ {viewingImage === "working" ? "Working" : "Reference"} +
+
+ )} + {/* Summary panel — bottom-left */} {probes.length > 0 && (
diff --git a/src/components/review/color-probe-marker.tsx b/src/components/review/color-probe-marker.tsx index 7149d81..003863d 100644 --- a/src/components/review/color-probe-marker.tsx +++ b/src/components/review/color-probe-marker.tsx @@ -202,6 +202,66 @@ function HsbRow({ ); } +/** + * Pending probe marker — pulsing dashed circle shown while waiting + * for the user to place the reference point. + */ +export function PendingProbeMarker({ + index, + x, + y, +}: { + index: number; + x: number; + y: number; +}) { + return ( + + {/* Pulsing outer ring */} + + + + + {/* Dashed circle */} + + {/* Index number */} + + {index} + + + ); +} + /** * Ghost marker shown on the reference image for an offset probe. */