Refine evidence UX: sidebar capture form, inline edit, click highlight

Significant UX iteration:

Visual palette
- Debug text-layer overlay flips from yellow to light grey so it no
  longer collides with the evidence highlight colour.
- New highlight-styles.css matches the sidebar's #fff8d6/#e0c050
  palette so a passage marked in the document and its sidebar card
  speak the same visual language.
- Active (focused) evidence: same fill, thick #b78b1c outline on both
  the highlight and the sidebar card. Library's red --scrolledTo
  box-shadow is suppressed.

Activation model
- Click an evidence card in the sidebar → activates that item +
  scrolls the viewer to the passage + thickens the borders (existing
  behaviour, now visually clearer).
- Click a highlight in the document → activates the evidence that
  owns that annotation. New `findByAnnotationId()` on EvidenceService
  is the reverse lookup. Wired through a new `onHighlightClicked`
  prop on PdfSpikeViewer + `activeAnnotationId` prop that drives the
  data-ce-active attribute on the highlight wrapper.

Inline edit
- Each evidence card has a ✎ button that flips the card into an
  inline form with the citation (quote) and commentary fields.
- Saving calls a new `AnnotationService.updateQuote()` +
  existing `EvidenceService.updateCommentary()`. The selectors are
  untouched, so the marked passage in the document stays put — the
  inline hint says so explicitly.
- New `AnnotationUpdated` event added to the engine event vocabulary
  (SharedContracts.md §4 updated).

Capture form placement
- The yellow "New annotation" toolbar that lived above the viewer is
  gone. A new InlineCaptureForm component is now slotted into the
  sidebar between the cards that bracket the new selection in
  document flow (sorted by page + y of the first PdfRectSelector).
  If the new selection is before all existing evidence it appears at
  the top; if after all of them, at the bottom.
- The legacy AnnotationToolbar.tsx is removed; the public surface
  re-exports `InlineCaptureForm` instead.

Test updates
- tests/integration/citation-card-export-e2e.dom.test.tsx: switched
  to the seed-session helper (matches the other E2Es) since the
  fixture-button click path is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:57:48 +02:00
parent f0af8887d1
commit 430c0e124c
11 changed files with 640 additions and 247 deletions

View File

@@ -2,39 +2,34 @@
* Debug overlay for PDF text layer alignment.
*
* The text layer is normally invisible (`opacity: 0`) and selectable.
* When `.ce-debug-textlayer` is on a parent, every text span becomes a
* yellow highlight so it's obvious where text is selectable and where it
* When `.ce-debug-textlayer` is on a parent, every text node becomes a
* light grey box so it's obvious where text is selectable and where it
* isn't — useful for diagnosing OCR misalignment, scan-only PDFs, and
* text-layer shift caused by font fallbacks.
*
* Light grey was chosen so the debug overlay does not clash with the
* citation-yellow used for evidence highlights (see highlight-styles.css).
*
* Toggle via the "Debug text layer" entry in SessionMenu.
*/
.ce-debug-textlayer .textLayer {
outline: 2px dashed rgba(255, 0, 0, 0.5);
background: rgba(255, 0, 0, 0.05);
outline: 2px dashed rgba(120, 120, 120, 0.55);
background: rgba(120, 120, 120, 0.06);
}
/* PDF.js 4.x wraps marked content in nested spans/divs — cover every
descendant so the entire selectable area is visible regardless of how
the renderer nested things. */
.ce-debug-textlayer .textLayer * {
background: rgba(255, 220, 0, 0.45) !important;
color: rgba(0, 0, 100, 0.85) !important;
background: rgba(170, 170, 170, 0.4) !important;
color: rgba(40, 40, 40, 0.85) !important;
opacity: 1 !important;
outline: 1px solid rgba(0, 100, 255, 0.3);
outline: 1px solid rgba(100, 100, 100, 0.35);
}
/* Make the canvas-rendered layer dim so the text-layer overlay stands
/* Dim the canvas-rendered layer slightly so the debug overlay stands
out by contrast. */
.ce-debug-textlayer canvas {
opacity: 0.35;
}
/* Make any existing TextHighlight rectangles obvious even in debug
mode (the highlighter's own yellow gets washed out by our debug
yellow). */
.ce-debug-textlayer .TextHighlight__part {
background: rgba(0, 200, 0, 0.45) !important;
outline: 2px solid rgba(0, 120, 0, 0.7) !important;
opacity: 0.4;
}

View File

@@ -0,0 +1,38 @@
/*
* Evidence highlight styling — matches the sidebar's "evidence card"
* palette so the viewer and the sidebar speak the same visual language.
*
* .TextHighlight__part inactive highlight (light yellow fill,
* thin amber border)
* .TextHighlight--active … the currently-focused evidence — same
* fill, thicker border
*
* The "active" class is applied by the spike viewer when the parent
* wrapper is marked with `data-ce-active="true"` so a single
* `activeAnnotationId` prop drives the entire viewer's focus state
* without per-highlight component coupling.
*
* We override the library's red `--scrolledTo` box-shadow so an
* activation doesn't flash a red ring that doesn't match the palette.
*/
.TextHighlight__part {
background: #fff8d6 !important;
outline: 1px solid #e0c050 !important;
outline-offset: 0;
cursor: pointer;
transition: outline 0.15s ease;
}
[data-ce-active="true"] .TextHighlight__part {
outline: 3px solid #b78b1c !important;
background: #fff5b8 !important;
}
/* The library applies `--scrolledTo` after a programmatic scroll. We
override its red box-shadow so the "you just landed on this" cue
sticks with the yellow palette. The thicker border from
`data-ce-active` already conveys focus. */
.TextHighlight--scrolledTo .TextHighlight__part {
box-shadow: none !important;
}

View File

@@ -28,6 +28,7 @@ import {
} from "react-pdf-highlighter-plus";
import "react-pdf-highlighter-plus/style/style.css";
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import "./highlight-styles.css";
import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@shared/selector";
@@ -112,20 +113,34 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
* For the spike, no editing tooling — just visual proof of "did the saved
* coordinates land on the right passage on the right page after reload?"
*/
function SpikeHighlightContainer(): ReactNode {
const { highlight, isScrolledTo } = useHighlightContainerContext();
// Wrap the highlight in a data-tagged container so the visual-guide
// overlay's HighlightRectBridge can locate it via DOM query. The
// wrapper uses display: contents so it doesn't affect layout — the
// bounding rect is gathered from the live TextHighlight children at
// query time.
return (
<div data-highlight-id={highlight.id} style={{ display: "contents" }}>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
interface HighlightContainerProps {
readonly activeAnnotationId: string | null | undefined;
readonly onHighlightClicked: ((annotationId: string) => void) | undefined;
}
function makeSpikeHighlightContainer(props: HighlightContainerProps) {
return function SpikeHighlightContainer(): ReactNode {
const { highlight, isScrolledTo } = useHighlightContainerContext();
const isActive = props.activeAnnotationId === highlight.id;
return (
<div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
// Click-capture so the click registers before the library's
// built-in selection-clearing logic eats it. Stop propagation
// so the highlight click doesn't also start a new selection.
e.stopPropagation();
props.onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
};
}
/**
@@ -168,7 +183,18 @@ export interface PdfSpikeViewerProps {
/** Annotation id to scroll to and highlight on mount, if any. */
readonly scrollToAnnotationId?: string;
/**
* When true, paint the PDF text-layer spans in yellow so it's
* Annotation id currently focused. The matching highlight gets a
* thicker border (see highlight-styles.css). `null`/undefined means
* "no active highlight".
*/
readonly activeAnnotationId?: string | null;
/**
* Called when the user clicks an existing highlight in the page.
* The receiver typically activates the matching evidence item.
*/
onHighlightClicked?(annotationId: string): void;
/**
* When true, paint the PDF text-layer spans in light grey so it's
* obvious which glyphs have a selectable text overlay and which are
* image-only. Also logs every onSelection event to the console.
*/
@@ -188,7 +214,19 @@ export interface StoredAnnotation {
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId, debugTextLayer } = props;
const {
pdfUrl,
storedAnnotations,
onSelectionCaptured,
scrollToAnnotationId,
activeAnnotationId,
onHighlightClicked,
debugTextLayer,
} = props;
const HighlightContainer = useMemo(
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
[activeAnnotationId, onHighlightClicked],
);
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null);
@@ -261,7 +299,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
onSelectionCaptured(capture, selectors);
}}
>
<SpikeHighlightContainer />
<HighlightContainer />
</PdfHighlighter>
)}
</PdfLoader>