diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index af4ca85f2..ffe913738 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -6,6 +6,11 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; + # Add .mjs MIME type mapping + types { + text/javascript mjs; + } + # Gzip compression gzip on; gzip_vary on; @@ -90,6 +95,14 @@ http { proxy_set_header X-Forwarded-Port $server_port; } + # Serve .mjs files with correct MIME type (must come before general static assets) + location ~* \.mjs$ { + try_files $uri =404; + add_header Content-Type "text/javascript; charset=utf-8" always; + expires 1y; + add_header Cache-Control "public, immutable"; + } + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d75e7c3e..7ebefca3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "@embedpdf/plugin-tiling": "^1.3.1", "@embedpdf/plugin-viewport": "^1.3.1", "@embedpdf/plugin-zoom": "^1.3.1", + "@embedpdf/plugin-export": "^1.3.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", @@ -537,6 +538,22 @@ "integrity": "sha512-qYGSS5ntz6DSY9Cxw/aigvHqGB+AKJLEcymNTZOL0GdlBzZpL++dOIYNEYHO2Tm/lOQVpE7I0e+Xh2TvD8O1zQ==", "license": "MIT" }, + "node_modules/@embedpdf/plugin-export": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.0.tgz", + "integrity": "sha512-R6VItLmXmXbb0/4AsH1YGUZd0c64K/8kxQd0XAvgUJwcL7Z4s8bLsqRx4sVQqwVllaPEJbAdFn1CC/ymkGB+fg==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "1.3.0" + }, + "peerDependencies": { + "@embedpdf/core": "1.3.0", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@embedpdf/plugin-interaction-manager": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3d77385dc..e4be895db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@embedpdf/plugin-tiling": "^1.3.1", "@embedpdf/plugin-viewport": "^1.3.1", "@embedpdf/plugin-zoom": "^1.3.1", + "@embedpdf/plugin-export": "^1.3.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 8e970e551..92e9daac0 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -66,6 +66,9 @@ export default function RightRail() { const { totalItems, selectedCount } = getSelectionState(); + // Get export state for viewer mode + const exportState = viewerContext?.getExportState?.(); + const handleSelectAll = useCallback(() => { if (currentView === 'fileEditor' || currentView === 'viewer') { // Select all file IDs @@ -96,7 +99,10 @@ export default function RightRail() { }, [currentView, setSelectedFiles, pageEditorFunctions]); const handleExportAll = useCallback(() => { - if (currentView === 'fileEditor' || currentView === 'viewer') { + if (currentView === 'viewer') { + // Use EmbedPDF export functionality for viewer mode + viewerContext?.exportActions?.download(); + } else if (currentView === 'fileEditor') { // Download selected files (or all if none selected) const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; @@ -113,7 +119,7 @@ export default function RightRail() { // Export all pages (not just selected) pageEditorFunctions?.onExportAll?.(); } - }, [currentView, activeFiles, selectedFiles, pageEditorFunctions]); + }, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext]); const handleCloseSelected = useCallback(() => { if (currentView !== 'fileEditor') return; @@ -445,7 +451,9 @@ export default function RightRail() { radius="md" className="right-rail-icon" onClick={handleExportAll} - disabled={currentView === 'viewer' || totalItems === 0} + disabled={ + currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 + } > diff --git a/frontend/src/components/viewer/ExportAPIBridge.tsx b/frontend/src/components/viewer/ExportAPIBridge.tsx new file mode 100644 index 000000000..20983bc92 --- /dev/null +++ b/frontend/src/components/viewer/ExportAPIBridge.tsx @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useExportCapability } from '@embedpdf/plugin-export/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * Component that runs inside EmbedPDF context and provides export functionality + */ +export function ExportAPIBridge() { + const { provides: exportApi } = useExportCapability(); + const { registerBridge } = useViewer(); + + useEffect(() => { + if (exportApi) { + // Register this bridge with ViewerContext + registerBridge('export', { + state: { + canExport: true, + }, + api: exportApi + }); + } + }, [exportApi, registerBridge]); + + return null; +} \ No newline at end of file diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx index 52c2ff2fd..3c4159d6d 100644 --- a/frontend/src/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx @@ -17,6 +17,7 @@ import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react'; import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; +import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; import { Rotation } from '@embedpdf/models'; import { CustomSearchLayer } from './CustomSearchLayer'; import { ZoomAPIBridge } from './ZoomAPIBridge'; @@ -29,6 +30,7 @@ import { SpreadAPIBridge } from './SpreadAPIBridge'; import { SearchAPIBridge } from './SearchAPIBridge'; import { ThumbnailAPIBridge } from './ThumbnailAPIBridge'; import { RotateAPIBridge } from './RotateAPIBridge'; +import { ExportAPIBridge } from './ExportAPIBridge'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -112,6 +114,11 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) { createPluginRegistration(RotatePluginPackage, { defaultRotation: Rotation.Degree0, // Start with no rotation }), + + // Register export plugin for downloading PDFs + createPluginRegistration(ExportPluginPackage, { + defaultFileName: 'document.pdf', + }), ]; }, [pdfUrl]); @@ -170,6 +177,7 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) { + { toPromise: () => Promise }; } +interface ExportAPIWrapper { + download: () => void; + saveAsCopy: () => { toPromise: () => Promise }; +} + // State interfaces - represent the shape of data from each bridge interface ScrollState { @@ -93,6 +98,10 @@ interface SearchState { activeIndex: number; } +interface ExportState { + canExport: boolean; +} + // Bridge registration interface - bridges register with state and API interface BridgeRef { state: TState; @@ -122,6 +131,7 @@ interface ViewerContextType { getRotationState: () => RotationState; getSearchState: () => SearchState; getThumbnailAPI: () => ThumbnailAPIWrapper | null; + getExportState: () => ExportState; // Immediate update callbacks registerImmediateZoomUpdate: (callback: (percent: number) => void) => void; @@ -179,7 +189,12 @@ interface ViewerContextType { clear: () => void; }; - // Bridge registration - internal use by bridges + exportActions: { + download: () => void; + saveAsCopy: () => Promise; + }; + + // Bridge registration - internal use by bridges registerBridge: (type: string, ref: BridgeRef) => void; } @@ -203,6 +218,7 @@ export const ViewerProvider: React.FC = ({ children }) => { spread: null as BridgeRef | null, rotation: null as BridgeRef | null, thumbnail: null as BridgeRef | null, + export: null as BridgeRef | null, }); // Immediate zoom callback for responsive display updates @@ -238,6 +254,9 @@ export const ViewerProvider: React.FC = ({ children }) => { case 'thumbnail': bridgeRefs.current.thumbnail = ref as BridgeRef; break; + case 'export': + bridgeRefs.current.export = ref as BridgeRef; + break; } }; @@ -278,6 +297,10 @@ export const ViewerProvider: React.FC = ({ children }) => { return bridgeRefs.current.thumbnail?.api || null; }; + const getExportState = (): ExportState => { + return bridgeRefs.current.export?.state || { canExport: false }; + }; + // Action handlers - call APIs directly const scrollActions = { scrollToPage: (page: number) => { @@ -473,6 +496,28 @@ export const ViewerProvider: React.FC = ({ children }) => { } }; + const exportActions = { + download: () => { + const api = bridgeRefs.current.export?.api; + if (api?.download) { + api.download(); + } + }, + saveAsCopy: async () => { + const api = bridgeRefs.current.export?.api; + if (api?.saveAsCopy) { + try { + const result = api.saveAsCopy(); + return await result.toPromise(); + } catch (error) { + console.error('Failed to save PDF copy:', error); + return null; + } + } + return null; + } + }; + const registerImmediateZoomUpdate = (callback: (percent: number) => void) => { immediateZoomUpdateCallback.current = callback; }; @@ -507,6 +552,7 @@ export const ViewerProvider: React.FC = ({ children }) => { getRotationState, getSearchState, getThumbnailAPI, + getExportState, // Immediate updates registerImmediateZoomUpdate, @@ -522,6 +568,7 @@ export const ViewerProvider: React.FC = ({ children }) => { spreadActions, rotationActions, searchActions, + exportActions, // Bridge registration registerBridge,