eyedropper tweaks
This commit is contained in:
parent
487671c949
commit
36cbd997f7
5 changed files with 304 additions and 42 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue