diff --git a/eslint.config.js b/eslint.config.js index ae3a8cd19..e41babdec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -164,6 +164,27 @@ export default [ }, }, }, + { + // Preact components - disable SolidJS rules + files: ['**/preact/**/*.{js,jsx,ts,tsx}'], + languageOptions: { + globals: { + React: 'readonly', + HTMLAnchorElement: 'readonly', + HTMLFormElement: 'readonly', + Node: 'readonly', + NodeJS: 'readonly', + }, + }, + rules: { + 'solid/reactivity': 'off', + 'solid/no-destructure': 'off', + 'solid/prefer-for': 'off', + 'solid/components-return-once': 'off', + 'solid/no-react-specific-props': 'off', + 'solid/style-prop': 'off', + }, + }, { ignores: [ '**/node_modules/**', diff --git a/packages/web/jsconfig.json b/packages/web/jsconfig.json index 88f23bb69..7a9bc407b 100644 --- a/packages/web/jsconfig.json +++ b/packages/web/jsconfig.json @@ -6,6 +6,7 @@ "@components/*": ["src/components/*"], "@auth/*": ["src/components/auth/*"], "@checklist/*": ["src/components/checklist/*"], + "@pdf/*": ["src/components/pdf/*"], "@project/*": ["src/components/project/*"], "@routes/*": ["src/routes/*"], "@primitives/*": ["src/primitives/*"], diff --git a/packages/web/package.json b/packages/web/package.json index d801a5e4d..da94f3d08 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,20 +19,50 @@ "dependencies": { "@corates/shared": "workspace:*", "@corates/ui": "workspace:*", + "@embedpdf/core": "^2.1.1", + "@embedpdf/engines": "^2.1.1", + "@embedpdf/models": "^2.1.1", + "@embedpdf/pdfium": "^2.1.1", + "@embedpdf/plugin-annotation": "^2.1.1", + "@embedpdf/plugin-capture": "^2.1.1", + "@embedpdf/plugin-commands": "^2.1.1", + "@embedpdf/plugin-document-manager": "^2.1.1", + "@embedpdf/plugin-export": "^2.1.1", + "@embedpdf/plugin-fullscreen": "^2.1.1", + "@embedpdf/plugin-history": "^2.1.1", + "@embedpdf/plugin-i18n": "^2.1.1", + "@embedpdf/plugin-interaction-manager": "^2.1.1", + "@embedpdf/plugin-pan": "^2.1.1", + "@embedpdf/plugin-print": "^2.1.1", + "@embedpdf/plugin-redaction": "^2.1.1", + "@embedpdf/plugin-render": "^2.1.1", + "@embedpdf/plugin-rotate": "^2.1.1", + "@embedpdf/plugin-scroll": "^2.1.1", + "@embedpdf/plugin-search": "^2.1.1", + "@embedpdf/plugin-selection": "^2.1.1", + "@embedpdf/plugin-spread": "^2.1.1", + "@embedpdf/plugin-thumbnail": "^2.1.1", + "@embedpdf/plugin-tiling": "^2.1.1", + "@embedpdf/plugin-ui": "^2.1.1", + "@embedpdf/plugin-view-manager": "^2.1.1", + "@embedpdf/plugin-viewport": "^2.1.1", + "@embedpdf/plugin-zoom": "^2.1.1", "@embedpdf/snippet": "^2.0.0", "@solidjs/router": "^0.15.4", "@tanstack/solid-query": "^5.90.18", "better-auth": "^1.4.9", "d3": "^7.9.0", "idb": "^8.0.3", - "pdfjs-dist": "^5.4.530", + "preact": "^10.28.1", "solid-icons": "^1.1.0", "solid-js": "^1.9.10", + "tailwind-merge": "^3.4.0", "y-indexeddb": "^9.0.12", "y-websocket": "^3.0.0", "yjs": "^13.6.28" }, "devDependencies": { + "@preact/preset-vite": "^2.10.0", "@solidjs/testing-library": "^0.8.10", "@tailwindcss/vite": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/web/src/components/checklist/ChecklistWithPdf.jsx b/packages/web/src/components/checklist/ChecklistWithPdf.jsx index 6937faa26..35562ec81 100644 --- a/packages/web/src/components/checklist/ChecklistWithPdf.jsx +++ b/packages/web/src/components/checklist/ChecklistWithPdf.jsx @@ -6,11 +6,9 @@ */ import GenericChecklist from '@/components/checklist/GenericChecklist.jsx'; -import PdfViewer from '@/components/checklist/pdf/PdfViewer.jsx'; -import EmbedPdfViewer from '@/components/checklist/embedpdf/EmbedPdfViewer.jsx'; +import EmbedPdfViewer from '@pdf/embedpdf/EmbedPdfViewer.jsx'; import SplitScreenLayout from '@/components/checklist/SplitScreenLayout.jsx'; -import { PDF_VIEWER_MODE } from '@config/pdfViewer.js'; -import { createMemo, Show } from 'solid-js'; +import { Show } from 'solid-js'; export default function ChecklistWithPdf(props) { // props.checklistType - the type of checklist ('AMSTAR2', 'ROBINS_I', etc.) @@ -19,10 +17,7 @@ export default function ChecklistWithPdf(props) { // props.headerContent - optional content to show in the header bar (left side) // props.pdfData - saved PDF ArrayBuffer (optional) // props.pdfFileName - saved PDF file name (optional) - // props.onPdfChange - callback when PDF changes: (data, fileName) => void - // props.onPdfClear - callback when PDF is cleared // props.readOnly - if true, disables checklist updates and PDF uploads - // props.allowDelete - if true, shows PDF delete button (only applies when !readOnly) // props.pdfs - array of PDFs for multi-PDF selection // props.selectedPdfId - currently selected PDF ID // props.onPdfSelect - handler for PDF selection change @@ -30,10 +25,6 @@ export default function ChecklistWithPdf(props) { // props.getRobinsText - function to get Y.Text for a ROBINS-I free-text field // props.pdfUrl - optional PDF URL (for server-hosted PDFs) - // Use EmbedPDF viewer when mode is 'snippet' and we have pdfData - // Note: EmbedPDF viewer only works with pdfData (blob URLs), not direct pdfUrl - const useEmbedPdf = createMemo(() => PDF_VIEWER_MODE === 'snippet' && !!props.pdfData); - return (
{/* Split screen with checklist and PDF */} @@ -56,22 +47,7 @@ export default function ChecklistWithPdf(props) { /> {/* Second panel: PDF Viewer */} - - } - > + - - - - - - - - - ); -} diff --git a/packages/web/src/components/checklist/embedpdf/EmbedPdfViewerPdfJs.jsx b/packages/web/src/components/checklist/embedpdf/EmbedPdfViewerPdfJs.jsx deleted file mode 100644 index ba2141573..000000000 --- a/packages/web/src/components/checklist/embedpdf/EmbedPdfViewerPdfJs.jsx +++ /dev/null @@ -1,13 +0,0 @@ -/** - * EmbedPdfViewerPdfJs - Wrapper for PDF.js viewer - * Uses the existing PdfViewer component from the pdf directory - */ - -import PdfViewer from '../pdf/PdfViewer'; - -export default function EmbedPdfViewerPdfJs(props) { - // props.pdfData - ArrayBuffer of PDF data (required) - // props.pdfFileName - Name of the PDF file (optional, for display) - - return ; -} diff --git a/packages/web/src/components/checklist/embedpdf/EmbedPdfViewerSnippet.jsx b/packages/web/src/components/checklist/embedpdf/EmbedPdfViewerSnippet.jsx deleted file mode 100644 index f5683548c..000000000 --- a/packages/web/src/components/checklist/embedpdf/EmbedPdfViewerSnippet.jsx +++ /dev/null @@ -1,600 +0,0 @@ -/** - * EmbedPdfViewerSnippet - Component for viewing PDF files using EmbedPDF snippet viewer - * Uses the vanilla EmbedPDF snippet with full UI (toolbar, sidebar, etc.) - * - * Configuration: - * - Light theme with blue accent colors matching app design - * - Only highlights and free-text comments enabled (no ink/shapes/redaction) - * - Read-only mode disables all annotation tools - */ - -import { createMemo, createEffect, onCleanup } from 'solid-js'; -import EmbedPDF from '@embedpdf/snippet'; - -export default function EmbedPdfViewerSnippet(props) { - // props.pdfData - ArrayBuffer of PDF data (required for snippet viewer) - // props.pdfFileName - Name of the PDF file (optional, for display) - // props.readOnly - If true, disables all annotation tools (view-only mode) - // props.pdfs - Array of PDFs for multi-PDF selection - // props.selectedPdfId - Currently selected PDF ID - // props.onPdfSelect - Handler for PDF selection change - - let containerRef; - let viewerInstance = null; - let currentBlobUrl = null; - let currentReadOnly = null; - let currentCategories = null; - let popoverElement = null; - let popoverButtonElement = null; - let clickOutsideHandler = null; - let escapeKeyHandler = null; - - // Create blob URL from pdfData - const blobUrl = createMemo(() => { - const pdfData = props.pdfData; - if (!pdfData) return null; - - const blob = new Blob([pdfData], { type: 'application/pdf' }); - return URL.createObjectURL(blob); - }); - - // Build disabled categories based on read-only state - // When readOnly is true, disable all annotation features - // When readOnly is false, only keep highlight and free-text comments - const disabledCategories = createMemo(() => { - if (props.readOnly) { - // Disable all annotation features in read-only mode - return [ - 'annotation', - 'annotation-highlight', - 'annotation-text', - 'annotation-ink', - 'annotation-shape', - 'mode-shapes', // Disable shapes mode button - 'annotation-stamp', - 'annotation-underline', - 'annotation-strikeout', - 'annotation-squiggly', - 'panel-comment', - 'redaction', - 'export', - 'print', - ]; - } - // In edit mode, only keep highlight and free-text comments - return [ - 'annotation-ink', - 'annotation-shape', - 'mode-shapes', // Disable shapes mode button - 'annotation-stamp', - 'annotation-underline', - 'annotation-strikeout', - 'annotation-squiggly', - 'redaction', - 'export', - 'print', - ]; - }); - - // Theme configuration: light mode with blue accent colors matching app design - const themeConfig = createMemo(() => ({ - preference: 'light', - light: { - accent: { - primary: '#2563eb', // blue-600 - primaryHover: '#1d4ed8', // blue-700 - primaryActive: '#1e40af', // blue-800 - primaryLight: '#dbeafe', // blue-100 - primaryForeground: '#ffffff', - }, - background: { - app: '#f0f9ff', // blue-50 - surface: '#ffffff', - surfaceAlt: '#f8fafc', - elevated: '#ffffff', - overlay: 'rgba(0, 0, 0, 0.5)', - input: '#ffffff', - }, - foreground: { - primary: '#1e293b', // slate-800 - secondary: '#64748b', // slate-500 - muted: '#94a3b8', // slate-400 - disabled: '#cbd5e1', // slate-300 - onAccent: '#ffffff', - }, - interactive: { - hover: '#f1f5f9', // slate-100 - active: '#e2e8f0', // slate-200 - selected: '#dbeafe', // blue-100 - focus: '#2563eb', // blue-600 - }, - border: { - default: '#e2e8f0', // slate-200 - subtle: '#f1f5f9', // slate-100 - strong: '#cbd5e1', // slate-300 - }, - }, - })); - - // Helper function to close the PDF switcher popover - function closePdfSwitcherPopover() { - if (popoverElement) { - popoverElement.style.display = 'none'; - popoverElement.classList.remove('open'); - } - if (clickOutsideHandler) { - document.removeEventListener('click', clickOutsideHandler); - clickOutsideHandler = null; - } - if (escapeKeyHandler) { - document.removeEventListener('keydown', escapeKeyHandler); - escapeKeyHandler = null; - } - } - - // Helper function to open the PDF switcher popover - function openPdfSwitcherPopover(buttonElement) { - if (!popoverElement || !containerRef) return; - popoverButtonElement = buttonElement; - - const containerRect = containerRef.getBoundingClientRect(); - - if (buttonElement) { - const buttonRect = buttonElement.getBoundingClientRect(); - // Position popover below the button, aligned to the right - popoverElement.style.top = `${buttonRect.bottom - containerRect.top + 4}px`; - popoverElement.style.right = `${containerRect.right - buttonRect.right}px`; - } else { - // Fallback position: top-right of container - popoverElement.style.top = '40px'; - popoverElement.style.right = '10px'; - } - - popoverElement.style.display = 'block'; - popoverElement.classList.add('open'); - - // Handle click outside to close - clickOutsideHandler = event => { - if ( - popoverElement && - !popoverElement.contains(event.target) && - (!buttonElement || !buttonElement.contains(event.target)) - ) { - closePdfSwitcherPopover(); - } - }; - setTimeout(() => { - document.addEventListener('click', clickOutsideHandler); - }, 0); - - // Handle ESC key to close - escapeKeyHandler = event => { - if (event.key === 'Escape') { - closePdfSwitcherPopover(); - } - }; - document.addEventListener('keydown', escapeKeyHandler); - } - - // Helper function to update the PDF switcher popover content - function updatePdfSwitcherPopoverContent() { - if (!popoverElement) return; - - const pdfs = props.pdfs || []; - if (pdfs.length === 0) { - popoverElement.innerHTML = ''; - return; - } - - // Sort PDFs: primary first, then protocol, then secondary - const sortedPdfs = [...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; - }); - - popoverElement.innerHTML = ''; - - sortedPdfs.forEach(pdf => { - const isSelected = pdf.id === props.selectedPdfId; - const tagLabel = - pdf.tag === 'primary' ? 'Primary' : pdf.tag === 'protocol' ? 'Protocol' : null; - const displayName = tagLabel ? `${pdf.fileName} (${tagLabel})` : pdf.fileName; - - const button = document.createElement('button'); - button.className = `embedpdf-pdf-switcher-item ${isSelected ? 'selected' : ''}`; - button.setAttribute('data-pdf-id', pdf.id); - button.textContent = displayName; - button.style.cssText = ` - width: 100%; - text-align: left; - padding: 8px 12px; - border: none; - background: ${isSelected ? '#dbeafe' : 'transparent'}; - color: ${isSelected ? '#1e40af' : '#1e293b'}; - cursor: pointer; - border-radius: 4px; - font-size: 14px; - transition: background-color 0.15s; - `; - - button.addEventListener('mouseenter', () => { - button.style.backgroundColor = isSelected ? '#dbeafe' : '#f1f5f9'; - }); - - button.addEventListener('mouseleave', () => { - button.style.backgroundColor = isSelected ? '#dbeafe' : 'transparent'; - }); - - button.addEventListener('click', () => { - if (props.onPdfSelect) { - props.onPdfSelect(pdf.id); - closePdfSwitcherPopover(); - } - }); - - popoverElement.appendChild(button); - }); - } - - // Helper function to create and setup the PDF switcher popover - function setupPdfSwitcherPopover(container) { - // Remove existing popover if it exists - const existingPopover = container.querySelector('.embedpdf-pdf-switcher-popover'); - if (existingPopover) { - existingPopover.remove(); - } - - // Create popover element - popoverElement = document.createElement('div'); - popoverElement.className = 'embedpdf-pdf-switcher-popover'; - popoverElement.style.cssText = ` - position: absolute; - display: none; - z-index: 1000; - background: white; - border: 1px solid #e2e8f0; - border-radius: 8px; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - min-width: 200px; - max-width: 300px; - max-height: 400px; - overflow-y: auto; - padding: 4px; - `; - - updatePdfSwitcherPopoverContent(); - container.appendChild(popoverElement); - } - - // Helper function to destroy viewer instance and clean up - function destroyViewer() { - closePdfSwitcherPopover(); - - if (popoverElement && popoverElement.parentNode) { - popoverElement.parentNode.removeChild(popoverElement); - popoverElement = null; - } - - if (viewerInstance) { - try { - if (typeof viewerInstance.destroy === 'function') { - viewerInstance.destroy(); - } else if (typeof viewerInstance.unmount === 'function') { - viewerInstance.unmount(); - } else if (typeof viewerInstance.dispose === 'function') { - viewerInstance.dispose(); - } - } catch (err) { - console.warn('Error destroying EmbedPDF viewer:', err); - } - viewerInstance = null; - } - - if (containerRef) { - try { - containerRef.innerHTML = ''; - } catch (err) { - console.warn('Error clearing container:', err); - } - } - - if (currentBlobUrl) { - try { - URL.revokeObjectURL(currentBlobUrl); - } catch (err) { - console.warn('Error revoking blob URL:', err); - } - currentBlobUrl = null; - } - } - - // Initialize or re-initialize the snippet viewer when dependencies change - createEffect(() => { - const url = blobUrl(); - const container = containerRef; - const categories = disabledCategories(); - const readOnly = props.readOnly; - - if (!url || !container) { - // Clean up existing viewer if URL is removed - destroyViewer(); - return; - } - - // Check if we need to recreate the viewer - // Recreate if: URL changed, readOnly changed, or categories changed - const needsRecreate = - !viewerInstance || - currentBlobUrl !== url || - currentReadOnly !== readOnly || - JSON.stringify(currentCategories) !== JSON.stringify(categories); - - if (needsRecreate) { - // Destroy existing viewer before creating new one - destroyViewer(); - - // Initialize new viewer - try { - viewerInstance = EmbedPDF.init({ - type: 'container', - target: container, - src: url, - theme: themeConfig(), - disabledCategories: categories, - }); - currentBlobUrl = url; - currentReadOnly = readOnly; - currentCategories = categories; - - // Customize responsive breakpoints and setup PDF switcher after initialization - // Adjust mobile view threshold (default is 640px for sm breakpoint) - viewerInstance.registry.then(registry => { - try { - const commands = registry.getPlugin('commands')?.provides(); - const ui = registry.getPlugin('ui')?.provides(); - if (!ui) return; - - const schema = ui.getSchema(); - const mainToolbar = schema.toolbars?.['main-toolbar']; - - // Customize responsive breakpoints - if (mainToolbar?.responsive?.breakpoints) { - const originalBreakpoints = mainToolbar.responsive.breakpoints; - ui.mergeSchema({ - toolbars: { - 'main-toolbar': { - ...mainToolbar, - responsive: { - ...mainToolbar.responsive, - breakpoints: { - ...originalBreakpoints, - xs: - originalBreakpoints.xs ? - { - ...originalBreakpoints.xs, - } - : originalBreakpoints.xs, - sm: - originalBreakpoints.sm ? - { - minWidth: 400, - maxWidth: 768, - hide: originalBreakpoints.sm.hide || [], - show: originalBreakpoints.sm.show || [], - } - : originalBreakpoints.sm, - md: - originalBreakpoints.md ? - { - minWidth: 640, - hide: originalBreakpoints.md.hide || [], - show: originalBreakpoints.md.show || [], - } - : originalBreakpoints.md, - }, - }, - }, - }, - }); - } - - // Setup PDF switcher if we have multiple PDFs - const pdfs = props.pdfs || []; - if (pdfs.length > 1 && commands && ui) { - // Register PDF switcher icon (document/file icon) - viewerInstance.registerIcon('pdf-switcher', { - viewBox: '0 0 24 24', - paths: [ - { - d: 'M14 3v4a1 1 0 0 0 1 1h4', - stroke: 'currentColor', - fill: 'none', - 'stroke-linecap': 'round', - 'stroke-linejoin': 'round', - }, - { - d: 'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z', - stroke: 'currentColor', - fill: 'none', - 'stroke-linecap': 'round', - 'stroke-linejoin': 'round', - }, - ], - }); - - // Setup popover - setupPdfSwitcherPopover(container); - - // Register PDF switcher command - commands.registerCommand({ - id: 'custom.pdf-switcher', - label: 'Switch PDF', - icon: 'pdf-switcher', - action: () => { - if (!popoverElement || !container) return; - - // Find the button element - try multiple selectors as EmbedPDF may use different attributes - let buttonElement = container.querySelector(`[data-command-id="custom.pdf-switcher"]`); - if (!buttonElement) { - buttonElement = container.querySelector('#pdf-switcher-button'); - } - if (!buttonElement) { - const allButtons = container.querySelectorAll('button'); - buttonElement = Array.from(allButtons).find( - btn => - btn.getAttribute('aria-label')?.includes('Switch PDF') || - btn.getAttribute('title')?.includes('Switch PDF'), - ); - } - - if (popoverElement.classList.contains('open')) { - closePdfSwitcherPopover(); - } else { - updatePdfSwitcherPopoverContent(); - openPdfSwitcherPopover(buttonElement || null); - } - }, - }); - - // Add PDF switcher button to toolbar - const toolbarSchema = JSON.parse(JSON.stringify(mainToolbar)); - const items = toolbarSchema.items || []; - const rightGroup = items.find(item => item.id === 'right-group'); - - if (rightGroup && Array.isArray(rightGroup.items)) { - // Check if button already exists - const existingIndex = rightGroup.items.findIndex( - item => item.id === 'pdf-switcher-button', - ); - - const pdfSwitcherButton = { - type: 'command-button', - id: 'pdf-switcher-button', - commandId: 'custom.pdf-switcher', - variant: 'icon', - }; - - if (existingIndex >= 0) { - rightGroup.items[existingIndex] = pdfSwitcherButton; - } else { - // Insert before the last item (usually a spacer or rightmost button) - rightGroup.items.splice(rightGroup.items.length - 1, 0, pdfSwitcherButton); - } - - ui.mergeSchema({ - toolbars: { - 'main-toolbar': { - ...toolbarSchema, - items, - }, - }, - }); - } - } - } catch (err) { - console.warn('Failed to customize viewer:', err); - } - }); - } catch (err) { - console.error('Failed to initialize EmbedPDF viewer:', err); - viewerInstance = null; - currentBlobUrl = null; - currentReadOnly = null; - currentCategories = null; - } - } else if (viewerInstance) { - // Only theme can be updated without recreating - // Update theme if it changed (though themeConfig is stable, this is defensive) - try { - if (viewerInstance.setTheme) { - viewerInstance.setTheme(themeConfig()); - } - } catch (err) { - console.warn('Failed to update EmbedPDF theme:', err); - } - } - }); - - // Update popover content when PDFs or selectedPdfId changes - createEffect(() => { - const pdfs = props.pdfs; - const selectedPdfId = props.selectedPdfId; - - // Update popover content if it exists - if (popoverElement && popoverElement.parentNode) { - updatePdfSwitcherPopoverContent(); - } - }); - - // Clean up on unmount - onCleanup(() => { - // Close and remove popover - closePdfSwitcherPopover(); - if (popoverElement && popoverElement.parentNode) { - popoverElement.parentNode.removeChild(popoverElement); - popoverElement = null; - } - - // Destroy viewer instance - if (viewerInstance) { - try { - if (typeof viewerInstance.destroy === 'function') { - viewerInstance.destroy(); - } else if (typeof viewerInstance.unmount === 'function') { - viewerInstance.unmount(); - } else if (typeof viewerInstance.dispose === 'function') { - viewerInstance.dispose(); - } - } catch (err) { - console.warn('Error destroying EmbedPDF viewer on cleanup:', err); - } - viewerInstance = null; - } - - // Clear container contents - if (containerRef) { - try { - containerRef.innerHTML = ''; - } catch (err) { - console.warn('Error clearing container:', err); - } - } - - // Revoke blob URL (only the current one to avoid double-revoking) - if (currentBlobUrl) { - try { - URL.revokeObjectURL(currentBlobUrl); - } catch (err) { - console.warn('Error revoking blob URL:', err); - } - currentBlobUrl = null; - } - - // Also revoke the current blobUrl if different (shouldn't happen, but safety check) - const url = blobUrl(); - if (url && url !== currentBlobUrl) { - try { - URL.revokeObjectURL(url); - } catch (err) { - console.warn('Error revoking blob URL:', err); - } - } - }); - - const hasPdfData = createMemo(() => !!props.pdfData); - - return ( -
- {hasPdfData() ? -
- :
-
-

No PDF selected

-
-
- } -
- ); -} 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 ( - 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 */} - -
- -
- setPageInput(e.target.value)} - placeholder={String(props.currentPage)} - class='w-10 rounded border border-gray-200 px-1 py-0.5 text-center text-sm text-gray-600 focus:border-blue-400 focus:outline-none' - title='Enter page number' - /> - / {props.totalPages} -
- -
- - {/* Zoom controls */} -
- -
- setZoomInput(e.target.value)} - placeholder={`${Math.round(props.scale * 100)}%`} - class='w-14 rounded border border-gray-200 px-1 py-0.5 text-center text-sm text-gray-600 focus:border-blue-400 focus:outline-none' - title='Enter zoom percentage (50-300)' - /> -
- - -
-
-
- ); -} 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()} -
-
- pdf.setPageCanvasRef(pageNum, el)} class='bg-white' /> - {/* Text layer for text selection */} -
pdf.setPageTextLayerRef(pageNum, el)} - class='pdf-text-layer absolute top-0 left-0 overflow-hidden' - /> -
-
- ); - }} - -
- -
-
- ); -} 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 ( +
+ toggleTool('highlight')} + isActive={activeTool?.id === 'highlight'} + aria-label='Highlight text' + title='Highlight Text' + > + + + + toggleTool('underline')} + isActive={activeTool?.id === 'underline'} + aria-label='Underline text' + title='Underline' + > + + + + toggleTool('strikeout')} + isActive={activeTool?.id === 'strikeout'} + aria-label='Strikethrough text' + title='Strikethrough' + > + + + + toggleTool('squiggly')} + isActive={activeTool?.id === 'squiggly'} + aria-label='Squiggly underline' + title='Squiggly Underline' + > + + + + toggleTool('ink')} + isActive={activeTool?.id === 'ink'} + aria-label='Freehand annotation' + title='Draw Freehand' + > + + + + toggleTool('freeText')} + isActive={activeTool?.id === 'freeText'} + aria-label='Text annotation' + title='Add Text Annotation' + > + + + + toggleTool('circle')} + isActive={activeTool?.id === 'circle'} + aria-label='Circle annotation' + title='Draw Circle' + > + + + + toggleTool('square')} + isActive={activeTool?.id === 'square'} + aria-label='Square annotation' + title='Draw Rectangle' + > + + + + toggleTool('polygon')} + isActive={activeTool?.id === 'polygon'} + aria-label='Polygon annotation' + title='Draw Polygon' + > + + + + toggleTool('polyline')} + isActive={activeTool?.id === 'polyline'} + aria-label='Polyline annotation' + title='Draw Polyline' + > + + + + toggleTool('line')} + isActive={activeTool?.id === 'line'} + aria-label='Line annotation' + title='Draw Line' + > + + + + toggleTool('lineArrow')} + isActive={activeTool?.id === 'lineArrow'} + aria-label='Arrow annotation' + title='Draw Arrow' + > + + + + {/* Divider */} +
+ + {/* Undo/Redo buttons */} + + + + + + + +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/capture-dialog.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/capture-dialog.tsx new file mode 100644 index 000000000..a27b9f7c8 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/capture-dialog.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react'; +import { useCapture } from '@embedpdf/plugin-capture/react'; +import { Dialog, DialogContent, DialogFooter, Button } from './ui'; + +interface CaptureData { + pageIndex: number; + rect: any; + blob: Blob; +} + +type CaptureDialogProps = { + documentId: string; +}; + +export function CaptureDialog({ documentId }: CaptureDialogProps) { + const { provides: capture } = useCapture(documentId); + const [open, setOpen] = useState(false); + const [captureData, setCaptureData] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [downloadUrl, setDownloadUrl] = useState(null); + const urlRef = useRef(null); + const downloadLinkRef = useRef(null); + + const handleClose = () => { + // Clean up object URLs + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + urlRef.current = null; + } + if (downloadUrl) { + URL.revokeObjectURL(downloadUrl); + setDownloadUrl(null); + } + setOpen(false); + setCaptureData(null); + setPreviewUrl(null); + }; + + const handleDownload = () => { + if (!captureData || !downloadLinkRef.current) return; + + // Create download URL and trigger download + const url = URL.createObjectURL(captureData.blob); + setDownloadUrl(url); + + // Use the ref to trigger download + downloadLinkRef.current.href = url; + downloadLinkRef.current.download = `pdf-capture-page-${captureData.pageIndex + 1}.png`; + downloadLinkRef.current.click(); + + handleClose(); + }; + + useEffect(() => { + if (!capture) return; + + return capture.onCaptureArea(({ pageIndex, rect, blob }) => { + setCaptureData({ pageIndex, rect, blob }); + + // Create preview URL + const objectUrl = URL.createObjectURL(blob); + urlRef.current = objectUrl; + setPreviewUrl(objectUrl); + setOpen(true); + }); + }, [capture]); + + const handleImageLoad = () => { + // Clean up the object URL after image loads + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + urlRef.current = null; + } + }; + + return ( + <> + + +
+ {previewUrl && ( + Captured PDF area + )} +
+
+ + + + +
+ + {/* Hidden download link */} + + + ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/command-button.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/command-button.tsx new file mode 100644 index 000000000..4270b7b61 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/command-button.tsx @@ -0,0 +1,83 @@ +import { useCommand } from '@embedpdf/plugin-commands/react'; +import { useRegisterAnchor } from '@embedpdf/plugin-ui/react'; +import { twMerge } from 'tailwind-merge'; +import { ToolbarButton } from './ui'; +import * as Icons from './icons'; + +type CommandButtonProps = { + commandId: string; + documentId: string; + variant?: 'icon' | 'text' | 'icon-text' | 'tab'; + itemId?: string; // Unique ID for this button instance (for anchor registry) + className?: string; +}; + +/** + * A button that executes a command when clicked. + * Uses the useCommand hook to get the command state and execution function. + * The icon is automatically retrieved from the command definition. + * + * Automatically registers itself with the anchor registry so menus can anchor to it. + */ +export function CommandButton({ + commandId, + documentId, + variant = 'icon', + itemId, + className, +}: CommandButtonProps) { + const command = useCommand(commandId, documentId); + // Register this button with the anchor registry if itemId is provided + // This allows menus to anchor to it when opened via UI state changes + const finalItemId = itemId || commandId; + const anchorRef = useRegisterAnchor(documentId, finalItemId); + + if (!command) return null; + + // Get the icon component from the command's icon property + // Add 'Icon' suffix to match the exported icon component names + const iconName = command.icon ? `${command.icon}Icon` : null; + const IconComponent = iconName ? Icons[iconName as keyof typeof Icons] : null; + + // Get iconProps if available (for dynamic colors, etc.) + const iconProps = command.iconProps || {}; + + return ( + command.execute()} + isActive={command.active} + disabled={command.disabled || !command.visible} + aria-label={command.label} + title={command.label} + className={className} + > + {variant === 'text' ? + {command.label} + : variant === 'icon-text' ? + <> + {IconComponent && ( + + )} + {command.label} + + : variant === 'tab' ? + {command.label} + // Default: icon only + : IconComponent ? + + : {command.label}} + + ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/document-menu.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/document-menu.tsx new file mode 100644 index 000000000..bd95a335f --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/document-menu.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { useExport } from '@embedpdf/plugin-export/react'; +import { useCapture } from '@embedpdf/plugin-capture/react'; +import { useFullscreen } from '@embedpdf/plugin-fullscreen/react'; +import { + MenuIcon, + PrintIcon, + DownloadIcon, + ScreenshotIcon, + FullscreenIcon, + FullscreenExitIcon, +} from './icons'; +import { PrintDialog } from './print-dialog'; +import { CaptureDialog } from './capture-dialog'; +import { ToolbarButton, DropdownMenu, DropdownItem } from './ui'; + +type DocumentMenuProps = { + documentId: string; +}; + +export function DocumentMenu({ documentId }: DocumentMenuProps) { + const { provides: exportProvider } = useExport(documentId); + const { provides: captureProvider, state: captureState } = useCapture(documentId); + const { provides: fullscreenProvider, state: fullscreenState } = useFullscreen(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isPrintDialogOpen, setIsPrintDialogOpen] = useState(false); + + if (!exportProvider) return null; + + const handleDownload = () => { + exportProvider.download(); + setIsMenuOpen(false); + }; + + const handlePrint = () => { + setIsMenuOpen(false); + setIsPrintDialogOpen(true); + }; + + const handleScreenshot = () => { + if (captureProvider) { + captureProvider.toggleMarqueeCapture(); + } + setIsMenuOpen(false); + }; + + const handleFullscreen = () => { + fullscreenProvider?.toggleFullscreen(`#${documentId}`); + setIsMenuOpen(false); + }; + + return ( + <> +
+ setIsMenuOpen(!isMenuOpen)} + isActive={isMenuOpen} + aria-label='Document Menu' + title='Document Menu' + > + + + + setIsMenuOpen(false)} className='w-48'> + } + > + Capture Area + + } + > + Print + + } + > + Download + + + : + } + > + {fullscreenState.isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + + +
+ + {/* Print Dialog */} + setIsPrintDialogOpen(false)} + /> + + {/* Capture Dialog */} + + + ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/document-password-prompt.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/document-password-prompt.tsx new file mode 100644 index 000000000..edbc5d1fb --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/document-password-prompt.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; +import { PdfErrorCode } from '@embedpdf/models'; +import { AlertIcon } from './icons'; +import { DocumentState } from '@embedpdf/core'; + +interface DocumentPasswordPromptProps { + documentState: DocumentState; +} + +export function DocumentPasswordPrompt({ documentState }: DocumentPasswordPromptProps) { + const { provides } = useDocumentManagerCapability(); + const [password, setPassword] = useState(''); + const [isRetrying, setIsRetrying] = useState(false); + + if (!documentState) return null; + + const { name, errorCode, passwordProvided } = documentState; + + // Clean logic using state + error code! + const isPasswordError = errorCode === PdfErrorCode.Password; + const isPasswordRequired = isPasswordError && !passwordProvided; + const isPasswordIncorrect = isPasswordError && passwordProvided; + + if (!isPasswordError) { + return ( +
+
+ +

Error loading document

+

+ {documentState.error || 'An unknown error occurred'} +

+ {errorCode &&

Error Code: {errorCode}

} + +
+
+ ); + } + + const handleRetry = async () => { + if (!provides || !password.trim()) return; + setIsRetrying(true); + + const task = provides.retryDocument(documentState.id, { password }); + task.wait( + () => { + setPassword(''); + setIsRetrying(false); + }, + error => { + console.error('Retry failed:', error); + setIsRetrying(false); + }, + ); + }; + + return ( +
+
+
+
+

Password Required

+ {name &&

{name}

} +
+ +
+ + {/* Different message based on state */} +

+ {isPasswordRequired && + 'This document is password protected. Please enter the password to open it.'} + {isPasswordIncorrect && 'The password you entered was incorrect. Please try again.'} +

+ +
+ + setPassword(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !isRetrying && password.trim() && handleRetry()} + disabled={isRetrying} + placeholder='Enter document password' + className='mt-1 block w-full rounded-md border px-3 py-2' + autoFocus + /> +
+ + {/* Show error feedback for incorrect password */} + {isPasswordIncorrect && ( +
+

Incorrect password. Please check and try again.

+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/empty-state.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/empty-state.tsx new file mode 100644 index 000000000..da47b4bae --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/empty-state.tsx @@ -0,0 +1,65 @@ +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; + +interface EmptyStateProps { + onDocumentOpened?: (_documentId: string) => void; +} + +export function EmptyState({ onDocumentOpened }: EmptyStateProps) { + const { provides } = useDocumentManagerCapability(); + + const handleOpenFile = () => { + const openTask = provides?.openFileDialog(); + openTask?.wait( + result => { + onDocumentOpened?.(result.documentId); + }, + error => { + console.error('Open file failed:', error); + }, + ); + }; + + return ( +
+
+
+
+ + + + +
+
+

No Documents Open

+

+ Get started by opening a PDF document. You can view multiple documents at once using tabs. +

+ +
Supported format: PDF
+
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/icons/index.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/icons/index.tsx new file mode 100644 index 000000000..5b57d2fcd --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/icons/index.tsx @@ -0,0 +1,1390 @@ +import { CSSProperties } from 'preact'; + +type IconProps = { + className?: string; + title?: string; + style?: CSSProperties; +}; + +export function DocumentIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function CloseIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function PlusIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function HandIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + ); +} + +export function SearchMinusIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function SearchPlusIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + ); +} + +export function ChevronDownIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function ZoomChevronDownIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + ); +} + +export function FitPageIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + ); +} + +export function FitWidthIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + ); +} + +export function MarqueeIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + + + + + ); +} + +export function RotateRightIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + ); +} + +export function RotateLeftIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + ); +} + +export function SinglePageIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function BookOpenIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function SettingsIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + + + + + ); +} + +export function PrintIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function DownloadIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function ScreenshotIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + + + + + ); +} + +export function FullscreenIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + ); +} + +export function FullscreenExitIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + ); +} + +export function MenuIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function MenuDotsIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + ); +} + +export function AlertIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function RefreshIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function CheckIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function SearchIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function ThumbnailsIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function ChevronLeftIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function ChevronRightIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function TextIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + ); +} + +export function PenIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + ); +} + +export function CircleIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function SquareIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function ArrowIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function HighlightIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + ); +} + +export function LineIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + ); +} + +export function PolygonIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + ); +} + +export function SquigglyIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + ); +} + +export function StrikethroughIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function UnderlineIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + ); +} + +export function ZigzagIcon({ className, title, style }: IconProps) { + return ( + + {title ? + {title} + : null} + + + ); +} + +export function PolylineIcon({ className, title, style }: IconProps) { + return ; +} + +export function ItalicIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + ); +} + +export function SquaresIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function TrashIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + ); +} + +export function UndoIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function RedoIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function RedactTextIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function RedactAreaIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function PhotoIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + ); +} + +export function ArrowBackUpIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function ArrowForwardUpIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function PointerIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + ); +} + +export function SidebarIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} + +export function CommentIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + + + ); +} + +export function CopyIcon({ className, title }: IconProps) { + return ( + + {title ? + {title} + : null} + + + + + ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/loading-spinner.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/loading-spinner.tsx new file mode 100644 index 000000000..26037225a --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/loading-spinner.tsx @@ -0,0 +1,34 @@ +type LoadingSpinnerProps = { + size?: 'sm' | 'md' | 'lg'; + message?: string; + className?: string; +}; + +const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-8 w-8', +}; + +export function LoadingSpinner({ size = 'md', message, className = '' }: LoadingSpinnerProps) { + return ( +
+ + + + + {message && {message}} +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/page-controls.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/page-controls.tsx new file mode 100644 index 000000000..fc199d13e --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/page-controls.tsx @@ -0,0 +1,140 @@ +import { useViewportCapability } from '@embedpdf/plugin-viewport/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from './icons'; + +type PageControlsProps = { + documentId: string; +}; + +export function PageControls({ documentId }: PageControlsProps) { + const { provides: viewport } = useViewportCapability(); + const { + provides: scroll, + state: { currentPage, totalPages }, + } = useScroll(documentId); + const [isVisible, setIsVisible] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const hideTimeoutRef = useRef(null); + const [inputValue, setInputValue] = useState(currentPage.toString()); + + useEffect(() => { + setInputValue(currentPage.toString()); + }, [currentPage]); + + const startHideTimer = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + hideTimeoutRef.current = setTimeout(() => { + if (!isHovering) { + setIsVisible(false); + } + }, 4000); + }, [isHovering]); + + useEffect(() => { + if (!viewport) return; + + return viewport.onScrollActivity(activity => { + if (activity.documentId === documentId) { + setIsVisible(true); + startHideTimer(); + } + }); + }, [viewport, startHideTimer]); + + useEffect(() => { + return () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + }; + }, []); + + const handleMouseEnter = () => { + setIsHovering(true); + setIsVisible(true); + }; + + const handleMouseLeave = () => { + setIsHovering(false); + startHideTimer(); + }; + + const handlePageChange = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const pageStr = formData.get('page') as string; + const page = parseInt(pageStr); + + if (!isNaN(page) && page >= 1 && page <= totalPages) { + scroll?.scrollToPage?.({ + pageNumber: page, + }); + } + }; + + const handlePreviousPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.currentTarget.blur(); + if (currentPage > 1) { + scroll?.scrollToPreviousPage(); + } + }; + + const handleNextPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.currentTarget.blur(); + if (currentPage < totalPages) { + scroll?.scrollToNextPage(); + } + }; + + return ( +
+
+ {/* Previous Button */} + + + {/* Page Input */} +
+ { + const value = e.target.value.replace(/[^0-9]/g, ''); + setInputValue(value); + }} + className='h-7 w-10 rounded border border-gray-300 bg-white px-1 text-center text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none' + /> + {totalPages} +
+ + {/* Next Button */} + +
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/page-settings-menu.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/page-settings-menu.tsx new file mode 100644 index 000000000..a1a2b69c0 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/page-settings-menu.tsx @@ -0,0 +1,95 @@ +import { useState } from 'preact/hooks'; +import { useRotate } from '@embedpdf/plugin-rotate/react'; +import { useSpread } from '@embedpdf/plugin-spread/react'; +import { SpreadMode } from '@embedpdf/plugin-spread'; +import { + SettingsIcon, + RotateRightIcon, + RotateLeftIcon, + SinglePageIcon, + BookOpenIcon, +} from './icons'; +import { ToolbarButton, DropdownMenu, DropdownSection, DropdownItem, DropdownDivider } from './ui'; + +type PageSettingsMenuProps = { + documentId: string; +}; + +export function PageSettingsMenu({ documentId }: PageSettingsMenuProps) { + const { provides: rotate } = useRotate(documentId); + const { spreadMode, provides: spread } = useSpread(documentId); + const [isOpen, setIsOpen] = useState(false); + + if (!rotate || !spread) return null; + + return ( +
+ setIsOpen(!isOpen)} + isActive={isOpen} + aria-label='Page Settings' + title='Page Settings' + > + + + + setIsOpen(false)} className='w-56'> + + { + rotate.rotateForward(); + setIsOpen(false); + }} + icon={} + > + Rotate Clockwise + + { + rotate.rotateBackward(); + setIsOpen(false); + }} + icon={} + > + Rotate Counter-clockwise + + + + + + + { + spread.setSpreadMode(SpreadMode.None); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.None} + > + Single Page + + { + spread.setSpreadMode(SpreadMode.Odd); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.Odd} + > + Odd Pages + + { + spread.setSpreadMode(SpreadMode.Even); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.Even} + > + Even Pages + + + +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/pan-toggle.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/pan-toggle.tsx new file mode 100644 index 000000000..dfcbdc271 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/pan-toggle.tsx @@ -0,0 +1,24 @@ +import { usePan } from '@embedpdf/plugin-pan/react'; +import { HandIcon } from './icons'; +import { ToolbarButton } from './ui'; + +type PanToggleButtonProps = { + documentId: string; +}; + +export function PanToggleButton({ documentId }: PanToggleButtonProps) { + const { provides: pan, isPanning } = usePan(documentId); + + if (!pan) return null; + + return ( + + + + ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/pdf-picker.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/pdf-picker.tsx new file mode 100644 index 000000000..e988771ac --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/pdf-picker.tsx @@ -0,0 +1,76 @@ +import { useState, useMemo } from 'preact/hooks'; +import { ToolbarButton, DropdownMenu, DropdownItem } from './ui'; +import { DocumentIcon } from './icons'; + +type PdfPickerProps = { + pdfs?: Array<{ id: string; fileName: string; tag?: string }>; + selectedPdfId?: string | null; + onPdfSelect?: (_pdfId: string) => void; +}; + +export function PdfPicker({ pdfs, selectedPdfId, onPdfSelect }: PdfPickerProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + // Don't show if there's only one or no PDFs + if (!pdfs || pdfs.length <= 1) { + return null; + } + + // Sort PDFs: primary first, then protocol, then secondary + const sortedPdfs = useMemo(() => { + return [...pdfs].sort((a, b) => { + const tagOrder = { primary: 0, protocol: 1, secondary: 2 }; + const tagA = tagOrder[a.tag as keyof typeof tagOrder] ?? 2; + const tagB = tagOrder[b.tag as keyof typeof tagOrder] ?? 2; + return tagA - tagB; + }); + }, [pdfs]); + + // Find the selected PDF + const selectedPdf = sortedPdfs.find(pdf => pdf.id === selectedPdfId); + + const handleSelect = (pdfId: string) => { + if (onPdfSelect) { + onPdfSelect(pdfId); + } + setIsMenuOpen(false); + }; + + // Format display name with tag + const formatDisplayName = (pdf: { fileName: string; tag?: string }) => { + if (pdf.tag === 'primary') { + return `${pdf.fileName} (Primary)`; + } + if (pdf.tag === 'protocol') { + return `${pdf.fileName} (Protocol)`; + } + return pdf.fileName; + }; + + return ( +
+ setIsMenuOpen(!isMenuOpen)} + isActive={isMenuOpen} + aria-label='Select PDF' + title={selectedPdf ? formatDisplayName(selectedPdf) : 'Select PDF'} + > + + + {pdfs.length} + + + + setIsMenuOpen(false)} className='w-64'> + {sortedPdfs.map(pdf => { + const isSelected = pdf.id === selectedPdfId; + return ( + handleSelect(pdf.id)} isActive={isSelected}> + {formatDisplayName(pdf)} + + ); + })} + +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/print-dialog.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/print-dialog.tsx new file mode 100644 index 000000000..1b99bc9a5 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/print-dialog.tsx @@ -0,0 +1,163 @@ +import { useState, useEffect } from 'preact/hooks'; +import { usePrint } from '@embedpdf/plugin-print/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import type { PdfPrintOptions } from '@embedpdf/models'; +import { Dialog, DialogContent, DialogFooter, Button } from './ui'; + +type PageSelection = 'all' | 'current' | 'custom'; + +type PrintDialogProps = { + documentId: string; + isOpen: boolean; + onClose: () => void; +}; + +export function PrintDialog({ documentId, isOpen, onClose }: PrintDialogProps) { + const { provides: print } = usePrint(documentId); + const { state: scrollState } = useScroll(documentId); + + const [selection, setSelection] = useState('all'); + const [customPages, setCustomPages] = useState(''); + const [includeAnnotations, setIncludeAnnotations] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + // Reset form when dialog opens/closes + useEffect(() => { + if (!isOpen) { + setSelection('all'); + setCustomPages(''); + setIncludeAnnotations(true); + setIsLoading(false); + } + }, [isOpen]); + + if (!isOpen) return null; + + const canSubmit = !isLoading && (selection !== 'custom' || customPages.trim().length > 0); + + const handlePrint = async () => { + if (!print || !canSubmit) return; + + setIsLoading(true); + + let pageRange: string | undefined; + + if (selection === 'current') { + pageRange = String(scrollState.currentPage); + } else if (selection === 'custom') { + pageRange = customPages.trim() || undefined; + } + + const options: PdfPrintOptions = { + includeAnnotations, + pageRange, + }; + + try { + const task = print.print(options); + + if (task) { + task.wait( + () => { + onClose(); + }, + error => { + console.error('Print failed:', error); + setIsLoading(false); + }, + ); + } + } catch (err) { + console.error('Print failed:', err); + setIsLoading(false); + } + }; + + return ( + + + {/* Pages to print */} +
+ +
+ + + + + +
+ + {/* Custom page range input */} +
+ setCustomPages(e.target.value)} + placeholder='e.g., 1-3, 5, 8-10' + disabled={selection !== 'custom'} + className='w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-500' + /> + {customPages.trim() && scrollState.totalPages > 0 && ( +

+ Total pages in document: {scrollState.totalPages} +

+ )} +
+
+ + {/* Include annotations */} +
+ +
+
+ + + + +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/redaction-selection-menu.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/redaction-selection-menu.tsx new file mode 100644 index 000000000..8f8b657aa --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/redaction-selection-menu.tsx @@ -0,0 +1,69 @@ +import { + useRedactionCapability, + type RedactionSelectionMenuProps, +} from '@embedpdf/plugin-redaction/react'; +import { CheckIcon, TrashIcon } from './icons'; + +interface Props extends RedactionSelectionMenuProps { + documentId: string; +} + +export function RedactionSelectionMenu({ + selected, + context, + documentId, + menuWrapperProps, + rect, +}: Props) { + const { provides: redactionCapability } = useRedactionCapability(); + + // Get document-scoped annotation API + const redactionScope = redactionCapability?.forDocument(documentId); + + const handleDelete = () => { + if (!redactionScope) return; + const { page, id } = context.item; + redactionScope.removePending(page, id); + }; + + const handleCommit = () => { + if (!redactionScope) return; + const { page, id } = context.item; + redactionScope.commitPending(page, 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/redaction-toolbar.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/redaction-toolbar.tsx new file mode 100644 index 000000000..47e56d4ca --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/redaction-toolbar.tsx @@ -0,0 +1,82 @@ +import { RedactionMode, useRedaction } from '@embedpdf/plugin-redaction/react'; +import { ToolbarButton } from './ui'; +import { CheckIcon, CloseIcon, RedactTextIcon, RedactAreaIcon } from './icons'; + +type RedactionToolbarProps = { + documentId: string; +}; + +export function RedactionToolbar({ documentId }: RedactionToolbarProps) { + const { provides, state } = useRedaction(documentId); + + if (!provides) return null; + + const handleTextRedact = () => { + provides.toggleRedactSelection(); + }; + + const handleAreaRedact = () => { + provides.toggleMarqueeRedact(); + }; + + const handleCommitPending = () => { + provides.commitAllPending(); + }; + + const handleClearPending = () => { + provides.clearPending(); + }; + + return ( +
+ {/* Redaction Mode Toggles */} + + + + + + + + + {/* Divider */} +
+ + {/* Action Buttons */} + + + + + {state.pendingCount > 0 && ( + + {state.pendingCount} pending redaction{state.pendingCount !== 1 ? 's' : ''} + + )} +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/search-sidebar.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/search-sidebar.tsx new file mode 100644 index 000000000..5141983e6 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/search-sidebar.tsx @@ -0,0 +1,259 @@ +import { useSearch } from '@embedpdf/plugin-search/react'; +import { useScrollCapability } from '@embedpdf/plugin-scroll/react'; +import { useState, useRef, useEffect } from 'preact/hooks'; +import { MatchFlag } from '@embedpdf/models'; +import { SearchResult } from '@embedpdf/models'; +import { SearchIcon, CloseIcon, ChevronRightIcon, ChevronLeftIcon } from './icons'; +import { useTranslations } from '@embedpdf/plugin-i18n/react'; +import { TargetedEvent } from 'preact'; + +const HitLine = ({ + hit, + onClick, + active, +}: { + hit: SearchResult; + onClick: () => void; + active: boolean; +}) => { + const ref = useRef(null); + + useEffect(() => { + if (active && ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [active]); + + return ( + + ); +}; + +type SearchSidebarProps = { + documentId: string; + onClose?: () => void; +}; + +export function SearchSidebar({ documentId, onClose }: SearchSidebarProps) { + const { state, provides } = useSearch(documentId); + const { provides: scroll } = useScrollCapability(); + const { translate } = useTranslations(documentId); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + + // Sync inputValue with persisted state.query when state loads + useEffect(() => { + setInputValue(state.query || ''); + }, [state.query, documentId]); // Include documentId to reset on tab change + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [provides]); + + useEffect(() => { + if (state.activeResultIndex !== undefined && state.activeResultIndex >= 0 && !state.loading) { + scrollToItem(state.activeResultIndex); + } + }, [state.activeResultIndex, state.loading, state.query, state.flags]); + + const handleInputChange = (e: TargetedEvent) => { + const value = (e.target as HTMLInputElement).value; + setInputValue(value); + + // Trigger search immediately on user input + if (value === '') { + provides?.stopSearch(); + } else { + provides?.searchAllPages(value); + } + }; + + const handleFlagChange = (flag: MatchFlag, checked: boolean) => { + if (checked) { + provides?.setFlags([...state.flags, flag]); + } else { + provides?.setFlags(state.flags.filter(f => f !== flag)); + } + }; + + const clearInput = () => { + setInputValue(''); + provides?.stopSearch(); + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const scrollToItem = (index: number) => { + const item = state.results[index]; + if (!item) return; + + const minCoordinates = item.rects.reduce( + (min, rect) => ({ + x: Math.min(min.x, rect.origin.x), + y: Math.min(min.y, rect.origin.y), + }), + { x: Infinity, y: Infinity }, + ); + + scroll?.forDocument(documentId).scrollToPage({ + pageNumber: item.pageIndex + 1, + pageCoordinates: minCoordinates, + // center: true, + }); + }; + + const groupByPage = (results: SearchResult[]) => { + return results.reduce>( + (map, r, i) => { + (map[r.pageIndex] ??= []).push({ hit: r, index: i }); + return map; + }, + {}, + ); + }; + + if (!provides) return null; + + const grouped = groupByPage(state.results); + + return ( +
+ {/* Header */} +
+

{translate('search.title')}

+ +
+ + {/* Search Input */} +
+
+
+ +
+ + {inputValue && ( + + )} +
+ + {/* Options */} +
+ + +
+ + {/* Results count and navigation */} + {state.active && !state.loading && state.total > 0 && ( +
+ + {translate('search.resultsFound', { params: { count: state.total } })} + + {state.total > 1 && ( +
+ + +
+ )} +
+ )} +
+ + {/* Results */} +
+ {state.loading ? +
+
+
+ :
+ {Object.entries(grouped).map(([page, hits]) => ( +
+
+ {translate('search.page', { params: { number: Number(page) + 1 } })} +
+
+ {hits.map(({ hit, index }) => ( + provides.goToResult(index)} + /> + ))} +
+
+ ))} +
+ } +
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/selection-selection-menu.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/selection-selection-menu.tsx new file mode 100644 index 000000000..b492f1cac --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/selection-selection-menu.tsx @@ -0,0 +1,78 @@ +import { SelectionSelectionMenuProps } from '@embedpdf/plugin-selection/react'; +import { useSelectionCapability } from '@embedpdf/plugin-selection/react'; +import { useState, useEffect } from 'react'; +import { SquaresIcon, CheckIcon } from './icons'; + +export interface Props extends SelectionSelectionMenuProps { + documentId: string; +} + +export function SelectionSelectionMenu({ rect, menuWrapperProps, placement, documentId }: Props) { + const { provides: selectionCapability } = useSelectionCapability(); + const [copied, setCopied] = useState(false); + + // Reset copied state when placement changes + useEffect(() => { + setCopied(false); + }, [placement]); + + const handleCopy = () => { + if (!selectionCapability) return; + + const scope = selectionCapability.forDocument(documentId); + if (!scope) return; + + // Copy to clipboard + scope.copyToClipboard(); + + // clear selection + scope.clear(); + + // Show feedback + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1500); + }; + + // Calculate position based on suggestTop + const menuStyle: React.CSSProperties = { + position: 'absolute', + pointerEvents: 'auto', + cursor: 'default', + }; + + if (placement.suggestTop) { + // Position above the selection + menuStyle.top = -40 - 8; + } else { + // Position below the selection (default) + menuStyle.top = rect.size.height + 8; + } + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/tab-context-menu.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/tab-context-menu.tsx new file mode 100644 index 000000000..539703fa5 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/tab-context-menu.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'preact/hooks'; +import { DocumentState } from '@embedpdf/core'; +import { useViewManagerCapability, useAllViews } from '@embedpdf/plugin-view-manager/react'; + +interface TabContextMenuProps { + documentState: DocumentState; + currentViewId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +export function TabContextMenu({ + documentState, + currentViewId, + position, + onClose, +}: TabContextMenuProps) { + const menuRef = useRef(null); + const { provides: viewManager } = useViewManagerCapability(); + const allViews = useAllViews(); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose]); + + const handleOpenInNewView = () => { + if (!viewManager) return; + + const newViewId = viewManager.createView(); + viewManager.addDocumentToView(newViewId, documentState.id); + viewManager.removeDocumentFromView(currentViewId, documentState.id); + viewManager.setFocusedView(newViewId); + onClose(); + }; + + const handleMoveToView = (targetViewId: string) => { + if (!viewManager) return; + viewManager.moveDocumentBetweenViews(currentViewId, targetViewId, documentState.id); + viewManager.setFocusedView(targetViewId); + viewManager.setViewActiveDocument(targetViewId, documentState.id); + onClose(); + }; + + const otherViews = allViews.filter(v => v.id !== currentViewId); + + return ( +
+
+ + + {otherViews.length > 0 && ( + <> +
+
Move to View
+ {otherViews.map((view, index) => ( + + ))} + + )} +
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/thumbnails-sidebar.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/thumbnails-sidebar.tsx new file mode 100644 index 000000000..e71f88644 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/thumbnails-sidebar.tsx @@ -0,0 +1,77 @@ +import { ThumbnailsPane, ThumbImg } from '@embedpdf/plugin-thumbnail/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; + +type ThumbnailsSidebarProps = { + documentId: string; + onClose?: () => void; +}; + +export function ThumbnailsSidebar({ documentId, _onClose }: ThumbnailsSidebarProps) { + const { state, provides } = useScroll(documentId); + + return ( +
+ {/* Thumbnails */} +
+ + {m => ( +
{ + provides?.scrollToPage?.({ + pageNumber: m.pageIndex + 1, + }); + }} + > +
+ +
+
+ {m.pageIndex + 1} +
+
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/button.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/button.tsx new file mode 100644 index 000000000..687e0d711 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/button.tsx @@ -0,0 +1,61 @@ +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +type ButtonVariant = 'default' | 'primary' | 'secondary' | 'ghost'; + +type ButtonProps = ButtonHTMLAttributes & { + children: ReactNode; + variant?: ButtonVariant; + active?: boolean; + tooltip?: string; +}; + +const variantStyles: Record = { + default: 'hover:bg-gray-100 hover:ring hover:ring-[#1a466b]', + primary: + 'bg-blue-600 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50', + secondary: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50', + ghost: 'text-gray-700 hover:bg-gray-100 disabled:opacity-50', +}; + +export function Button({ + children, + onClick, + variant = 'default', + active = false, + disabled = false, + className = '', + tooltip, + ...props +}: ButtonProps) { + // For default variant with active state (toolbar buttons) + if (variant === 'default') { + return ( + + ); + } + + // For other variants (dialog buttons) + return ( + + ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog-content.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog-content.tsx new file mode 100644 index 000000000..c662899f3 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog-content.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; + +type DialogContentProps = { + children: ReactNode; + className?: string; +}; + +export function DialogContent({ children, className = '' }: DialogContentProps) { + return
{children}
; +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog-footer.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog-footer.tsx new file mode 100644 index 000000000..13d9582ae --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog-footer.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; + +type DialogFooterProps = { + children: ReactNode; + className?: string; +}; + +export function DialogFooter({ children, className = '' }: DialogFooterProps) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog.tsx new file mode 100644 index 000000000..d67d5b442 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dialog.tsx @@ -0,0 +1,102 @@ +import { ReactNode, useEffect, useRef } from 'react'; +import { CloseIcon } from '../icons'; + +export interface DialogProps { + /** Controlled visibility — `true` shows, `false` hides */ + open: boolean; + /** Dialog title */ + title?: string; + /** Dialog content */ + children: ReactNode; + /** Callback when dialog should close */ + onClose?: () => void; + /** Optional className for the dialog content */ + className?: string; + /** Whether to show close button */ + showCloseButton?: boolean; + /** Maximum width of the dialog */ + maxWidth?: string; +} + +export function Dialog({ + open, + title, + children, + onClose, + className, + showCloseButton = true, + maxWidth = '32rem', +}: DialogProps) { + const overlayRef = useRef(null); + + // Handle escape key + useEffect(() => { + if (!open) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose?.(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [open, onClose]); + + // Handle backdrop click + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) { + onClose?.(); + } + }; + + // Prevent body scroll when dialog is open + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} + {(title || showCloseButton) && ( +
+ {title &&

{title}

} + {showCloseButton && ( + + )} +
+ )} + + {/* Content */} +
+ {children} +
+
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dropdown-menu.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..041e257c3 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from 'react'; + +type DropdownMenuProps = { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + className?: string; +}; + +export function DropdownMenu({ isOpen, onClose, children, className = '' }: DropdownMenuProps) { + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Menu */} +
+ {children} +
+ + ); +} + +type DropdownItemProps = { + onClick: () => void; + icon?: ReactNode; + children: ReactNode; + isActive?: boolean; +}; + +export function DropdownItem({ onClick, icon, children, isActive = false }: DropdownItemProps) { + return ( + + ); +} + +type DropdownSectionProps = { + title?: string; + children: ReactNode; +}; + +export function DropdownSection({ title, children }: DropdownSectionProps) { + return ( + <> + {title && ( +
+ {title} +
+ )} + {children} + + ); +} + +export function DropdownDivider() { + return
; +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/index.ts b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/index.ts new file mode 100644 index 000000000..f2d8aa93e --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/index.ts @@ -0,0 +1,7 @@ +export * from './toolbar-button'; +export * from './dropdown-menu'; +export * from './dialog'; +export * from './dialog-content'; +export * from './dialog-footer'; +export * from './button'; +export * from './toolbar-divider'; diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/toolbar-button.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/toolbar-button.tsx new file mode 100644 index 000000000..3d8dc8963 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/toolbar-button.tsx @@ -0,0 +1,61 @@ +import { ReactNode, forwardRef } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type ToolbarButtonProps = { + onClick?: () => void; + isActive?: boolean; + disabled?: boolean; + children: ReactNode; + 'aria-label'?: string; + title?: string; + className?: string; +}; + +export const ToolbarButton = forwardRef( + ( + { + onClick, + isActive = false, + disabled = false, + children, + 'aria-label': ariaLabel, + title, + className = '', + }, + ref, + ) => { + const baseClasses = + isActive ? + 'border-none bg-blue-50 text-blue-500 shadow ring ring-blue-500' + : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 hover:ring hover:ring-[#1a466b]'; + + const disabledClasses = + disabled ? + 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-600 hover:ring-0' + : ''; + + const mergedClasses = twMerge( + 'rounded p-1.5 transition-colors', + baseClasses, + disabledClasses, + className, + ); + + return ( + + ); + }, +); + +ToolbarButton.displayName = 'ToolbarButton'; diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/toolbar-divider.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/toolbar-divider.tsx new file mode 100644 index 000000000..88518b98d --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/ui/toolbar-divider.tsx @@ -0,0 +1,13 @@ +type ToolbarDividerProps = { + orientation?: 'vertical' | 'horizontal'; + className?: string; +}; + +export function ToolbarDivider({ orientation = 'vertical', className = '' }: ToolbarDividerProps) { + const dividerClasses = + orientation === 'horizontal' ? + `my-1 h-px w-full bg-gray-300 ${className}` + : `mx-1 h-6 w-px bg-gray-300 ${className}`; + + return
; +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/viewer-toolbar.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/viewer-toolbar.tsx new file mode 100644 index 000000000..3f398a4d9 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/viewer-toolbar.tsx @@ -0,0 +1,119 @@ +import { ZoomToolbar } from './zoom-toolbar'; +import { PanToggleButton } from './pan-toggle'; +import { PageSettingsMenu } from './page-settings-menu'; +import { DocumentMenu } from './document-menu'; +import { SearchIcon, ThumbnailsIcon } from './icons'; +import { ToolbarButton, ToolbarDivider } from './ui'; +import { RedactionToolbar } from './redaction-toolbar'; +import { AnnotationToolbar } from './annotation-toolbar'; +import { PdfPicker } from './pdf-picker'; + +export type ViewMode = 'view' | 'annotate' | 'redact'; + +type ViewerToolbarProps = { + documentId: string; + onToggleSearch: () => void; + onToggleThumbnails: () => void; + isSearchOpen: boolean; + isThumbnailsOpen: boolean; + mode: ViewMode; + onModeChange: (_mode: ViewMode) => void; + pdfs?: Array<{ id: string; fileName: string; tag?: string }>; + selectedPdfId?: string | null; + onPdfSelect?: (_pdfId: string) => void; + readOnly?: boolean; +}; + +export function ViewerToolbar({ + documentId, + onToggleSearch, + onToggleThumbnails, + isSearchOpen, + isThumbnailsOpen, + mode, + onModeChange, + pdfs, + selectedPdfId, + onPdfSelect, + readOnly = false, +}: ViewerToolbarProps) { + return ( + <> + {/* Main Toolbar */} +
+ {/* Left side - Document menu and Thumbnails toggle */} + + + + + + + + + {/* Center - Zoom toolbar */} + + + + + + + {/* Mode Tabs - Hidden in read-only mode */} + {!readOnly && ( +
+
+ + + {/* */} +
+
+ )} + + {/* Right side - Search toggle */} + + + +
+ + {/* Redaction Toolbar */} + {mode === 'redact' && } + {mode === 'annotate' && } + + ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/components/zoom-toolbar.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/components/zoom-toolbar.tsx new file mode 100644 index 000000000..c1ebcb1be --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/components/zoom-toolbar.tsx @@ -0,0 +1,160 @@ +import { useZoom } from '@embedpdf/plugin-zoom/react'; +import { ZoomMode } from '@embedpdf/plugin-zoom'; +import { useState } from 'react'; +import { + ChevronDownIcon, + FitPageIcon, + FitWidthIcon, + SearchMinusIcon, + SearchPlusIcon, + MarqueeIcon, +} from './icons'; +import { DropdownMenu, DropdownItem, DropdownDivider } from './ui'; + +interface ZoomToolbarProps { + documentId: string; +} + +interface ZoomPreset { + value: number; + label: string; +} + +interface ZoomModeItem { + value: ZoomMode; + label: string; +} + +const ZOOM_PRESETS: ZoomPreset[] = [ + { value: 0.5, label: '50%' }, + { value: 1, label: '100%' }, + { value: 1.5, label: '150%' }, + { value: 2, label: '200%' }, + { value: 4, label: '400%' }, + { value: 8, label: '800%' }, +]; + +const ZOOM_MODES: ZoomModeItem[] = [ + { value: ZoomMode.FitPage, label: 'Fit to Page' }, + { value: ZoomMode.FitWidth, label: 'Fit to Width' }, +]; + +export function ZoomToolbar({ documentId }: ZoomToolbarProps) { + const { state, provides } = useZoom(documentId); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + if (!provides) return null; + + const zoomPercentage = Math.round(state.currentZoomLevel * 100); + + const handleZoomIn = () => { + provides.zoomIn(); + setIsMenuOpen(false); + }; + + const handleZoomOut = () => { + provides.zoomOut(); + setIsMenuOpen(false); + }; + + const handleSelectZoom = (value: number | ZoomMode) => { + provides.requestZoom(value); + setIsMenuOpen(false); + }; + + const handleToggleMarquee = () => { + provides.toggleMarqueeZoom(); + setIsMenuOpen(false); + }; + + return ( +
+
+ {/* Zoom Out Button */} + + + {/* Zoom Percentage Display */} + + + {/* Zoom In Button */} + +
+ + setIsMenuOpen(false)} className='w-48'> + } + > + Zoom In + + } + > + Zoom Out + + + + + {/* Zoom Presets */} + {ZOOM_PRESETS.map(({ value, label }) => ( + handleSelectZoom(value)} + isActive={Math.abs(state.currentZoomLevel - value) < 0.01} + > + {label} + + ))} + + + + {/* Zoom Modes */} + {ZOOM_MODES.map(({ value, label }) => ( + handleSelectZoom(value)} + icon={ + value === ZoomMode.FitPage ? + + : + } + isActive={state.zoomLevel === value} + > + {label} + + ))} + + + + } + isActive={state.isMarqueeZoomActive} + > + Marquee Zoom + + +
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/config/commands.ts b/packages/web/src/components/pdf/embedpdf/preact/src/config/commands.ts new file mode 100644 index 000000000..2d3781e91 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/config/commands.ts @@ -0,0 +1,1180 @@ +import { Command } from '@embedpdf/plugin-commands/preact'; +import { CapturePlugin } from '@embedpdf/plugin-capture/preact'; +import { ZoomMode, ZoomPlugin } from '@embedpdf/plugin-zoom/preact'; +import { PanPlugin } from '@embedpdf/plugin-pan/preact'; +import { SpreadMode, SpreadPlugin } from '@embedpdf/plugin-spread/preact'; +import { RotatePlugin } from '@embedpdf/plugin-rotate/preact'; +import { + ANNOTATION_PLUGIN_ID, + AnnotationPlugin, + getToolDefaultsById, +} from '@embedpdf/plugin-annotation/preact'; +// import { +// REDACTION_PLUGIN_ID, +// RedactionMode, +// RedactionPlugin, +// } from '@embedpdf/plugin-redaction/react'; +import { PrintPlugin } from '@embedpdf/plugin-print/preact'; +import { ExportPlugin } from '@embedpdf/plugin-export/preact'; +import { DocumentManagerPlugin } from '@embedpdf/plugin-document-manager/preact'; +import { HISTORY_PLUGIN_ID, HistoryPlugin } from '@embedpdf/plugin-history/preact'; +import { State } from './types'; +import { isSidebarOpen, isToolbarOpen, UI_PLUGIN_ID, UIPlugin } from '@embedpdf/plugin-ui'; +import { ScrollPlugin, ScrollStrategy } from '@embedpdf/plugin-scroll/preact'; +import { InteractionManagerPlugin } from '@embedpdf/plugin-interaction-manager'; +import { SelectionPlugin } from '@embedpdf/plugin-selection/preact'; + +export const commands: Record> = { + // ───────────────────────────────────────────────────────── + // Zoom Commands + // ───────────────────────────────────────────────────────── + 'zoom:in': { + id: 'zoom:in', + labelKey: 'zoom.in', + icon: 'SearchPlus', + shortcuts: ['Ctrl+=', 'Meta+=', 'Ctrl+NumpadAdd', 'Meta+NumpadAdd'], + categories: ['view'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.zoomIn(); + }, + }, + + 'zoom:out': { + id: 'zoom:out', + labelKey: 'zoom.out', + icon: 'SearchMinus', + shortcuts: ['Ctrl+-', 'Meta+-', 'Ctrl+NumpadSubtract', 'Meta+NumpadSubtract'], + categories: ['view'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.zoomOut(); + }, + }, + + 'zoom:fit-page': { + id: 'zoom:fit-page', + labelKey: 'zoom.fitPage', + icon: 'FitPage', + shortcuts: ['Ctrl+0', 'Meta+0'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(ZoomMode.FitPage); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === ZoomMode.FitPage, + }, + + 'zoom:fit-width': { + id: 'zoom:fit-width', + labelKey: 'zoom.fitWidth', + icon: 'FitWidth', + shortcuts: ['Ctrl+1', 'Meta+1'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(ZoomMode.FitWidth); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === ZoomMode.FitWidth, + }, + + 'zoom:marquee': { + id: 'zoom:marquee', + labelKey: 'zoom.marquee', + icon: 'Marquee', + shortcuts: ['Ctrl+M', 'Meta+M'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.toggleMarqueeZoom(); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.isMarqueeZoomActive ?? false, + }, + + 'zoom:25': { + id: 'zoom:25', + label: '25%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(0.25); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 0.25, + }, + + 'zoom:50': { + id: 'zoom:50', + label: '50%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(0.5); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 0.5, + }, + + 'zoom:100': { + id: 'zoom:100', + label: '100%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(1); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 1, + }, + + 'zoom:125': { + id: 'zoom:125', + label: '125%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(1.25); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 1.25, + }, + + 'zoom:150': { + id: 'zoom:150', + label: '150%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(1.5); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 1.5, + }, + + 'zoom:200': { + id: 'zoom:200', + label: '200%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(2); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 2, + }, + + 'zoom:400': { + id: 'zoom:400', + label: '400%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(4); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 4, + }, + + 'zoom:800': { + id: 'zoom:800', + label: '800%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(8); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 8, + }, + + 'zoom:1600': { + id: 'zoom:1600', + label: '1600%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(16); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 16, + }, + + 'zoom:toggle-menu': { + id: 'zoom:toggle-menu', + labelKey: 'zoom.menu', + icon: 'ZoomChevronDown', + iconProps: { + className: 'h-3.5 w-3.5', + }, + categories: ['tools'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + const scope = ui.forDocument(documentId); + scope.toggleMenu('zoom-menu', 'zoom:toggle-menu', 'zoom-menu-button'); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['zoom-menu'] !== undefined; + }, + }, + + 'zoom:toggle-menu-mobile': { + id: 'zoom:toggle-menu-mobile', + labelKey: 'zoom.menu', + icon: 'SearchPlus', + categories: ['tools'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + const scope = ui.forDocument(documentId); + scope.toggleMenu('zoom-menu', 'zoom:toggle-menu-mobile', 'zoom-menu-button'); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['zoom-menu'] !== undefined; + }, + }, + + // ───────────────────────────────────────────────────────── + // Pan Command + // ───────────────────────────────────────────────────────── + 'pan:toggle': { + id: 'pan:toggle', + labelKey: 'pan.toggle', + icon: 'Hand', + shortcuts: ['h'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const pan = registry.getPlugin('pan')?.provides(); + if (!pan) return; + + const scope = pan.forDocument(documentId); + scope.togglePan(); + }, + active: ({ state, documentId }) => + state.plugins['pan']?.documents[documentId]?.isPanMode ?? false, + }, + + // ───────────────────────────────────────────────────────── + // Pointer Command + // ───────────────────────────────────────────────────────── + 'pointer:toggle': { + id: 'pointer:toggle', + labelKey: 'pointer.toggle', + icon: 'Pointer', + shortcuts: ['p'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const pointer = registry + .getPlugin('interaction-manager') + ?.provides(); + if (!pointer) return; + + const scope = pointer.forDocument(documentId); + scope.activate('pointerMode'); + }, + active: ({ state, documentId }) => + state.plugins['interaction-manager']?.documents[documentId]?.activeMode === 'pointerMode', + }, + + // ───────────────────────────────────────────────────────── + // Capture Command + // ───────────────────────────────────────────────────────── + 'capture:screenshot': { + id: 'capture:screenshot', + labelKey: 'capture.screenshot', + icon: 'Screenshot', + shortcuts: ['Ctrl+Shift+S', 'Meta+Shift+S'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const capture = registry.getPlugin('capture')?.provides(); + if (!capture) return; + + const scope = capture.forDocument(documentId); + if (scope.isMarqueeCaptureActive()) { + scope.disableMarqueeCapture(); + } else { + scope.enableMarqueeCapture(); + } + }, + active: ({ state, documentId }) => + state.plugins['interaction-manager'].documents[documentId]?.activeMode === 'marqueeCapture', + }, + + // ───────────────────────────────────────────────────────── + // Document Commands + // ───────────────────────────────────────────────────────── + 'document:menu': { + id: 'document:menu', + labelKey: 'document.menu', + icon: 'Menu', + categories: ['document'], + action: ({ registry, documentId }) => { + // Toggle the document menu via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleMenu( + 'document-menu', + 'document:menu', + 'document-menu-button', // Must match the item ID in ui-schema + ); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['document-menu'] !== undefined; + }, + }, + + 'document:open': { + id: 'document:open', + labelKey: 'document.open', + icon: 'Document', + shortcuts: ['Ctrl+O', 'Meta+O'], + categories: ['document'], + action: ({ registry }) => { + const docManager = registry.getPlugin('document-manager')?.provides(); + docManager?.openFileDialog(); + }, + }, + + 'document:close': { + id: 'document:close', + labelKey: 'document.close', + icon: 'Close', + shortcuts: ['Ctrl+W', 'Meta+W'], + categories: ['document'], + action: ({ registry, documentId }) => { + const docManager = registry.getPlugin('document-manager')?.provides(); + docManager?.closeDocument(documentId); + }, + }, + + 'document:print': { + id: 'document:print', + labelKey: 'document.print', + icon: 'Print', + shortcuts: ['Ctrl+P', 'Meta+P'], + categories: ['document'], + action: ({ registry, documentId }) => { + const print = registry.getPlugin('print')?.provides(); + print?.forDocument(documentId).print(); + }, + }, + + 'document:export': { + id: 'document:export', + labelKey: 'document.export', + icon: 'Download', + categories: ['document'], + action: ({ registry, documentId }) => { + const exportPlugin = registry.getPlugin('export')?.provides(); + exportPlugin?.forDocument(documentId).download(); + }, + }, + + 'document:properties': { + id: 'document:properties', + labelKey: 'document.properties', + icon: 'Alert', + categories: ['document'], + action: () => { + console.log('Document properties clicked'); + }, + }, + + // ───────────────────────────────────────────────────────── + // Panel Commands + // ───────────────────────────────────────────────────────── + 'panel:toggle-sidebar': { + id: 'panel:toggle-sidebar', + labelKey: 'panel.sidebar', + icon: 'Sidebar', + categories: ['panels'], + action: ({ registry, documentId }) => { + // Toggle the thumbnails panel via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleSidebar('left', 'main', 'sidebar-panel'); + }, + active: ({ state, documentId }) => { + return isSidebarOpen(state.plugins, documentId, 'left', 'main', 'sidebar-panel'); + }, + }, + + 'panel:toggle-search': { + id: 'panel:toggle-search', + labelKey: 'panel.search', + icon: 'Search', + shortcuts: ['Ctrl+F', 'Meta+F'], + categories: ['panels'], + action: ({ registry, documentId }) => { + // Toggle the search panel via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleSidebar('right', 'main', 'search-panel'); + }, + active: ({ state, documentId }) => { + return isSidebarOpen(state.plugins, documentId, 'right', 'main', 'search-panel'); + }, + }, + + 'panel:toggle-comment': { + id: 'panel:toggle-comment', + labelKey: 'panel.comment', + icon: 'Comment', + categories: ['panels'], + action: ({ registry, documentId }) => { + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleSidebar('right', 'main', 'comment-panel'); + }, + active: ({ state, documentId }) => { + return isSidebarOpen(state.plugins, documentId, 'right', 'main', 'comment-panel'); + }, + }, + + // ───────────────────────────────────────────────────────── + // Page Settings Commands + // ───────────────────────────────────────────────────────── + 'page:settings': { + id: 'page:settings', + labelKey: 'page.settings', + icon: 'Settings', + categories: ['page'], + action: ({ registry, documentId }) => { + // Toggle the page settings menu via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleMenu( + 'page-settings-menu', + 'page:settings', + 'page-settings-button', // Must match the item ID in ui-schema + ); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['page-settings-menu'] !== undefined; + }, + }, + + 'spread:none': { + id: 'spread:none', + labelKey: 'page.single', + categories: ['page'], + action: ({ registry, documentId }) => { + const spread = registry.getPlugin('spread')?.provides(); + spread?.forDocument(documentId).setSpreadMode(SpreadMode.None); + }, + active: ({ state, documentId }) => + state.plugins['spread']?.documents[documentId]?.spreadMode === SpreadMode.None, + }, + + 'spread:odd': { + id: 'spread:odd', + labelKey: 'page.twoOdd', + categories: ['page'], + action: ({ registry, documentId }) => { + const spread = registry.getPlugin('spread')?.provides(); + spread?.forDocument(documentId).setSpreadMode(SpreadMode.Odd); + }, + active: ({ state, documentId }) => + state.plugins['spread']?.documents[documentId]?.spreadMode === SpreadMode.Odd, + }, + + 'spread:even': { + id: 'spread:even', + labelKey: 'page.twoEven', + categories: ['page'], + action: ({ registry, documentId }) => { + const spread = registry.getPlugin('spread')?.provides(); + spread?.forDocument(documentId).setSpreadMode(SpreadMode.Even); + }, + active: ({ state, documentId }) => + state.plugins['spread']?.documents[documentId]?.spreadMode === SpreadMode.Even, + }, + + 'rotate:clockwise': { + id: 'rotate:clockwise', + labelKey: 'rotate.clockwise', + icon: 'RotateRight', + shortcuts: ['Ctrl+]', 'Meta+]'], + categories: ['page'], + action: ({ registry, documentId }) => { + const rotate = registry.getPlugin('rotate')?.provides(); + rotate?.forDocument(documentId).rotateForward(); + }, + }, + + 'rotate:counter-clockwise': { + id: 'rotate:counter-clockwise', + labelKey: 'rotate.counterClockwise', + icon: 'RotateLeft', + shortcuts: ['Ctrl+[', 'Meta+['], + categories: ['page'], + action: ({ registry, documentId }) => { + const rotate = registry.getPlugin('rotate')?.provides(); + rotate?.forDocument(documentId).rotateBackward(); + }, + }, + + 'scroll:vertical': { + id: 'scroll:vertical', + labelKey: 'page.vertical', + categories: ['page'], + action: ({ registry, documentId }) => { + const scroll = registry.getPlugin('scroll')?.provides(); + scroll?.forDocument(documentId).setScrollStrategy(ScrollStrategy.Vertical); + }, + active: ({ state, documentId }) => + state.plugins['scroll']?.documents[documentId]?.strategy === ScrollStrategy.Vertical, + }, + + 'scroll:horizontal': { + id: 'scroll:horizontal', + labelKey: 'page.horizontal', + categories: ['page'], + action: ({ registry, documentId }) => { + const scroll = registry.getPlugin('scroll')?.provides(); + scroll?.forDocument(documentId).setScrollStrategy(ScrollStrategy.Horizontal); + }, + active: ({ state, documentId }) => + state.plugins['scroll']?.documents[documentId]?.strategy === ScrollStrategy.Horizontal, + }, + + // ───────────────────────────────────────────────────────── + // Mode Commands + // ───────────────────────────────────────────────────────── + 'mode:view': { + id: 'mode:view', + labelKey: 'mode.view', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + ui.forDocument(documentId).closeToolbarSlot('top', 'secondary'); + }, + active: ({ state, documentId }) => { + // Active if no secondary toolbar is shown + return !isToolbarOpen(state.plugins, documentId, 'top', 'secondary'); + }, + }, + + 'mode:annotate': { + id: 'mode:annotate', + labelKey: 'mode.annotate', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Show the annotation toolbar + ui.setActiveToolbar('top', 'secondary', 'annotation-toolbar', documentId); + }, + active: ({ state, documentId }) => { + return isToolbarOpen(state.plugins, documentId, 'top', 'secondary', 'annotation-toolbar'); + }, + }, + + 'mode:shapes': { + id: 'mode:shapes', + labelKey: 'mode.shapes', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Show the annotation toolbar (shapes use the same toolbar) + ui.setActiveToolbar('top', 'secondary', 'shapes-toolbar', documentId); + }, + active: ({ state, documentId }) => { + return isToolbarOpen(state.plugins, documentId, 'top', 'secondary', 'shapes-toolbar'); + }, + }, + + 'mode:redact': { + id: 'mode:redact', + labelKey: 'mode.redact', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Show the redaction toolbar + ui.setActiveToolbar('top', 'secondary', 'redaction-toolbar', documentId); + }, + active: ({ state, documentId }) => { + // Active when redaction toolbar is shown + return isToolbarOpen(state.plugins, documentId, 'top', 'secondary', 'redaction-toolbar'); + }, + }, + + 'tabs:overflow-menu': { + id: 'tabs:overflow-menu', + labelKey: 'tabs.overflowMenu', + icon: 'MenuDots', + categories: ['ui'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Toggle the overflow tabs menu + ui.toggleMenu( + 'mode-tabs-overflow-menu', + 'tabs:overflow-menu', + 'overflow-tabs-button', + documentId, + ); + }, + }, + + // ───────────────────────────────────────────────────────── + // Annotation Commands + // ───────────────────────────────────────────────────────── + 'annotation:add-text': { + id: 'annotation:add-text', + labelKey: 'annotation.text', + icon: 'Text', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'freeText')?.fontColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'freeText') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('freeText'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'freeText'; + }, + }, + + 'annotation:add-highlight': { + id: 'annotation:add-highlight', + labelKey: 'annotation.highlight', + icon: 'Highlight', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'highlight')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'highlight') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('highlight'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'highlight'; + }, + }, + + 'annotation:add-strikeout': { + id: 'annotation:add-strikeout', + labelKey: 'annotation.strikeout', + icon: 'Strikethrough', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'strikeout')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'strikeout') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('strikeout'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'strikeout'; + }, + }, + + 'annotation:add-underline': { + id: 'annotation:add-underline', + labelKey: 'annotation.underline', + icon: 'Underline', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'underline')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'underline') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('underline'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'underline'; + }, + }, + + 'annotation:add-rectangle': { + id: 'annotation:add-rectangle', + labelKey: 'annotation.rectangle', + icon: 'Square', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'square')?.strokeColor, + secondaryColor: getToolDefaultsById(state.plugins.annotation, 'square')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'square') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('square'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'square'; + }, + }, + + 'annotation:add-circle': { + id: 'annotation:add-circle', + labelKey: 'annotation.circle', + icon: 'Circle', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'circle')?.strokeColor, + secondaryColor: getToolDefaultsById(state.plugins.annotation, 'circle')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'circle') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('circle'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'circle'; + }, + }, + + 'annotation:add-line': { + id: 'annotation:add-line', + labelKey: 'annotation.line', + icon: 'Line', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'line')?.strokeColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'line') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('line'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'line'; + }, + }, + + 'annotation:add-arrow': { + id: 'annotation:add-arrow', + labelKey: 'annotation.arrow', + icon: 'Arrow', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'line')?.strokeColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'lineArrow') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('lineArrow'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'lineArrow'; + }, + }, + + 'annotation:add-polygon': { + id: 'annotation:add-polygon', + labelKey: 'annotation.polygon', + icon: 'Polygon', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'polygon')?.strokeColor, + secondaryColor: getToolDefaultsById(state.plugins.annotation, 'polygon')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'polygon') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('polygon'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'polygon'; + }, + }, + + 'annotation:add-polyline': { + id: 'annotation:add-polyline', + labelKey: 'annotation.polyline', + icon: 'Polyline', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'polyline')?.strokeColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'polyline') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('polyline'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'polyline'; + }, + }, + + 'annotation:add-ink': { + id: 'annotation:add-ink', + labelKey: 'annotation.ink', + icon: 'Pen', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'ink')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'ink') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('ink'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'ink'; + }, + }, + + 'annotation:add-stamp': { + id: 'annotation:add-stamp', + labelKey: 'annotation.stamp', + icon: 'Photo', + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'stamp') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('stamp'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'stamp'; + }, + }, + + 'annotation:delete-selected': { + id: 'annotation:delete-selected', + labelKey: 'annotation.deleteSelected', + icon: 'Trash', + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + const selectedAnnotation = annotationScope.getSelectedAnnotation(); + if (!selectedAnnotation) return; + + annotationScope.deleteAnnotation( + selectedAnnotation.object.pageIndex, + selectedAnnotation.object.id, + ); + }, + }, + + // ───────────────────────────────────────────────────────── + // Redaction Commands + // ───────────────────────────────────────────────────────── + // 'redaction:redact-area': { + // id: 'redaction:redact-area', + // labelKey: 'redaction.area', + // icon: 'RedactArea', + // categories: ['redaction'], + // action: ({ registry, documentId }) => { + // const redaction = registry.getPlugin('redaction')?.provides(); + // redaction?.forDocument(documentId).toggleMarqueeRedact(); + // }, + // active: ({ state, documentId }) => { + // const redaction = state.plugins[REDACTION_PLUGIN_ID]?.documents[documentId]; + // return redaction?.activeType === RedactionMode.MarqueeRedact; + // }, + // }, + + // 'redaction:redact-text': { + // id: 'redaction:redact-text', + // labelKey: 'redaction.text', + // icon: 'RedactText', + // categories: ['redaction'], + // action: ({ registry, documentId }) => { + // const redaction = registry.getPlugin('redaction')?.provides(); + // redaction?.forDocument(documentId).toggleRedactSelection(); + // }, + // active: ({ state, documentId }) => { + // const redaction = state.plugins[REDACTION_PLUGIN_ID]?.documents[documentId]; + // return redaction?.activeType === RedactionMode.RedactSelection; + // }, + // }, + + // 'redaction:apply-all': { + // id: 'redaction:apply-all', + // labelKey: 'redaction.applyAll', + // icon: 'Check', + // categories: ['redaction'], + // action: ({ registry, documentId }) => { + // const redaction = registry.getPlugin('redaction')?.provides(); + // redaction?.forDocument(documentId).commitAllPending(); + // }, + // }, + + // 'redaction:clear-all': { + // id: 'redaction:clear-all', + // labelKey: 'redaction.clearAll', + // icon: 'Close', + // categories: ['redaction'], + // action: ({ registry, documentId }) => { + // const redaction = registry.getPlugin('redaction')?.provides(); + // redaction?.forDocument(documentId).clearPending(); + // }, + // }, + + // 'redaction:delete-selected': { + // id: 'redaction:delete-selected', + // labelKey: 'redaction.deleteSelected', + // icon: 'Trash', + // categories: ['redaction'], + // action: ({ registry, documentId }) => { + // const redaction = registry.getPlugin('redaction')?.provides(); + // const selectedRedaction = redaction?.forDocument(documentId).getSelectedPending(); + // if (!selectedRedaction) return; + // redaction + // ?.forDocument(documentId) + // .removePending(selectedRedaction.page, selectedRedaction.id); + // }, + // }, + + // 'redaction:commit-selected': { + // id: 'redaction:commit-selected', + // labelKey: 'redaction.commitSelected', + // icon: 'Check', + // categories: ['redaction'], + // action: ({ registry, documentId }) => { + // const redaction = registry.getPlugin('redaction')?.provides(); + // const selectedRedaction = redaction?.forDocument(documentId).getSelectedPending(); + // if (!selectedRedaction) return; + // redaction + // ?.forDocument(documentId) + // .commitPending(selectedRedaction.page, selectedRedaction.id); + // }, + // }, + + 'selection:copy': { + id: 'selection:copy', + labelKey: 'selection.copy', + icon: 'Copy', + categories: ['selection'], + action: ({ registry, documentId }) => { + const plugin = registry.getPlugin('selection'); + const scope = plugin?.provides().forDocument(documentId); + scope?.copyToClipboard(); + scope?.clear(); + }, + }, + + // ───────────────────────────────────────────────────────── + // History Commands + // ───────────────────────────────────────────────────────── + 'history:undo': { + id: 'history:undo', + labelKey: 'history.undo', + icon: 'ArrowBackUp', + shortcuts: ['Ctrl+Z', 'Meta+Z'], + categories: ['edit'], + action: ({ registry, documentId }) => { + const history = registry.getPlugin(HISTORY_PLUGIN_ID)?.provides(); + if (!history) return; + + const scope = history.forDocument(documentId); + scope.undo(); + }, + disabled: ({ state, documentId }) => { + const history = state.plugins[HISTORY_PLUGIN_ID]?.documents[documentId]; + return !history?.global.canUndo; + }, + }, + + 'history:redo': { + id: 'history:redo', + labelKey: 'history.redo', + icon: 'ArrowForwardUp', + shortcuts: ['Ctrl+Y', 'Meta+Shift+Z'], + categories: ['edit'], + action: ({ registry, documentId }) => { + const history = registry.getPlugin(HISTORY_PLUGIN_ID)?.provides(); + if (!history) return; + + const scope = history.forDocument(documentId); + scope.redo(); + }, + disabled: ({ state, documentId }) => { + const history = state.plugins[HISTORY_PLUGIN_ID]?.documents[documentId]; + return !history?.global.canRedo; + }, + }, + + 'annotation:overflow-tools': { + id: 'annotation:overflow-tools', + labelKey: 'annotation.overflowTools', + icon: 'MenuDots', + categories: ['annotation'], + action: ({ registry, documentId }) => { + const uiCapability = registry.getPlugin('ui')?.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + if (!scope) return; + + scope.toggleMenu( + 'annotation-tools-menu', + 'annotation:overflow-tools', + 'overflow-annotation-tools', + ); + }, + active: ({ state, documentId }) => { + const ui = state.plugins['ui']?.documents[documentId]; + return ui?.openMenus['annotation-tools-menu'] !== undefined; + }, + }, +}; diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/config/index.ts b/packages/web/src/components/pdf/embedpdf/preact/src/config/index.ts new file mode 100644 index 000000000..f47b2f4d4 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/config/index.ts @@ -0,0 +1,3 @@ +export * from './commands'; +export * from './types'; +export * from './translations'; diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/config/translations.ts b/packages/web/src/components/pdf/embedpdf/preact/src/config/translations.ts new file mode 100644 index 000000000..d9e7cb120 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/config/translations.ts @@ -0,0 +1,430 @@ +import { ParamResolvers, Locale } from '@embedpdf/plugin-i18n'; +import { State } from './types'; +import { ZOOM_PLUGIN_ID } from '@embedpdf/plugin-zoom'; +import { SEARCH_PLUGIN_ID } from '@embedpdf/plugin-search/react'; + +export const englishTranslations: Locale = { + code: 'en', + name: 'English', + translations: { + zoom: { + in: 'Zoom In', + out: 'Zoom Out', + fitWidth: 'Fit to Width', + fitPage: 'Fit to Page', + marquee: 'Marquee Zoom', + automatic: 'Automatic', + level: 'Zoom Level ({level}%)', + inArea: 'Zoom In Area', + menu: 'Zoom Menu', + }, + pan: { + toggle: 'Toggle Pan Mode', + }, + pointer: { + toggle: 'Toggle Pointer Mode', + }, + capture: { + screenshot: 'Screenshot', + }, + document: { + menu: 'Document Menu', + open: 'Open', + close: 'Close', + print: 'Print', + export: 'Export', + properties: 'Properties', + }, + panel: { + sidebar: 'Sidebar', + search: 'Search', + comment: 'Comment', + thumbnails: 'Thumbnails', + outline: 'Outline', + }, + page: { + settings: 'Page Settings', + single: 'Single Page', + twoOdd: 'Two Page (Odd)', + twoEven: 'Two Page (Even)', + vertical: 'Vertical', + horizontal: 'Horizontal', + spreadMode: 'Spread Mode', + scrollLayout: 'Scroll Layout', + rotation: 'Page Rotation', + }, + rotate: { + clockwise: 'Rotate Clockwise', + counterClockwise: 'Rotate Counter-Clockwise', + }, + mode: { + view: 'View', + annotate: 'Annotate', + shapes: 'Shapes', + redact: 'Redact', + }, + tabs: { + overflowMenu: 'More tabs', + }, + annotation: { + text: 'Text', + highlight: 'Highlight', + strikeout: 'Strikeout', + underline: 'Underline', + rectangle: 'Rectangle', + circle: 'Circle', + line: 'Line', + arrow: 'Arrow', + polygon: 'Polygon', + polyline: 'Polyline', + ink: 'Ink', + stamp: 'Stamp', + overflowTools: 'Overflow Tools', + }, + redaction: { + area: 'Redact Area', + text: 'Redact Text', + applyAll: 'Apply All', + clearAll: 'Clear All', + }, + history: { + undo: 'Undo', + redo: 'Redo', + }, + search: { + title: 'Search', + placeholder: 'Search', + close: 'Close search', + caseSensitive: 'Case sensitive', + wholeWord: 'Whole word', + resultsFound: '{count} results found', + previousResult: 'Previous result', + nextResult: 'Next result', + page: 'Page {number}', + }, + }, +}; + +export const spanishTranslations: Locale = { + code: 'es', + name: 'Español', + translations: { + zoom: { + in: 'Acercar', + out: 'Alejar', + fitWidth: 'Ajustar al ancho', + fitPage: 'Ajustar a la página', + marquee: 'Zoom de marquesina', + automatic: 'Automático', + level: 'Nivel de zoom ({level}%)', + inArea: 'Acercar área', + menu: 'Menú de zoom', + }, + pan: { + toggle: 'Alternar modo panorámico', + }, + pointer: { + toggle: 'Alternar modo puntero', + }, + capture: { + screenshot: 'Captura de pantalla', + }, + document: { + menu: 'Menú de documento', + open: 'Abrir', + close: 'Cerrar', + print: 'Imprimir', + export: 'Exportar', + properties: 'Propiedades', + }, + panel: { + sidebar: 'Barra lateral', + search: 'Buscar', + comment: 'Comentario', + thumbnails: 'Miniaturas', + outline: 'Esquema', + }, + page: { + settings: 'Configuración de página', + single: 'Página única', + twoOdd: 'Dos páginas (impar)', + twoEven: 'Dos páginas (par)', + vertical: 'Vertical', + horizontal: 'Horizontal', + spreadMode: 'Modo de extensión', + scrollLayout: 'Diseño de desplazamiento', + rotation: 'Rotación de página', + }, + rotate: { + clockwise: 'Girar en sentido horario', + counterClockwise: 'Girar en sentido antihorario', + }, + mode: { + view: 'Ver', + annotate: 'Anotar', + shapes: 'Formas', + redact: 'Redactar', + }, + tabs: { + overflowMenu: 'Más pestañas', + }, + annotation: { + text: 'Texto', + highlight: 'Resaltar', + strikeout: 'Tachar', + underline: 'Subrayar', + rectangle: 'Rectángulo', + circle: 'Círculo', + line: 'Línea', + arrow: 'Flecha', + polygon: 'Polígono', + polyline: 'Polilínea', + ink: 'Tinta', + stamp: 'Sello', + overflowTools: 'Más herramientas', + }, + redaction: { + area: 'Redactar área', + text: 'Redactar texto', + applyAll: 'Aplicar todo', + clearAll: 'Borrar todo', + }, + history: { + undo: 'Deshacer', + redo: 'Rehacer', + }, + search: { + title: 'Buscar', + placeholder: 'Buscar', + close: 'Cerrar búsqueda', + caseSensitive: 'Distinguir mayúsculas', + wholeWord: 'Palabra completa', + resultsFound: '{count} resultados encontrados', + previousResult: 'Resultado anterior', + nextResult: 'Resultado siguiente', + page: 'Página {number}', + }, + }, +}; + +export const germanTranslations: Locale = { + code: 'de', + name: 'Deutsch', + translations: { + zoom: { + in: 'Vergrößern', + out: 'Verkleinern', + fitWidth: 'An Breite anpassen', + fitPage: 'An Seite anpassen', + marquee: 'Auswahlzoom', + automatic: 'Automatisch', + level: 'Zoomstufe ({level}%)', + inArea: 'Bereich vergrößern', + menu: 'Zoom-Menü', + }, + pan: { + toggle: 'Schwenkmodus umschalten', + }, + pointer: { + toggle: 'Zeigermodus umschalten', + }, + capture: { + screenshot: 'Screenshot', + }, + document: { + menu: 'Dokumentmenü', + open: 'Öffnen', + close: 'Schließen', + print: 'Drucken', + export: 'Exportieren', + properties: 'Eigenschaften', + }, + panel: { + sidebar: 'Seitenleiste', + search: 'Suchen', + comment: 'Kommentar', + thumbnails: 'Miniaturansichten', + outline: 'Gliederung', + }, + page: { + settings: 'Seiteneinstellungen', + single: 'Einzelne Seite', + twoOdd: 'Zwei Seiten (ungerade)', + twoEven: 'Zwei Seiten (gerade)', + vertical: 'Vertikal', + horizontal: 'Horizontal', + spreadMode: 'Seitenmodus', + scrollLayout: 'Scroll-Layout', + rotation: 'Seitendrehung', + }, + rotate: { + clockwise: 'Im Uhrzeigersinn drehen', + counterClockwise: 'Gegen den Uhrzeigersinn drehen', + }, + mode: { + view: 'Ansicht', + annotate: 'Annotieren', + shapes: 'Formen', + redact: 'Schwärzen', + }, + tabs: { + overflowMenu: 'Weitere Tabs', + }, + annotation: { + text: 'Text', + highlight: 'Hervorheben', + strikeout: 'Durchstreichen', + underline: 'Unterstreichen', + rectangle: 'Rechteck', + circle: 'Kreis', + line: 'Linie', + arrow: 'Pfeil', + polygon: 'Polygon', + polyline: 'Polylinie', + ink: 'Stift', + stamp: 'Stempel', + overflowTools: 'Weitere Werkzeuge', + }, + redaction: { + area: 'Bereich schwärzen', + text: 'Text schwärzen', + applyAll: 'Alles anwenden', + clearAll: 'Alles löschen', + }, + history: { + undo: 'Rückgängig', + redo: 'Wiederholen', + }, + search: { + title: 'Suchen', + placeholder: 'Suchen', + close: 'Suche schließen', + caseSensitive: 'Groß-/Kleinschreibung', + wholeWord: 'Ganzes Wort', + resultsFound: '{count} Ergebnisse gefunden', + previousResult: 'Vorheriges Ergebnis', + nextResult: 'Nächstes Ergebnis', + page: 'Seite {number}', + }, + }, +}; + +export const dutchTranslations: Locale = { + code: 'nl', + name: 'Nederlands', + translations: { + zoom: { + in: 'Inzoomen', + out: 'Uitzoomen', + fitWidth: 'Aan breedte aanpassen', + fitPage: 'Aan pagina aanpassen', + marquee: 'Selectiezoom', + automatic: 'Automatisch', + level: 'Zoomniveau ({level}%)', + inArea: 'Gebied inzoomen', + menu: 'Zoommenu', + }, + pan: { + toggle: 'Panbewegingsmodus schakelen', + }, + pointer: { + toggle: 'Aanwijzermodus schakelen', + }, + capture: { + screenshot: 'Schermafbeelding', + }, + document: { + menu: 'Documentmenu', + open: 'Openen', + close: 'Sluiten', + print: 'Afdrukken', + export: 'Exporteren', + properties: 'Eigenschappen', + }, + panel: { + sidebar: 'Zijbalk', + search: 'Zoeken', + comment: 'Commentaar', + thumbnails: 'Miniaturen', + outline: 'Overzicht', + }, + page: { + settings: 'Pagina-instellingen', + single: 'Enkele pagina', + twoOdd: "Twee pagina's (oneven)", + twoEven: "Twee pagina's (even)", + vertical: 'Verticaal', + horizontal: 'Horizontaal', + spreadMode: 'Spreidmodus', + scrollLayout: 'Scroll-indeling', + rotation: 'Paginadraaiing', + }, + rotate: { + clockwise: 'Met de klok mee draaien', + counterClockwise: 'Tegen de klok in draaien', + }, + mode: { + view: 'Weergave', + annotate: 'Annoteren', + shapes: 'Vormen', + redact: 'Redigeren', + }, + tabs: { + overflowMenu: 'Meer tabbladen', + }, + annotation: { + text: 'Tekst', + highlight: 'Markeren', + strikeout: 'Doorhalen', + underline: 'Onderstrepen', + rectangle: 'Rechthoek', + circle: 'Cirkel', + line: 'Lijn', + arrow: 'Pijl', + polygon: 'Veelhoek', + polyline: 'Polylijn', + ink: 'Inkt', + stamp: 'Stempel', + overflowTools: 'Meer gereedschappen', + }, + redaction: { + area: 'Gebied redigeren', + text: 'Tekst redigeren', + applyAll: 'Alles toepassen', + clearAll: 'Alles wissen', + }, + history: { + undo: 'Ongedaan maken', + redo: 'Opnieuw uitvoeren', + }, + search: { + title: 'Zoeken', + placeholder: 'Zoeken', + close: 'Zoekopdracht sluiten', + caseSensitive: 'Hoofdlettergevoelig', + wholeWord: 'Heel woord', + resultsFound: '{count} resultaten gevonden', + previousResult: 'Vorig resultaat', + nextResult: 'Volgend resultaat', + page: 'Pagina {number}', + }, + }, +}; + +export const paramResolvers: ParamResolvers = { + 'zoom.level': ({ state, documentId }) => { + const zoomLevel = + documentId ? + (state.plugins[ZOOM_PLUGIN_ID]?.documents[documentId]?.currentZoomLevel ?? 1) + : 1; + return { + level: Math.round(zoomLevel * 100), + }; + }, + 'search.resultsFound': ({ state, documentId }) => { + const searchState = documentId ? state.plugins[SEARCH_PLUGIN_ID]?.documents[documentId] : null; + return { + count: searchState?.total ?? 0, + }; + }, +}; diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/config/types.ts b/packages/web/src/components/pdf/embedpdf/preact/src/config/types.ts new file mode 100644 index 000000000..bc06e7938 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/config/types.ts @@ -0,0 +1,41 @@ +import { GlobalStoreState } from '@embedpdf/core'; +import { CAPTURE_PLUGIN_ID, CaptureState } from '@embedpdf/plugin-capture/react'; +import { ZOOM_PLUGIN_ID, ZoomState } from '@embedpdf/plugin-zoom/react'; +import { VIEWPORT_PLUGIN_ID, ViewportState } from '@embedpdf/plugin-viewport/react'; +import { SCROLL_PLUGIN_ID, ScrollState } from '@embedpdf/plugin-scroll/react'; +import { SPREAD_PLUGIN_ID, SpreadState } from '@embedpdf/plugin-spread/react'; +import { SEARCH_PLUGIN_ID, SearchState } from '@embedpdf/plugin-search/react'; +import { SELECTION_PLUGIN_ID, SelectionState } from '@embedpdf/plugin-selection/react'; +import { ANNOTATION_PLUGIN_ID, AnnotationState } from '@embedpdf/plugin-annotation/react'; +import { FULLSCREEN_PLUGIN_ID, FullscreenState } from '@embedpdf/plugin-fullscreen/react'; +import { + INTERACTION_MANAGER_PLUGIN_ID, + InteractionManagerState, +} from '@embedpdf/plugin-interaction-manager/react'; +import { HISTORY_PLUGIN_ID, HistoryState } from '@embedpdf/plugin-history/react'; +import { REDACTION_PLUGIN_ID, RedactionState } from '@embedpdf/plugin-redaction/react'; +import { PAN_PLUGIN_ID, PanState } from '@embedpdf/plugin-pan/react'; +import { UI_PLUGIN_ID, UIState } from '@embedpdf/plugin-ui'; + +export type State = GlobalStoreState<{ + [CAPTURE_PLUGIN_ID]: CaptureState; + [ZOOM_PLUGIN_ID]: ZoomState; + [VIEWPORT_PLUGIN_ID]: ViewportState; + [SCROLL_PLUGIN_ID]: ScrollState; + [SPREAD_PLUGIN_ID]: SpreadState; + [SEARCH_PLUGIN_ID]: SearchState; + [SELECTION_PLUGIN_ID]: SelectionState; + [ANNOTATION_PLUGIN_ID]: AnnotationState; + [FULLSCREEN_PLUGIN_ID]: FullscreenState; + [INTERACTION_MANAGER_PLUGIN_ID]: InteractionManagerState; + [HISTORY_PLUGIN_ID]: HistoryState; + [REDACTION_PLUGIN_ID]: RedactionState; + [PAN_PLUGIN_ID]: PanState; + [UI_PLUGIN_ID]: UIState; +}>; + +// Type for tracking sidebar state per document +export type SidebarState = { + search: boolean; + thumbnails: boolean; +}; diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/index.css b/packages/web/src/components/pdf/embedpdf/preact/src/index.css new file mode 100644 index 000000000..57153ca95 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/index.css @@ -0,0 +1,37 @@ +@import 'tailwindcss'; + +@layer utilities { + /* Safe area padding for mobile devices */ + .pb-safe { + padding-bottom: env(safe-area-inset-bottom); + } + + /* Animations */ + @keyframes slide-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } + } + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-slide-up { + animation: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1); + } + + .animate-fade-in { + animation: fade-in 0.15s ease-out; + } +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/main.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/main.tsx new file mode 100644 index 000000000..759743323 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/main.tsx @@ -0,0 +1,4 @@ +import './index.css'; +import { ViewerPage } from './viewer'; + +export default ViewerPage; diff --git a/packages/web/src/components/pdf/embedpdf/preact/src/viewer.tsx b/packages/web/src/components/pdf/embedpdf/preact/src/viewer.tsx new file mode 100644 index 000000000..5c865115d --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/src/viewer.tsx @@ -0,0 +1,460 @@ +import { useMemo, useRef, useState, useEffect } from 'preact/hooks'; +import { EmbedPDF } from '@embedpdf/core/react'; +import { usePdfiumEngine } from '@embedpdf/engines/react'; +import { createPluginRegistration } from '@embedpdf/core'; +import { ViewportPluginPackage, Viewport } from '@embedpdf/plugin-viewport/react'; +import { ScrollPluginPackage, ScrollStrategy, Scroller } from '@embedpdf/plugin-scroll/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, + DocumentManagerPlugin, +} from '@embedpdf/plugin-document-manager/react'; +import { + InteractionManagerPluginPackage, + GlobalPointerProvider, + PagePointerProvider, +} from '@embedpdf/plugin-interaction-manager/react'; +import { + ZoomMode, + ZoomPluginPackage, + MarqueeZoom, + ZoomGestureWrapper, +} from '@embedpdf/plugin-zoom/react'; +import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; +import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; +import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; +import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; +import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; +import { SearchLayer, SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; +import { CapturePluginPackage, MarqueeCapture } from '@embedpdf/plugin-capture/react'; +import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; +import { AnnotationPluginPackage, AnnotationLayer } from '@embedpdf/plugin-annotation/react'; +import { ViewerToolbar, ViewMode } from './components/viewer-toolbar'; +import { LoadingSpinner } from './components/loading-spinner'; +import { DocumentPasswordPrompt } from './components/document-password-prompt'; +import { SearchSidebar } from './components/search-sidebar'; +import { ThumbnailsSidebar } from './components/thumbnails-sidebar'; +import { PageControls } from './components/page-controls'; +import { ConsoleLogger } from '@embedpdf/models'; +import { AnnotationSelectionMenu } from './components/annotation-selection-menu'; +import { SelectionSelectionMenu } from './components/selection-selection-menu'; +import { EmptyState } from './components/empty-state'; +import { I18nPluginPackage } from '@embedpdf/plugin-i18n/react'; +import { RedactionSelectionMenu } from './components/redaction-selection-menu'; +import { + englishTranslations, + spanishTranslations, + germanTranslations, + dutchTranslations, + paramResolvers, +} from './config'; + +const logger = new ConsoleLogger(); + +// Type for tracking sidebar state per document +type SidebarState = { + search: boolean; + thumbnails: boolean; +}; + +type ViewerPageProps = { + pdfData?: ArrayBuffer; + pdfFileName?: string; + pdfs?: Array<{ id: string; fileName: string; tag?: string }>; + selectedPdfId?: string | null; + onPdfSelect?: (_pdfId: string) => void; + readOnly?: boolean; +}; + +export function ViewerPage({ + pdfData, + pdfFileName, + pdfs, + selectedPdfId, + onPdfSelect, + readOnly = false, +}: ViewerPageProps = {}) { + const containerRef = useRef(null); + const { engine, isLoading, error } = usePdfiumEngine({ + logger, + }); + + // Track sidebar state per document + const [sidebarStates, setSidebarStates] = useState>({}); + + // Track toolbar mode per document + const [toolbarModes, setToolbarModes] = useState>({}); + + // Store reference to document manager for reloading documents + const docManagerRef = useRef | null>(null); + // Track the current active document ID to close it when switching + const activeDocumentIdRef = useRef(null); + // Track the previous selectedPdfId to detect changes + const previousSelectedPdfIdRef = useRef(undefined); + // Track loading state to prevent race conditions + const isLoadingRef = useRef(false); + + const plugins = useMemo( + () => [ + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(DocumentManagerPluginPackage), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage, { + targetElement: '#document-content', + }), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + createPluginRegistration(I18nPluginPackage, { + defaultLocale: 'en', + fallbackLocale: 'en', + locales: [englishTranslations, spanishTranslations, germanTranslations, dutchTranslations], + paramResolvers, + }), + ], + [], // Empty dependency array since these never change + ); + + const toggleSidebar = (documentId: string, sidebar: keyof SidebarState) => { + setSidebarStates(prev => ({ + ...prev, + [documentId]: { + ...(prev[documentId] || { search: false, thumbnails: false }), + [sidebar]: !prev[documentId]?.[sidebar], + }, + })); + }; + + const getSidebarState = (documentId: string): SidebarState => { + return sidebarStates[documentId] || { search: false, thumbnails: false }; + }; + + const getToolbarMode = (documentId: string): ViewMode => { + // Force 'view' mode when readOnly is true + if (readOnly) return 'view'; + return toolbarModes[documentId] || 'view'; + }; + + const setToolbarMode = (documentId: string, mode: ViewMode) => { + // Prevent mode changes when readOnly is true + if (readOnly) return; + setToolbarModes(prev => ({ + ...prev, + [documentId]: mode, + })); + }; + + // Reload document when pdfData or selectedPdfId changes + useEffect(() => { + if (!docManagerRef.current || !pdfData) return; + + // Skip if selectedPdfId hasn't actually changed (initial render or same PDF) + if (selectedPdfId === previousSelectedPdfIdRef.current) { + return; + } + + // Prevent multiple simultaneous loads + if (isLoadingRef.current) { + return; + } + + const loadDocument = async () => { + isLoadingRef.current = true; + const previousPdfId = previousSelectedPdfIdRef.current; + previousSelectedPdfIdRef.current = selectedPdfId; + + try { + // Close the previous document if it exists and we're switching PDFs + if (previousPdfId !== undefined && activeDocumentIdRef.current) { + try { + await docManagerRef.current!.closeDocument(activeDocumentIdRef.current); + } catch (err) { + // Ignore errors when closing (document might already be closed) + console.warn('Error closing previous document:', err); + } + activeDocumentIdRef.current = null; + } + + const selectedPdf = pdfs?.find(pdf => pdf.id === selectedPdfId) || pdfs?.[0]; + const pdfName = pdfFileName || selectedPdf?.fileName || 'document.pdf'; + + // Open the new document + // The document ID will be available via activeDocumentId in the render function + await docManagerRef + .current!.openDocumentBuffer({ + buffer: pdfData, + name: pdfName, + autoActivate: true, + }) + .toPromise(); + } catch (err) { + console.error('Error loading document:', err); + // Reset the ref on error so we can retry + previousSelectedPdfIdRef.current = previousPdfId; + } finally { + isLoadingRef.current = false; + } + }; + + loadDocument(); + }, [pdfData, selectedPdfId, pdfFileName, pdfs]); + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading || !engine) { + return ( +
+ +
+ ); + } + + return ( +
+
+ { + const docManager = registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides(); + + if (!docManager) return; + + // Store reference for reloading documents + docManagerRef.current = docManager; + + // Load PDF from ArrayBuffer if provided, otherwise use default URL + // Document is automatically activated via autoActivate: true + if (pdfData) { + const selectedPdf = pdfs?.find(pdf => pdf.id === selectedPdfId) || pdfs?.[0]; + const pdfName = pdfFileName || selectedPdf?.fileName || 'document.pdf'; + await docManager + .openDocumentBuffer({ + buffer: pdfData, + name: pdfName, + autoActivate: true, + }) + .toPromise(); + } else { + // Fallback to default PDF URL + await docManager + .openDocumentUrl({ url: 'https://snippet.embedpdf.com/ebook.pdf' }) + .toPromise(); + } + }} + > + {({ + pluginsReady, + activeDocumentId, + }: { + pluginsReady: boolean; + activeDocumentId: string | null; + }) => { + // Update the ref when activeDocumentId changes + if (activeDocumentId !== activeDocumentIdRef.current) { + activeDocumentIdRef.current = activeDocumentId; + } + + return ( + <> + {pluginsReady ? +
+ {activeDocumentId && ( + toggleSidebar(activeDocumentId, 'search')} + onToggleThumbnails={() => toggleSidebar(activeDocumentId, 'thumbnails')} + isSearchOpen={getSidebarState(activeDocumentId).search} + isThumbnailsOpen={getSidebarState(activeDocumentId).thumbnails} + mode={getToolbarMode(activeDocumentId)} + onModeChange={mode => setToolbarMode(activeDocumentId, mode)} + pdfs={pdfs} + selectedPdfId={selectedPdfId} + onPdfSelect={onPdfSelect} + readOnly={readOnly} + /> + )} + + {/* Empty State - No Documents */} + {!activeDocumentId && ( + { + // Document will be activated automatically via autoActivate: true + }} + /> + )} + + {/* Document Content Area */} + {activeDocumentId && ( +
+ {/* Thumbnails Sidebar - Left */} + {getSidebarState(activeDocumentId).thumbnails && ( + toggleSidebar(activeDocumentId, 'thumbnails')} + /> + )} + + {/* Main Viewer */} +
+ + {({ documentState, isLoading, isError, isLoaded }) => ( + <> + {isLoading && ( +
+ +
+ )} + {isError && ( + + )} + {isLoaded && ( +
+ + + + ( + + + + + + + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + )} + /> + + {/* Page Controls */} + + + +
+ )} + + )} +
+
+ + {/* Search Sidebar - Right */} + {getSidebarState(activeDocumentId).search && ( + toggleSidebar(activeDocumentId, 'search')} + /> + )} +
+ )} +
+ :
+ +
+ } + + ); + }} +
+
+
+ ); +} diff --git a/packages/web/src/components/pdf/embedpdf/preact/tsconfig.json b/packages/web/src/components/pdf/embedpdf/preact/tsconfig.json new file mode 100644 index 000000000..6889d42c0 --- /dev/null +++ b/packages/web/src/components/pdf/embedpdf/preact/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/packages/web/src/components/pdf/index.js b/packages/web/src/components/pdf/index.js new file mode 100644 index 000000000..deb520e2f --- /dev/null +++ b/packages/web/src/components/pdf/index.js @@ -0,0 +1,10 @@ +/** + * PDF Components - Barrel export + */ + +// PDF list components (for displaying PDF metadata in lists) +export { default as PdfListItem } from './PdfListItem.jsx'; +export { default as PdfTagBadge } from './PdfTagBadge.jsx'; + +// EmbedPDF viewer component +export { default as EmbedPdfViewer } from './embedpdf/EmbedPdfViewer.jsx'; diff --git a/packages/web/src/components/project/PdfPreviewPanel.jsx b/packages/web/src/components/project/PdfPreviewPanel.jsx index 8dc25486b..03d901966 100644 --- a/packages/web/src/components/project/PdfPreviewPanel.jsx +++ b/packages/web/src/components/project/PdfPreviewPanel.jsx @@ -7,7 +7,7 @@ import { Switch, Match, createMemo } from 'solid-js'; import SlidingPanel from './SlidingPanel.jsx'; -import PdfViewer from '@/components/checklist/pdf/PdfViewer.jsx'; +import EmbedPdfViewer from '@pdf/embedpdf/EmbedPdfViewer.jsx'; import pdfPreviewStore from '@/stores/pdfPreviewStore.js'; export default function PdfPreviewPanel() { @@ -63,7 +63,7 @@ export default function PdfPreviewPanel() { - } > - b.transform[5] - a.transform[5]); - - for (const item of sorted) { - if (!item.str.trim()) continue; - - const y = item.transform[5]; - - if (lastY === null || Math.abs(y - lastY) < yThreshold) { - currentLine.push(item); - } else { - if (currentLine.length > 0) { - // Sort line items by x position - currentLine.sort((a, b) => a.transform[4] - b.transform[4]); - lines.push(currentLine); - } - currentLine = [item]; - } - lastY = y; - } - if (currentLine.length > 0) { - currentLine.sort((a, b) => a.transform[4] - b.transform[4]); - lines.push(currentLine); - } +// Mock EmbedPDF engine before importing pdfUtils +const mockEngine = { + openDocumentBuffer: vi.fn(), + getMetadata: vi.fn(), + extractText: vi.fn(), + closeDocument: vi.fn(), +}; - return lines; -} +vi.mock('../embedPdfEngine.js', () => ({ + initEmbedPdfEngine: vi.fn(() => Promise.resolve(mockEngine)), +})); +import { readFileAsArrayBuffer, extractPdfTitle, extractPdfDoi } from '../pdfUtils'; + +// Recreate internal function for testing since it's not exported function cleanTitle(title) { return ( title @@ -62,113 +40,6 @@ function cleanTitle(title) { ); } -describe('groupTextIntoLines', () => { - describe('basic line grouping', () => { - it('should group items on the same y-position into one line', () => { - const items = [ - { str: 'Hello', transform: [1, 0, 0, 1, 10, 100] }, - { str: 'World', transform: [1, 0, 0, 1, 60, 100] }, - ]; - - const lines = groupTextIntoLines(items); - - expect(lines).toHaveLength(1); - expect(lines[0].map(i => i.str).join(' ')).toBe('Hello World'); - }); - - it('should group items within y-threshold into one line', () => { - const items = [ - { str: 'Hello', transform: [1, 0, 0, 1, 10, 100] }, - { str: 'World', transform: [1, 0, 0, 1, 60, 103] }, // Within 5px threshold - ]; - - const lines = groupTextIntoLines(items); - - expect(lines).toHaveLength(1); - }); - - it('should separate items beyond y-threshold into different lines', () => { - const items = [ - { str: 'Line 1', transform: [1, 0, 0, 1, 10, 200] }, - { str: 'Line 2', transform: [1, 0, 0, 1, 10, 100] }, // 100px difference - ]; - - const lines = groupTextIntoLines(items); - - expect(lines).toHaveLength(2); - }); - }); - - describe('sorting behavior', () => { - it('should sort lines by y-position descending (top to bottom in PDF coords)', () => { - const items = [ - { str: 'Bottom', transform: [1, 0, 0, 1, 10, 50] }, - { str: 'Top', transform: [1, 0, 0, 1, 10, 200] }, - { str: 'Middle', transform: [1, 0, 0, 1, 10, 125] }, - ]; - - const lines = groupTextIntoLines(items); - - expect(lines).toHaveLength(3); - expect(lines[0][0].str).toBe('Top'); - expect(lines[1][0].str).toBe('Middle'); - expect(lines[2][0].str).toBe('Bottom'); - }); - - it('should sort items within a line by x-position', () => { - const items = [ - { str: 'Third', transform: [1, 0, 0, 1, 150, 100] }, - { str: 'First', transform: [1, 0, 0, 1, 10, 100] }, - { str: 'Second', transform: [1, 0, 0, 1, 80, 100] }, - ]; - - const lines = groupTextIntoLines(items); - - expect(lines).toHaveLength(1); - expect(lines[0].map(i => i.str)).toEqual(['First', 'Second', 'Third']); - }); - }); - - describe('edge cases', () => { - it('should skip empty string items', () => { - const items = [ - { str: 'Hello', transform: [1, 0, 0, 1, 10, 100] }, - { str: '', transform: [1, 0, 0, 1, 40, 100] }, - { str: ' ', transform: [1, 0, 0, 1, 50, 100] }, - { str: 'World', transform: [1, 0, 0, 1, 60, 100] }, - ]; - - const lines = groupTextIntoLines(items); - - expect(lines).toHaveLength(1); - expect(lines[0]).toHaveLength(2); - expect(lines[0].map(i => i.str)).toEqual(['Hello', 'World']); - }); - - it('should return empty array for empty input', () => { - expect(groupTextIntoLines([])).toEqual([]); - }); - - it('should return empty array when all items are whitespace', () => { - const items = [ - { str: '', transform: [1, 0, 0, 1, 10, 100] }, - { str: ' ', transform: [1, 0, 0, 1, 40, 100] }, - ]; - - expect(groupTextIntoLines(items)).toEqual([]); - }); - - it('should handle single item', () => { - const items = [{ str: 'Single', transform: [1, 0, 0, 1, 10, 100] }]; - - const lines = groupTextIntoLines(items); - - expect(lines).toHaveLength(1); - expect(lines[0][0].str).toBe('Single'); - }); - }); -}); - describe('cleanTitle', () => { describe('whitespace normalization', () => { it('should collapse multiple spaces into single space', () => { @@ -299,15 +170,256 @@ describe('readFileAsArrayBuffer', () => { }); describe('extractPdfTitle', () => { - // These tests would require mocking pdfjs-dist - // For now, we document the intended behavior - - it.todo('should return title from PDF metadata when available'); - it.todo('should fall back to first page text when metadata has no title'); - it.todo('should find largest font text as likely title'); - it.todo('should return null when no text can be extracted'); - it.todo('should return null on PDF parsing error'); - it.todo('should clean extracted title of prefixes'); - it.todo('should reject titles that are too short (< 5 chars)'); - it.todo('should reject titles that are too long (> 300 chars)'); + const mockDoc = { id: 'test-doc' }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return title from PDF metadata when available', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({ title: 'Test PDF Title' }), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const title = await extractPdfTitle(pdfData); + + expect(title).toBe('Test PDF Title'); + expect(mockEngine.openDocumentBuffer).toHaveBeenCalled(); + expect(mockEngine.getMetadata).toHaveBeenCalledWith(mockDoc); + expect(mockEngine.closeDocument).toHaveBeenCalledWith(mockDoc); + }); + + it('should fall back to first page text when metadata has no title', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({ title: null }), + }); + mockEngine.extractText.mockReturnValue({ + toPromise: () => Promise.resolve('Important Study About Cats\nAbstract text here...'), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const title = await extractPdfTitle(pdfData); + + expect(title).toBe('Important Study About Cats'); + expect(mockEngine.extractText).toHaveBeenCalledWith(mockDoc, [0]); + }); + + it('should clean extracted title of prefixes', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({ title: null }), + }); + mockEngine.extractText.mockReturnValue({ + toPromise: () => Promise.resolve('Original Article: Machine Learning Study'), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const title = await extractPdfTitle(pdfData); + + expect(title).toBe('Machine Learning Study'); + }); + + it('should return null when no text can be extracted', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({ title: null }), + }); + mockEngine.extractText.mockReturnValue({ + toPromise: () => Promise.resolve(''), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const title = await extractPdfTitle(pdfData); + + expect(title).toBeNull(); + }); + + it('should reject titles that are too short', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({ title: null }), + }); + mockEngine.extractText.mockReturnValue({ + toPromise: () => Promise.resolve('Hi\nThis is a longer line'), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const title = await extractPdfTitle(pdfData); + + // Should skip "Hi" (too short) and return the next substantial line + expect(title).toBe('This is a longer line'); + }); + + it('should handle PDF parsing errors gracefully', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.reject(new Error('Invalid PDF')), + }); + + await expect(extractPdfTitle(pdfData)).rejects.toThrow(); + }); +}); + +describe('extractPdfDoi', () => { + const mockDoc = { id: 'test-doc' }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should extract DOI from metadata subject field', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({ subject: 'DOI: 10.1234/test.doi' }), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const doi = await extractPdfDoi(pdfData); + + expect(doi).toBe('10.1234/test.doi'); + }); + + it('should extract DOI from metadata keywords field', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({ keywords: '10.5678/example.doi' }), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const doi = await extractPdfDoi(pdfData); + + expect(doi).toBe('10.5678/example.doi'); + }); + + it('should extract DOI from custom metadata fields', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => + Promise.resolve({ + custom: { + doi: '10.9999/custom.doi', + }, + }), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const doi = await extractPdfDoi(pdfData); + + expect(doi).toBe('10.9999/custom.doi'); + }); + + it('should fall back to extracting DOI from first page text', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({}), + }); + mockEngine.extractText.mockReturnValue({ + toPromise: () => Promise.resolve('This is a study. DOI: 10.1234/page.doi'), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const doi = await extractPdfDoi(pdfData); + + expect(doi).toBe('10.1234/page.doi'); + }); + + it('should return null when no DOI is found', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({}), + }); + mockEngine.extractText.mockReturnValue({ + toPromise: () => Promise.resolve('No DOI in this text'), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const doi = await extractPdfDoi(pdfData); + + expect(doi).toBeNull(); + }); + + it('should clean DOI by removing URL prefixes and normalizing', async () => { + const pdfData = new ArrayBuffer(100); + + mockEngine.openDocumentBuffer.mockReturnValue({ + toPromise: () => Promise.resolve(mockDoc), + }); + mockEngine.getMetadata.mockReturnValue({ + toPromise: () => Promise.resolve({}), + }); + mockEngine.extractText.mockReturnValue({ + toPromise: () => Promise.resolve('https://doi.org/10.1234/test.doi'), + }); + mockEngine.closeDocument.mockReturnValue({ + toPromise: () => Promise.resolve(), + }); + + const doi = await extractPdfDoi(pdfData); + + expect(doi).toBe('10.1234/test.doi'); + }); }); diff --git a/packages/web/src/lib/embedPdfEngine.js b/packages/web/src/lib/embedPdfEngine.js new file mode 100644 index 000000000..b0839b178 --- /dev/null +++ b/packages/web/src/lib/embedPdfEngine.js @@ -0,0 +1,94 @@ +/** + * EmbedPDF engine initialization for programmatic PDF operations + * Provides singleton engine instance for text and metadata extraction + * Modules are loaded dynamically to reduce initial bundle size + */ + +// Engine instance and initialization promise +let engineInstance = null; +let engineInitPromise = null; + +// Timeout constant for engine initialization +const ENGINE_INIT_TIMEOUT = 15000; // 15 seconds for WASM loading +const MODULE_LOAD_TIMEOUT = 10000; // 10 seconds for module loading + +/** + * Wrap a promise with a timeout + * @param {Promise} promise - The promise to wrap + * @param {number} ms - Timeout in milliseconds + * @param {string} operationName - Name of the operation for error messages + * @returns {Promise} - The promise with timeout + */ +function withTimeout(promise, ms, operationName = 'Operation') { + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${operationName} timed out after ${ms / 1000} seconds`)); + }, ms); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timeoutId); + }); +} + +/** + * Initialize EmbedPDF engine lazily + * Loads PDFium WASM and creates engine instance + * @returns {Promise} - The initialized EmbedPDF engine + */ +export async function initEmbedPdfEngine() { + if (engineInstance) return engineInstance; + + if (engineInitPromise) return engineInitPromise; + + engineInitPromise = (async () => { + try { + // Dynamically import EmbedPDF modules + const [{ init }, { PdfiumNative, PdfEngine }, { ConsoleLogger }] = await withTimeout( + Promise.all([ + import('@embedpdf/pdfium'), + import('@embedpdf/engines/pdfium'), + import('@embedpdf/models'), + ]), + MODULE_LOAD_TIMEOUT, + 'EmbedPDF module loading', + ); + + // Load PDFium WASM from CDN + const wasmUrl = 'https://cdn.jsdelivr.net/npm/@embedpdf/pdfium/dist/pdfium.wasm'; + const response = await withTimeout(fetch(wasmUrl), ENGINE_INIT_TIMEOUT, 'PDFium WASM fetch'); + + if (!response.ok) { + throw new Error(`Failed to fetch PDFium WASM: ${response.status} ${response.statusText}`); + } + + const wasmBinary = await withTimeout( + response.arrayBuffer(), + ENGINE_INIT_TIMEOUT, + 'PDFium WASM arrayBuffer', + ); + + // Initialize PDFium module + const pdfiumModule = await withTimeout( + init({ wasmBinary }), + ENGINE_INIT_TIMEOUT, + 'PDFium initialization', + ); + + // Create native executor + const native = new PdfiumNative(pdfiumModule, { logger: new ConsoleLogger() }); + + // Create high-level engine + engineInstance = new PdfEngine(native, { logger: new ConsoleLogger() }); + + return engineInstance; + } catch (error) { + // Reset promise on error so it can be retried + engineInitPromise = null; + throw error; + } + })(); + + return engineInitPromise; +} diff --git a/packages/web/src/lib/pdfUtils.js b/packages/web/src/lib/pdfUtils.js index 3dd7e52d6..1c7ae84c4 100644 --- a/packages/web/src/lib/pdfUtils.js +++ b/packages/web/src/lib/pdfUtils.js @@ -2,15 +2,12 @@ * PDF utility functions for extracting metadata and text */ -// PDF.js library reference (loaded dynamically) -let pdfjsLib = null; -let pdfjsInitPromise = null; +import { initEmbedPdfEngine } from './embedPdfEngine.js'; // DOI regex pattern const DOI_REGEX = /\b(10\.\d{4,}(?:\.\d+)*\/\S+)\b/gi; // Timeout constants -const PDF_INIT_TIMEOUT = 10000; // 10 seconds for PDF.js initialization const PDF_EXTRACT_TIMEOUT = 10000; // 10 seconds for title/DOI extraction /** @@ -33,32 +30,6 @@ export function withTimeout(promise, ms, operationName = 'Operation') { }); } -/** - * Initialize PDF.js library lazily - * This is the shared initialization function used by all PDF-related components - * @returns {Promise} - The initialized pdfjs-dist library - */ -export async function initPdfJs() { - if (pdfjsLib) return pdfjsLib; - - if (pdfjsInitPromise) return pdfjsInitPromise; - - pdfjsInitPromise = (async () => { - const [pdfjs, workerModule] = await withTimeout( - Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]), - PDF_INIT_TIMEOUT, - 'PDF.js initialization', - ); - - pdfjsLib = pdfjs; - pdfjsLib.GlobalWorkerOptions.workerSrc = workerModule.default; - - return pdfjsLib; - })(); - - return pdfjsInitPromise; -} - /** * Extract title from a PDF file * Tries metadata first, then extracts from first page text @@ -74,109 +45,66 @@ export async function extractPdfTitle(pdfData) { * Internal implementation of title extraction (without timeout wrapper) */ async function extractPdfTitleInternal(pdfData) { - const pdfjs = await initPdfJs(); - // verbosity: 0 = ERRORS only (suppress warnings about malformed PDFs) - const pdf = await pdfjs.getDocument({ data: pdfData, verbosity: 0 }).promise; + const engine = await initEmbedPdfEngine(); + // Generate unique document ID for this extraction + const docId = `title-extract-${Date.now()}-${Math.random().toString(36).substring(7)}`; + + // Clone ArrayBuffer since EmbedPDF may detach it + const pdfBuffer = pdfData.slice(0); + + let doc = null; try { + // Open document + doc = await engine.openDocumentBuffer({ id: docId, content: pdfBuffer }).toPromise(); + // Try to get title from PDF metadata first - const metadata = await pdf.getMetadata(); - if (metadata?.info?.Title && metadata.info.Title.trim()) { - return metadata.info.Title.trim(); + const metadata = await engine.getMetadata(doc).toPromise(); + if (metadata?.title && metadata.title.trim()) { + return metadata.title.trim(); } // Fall back to extracting from first page text - const firstPage = await pdf.getPage(1); - const textContent = await firstPage.getTextContent(); + const pageText = await engine.extractText(doc, [0]).toPromise(); - // Get text items from the first page - const textItems = textContent.items; - if (!textItems || textItems.length === 0) { + if (!pageText || !pageText.trim()) { return null; } - // Strategy: Find the largest font size text on the first page (likely the title) - // Group text by approximate y-position to find lines - const lines = groupTextIntoLines(textItems); - - // Find the line with the largest font size (likely the title) - let titleLine = null; - let maxFontSize = 0; - - for (const line of lines.slice(0, 10)) { - // Only check first 10 lines - const avgFontSize = line.reduce((sum, item) => sum + (item.height || 12), 0) / line.length; - if (avgFontSize > maxFontSize) { - maxFontSize = avgFontSize; - titleLine = line; + // Split text into lines and find first substantial line + const lines = pageText + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0); + + // Find first substantial line (likely the title) + for (const line of lines.slice(0, 20)) { + // Check first 20 lines + const cleaned = cleanTitle(line); + if (cleaned.length > 5 && cleaned.length < 300) { + return cleaned; } } - if (titleLine) { - const title = titleLine - .map(item => item.str) - .join(' ') - .trim(); - if (title.length > 5 && title.length < 300) { - return cleanTitle(title); - } - } - - // Fallback: just use the first substantial line of text + // Fallback: use first substantial line regardless of length check for (const line of lines) { - const text = line - .map(item => item.str) - .join(' ') - .trim(); - if (text.length > 10 && text.length < 300) { - return cleanTitle(text); + const cleaned = cleanTitle(line); + if (cleaned.length > 10 && cleaned.length < 300) { + return cleaned; } } return null; } finally { - if (pdf) { - await pdf.destroy(); - } - } -} - -/** - * Group text items into lines based on y-position - */ -function groupTextIntoLines(textItems) { - const lines = []; - let currentLine = []; - let lastY = null; - const yThreshold = 5; // pixels - - // Sort by y position (descending, since PDF coords start from bottom) - const sorted = [...textItems].sort((a, b) => b.transform[5] - a.transform[5]); - - for (const item of sorted) { - if (!item.str.trim()) continue; - - const y = item.transform[5]; - - if (lastY === null || Math.abs(y - lastY) < yThreshold) { - currentLine.push(item); - } else { - if (currentLine.length > 0) { - // Sort line items by x position - currentLine.sort((a, b) => a.transform[4] - b.transform[4]); - lines.push(currentLine); + if (doc) { + try { + await engine.closeDocument(doc).toPromise(); + } catch (err) { + // Ignore close errors + console.warn('Error closing PDF document:', err); } - currentLine = [item]; } - lastY = y; } - - if (currentLine.length > 0) { - currentLine.sort((a, b) => a.transform[4] - b.transform[4]); - lines.push(currentLine); - } - - return lines; } /** @@ -221,34 +149,49 @@ export async function extractPdfDoi(pdfData) { * Internal implementation of DOI extraction (without timeout wrapper) */ async function extractPdfDoiInternal(pdfData) { - const pdfjs = await initPdfJs(); - const pdf = await pdfjs.getDocument({ data: pdfData, verbosity: 0 }).promise; + const engine = await initEmbedPdfEngine(); + + // Generate unique document ID for this extraction + const docId = `doi-extract-${Date.now()}-${Math.random().toString(36).substring(7)}`; + // Clone ArrayBuffer since EmbedPDF may detach it + const pdfBuffer = pdfData.slice(0); + + let doc = null; try { + // Open document + doc = await engine.openDocumentBuffer({ id: docId, content: pdfBuffer }).toPromise(); + // Try metadata first - const metadata = await pdf.getMetadata(); - - // Check specific metadata fields that commonly contain DOI - const info = metadata?.info || {}; - - // Check common DOI fields directly - const doiFields = ['doi', 'DOI', 'Subject', 'Keywords', 'Description']; - for (const field of doiFields) { - if (info[field]) { - const fieldValue = String(info[field]); - const match = fieldValue.match(DOI_REGEX); - if (match) { - return cleanDoi(match[0]); - } + const metadata = await engine.getMetadata(doc).toPromise(); + + // Check standard metadata fields that commonly contain DOI + const fieldsToCheck = [ + metadata.subject, + metadata.keywords, + metadata.title, + metadata.author, + ].filter(Boolean); + + for (const fieldValue of fieldsToCheck) { + const match = String(fieldValue).match(DOI_REGEX); + if (match) { + return cleanDoi(match[0]); } } - // Check custom metadata - if (metadata?.metadata?._metadataMap) { - for (const [key, value] of metadata.metadata._metadataMap) { - const valueStr = String(value || ''); - if (valueStr && /doi/i.test(key)) { - const match = valueStr.match(DOI_REGEX); + // Check custom metadata fields + if (metadata?.custom) { + for (const [key, value] of Object.entries(metadata.custom)) { + if (value && /doi/i.test(key)) { + const match = String(value).match(DOI_REGEX); + if (match) { + return cleanDoi(match[0]); + } + } + // Also check the value itself for DOI pattern + if (value) { + const match = String(value).match(DOI_REGEX); if (match) { return cleanDoi(match[0]); } @@ -257,19 +200,24 @@ async function extractPdfDoiInternal(pdfData) { } // Fall back to extracting from first page text - const firstPage = await pdf.getPage(1); - const textContent = await firstPage.getTextContent(); - const pageText = textContent.items.map(item => item.str).join(' '); + const pageText = await engine.extractText(doc, [0]).toPromise(); - const textMatch = pageText.match(DOI_REGEX); - if (textMatch) { - return cleanDoi(textMatch[0]); + if (pageText) { + const textMatch = pageText.match(DOI_REGEX); + if (textMatch) { + return cleanDoi(textMatch[0]); + } } return null; } finally { - if (pdf) { - await pdf.destroy(); + if (doc) { + try { + await engine.closeDocument(doc).toPromise(); + } catch (err) { + // Ignore close errors + console.warn('Error closing PDF document:', err); + } } } } diff --git a/packages/web/vite.config.js b/packages/web/vite.config.js index e69f377cf..beb447d49 100644 --- a/packages/web/vite.config.js +++ b/packages/web/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import solidPlugin from 'vite-plugin-solid'; +import preact from '@preact/preset-vite'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; @@ -13,17 +14,34 @@ export default defineConfig(({ mode }) => ({ '@primitives': path.resolve(__dirname, 'src/primitives'), '@auth': path.resolve(__dirname, 'src/components/auth'), '@checklist': path.resolve(__dirname, 'src/components/checklist'), + '@pdf': path.resolve(__dirname, 'src/components/pdf'), '@project': path.resolve(__dirname, 'src/components/project'), '@offline': path.resolve(__dirname, 'src/offline'), '@api': path.resolve(__dirname, 'src/api'), '@config': path.resolve(__dirname, 'src/config'), '@lib': path.resolve(__dirname, 'src/lib'), + // Preact/compat aliases for React-style imports in Preact components + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react-dom/test-utils': 'preact/test-utils', + 'react/jsx-runtime': 'preact/jsx-runtime', }, }, server: { allowedHosts: ['corates.org', 'www.corates.org', 'localhost'], }, - plugins: [solidPlugin(), tailwindcss()], + plugins: [ + // SolidJS plugin - exclude Preact files + solidPlugin({ + include: ['**/*.{js,jsx,ts,tsx}'], + exclude: ['**/preact/**', '**/preact-2/**'], + }), + // Preact plugin - only process Preact files + preact({ + include: ['**/preact/**/*.{js,jsx,ts,tsx}', '**/preact-2/**/*.{js,jsx,ts,tsx}'], + }), + tailwindcss(), + ], build: { minify: mode === 'analyze' ? 'terser' : 'esbuild', sourcemap: mode === 'analyze', @@ -32,6 +50,25 @@ export default defineConfig(({ mode }) => ({ comments: false, }, }, + rollupOptions: { + output: { + manualChunks: id => { + // Separate EmbedPDF modules into their own chunks for code-splitting + if (id.includes('@embedpdf/')) { + // Group all font packages together (they're small and related) + if (id.includes('@embedpdf/fonts-')) { + return 'embedpdf-fonts'; + } + // Extract package name from path for other packages + const match = id.match(/@embedpdf\/([^/]+)/); + if (match) { + return `embedpdf-${match[1]}`; + } + return 'embedpdf'; + } + }, + }, + }, }, test: { environment: 'jsdom', diff --git a/packages/workers/migrations/meta/0000_snapshot.json b/packages/workers/migrations/meta/0000_snapshot.json index 38dd7923d..b63cc3844 100644 --- a/packages/workers/migrations/meta/0000_snapshot.json +++ b/packages/workers/migrations/meta/0000_snapshot.json @@ -107,12 +107,8 @@ "name": "account_userId_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -190,12 +186,8 @@ "name": "invitation_inviterId_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviterId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviterId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -203,12 +195,8 @@ "name": "invitation_organizationId_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -284,12 +272,8 @@ "name": "mediaFiles_uploadedBy_user_id_fk", "tableFrom": "mediaFiles", "tableTo": "user", - "columnsFrom": [ - "uploadedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["uploadedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -345,12 +329,8 @@ "name": "member_userId_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -358,12 +338,8 @@ "name": "member_organizationId_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -443,9 +419,7 @@ "indexes": { "org_access_grants_stripeCheckoutSessionId_unique": { "name": "org_access_grants_stripeCheckoutSessionId_unique", - "columns": [ - "stripeCheckoutSessionId" - ], + "columns": ["stripeCheckoutSessionId"], "isUnique": true } }, @@ -454,12 +428,8 @@ "name": "org_access_grants_orgId_organization_id_fk", "tableFrom": "org_access_grants", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -518,9 +488,7 @@ "indexes": { "organization_slug_unique": { "name": "organization_slug_unique", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -624,9 +592,7 @@ "indexes": { "project_invitations_token_unique": { "name": "project_invitations_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true } }, @@ -635,12 +601,8 @@ "name": "project_invitations_orgId_organization_id_fk", "tableFrom": "project_invitations", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -648,12 +610,8 @@ "name": "project_invitations_projectId_projects_id_fk", "tableFrom": "project_invitations", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -661,12 +619,8 @@ "name": "project_invitations_invitedBy_user_id_fk", "tableFrom": "project_invitations", "tableTo": "user", - "columnsFrom": [ - "invitedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invitedBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -722,12 +676,8 @@ "name": "project_members_projectId_projects_id_fk", "tableFrom": "project_members", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -735,12 +685,8 @@ "name": "project_members_userId_user_id_fk", "tableFrom": "project_members", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -810,12 +756,8 @@ "name": "projects_orgId_organization_id_fk", "tableFrom": "projects", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -823,12 +765,8 @@ "name": "projects_createdBy_user_id_fk", "tableFrom": "projects", "tableTo": "user", - "columnsFrom": [ - "createdBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["createdBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -916,9 +854,7 @@ "indexes": { "session_token_unique": { "name": "session_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true } }, @@ -927,12 +863,8 @@ "name": "session_userId_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -940,12 +872,8 @@ "name": "session_impersonatedBy_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "impersonatedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["impersonatedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -953,12 +881,8 @@ "name": "session_activeOrganizationId_organization_id_fk", "tableFrom": "session", "tableTo": "organization", - "columnsFrom": [ - "activeOrganizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["activeOrganizationId"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1108,16 +1032,12 @@ "indexes": { "stripe_event_ledger_payloadHash_unique": { "name": "stripe_event_ledger_payloadHash_unique", - "columns": [ - "payloadHash" - ], + "columns": ["payloadHash"], "isUnique": true }, "stripe_event_ledger_stripeEventId_unique": { "name": "stripe_event_ledger_stripeEventId_unique", - "columns": [ - "stripeEventId" - ], + "columns": ["stripeEventId"], "isUnique": true } }, @@ -1313,12 +1233,8 @@ "name": "twoFactor_userId_user_id_fk", "tableFrom": "twoFactor", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1465,16 +1381,12 @@ "indexes": { "user_email_unique": { "name": "user_email_unique", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true }, "user_username_unique": { "name": "user_username_unique", - "columns": [ - "username" - ], + "columns": ["username"], "isUnique": true } }, @@ -1548,4 +1460,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/workers/migrations/meta/_journal.json b/packages/workers/migrations/meta/_journal.json index d5295cd1b..64f0ab8a1 100644 --- a/packages/workers/migrations/meta/_journal.json +++ b/packages/workers/migrations/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/workers/src/durable-objects/__tests__/ProjectDoc.ws-auth.test.js b/packages/workers/src/durable-objects/__tests__/ProjectDoc.ws-auth.test.js index 237597b76..65df11f98 100644 --- a/packages/workers/src/durable-objects/__tests__/ProjectDoc.ws-auth.test.js +++ b/packages/workers/src/durable-objects/__tests__/ProjectDoc.ws-auth.test.js @@ -49,17 +49,6 @@ describe('ProjectDoc WebSocket Authorization Boundary', () => { }); } - function createInternalSyncMemberRequest(projectId, action, member) { - return new Request(`https://internal/api/project-doc/${projectId}/sync-member`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Internal-Request': 'true', - }, - body: JSON.stringify({ action, member }), - }); - } - // NOTE: We do not test WebSocket disconnection when a member is removed via sync-member // because the test environment's runInDurableObject utility does not reliably observe // in-memory state changes (this.sessions Map) after WebSocket close operations. @@ -113,7 +102,7 @@ describe('ProjectDoc WebSocket Authorization Boundary', () => { expect(wsResponse.status).toBe(101); - await runInDurableObject(stub, async (instance, state) => { + await runInDurableObject(stub, async (instance, _state) => { expect(instance.sessions.size).toBe(1); }); }); diff --git a/packages/workers/src/routes/admin/__tests__/admin-billing.test.js b/packages/workers/src/routes/admin/__tests__/admin-billing.test.js index 4e3fc584f..854e62f1b 100644 --- a/packages/workers/src/routes/admin/__tests__/admin-billing.test.js +++ b/packages/workers/src/routes/admin/__tests__/admin-billing.test.js @@ -13,9 +13,8 @@ import { json, } from '../../../__tests__/helpers.js'; import { createDb } from '../../../db/client.js'; -import { subscription, orgAccessGrants } from '../../../db/schema.js'; +import { subscription } from '../../../db/schema.js'; import { eq } from 'drizzle-orm'; -import { getGrantByOrgIdAndType } from '../../../db/orgAccessGrants.js'; vi.mock('../../../middleware/requireAdmin.js', () => { return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index deb2a4a69..76e70fc52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,90 @@ importers: '@corates/ui': specifier: workspace:* version: link:../ui + '@embedpdf/core': + specifier: ^2.1.1 + version: 2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/engines': + specifier: ^2.1.1 + version: 2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/models': + specifier: ^2.1.1 + version: 2.1.1 + '@embedpdf/pdfium': + specifier: ^2.1.1 + version: 2.1.1 + '@embedpdf/plugin-annotation': + specifier: ^2.1.1 + version: 2.1.1(c992e828e9c7efabded7d679039558a4) + '@embedpdf/plugin-capture': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(@embedpdf/plugin-interaction-manager@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(@embedpdf/plugin-render@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-commands': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-document-manager': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-export': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-fullscreen': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-history': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-i18n': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-interaction-manager': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-pan': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(@embedpdf/plugin-interaction-manager@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(@embedpdf/plugin-viewport@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-print': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-redaction': + specifier: ^2.1.1 + version: 2.1.1(0af51ce9fd01284b7d17c526b0aaf616) + '@embedpdf/plugin-render': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-rotate': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-scroll': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(@embedpdf/plugin-viewport@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-search': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-selection': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(@embedpdf/plugin-interaction-manager@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-spread': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-thumbnail': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(@embedpdf/plugin-render@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-tiling': + specifier: ^2.1.1 + version: 2.1.1(d0b692e7e2aee8b58400e0abd7d367be) + '@embedpdf/plugin-ui': + specifier: ^2.1.1 + version: 2.1.1(d0b692e7e2aee8b58400e0abd7d367be) + '@embedpdf/plugin-view-manager': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-viewport': + specifier: ^2.1.1 + version: 2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/plugin-zoom': + specifier: ^2.1.1 + version: 2.1.1(b1c8daec8b8248609960e85499d9d470) '@embedpdf/snippet': specifier: ^2.0.0 version: 2.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) @@ -193,15 +277,18 @@ importers: idb: specifier: ^8.0.3 version: 8.0.3 - pdfjs-dist: - specifier: ^5.4.530 - version: 5.4.530 + preact: + specifier: ^10.28.1 + version: 10.28.1 solid-icons: specifier: ^1.1.0 version: 1.1.0(solid-js@1.9.10) solid-js: specifier: ^1.9.10 version: 1.9.10 + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 y-indexeddb: specifier: ^9.0.12 version: 9.0.12(yjs@13.6.28) @@ -212,6 +299,9 @@ importers: specifier: ^13.6.28 version: 13.6.28 devDependencies: + '@preact/preset-vite': + specifier: ^2.10.0 + version: 2.10.2(@babel/core@7.28.5)(preact@10.28.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@solidjs/testing-library': specifier: ^0.8.10 version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.10))(solid-js@1.9.10) @@ -523,6 +613,13 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/helper-annotate-as-pure@7.27.3': + resolution: + { + integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==, + } + engines: { node: '>=6.9.0' } + '@babel/helper-compilation-targets@7.27.2': resolution: { @@ -621,6 +718,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: + { + integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.27.1': + resolution: + { + integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: { @@ -1316,6 +1431,19 @@ packages: svelte: '>=5 <6' vue: '>=3.2.0' + '@embedpdf/plugin-view-manager@2.1.1': + resolution: + { + integrity: sha512-Q+qD9rY3LhngnyW4k3kVT2864QaUw4FQbU2o2D2pFpNUXdX8bG/zZlJEkBoaBwYDN+D6gRVDnZkZd0skYCZpmg==, + } + peerDependencies: + '@embedpdf/core': 2.1.1 + preact: ^10.26.4 + react: '>=16.8.0' + react-dom: '>=16.8.0' + svelte: '>=5 <6' + vue: '>=3.2.0' + '@embedpdf/plugin-viewport@2.1.1': resolution: { @@ -2928,112 +3056,6 @@ packages: '@cfworker/json-schema': optional: true - '@napi-rs/canvas-android-arm64@0.1.88': - resolution: - { - integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [android] - - '@napi-rs/canvas-darwin-arm64@0.1.88': - resolution: - { - integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [darwin] - - '@napi-rs/canvas-darwin-x64@0.1.88': - resolution: - { - integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [darwin] - - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': - resolution: - { - integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==, - } - engines: { node: '>= 10' } - cpu: [arm] - os: [linux] - - '@napi-rs/canvas-linux-arm64-gnu@0.1.88': - resolution: - { - integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-arm64-musl@0.1.88': - resolution: - { - integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': - resolution: - { - integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==, - } - engines: { node: '>= 10' } - cpu: [riscv64] - os: [linux] - - '@napi-rs/canvas-linux-x64-gnu@0.1.88': - resolution: - { - integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-linux-x64-musl@0.1.88': - resolution: - { - integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-win32-arm64-msvc@0.1.88': - resolution: - { - integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [win32] - - '@napi-rs/canvas-win32-x64-msvc@0.1.88': - resolution: - { - integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [win32] - - '@napi-rs/canvas@0.1.88': - resolution: - { - integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==, - } - engines: { node: '>= 10' } - '@napi-rs/wasm-runtime@1.1.0': resolution: { @@ -3260,6 +3282,44 @@ packages: integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==, } + '@preact/preset-vite@2.10.2': + resolution: + { + integrity: sha512-K9wHlJOtkE+cGqlyQ5v9kL3Ge0Ql4LlIZjkUTL+1zf3nNdF88F9UZN6VTV8jdzBX9Fl7WSzeNMSDG7qECPmSmg==, + } + peerDependencies: + '@babel/core': 7.x + vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x + + '@prefresh/babel-plugin@0.5.2': + resolution: + { + integrity: sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==, + } + + '@prefresh/core@1.5.9': + resolution: + { + integrity: sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==, + } + peerDependencies: + preact: ^10.0.0 || ^11.0.0-0 + + '@prefresh/utils@1.2.1': + resolution: + { + integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==, + } + + '@prefresh/vite@2.4.11': + resolution: + { + integrity: sha512-/XjURQqdRiCG3NpMmWqE9kJwrg9IchIOWHzulCfqg2sRe/8oQ1g5De7xrk9lbqPIQLn7ntBkKdqWXIj4E9YXyg==, + } + peerDependencies: + preact: ^10.4.0 || ^11.0.0-0 + vite: '>=2.0.0' + '@puppeteer/browsers@2.11.0': resolution: { @@ -3483,6 +3543,13 @@ packages: rollup: optional: true + '@rollup/pluginutils@4.2.1': + resolution: + { + integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==, + } + engines: { node: '>= 8.0.0' } + '@rollup/pluginutils@5.3.0': resolution: { @@ -5455,6 +5522,14 @@ packages: peerDependencies: '@babel/core': ^7.20.12 + babel-plugin-transform-hook-names@1.0.2: + resolution: + { + integrity: sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==, + } + peerDependencies: + '@babel/core': ^7.12.10 + babel-preset-solid@1.9.10: resolution: { @@ -5659,6 +5734,12 @@ packages: } engines: { node: '>=18' } + boolbase@1.0.0: + resolution: + { + integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, + } + boxen@8.0.1: resolution: { @@ -6223,6 +6304,12 @@ packages: integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==, } + css-select@5.2.2: + resolution: + { + integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==, + } + css-tree@3.1.0: resolution: { @@ -6230,6 +6317,13 @@ packages: } engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0 } + css-what@6.2.2: + resolution: + { + integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==, + } + engines: { node: '>= 6' } + css.escape@1.5.1: resolution: { @@ -6742,12 +6836,37 @@ packages: integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==, } + dom-serializer@2.0.0: + resolution: + { + integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==, + } + + domelementtype@2.3.0: + resolution: + { + integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, + } + + domhandler@5.0.3: + resolution: + { + integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==, + } + engines: { node: '>= 4' } + dompurify@3.3.1: resolution: { integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==, } + domutils@3.2.2: + resolution: + { + integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==, + } + dot-prop@10.1.0: resolution: { @@ -6954,6 +7073,13 @@ packages: } engines: { node: '>=10.13.0' } + entities@4.5.0: + resolution: + { + integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, + } + engines: { node: '>=0.12' } + entities@6.0.1: resolution: { @@ -7787,6 +7913,13 @@ packages: integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, } + he@1.2.0: + resolution: + { + integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==, + } + hasBin: true + hono@4.11.3: resolution: { @@ -8371,6 +8504,12 @@ packages: integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==, } + kolorist@1.8.0: + resolution: + { + integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, + } + kysely@0.28.9: resolution: { @@ -9005,6 +9144,12 @@ packages: } hasBin: true + node-html-parser@6.1.13: + resolution: + { + integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==, + } + node-mock-http@1.0.4: resolution: { @@ -9045,6 +9190,12 @@ packages: } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + nth-check@2.1.1: + resolution: + { + integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, + } + nypm@0.6.2: resolution: { @@ -9296,13 +9447,6 @@ packages: } engines: { node: '>= 14.16' } - pdfjs-dist@5.4.530: - resolution: - { - integrity: sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==, - } - engines: { node: '>=20.16.0 || >=22.3.0' } - pend@1.2.0: resolution: { @@ -10150,6 +10294,12 @@ packages: } engines: { node: '>=14' } + simple-code-frame@1.3.0: + resolution: + { + integrity: sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==, + } + simple-swizzle@0.2.4: resolution: { @@ -10276,6 +10426,13 @@ packages: } engines: { node: '>=0.10.0' } + stack-trace@1.0.0-pre2: + resolution: + { + integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==, + } + engines: { node: '>=16' } + stackback@0.0.2: resolution: { @@ -11107,6 +11264,14 @@ packages: '@testing-library/jest-dom': optional: true + vite-prerender-plugin@0.5.12: + resolution: + { + integrity: sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==, + } + peerDependencies: + vite: 5.x || 6.x || 7.x + vite@5.4.21: resolution: { @@ -11977,6 +12142,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.5 + '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.28.5 @@ -12034,6 +12203,24 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -12510,6 +12697,16 @@ snapshots: svelte: 5.46.1 vue: 3.5.26(typescript@5.9.3) + '@embedpdf/plugin-view-manager@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@embedpdf/core': 2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) + '@embedpdf/models': 2.1.1 + preact: 10.28.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + svelte: 5.46.1 + vue: 3.5.26(typescript@5.9.3) + '@embedpdf/plugin-viewport@2.1.1(@embedpdf/core@2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)))(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3))': dependencies: '@embedpdf/core': 2.1.1(preact@10.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(svelte@5.46.1)(vue@3.5.26(typescript@5.9.3)) @@ -13239,54 +13436,6 @@ snapshots: - hono - supports-color - '@napi-rs/canvas-android-arm64@0.1.88': - optional: true - - '@napi-rs/canvas-darwin-arm64@0.1.88': - optional: true - - '@napi-rs/canvas-darwin-x64@0.1.88': - optional: true - - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': - optional: true - - '@napi-rs/canvas-linux-arm64-gnu@0.1.88': - optional: true - - '@napi-rs/canvas-linux-arm64-musl@0.1.88': - optional: true - - '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': - optional: true - - '@napi-rs/canvas-linux-x64-gnu@0.1.88': - optional: true - - '@napi-rs/canvas-linux-x64-musl@0.1.88': - optional: true - - '@napi-rs/canvas-win32-arm64-msvc@0.1.88': - optional: true - - '@napi-rs/canvas-win32-x64-msvc@0.1.88': - optional: true - - '@napi-rs/canvas@0.1.88': - optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.88 - '@napi-rs/canvas-darwin-arm64': 0.1.88 - '@napi-rs/canvas-darwin-x64': 0.1.88 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.88 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.88 - '@napi-rs/canvas-linux-arm64-musl': 0.1.88 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.88 - '@napi-rs/canvas-linux-x64-gnu': 0.1.88 - '@napi-rs/canvas-linux-x64-musl': 0.1.88 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.88 - '@napi-rs/canvas-win32-x64-msvc': 0.1.88 - optional: true - '@napi-rs/wasm-runtime@1.1.0': dependencies: '@emnapi/core': 1.7.1 @@ -13403,6 +13552,42 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@preact/preset-vite@2.10.2(@babel/core@7.28.5)(preact@10.28.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.5) + '@prefresh/vite': 2.4.11(preact@10.28.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@rollup/pluginutils': 4.2.1 + babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.5) + debug: 4.4.3 + picocolors: 1.1.1 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-prerender-plugin: 0.5.12(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + transitivePeerDependencies: + - preact + - supports-color + + '@prefresh/babel-plugin@0.5.2': {} + + '@prefresh/core@1.5.9(preact@10.28.1)': + dependencies: + preact: 10.28.1 + + '@prefresh/utils@1.2.1': {} + + '@prefresh/vite@2.4.11(preact@10.28.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.28.5 + '@prefresh/babel-plugin': 0.5.2 + '@prefresh/core': 1.5.9(preact@10.28.1) + '@prefresh/utils': 1.2.1 + '@rollup/pluginutils': 4.2.1 + preact: 10.28.1 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@puppeteer/browsers@2.11.0': dependencies: debug: 4.4.3 @@ -13520,6 +13705,11 @@ snapshots: optionalDependencies: rollup: 4.54.0 + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + '@rollup/pluginutils@5.3.0(rollup@4.54.0)': dependencies: '@types/estree': 1.0.8 @@ -15104,6 +15294,10 @@ snapshots: html-entities: 2.3.3 parse5: 7.3.0 + babel-plugin-transform-hook-names@1.0.2(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + babel-preset-solid@1.9.10(@babel/core@7.28.5)(solid-js@1.9.10): dependencies: '@babel/core': 7.28.5 @@ -15241,6 +15435,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + boxen@8.0.1: dependencies: ansi-align: 3.0.1 @@ -15546,11 +15742,21 @@ snapshots: dependencies: uncrypto: 0.1.3 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 source-map-js: 1.2.1 + css-what@6.2.2: {} + css.escape@1.5.1: {} cssstyle@5.3.5: @@ -15823,10 +16029,28 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + dompurify@3.3.1: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dot-prop@10.1.0: dependencies: type-fest: 5.3.1 @@ -15887,6 +16111,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + entities@6.0.1: {} entities@7.0.0: {} @@ -16585,6 +16811,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + he@1.2.0: {} + hono@4.11.3: {} hookable@5.5.3: {} @@ -16867,6 +17095,8 @@ snapshots: known-css-properties@0.30.0: {} + kolorist@1.8.0: {} + kysely@0.28.9: {} langium@3.3.1: @@ -17302,6 +17532,11 @@ snapshots: node-gyp-build@4.8.4: {} + node-html-parser@6.1.13: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + node-mock-http@1.0.4: {} node-releases@2.0.27: {} @@ -17319,6 +17554,10 @@ snapshots: dependencies: path-key: 4.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nypm@0.6.2: dependencies: citty: 0.1.6 @@ -17465,10 +17704,6 @@ snapshots: pathval@2.0.1: {} - pdfjs-dist@5.4.530: - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - pend@1.2.0: {} perfect-debounce@1.0.0: {} @@ -18035,6 +18270,10 @@ snapshots: signal-exit@4.1.0: {} + simple-code-frame@1.3.0: + dependencies: + kolorist: 1.8.0 + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -18117,6 +18356,8 @@ snapshots: speakingurl@14.0.1: {} + stack-trace@1.0.0-pre2: {} + stackback@0.0.2: {} stackframe@1.3.4: {} @@ -18683,6 +18924,16 @@ snapshots: transitivePeerDependencies: - supports-color + vite-prerender-plugin@0.5.12(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + kolorist: 1.8.0 + magic-string: 0.30.21 + node-html-parser: 6.1.13 + simple-code-frame: 1.3.0 + source-map: 0.7.6 + stack-trace: 1.0.0-pre2 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.44.1): dependencies: esbuild: 0.21.5