UploadDropzone accepts HTML/MD files; ViewerShell routes by representationType; PendingSelection uses SelectionCapture union.
202 lines
No EOL
5.8 KiB
TypeScript
202 lines
No EOL
5.8 KiB
TypeScript
/**
|
|
* UploadDropzone — drag-drop + file-picker for uploading documents into the
|
|
* active session (PDF, HTML, Markdown).
|
|
*/
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
|
|
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";
|
|
error?: string;
|
|
}
|
|
|
|
export interface UploadDropzoneProps {
|
|
/** Optional callback fired after each successful upload. */
|
|
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();
|
|
const { setId } = useActiveDocumentId();
|
|
const [entries, setEntries] = useState<readonly UploadEntry[]>([]);
|
|
const [isOver, setIsOver] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const processFiles = useCallback(
|
|
async (files: readonly File[]) => {
|
|
if (files.length === 0) return;
|
|
const initial: UploadEntry[] = files.map((file) => {
|
|
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]);
|
|
|
|
let lastDocumentId: import("@shared/ids").DocumentId | null = null;
|
|
for (const entry of initial) {
|
|
if (entry.status === "error") continue;
|
|
entry.status = "uploading";
|
|
setEntries((prev) => [...prev]);
|
|
try {
|
|
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 = ingested.document.id;
|
|
onUploaded?.(ingested.document.id);
|
|
} catch (err) {
|
|
entry.status = "error";
|
|
entry.error = err instanceof Error ? err.message : String(err);
|
|
}
|
|
setEntries((prev) => [...prev]);
|
|
}
|
|
if (lastDocumentId) setId(lastDocumentId);
|
|
},
|
|
[byteStore, engine, onUploaded, setId],
|
|
);
|
|
|
|
const onDrop = useCallback(
|
|
(e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
setIsOver(false);
|
|
const files = Array.from(e.dataTransfer.files);
|
|
void processFiles(files);
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
setIsOver(true);
|
|
}, []);
|
|
|
|
const onDragLeave = useCallback(() => {
|
|
setIsOver(false);
|
|
}, []);
|
|
|
|
const openPicker = useCallback(() => {
|
|
fileInputRef.current?.click();
|
|
}, []);
|
|
|
|
const onPicked = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files ? Array.from(e.target.files) : [];
|
|
void processFiles(files);
|
|
e.target.value = "";
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
return (
|
|
<div data-testid="upload-dropzone">
|
|
<div
|
|
onDrop={onDrop}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
role="region"
|
|
aria-label="Document upload"
|
|
style={{
|
|
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
|
|
background: isOver ? "#e8f0ff" : "#fafafa",
|
|
padding: 16,
|
|
textAlign: "center",
|
|
fontSize: 12,
|
|
color: "#555",
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<div>Drop PDF, HTML, or Markdown files here</div>
|
|
<div style={{ margin: "6px 0", color: "#888" }}>or</div>
|
|
<button
|
|
type="button"
|
|
onClick={openPicker}
|
|
data-testid="upload-pick-button"
|
|
style={{
|
|
fontSize: 12,
|
|
padding: "4px 10px",
|
|
border: "1px solid #888",
|
|
background: "white",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Choose file…
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="application/pdf,.pdf,text/html,.html,.htm,text/markdown,.md,.markdown"
|
|
multiple
|
|
onChange={onPicked}
|
|
style={{ display: "none" }}
|
|
data-testid="upload-file-input"
|
|
/>
|
|
</div>
|
|
{entries.length > 0 && (
|
|
<ul
|
|
data-testid="upload-progress"
|
|
style={{ listStyle: "none", padding: 0, margin: "8px 0 0", fontSize: 11 }}
|
|
>
|
|
{entries.map((entry, i) => (
|
|
<li
|
|
key={`${entry.file.name}-${i}`}
|
|
data-status={entry.status}
|
|
style={{
|
|
padding: "2px 4px",
|
|
color:
|
|
entry.status === "error"
|
|
? "#7a0000"
|
|
: entry.status === "done"
|
|
? "#0a5a0a"
|
|
: "#333",
|
|
}}
|
|
>
|
|
{entry.file.name} — {entry.status}
|
|
{entry.error ? `: ${entry.error}` : ""}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
} |