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,
type Engine,
} 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 { useContext as useReactContext } from "react";
import { SessionInternalContext } from "./SessionContextInternal";
@ -60,7 +60,7 @@ function activeDocumentKeyFor(sessionId: SessionId | null): string {
* `null` means "no selection waiting for a comment".
*/
export interface PendingSelection {
readonly capture: PdfSelectionCapture;
readonly capture: SelectionCapture;
readonly selectors: readonly Selector[];
}

View file

@ -142,7 +142,10 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
const pendingOrder = useMemo<number>(() => {
if (!pending) return Number.POSITIVE_INFINITY;
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]);
// 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
* active session.
*
* 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.
* UploadDropzone drag-drop + file-picker for uploading documents into the
* active session (PDF, HTML, Markdown).
*/
import { useCallback, useRef, useState } from "react";
import { ingestPdfFromFile } from "@source/index";
import {
ingestHtmlFromFile,
ingestMarkdownFromFile,
ingestPdfFromFile,
} from "@source/index";
import {
useActiveDocumentId,
useEngine,
usePdfByteStore,
} from "./EngineContext";
type UploadFormat = "pdf" | "html" | "markdown" | "unsupported";
interface UploadEntry {
readonly file: File;
status: "queued" | "uploading" | "done" | "error";
@ -43,6 +29,22 @@ export interface UploadDropzoneProps {
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) {
const engine = useEngine();
const byteStore = usePdfByteStore();
@ -55,14 +57,15 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
async (files: readonly File[]) => {
if (files.length === 0) return;
const initial: UploadEntry[] = files.map((file) => {
const isPdf =
file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
if (isPdf) return { file, status: "queued" };
return {
file,
status: "error",
error: "Not a PDF (only application/pdf accepted)",
};
const format = detectFormat(file);
if (format === "unsupported") {
return {
file,
status: "error",
error: "Unsupported format (PDF, HTML, or Markdown only)",
};
}
return { file, status: "queued" };
});
setEntries((prev) => [...prev, ...initial]);
@ -72,14 +75,16 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
entry.status = "uploading";
setEntries((prev) => [...prev]);
try {
const { document, representation } = await ingestPdfFromFile(
entry.file,
byteStore,
);
engine.documents.register({ document, representation });
const format = detectFormat(entry.file);
const ingested = await (format === "html"
? ingestHtmlFromFile(entry.file, byteStore)
: format === "markdown"
? ingestMarkdownFromFile(entry.file, byteStore)
: ingestPdfFromFile(entry.file, byteStore));
engine.documents.register(ingested);
entry.status = "done";
lastDocumentId = document.id;
onUploaded?.(document.id);
lastDocumentId = ingested.document.id;
onUploaded?.(ingested.document.id);
} catch (err) {
entry.status = "error";
entry.error = err instanceof Error ? err.message : String(err);
@ -118,7 +123,6 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [];
void processFiles(files);
// Reset so the same filename can be picked again.
e.target.value = "";
},
[processFiles],
@ -131,7 +135,7 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
onDragOver={onDragOver}
onDragLeave={onDragLeave}
role="region"
aria-label="PDF upload"
aria-label="Document upload"
style={{
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
background: isOver ? "#e8f0ff" : "#fafafa",
@ -142,7 +146,7 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
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>
<button
type="button"
@ -156,12 +160,12 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
cursor: "pointer",
}}
>
Choose PDF
Choose file
</button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
accept="application/pdf,.pdf,text/html,.html,.htm,text/markdown,.md,.markdown"
multiple
onChange={onPicked}
style={{ display: "none" }}
@ -195,4 +199,4 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
)}
</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.
*
* Hosts the viewer adapter (currently the T02 PDF spike) and shows whatever
* is active. `work/` consumes only the adapter's public surface
* (`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.
* Hosts the format-appropriate viewer adapter (PDF or HTML/Markdown) and
* surfaces selection + highlight activation events to the review shell.
*/
import { useCallback, useMemo } from "react";
import { PdfSpikeViewer, type StoredAnnotation } from "@citation-evidence/evidence-anchor";
import { resolvePdfViewerUrl } from "@source/pdf/viewer-url";
import {
HtmlViewerAdapter,
PdfSpikeViewer,
type StoredAnnotation,
} from "@citation-evidence/evidence-anchor";
import { resolveDomViewerHtml, resolvePdfViewerUrl } from "@source/index";
import type { AnnotationId } from "@shared/ids";
import {
useActiveDocument,
@ -41,8 +37,6 @@ export function ViewerShell() {
const [hideXfaLayer] = useDebugFlag("hideXfaLayer");
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 annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
@ -55,19 +49,28 @@ export function ViewerShell() {
}));
}, [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>(() => {
if (!activeEvidenceId) return null;
const item = engine.evidence.get(activeEvidenceId);
return item?.annotationIds[0] ?? null;
}, [activeEvidenceId, engine]);
const fileUrl = useMemo(() => {
const pdfUrl = useMemo(() => {
if (!document) return null;
return resolvePdfViewerUrl(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 =
scrollToId !== null ? `${scrollToId}:${scrollVersion}` : null;
@ -80,14 +83,19 @@ export function ViewerShell() {
);
if (!item) return;
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);
},
[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 (
<main
style={{
@ -99,7 +107,46 @@ export function ViewerShell() {
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>
);
}
@ -115,33 +162,45 @@ export function ViewerShell() {
}}
>
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<PdfSpikeViewer
// Re-key on document + debug flags only — scroll requests must
// not remount the viewer (that re-fetches the PDF blob).
key={[
document.id,
debugTextLayer ? "d" : "n",
hideCanvas ? "hc" : "",
hideTextLayer ? "ht" : "",
hideAnnotationLayer ? "ha" : "",
hideXfaLayer ? "hx" : "",
].join("#")}
pdfUrl={fileUrl}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
{...(scrollRequestKey ? { scrollRequestKey } : {})}
activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked}
debugTextLayer={debugTextLayer}
hideCanvas={hideCanvas}
hideTextLayer={hideTextLayer}
hideAnnotationLayer={hideAnnotationLayer}
hideXfaLayer={hideXfaLayer}
onSelectionCaptured={(capture, selectors) => {
setPending({ capture, selectors });
}}
/>
{isPdf && pdfUrl ? (
<PdfSpikeViewer
key={[
document.id,
debugTextLayer ? "d" : "n",
hideCanvas ? "hc" : "",
hideTextLayer ? "ht" : "",
hideAnnotationLayer ? "ha" : "",
hideXfaLayer ? "hx" : "",
].join("#")}
pdfUrl={pdfUrl}
representation={representation}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
{...(scrollRequestKey ? { scrollRequestKey } : {})}
activeAnnotationId={activeAnnotationId}
onHighlightClicked={handleHighlightClicked}
debugTextLayer={debugTextLayer}
hideCanvas={hideCanvas}
hideTextLayer={hideTextLayer}
hideAnnotationLayer={hideAnnotationLayer}
hideXfaLayer={hideXfaLayer}
onSelectionCaptured={handleSelectionCaptured}
/>
) : 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>
</main>
);
}
}