- );
-}
diff --git a/packages/web/src/components/checklist/pdf/PdfEmptyState.jsx b/packages/web/src/components/checklist/pdf/PdfEmptyState.jsx
deleted file mode 100644
index c1616fd20..000000000
--- a/packages/web/src/components/checklist/pdf/PdfEmptyState.jsx
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * PdfEmptyState - Component for displaying PDF loading, error, and empty states
- */
-
-import { Show } from 'solid-js';
-import { HiOutlineDocument } from 'solid-icons/hi';
-import { FileUpload } from '@corates/ui';
-
-function LoadingSpinner() {
- return ;
-}
-
-export default function PdfEmptyState(props) {
- // props.libReady - Whether PDF.js is ready
- // props.loading - Whether PDF is loading
- // props.error - Error message if any
- // props.pdfDoc - The loaded PDF document
- // props.readOnly - If true, hides "Open a PDF file" button
- // props.onFileAccept - Handler when files are accepted: (details: { files: File[] }) => void
-
- const handleFileAccept = details => {
- if (details.files.length > 0) {
- props.onFileAccept?.(details.files[0]);
- }
- };
-
- return (
- <>
- {/* Library initializing */}
-
-
-
-
- Initializing PDF viewer...
-
-
-
-
- {/* PDF loading */}
-
-
-
-
- Loading PDF...
-
-
-
-
- {/* Error state */}
-
-
-
-
{props.error}
-
-
-
-
-
-
-
- {/* No PDF loaded */}
-
-
-
-
No PDF loaded
- PDF will be displayed here}
- >
-
-
-
-
- >
- );
-}
diff --git a/packages/web/src/components/checklist/pdf/PdfList.jsx b/packages/web/src/components/checklist/pdf/PdfList.jsx
deleted file mode 100644
index 50e17c15b..000000000
--- a/packages/web/src/components/checklist/pdf/PdfList.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * PdfList - List all PDFs for a study with tag management
- *
- * Features:
- * - Display all PDFs with their tags (badge indicators)
- * - View/download PDF
- * - Delete PDF (with confirmation)
- * - Change tag via dropdown menu
- * - Visual distinction for primary/protocol/secondary
- */
-
-import { For, Show, createMemo } from 'solid-js';
-import { HiOutlineDocumentPlus } from 'solid-icons/hi';
-import PdfListItem from './PdfListItem.jsx';
-
-export default function PdfList(props) {
- // props.pdfs: Array<{ id, fileName, key, size, uploadedAt, tag }>
- // props.onView: (pdf) => void
- // props.onDownload: (pdf) => void
- // props.onDelete: (pdf) => void
- // props.onTagChange: (pdfId, newTag) => void
- // props.onUpload: () => void - trigger file picker
- // props.readOnly: boolean
- // props.uploading: boolean
-
- const sortedPdfs = createMemo(() => {
- const pdfs = props.pdfs || [];
- // Sort: primary first, then protocol, then secondary by uploadedAt desc
- return [...pdfs].sort((a, b) => {
- const tagOrder = { primary: 0, protocol: 1, secondary: 2 };
- const tagA = tagOrder[a.tag] ?? 2;
- const tagB = tagOrder[b.tag] ?? 2;
- if (tagA !== tagB) return tagA - tagB;
- return (b.uploadedAt || 0) - (a.uploadedAt || 0);
- });
- });
-
- const hasPrimary = createMemo(() => (props.pdfs || []).some(pdf => pdf.tag === 'primary'));
-
- const hasProtocol = createMemo(() => (props.pdfs || []).some(pdf => pdf.tag === 'protocol'));
-
- return (
-
- {/* Header with upload button */}
-
-
PDFs ({(props.pdfs || []).length})
-
-
-
-
-
- {/* PDF list */}
- 0}
- fallback={
-
-
-
No PDFs uploaded yet
-
-
-
-
- }
- >
-
-
- {pdf => (
-
- )}
-
-
-
-
- );
-}
diff --git a/packages/web/src/components/checklist/pdf/PdfSelector.jsx b/packages/web/src/components/checklist/pdf/PdfSelector.jsx
deleted file mode 100644
index 462510544..000000000
--- a/packages/web/src/components/checklist/pdf/PdfSelector.jsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * PdfSelector - Dropdown to select which PDF to view when multiple PDFs exist
- *
- * Shows a compact dropdown that displays the current PDF with tag badge,
- * and allows switching between PDFs.
- */
-
-import { Show, createMemo } from 'solid-js';
-import { Menu } from '@corates/ui';
-
-export default function PdfSelector(props) {
- // props.pdfs: Array<{ id, fileName, tag }>
- // props.selectedPdfId: string | null
- // props.onSelect: (pdfId: string) => void
-
- const sortedPdfs = createMemo(() => {
- const pdfs = props.pdfs || [];
- // Sort: primary first, then protocol, then secondary
- return [...pdfs].sort((a, b) => {
- const tagOrder = { primary: 0, protocol: 1, secondary: 2 };
- const tagA = tagOrder[a.tag] ?? 2;
- const tagB = tagOrder[b.tag] ?? 2;
- return tagA - tagB;
- });
- });
-
- // Build menu items with formatted labels including tag
- const menuItems = createMemo(() =>
- sortedPdfs().map(pdf => ({
- value: pdf.id,
- label:
- pdf.tag === 'primary' || pdf.tag === 'protocol' ?
- `${pdf.fileName} (${pdf.tag === 'primary' ? 'Primary' : 'Protocol'})`
- : pdf.fileName,
- })),
- );
-
- // Menu's onSelect callback receives { value: string }
- const handleSelect = details => {
- props.onSelect?.(details.value);
- };
-
- const shouldShow = createMemo(() => props.pdfs && props.pdfs.length > 1);
-
- return (
-
-
-
- {props.pdfs?.length || 0} PDFs
-
-
- );
-}
diff --git a/packages/web/src/components/checklist/pdf/PdfTagSelect.jsx b/packages/web/src/components/checklist/pdf/PdfTagSelect.jsx
deleted file mode 100644
index da46aad62..000000000
--- a/packages/web/src/components/checklist/pdf/PdfTagSelect.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * PdfTagSelect - Dropdown to change PDF tag
- *
- * Uses the Select component to allow changing PDF tags
- */
-
-import { Select } from '@corates/ui';
-
-const TAG_OPTIONS = [
- { label: 'Primary Report', value: 'primary' },
- { label: 'Protocol', value: 'protocol' },
- { label: 'Secondary', value: 'secondary' },
-];
-
-export default function PdfTagSelect(props) {
- // props.value: current tag value
- // props.onChange: (tag: string) => void
- // props.disabled: boolean
- // props.disablePrimary: boolean - disable primary option if another PDF has it
- // props.disableProtocol: boolean - disable protocol option if another PDF has it
-
- const disabledValues = () => {
- const disabled = [];
- if (props.disablePrimary) disabled.push('primary');
- if (props.disableProtocol) disabled.push('protocol');
- return disabled;
- };
-
- return (
-
- );
-}
diff --git a/packages/web/src/components/checklist/pdf/PdfToolbar.jsx b/packages/web/src/components/checklist/pdf/PdfToolbar.jsx
deleted file mode 100644
index ac5222405..000000000
--- a/packages/web/src/components/checklist/pdf/PdfToolbar.jsx
+++ /dev/null
@@ -1,204 +0,0 @@
-/**
- * PdfToolbar - Toolbar component for PDF viewer
- * Contains file controls, page navigation, and zoom controls
- */
-
-import { Show, createSignal } from 'solid-js';
-import { AiOutlineUpload, AiOutlineClose, AiOutlineMinus, AiOutlinePlus } from 'solid-icons/ai';
-import {
- BiRegularChevronLeft,
- BiRegularChevronRight,
- BiRegularExpandHorizontal,
-} from 'solid-icons/bi';
-import { useConfirmDialog } from '@corates/ui';
-import PdfSelector from './PdfSelector.jsx';
-
-export default function PdfToolbar(props) {
- // props.readOnly - If true, hides upload/change/clear buttons
- // props.allowDelete - If true, shows delete button (only applies when !readOnly)
- // props.libReady - Whether PDF.js is ready
- // props.pdfDoc - The loaded PDF document
- // props.fileName - Current file name
- // props.currentPage - Current page number
- // props.totalPages - Total number of pages
- // props.scale - Current zoom scale
- // props.onOpenFile - Handler to open file picker
- // props.onClearPdf - Handler to clear PDF
- // props.onPrevPage - Handler for previous page
- // props.onNextPage - Handler for next page
- // props.onZoomIn - Handler for zoom in
- // props.onZoomOut - Handler for zoom out
- // props.onResetZoom - Handler for reset zoom
- // props.onSetScale - Handler to set specific zoom scale
- // props.onGoToPage - Handler to go to specific page
- // props.onFitToWidth - Handler for fit to width
- // props.fileInputRef - Ref setter for hidden file input
- // props.onFileUpload - Handler for file upload
- // props.pdfs - Array of PDFs for multi-PDF selection
- // props.selectedPdfId - Currently selected PDF ID
- // props.onPdfSelect - Handler for PDF selection change
-
- // Local state for page input
- const [pageInput, setPageInput] = createSignal('');
- const [zoomInput, setZoomInput] = createSignal('');
-
- const confirmRemovePdf = useConfirmDialog();
-
- // Handle page input submission
- function handlePageSubmit(e) {
- e.preventDefault();
- const pageNum = parseInt(pageInput(), 10);
- if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= props.totalPages) {
- props.onGoToPage?.(pageNum);
- }
- setPageInput('');
- e.target.querySelector('input')?.blur();
- }
-
- // Handle zoom input submission
- function handleZoomSubmit(e) {
- e.preventDefault();
- const zoomPercent = parseInt(zoomInput(), 10);
- if (!isNaN(zoomPercent) && zoomPercent >= 50 && zoomPercent <= 300) {
- props.onSetScale?.(zoomPercent / 100);
- }
- setZoomInput('');
- e.target.querySelector('input')?.blur();
- }
-
- return (
-
-
- {/* File upload and info */}
-
-
- props.onFileUpload?.(e)}
- class='hidden'
- />
-
-
-
- {/* PDF Selector - for switching between multiple PDFs */}
- 1}>
-
-
-
- {/* Show file name if PDF is loaded (only when not using multi-PDF selector) */}
-
-
- {props.fileName}
-
-
-
- {/* Delete button - only for local checklists */}
-
-
-
-
-
- {/* Page navigation */}
-
-
-
-
-
-
-
- {/* Zoom controls */}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/web/src/components/checklist/pdf/PdfViewer.jsx b/packages/web/src/components/checklist/pdf/PdfViewer.jsx
deleted file mode 100644
index 1e7076d87..000000000
--- a/packages/web/src/components/checklist/pdf/PdfViewer.jsx
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * PdfViewer - Component for viewing PDF files with continuous scrolling
- * Supports zoom, page navigation via scroll, file upload, and persistent PDF data
- *
- * This is the main component that composes:
- * - usePdfJs: Primitive for PDF.js state management
- * - PdfToolbar: File controls, navigation, and zoom
- * - PdfCanvas: PDF page rendering (now renders all pages)
- * - PdfEmptyState: Loading, error, and empty states
- */
-
-import { Show, For } from 'solid-js';
-import usePdfJs from './usePdfJs.js';
-import PdfToolbar from './PdfToolbar.jsx';
-import PdfEmptyState from './PdfEmptyState.jsx';
-
-/**
- * PdfViewer - Component for viewing PDF files with continuous scrolling
- * @param {Object} props - Component props
- * @param {ArrayBuffer} props.pdfData - ArrayBuffer of saved PDF data (optional)
- * @param {string} props.pdfFileName - Name of the saved PDF file (optional)
- * @param {Function} props.onPdfChange - Callback when PDF changes: (data: ArrayBuffer, fileName: string) => void
- * @param {Function} props.onPdfClear - Callback when PDF is cleared: () => void
- * @param {boolean} props.readOnly - If true, hides upload/change/clear buttons (view only mode)
- * @param {boolean} props.allowDelete - If true, shows delete button (only applies when !readOnly)
- * @param {Array} props.pdfs - Array of PDFs for multi-PDF selection
- * @param {string} props.selectedPdfId - Currently selected PDF ID
- * @param {Function} props.onPdfSelect - Handler for PDF selection change
- * @returns {JSX.Element}
- */
-export default function PdfViewer(props) {
- // Use the PDF.js primitive
- const pdf = usePdfJs({
- pdfData: () => props.pdfData,
- pdfFileName: () => props.pdfFileName,
- onPdfChange: (data, name) => props.onPdfChange?.(data, name),
- onPdfClear: () => props.onPdfClear?.(),
- });
-
- // Setup refs after mount
- const setContainerRef = el => {
- pdf.setupResizeObserver(el);
- };
-
- const setFileInputRef = el => {
- pdf.setFileInputRef(el);
- };
-
- return (
-
-
-
- {/* PDF Content - Scrollable container for all pages */}
-
-
-
- {/* PDF Pages - Render all pages in a continuous scroll */}
-
-
- {/* Use For with docId-based keys to force DOM recreation when PDF changes */}
- `${pdf.docId()}-${i + 1}`)}
- >
- {key => {
- const pageNum = parseInt(key.split('-')[1], 10);
- return (
-
pdf.setPageRef(pageNum, el)} class='relative'>
- {/* Page number label */}
-
- Page {pageNum} of {pdf.totalPages()}
-
-
-
- );
- }}
-
-
-
-
-
- );
-}
diff --git a/packages/web/src/components/checklist/pdf/index.js b/packages/web/src/components/checklist/pdf/index.js
deleted file mode 100644
index 1f00fdcff..000000000
--- a/packages/web/src/components/checklist/pdf/index.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * PDF Components - Barrel export
- */
-
-export { default as PdfViewer } from './PdfViewer.jsx';
-export { default as PdfToolbar } from './PdfToolbar.jsx';
-export { default as PdfEmptyState } from './PdfEmptyState.jsx';
-export { default as PdfList } from './PdfList.jsx';
-export { default as PdfListItem } from './PdfListItem.jsx';
-export { default as PdfTagBadge } from './PdfTagBadge.jsx';
-export { default as PdfTagSelect } from './PdfTagSelect.jsx';
-export { default as PdfSelector } from './PdfSelector.jsx';
-export { default as usePdfJs } from './usePdfJs.js';
diff --git a/packages/web/src/components/checklist/pdf/pdfDocument.js b/packages/web/src/components/checklist/pdf/pdfDocument.js
deleted file mode 100644
index ac4a7e9cb..000000000
--- a/packages/web/src/components/checklist/pdf/pdfDocument.js
+++ /dev/null
@@ -1,161 +0,0 @@
-/**
- * pdfDocument - PDF document loading and state management
- * Handles PDF.js library initialization, document loading/unloading, and document state
- */
-
-import { createSignal, createEffect, onMount } from 'solid-js';
-import { initPdfJs } from '@/lib/pdfUtils.js';
-
-// Local reference to pdfjsLib after initialization
-let pdfjsLib = null;
-
-/**
- * Creates PDF document management module
- * @returns {Object} Document state and operations
- */
-export function createPdfDocument() {
- const [pdfDoc, setPdfDoc] = createSignal(null);
- const [totalPages, setTotalPages] = createSignal(0);
- const [loading, setLoading] = createSignal(false);
- const [error, setError] = createSignal(null);
- const [pdfSource, setPdfSource] = createSignal(null);
- const [fileName, setFileName] = createSignal(null);
- const [libReady, setLibReady] = createSignal(false);
- const [docId, setDocId] = createSignal(0);
-
- let loadingSourceId = null;
- let userCleared = false;
-
- // Initialize PDF.js on mount
- onMount(async () => {
- try {
- pdfjsLib = await initPdfJs();
- setLibReady(true);
- } catch (err) {
- console.error('Failed to initialize PDF.js:', err);
- setError('Failed to initialize PDF viewer');
- }
- });
-
- // Store callback for before load
- let onBeforeLoadCallback = null;
-
- function setOnBeforeLoad(callback) {
- onBeforeLoadCallback = callback;
- }
-
- // Load PDF when source changes and library is ready
- createEffect(() => {
- const source = pdfSource();
- const ready = libReady();
- if (source && ready) {
- loadPdf(source, onBeforeLoadCallback);
- }
- });
-
- async function loadPdf(source, onBeforeLoad = null) {
- if (!pdfjsLib) return;
-
- // Generate a unique ID for this source to prevent duplicate loads
- const sourceId = source.data ? source.data.byteLength : JSON.stringify(source);
-
- // Skip if we're already loading this exact source
- if (loadingSourceId === sourceId) {
- return;
- }
- loadingSourceId = sourceId;
-
- setLoading(true);
- setError(null);
-
- // Destroy old PDF document to release resources
- const oldDoc = pdfDoc();
- if (oldDoc) {
- try {
- await oldDoc.destroy();
- } catch {
- // Ignore destroy errors
- }
- }
-
- // Call callback before loading to allow clearing canvases
- if (onBeforeLoad) {
- onBeforeLoad();
- }
-
- try {
- // Clone the ArrayBuffer before passing to PDF.js since it transfers ownership
- // to the web worker, which detaches the original buffer
- let loadSource = source;
- if (source.data && source.data instanceof ArrayBuffer && source.data.byteLength > 0) {
- loadSource = { ...source, data: source.data.slice(0) };
- }
-
- // verbosity: 0 = ERRORS only (suppress warnings about malformed PDFs)
- const loadingTask = pdfjsLib.getDocument({ ...loadSource, verbosity: 0 });
- const pdf = await loadingTask.promise;
-
- // Increment docId to force DOM recreation of canvas elements
- setDocId(id => id + 1);
-
- setPdfDoc(pdf);
- setTotalPages(pdf.numPages);
- } catch (err) {
- console.error('Error loading PDF:', err);
- setError('Failed to load PDF. Please try another file.');
- setPdfDoc(null);
- } finally {
- setLoading(false);
- loadingSourceId = null;
- }
- }
-
- function clearPdf() {
- // Destroy the PDF document to release resources
- const doc = pdfDoc();
- if (doc) {
- doc.destroy().catch(() => {
- // Ignore destroy errors
- });
- }
-
- // Mark as user-cleared to prevent auto-reload from props
- userCleared = true;
-
- setPdfDoc(null);
- setPdfSource(null);
- setFileName(null);
- setTotalPages(0);
- setError(null);
- }
-
- function setPdfSourceAndName(source, name) {
- userCleared = false;
- setFileName(name);
- setPdfSource(source);
- }
-
- return {
- // State
- pdfDoc,
- totalPages,
- loading,
- error,
- fileName,
- libReady,
- docId,
- pdfSource,
- userCleared: () => userCleared,
-
- // Actions
- loadPdf,
- clearPdf,
- setPdfSourceAndName,
- setFileName,
- setError,
- setOnBeforeLoad,
-
- // Library access
- getPdfJsLib: () => pdfjsLib,
- };
-}
diff --git a/packages/web/src/components/checklist/pdf/pdfFileHandler.js b/packages/web/src/components/checklist/pdf/pdfFileHandler.js
deleted file mode 100644
index 9c12f7ffd..000000000
--- a/packages/web/src/components/checklist/pdf/pdfFileHandler.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * pdfFileHandler - File upload and PDF source management
- * Handles file input, file upload logic, PDF clearing, and blob URL management
- */
-
-/**
- * Creates PDF file handling module
- * @param {Object} document - PDF document module
- * @param {Object} options - Options with callbacks
- * @returns {Object} File handling operations
- */
-export function createPdfFileHandler(document, options = {}) {
- let blobUrl = null;
- let fileInputRef = null;
-
- async function handleFile(file) {
- if (!file) return;
-
- if (file.type !== 'application/pdf') {
- document.setError('Please select a PDF file');
- return;
- }
-
- if (blobUrl) {
- URL.revokeObjectURL(blobUrl);
- blobUrl = null;
- }
-
- try {
- const arrayBuffer = await file.arrayBuffer();
- document.setFileName(file.name);
- // Clone the buffer for internal use since PDF.js will detach it
- document.setPdfSourceAndName({ data: arrayBuffer.slice(0) }, file.name);
-
- if (options.onPdfChange) {
- // Clone again for the callback so parent has a usable copy
- options.onPdfChange(arrayBuffer.slice(0), file.name);
- }
- } catch (err) {
- console.error('Error reading PDF file:', err);
- document.setError('Failed to read PDF file');
- }
- }
-
- async function handleFileUpload(event) {
- const file = event.target.files[0];
- await handleFile(file);
- event.target.value = '';
- }
-
- function clearPdf() {
- if (blobUrl) {
- URL.revokeObjectURL(blobUrl);
- blobUrl = null;
- }
-
- document.clearPdf();
-
- if (options.onPdfClear) {
- options.onPdfClear();
- }
- }
-
- function openFilePicker() {
- fileInputRef?.click();
- }
-
- function setFileInputRef(ref) {
- fileInputRef = ref;
- }
-
- return {
- handleFile,
- handleFileUpload,
- clearPdf,
- openFilePicker,
- setFileInputRef,
- };
-}
diff --git a/packages/web/src/components/checklist/pdf/pdfRenderer.js b/packages/web/src/components/checklist/pdf/pdfRenderer.js
deleted file mode 100644
index f3c6b5c10..000000000
--- a/packages/web/src/components/checklist/pdf/pdfRenderer.js
+++ /dev/null
@@ -1,489 +0,0 @@
-/**
- * pdfRenderer - Page rendering logic, render queue, and canvas management
- * Handles page rendering, render task management, canvas/text layer refs, and scale changes
- */
-
-import { createSignal, createEffect } from 'solid-js';
-import { createTextSelectionLayer } from './pdfTextSelection.js';
-
-/**
- * Creates PDF rendering module
- * @param {Object} document - PDF document module
- * @param {Object} scrollHandler - Scroll handler module
- * @returns {Object} Rendering operations
- */
-export function createPdfRenderer(document, scrollHandler) {
- const [rendering, setRendering] = createSignal(false);
- const [pageCanvases, setPageCanvases] = createSignal([]);
- const [pageTextLayers, setPageTextLayers] = createSignal([]);
-
- let currentRenderTasks = new Map();
- let renderingPages = new Set();
- let pendingRenders = new Map();
- let renderedPages = new Set();
- let renderedTextLayers = new Set();
- let textSelectionLayers = new Map();
- let containerRef = null;
- let resizeObserver = null;
- let resizeRafId = null;
- let prevScale = null;
-
- const RENDER_CONCURRENCY = 3; // Limit concurrent renders to avoid overwhelming GPU
- const CANVAS_CLEAR_DELAY = 5000; // Clear canvas 5 seconds after page leaves viewport
- const canvasClearTimeouts = new Map(); // Track timeouts for clearing canvases
-
- // Setup resize observer
- // Note: We don't re-render on resize to avoid white flashes.
- // The canvas maintains its pixel dimensions and scales with CSS.
- // Re-rendering only happens when scale explicitly changes (zoom) or PDF changes.
- function setupResizeObserver(container) {
- if (resizeObserver) {
- resizeObserver.disconnect();
- }
-
- containerRef = container;
-
- // ResizeObserver is kept for potential future use (e.g., fit-to-width on resize)
- // But we don't trigger re-renders here to prevent white flashes
- resizeObserver = new ResizeObserver(() => {
- // No-op: We don't re-render on resize to prevent white flashes
- // The canvas will scale with CSS transforms
- });
-
- if (containerRef) {
- resizeObserver.observe(containerRef);
- }
- }
-
- // Re-render visible pages when scale changes
- createEffect(() => {
- const doc = document.pdfDoc();
- const currentScale = scrollHandler.scale();
- if (doc && prevScale !== null && prevScale !== currentScale) {
- // Clear rendered tracking for visible pages
- const visiblePages =
- scrollHandler.getVisiblePages ? scrollHandler.getVisiblePages() : new Set();
- if (visiblePages.size > 0) {
- visiblePages.forEach(pageNum => {
- renderedPages.delete(pageNum);
- renderedTextLayers.delete(pageNum);
- });
- } else {
- renderedPages.clear();
- renderedTextLayers.clear();
- // Clean up all text selection layers
- textSelectionLayers.forEach(layer => layer.cleanup());
- textSelectionLayers.clear();
- }
- renderAllPages();
- }
- prevScale = currentScale;
- });
-
- // Render newly mounted canvases - only render visible pages
- createEffect(() => {
- const doc = document.pdfDoc();
- const canvases = pageCanvases();
- const currentScale = scrollHandler.scale();
- // Track docId to re-run this effect when PDF changes
- document.docId();
-
- if (!doc) {
- renderedPages.clear();
- return;
- }
-
- // Get visible pages from scroll handler
- const visiblePages =
- scrollHandler.getVisiblePages ? scrollHandler.getVisiblePages() : new Set();
-
- // Find canvases that exist but haven't been rendered
- // Only render if page is visible or if no visibility tracking is available (fallback)
- const pagesToRender = [];
- for (let i = 0; i < canvases.length; i++) {
- const pageNum = i + 1;
- if (canvases[i] && !renderedPages.has(pageNum)) {
- // Only render visible pages, or all pages if visibility tracking not available
- if (visiblePages.size === 0 || visiblePages.has(pageNum)) {
- pagesToRender.push(pageNum);
- }
- }
- }
-
- if (pagesToRender.length > 0) {
- // Mark pages as being rendered to avoid duplicate renders
- pagesToRender.forEach(p => renderedPages.add(p));
-
- // Capture scale for async callback
- const capturedScale = currentScale;
-
- // Use queueMicrotask to ensure DOM is fully updated before rendering
- // Render with priority: visible pages first
- queueMicrotask(() => renderWithPriority(pagesToRender, capturedScale));
- }
- });
-
- // Schedule a page render (called by IntersectionObserver)
- function schedulePageRender(pageNum) {
- const doc = document.pdfDoc();
- const canvases = pageCanvases();
- const canvas = canvases[pageNum - 1];
-
- if (!doc || !canvas) return;
-
- // Cancel any pending canvas clear timeout
- if (canvasClearTimeouts.has(pageNum)) {
- clearTimeout(canvasClearTimeouts.get(pageNum));
- canvasClearTimeouts.delete(pageNum);
- }
-
- // Only render if not already rendered or if canvas was cleared
- if (!renderedPages.has(pageNum) || canvas.width === 0) {
- const currentScale = scrollHandler.scale();
- renderedPages.add(pageNum);
- queueMicrotask(() => renderPage(pageNum, currentScale));
- }
- }
-
- // Cancel a page render (called by IntersectionObserver)
- function cancelPageRender(pageNum) {
- if (currentRenderTasks.has(pageNum)) {
- try {
- const task = currentRenderTasks.get(pageNum);
- task.cancel();
- currentRenderTasks.delete(pageNum);
- } catch {
- // Ignore cancel errors
- }
- }
- renderingPages.delete(pageNum);
- pendingRenders.delete(pageNum);
- renderedPages.delete(pageNum);
- renderedTextLayers.delete(pageNum);
-
- // Clean up text selection layer
- if (textSelectionLayers.has(pageNum)) {
- const layer = textSelectionLayers.get(pageNum);
- layer.cleanup();
- textSelectionLayers.delete(pageNum);
- }
-
- // Schedule canvas clearing after delay
- if (canvasClearTimeouts.has(pageNum)) {
- clearTimeout(canvasClearTimeouts.get(pageNum));
- }
-
- const timeoutId = setTimeout(() => {
- clearPageCanvas(pageNum);
- canvasClearTimeouts.delete(pageNum);
- }, CANVAS_CLEAR_DELAY);
-
- canvasClearTimeouts.set(pageNum, timeoutId);
- }
-
- // Clear a single page canvas
- function clearPageCanvas(pageNum) {
- const canvases = pageCanvases();
- const canvas = canvases[pageNum - 1];
- if (canvas) {
- const context = canvas.getContext('2d');
- context.clearRect(0, 0, canvas.width, canvas.height);
- canvas.width = 0;
- canvas.height = 0;
- }
- }
-
- // Render pages in parallel with priority queue
- async function renderWithPriority(pages, scaleValue) {
- if (pages.length === 0) return;
-
- // Get visible pages and current page for prioritization
- const visiblePages =
- scrollHandler.getVisiblePages ? scrollHandler.getVisiblePages() : new Set();
- const currentPageNum = scrollHandler.currentPage ? scrollHandler.currentPage() : 1;
-
- // Sort by priority: visible first, then by distance from current page
- const prioritized = pages.sort((a, b) => {
- const aVisible = visiblePages.has(a);
- const bVisible = visiblePages.has(b);
- if (aVisible !== bVisible) return aVisible ? -1 : 1;
- return Math.abs(a - currentPageNum) - Math.abs(b - currentPageNum);
- });
-
- // Process in batches to limit concurrency
- for (let i = 0; i < prioritized.length; i += RENDER_CONCURRENCY) {
- const batch = prioritized.slice(i, i + RENDER_CONCURRENCY);
- await Promise.allSettled(batch.map(pageNum => renderPage(pageNum, scaleValue)));
- }
- }
-
- async function renderAllPages() {
- const doc = document.pdfDoc();
- if (!doc) return;
-
- setRendering(true);
-
- try {
- const numPages = doc.numPages;
- const currentScale = scrollHandler.scale();
-
- // Get visible pages - only re-render visible pages on resize
- const visiblePages =
- scrollHandler.getVisiblePages ? scrollHandler.getVisiblePages() : new Set();
-
- // Clear rendered tracking for visible pages only
- if (visiblePages.size > 0) {
- visiblePages.forEach(pageNum => renderedPages.delete(pageNum));
- } else {
- // Fallback: clear all if visibility tracking not available
- renderedPages.clear();
- }
-
- // Render pages - prioritize visible pages
- const pagesToRender = [];
- const canvases = pageCanvases();
-
- for (let pageNum = 1; pageNum <= numPages; pageNum++) {
- if (canvases[pageNum - 1]) {
- // Only render visible pages, or all if visibility tracking not available
- if (visiblePages.size === 0 || visiblePages.has(pageNum)) {
- pagesToRender.push(pageNum);
- renderedPages.add(pageNum);
- }
- }
- }
-
- // Render pages in parallel with priority queue
- await renderWithPriority(pagesToRender, currentScale);
- } finally {
- setRendering(false);
- }
- }
-
- async function renderPage(pageNum, scaleValue) {
- const doc = document.pdfDoc();
- const canvases = pageCanvases();
- const canvas = canvases[pageNum - 1];
- const pdfjsLib = document.getPdfJsLib();
-
- if (!doc || !canvas || !pdfjsLib) return;
-
- // If this page is already rendering, queue the request
- if (renderingPages.has(pageNum)) {
- pendingRenders.set(pageNum, scaleValue);
- return;
- }
-
- // Mark page as rendering
- renderingPages.add(pageNum);
-
- // Cancel any existing render task for this page
- if (currentRenderTasks.has(pageNum)) {
- try {
- const task = currentRenderTasks.get(pageNum);
- task.cancel();
- await task.promise.catch(() => {});
- // Small delay to ensure PDF.js fully releases the canvas
- await new Promise(resolve => setTimeout(resolve, 10));
- } catch {
- // Ignore cancel errors
- }
- currentRenderTasks.delete(pageNum);
- }
-
- try {
- const page = await doc.getPage(pageNum);
- const viewport = page.getViewport({ scale: scaleValue });
-
- const context = canvas.getContext('2d');
-
- // Account for device pixel ratio for sharp rendering on high-DPI displays
- const dpr = window.devicePixelRatio || 1;
- const newWidth = viewport.width * dpr;
- const newHeight = viewport.height * dpr;
-
- // Only update canvas dimensions if they actually changed
- // This prevents unnecessary clearing during resize
- const dimensionsChanged = canvas.width !== newWidth || canvas.height !== newHeight;
-
- if (dimensionsChanged) {
- canvas.width = newWidth;
- canvas.height = newHeight;
- }
-
- canvas.style.width = `${viewport.width}px`;
- canvas.style.height = `${viewport.height}px`;
-
- if (dimensionsChanged) {
- context.scale(dpr, dpr);
- context.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
- } else {
- // If dimensions haven't changed, we can skip clearing and just re-render
- // This prevents white flash during scale changes when size is the same
- context.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
- }
-
- const renderContext = {
- canvasContext: context,
- viewport: viewport,
- };
-
- const renderTask = page.render(renderContext);
- currentRenderTasks.set(pageNum, renderTask);
- await renderTask.promise;
- currentRenderTasks.delete(pageNum);
-
- // If we preserved old content during resize, it's now been overwritten by the new render
- // No need to do anything - the new render is complete
-
- // Create custom text selection layer - works reliably at any zoom level
- const textLayers = pageTextLayers();
- const textLayerDiv = textLayers[pageNum - 1];
- if (textLayerDiv) {
- try {
- // Clean up existing selection layer if it exists (e.g., on zoom/viewport change)
- // This ensures selections are cleared when viewport changes rather than becoming misaligned
- if (textSelectionLayers.has(pageNum)) {
- const oldLayer = textSelectionLayers.get(pageNum);
- oldLayer.cleanup();
- textSelectionLayers.delete(pageNum);
- }
-
- // Create new text selection layer with current viewport
- const selectionLayer = createTextSelectionLayer(textLayerDiv, page, viewport);
- textSelectionLayers.set(pageNum, selectionLayer);
- renderedTextLayers.add(pageNum);
- } catch (err) {
- // Ignore errors if page is no longer visible or rendering was cancelled
- if (err.name !== 'RenderingCancelledException') {
- console.error('Error creating text selection layer:', pageNum, err);
- }
- }
- }
- } catch (err) {
- if (err.name !== 'RenderingCancelledException') {
- console.error('Error rendering page:', pageNum, err);
- }
- } finally {
- renderingPages.delete(pageNum);
-
- // Check if there's a pending render for this page
- if (pendingRenders.has(pageNum)) {
- const pendingScale = pendingRenders.get(pageNum);
- pendingRenders.delete(pageNum);
- // Schedule the pending render
- renderPage(pageNum, pendingScale);
- }
- }
- }
-
- // Set canvas ref for a specific page
- function setPageCanvasRef(pageNum, ref) {
- setPageCanvases(prev => {
- const newCanvases = [...prev];
- newCanvases[pageNum - 1] = ref;
- return newCanvases;
- });
- }
-
- // Set text layer ref for a specific page
- function setPageTextLayerRef(pageNum, ref) {
- setPageTextLayers(prev => {
- const newLayers = [...prev];
- newLayers[pageNum - 1] = ref;
- return newLayers;
- });
- }
-
- function clearRenderedTracking() {
- renderedPages.clear();
- renderedTextLayers.clear();
- }
-
- function cancelAllRenders() {
- currentRenderTasks.forEach(task => {
- try {
- task.cancel();
- } catch {
- // Ignore
- }
- });
- currentRenderTasks.clear();
- renderingPages.clear();
- pendingRenders.clear();
- }
-
- function clearAllCanvases() {
- pageCanvases().forEach(canvas => {
- if (canvas) {
- const context = canvas.getContext('2d');
- context.clearRect(0, 0, canvas.width, canvas.height);
- canvas.width = 0;
- canvas.height = 0;
- }
- });
- // Clear text layers when clearing canvases
- const textLayers = pageTextLayers();
- textLayers.forEach(textLayerDiv => {
- if (textLayerDiv) {
- textLayerDiv.innerHTML = '';
- }
- });
- // Clean up all text selection layers
- textSelectionLayers.forEach(layer => layer.cleanup());
- textSelectionLayers.clear();
- renderedTextLayers.clear();
- }
-
- function initializeCanvasArrays(numPages) {
- setPageCanvases(Array(numPages).fill(null));
- setPageTextLayers(Array(numPages).fill(null));
- }
-
- function cleanup() {
- if (resizeRafId !== null) {
- cancelAnimationFrame(resizeRafId);
- resizeRafId = null;
- }
-
- // Clear all canvas clear timeouts
- canvasClearTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
- canvasClearTimeouts.clear();
-
- cancelAllRenders();
-
- if (resizeObserver) {
- resizeObserver.disconnect();
- }
-
- // Clean up all text selection layers
- textSelectionLayers.forEach(layer => layer.cleanup());
- textSelectionLayers.clear();
-
- clearAllCanvases();
- }
-
- return {
- // State
- rendering,
- pageCanvases,
-
- // Ref setters
- setPageCanvasRef,
- setPageTextLayerRef,
- setupResizeObserver,
-
- // Rendering
- renderPage,
- renderAllPages,
- schedulePageRender,
- cancelPageRender,
- clearRenderedTracking,
- cancelAllRenders,
- clearAllCanvases,
- initializeCanvasArrays,
-
- // Cleanup
- cleanup,
- };
-}
diff --git a/packages/web/src/components/checklist/pdf/pdfScrollHandler.js b/packages/web/src/components/checklist/pdf/pdfScrollHandler.js
deleted file mode 100644
index d5e8e8297..000000000
--- a/packages/web/src/components/checklist/pdf/pdfScrollHandler.js
+++ /dev/null
@@ -1,560 +0,0 @@
-/**
- * pdfScrollHandler - Scroll handling, page tracking, and IntersectionObserver
- * Handles scroll events, current page tracking, and lazy loading via IntersectionObserver
- */
-
-import { createSignal, createEffect } from 'solid-js';
-
-const RENDER_MARGIN = 2; // Render 2 pages ahead/behind viewport
-
-/**
- * Creates PDF scroll handling module
- * @param {Object} document - PDF document module
- * @returns {Object} Scroll handling operations
- */
-export function createPdfScrollHandler(document) {
- const [currentPage, setCurrentPage] = createSignal(1);
- const [scale, setScale] = createSignal(1.0);
-
- let scrollContainerRef = null;
- let pageRefs = new Map();
- let gestureStartScale = 1.0;
- let scrollRafId = null;
- let intersectionObserver = null;
- const visiblePages = new Set();
- let rendererCallbacks = null;
-
- // Zoom-to-point tracking
- let zoomOriginPoint = null; // { x, y } in container coordinates
- let zoomOriginScale = null; // Scale before zoom
- let lastMousePosition = null; // { x, y } for button-triggered zoom
- let _isWheelZooming = false; // Track if we're in an active wheel zoom gesture
- let scrollAdjustRafId = null; // RAF ID for scroll adjustment to prevent multiple queued adjustments
- let zoomAdjustToken = 0; // Token to coalesce rapid zoom adjustments (latest wins)
-
- // Handle scroll to update current page indicator
- function handleScroll() {
- if (!scrollContainerRef || pageRefs.size === 0) return;
-
- // Throttle using requestAnimationFrame
- if (scrollRafId !== null) return;
-
- scrollRafId = requestAnimationFrame(() => {
- scrollRafId = null;
-
- const containerRect = scrollContainerRef.getBoundingClientRect();
- const containerMiddle = containerRect.top + containerRect.height / 2;
-
- let closestPage = 1;
- let closestDistance = Infinity;
-
- pageRefs.forEach((pageEl, pageNum) => {
- if (pageEl) {
- const pageRect = pageEl.getBoundingClientRect();
- const pageMiddle = pageRect.top + pageRect.height / 2;
- const distance = Math.abs(pageMiddle - containerMiddle);
-
- if (distance < closestDistance) {
- closestDistance = distance;
- closestPage = pageNum;
- }
- }
- });
-
- if (closestPage !== currentPage()) {
- setCurrentPage(closestPage);
- }
- });
- }
-
- function handleGestureStart(e) {
- e.preventDefault();
- gestureStartScale = scale();
- }
-
- function handleGestureChange(e) {
- e.preventDefault();
- // e.scale is relative to gesture start (1.0 = no change)
- const newScale = Math.min(Math.max(gestureStartScale * e.scale, 0.5), 3.0);
- setScale(newScale);
- }
-
- function handleGestureEnd(e) {
- e.preventDefault();
- }
-
- // Track mouse position for button-triggered zoom
- function handleMouseMove(e) {
- if (!scrollContainerRef) return;
- const rect = scrollContainerRef.getBoundingClientRect();
- lastMousePosition = {
- x: e.clientX - rect.left,
- y: e.clientY - rect.top,
- };
- }
-
- // Handle pinch-to-zoom via wheel event with ctrlKey (trackpad gesture - Chrome/Firefox)
- function handleWheel(e) {
- // Only handle pinch-to-zoom (ctrlKey is set for trackpad pinch gestures)
- if (!e.ctrlKey) return;
-
- e.preventDefault();
-
- if (!scrollContainerRef) return;
-
- // Get cursor position relative to scroll container
- const rect = scrollContainerRef.getBoundingClientRect();
- const cursorPoint = {
- x: e.clientX - rect.left,
- y: e.clientY - rect.top,
- };
-
- // Mark that we're in a wheel zoom gesture
- _isWheelZooming = true;
-
- // Use deltaY directly for smooth, proportional zooming
- // Smaller divisor = more sensitive, larger = less sensitive
- const zoomDelta = -e.deltaY * 0.01;
- const oldScale = scale();
- const newScale = Math.min(Math.max(oldScale + zoomDelta, 0.5), 3.0);
-
- if (newScale === oldScale) return;
-
- // Store zoom origin before changing scale (same as button zoom)
- zoomOriginPoint = cursorPoint;
- zoomOriginScale = oldScale;
- zoomAdjustToken += 1; // Increment token to invalidate any pending adjustments
-
- // Update scale - the createEffect will handle scroll adjustment
- setScale(newScale);
-
- // Clear wheel zoom flag after a short delay (when gesture ends)
- clearTimeout(handleWheel.zoomEndTimeout);
- handleWheel.zoomEndTimeout = setTimeout(() => {
- _isWheelZooming = false;
- }, 150);
- }
-
- // Set scroll container ref and setup scroll listener
- function setScrollContainerRef(ref) {
- if (scrollContainerRef) {
- scrollContainerRef.removeEventListener('scroll', handleScroll);
- scrollContainerRef.removeEventListener('wheel', handleWheel);
- scrollContainerRef.removeEventListener('mousemove', handleMouseMove);
- scrollContainerRef.removeEventListener('gesturestart', handleGestureStart);
- scrollContainerRef.removeEventListener('gesturechange', handleGestureChange);
- scrollContainerRef.removeEventListener('gestureend', handleGestureEnd);
- }
-
- scrollContainerRef = ref;
- if (ref) {
- ref.addEventListener('scroll', handleScroll, { passive: true });
- ref.addEventListener('wheel', handleWheel, { passive: false });
- ref.addEventListener('mousemove', handleMouseMove, { passive: true });
- // Safari-specific gesture events for pinch-to-zoom
- ref.addEventListener('gesturestart', handleGestureStart);
- ref.addEventListener('gesturechange', handleGestureChange);
- ref.addEventListener('gestureend', handleGestureEnd);
-
- // Setup IntersectionObserver after container is set
- setupIntersectionObserver();
- }
- }
-
- // Register a page container ref
- function setPageRef(pageNum, ref) {
- if (ref) {
- pageRefs.set(pageNum, ref);
- // Set data attribute for IntersectionObserver
- ref.dataset.pageNum = pageNum;
- // Observe the page element if observer is set up
- if (intersectionObserver) {
- intersectionObserver.observe(ref);
- }
- } else {
- pageRefs.delete(pageNum);
- // Unobserve if observer exists
- if (intersectionObserver && ref) {
- intersectionObserver.unobserve(ref);
- }
- }
- }
-
- // Setup IntersectionObserver for lazy loading
- function setupIntersectionObserver() {
- if (intersectionObserver || !scrollContainerRef) return;
-
- intersectionObserver = new IntersectionObserver(
- entries => {
- entries.forEach(entry => {
- const pageNum = parseInt(entry.target.dataset.pageNum, 10);
- if (isNaN(pageNum)) return;
-
- if (entry.isIntersecting) {
- visiblePages.add(pageNum);
- // Schedule render if renderer callbacks are available
- if (rendererCallbacks && rendererCallbacks.schedulePageRender) {
- rendererCallbacks.schedulePageRender(pageNum);
- }
- } else {
- visiblePages.delete(pageNum);
- // Cancel render if renderer callbacks are available
- if (rendererCallbacks && rendererCallbacks.cancelPageRender) {
- rendererCallbacks.cancelPageRender(pageNum);
- }
- }
- });
- },
- {
- root: scrollContainerRef,
- rootMargin: `${RENDER_MARGIN * 100}% 0px`, // Render margin pages
- threshold: 0.01,
- },
- );
-
- // Observe all existing page refs
- pageRefs.forEach((pageEl, pageNum) => {
- if (pageEl) {
- pageEl.dataset.pageNum = pageNum;
- intersectionObserver.observe(pageEl);
- }
- });
- }
-
- function getVisiblePages() {
- return visiblePages;
- }
-
- function isPageVisible(pageNum) {
- return visiblePages.has(pageNum);
- }
-
- // Navigation - scroll to page
- function goToPage(pageNum) {
- const pageEl = pageRefs.get(pageNum);
- if (pageEl && scrollContainerRef) {
- pageEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
- setCurrentPage(pageNum);
- }
- }
-
- function goToPrevPage() {
- if (currentPage() > 1) {
- goToPage(currentPage() - 1);
- }
- }
-
- function goToNextPage() {
- if (currentPage() < document.totalPages()) {
- goToPage(currentPage() + 1);
- }
- }
-
- // Zoom to a specific point, preserving that point's position on screen
- function zoomToPoint(scaleDelta, point = null) {
- if (!scrollContainerRef) return;
-
- const oldScale = scale();
- let zoomPoint = point;
-
- // If no point provided, use viewport center or last mouse position
- if (!zoomPoint) {
- if (lastMousePosition) {
- zoomPoint = lastMousePosition;
- } else {
- const rect = scrollContainerRef.getBoundingClientRect();
- zoomPoint = {
- x: rect.width / 2,
- y: rect.height / 2,
- };
- }
- }
-
- // Store zoom origin before changing scale
- zoomOriginPoint = zoomPoint;
- zoomOriginScale = oldScale;
- zoomAdjustToken += 1; // Increment token to invalidate any pending adjustments
-
- // Calculate new scale
- const newScale = Math.min(Math.max(oldScale + scaleDelta, 0.5), 3.0);
- setScale(newScale);
- }
-
- // Adjust scroll position after scale change to maintain zoom origin
- // Works for both button-triggered zoom and pinch zoom
- createEffect(() => {
- const currentScale = scale();
-
- // Only adjust scroll if we have a stored zoom origin
- if (zoomOriginPoint === null || zoomOriginScale === null || !scrollContainerRef) {
- return;
- }
-
- // Skip if scale hasn't actually changed
- if (currentScale === zoomOriginScale) {
- return;
- }
-
- // Capture the current token to check if we're still the latest adjustment
- const currentToken = zoomAdjustToken;
- const pointX = zoomOriginPoint.x;
- const pointY = zoomOriginPoint.y;
-
- // Find which page contains the zoom point
- let targetPage = null;
- let targetPageNum = null;
- let pointOffsetFromPageTop = 0;
- let pointOffsetFromPageLeft = 0;
-
- // Calculate the point's position in the scroll container's coordinate space
- const pointInContainer = {
- x: scrollContainerRef.scrollLeft + pointX,
- y: scrollContainerRef.scrollTop + pointY,
- };
-
- // Find the page that contains this point
- pageRefs.forEach((pageEl, pageNum) => {
- if (!pageEl || targetPage) return;
-
- const pageTop = pageEl.offsetTop;
- const pageBottom = pageTop + pageEl.offsetHeight;
- const pageLeft = pageEl.offsetLeft;
- const pageRight = pageLeft + pageEl.offsetWidth;
-
- // Check if point is within this page's bounds (both vertical and horizontal)
- if (
- pointInContainer.y >= pageTop &&
- pointInContainer.y <= pageBottom &&
- pointInContainer.x >= pageLeft &&
- pointInContainer.x <= pageRight
- ) {
- targetPage = pageEl;
- targetPageNum = pageNum;
- pointOffsetFromPageTop = pointInContainer.y - pageTop;
- pointOffsetFromPageLeft = pointInContainer.x - pageLeft;
- }
- });
-
- // Prioritize rendering the target page if we found it
- if (targetPageNum !== null && rendererCallbacks && rendererCallbacks.schedulePageRender) {
- rendererCallbacks.schedulePageRender(targetPageNum);
- }
-
- // Cancel any pending scroll adjustment
- if (scrollAdjustRafId !== null) {
- cancelAnimationFrame(scrollAdjustRafId);
- scrollAdjustRafId = null;
- }
-
- if (!targetPage || targetPageNum === null) {
- // Fallback: use viewport center calculation
- const oldScrollHeight = scrollContainerRef.scrollHeight;
- const oldScrollWidth = scrollContainerRef.scrollWidth;
- const scrollRatio = oldScrollHeight > 0 ? scrollContainerRef.scrollTop / oldScrollHeight : 0;
- const scrollLeftRatio =
- oldScrollWidth > 0 ? scrollContainerRef.scrollLeft / oldScrollWidth : 0;
-
- // Poll for layout update with bounded timeout
- let pollStartTime = Date.now();
- const maxPollTime = 400; // Max 400ms to wait for layout
-
- function pollForLayoutUpdate() {
- // Check if we're still the latest adjustment
- if (currentToken !== zoomAdjustToken) {
- return; // Stale adjustment, abort
- }
-
- if (!scrollContainerRef) return;
-
- const newScrollHeight = scrollContainerRef.scrollHeight;
- const newScrollWidth = scrollContainerRef.scrollWidth;
- if (newScrollHeight > 0 && newScrollHeight !== oldScrollHeight) {
- // Layout has updated
- scrollContainerRef.scrollTop = scrollRatio * newScrollHeight;
-
- // Adjust horizontal scroll if container has horizontal overflow
- if (
- newScrollWidth > 0 &&
- newScrollWidth !== oldScrollWidth &&
- scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth
- ) {
- scrollContainerRef.scrollLeft = scrollLeftRatio * newScrollWidth;
- }
-
- zoomOriginPoint = null;
- zoomOriginScale = null;
- } else if (Date.now() - pollStartTime < maxPollTime) {
- // Keep polling
- scrollAdjustRafId = requestAnimationFrame(pollForLayoutUpdate);
- } else {
- // Timeout - apply adjustment anyway
- if (newScrollHeight > 0) {
- scrollContainerRef.scrollTop = scrollRatio * newScrollHeight;
- }
- if (
- newScrollWidth > 0 &&
- scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth
- ) {
- scrollContainerRef.scrollLeft = scrollLeftRatio * newScrollWidth;
- }
- zoomOriginPoint = null;
- zoomOriginScale = null;
- }
- }
-
- scrollAdjustRafId = requestAnimationFrame(pollForLayoutUpdate);
- return;
- }
-
- // Calculate the ratio of the point's position within the page (both vertical and horizontal)
- const oldPageHeight = targetPage.offsetHeight;
- const oldPageWidth = targetPage.offsetWidth;
- const pointRatio = oldPageHeight > 0 ? pointOffsetFromPageTop / oldPageHeight : 0;
- const pointRatioX = oldPageWidth > 0 ? pointOffsetFromPageLeft / oldPageWidth : 0;
-
- // Poll for page dimensions update with bounded timeout
- let pollStartTime = Date.now();
- const maxPollTime = 400; // Max 400ms to wait for layout
-
- function pollForPageHeightUpdate() {
- // Check if we're still the latest adjustment
- if (currentToken !== zoomAdjustToken) {
- return; // Stale adjustment, abort
- }
-
- if (!scrollContainerRef || !targetPage) return;
-
- const newPageHeight = targetPage.offsetHeight;
- const newPageWidth = targetPage.offsetWidth;
-
- if (newPageHeight !== oldPageHeight && newPageHeight > 0) {
- // Page dimensions have updated - apply scroll adjustment
- const newPointOffsetFromPageTop = pointRatio * newPageHeight;
- const newPageTop = targetPage.offsetTop;
- const newPointInContainerY = newPageTop + newPointOffsetFromPageTop;
-
- // Adjust vertical scroll to keep the point at the same screen position
- const newScrollTop = newPointInContainerY - pointY;
- scrollContainerRef.scrollTop = Math.max(0, newScrollTop);
-
- // Adjust horizontal scroll if container has horizontal overflow
- if (scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth) {
- const newPointOffsetFromPageLeft = pointRatioX * newPageWidth;
- const newPageLeft = targetPage.offsetLeft;
- const newPointInContainerX = newPageLeft + newPointOffsetFromPageLeft;
- const newScrollLeft = newPointInContainerX - pointX;
- scrollContainerRef.scrollLeft = Math.max(0, newScrollLeft);
- }
-
- // Clear zoom origin tracking
- zoomOriginPoint = null;
- zoomOriginScale = null;
- } else if (Date.now() - pollStartTime < maxPollTime) {
- // Keep polling
- scrollAdjustRafId = requestAnimationFrame(pollForPageHeightUpdate);
- } else {
- // Timeout - apply adjustment with current dimensions anyway
- if (newPageHeight > 0) {
- const newPointOffsetFromPageTop = pointRatio * newPageHeight;
- const newPageTop = targetPage.offsetTop;
- const newPointInContainerY = newPageTop + newPointOffsetFromPageTop;
- const newScrollTop = newPointInContainerY - pointY;
- scrollContainerRef.scrollTop = Math.max(0, newScrollTop);
-
- // Adjust horizontal scroll if container has horizontal overflow
- if (scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth && newPageWidth > 0) {
- const newPointOffsetFromPageLeft = pointRatioX * newPageWidth;
- const newPageLeft = targetPage.offsetLeft;
- const newPointInContainerX = newPageLeft + newPointOffsetFromPageLeft;
- const newScrollLeft = newPointInContainerX - pointX;
- scrollContainerRef.scrollLeft = Math.max(0, newScrollLeft);
- }
- }
- zoomOriginPoint = null;
- zoomOriginScale = null;
- }
- }
-
- scrollAdjustRafId = requestAnimationFrame(pollForPageHeightUpdate);
- });
-
- function cleanup() {
- if (scrollRafId !== null) {
- cancelAnimationFrame(scrollRafId);
- scrollRafId = null;
- }
-
- if (scrollAdjustRafId !== null) {
- cancelAnimationFrame(scrollAdjustRafId);
- scrollAdjustRafId = null;
- }
-
- if (handleWheel.zoomEndTimeout) {
- clearTimeout(handleWheel.zoomEndTimeout);
- handleWheel.zoomEndTimeout = null;
- }
-
- if (intersectionObserver) {
- intersectionObserver.disconnect();
- intersectionObserver = null;
- }
-
- if (scrollContainerRef) {
- scrollContainerRef.removeEventListener('scroll', handleScroll);
- scrollContainerRef.removeEventListener('wheel', handleWheel);
- scrollContainerRef.removeEventListener('mousemove', handleMouseMove);
- scrollContainerRef.removeEventListener('gesturestart', handleGestureStart);
- scrollContainerRef.removeEventListener('gesturechange', handleGestureChange);
- scrollContainerRef.removeEventListener('gestureend', handleGestureEnd);
- }
-
- pageRefs.clear();
- visiblePages.clear();
- }
-
- return {
- // State
- currentPage,
- scale,
- setScale,
- setCurrentPage,
-
- // Ref setters
- setScrollContainerRef,
- setPageRef,
- setupIntersectionObserver,
-
- // Visibility tracking
- getVisiblePages,
- isPageVisible,
- setRendererCallbacks: callbacks => {
- rendererCallbacks = callbacks;
- },
-
- // Navigation
- goToPage,
- goToPrevPage,
- goToNextPage,
-
- // Zoom controls
- zoomIn: () => zoomToPoint(0.25),
- zoomOut: () => zoomToPoint(-0.25),
- resetZoom: () => setScale(1.0),
- fitToWidth: () => {
- if (!scrollContainerRef || !document.pdfDoc()) return;
-
- document
- .pdfDoc()
- .getPage(1)
- .then(page => {
- const viewport = page.getViewport({ scale: 1.0 });
- const containerWidth = scrollContainerRef.clientWidth - 64; // Account for padding
- const newScale = containerWidth / viewport.width;
- setScale(Math.min(Math.max(newScale, 0.5), 3.0));
- });
- },
-
- // Cleanup
- cleanup,
- };
-}
diff --git a/packages/web/src/components/checklist/pdf/pdfTextSelection.js b/packages/web/src/components/checklist/pdf/pdfTextSelection.js
deleted file mode 100644
index 3a1b9a13d..000000000
--- a/packages/web/src/components/checklist/pdf/pdfTextSelection.js
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * pdfTextSelection - Native text selection implementation for PDF.js
- * Creates invisible text elements positioned over PDF content for native browser selection
- * Works reliably at any zoom level by using PDF coordinates and viewport transforms
- */
-
-/**
- * Creates a native text selection layer for a PDF page
- * @param {HTMLElement} container - Container element (positioned over canvas)
- * @param {Object} page - PDF.js page object
- * @param {Object} viewport - PDF.js viewport object
- * @returns {Object} Selection layer with cleanup function
- */
-export function createTextSelectionLayer(container, page, initialViewport) {
- let viewport = initialViewport;
- let textItems = null;
- let textLayerDiv = null;
-
- // Create text layer container
- textLayerDiv = document.createElement('div');
- textLayerDiv.style.position = 'absolute';
- textLayerDiv.style.top = '0';
- textLayerDiv.style.left = '0';
- textLayerDiv.style.width = `${viewport.width}px`;
- textLayerDiv.style.height = `${viewport.height}px`;
- textLayerDiv.style.overflow = 'hidden';
- textLayerDiv.style.pointerEvents = 'auto';
- textLayerDiv.style.userSelect = 'text';
- textLayerDiv.style.cursor = 'text';
- textLayerDiv.style.zIndex = '3';
- textLayerDiv.className = 'pdf-text-layer-native';
- container.appendChild(textLayerDiv);
-
- // Render text items as invisible, selectable spans
- async function renderTextItems() {
- if (!textLayerDiv) return;
-
- try {
- const textContent = await page.getTextContent();
-
- // Check again after async operation - cleanup may have run
- if (!textLayerDiv) return;
-
- textItems = textContent.items;
-
- // Clear existing content
- textLayerDiv.innerHTML = '';
-
- // Create spans for each text item
- for (const item of textItems) {
- if (!item.str || !item.transform) continue;
-
- const span = document.createElement('span');
- span.textContent = item.str;
-
- // Get viewport coordinates for this text item
- const pdfX = item.transform[4];
- const pdfY = item.transform[5];
- const itemWidth = item.width || 0;
- const itemHeight = item.height || 0;
-
- // Convert PDF coordinates to viewport coordinates
- // PDF origin is bottom-left, viewport origin is top-left
- const topLeft = viewport.convertToViewportPoint(pdfX, pdfY + itemHeight);
- const fontSize = itemHeight * viewport.scale;
-
- // Position the span exactly over the PDF text
- span.style.position = 'absolute';
- span.style.left = `${topLeft[0]}px`;
- span.style.top = `${topLeft[1]}px`;
- span.style.fontSize = `${fontSize}px`;
- span.style.lineHeight = '1';
- span.style.color = 'transparent';
- span.style.whiteSpace = 'pre';
- span.style.userSelect = 'text';
- span.style.pointerEvents = 'auto';
- span.style.fontFamily = item.fontName || 'sans-serif';
- span.style.transformOrigin = '0% 0%';
-
- // Apply text transform if needed (rotation, etc.)
- // PDF transform matrix: [a, b, c, d, e, f]
- // a, b, c, d form the rotation/scaling matrix
- const a = item.transform[0];
- const b = item.transform[1];
- const _c = item.transform[2];
- const _d = item.transform[3];
-
- // Calculate rotation angle from transform matrix
- const angle = Math.atan2(b, a) * (180 / Math.PI);
- if (Math.abs(angle) > 0.1) {
- span.style.transform = `rotate(${angle}deg)`;
- }
-
- // Set dimensions to match text item
- span.style.width = `${itemWidth * viewport.scale}px`;
- span.style.height = `${itemHeight * viewport.scale}px`;
- span.style.display = 'inline-block';
-
- // Check again before appending - cleanup may have run during loop
- if (!textLayerDiv) return;
- textLayerDiv.appendChild(span);
- }
- } catch (err) {
- console.error('Error rendering text items:', err);
- }
- }
-
- // Initial render
- renderTextItems();
-
- // Update viewport when scale changes
- function updateViewport(newViewport) {
- viewport = newViewport;
-
- if (textLayerDiv) {
- textLayerDiv.style.width = `${newViewport.width}px`;
- textLayerDiv.style.height = `${newViewport.height}px`;
-
- // Re-render all text items with new viewport
- renderTextItems();
- }
- }
-
- // Cleanup function
- function cleanup() {
- if (textLayerDiv) {
- textLayerDiv.remove();
- textLayerDiv = null;
- }
- textItems = null;
- }
-
- return {
- cleanup,
- updateViewport,
- getSelectedText: () => {
- // Get native browser selection
- const selection = window.getSelection();
- return selection.toString();
- },
- };
-}
diff --git a/packages/web/src/components/checklist/pdf/pdfVirtualization.js b/packages/web/src/components/checklist/pdf/pdfVirtualization.js
deleted file mode 100644
index 0df38d0b5..000000000
--- a/packages/web/src/components/checklist/pdf/pdfVirtualization.js
+++ /dev/null
@@ -1,271 +0,0 @@
-/**
- * pdfVirtualization - Page virtualization for large PDFs
- * Only renders DOM nodes for pages in/near the viewport to keep DOM/layout stable
- */
-
-import { createSignal, createEffect, onCleanup, untrack } from 'solid-js';
-
-const VIRTUAL_WINDOW_MARGIN = 3; // Render 3 pages above/below viewport
-const PAGE_GAP_PX = 32; // gap-8
-const PAGE_CHROME_PX = 28; // label + small vertical padding/shadow allowance
-
-/**
- * Creates PDF page virtualization module
- * @param {Object} document - PDF document module
- * @param {Object} scrollHandler - Scroll handler module
- * @returns {Object} Virtualization operations
- */
-export function createPdfVirtualization(document, scrollHandler) {
- const [visiblePageRange, setVisiblePageRange] = createSignal({ start: 1, end: 1 });
- const [basePageHeights, setBasePageHeights] = createSignal([]); // 1-indexed (index 1..n)
- const [defaultBaseHeight, setDefaultBaseHeight] = createSignal(800);
- const [prefixOffsets, setPrefixOffsets] = createSignal([]); // 1-indexed: top offset of each page
- const [totalHeight, setTotalHeight] = createSignal(0);
-
- let scrollContainerRef = null;
- let resizeObserver = null;
- let scrollListener = null;
- let pendingHeightBuild = null;
-
- function getBaseHeight(pageNum) {
- const base = basePageHeights();
- return base[pageNum] || defaultBaseHeight();
- }
-
- function getPageHeight(pageNum) {
- const s = scrollHandler.scale();
- return getBaseHeight(pageNum) * s + PAGE_CHROME_PX;
- }
-
- function rebuildPrefixOffsets({ preserveScroll = true } = {}) {
- const totalPages = document.totalPages();
- if (!scrollContainerRef || totalPages <= 0) {
- setPrefixOffsets([]);
- setTotalHeight(0);
- return;
- }
-
- // Preserve the center-of-container anchor so updating offsets doesn't jump the view.
- // Important: we must not accidentally track prefixOffsets() inside any createEffect that calls
- // rebuildPrefixOffsets(), otherwise setPrefixOffsets() will retrigger that effect and loop.
- let anchor = null;
- if (preserveScroll) {
- anchor = untrack(() => {
- const y = scrollContainerRef.scrollTop + scrollContainerRef.clientHeight / 2;
- const pageNum = getPageAtScrollOffset(y);
- const pageTop = getPageOffset(pageNum);
- const pageHeight = getPageHeight(pageNum);
- const ratio = pageHeight > 0 ? Math.min(1, Math.max(0, (y - pageTop) / pageHeight)) : 0.5;
- return { y, pageNum, ratio };
- });
- }
-
- const offsets = Array(totalPages + 2).fill(0);
- let running = 0;
- for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
- offsets[pageNum] = running;
- running += getPageHeight(pageNum);
- if (pageNum < totalPages) running += PAGE_GAP_PX;
- }
- offsets[totalPages + 1] = running;
-
- setPrefixOffsets(offsets);
- setTotalHeight(running);
-
- if (preserveScroll && anchor) {
- const newTop = getPageOffset(anchor.pageNum);
- const newHeight = getPageHeight(anchor.pageNum);
- const newCenterY = newTop + anchor.ratio * newHeight;
- scrollContainerRef.scrollTop = Math.max(0, newCenterY - scrollContainerRef.clientHeight / 2);
- }
- }
-
- // Binary search helpers (prefixOffsets must be built)
- function getPageOffset(pageNum) {
- const offsets = prefixOffsets();
- return offsets[pageNum] || 0;
- }
-
- function getPageAtScrollOffset(y) {
- const totalPages = document.totalPages();
- if (totalPages <= 0) return 1;
-
- const offsets = prefixOffsets();
- if (!offsets || offsets.length === 0) {
- // Fallback until offsets are built
- const estimated = defaultBaseHeight() * scrollHandler.scale() + PAGE_CHROME_PX + PAGE_GAP_PX;
- return Math.min(totalPages, Math.max(1, Math.floor(y / Math.max(1, estimated)) + 1));
- }
-
- let lo = 1;
- let hi = totalPages;
- while (lo <= hi) {
- const mid = (lo + hi) >> 1;
- const top = offsets[mid];
- const bottom = top + getPageHeight(mid);
- if (y < top) {
- hi = mid - 1;
- } else if (y > bottom) {
- lo = mid + 1;
- } else {
- return mid;
- }
- }
- return Math.min(totalPages, Math.max(1, lo));
- }
-
- // Calculate which pages should be rendered based on scroll position
- function calculateVisibleRange() {
- if (!scrollContainerRef) return { start: 1, end: 1 };
-
- const container = scrollContainerRef;
- const scrollTop = container.scrollTop;
- const containerHeight = container.clientHeight;
- const viewportTop = scrollTop;
- const viewportBottom = scrollTop + containerHeight;
-
- const totalPages = document.totalPages();
- if (totalPages === 0) return { start: 1, end: 1 };
-
- const firstVisible = getPageAtScrollOffset(viewportTop);
- const lastVisible = getPageAtScrollOffset(viewportBottom);
- const start = Math.max(1, firstVisible - VIRTUAL_WINDOW_MARGIN);
- const end = Math.min(totalPages, lastVisible + VIRTUAL_WINDOW_MARGIN);
- return { start, end };
- }
-
- // Update visible range on scroll
- function handleScroll() {
- const newRange = calculateVisibleRange();
- const current = visiblePageRange();
- if (newRange.start !== current.start || newRange.end !== current.end) {
- setVisiblePageRange(newRange);
- }
- }
-
- // Set scroll container and setup listeners
- function setScrollContainer(ref) {
- if (scrollContainerRef) {
- if (scrollListener) {
- scrollContainerRef.removeEventListener('scroll', scrollListener);
- }
- if (resizeObserver) {
- resizeObserver.disconnect();
- }
- }
-
- scrollContainerRef = ref;
-
- if (ref) {
- scrollListener = () => {
- requestAnimationFrame(handleScroll);
- };
- ref.addEventListener('scroll', scrollListener, { passive: true });
-
- resizeObserver = new ResizeObserver(() => {
- rebuildPrefixOffsets({ preserveScroll: true });
- handleScroll();
- });
- resizeObserver.observe(ref);
-
- // Initial calculation
- handleScroll();
- }
- }
-
- // Register a page element (kept for API compatibility; deterministic model doesn't need DOM measurement)
- function registerPage(pageNum, element) {
- void pageNum;
- void element;
- }
-
- // Update visible range when PDF changes
- createEffect(() => {
- const totalPages = document.totalPages();
- const doc = document.pdfDoc();
- const docId = document.docId();
- if (totalPages > 0) {
- // Reset cached page sizes when PDF changes
- setBasePageHeights([]);
- setPrefixOffsets([]);
- setTotalHeight(0);
-
- if (!doc) return;
-
- // Cancel any previous build loop
- if (pendingHeightBuild) {
- pendingHeightBuild.cancelled = true;
- }
- const token = { cancelled: false, docId };
- pendingHeightBuild = token;
-
- // Compute all base heights once, then apply with scroll preservation.
- (async () => {
- try {
- const next = Array(totalPages + 2).fill(0);
-
- // Seed default from page 1 immediately (helps initial offsets).
- const page1 = await doc.getPage(1);
- const viewport1 = page1.getViewport({ scale: 1.0 });
- const base1 = Math.max(200, viewport1.height);
- if (token.cancelled) return;
- setDefaultBaseHeight(base1);
-
- // Build all page base heights.
- next[1] = base1;
- for (let pageNum = 2; pageNum <= totalPages; pageNum++) {
- const page = await doc.getPage(pageNum);
- const viewport = page.getViewport({ scale: 1.0 });
- next[pageNum] = Math.max(50, viewport.height);
- if (token.cancelled) return;
- }
-
- // Apply in one shot to avoid repeated offset churn.
- setBasePageHeights(next);
- rebuildPrefixOffsets({ preserveScroll: true });
- handleScroll();
- } catch {
- // Fall back to default height estimate if precompute fails.
- rebuildPrefixOffsets({ preserveScroll: true });
- handleScroll();
- }
- })();
- }
- });
-
- // Rebuild offsets when scale changes
- createEffect(() => {
- const currentScale = scrollHandler.scale();
- void currentScale;
- if (!scrollContainerRef) return;
- if (document.totalPages() <= 0) return;
- rebuildPrefixOffsets({ preserveScroll: true });
- handleScroll();
- });
-
- // Cleanup
- function cleanup() {
- if (scrollListener && scrollContainerRef) {
- scrollContainerRef.removeEventListener('scroll', scrollListener);
- }
- if (resizeObserver) {
- resizeObserver.disconnect();
- }
- if (pendingHeightBuild) {
- pendingHeightBuild.cancelled = true;
- }
- }
-
- onCleanup(cleanup);
-
- return {
- visiblePageRange,
- totalHeight,
- setScrollContainer,
- registerPage,
- getPageOffset,
- getPageHeight,
- getPageAtScrollOffset,
- cleanup,
- };
-}
diff --git a/packages/web/src/components/checklist/pdf/usePdfJs.js b/packages/web/src/components/checklist/pdf/usePdfJs.js
deleted file mode 100644
index eae57bec4..000000000
--- a/packages/web/src/components/checklist/pdf/usePdfJs.js
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * usePdfJs - Main hook that composes PDF modules
- * Coordinates between pdfDocument, pdfRenderer, pdfScrollHandler, and pdfFileHandler
- */
-
-import { createSignal, createEffect, onCleanup } from 'solid-js';
-import { createPdfDocument } from './pdfDocument.js';
-import { createPdfRenderer } from './pdfRenderer.js';
-import { createPdfScrollHandler } from './pdfScrollHandler.js';
-import { createPdfFileHandler } from './pdfFileHandler.js';
-
-/**
- * Hook for managing PDF.js document state with continuous scrolling support
- * @param {Object} options
- * @param {() => ArrayBuffer|null} options.pdfData - Accessor for PDF data from props
- * @param {() => string|null} options.pdfFileName - Accessor for PDF file name from props
- * @param {(data: ArrayBuffer, fileName: string) => void} options.onPdfChange - Callback when PDF changes
- * @param {() => void} options.onPdfClear - Callback when PDF is cleared
- */
-export default function usePdfJs(options = {}) {
- // Create modules
- const document = createPdfDocument();
- const scrollHandler = createPdfScrollHandler(document);
- const renderer = createPdfRenderer(document, scrollHandler);
- const fileHandler = createPdfFileHandler(document, {
- onPdfChange: options.onPdfChange,
- onPdfClear: options.onPdfClear,
- });
-
- // Connect renderer to scroll handler for IntersectionObserver callbacks
- scrollHandler.setRendererCallbacks({
- schedulePageRender: pageNum => renderer.schedulePageRender(pageNum),
- cancelPageRender: pageNum => renderer.cancelPageRender(pageNum),
- });
-
- // Setup callback to clear canvases before loading new PDF
- document.setOnBeforeLoad(() => {
- renderer.clearAllCanvases();
- renderer.cancelAllRenders();
- });
-
- // Track currently loaded filename to detect when props change to a different PDF
- const [loadedPdfName, setLoadedPdfName] = createSignal(null);
-
- // Load saved PDF data when provided via props
- // NOTE: We intentionally do NOT clear state when props become null.
- // This allows the old PDF to stay visible during transitions while the new one loads.
- // Clearing is only done via explicit clearPdf() call (e.g., for local checklist deletion).
- createEffect(() => {
- const ready = document.libReady();
- const savedData = options.pdfData?.();
- const savedName = options.pdfFileName?.();
-
- // Only proceed if we have data and a name
- if (!ready || !savedData || !savedName) return;
-
- // Skip if we've already loaded this exact file
- if (loadedPdfName() === savedName) return;
-
- // If user cleared but now we have a DIFFERENT file, accept it
- // (This handles the case where parent sends new data after clear)
- if (document.userCleared()) {
- // Reset userCleared flag in document module
- // We'll handle this by setting the source directly
- }
-
- // Load the new PDF (old one stays visible until this completes)
- const clonedData = savedData.slice(0);
- setLoadedPdfName(savedName);
- document.setPdfSourceAndName({ data: clonedData }, savedName);
- });
-
- // Initialize canvas arrays when PDF loads
- createEffect(() => {
- const doc = document.pdfDoc();
- const totalPages = document.totalPages();
- const docId = document.docId();
- if (doc && totalPages > 0 && docId > 0) {
- renderer.initializeCanvasArrays(totalPages);
- scrollHandler.setCurrentPage(1);
- }
- });
-
- // Clear rendered tracking when PDF changes
- createEffect(() => {
- const docId = document.docId();
- if (docId > 0) {
- renderer.clearRenderedTracking();
- renderer.clearAllCanvases();
- }
- });
-
- // Cleanup
- onCleanup(() => {
- renderer.cleanup();
- scrollHandler.cleanup();
- renderer.cancelAllRenders();
- });
-
- return {
- // State
- pdfDoc: document.pdfDoc,
- currentPage: scrollHandler.currentPage,
- totalPages: document.totalPages,
- scale: scrollHandler.scale,
- loading: document.loading,
- error: document.error,
- fileName: document.fileName,
- libReady: document.libReady,
- rendering: renderer.rendering,
- pageCanvases: renderer.pageCanvases,
- docId: document.docId,
-
- // Ref setters
- setPageCanvasRef: renderer.setPageCanvasRef,
- setPageTextLayerRef: renderer.setPageTextLayerRef,
- setPageRef: scrollHandler.setPageRef,
- setFileInputRef: fileHandler.setFileInputRef,
- setScrollContainerRef: scrollHandler.setScrollContainerRef,
- setupResizeObserver: renderer.setupResizeObserver,
-
- // Actions
- handleFile: fileHandler.handleFile,
- handleFileUpload: fileHandler.handleFileUpload,
- clearPdf: fileHandler.clearPdf,
- openFilePicker: fileHandler.openFilePicker,
- goToPage: scrollHandler.goToPage,
- goToPrevPage: scrollHandler.goToPrevPage,
- goToNextPage: scrollHandler.goToNextPage,
- zoomIn: scrollHandler.zoomIn,
- zoomOut: scrollHandler.zoomOut,
- resetZoom: scrollHandler.resetZoom,
- setScale: scrollHandler.setScale,
- fitToWidth: scrollHandler.fitToWidth,
- };
-}
diff --git a/packages/web/src/components/checklist/pdf/PdfListItem.jsx b/packages/web/src/components/pdf/PdfListItem.jsx
similarity index 100%
rename from packages/web/src/components/checklist/pdf/PdfListItem.jsx
rename to packages/web/src/components/pdf/PdfListItem.jsx
diff --git a/packages/web/src/components/checklist/pdf/PdfTagBadge.jsx b/packages/web/src/components/pdf/PdfTagBadge.jsx
similarity index 100%
rename from packages/web/src/components/checklist/pdf/PdfTagBadge.jsx
rename to packages/web/src/components/pdf/PdfTagBadge.jsx
diff --git a/packages/web/src/components/pdf/embedpdf/EmbedPdfViewer.jsx b/packages/web/src/components/pdf/embedpdf/EmbedPdfViewer.jsx
new file mode 100644
index 000000000..61007056e
--- /dev/null
+++ b/packages/web/src/components/pdf/embedpdf/EmbedPdfViewer.jsx
@@ -0,0 +1,64 @@
+/**
+ * EmbedPdfViewer - Preact island wrapper for EmbedPDF viewer
+ * Manages Preact component lifecycle and converts SolidJS props to plain values
+ */
+
+import { createEffect, onCleanup } from 'solid-js';
+import { render, h } from 'preact';
+import EmbedPdfViewerPreact from './preact/src/main';
+
+/**
+ * EmbedPdfViewer - SolidJS wrapper that manages Preact island
+ * @param {Object} props - Component props
+ * @param {ArrayBuffer} props.pdfData - ArrayBuffer of PDF data (required)
+ * @param {string} props.pdfFileName - Name of the PDF file (optional)
+ * @param {boolean} props.readOnly - If true, view only mode
+ * @param {Array} props.pdfs - Array of PDFs for multi-PDF selection
+ * @param {string} props.selectedPdfId - Currently selected PDF ID
+ * @param {Function} props.onPdfSelect - Handler for PDF selection change
+ */
+export default function EmbedPdfViewer(props) {
+ let containerRef;
+
+ createEffect(() => {
+ const container = containerRef;
+ if (!container) return;
+
+ // Access props directly in effect to track reactivity
+ const pdfData = props.pdfData;
+ const pdfFileName = props.pdfFileName;
+ const pdfs = props.pdfs;
+ const selectedPdfId = props.selectedPdfId;
+ const onPdfSelect = props.onPdfSelect;
+ const readOnly = props.readOnly;
+
+ // Render Preact component into the container
+ render(
+ h(EmbedPdfViewerPreact, {
+ pdfData,
+ pdfFileName,
+ pdfs,
+ selectedPdfId,
+ onPdfSelect,
+ readOnly,
+ }),
+ container,
+ );
+
+ // Cleanup function
+ return () => {
+ if (container) {
+ render(null, container);
+ }
+ };
+ });
+
+ // Cleanup on unmount
+ onCleanup(() => {
+ if (containerRef) {
+ render(null, containerRef);
+ }
+ });
+
+ return ;
+}
diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/annotation-selection-menu.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/annotation-selection-menu.tsx
new file mode 100644
index 000000000..1a97ce7fd
--- /dev/null
+++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/annotation-selection-menu.tsx
@@ -0,0 +1,55 @@
+import {
+ useAnnotationCapability,
+ type AnnotationSelectionMenuProps,
+} from '@embedpdf/plugin-annotation/react';
+import { TrashIcon } from './icons';
+
+interface Props extends AnnotationSelectionMenuProps {
+ documentId: string;
+}
+
+export function AnnotationSelectionMenu({
+ selected,
+ context,
+ documentId,
+ menuWrapperProps,
+ rect,
+}: Props) {
+ const { provides: annotationCapability } = useAnnotationCapability();
+
+ // Get document-scoped annotation API
+ const annotationScope = annotationCapability?.forDocument(documentId);
+
+ const handleDelete = () => {
+ if (!annotationScope) return;
+ const { pageIndex, id } = context.annotation.object;
+ annotationScope.deleteAnnotation(pageIndex, id);
+ };
+
+ if (!selected) return null;
+
+ // Calculate position - position below the annotation by default
+ const menuStyle: React.CSSProperties = {
+ position: 'absolute',
+ pointerEvents: 'auto',
+ cursor: 'default',
+ top: rect.size.height + 8,
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/annotation-toolbar.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/annotation-toolbar.tsx
new file mode 100644
index 000000000..0742c455b
--- /dev/null
+++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/annotation-toolbar.tsx
@@ -0,0 +1,250 @@
+import { AnnotationTool, useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
+import { useHistoryCapability } from '@embedpdf/plugin-history/react';
+import { useEffect, useState, useMemo } from 'react';
+import { ToolbarButton } from './ui';
+import {
+ HighlightIcon,
+ UnderlineIcon,
+ StrikethroughIcon,
+ SquigglyIcon,
+ PenIcon,
+ TextIcon,
+ CircleIcon,
+ SquareIcon,
+ PolygonIcon,
+ PolylineIcon,
+ LineIcon,
+ ArrowIcon,
+ UndoIcon,
+ RedoIcon,
+} from './icons';
+
+type AnnotationToolbarProps = {
+ documentId: string;
+};
+
+// Helper type for tool colors
+type ToolColors = Record;
+
+// Helper function to extract tool colors
+function extractToolColors(tools: AnnotationTool[]): ToolColors {
+ const colors: ToolColors = {};
+ tools.forEach(tool => {
+ const defaults = tool.defaults as any;
+ colors[tool.id] = {
+ primaryColor: defaults.strokeColor || defaults.color || defaults.fontColor,
+ secondaryColor: defaults.color,
+ };
+ });
+ return colors;
+}
+
+export function AnnotationToolbar({ documentId }: AnnotationToolbarProps) {
+ const { provides: annotationCapability } = useAnnotationCapability();
+ const { provides: historyCapability } = useHistoryCapability();
+ const [activeTool, setActiveTool] = useState(null);
+ const [canUndo, setCanUndo] = useState(false);
+ const [canRedo, setCanRedo] = useState(false);
+
+ // Initialize tool colors synchronously to avoid flash
+ const [toolColors, setToolColors] = useState(() =>
+ annotationCapability ? extractToolColors(annotationCapability.getTools()) : {},
+ );
+
+ // Get scoped API for this document
+ const annotationProvides = useMemo(
+ () => (annotationCapability ? annotationCapability.forDocument(documentId) : null),
+ [annotationCapability, documentId],
+ );
+
+ // Get scoped history for this document
+ const historyProvides = useMemo(
+ () => (historyCapability ? historyCapability.forDocument(documentId) : null),
+ [historyCapability, documentId],
+ );
+
+ useEffect(() => {
+ if (!annotationProvides) return;
+
+ // Initialize with current tool
+ setActiveTool(annotationProvides.getActiveTool());
+
+ // Subscribe to changes
+ return annotationProvides.onActiveToolChange(tool => {
+ setActiveTool(tool);
+ });
+ }, [annotationProvides]);
+
+ // Subscribe to tool changes to get tool defaults (only fires when tools are updated)
+ useEffect(() => {
+ if (!annotationCapability) return;
+
+ // Subscribe to tool changes (only when tool defaults are updated)
+ return annotationCapability.onToolsChange(event => {
+ setToolColors(extractToolColors(event.tools));
+ });
+ }, [annotationCapability]);
+
+ // Subscribe to history state changes for this document
+ useEffect(() => {
+ if (!historyProvides) return;
+
+ // Initialize with current state
+ const state = historyProvides.getHistoryState();
+ setCanUndo(state.global.canUndo);
+ setCanRedo(state.global.canRedo);
+
+ // Subscribe to history changes
+ return historyProvides.onHistoryChange(() => {
+ const newState = historyProvides.getHistoryState();
+ setCanUndo(newState.global.canUndo);
+ setCanRedo(newState.global.canRedo);
+ });
+ }, [historyProvides]);
+
+ if (!annotationProvides) return null;
+
+ const toggleTool = (toolId: string) => {
+ const currentId = activeTool?.id ?? null;
+ annotationProvides.setActiveTool(currentId === toolId ? null : toolId);
+ };
+
+ const handleUndo = () => {
+ if (historyProvides) {
+ historyProvides.undo();
+ }
+ };
+
+ const handleRedo = () => {
+ if (historyProvides) {
+ historyProvides.redo();
+ }
+ };
+
+ return (
+
+ {isPasswordRequired &&
+ 'This document is password protected. Please enter the password to open it.'}
+ {isPasswordIncorrect && 'The password you entered was incorrect. Please try again.'}
+