generated from coulomb/repo-seed
Unify capture/edit form, thicker active document border, layer-hide toggles
Three UX iterations rolled into one:
1. Unified evidence form
- New EvidenceFormBody is the single source for "citation +
commentary" editing. Both InlineCaptureForm (creating fresh
evidence from a selection) and the EvidenceCard edit mode render
this body with their own save/cancel labels + badge/helper text.
- The capture form now exposes the citation as an editable
textarea — pre-filled with the selection text — so the user can
refine a partial capture before saving without re-selecting.
- Old testid prefixes are unchanged for the inline-capture flow
(`inline-capture-quote/commentary/save/cancel`); edit-mode
testids are now `evidence-edit-<id>-{quote,commentary,save,cancel}`.
2. Active document card
- The blue background alone was the only "this is open" cue. Added
a 3px #0050b3 border (matching the evidence-card thick-border
pattern, but in the documents-are-blue palette) plus a
`data-active` attribute.
3. PDF layer-hide diagnostics
- New debug flags `hideCanvas`, `hideTextLayer`, `hideAnnotationLayer`,
`hideXfaLayer` — applied as `.ce-hide-<layer>` classes on the viewer
wrapper, each `display: none`-ing the matching PDF.js layer.
- SessionMenu groups the toggles under a "PDF diagnostics" header
with a new shared DebugCheckbox helper. The existing "Debug text
layer" highlight toggle now lives in the same group.
- Lets the user isolate stacking issues by elimination — e.g.
"hide text layer, can I now see the canvas content underneath?".
Tests
- citation-card-export-e2e + session-export-reimport switched from
placeholder/role-name lookups to the inline-capture testids so
they survive form-copy changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -33,3 +33,27 @@
|
|||||||
.ce-debug-textlayer canvas {
|
.ce-debug-textlayer canvas {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Layer-visibility toggles. Each `.ce-hide-<layer>` class is applied
|
||||||
|
* to the same viewer-wrapper element so a single parent can hide any
|
||||||
|
* combination of layers. Useful for diagnosing layer stacking issues
|
||||||
|
* (e.g. "is the textLayer covering the canvas?") by elimination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ce-hide-canvas canvas {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-hide-text-layer .textLayer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-hide-annotation-layer .annotationLayer,
|
||||||
|
.ce-hide-annotation-layer .annotationEditorLayer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-hide-xfa-layer .xfaLayer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,6 +199,15 @@ export interface PdfSpikeViewerProps {
|
|||||||
* image-only. Also logs every onSelection event to the console.
|
* image-only. Also logs every onSelection event to the console.
|
||||||
*/
|
*/
|
||||||
readonly debugTextLayer?: boolean;
|
readonly debugTextLayer?: boolean;
|
||||||
|
/**
|
||||||
|
* Hide specific PDF.js layers so you can see what sits underneath.
|
||||||
|
* Helps diagnose layer-stacking issues (e.g. "is the text layer
|
||||||
|
* covering the canvas content?").
|
||||||
|
*/
|
||||||
|
readonly hideCanvas?: boolean;
|
||||||
|
readonly hideTextLayer?: boolean;
|
||||||
|
readonly hideAnnotationLayer?: boolean;
|
||||||
|
readonly hideXfaLayer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredAnnotation {
|
export interface StoredAnnotation {
|
||||||
@@ -222,11 +231,24 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
|||||||
activeAnnotationId,
|
activeAnnotationId,
|
||||||
onHighlightClicked,
|
onHighlightClicked,
|
||||||
debugTextLayer,
|
debugTextLayer,
|
||||||
|
hideCanvas,
|
||||||
|
hideTextLayer,
|
||||||
|
hideAnnotationLayer,
|
||||||
|
hideXfaLayer,
|
||||||
} = props;
|
} = props;
|
||||||
const HighlightContainer = useMemo(
|
const HighlightContainer = useMemo(
|
||||||
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
|
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
|
||||||
[activeAnnotationId, onHighlightClicked],
|
[activeAnnotationId, onHighlightClicked],
|
||||||
);
|
);
|
||||||
|
const wrapperClasses = [
|
||||||
|
debugTextLayer ? "ce-debug-textlayer" : null,
|
||||||
|
hideCanvas ? "ce-hide-canvas" : null,
|
||||||
|
hideTextLayer ? "ce-hide-text-layer" : null,
|
||||||
|
hideAnnotationLayer ? "ce-hide-annotation-layer" : null,
|
||||||
|
hideXfaLayer ? "ce-hide-xfa-layer" : null,
|
||||||
|
]
|
||||||
|
.filter((c): c is string => c !== null)
|
||||||
|
.join(" ");
|
||||||
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
||||||
const [didScroll, setDidScroll] = useState<string | null>(null);
|
const [didScroll, setDidScroll] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -273,7 +295,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={debugTextLayer ? "ce-debug-textlayer" : undefined}
|
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
>
|
>
|
||||||
<PdfLoader document={pdfUrl}>
|
<PdfLoader document={pdfUrl}>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
const tick = useSessionListTick();
|
const tick = useSessionListTick();
|
||||||
const active = useActiveSession();
|
const active = useActiveSession();
|
||||||
const [debugTextLayer, setDebugTextLayer] = useDebugFlag("textLayer");
|
const [debugTextLayer, setDebugTextLayer] = useDebugFlag("textLayer");
|
||||||
|
const [hideCanvas, setHideCanvas] = useDebugFlag("hideCanvas");
|
||||||
|
const [hideTextLayer, setHideTextLayer] = useDebugFlag("hideTextLayer");
|
||||||
|
const [hideAnnotationLayer, setHideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
|
||||||
|
const [hideXfaLayer, setHideXfaLayer] = useDebugFlag("hideXfaLayer");
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
@@ -407,25 +411,52 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<hr style={dividerStyle} />
|
<hr style={dividerStyle} />
|
||||||
<label
|
<div
|
||||||
data-testid="session-menu-debug-textlayer"
|
|
||||||
style={{
|
style={{
|
||||||
...menuItemStyle,
|
padding: "4px 8px",
|
||||||
display: "flex",
|
color: "#666",
|
||||||
alignItems: "center",
|
fontSize: 11,
|
||||||
gap: 6,
|
textTransform: "uppercase",
|
||||||
cursor: "pointer",
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
title="Paint the PDF text-layer spans in yellow so you can see what's selectable. Also logs every selection event to the browser console."
|
|
||||||
>
|
>
|
||||||
<input
|
PDF diagnostics
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={debugTextLayer}
|
<DebugCheckbox
|
||||||
onChange={(e) => setDebugTextLayer(e.target.checked)}
|
label="Highlight text layer"
|
||||||
style={{ margin: 0 }}
|
testid="session-menu-debug-textlayer"
|
||||||
/>
|
title="Paint the PDF text-layer spans in light grey so you can see what's selectable. Logs every selection event to the browser console."
|
||||||
Debug text layer
|
checked={debugTextLayer}
|
||||||
</label>
|
onChange={setDebugTextLayer}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide canvas layer"
|
||||||
|
testid="session-menu-hide-canvas"
|
||||||
|
title="Hide the rendered glyphs so only the text/annotation overlay layers remain. Use to see if the textLayer covers regions where the canvas has no content."
|
||||||
|
checked={hideCanvas}
|
||||||
|
onChange={setHideCanvas}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide text layer"
|
||||||
|
testid="session-menu-hide-textlayer"
|
||||||
|
title="Hide the invisible selection text layer entirely. Use to see if it's covering the canvas content underneath."
|
||||||
|
checked={hideTextLayer}
|
||||||
|
onChange={setHideTextLayer}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide annotation layer"
|
||||||
|
testid="session-menu-hide-annotation"
|
||||||
|
title="Hide PDF annotations (stamps, form widgets, links). Use to see if a stamp is obscuring content or capturing your clicks."
|
||||||
|
checked={hideAnnotationLayer}
|
||||||
|
onChange={setHideAnnotationLayer}
|
||||||
|
/>
|
||||||
|
<DebugCheckbox
|
||||||
|
label="Hide XFA layer"
|
||||||
|
testid="session-menu-hide-xfa"
|
||||||
|
title="Hide the XFA form layer (rare; only present on Adobe XFA forms)."
|
||||||
|
checked={hideXfaLayer}
|
||||||
|
onChange={setHideXfaLayer}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -459,6 +490,38 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DebugCheckboxProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly testid: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly checked: boolean;
|
||||||
|
onChange(next: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DebugCheckbox(p: DebugCheckboxProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-testid={p.testid}
|
||||||
|
title={p.title}
|
||||||
|
style={{
|
||||||
|
...menuItemStyle,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.checked}
|
||||||
|
onChange={(e) => p.onChange(e.target.checked)}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
{p.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const menuItemStyle: CSSProperties = {
|
const menuItemStyle: CSSProperties = {
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -106,13 +106,14 @@ export function CollectionList({ upload, title }: CollectionListProps) {
|
|||||||
<li key={doc.id} style={{ marginBottom: 6 }}>
|
<li key={doc.id} style={{ marginBottom: 6 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid #ccc",
|
border: isActive ? "3px solid #0050b3" : "1px solid #ccc",
|
||||||
background: isActive ? "#e8f0ff" : "white",
|
background: isActive ? "#e8f0ff" : "white",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
data-testid={`collection-item-${doc.id}`}
|
data-testid={`collection-item-${doc.id}`}
|
||||||
|
data-active={isActive ? "true" : "false"}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setId(doc.id)}
|
onClick={() => setId(doc.id)}
|
||||||
|
|||||||
104
src/work/EvidenceFormBody.tsx
Normal file
104
src/work/EvidenceFormBody.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* EvidenceFormBody — the shared "citation + commentary" editor.
|
||||||
|
*
|
||||||
|
* One form drives both:
|
||||||
|
* - the *capture* flow (creating evidence from a fresh selection
|
||||||
|
* inside `InlineCaptureForm`), and
|
||||||
|
* - the *edit* flow (modifying an existing evidence card inside
|
||||||
|
* `EvidenceSidebar`).
|
||||||
|
*
|
||||||
|
* Keeping a single component means changes to the field layout, hint
|
||||||
|
* placement, or save-button copy land in one file and apply to both
|
||||||
|
* surfaces. Callers control the labels and any badge/helper text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface EvidenceFormBodyProps {
|
||||||
|
readonly quote: string;
|
||||||
|
readonly commentary: string;
|
||||||
|
onChangeQuote(next: string): void;
|
||||||
|
onChangeCommentary(next: string): void;
|
||||||
|
onSave(): void;
|
||||||
|
onCancel(): void;
|
||||||
|
/** Save-button label, defaults to "Save". */
|
||||||
|
readonly saveLabel?: string;
|
||||||
|
/** Cancel-button label, defaults to "Cancel". */
|
||||||
|
readonly cancelLabel?: string;
|
||||||
|
/** Short caption rendered at the top of the form, e.g. a selector
|
||||||
|
* count badge or "Editing". */
|
||||||
|
readonly badge?: ReactNode;
|
||||||
|
/** Inline note shown under the buttons (e.g. "won't move the
|
||||||
|
* marked passage" when editing an existing item). */
|
||||||
|
readonly helper?: ReactNode;
|
||||||
|
/** data-testid prefix for the input + button hooks. */
|
||||||
|
readonly testidPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvidenceFormBody(p: EvidenceFormBodyProps) {
|
||||||
|
const saveLabel = p.saveLabel ?? "Save";
|
||||||
|
const cancelLabel = p.cancelLabel ?? "Cancel";
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8, fontSize: 12 }}>
|
||||||
|
{p.badge && (
|
||||||
|
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
|
||||||
|
)}
|
||||||
|
<label style={labelStyle}>Citation text</label>
|
||||||
|
<textarea
|
||||||
|
value={p.quote}
|
||||||
|
onChange={(e) => p.onChangeQuote(e.target.value)}
|
||||||
|
data-testid={`${p.testidPrefix}-quote`}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
...textareaStyle,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label style={labelStyle}>Commentary</label>
|
||||||
|
<textarea
|
||||||
|
value={p.commentary}
|
||||||
|
onChange={(e) => p.onChangeCommentary(e.target.value)}
|
||||||
|
data-testid={`${p.testidPrefix}-commentary`}
|
||||||
|
rows={2}
|
||||||
|
placeholder="(optional)"
|
||||||
|
style={textareaStyle}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={p.onSave}
|
||||||
|
data-testid={`${p.testidPrefix}-save`}
|
||||||
|
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={p.onCancel}
|
||||||
|
data-testid={`${p.testidPrefix}-cancel`}
|
||||||
|
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
{p.helper && (
|
||||||
|
<span style={{ fontSize: 11, color: "#888" }}>{p.helper}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const textareaStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
type ExportResult,
|
type ExportResult,
|
||||||
} from "./useExportEvidence";
|
} from "./useExportEvidence";
|
||||||
|
import { EvidenceFormBody } from "./EvidenceFormBody";
|
||||||
import { InlineCaptureForm } from "./InlineCaptureForm";
|
import { InlineCaptureForm } from "./InlineCaptureForm";
|
||||||
|
|
||||||
const TOAST_TIMEOUT_MS = 2000;
|
const TOAST_TIMEOUT_MS = 2000;
|
||||||
@@ -375,79 +376,18 @@ function EvidenceCard(p: EvidenceCardProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.isEditing ? (
|
{p.isEditing ? (
|
||||||
<div style={{ padding: 8, fontSize: 12 }}>
|
<EvidenceFormBody
|
||||||
<label
|
quote={p.editQuote}
|
||||||
style={{
|
commentary={p.editCommentary}
|
||||||
display: "block",
|
onChangeQuote={p.onChangeQuote}
|
||||||
color: "#666",
|
onChangeCommentary={p.onChangeCommentary}
|
||||||
fontSize: 11,
|
onSave={p.onSaveEdit}
|
||||||
marginBottom: 2,
|
onCancel={p.onCancelEdit}
|
||||||
}}
|
saveLabel="Save"
|
||||||
>
|
cancelLabel="Cancel"
|
||||||
Citation text
|
helper="The marked passage in the document stays the same."
|
||||||
</label>
|
testidPrefix={`evidence-edit-${p.item.id}`}
|
||||||
<textarea
|
/>
|
||||||
value={p.editQuote}
|
|
||||||
onChange={(e) => p.onChangeQuote(e.target.value)}
|
|
||||||
data-testid={`evidence-edit-quote-${p.item.id}`}
|
|
||||||
rows={3}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
fontSize: 12,
|
|
||||||
padding: 4,
|
|
||||||
marginBottom: 6,
|
|
||||||
fontStyle: "italic",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
color: "#666",
|
|
||||||
fontSize: 11,
|
|
||||||
marginBottom: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Commentary
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={p.editCommentary}
|
|
||||||
onChange={(e) => p.onChangeCommentary(e.target.value)}
|
|
||||||
data-testid={`evidence-edit-commentary-${p.item.id}`}
|
|
||||||
rows={2}
|
|
||||||
placeholder="(empty)"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
fontSize: 12,
|
|
||||||
padding: 4,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={p.onSaveEdit}
|
|
||||||
data-testid={`evidence-edit-save-${p.item.id}`}
|
|
||||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={p.onCancelEdit}
|
|
||||||
data-testid={`evidence-edit-cancel-${p.item.id}`}
|
|
||||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
style={{ fontSize: 11, color: "#888", alignSelf: "center" }}
|
|
||||||
>
|
|
||||||
The marked passage in the document stays the same.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* `InlineCaptureForm` — the "I just selected text, let me save it as
|
* `InlineCaptureForm` — the "I just selected text, let me save it as
|
||||||
* evidence" form. Renders only when a pendingSelection is set; the
|
* evidence" form. Renders only when a `pendingSelection` is set;
|
||||||
* EvidenceSidebar slots it into the right position in document order
|
* `EvidenceSidebar` slots it into the right position in document
|
||||||
* so the form appears between the cards that bracket the new
|
* order so the new capture appears between the cards that bracket
|
||||||
* selection.
|
* it.
|
||||||
*
|
*
|
||||||
* Behaviour matches the pre-iteration AnnotationToolbar (which used to
|
* Uses the shared `EvidenceFormBody` so the field layout matches the
|
||||||
* live above the viewer):
|
* edit form on an existing card. The user can refine the
|
||||||
|
* auto-captured citation text before saving (handy when the
|
||||||
|
* underlying text layer captured fragments — they can paste in the
|
||||||
|
* correct quote without re-selecting).
|
||||||
*
|
*
|
||||||
|
* Save pipeline:
|
||||||
* 1. `createSelectors(capture, representation)` — anchor builds the
|
* 1. `createSelectors(capture, representation)` — anchor builds the
|
||||||
* maximal selector set against the active representation.
|
* maximal selector set against the active representation.
|
||||||
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
|
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
|
||||||
@@ -25,15 +29,18 @@ import {
|
|||||||
useEngine,
|
useEngine,
|
||||||
usePendingSelection,
|
usePendingSelection,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
|
import { EvidenceFormBody } from "./EvidenceFormBody";
|
||||||
|
|
||||||
export function InlineCaptureForm() {
|
export function InlineCaptureForm() {
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
const { document, representation } = useActiveDocument();
|
const { document, representation } = useActiveDocument();
|
||||||
const { pending, set } = usePendingSelection();
|
const { pending, set } = usePendingSelection();
|
||||||
|
const [quote, setQuote] = useState("");
|
||||||
const [commentary, setCommentary] = useState("");
|
const [commentary, setCommentary] = useState("");
|
||||||
|
|
||||||
// Reset the commentary box whenever a fresh selection arrives.
|
// Re-seed the form whenever a fresh selection arrives.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setQuote(pending?.capture.text ?? "");
|
||||||
setCommentary("");
|
setCommentary("");
|
||||||
}, [pending]);
|
}, [pending]);
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ export function InlineCaptureForm() {
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
representationId: representation.id,
|
representationId: representation.id,
|
||||||
selectors,
|
selectors,
|
||||||
quote: pending.capture.text,
|
quote: quote.trim().length > 0 ? quote : pending.capture.text,
|
||||||
});
|
});
|
||||||
engine.evidence.create({
|
engine.evidence.create({
|
||||||
annotationIds: [annotation.id],
|
annotationIds: [annotation.id],
|
||||||
@@ -56,8 +63,7 @@ export function InlineCaptureForm() {
|
|||||||
|
|
||||||
const handleDiscard = () => set(null);
|
const handleDiscard = () => set(null);
|
||||||
|
|
||||||
const quote = pending.capture.text;
|
const selectorCount = pending.selectors.length;
|
||||||
const shortQuote = quote.length > 200 ? `${quote.slice(0, 200)}…` : quote;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -65,49 +71,28 @@ export function InlineCaptureForm() {
|
|||||||
style={{
|
style={{
|
||||||
border: "1px dashed #b78b1c",
|
border: "1px dashed #b78b1c",
|
||||||
background: "#fff8d6",
|
background: "#fff8d6",
|
||||||
padding: 8,
|
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
fontSize: 12,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>
|
<EvidenceFormBody
|
||||||
New annotation ({pending.selectors.length} selector
|
quote={quote}
|
||||||
{pending.selectors.length === 1 ? "" : "s"})
|
commentary={commentary}
|
||||||
</div>
|
onChangeQuote={setQuote}
|
||||||
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
|
onChangeCommentary={setCommentary}
|
||||||
“{shortQuote}”
|
onSave={handleSave}
|
||||||
</div>
|
onCancel={handleDiscard}
|
||||||
<textarea
|
saveLabel="Save evidence"
|
||||||
value={commentary}
|
cancelLabel="Discard"
|
||||||
onChange={(e) => setCommentary(e.target.value)}
|
badge={
|
||||||
placeholder="Add a one-line comment (optional)…"
|
<>
|
||||||
rows={2}
|
New evidence (
|
||||||
style={{
|
{selectorCount} selector{selectorCount === 1 ? "" : "s"}
|
||||||
width: "100%",
|
) — refine the citation if needed
|
||||||
boxSizing: "border-box",
|
</>
|
||||||
fontSize: 12,
|
}
|
||||||
padding: 4,
|
testidPrefix="inline-capture"
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
data-testid="inline-capture-save"
|
|
||||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
|
||||||
>
|
|
||||||
Save evidence
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDiscard}
|
|
||||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
|
||||||
>
|
|
||||||
Discard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export function ViewerShell() {
|
|||||||
const { set: setPending } = usePendingSelection();
|
const { set: setPending } = usePendingSelection();
|
||||||
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
|
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
|
||||||
const [debugTextLayer] = useDebugFlag("textLayer");
|
const [debugTextLayer] = useDebugFlag("textLayer");
|
||||||
|
const [hideCanvas] = useDebugFlag("hideCanvas");
|
||||||
|
const [hideTextLayer] = useDebugFlag("hideTextLayer");
|
||||||
|
const [hideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
|
||||||
|
const [hideXfaLayer] = useDebugFlag("hideXfaLayer");
|
||||||
const activeEvidenceId = useLastActivatedEvidence();
|
const activeEvidenceId = useLastActivatedEvidence();
|
||||||
|
|
||||||
// The viewer needs to re-fetch its highlight list whenever annotations
|
// The viewer needs to re-fetch its highlight list whenever annotations
|
||||||
@@ -108,16 +112,29 @@ export function ViewerShell() {
|
|||||||
>
|
>
|
||||||
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||||
<PdfSpikeViewer
|
<PdfSpikeViewer
|
||||||
// Re-key on scrollVersion so clicking the same item twice still
|
// Re-key on scrollVersion + debug flags so toggling any of
|
||||||
// triggers the viewer's mount-time scroll effect. Also re-key on
|
// them remounts the viewer (the CSS classes are on a parent,
|
||||||
// debugTextLayer so toggling it remounts the viewer.
|
// but a re-render is the simplest way to make sure the layers
|
||||||
key={`${document.id}#${scrollVersion}#${debugTextLayer ? "d" : "n"}`}
|
// get re-laid-out).
|
||||||
|
key={[
|
||||||
|
document.id,
|
||||||
|
scrollVersion,
|
||||||
|
debugTextLayer ? "d" : "n",
|
||||||
|
hideCanvas ? "hc" : "",
|
||||||
|
hideTextLayer ? "ht" : "",
|
||||||
|
hideAnnotationLayer ? "ha" : "",
|
||||||
|
hideXfaLayer ? "hx" : "",
|
||||||
|
].join("#")}
|
||||||
pdfUrl={fileUrl}
|
pdfUrl={fileUrl}
|
||||||
storedAnnotations={annotations}
|
storedAnnotations={annotations}
|
||||||
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
|
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
|
||||||
activeAnnotationId={activeAnnotationId}
|
activeAnnotationId={activeAnnotationId}
|
||||||
onHighlightClicked={handleHighlightClicked}
|
onHighlightClicked={handleHighlightClicked}
|
||||||
debugTextLayer={debugTextLayer}
|
debugTextLayer={debugTextLayer}
|
||||||
|
hideCanvas={hideCanvas}
|
||||||
|
hideTextLayer={hideTextLayer}
|
||||||
|
hideAnnotationLayer={hideAnnotationLayer}
|
||||||
|
hideXfaLayer={hideXfaLayer}
|
||||||
onSelectionCaptured={(capture, selectors) => {
|
onSelectionCaptured={(capture, selectors) => {
|
||||||
setPending({ capture, selectors });
|
setPending({ capture, selectors });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
const KEY_PREFIX = "citation-evidence:debug:";
|
const KEY_PREFIX = "citation-evidence:debug:";
|
||||||
const STORAGE_EVENT = "ce-debug-flag-change";
|
const STORAGE_EVENT = "ce-debug-flag-change";
|
||||||
|
|
||||||
export type DebugFlag = "textLayer";
|
export type DebugFlag =
|
||||||
|
| "textLayer"
|
||||||
|
| "hideCanvas"
|
||||||
|
| "hideTextLayer"
|
||||||
|
| "hideAnnotationLayer"
|
||||||
|
| "hideXfaLayer";
|
||||||
|
|
||||||
function storageKey(flag: DebugFlag): string {
|
function storageKey(flag: DebugFlag): string {
|
||||||
return `${KEY_PREFIX}${flag}`;
|
return `${KEY_PREFIX}${flag}`;
|
||||||
|
|||||||
@@ -138,10 +138,10 @@ describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await user.type(
|
await user.type(
|
||||||
screen.getByPlaceholderText(/Add a one-line comment/),
|
screen.getByTestId("inline-capture-commentary"),
|
||||||
"Export E2E commentary",
|
"Export E2E commentary",
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
|
await user.click(screen.getByTestId("inline-capture-save"));
|
||||||
await screen.findByText(/Export E2E commentary/);
|
await screen.findByText(/Export E2E commentary/);
|
||||||
|
|
||||||
// --- Step 10: click Export → Copy as Markdown ----------------------
|
// --- Step 10: click Export → Copy as Markdown ----------------------
|
||||||
|
|||||||
@@ -251,10 +251,10 @@ describe("CE-WP-0005-T08 — full create → annotate → export → reimport E2
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await user.type(
|
await user.type(
|
||||||
screen.getByPlaceholderText(/Add a one-line comment/),
|
screen.getByTestId("inline-capture-commentary"),
|
||||||
"E2E session commentary",
|
"E2E session commentary",
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
|
await user.click(screen.getByTestId("inline-capture-save"));
|
||||||
await screen.findByText(/E2E session commentary/);
|
await screen.findByText(/E2E session commentary/);
|
||||||
|
|
||||||
// Step 5 (sanity): export the evidence as Markdown via the sidebar.
|
// Step 5 (sanity): export the evidence as Markdown via the sidebar.
|
||||||
|
|||||||
Reference in New Issue
Block a user