Skip to content

Commit 4157e06

Browse files
committed
feat: add web browser support for Cloudflare Pages deployment
- Add web API fallbacks in platform.js (file open/save via browser dialogs, localStorage for session/preferences, download-based file saving) - Remove Tauri-only guards from loader.js and saver.js so PDF open/save works in browser - Hide window control buttons (min/max/close) in browser mode - Add _redirects file for Cloudflare Pages SPA routing - Bump version to 1.33.0
1 parent 799d4fe commit 4157e06

9 files changed

Lines changed: 143 additions & 81 deletions

File tree

open-pdf-studio/js/core/platform.js

Lines changed: 108 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22
* Tauri API wrapper module
33
* Provides a unified interface for Tauri 2.x APIs
44
* Uses the global __TAURI__ object instead of ES module imports
5+
* Falls back to Web APIs when running in a browser (non-Tauri)
56
*/
67

8+
// ── Web file cache ──────────────────────────────────────────────────────────
9+
// When running in a browser, files opened via <input type="file"> are stored
10+
// here so that readBinaryFile() can retrieve them by name.
11+
const _webFileCache = new Map(); // filename -> Uint8Array
12+
713
// Extract a display-friendly file name from a path or content:// URI
814
export function extractFileName(pathOrUri) {
915
if (!pathOrUri) return 'Document';
@@ -95,47 +101,79 @@ export async function closeWindow() {
95101
}
96102

97103
// File dialogs - using Tauri commands since plugin APIs may not be globally available
98-
export async function openFileDialog() {
99-
if (!isTauri()) return null;
100-
101-
// Try using the dialog plugin via window.__TAURI__.dialog
102-
if (window.__TAURI__.dialog) {
103-
try {
104-
const result = await window.__TAURI__.dialog.open({
105-
multiple: false,
106-
filters: [{ name: 'PDF Files', extensions: ['pdf'] }]
107-
});
108-
return result;
109-
} catch (e) {
110-
console.error('Dialog plugin error:', e);
104+
export async function openFileDialog(extensions) {
105+
if (isTauri()) {
106+
const filters = extensions
107+
? [{ name: 'Files', extensions }]
108+
: [{ name: 'PDF Files', extensions: ['pdf'] }];
109+
110+
// Try using the dialog plugin via window.__TAURI__.dialog
111+
if (window.__TAURI__.dialog) {
112+
try {
113+
const result = await window.__TAURI__.dialog.open({
114+
multiple: false,
115+
filters
116+
});
117+
return result;
118+
} catch (e) {
119+
console.error('Dialog plugin error:', e);
120+
}
111121
}
122+
123+
// Fallback: use invoke to call a custom command
124+
return await invoke('open_file_dialog');
112125
}
113126

114-
// Fallback: use invoke to call a custom command
115-
return await invoke('open_file_dialog');
127+
// Web fallback: use HTML <input type="file">
128+
const accept = extensions
129+
? extensions.map(e => '.' + e).join(',')
130+
: '.pdf';
131+
return new Promise((resolve) => {
132+
const input = document.createElement('input');
133+
input.type = 'file';
134+
input.accept = accept;
135+
input.style.display = 'none';
136+
input.addEventListener('change', async () => {
137+
const file = input.files?.[0];
138+
document.body.removeChild(input);
139+
if (!file) { resolve(null); return; }
140+
const data = new Uint8Array(await file.arrayBuffer());
141+
_webFileCache.set(file.name, data);
142+
resolve(file.name);
143+
});
144+
input.addEventListener('cancel', () => {
145+
document.body.removeChild(input);
146+
resolve(null);
147+
});
148+
document.body.appendChild(input);
149+
input.click();
150+
});
116151
}
117152

118153
export async function saveFileDialog(defaultPath, filters) {
119-
if (!isTauri()) return null;
120-
121-
if (!filters) {
122-
filters = [{ name: 'PDF Files', extensions: ['pdf'] }];
123-
}
154+
if (isTauri()) {
155+
if (!filters) {
156+
filters = [{ name: 'PDF Files', extensions: ['pdf'] }];
157+
}
124158

125-
// Try using the dialog plugin
126-
if (window.__TAURI__.dialog) {
127-
try {
128-
const result = await window.__TAURI__.dialog.save({
129-
defaultPath: defaultPath,
130-
filters: filters
131-
});
132-
return result;
133-
} catch (e) {
134-
console.error('Dialog plugin error:', e);
159+
// Try using the dialog plugin
160+
if (window.__TAURI__.dialog) {
161+
try {
162+
const result = await window.__TAURI__.dialog.save({
163+
defaultPath: defaultPath,
164+
filters: filters
165+
});
166+
return result;
167+
} catch (e) {
168+
console.error('Dialog plugin error:', e);
169+
}
135170
}
171+
172+
return null;
136173
}
137174

138-
return null;
175+
// Web fallback: return the suggested filename (writeBinaryFile will trigger download)
176+
return defaultPath || 'document.pdf';
139177
}
140178

141179
// Folder picker dialog
@@ -160,7 +198,12 @@ export async function openFolderDialog(title) {
160198

161199
// File system operations
162200
export async function readBinaryFile(path) {
163-
if (!isTauri()) return null;
201+
// Web fallback: check the in-memory file cache first
202+
if (!isTauri()) {
203+
const cached = _webFileCache.get(path);
204+
if (cached) return cached;
205+
return null;
206+
}
164207

165208
// Use the fs plugin directly
166209
if (window.__TAURI__.fs) {
@@ -171,7 +214,26 @@ export async function readBinaryFile(path) {
171214
}
172215

173216
export async function writeBinaryFile(path, data) {
174-
if (!isTauri()) return false;
217+
if (!isTauri()) {
218+
// Web fallback: trigger a browser download
219+
const fileName = path.replace(/^.*[\\/]/, '') || 'download';
220+
const ext = fileName.split('.').pop()?.toLowerCase();
221+
const mimeMap = {
222+
pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg',
223+
jpeg: 'image/jpeg', csv: 'text/csv', xfdf: 'application/xml',
224+
xml: 'application/xml',
225+
};
226+
const blob = new Blob([data], { type: mimeMap[ext] || 'application/octet-stream' });
227+
const url = URL.createObjectURL(blob);
228+
const a = document.createElement('a');
229+
a.href = url;
230+
a.download = fileName;
231+
document.body.appendChild(a);
232+
a.click();
233+
document.body.removeChild(a);
234+
URL.revokeObjectURL(url);
235+
return true;
236+
}
175237

176238
// Use the fs plugin directly - no fallback to slow base64 method
177239
if (window.__TAURI__.fs) {
@@ -322,7 +384,9 @@ export async function buildUserAgent() {
322384

323385
// Get app version from Tauri config
324386
export async function getAppVersion() {
325-
if (!isTauri()) return null;
387+
if (!isTauri()) {
388+
return typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
389+
}
326390
try {
327391
return await window.__TAURI__.app.getVersion();
328392
} catch {
@@ -346,10 +410,20 @@ export async function getOpenedFiles() {
346410

347411
// Session management
348412
export async function saveSession(data) {
413+
if (!isTauri()) {
414+
try { localStorage.setItem('pdfStudioSession', JSON.stringify(data)); } catch { /* ignore */ }
415+
return;
416+
}
349417
return await invoke('save_session', { data: JSON.stringify(data) });
350418
}
351419

352420
export async function loadSession() {
421+
if (!isTauri()) {
422+
try {
423+
const s = localStorage.getItem('pdfStudioSession');
424+
return s ? JSON.parse(s) : null;
425+
} catch { return null; }
426+
}
353427
const result = await invoke('load_session');
354428
if (result) {
355429
try {

open-pdf-studio/js/main.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,6 @@ function setupSessionSaveOnClose() {
315315
}
316316

317317
window.addEventListener('beforeunload', async () => {
318-
if (!isTauri()) return;
319318
await saveSessionData();
320319
});
321320
}

open-pdf-studio/js/pdf/loader.js

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,26 +117,27 @@ export async function loadPDF(filePath, docIndex, preloadedData = null) {
117117
// Use pre-loaded bytes (e.g. from virtual printer capture, or browser file input)
118118
typedArray = preloadedData instanceof Uint8Array ? preloadedData : new Uint8Array(preloadedData);
119119
originalBytesCache.set(filePath, typedArray.slice());
120-
} else if (isTauri()) {
121-
// Lock the file to prevent other apps from writing while we have it open
122-
// (skip on Android — content:// URIs don't support filesystem locking)
123-
const { isMobile } = await import('../core/platform.js');
124-
if (isClosed()) return;
125-
if (!isMobile()) {
126-
await lockFile(filePath);
120+
} else {
121+
if (isTauri()) {
122+
// Lock the file to prevent other apps from writing while we have it open
123+
// (skip on Android — content:// URIs don't support filesystem locking)
124+
const { isMobile } = await import('../core/platform.js');
127125
if (isClosed()) return;
126+
if (!isMobile()) {
127+
await lockFile(filePath);
128+
if (isClosed()) return;
129+
}
128130
}
129131

130-
// Read file using Tauri fs plugin (handles content:// URIs on Android)
132+
// Read file using Tauri fs plugin or web file cache
131133
const data = await readBinaryFile(filePath);
132134
if (isClosed()) return;
135+
if (!data) throw new Error('File system access not available');
133136
typedArray = new Uint8Array(data);
134137

135138
// Cache a copy of original bytes for saver (pdf.js transfers the buffer
136139
// to a web worker, which detaches the original Uint8Array making it length 0)
137140
originalBytesCache.set(filePath, typedArray.slice());
138-
} else {
139-
throw new Error('File system access not available');
140141
}
141142

142143
// Load PDF using pdf.js (this transfers the buffer to a worker)
@@ -256,11 +257,6 @@ export async function loadPDF(filePath, docIndex, preloadedData = null) {
256257

257258
// Open file dialog and load PDF
258259
export async function openPDFFile() {
259-
if (!isTauri()) {
260-
console.warn('File dialogs require Tauri environment');
261-
return;
262-
}
263-
264260
try {
265261
const result = await openFileDialog();
266262
if (result) {

open-pdf-studio/js/pdf/saver.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,6 @@ export async function savePDF(saveAsPath = null) {
1818
return await savePDFAs();
1919
}
2020

21-
if (!isTauri()) {
22-
showMessage(i18next.t('saveRequiresTauri'));
23-
return false;
24-
}
25-
2621
try {
2722
showLoading('Saving PDF...');
2823

@@ -1662,11 +1657,6 @@ export async function savePDFAs() {
16621657
return false;
16631658
}
16641659

1665-
if (!isTauri()) {
1666-
showMessage(i18next.t('saveRequiresTauri'));
1667-
return false;
1668-
}
1669-
16701660
// Use current path as default, or the untitled file name
16711661
const doc = getActiveDocument();
16721662
const defaultPath = state.currentPdfPath || (doc ? doc.fileName : 'Untitled.pdf');

open-pdf-studio/js/solid/components/TitleBar.jsx

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -143,24 +143,26 @@ export default function TitleBar() {
143143
<button class="send-feedback-btn" onClick={() => openDialog('feedback')}>
144144
{tCommon('sendFeedback')}
145145
</button>
146-
<button class="window-btn" title={tCommon('minimize')} disabled={hasDialogs()}
147-
onClick={() => import('../../core/platform.js').then(m => m.minimizeWindow())}>
148-
<svg width="10" height="1" viewBox="0 0 10 1"><rect width="10" height="1" fill="currentColor"/></svg>
149-
</button>
150-
<button class="window-btn" title={isMaximized() ? tCommon('restore') : tCommon('maximize')} disabled={hasDialogs()}
151-
onClick={() => import('../../core/platform.js').then(m => m.maximizeWindow())}>
152-
{isMaximized() ? (
153-
<svg width="10" height="10" viewBox="2 2 12 12" fill="currentColor">
154-
<path d="M5.08496 4C5.29088 3.4174 5.8465 3 6.49961 3H9.99961C11.6565 3 12.9996 4.34315 12.9996 6V9.5C12.9996 10.1531 12.5822 10.7087 11.9996 10.9146V6C11.9996 4.89543 11.1042 4 9.99961 4H5.08496ZM4.5 5H9.5C10.3284 5 11 5.67157 11 6.5V11.5C11 12.3284 10.3284 13 9.5 13H4.5C3.67157 13 3 12.3284 3 11.5V6.5C3 5.67157 3.67157 5 4.5 5ZM4.5 6C4.22386 6 4 6.22386 4 6.5V11.5C4 11.7761 4.22386 12 4.5 12H9.5C9.77614 12 10 11.7761 10 11.5V6.5C10 6.22386 9.77614 6 9.5 6H4.5Z"/>
155-
</svg>
156-
) : (
157-
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
158-
)}
159-
</button>
160-
<button class="window-btn window-btn-close" title={tCommon('close')} disabled={hasDialogs()}
161-
onClick={handleClose}>
162-
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2"/></svg>
163-
</button>
146+
{isTauri() && <>
147+
<button class="window-btn" title={tCommon('minimize')} disabled={hasDialogs()}
148+
onClick={() => import('../../core/platform.js').then(m => m.minimizeWindow())}>
149+
<svg width="10" height="1" viewBox="0 0 10 1"><rect width="10" height="1" fill="currentColor"/></svg>
150+
</button>
151+
<button class="window-btn" title={isMaximized() ? tCommon('restore') : tCommon('maximize')} disabled={hasDialogs()}
152+
onClick={() => import('../../core/platform.js').then(m => m.maximizeWindow())}>
153+
{isMaximized() ? (
154+
<svg width="10" height="10" viewBox="2 2 12 12" fill="currentColor">
155+
<path d="M5.08496 4C5.29088 3.4174 5.8465 3 6.49961 3H9.99961C11.6565 3 12.9996 4.34315 12.9996 6V9.5C12.9996 10.1531 12.5822 10.7087 11.9996 10.9146V6C11.9996 4.89543 11.1042 4 9.99961 4H5.08496ZM4.5 5H9.5C10.3284 5 11 5.67157 11 6.5V11.5C11 12.3284 10.3284 13 9.5 13H4.5C3.67157 13 3 12.3284 3 11.5V6.5C3 5.67157 3.67157 5 4.5 5ZM4.5 6C4.22386 6 4 6.22386 4 6.5V11.5C4 11.7761 4.22386 12 4.5 12H9.5C9.77614 12 10 11.7761 10 11.5V6.5C10 6.22386 9.77614 6 9.5 6H4.5Z"/>
156+
</svg>
157+
) : (
158+
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
159+
)}
160+
</button>
161+
<button class="window-btn window-btn-close" title={tCommon('close')} disabled={hasDialogs()}
162+
onClick={handleClose}>
163+
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2"/></svg>
164+
</button>
165+
</>}
164166
</div>
165167
</div>
166168
);

open-pdf-studio/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "open-pdf-studio",
3-
"version": "1.32.0",
3+
"version": "1.33.0",
44
"description": "A free, open-source PDF annotation editor built with Tauri",
55
"scripts": {
66
"dev": "vite",

open-pdf-studio/public/_redirects

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* /index.html 200

open-pdf-studio/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "open-pdf-studio"
3-
version = "1.32.0"
3+
version = "1.33.0"
44
description = "A free, open-source PDF annotation editor"
55
authors = ["OpenAEC Foundation"]
66
license = "MIT"

open-pdf-studio/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Open PDF Studio",
4-
"version": "1.32.0",
4+
"version": "1.33.0",
55
"identifier": "org.openaec.openpdfstudio",
66
"build": {
77
"frontendDist": "../dist",

0 commit comments

Comments
 (0)