Wire HtmlViewerAdapter into ViewerShell for HTML/Markdown documents
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

UploadDropzone accepts HTML/MD files; ViewerShell routes by
representationType; PendingSelection uses SelectionCapture union.
This commit is contained in:
tegwick 2026-07-09 09:58:41 +02:00
parent 0cd2ca5d04
commit 589b8356b4
5 changed files with 208 additions and 95 deletions

View file

@ -30,7 +30,7 @@ import {
restoreFromStorage, restoreFromStorage,
type Engine, type Engine,
} from "@engine/index"; } from "@engine/index";
import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import type { SelectionCapture } from "@citation-evidence/evidence-anchor";
import { createPdfByteStore, type PdfByteStore } from "@source/index"; import { createPdfByteStore, type PdfByteStore } from "@source/index";
import { useContext as useReactContext } from "react"; import { useContext as useReactContext } from "react";
import { SessionInternalContext } from "./SessionContextInternal"; import { SessionInternalContext } from "./SessionContextInternal";
@ -60,7 +60,7 @@ function activeDocumentKeyFor(sessionId: SessionId | null): string {
* `null` means "no selection waiting for a comment". * `null` means "no selection waiting for a comment".
*/ */
export interface PendingSelection { export interface PendingSelection {
readonly capture: PdfSelectionCapture; readonly capture: SelectionCapture;
readonly selectors: readonly Selector[]; readonly selectors: readonly Selector[];
} }

View file

@ -142,7 +142,10 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
const pendingOrder = useMemo<number>(() => { const pendingOrder = useMemo<number>(() => {
if (!pending) return Number.POSITIVE_INFINITY; if (!pending) return Number.POSITIVE_INFINITY;
const c = pending.capture; const c = pending.capture;
return c.page * 1000 + (c.boundingRect?.y ?? 0); if (c.kind === "pdf") {
return c.page * 1000 + (c.boundingRect?.y ?? 0);
}
return c.startOffset;
}, [pending]); }, [pending]);
// Find the insert position for the pending capture form: first index // Find the insert position for the pending capture form: first index

View file

@ -1,37 +1,23 @@
/** /**
* UploadDropzone drag-drop + file-picker for uploading PDFs into the * UploadDropzone drag-drop + file-picker for uploading documents into the
* active session. * active session (PDF, HTML, Markdown).
*
* On every successful drop:
* 1. read each File as bytes,
* 2. run the source-layer `ingestPdfFromFile` (mints the blob URL
* via the session's `PdfByteStore`),
* 3. register the resulting `{document, representation}` with the
* engine,
* 4. activate the most-recently-uploaded document.
*
* Failures (non-PDFs, ingest errors) are surfaced inline above the
* dropzone; the caller doesn't need a separate toast for them.
*
* Extraction decision (CWORK-WP-0001 T03)
* This component lives in `citation-work` rather than the umbrella app.
* Its only dependencies are `@source` (PDF ingestion) and the local
* engine/session hooks both inside the review-workspace boundary so
* hosting it here lets the package render a complete review shell,
* including the upload affordance, without any umbrella-local imports.
* `ReviewShell` still exposes an `upload` slot so a consumer may swap in
* its own affordance; when the slot is omitted, this default is used.
*/ */
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { ingestPdfFromFile } from "@source/index"; import {
ingestHtmlFromFile,
ingestMarkdownFromFile,
ingestPdfFromFile,
} from "@source/index";
import { import {
useActiveDocumentId, useActiveDocumentId,
useEngine, useEngine,
usePdfByteStore, usePdfByteStore,
} from "./EngineContext"; } from "./EngineContext";
type UploadFormat = "pdf" | "html" | "markdown" | "unsupported";
interface UploadEntry { interface UploadEntry {
readonly file: File; readonly file: File;
status: "queued" | "uploading" | "done" | "error"; status: "queued" | "uploading" | "done" | "error";
@ -43,6 +29,22 @@ export interface UploadDropzoneProps {
readonly onUploaded?: (documentId: import("@shared/ids").DocumentId) => void; readonly onUploaded?: (documentId: import("@shared/ids").DocumentId) => void;
} }
function detectFormat(file: File): UploadFormat {
const name = file.name.toLowerCase();
if (file.type === "application/pdf" || name.endsWith(".pdf")) return "pdf";
if (file.type === "text/html" || name.endsWith(".html") || name.endsWith(".htm")) {
return "html";
}
if (
file.type === "text/markdown"
|| name.endsWith(".md")
|| name.endsWith(".markdown")
) {
return "markdown";
}
return "unsupported";
}
export function UploadDropzone({ onUploaded }: UploadDropzoneProps) { export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
const engine = useEngine(); const engine = useEngine();
const byteStore = usePdfByteStore(); const byteStore = usePdfByteStore();
@ -55,14 +57,15 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
async (files: readonly File[]) => { async (files: readonly File[]) => {
if (files.length === 0) return; if (files.length === 0) return;
const initial: UploadEntry[] = files.map((file) => { const initial: UploadEntry[] = files.map((file) => {
const isPdf = const format = detectFormat(file);
file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); if (format === "unsupported") {
if (isPdf) return { file, status: "queued" }; return {
return { file,
file, status: "error",
status: "error", error: "Unsupported format (PDF, HTML, or Markdown only)",
error: "Not a PDF (only application/pdf accepted)", };
}; }
return { file, status: "queued" };
}); });
setEntries((prev) => [...prev, ...initial]); setEntries((prev) => [...prev, ...initial]);
@ -72,14 +75,16 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
entry.status = "uploading"; entry.status = "uploading";
setEntries((prev) => [...prev]); setEntries((prev) => [...prev]);
try { try {
const { document, representation } = await ingestPdfFromFile( const format = detectFormat(entry.file);
entry.file, const ingested = await (format === "html"
byteStore, ? ingestHtmlFromFile(entry.file, byteStore)
); : format === "markdown"
engine.documents.register({ document, representation }); ? ingestMarkdownFromFile(entry.file, byteStore)
: ingestPdfFromFile(entry.file, byteStore));
engine.documents.register(ingested);
entry.status = "done"; entry.status = "done";
lastDocumentId = document.id; lastDocumentId = ingested.document.id;
onUploaded?.(document.id); onUploaded?.(ingested.document.id);
} catch (err) { } catch (err) {
entry.status = "error"; entry.status = "error";
entry.error = err instanceof Error ? err.message : String(err); entry.error = err instanceof Error ? err.message : String(err);
@ -118,7 +123,6 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : []; const files = e.target.files ? Array.from(e.target.files) : [];
void processFiles(files); void processFiles(files);
// Reset so the same filename can be picked again.
e.target.value = ""; e.target.value = "";
}, },
[processFiles], [processFiles],
@ -131,7 +135,7 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
role="region" role="region"
aria-label="PDF upload" aria-label="Document upload"
style={{ style={{
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`, border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
background: isOver ? "#e8f0ff" : "#fafafa", background: isOver ? "#e8f0ff" : "#fafafa",
@ -142,7 +146,7 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
borderRadius: 4, borderRadius: 4,
}} }}
> >
<div>Drop PDF files here</div> <div>Drop PDF, HTML, or Markdown files here</div>
<div style={{ margin: "6px 0", color: "#888" }}>or</div> <div style={{ margin: "6px 0", color: "#888" }}>or</div>
<button <button
type="button" type="button"
@ -156,12 +160,12 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
cursor: "pointer", cursor: "pointer",
}} }}
> >
Choose PDF Choose file
</button> </button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="application/pdf,.pdf" accept="application/pdf,.pdf,text/html,.html,.htm,text/markdown,.md,.markdown"
multiple multiple
onChange={onPicked} onChange={onPicked}
style={{ display: "none" }} style={{ display: "none" }}
@ -195,4 +199,4 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
)} )}
</div> </div>
); );
} }

View file

@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { useEffect } from "react";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { createEngine, type Engine } from "@engine/index";
import { ingestHtmlFromFile } from "@source/index";
import { EngineProvider, useActiveDocumentId, usePdfByteStore } from "./EngineContext";
import { ViewerShell } from "./ViewerShell";
function BootstrapHtmlDoc({
engine,
file,
}: {
readonly engine: Engine;
readonly file: File;
}) {
const byteStore = usePdfByteStore();
const { setId } = useActiveDocumentId();
useEffect(() => {
void ingestHtmlFromFile(file, byteStore).then(({ document, representation }) => {
engine.documents.register({ document, representation });
setId(document.id);
});
}, [byteStore, engine, file, setId]);
return null;
}
describe("ViewerShell (dom)", () => {
it("renders HtmlViewerAdapter content for html-dom documents", async () => {
const engine = createEngine();
const file = new File(
["<!DOCTYPE html><html><body><p>Dom viewer smoke test</p></body></html>"],
"sample.html",
{ type: "text/html" },
);
render(
<EngineProvider engine={engine}>
<BootstrapHtmlDoc engine={engine} file={file} />
<ViewerShell />
</EngineProvider>,
);
expect(await screen.findByText("Dom viewer smoke test")).toBeTruthy();
});
});

View file

@ -1,21 +1,17 @@
/** /**
* ViewerShell the centre pane. * ViewerShell the centre pane.
* *
* Hosts the viewer adapter (currently the T02 PDF spike) and shows whatever * Hosts the format-appropriate viewer adapter (PDF or HTML/Markdown) and
* is active. `work/` consumes only the adapter's public surface * surfaces selection + highlight activation events to the review shell.
* (`PdfSpikeViewer`) it never touches PDF.js or react-pdf-highlighter-plus
* directly. When the PDF library is swapped (or the spike is replaced),
* only the adapter module changes; this shell stays the same.
*
* The annotation toolbar lived here in earlier iterations; CE-WP-0005-iter4
* moved it into the evidence sidebar so the capture form appears in the
* sidebar's document-flow position. The viewer now only renders the PDF
* and surfaces the activate/click events.
*/ */
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { PdfSpikeViewer, type StoredAnnotation } from "@citation-evidence/evidence-anchor"; import {
import { resolvePdfViewerUrl } from "@source/pdf/viewer-url"; HtmlViewerAdapter,
PdfSpikeViewer,
type StoredAnnotation,
} from "@citation-evidence/evidence-anchor";
import { resolveDomViewerHtml, resolvePdfViewerUrl } from "@source/index";
import type { AnnotationId } from "@shared/ids"; import type { AnnotationId } from "@shared/ids";
import { import {
useActiveDocument, useActiveDocument,
@ -41,8 +37,6 @@ export function ViewerShell() {
const [hideXfaLayer] = useDebugFlag("hideXfaLayer"); const [hideXfaLayer] = useDebugFlag("hideXfaLayer");
const activeEvidenceId = useLastActivatedEvidence(); const activeEvidenceId = useLastActivatedEvidence();
// The viewer needs to re-fetch its highlight list whenever annotations
// change. The tick is included in the memo deps so the list re-resolves.
const annotationTick = useEngineEventTick("AnnotationCreated"); const annotationTick = useEngineEventTick("AnnotationCreated");
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated"); const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
@ -55,19 +49,28 @@ export function ViewerShell() {
})); }));
}, [document, engine, annotationTick, annotationUpdateTick]); }, [document, engine, annotationTick, annotationUpdateTick]);
// The annotation id that visually represents the "active" focus —
// derived from the active evidence's first annotation.
const activeAnnotationId = useMemo<AnnotationId | null>(() => { const activeAnnotationId = useMemo<AnnotationId | null>(() => {
if (!activeEvidenceId) return null; if (!activeEvidenceId) return null;
const item = engine.evidence.get(activeEvidenceId); const item = engine.evidence.get(activeEvidenceId);
return item?.annotationIds[0] ?? null; return item?.annotationIds[0] ?? null;
}, [activeEvidenceId, engine]); }, [activeEvidenceId, engine]);
const fileUrl = useMemo(() => { const pdfUrl = useMemo(() => {
if (!document) return null; if (!document) return null;
return resolvePdfViewerUrl(document, byteStore); return resolvePdfViewerUrl(document, byteStore);
}, [document, byteStore]); }, [document, byteStore]);
const domHtml = useMemo(() => {
if (!document || !representation) return null;
if (
representation.representationType !== "html-dom"
&& representation.representationType !== "markdown-rendered"
) {
return null;
}
return resolveDomViewerHtml(document, representation, byteStore);
}, [document, representation, byteStore]);
const scrollRequestKey = const scrollRequestKey =
scrollToId !== null ? `${scrollToId}:${scrollVersion}` : null; scrollToId !== null ? `${scrollToId}:${scrollVersion}` : null;
@ -80,14 +83,19 @@ export function ViewerShell() {
); );
if (!item) return; if (!item) return;
engine.evidence.activate(item.id, "citation-card"); engine.evidence.activate(item.id, "citation-card");
// Re-trigger scroll so a click on the highlight also keeps it
// centred in the viewport.
scrollTo(annotationId as AnnotationId); scrollTo(annotationId as AnnotationId);
}, },
[document, engine, scrollTo], [document, engine, scrollTo],
); );
if (!document || !representation || !fileUrl) { const handleSelectionCaptured = useCallback(
(capture: import("@citation-evidence/evidence-anchor").SelectionCapture, selectors: import("@shared/selector").Selector[]) => {
setPending({ capture, selectors });
},
[setPending],
);
if (!document || !representation) {
return ( return (
<main <main
style={{ style={{
@ -99,7 +107,46 @@ export function ViewerShell() {
fontFamily: "system-ui, sans-serif", fontFamily: "system-ui, sans-serif",
}} }}
> >
Upload a PDF on the left to begin. Upload a document on the left to begin.
</main>
);
}
const isPdf = representation.representationType === "pdf-text";
const isDom =
representation.representationType === "html-dom"
|| representation.representationType === "markdown-rendered";
if (isPdf && !pdfUrl) {
return (
<main
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#666",
fontFamily: "system-ui, sans-serif",
}}
>
PDF bytes not available re-upload the document.
</main>
);
}
if (isDom && !domHtml) {
return (
<main
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#666",
fontFamily: "system-ui, sans-serif",
}}
>
Document bytes not available re-upload the file.
</main> </main>
); );
} }
@ -115,33 +162,45 @@ export function ViewerShell() {
}} }}
> >
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}> <div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<PdfSpikeViewer {isPdf && pdfUrl ? (
// Re-key on document + debug flags only — scroll requests must <PdfSpikeViewer
// not remount the viewer (that re-fetches the PDF blob). key={[
key={[ document.id,
document.id, debugTextLayer ? "d" : "n",
debugTextLayer ? "d" : "n", hideCanvas ? "hc" : "",
hideCanvas ? "hc" : "", hideTextLayer ? "ht" : "",
hideTextLayer ? "ht" : "", hideAnnotationLayer ? "ha" : "",
hideAnnotationLayer ? "ha" : "", hideXfaLayer ? "hx" : "",
hideXfaLayer ? "hx" : "", ].join("#")}
].join("#")} pdfUrl={pdfUrl}
pdfUrl={fileUrl} representation={representation}
storedAnnotations={annotations} storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})} {...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
{...(scrollRequestKey ? { scrollRequestKey } : {})} {...(scrollRequestKey ? { scrollRequestKey } : {})}
activeAnnotationId={activeAnnotationId} activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked} onHighlightClicked={handleHighlightClicked}
debugTextLayer={debugTextLayer} debugTextLayer={debugTextLayer}
hideCanvas={hideCanvas} hideCanvas={hideCanvas}
hideTextLayer={hideTextLayer} hideTextLayer={hideTextLayer}
hideAnnotationLayer={hideAnnotationLayer} hideAnnotationLayer={hideAnnotationLayer}
hideXfaLayer={hideXfaLayer} hideXfaLayer={hideXfaLayer}
onSelectionCaptured={(capture, selectors) => { onSelectionCaptured={handleSelectionCaptured}
setPending({ capture, selectors }); />
}} ) : null}
/> {isDom && domHtml ? (
<HtmlViewerAdapter
key={document.id}
html={domHtml}
representation={representation}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
{...(scrollRequestKey ? { scrollRequestKey } : {})}
activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked}
onSelectionCaptured={handleSelectionCaptured}
/>
) : null}
</div> </div>
</main> </main>
); );
} }