From 589b8356b4b117339a26903dde2fb75cb7f242c7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 9 Jul 2026 09:58:41 +0200 Subject: [PATCH] Wire HtmlViewerAdapter into ViewerShell for HTML/Markdown documents UploadDropzone accepts HTML/MD files; ViewerShell routes by representationType; PendingSelection uses SelectionCapture union. --- src/work/EngineContext.tsx | 4 +- src/work/EvidenceSidebar.tsx | 5 +- src/work/UploadDropzone.tsx | 92 +++++++++--------- src/work/ViewerShell.dom.test.tsx | 47 +++++++++ src/work/ViewerShell.tsx | 155 +++++++++++++++++++++--------- 5 files changed, 208 insertions(+), 95 deletions(-) create mode 100644 src/work/ViewerShell.dom.test.tsx diff --git a/src/work/EngineContext.tsx b/src/work/EngineContext.tsx index d2c892a..d997c5c 100644 --- a/src/work/EngineContext.tsx +++ b/src/work/EngineContext.tsx @@ -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[]; } diff --git a/src/work/EvidenceSidebar.tsx b/src/work/EvidenceSidebar.tsx index 71428ca..6a6731c 100644 --- a/src/work/EvidenceSidebar.tsx +++ b/src/work/EvidenceSidebar.tsx @@ -142,7 +142,10 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) { const pendingOrder = useMemo(() => { 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 diff --git a/src/work/UploadDropzone.tsx b/src/work/UploadDropzone.tsx index 67b5380..382ecfe 100644 --- a/src/work/UploadDropzone.tsx +++ b/src/work/UploadDropzone.tsx @@ -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) => { 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, }} > -
Drop PDF files here
+
Drop PDF, HTML, or Markdown files here
or
); -} +} \ No newline at end of file diff --git a/src/work/ViewerShell.dom.test.tsx b/src/work/ViewerShell.dom.test.tsx new file mode 100644 index 0000000..ef3c425 --- /dev/null +++ b/src/work/ViewerShell.dom.test.tsx @@ -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( + ["

Dom viewer smoke test

"], + "sample.html", + { type: "text/html" }, + ); + + render( + + + + , + ); + + expect(await screen.findByText("Dom viewer smoke test")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/work/ViewerShell.tsx b/src/work/ViewerShell.tsx index 5e42083..34fba3b 100644 --- a/src/work/ViewerShell.tsx +++ b/src/work/ViewerShell.tsx @@ -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(() => { 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 (
- Upload a PDF on the left to begin. + Upload a document on the left to begin. +
+ ); + } + + const isPdf = representation.representationType === "pdf-text"; + const isDom = + representation.representationType === "html-dom" + || representation.representationType === "markdown-rendered"; + + if (isPdf && !pdfUrl) { + return ( +
+ PDF bytes not available — re-upload the document. +
+ ); + } + + if (isDom && !domHtml) { + return ( +
+ Document bytes not available — re-upload the file.
); } @@ -115,33 +162,45 @@ export function ViewerShell() { }} >
- { - setPending({ capture, selectors }); - }} - /> + {isPdf && pdfUrl ? ( + + ) : null} + {isDom && domHtml ? ( + + ) : null}
); -} +} \ No newline at end of file