Wire HtmlViewerAdapter into ViewerShell for HTML/Markdown documents
UploadDropzone accepts HTML/MD files; ViewerShell routes by representationType; PendingSelection uses SelectionCapture union.
This commit is contained in:
parent
0cd2ca5d04
commit
589b8356b4
5 changed files with 208 additions and 95 deletions
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/work/ViewerShell.dom.test.tsx
Normal file
47
src/work/ViewerShell.dom.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue