A lightweight client-side PDF viewer and annotation tool.
All the code has been written by Codex.
The idea is to create a lightweight and fast tool using PDF.js and pdf-lib for reading PDFs, and making highlight and freehand annotations.
It also includes some basic document management operations: add blank page, delete page and merge.
Local files can be opened, edited and modifications saved back to the original file or downloaded as a copy.
Import the reusable annotator from src/annotator. It owns its default CSS; pass PDF bytes in a source.
import { useState } from 'react';
import { PdfWorkspace, readPdfFile } from './annotator';
import type { PdfWorkspaceSource } from './annotator';
export function PdfView() {
const [source, setSource] = useState<PdfWorkspaceSource | null>(null);
async function openFile(file: File) {
setSource({
bytes: await readPdfFile(file),
name: file.name,
sourceId: file.name
});
}
return source ? (
<PdfWorkspace
source={source}
onClose={() => setSource(null)}
onOpenExternalLink={(url) =>
window.open(url, '_blank', 'noopener,noreferrer')
}
/>
) : (
<input
accept="application/pdf"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void openFile(file);
}}
type="file"
/>
);
}For a PDF already hosted by your site, fetch it and set bytes: new Uint8Array(await response.arrayBuffer()).
Required props:
source: aPdfWorkspaceSourcecontaining PDF bytes or a loader.onClose: called when the workspace close button is pressed.
Optional props:
className,style: size and style the workspace host element.confirmDiscardChanges(session): override the unsaved-close confirmation.enableGlobalShortcuts: enableCtrl+S, undo/redo, zoom shortcuts. Defaults totrue.enableWheelZoom: enableCtrl+wheelzoom. Defaults totrue.initialSession: restore a previousPdfWorkspaceSession.manageDocumentTitle: let the component updatedocument.title. Defaults totrue.onDirtyChange(isDirty): observe unsaved-change state.onDocumentTitleChange(title): observe the current document title.onOpenExternalLink(url, context): open confirmed external PDF links. If omitted, links open in a new browser tab.onSessionChange(session): observe the current workspace session.showCloseButton: show the workspace close button. Defaults totrue.warnBeforeUnload: show the browser unsaved-changes prompt. Defaults totrue.
source can be:
{ bytes, name, sourceId }for already-loaded PDF bytes.{ kind: 'loader', loadBytes, name, sourceId }to let the workspace show its loading UI while bytes are fetched.- Either source may include
saveTarget.save(bytes)to write back to the original file. If saving fails or nosaveTargetis supplied, the workspace downloads a copy. - Either source may include
initialAnnotationsto open a generated PDF with editable unsaved annotations already on the page.
Style it by overriding CSS variables and, if needed, passing className/style to control size.
.pdf-annotator {
--pdfa-bg: #f3f3f3;
--pdfa-ui: #ffffff;
--pdfa-ink: #171c1c;
--pdfa-accent: #cc41bf;
}The code is split into reusable layers:
src/annotator: single-PDF viewer/editor component. It does not know how the host opens files.src/tabbedapp: reusable multi-PDF tab shell. It owns tabs, snapshots, dirty state and resource cleanup.src/browserapp: browser/GitHub Pages host. It provides file-system access and the branded home page.
Browser apps can use the default external-link opener. Desktop hosts should pass onOpenExternalLink and open confirmed links through the system browser.
Import TabbedPdfShell from src/tabbedapp and provide a PdfHostAdapter. Browser-only file picker and drag/drop code lives in src/browserapp; it is not part of the reusable tabbed shell.
const shellRef = useRef<TabbedPdfShellHandle>(null);
<TabbedPdfShell
fileAdapter={myFileAdapter}
ref={shellRef}
workspaceOptions={{ onOpenExternalLink: openInHostBrowser }}
/>
shellRef.current?.openDocument({
fileKey: referenceItem.id,
source: {
kind: 'loader',
loadBytes: () => loadPdfBytesForReference(referenceItem),
name: referenceItem.pdfName
}
});The shell also accepts initialDocuments, onDocumentsChange, confirmCloseDocuments, and renderHome. The default home tab is intentionally blank; host apps can pass renderHome to provide a library, dashboard or landing page inside the home tab. The browser app uses this to supply the current Open PDFs/Create PDF home screen.
Desktop-style hosts should keep one workspace mounted for the active tab. Before hiding a tab, call snapshot() from the component ref and store the returned PdfWorkspaceSession. After hiding it, call releaseRenderResources() to discard PDF.js canvases/pages. When showing that tab again, pass the saved session back as initialSession.
Ref methods:
snapshot(): returns the currentPdfWorkspaceSession, ornullif no PDF is loaded.releaseRenderResources(): releases PDF.js render resources for a hidden workspace.