eyedropper tweaks

This commit is contained in:
Leivur Djurhuus 2026-03-17 20:39:07 -05:00
parent 487671c949
commit 36cbd997f7
5 changed files with 304 additions and 42 deletions

View file

@ -90,6 +90,9 @@ export default function ReviewPage() {
// ── Annotation hover highlight (from feedback sidebar) ──────────────
const [hoveredAnnotationId, setHoveredAnnotationId] = useState<string | null>(null);
// ── Eyedropper image override (swaps canvas between working/reference) ──
const [imageOverride, setImageOverride] = useState<string | null>(null);
// ── Comparison mode state ────────────────────────────────────────────
const [comparisonActive, setComparisonActive] = useState(false);
const [comparisonMode, setComparisonMode] =
@ -566,7 +569,7 @@ export default function ReviewPage() {
/>
) : (
<ImageViewer
src={activeImageUrl}
src={imageOverride ?? activeImageUrl}
className="min-h-0 flex-1"
renderOverlay={(vs: ImageViewerState) => (
<AnnotationLayer
@ -581,6 +584,7 @@ export default function ReviewPage() {
hoveredAnnotationId={hoveredAnnotationId}
workingImageUrl={workingImageUrl}
referenceImageUrl={referenceImageUrl}
onImageOverride={setImageOverride}
/>
)}
/>

View file

@ -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<EyedropperViewingImage>("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}
/>
</div>
</div>
@ -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 && (
<ColorProbeLayer
revisionId={revisionId}
active={ann.activeTool === "eyedropper"}
active={isEyedropperActive}
movable={ann.activeTool === "move"}
visible={probesVisible}
zoom={zoom}
@ -387,6 +444,8 @@ export function AnnotationLayer({
containerHeight={containerHeight}
workingImageUrl={workingImageUrl ?? null}
referenceImageUrl={referenceImageUrl ?? null}
viewingImage={eyedropperViewingImage}
onViewingImageChange={handleViewingImageChange}
/>
)}

View file

@ -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 (
<div className="flex items-center gap-1">
@ -128,6 +138,47 @@ export function AnnotationTools({
);
})}
{/* ── Working / Reference toggle (when eyedropper active) ── */}
{eyedropperActive && hasReferenceImage && onViewingImageChange && (
<>
<Separator orientation="vertical" className="mx-1 h-5" />
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center rounded-md border bg-[var(--background)] p-0.5">
<button
className={cn(
"rounded-sm px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
viewingImage === "working"
? "bg-emerald-500/20 text-emerald-400"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
onClick={() => onViewingImageChange("working")}
>
W
</button>
<button
className={cn(
"rounded-sm px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
viewingImage === "reference"
? "bg-blue-500/20 text-blue-400"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
onClick={() => onViewingImageChange("reference")}
>
R
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Toggle Working / Reference
<kbd className="ml-1.5 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">
Tab
</kbd>
</TooltipContent>
</Tooltip>
</>
)}
<Separator orientation="vertical" className="mx-1 h-5" />
{/* Color picker */}

View file

@ -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<string | null>(null);
const [pendingProbe, setPendingProbe] = useState<PendingProbe | null>(null);
// Loaded image refs for sampling
const workingImgRef = useRef<HTMLImageElement | null>(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<number, ProbeResult>();
@ -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<SVGSVGElement>) => {
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 && (
<svg
className="absolute inset-0 z-22"
@ -280,18 +333,13 @@ export function ColorProbeLayer({
/>
)}
{/* 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 */}
<svg
className="absolute inset-0 z-23"
width={containerWidth}
height={containerHeight}
pointerEvents="none"
>
{/* 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({
</g>
);
})}
{/* Pending probe marker (shown as ghost on reference image) */}
{pendingProbe && (
<PendingProbeMarker
index={pendingProbe.index}
x={panX + pendingProbe.workingX * zoom}
y={panY + pendingProbe.workingY * zoom}
/>
)}
</svg>
{/* Step indicator when pending probe exists */}
{active && pendingProbe && (
<div className="absolute left-1/2 bottom-14 z-30 -translate-x-1/2">
<div className="rounded-md border bg-[var(--card)]/95 px-3 py-1.5 shadow-lg backdrop-blur-sm">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Click the reference image to set comparison point
</p>
<p className="mt-0.5 text-[9px] text-[var(--muted-foreground)]/60">
Press Esc to cancel
</p>
</div>
</div>
)}
{/* Image label badge */}
{active && (
<div className="absolute right-3 bottom-3 z-30">
<div
className="rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm transition-colors duration-200"
style={{
backgroundColor: viewingImage === "working"
? "rgba(34, 197, 94, 0.2)"
: "rgba(59, 130, 246, 0.2)",
color: viewingImage === "working" ? "#22C55E" : "#3B82F6",
border: `1px solid ${viewingImage === "working" ? "rgba(34,197,94,0.3)" : "rgba(59,130,246,0.3)"}`,
}}
>
{viewingImage === "working" ? "Working" : "Reference"}
</div>
</div>
)}
{/* Summary panel — bottom-left */}
{probes.length > 0 && (
<div className="absolute bottom-3 left-3 z-30">

View file

@ -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 (
<g transform={`translate(${x}, ${y})`} pointerEvents="none">
{/* Pulsing outer ring */}
<circle
r={MARKER_RADIUS + 6}
fill="none"
stroke="rgba(59,130,246,0.5)"
strokeWidth={2}
strokeDasharray="4 3"
>
<animate
attributeName="r"
values={`${MARKER_RADIUS + 4};${MARKER_RADIUS + 8};${MARKER_RADIUS + 4}`}
dur="1.5s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.6;0.2;0.6"
dur="1.5s"
repeatCount="indefinite"
/>
</circle>
{/* Dashed circle */}
<circle
r={MARKER_RADIUS}
fill="rgba(59,130,246,0.15)"
stroke="#3B82F6"
strokeWidth={2}
strokeDasharray="5 3"
/>
{/* Index number */}
<text
textAnchor="middle"
dominantBaseline="central"
fontSize="13"
fontWeight="700"
fontFamily="monospace"
fill="#3B82F6"
style={{ userSelect: "none" }}
>
{index}
</text>
</g>
);
}
/**
* Ghost marker shown on the reference image for an offset probe.
*/