generated from coulomb/repo-seed
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:
@@ -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;
|
||||
}
|
||||
|
||||
38
src/anchor/highlight-styles.css
Normal file
38
src/anchor/highlight-styles.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user