Skip to content

Commit 985a849

Browse files
strukalexclaude
authored andcommitted
hitl: inline canvas-overlay editor, confidence-tier colors, smarter auto-zoom
- AnnotationCanvas: add `activeBoxId` + `renderActiveBoxOverlay` render-prop so callers can anchor an HTML widget under the active bounding box; expose `getFitScale` / `getCanvasSize` for zoom-to-fit calculations; gate `BoundingBoxLayer` behind a `hideBoxes` prop; move stage deselect from `mouseDown` to `onClick` so drag-pan no longer clears the active field. The `screenRect` passed to overlays now also includes `height` for height-aware widgets. - CanvasFieldOverlay (new): inline edit widget anchored beneath the active bounding box. Single-line `TextInput` (or `Checkbox` for selection-mark fields) exactly matching the bounding-box width. Font-size auto-scales so the rendered text fills the input width (text natural width measured via canvas `measureText`). Confidence- tier border. Mouse-hover and F2-keyboard fade together for peek-behind. Field key + confidence percentage label sits below the input, fading in lockstep with the editor. - useFieldFocus: per-field smart zoom — target a 50 px on-screen box height as a floor, cap at a configurable fraction of canvas width, and relax that cap up to an absolute ceiling when the inline overlay's font would otherwise be too small. Smooth pan-to via Konva tween (existing behaviour preserved). - ReviewWorkspacePage: wires the canvas overlay; adds an F2 shortcut (`alwaysActive: true`) for keyboard peek; adds an eye-icon toggle in the canvas corner that suppresses bounding boxes entirely while keeping the active-field overlay; switches sidebar field-row borders to confidence-tier colors (replacing field-key-based colors); and teaches `navigateToField` to detect when the cursor is inside the canvas overlay so Tab navigation keeps focus on the overlay instead of bouncing to the matching sidebar input. - ConfidenceIndicator: exports `getConfidenceBorderColor` (CSS variable, for borders) and `getConfidenceCanvasColor` (hex, for Konva strokes) so confidence semantics propagate to borders, document-view bounding-box strokes, and inline overlay borders consistently. - SnippetView: borders driven by confidence tier instead of field-key color (matching the document view); crop padding bumped so each snippet shows more surrounding document context; label/confidence stacked vertically on the left under the field name. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent cd87c3c commit 985a849

6 files changed

Lines changed: 674 additions & 64 deletions

File tree

apps/frontend/src/features/annotation/core/canvas/AnnotationCanvas.tsx

Lines changed: 141 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Konva from "konva";
22
import { KonvaEventObject } from "konva/lib/Node";
33
import {
44
forwardRef,
5+
ReactNode,
56
useCallback,
67
useEffect,
78
useImperativeHandle,
@@ -19,6 +20,14 @@ import { useCanvasSelection } from "./hooks/useCanvasSelection";
1920

2021
export interface AnnotationCanvasHandle {
2122
panTo: (centerX: number, centerY: number, targetZoom: number) => void;
23+
/**
24+
* Returns the auto-fit base scale (effectiveScale = fitScale × userZoom).
25+
* Callers that need to compute a userZoom from a target effective scale
26+
* (e.g. zoom-to-fit-text logic) need this to invert the relationship.
27+
*/
28+
getFitScale: () => number;
29+
/** Returns the canvas container's pixel size — useful for fit/cap calcs. */
30+
getCanvasSize: () => { width: number; height: number };
2231
}
2332

2433
interface AnnotationCanvasProps {
@@ -41,6 +50,33 @@ interface AnnotationCanvasProps {
4150
verticalAlign?: "top" | "center";
4251
/** Fraction of container used when fitting (1 = edge-to-edge). Default 0.95. */
4352
fitPadding?: number;
53+
/**
54+
* Identifies the currently-active box, controlled by the parent. Used to
55+
* position the optional overlay (renderActiveBoxOverlay) right below
56+
* the corresponding bounding box.
57+
*/
58+
activeBoxId?: string | null;
59+
/**
60+
* Render-prop for an HTML overlay anchored beneath the active box. The
61+
* `screenRect` is in container-relative pixels and already incorporates
62+
* the Stage's pan and zoom. Use it for inline edit widgets so the
63+
* reviewer can see input + bounding box at the same time.
64+
*/
65+
renderActiveBoxOverlay?: (params: {
66+
boxId: string;
67+
screenRect: {
68+
left: number;
69+
top: number;
70+
width: number;
71+
height: number;
72+
};
73+
}) => ReactNode;
74+
/**
75+
* When true, the BoundingBoxLayer (boxes, labels) is suppressed. The
76+
* active-box overlay (renderActiveBoxOverlay) still renders so callers
77+
* can keep inline editing while hiding canvas chrome.
78+
*/
79+
hideBoxes?: boolean;
4480
}
4581

4682
export const AnnotationCanvas = forwardRef<
@@ -60,6 +96,9 @@ export const AnnotationCanvas = forwardRef<
6096
rotation = 0,
6197
verticalAlign = "center",
6298
fitPadding = 0.95,
99+
activeBoxId,
100+
renderActiveBoxOverlay,
101+
hideBoxes,
63102
},
64103
ref,
65104
) => {
@@ -171,6 +210,8 @@ export const AnnotationCanvas = forwardRef<
171210
}).play();
172211
}
173212
},
213+
getFitScale: () => fitScale,
214+
getCanvasSize: () => ({ width, height }),
174215
}),
175216
[fitScale, width, height, clampPan, setPan],
176217
);
@@ -213,11 +254,12 @@ export const AnnotationCanvas = forwardRef<
213254
const pos = stage.getPointerPosition();
214255
if (!pos) return;
215256

216-
// Click on empty canvas area deselects
257+
// We intentionally do NOT deselect on mouseDown — that runs the
258+
// deselect before the user has a chance to drag-pan, clearing
259+
// their selection mid-drag. Deselection lives on the Stage's click
260+
// handler (handleStageClick), which Konva only fires when the user
261+
// pressed and released without dragging.
217262
if (e.target === e.target.getStage()) {
218-
selectBox(null);
219-
onBoxSelect?.(null);
220-
221263
// If we're in DRAW_BOX mode, start drawing
222264
if (activeTool === CanvasTool.DRAW_BOX) {
223265
const relativePos = {
@@ -271,13 +313,62 @@ export const AnnotationCanvas = forwardRef<
271313
[],
272314
);
273315

316+
/**
317+
* Deselect on click outside any box. Konva's Stage onClick fires only
318+
* on a real click (mousedown + mouseup without enough movement to be
319+
* classified as a drag), so a drag-pan keeps the current selection.
320+
*/
321+
const handleStageClick = (e: KonvaEventObject<MouseEvent>) => {
322+
if (e.target === e.target.getStage()) {
323+
selectBox(null);
324+
onBoxSelect?.(null);
325+
}
326+
};
327+
328+
// Compute container-relative screen rect for the active box so the
329+
// overlay (if any) can be positioned directly beneath it.
330+
const activeOverlayPlacement = useMemo(() => {
331+
if (!activeBoxId || !renderActiveBoxOverlay) return null;
332+
const target = boxes.find((b) => b.id === activeBoxId);
333+
if (!target) return null;
334+
const points = target.box.polygon;
335+
if (!points || points.length === 0) return null;
336+
let minX = Infinity;
337+
let maxX = -Infinity;
338+
let minY = Infinity;
339+
let maxY = -Infinity;
340+
for (const p of points) {
341+
if (p.x < minX) minX = p.x;
342+
if (p.x > maxX) maxX = p.x;
343+
if (p.y < minY) minY = p.y;
344+
if (p.y > maxY) maxY = p.y;
345+
}
346+
if (
347+
!Number.isFinite(minX) ||
348+
!Number.isFinite(maxX) ||
349+
!Number.isFinite(minY) ||
350+
!Number.isFinite(maxY)
351+
)
352+
return null;
353+
return {
354+
boxId: target.id,
355+
screenRect: {
356+
left: minX * effectiveScale + pan.x,
357+
top: maxY * effectiveScale + pan.y,
358+
width: (maxX - minX) * effectiveScale,
359+
height: (maxY - minY) * effectiveScale,
360+
},
361+
};
362+
}, [activeBoxId, renderActiveBoxOverlay, boxes, effectiveScale, pan]);
363+
274364
return (
275365
<Box
276366
style={{
277367
width: "100%",
278368
height: "100%",
279369
overflow: "hidden",
280370
cursor: "default",
371+
position: "relative",
281372
}}
282373
>
283374
<Stage
@@ -292,6 +383,7 @@ export const AnnotationCanvas = forwardRef<
292383
onMouseDown={handleMouseDown}
293384
onMouseMove={handleMouseMove}
294385
onMouseUp={handleMouseUp}
386+
onClick={handleStageClick}
295387
onMouseEnter={forceDefaultCursor}
296388
onMouseLeave={forceDefaultCursor}
297389
draggable={isPanEnabled}
@@ -319,30 +411,32 @@ export const AnnotationCanvas = forwardRef<
319411
</Layer>
320412
)}
321413

322-
<BoundingBoxLayer
323-
boxes={boxes}
324-
selectedBoxId={selectedBoxId}
325-
hoveredBoxId={hoveredBoxId}
326-
onBoxClick={handleBoxClick}
327-
onBoxMouseEnter={(id) => {
328-
hoverBox(id);
329-
if (onBoxHover && stageRef.current) {
330-
const pointer = stageRef.current.getPointerPosition();
331-
if (pointer) {
332-
onBoxHover({ boxId: id, x: pointer.x, y: pointer.y });
414+
{!hideBoxes && (
415+
<BoundingBoxLayer
416+
boxes={boxes}
417+
selectedBoxId={selectedBoxId}
418+
hoveredBoxId={hoveredBoxId}
419+
onBoxClick={handleBoxClick}
420+
onBoxMouseEnter={(id) => {
421+
hoverBox(id);
422+
if (onBoxHover && stageRef.current) {
423+
const pointer = stageRef.current.getPointerPosition();
424+
if (pointer) {
425+
onBoxHover({ boxId: id, x: pointer.x, y: pointer.y });
426+
}
333427
}
334-
}
335-
}}
336-
onBoxMouseLeave={() => {
337-
hoverBox(null);
338-
onBoxHover?.(null);
339-
}}
340-
rotation={rotation}
341-
offsetX={imageSize ? imageSize.width / 2 : 0}
342-
offsetY={imageSize ? imageSize.height / 2 : 0}
343-
x={imageSize ? imageSize.width / 2 : 0}
344-
y={imageSize ? imageSize.height / 2 : 0}
345-
/>
428+
}}
429+
onBoxMouseLeave={() => {
430+
hoverBox(null);
431+
onBoxHover?.(null);
432+
}}
433+
rotation={rotation}
434+
offsetX={imageSize ? imageSize.width / 2 : 0}
435+
offsetY={imageSize ? imageSize.height / 2 : 0}
436+
x={imageSize ? imageSize.width / 2 : 0}
437+
y={imageSize ? imageSize.height / 2 : 0}
438+
/>
439+
)}
346440

347441
<DrawingLayer
348442
drawingBox={drawingBox}
@@ -353,6 +447,26 @@ export const AnnotationCanvas = forwardRef<
353447
y={imageSize ? imageSize.height / 2 : 0}
354448
/>
355449
</Stage>
450+
{activeOverlayPlacement && renderActiveBoxOverlay && (
451+
<div
452+
style={{
453+
position: "absolute",
454+
left: activeOverlayPlacement.screenRect.left,
455+
top: activeOverlayPlacement.screenRect.top,
456+
minWidth: activeOverlayPlacement.screenRect.width,
457+
pointerEvents: "auto",
458+
zIndex: 10,
459+
}}
460+
// Prevent canvas pan/drag handlers (which sit on the Stage) from
461+
// hijacking text-input mouse events inside the overlay.
462+
onMouseDown={(e) => e.stopPropagation()}
463+
onMouseMove={(e) => e.stopPropagation()}
464+
onMouseUp={(e) => e.stopPropagation()}
465+
onWheel={(e) => e.stopPropagation()}
466+
>
467+
{renderActiveBoxOverlay(activeOverlayPlacement)}
468+
</div>
469+
)}
356470
</Box>
357471
);
358472
},

0 commit comments

Comments
 (0)