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,