refactor: migrate PDF export to desktop runtime abstraction
- Extract desktop-specific logic into src/services/desktop.ts - Add runtime detection for Electron vs Tauri environments - Simplify NoteEditor by delegating PDF export to desktop service - Update printExport.ts to remove unused storage helpers - Add Electron main process files in electron/ directory - Update vite config and types for Electron integration - Update .gitignore and package.json for Electron workflow
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
release
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
159
electron/main.cjs
Normal file
159
electron/main.cjs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
|
||||||
|
|
||||||
|
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
|
||||||
|
const isDev = Boolean(rendererUrl);
|
||||||
|
let mainWindow = null;
|
||||||
|
|
||||||
|
const waitForPrintDocument = () => `
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const pendingImages = Array.from(document.images).filter((image) => !image.complete);
|
||||||
|
const waitForImages = Promise.all(
|
||||||
|
pendingImages.map(
|
||||||
|
(image) =>
|
||||||
|
new Promise((done) => {
|
||||||
|
image.addEventListener('load', () => done(), { once: true });
|
||||||
|
image.addEventListener('error', () => done(), { once: true });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const waitForFonts = document.fonts ? document.fonts.ready.catch(() => undefined) : Promise.resolve();
|
||||||
|
|
||||||
|
Promise.all([waitForImages, waitForFonts]).then(() => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createMainWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1300,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
mainWindow.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
void mainWindow.loadURL(rendererUrl);
|
||||||
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
} else {
|
||||||
|
void mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createMainWindow();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createMainWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('desktop:show-message', async (event, options) => {
|
||||||
|
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
|
||||||
|
const typeMap = {
|
||||||
|
info: 'info',
|
||||||
|
warning: 'warning',
|
||||||
|
error: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
await dialog.showMessageBox(ownerWindow, {
|
||||||
|
type: typeMap[options.kind] || 'info',
|
||||||
|
title: options.title || app.name,
|
||||||
|
message: options.message,
|
||||||
|
buttons: ['OK'],
|
||||||
|
defaultId: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('desktop:http-request', async (_event, payload) => {
|
||||||
|
const body =
|
||||||
|
payload.bodyBase64 != null
|
||||||
|
? Buffer.from(payload.bodyBase64, 'base64')
|
||||||
|
: payload.bodyText;
|
||||||
|
|
||||||
|
const response = await fetch(payload.url, {
|
||||||
|
method: payload.method || 'GET',
|
||||||
|
headers: payload.headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
bodyBase64: buffer.toString('base64'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('desktop:export-pdf', async (event, payload) => {
|
||||||
|
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
|
||||||
|
const saveResult = await dialog.showSaveDialog(ownerWindow, {
|
||||||
|
title: 'Export PDF',
|
||||||
|
defaultPath: payload.fileName,
|
||||||
|
filters: [{ name: 'PDF Document', extensions: ['pdf'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saveResult.canceled || !saveResult.filePath) {
|
||||||
|
return { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfWindow = new BrowserWindow({
|
||||||
|
width: 960,
|
||||||
|
height: 1100,
|
||||||
|
show: false,
|
||||||
|
parent: ownerWindow ?? undefined,
|
||||||
|
webPreferences: {
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pdfWindow.loadURL(
|
||||||
|
`data:text/html;charset=utf-8,${encodeURIComponent(payload.documentHtml)}`
|
||||||
|
);
|
||||||
|
await pdfWindow.webContents.executeJavaScript(waitForPrintDocument(), true);
|
||||||
|
|
||||||
|
const pdfData = await pdfWindow.webContents.printToPDF({
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.writeFile(saveResult.filePath, pdfData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canceled: false,
|
||||||
|
filePath: saveResult.filePath,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (!pdfWindow.isDestroyed()) {
|
||||||
|
pdfWindow.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
8
electron/preload.cjs
Normal file
8
electron/preload.cjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronDesktop', {
|
||||||
|
showMessage: (options) => ipcRenderer.invoke('desktop:show-message', options),
|
||||||
|
exportPdf: (payload) => ipcRenderer.invoke('desktop:export-pdf', payload),
|
||||||
|
httpRequest: (payload) => ipcRenderer.invoke('desktop:http-request', payload),
|
||||||
|
getRuntime: () => 'electron',
|
||||||
|
});
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'self'"
|
||||||
|
/>
|
||||||
<title>Nextcloud Notes</title>
|
<title>Nextcloud Notes</title>
|
||||||
<!-- Local fonts for offline support -->
|
<!-- Local fonts for offline support -->
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||||
|
|||||||
1576
package-lock.json
generated
1576
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -3,9 +3,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:renderer": "vite",
|
||||||
|
"dev:electron": "wait-on tcp:1420 && cross-env ELECTRON_RENDERER_URL=http://localhost:1420 electron .",
|
||||||
|
"dev:desktop": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"desktop": "electron .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
@@ -34,9 +39,13 @@
|
|||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"electron": "^37.3.1",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4",
|
||||||
|
"wait-on": "^8.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||||
import { LoginView } from './components/LoginView';
|
import { LoginView } from './components/LoginView';
|
||||||
import { NotesList } from './components/NotesList';
|
import { NotesList } from './components/NotesList';
|
||||||
import { NoteEditor } from './components/NoteEditor';
|
import { NoteEditor } from './components/NoteEditor';
|
||||||
@@ -9,9 +9,13 @@ import { syncManager, SyncStatus } from './services/syncManager';
|
|||||||
import { localDB } from './db/localDB';
|
import { localDB } from './db/localDB';
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||||
import { categoryColorsSync } from './services/categoryColorsSync';
|
import { categoryColorsSync } from './services/categoryColorsSync';
|
||||||
import { PrintView } from './components/PrintView';
|
|
||||||
import { PRINT_EXPORT_QUERY_PARAM } from './printExport';
|
import { PRINT_EXPORT_QUERY_PARAM } from './printExport';
|
||||||
|
|
||||||
|
const LazyPrintView = lazy(async () => {
|
||||||
|
const module = await import('./components/PrintView');
|
||||||
|
return { default: module.PrintView };
|
||||||
|
});
|
||||||
|
|
||||||
function MainApp() {
|
function MainApp() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
||||||
@@ -397,7 +401,11 @@ function App() {
|
|||||||
const printJobId = params.get(PRINT_EXPORT_QUERY_PARAM);
|
const printJobId = params.get(PRINT_EXPORT_QUERY_PARAM);
|
||||||
|
|
||||||
if (printJobId) {
|
if (printJobId) {
|
||||||
return <PrintView jobId={printJobId} />;
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">Preparing print view...</div>}>
|
||||||
|
<LazyPrintView jobId={printJobId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MainApp />;
|
return <MainApp />;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
|
||||||
import { Note, APIConfig } from '../types';
|
import { Note, APIConfig } from '../types';
|
||||||
|
import { runtimeFetch } from '../services/runtimeFetch';
|
||||||
|
|
||||||
export class NextcloudAPI {
|
export class NextcloudAPI {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
@@ -16,7 +16,7 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(`${this.baseURL}${path}`, {
|
const response = await runtimeFetch(`${this.baseURL}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -124,7 +124,7 @@ export class NextcloudAPI {
|
|||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url);
|
console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url);
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
},
|
},
|
||||||
@@ -174,7 +174,7 @@ export class NextcloudAPI {
|
|||||||
// First, try to create the attachments directory (MKCOL)
|
// First, try to create the attachments directory (MKCOL)
|
||||||
// This may fail if it already exists, which is fine
|
// This may fail if it already exists, which is fine
|
||||||
try {
|
try {
|
||||||
await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
|
await runtimeFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
|
||||||
method: 'MKCOL',
|
method: 'MKCOL',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -188,7 +188,7 @@ export class NextcloudAPI {
|
|||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
// Upload the file via PUT
|
// Upload the file via PUT
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -210,7 +210,7 @@ export class NextcloudAPI {
|
|||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
},
|
},
|
||||||
@@ -238,7 +238,7 @@ export class NextcloudAPI {
|
|||||||
|
|
||||||
const content = JSON.stringify(colors, null, 2);
|
const content = JSON.stringify(colors, null, 2);
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -281,7 +281,7 @@ export class NextcloudAPI {
|
|||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'PROPFIND',
|
method: 'PROPFIND',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -361,7 +361,7 @@ export class NextcloudAPI {
|
|||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
headers: { 'Authorization': this.authHeader },
|
headers: { 'Authorization': this.authHeader },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ export class NextcloudAPI {
|
|||||||
if (category) {
|
if (category) {
|
||||||
try {
|
try {
|
||||||
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
|
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
|
||||||
await tauriFetch(categoryUrl, {
|
await runtimeFetch(categoryUrl, {
|
||||||
method: 'MKCOL',
|
method: 'MKCOL',
|
||||||
headers: { 'Authorization': this.authHeader },
|
headers: { 'Authorization': this.authHeader },
|
||||||
});
|
});
|
||||||
@@ -394,7 +394,7 @@ export class NextcloudAPI {
|
|||||||
|
|
||||||
const noteContent = `${title}\n${content}`;
|
const noteContent = `${title}\n${content}`;
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -451,7 +451,7 @@ export class NextcloudAPI {
|
|||||||
|
|
||||||
const noteContent = this.formatNoteContent(note);
|
const noteContent = this.formatNoteContent(note);
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -482,7 +482,7 @@ export class NextcloudAPI {
|
|||||||
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`;
|
const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`;
|
||||||
|
|
||||||
const response = await tauriFetch(`${this.serverURL}${oldPath}`, {
|
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||||
method: 'MOVE',
|
method: 'MOVE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -509,7 +509,7 @@ export class NextcloudAPI {
|
|||||||
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`;
|
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||||
method: 'MOVE',
|
method: 'MOVE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -535,7 +535,7 @@ export class NextcloudAPI {
|
|||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': this.authHeader },
|
headers: { 'Authorization': this.authHeader },
|
||||||
});
|
});
|
||||||
@@ -560,7 +560,7 @@ export class NextcloudAPI {
|
|||||||
currentPath += (currentPath ? '/' : '') + part;
|
currentPath += (currentPath ? '/' : '') + part;
|
||||||
try {
|
try {
|
||||||
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`;
|
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`;
|
||||||
await tauriFetch(categoryUrl, {
|
await runtimeFetch(categoryUrl, {
|
||||||
method: 'MKCOL',
|
method: 'MKCOL',
|
||||||
headers: { 'Authorization': this.authHeader },
|
headers: { 'Authorization': this.authHeader },
|
||||||
});
|
});
|
||||||
@@ -570,7 +570,7 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await tauriFetch(`${this.serverURL}${oldPath}`, {
|
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||||
method: 'MOVE',
|
method: 'MOVE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -597,7 +597,7 @@ export class NextcloudAPI {
|
|||||||
console.log(` To: ${newAttachmentPath}`);
|
console.log(` To: ${newAttachmentPath}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const attachmentResponse = await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||||
method: 'MOVE',
|
method: 'MOVE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { message } from '@tauri-apps/plugin-dialog';
|
|
||||||
import { emitTo, listen } from '@tauri-apps/api/event';
|
|
||||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
|
||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
import { NextcloudAPI } from '../api/nextcloud';
|
import { NextcloudAPI } from '../api/nextcloud';
|
||||||
import { FloatingToolbar } from './FloatingToolbar';
|
import { FloatingToolbar } from './FloatingToolbar';
|
||||||
import { InsertToolbar } from './InsertToolbar';
|
import { InsertToolbar } from './InsertToolbar';
|
||||||
|
import {
|
||||||
|
exportPdfDocument,
|
||||||
|
getDesktopRuntime,
|
||||||
|
showDesktopMessage,
|
||||||
|
} from '../services/desktop';
|
||||||
import {
|
import {
|
||||||
getNoteTitleFromContent,
|
getNoteTitleFromContent,
|
||||||
PRINT_EXPORT_QUERY_PARAM,
|
PrintExportPayload,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
} from '../printExport';
|
} from '../printExport';
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
const previousNoteContentRef = useRef<string>('');
|
const previousNoteContentRef = useRef<string>('');
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const desktopRuntime = getDesktopRuntime();
|
||||||
|
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onUnsavedChanges?.(hasUnsavedChanges);
|
onUnsavedChanges?.(hasUnsavedChanges);
|
||||||
@@ -222,13 +226,6 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
if (!note) return;
|
if (!note) return;
|
||||||
|
|
||||||
setIsExportingPDF(true);
|
setIsExportingPDF(true);
|
||||||
const exportState: {
|
|
||||||
printWindow: WebviewWindow | null;
|
|
||||||
unlistenReady: (() => void) | null;
|
|
||||||
} = {
|
|
||||||
printWindow: null,
|
|
||||||
unlistenReady: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let contentForPrint = localContent;
|
let contentForPrint = localContent;
|
||||||
@@ -264,68 +261,22 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
|
|
||||||
const title = getNoteTitleFromContent(localContent);
|
const title = getNoteTitleFromContent(localContent);
|
||||||
const fileName = `${sanitizeFileName(title)}.pdf`;
|
const fileName = `${sanitizeFileName(title)}.pdf`;
|
||||||
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const noteHtml = marked.parse(contentForPrint || '', { async: false }) as string;
|
||||||
const windowLabel = `print-export-${jobId}`;
|
const payload: PrintExportPayload = {
|
||||||
const html = marked.parse(contentForPrint || '', { async: false }) as string;
|
fileName,
|
||||||
await new Promise<void>((resolve, reject) => {
|
title,
|
||||||
let settled = false;
|
html: noteHtml,
|
||||||
const timeoutId = window.setTimeout(() => {
|
previewFont,
|
||||||
if (settled) return;
|
previewFontSize,
|
||||||
settled = true;
|
};
|
||||||
exportState.unlistenReady?.();
|
|
||||||
reject(new Error('Print view initialization timed out.'));
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
listen<{ jobId: string }>('print-export-ready', (event) => {
|
await exportPdfDocument({
|
||||||
if (settled || event.payload.jobId !== jobId) return;
|
...payload,
|
||||||
|
|
||||||
settled = true;
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
exportState.unlistenReady?.();
|
|
||||||
|
|
||||||
void emitTo(windowLabel, 'print-export-payload', {
|
|
||||||
fileName,
|
|
||||||
title,
|
|
||||||
html,
|
|
||||||
previewFont,
|
|
||||||
previewFontSize,
|
|
||||||
})
|
|
||||||
.then(() => resolve())
|
|
||||||
.catch(reject);
|
|
||||||
})
|
|
||||||
.then((unlisten) => {
|
|
||||||
exportState.unlistenReady = unlisten;
|
|
||||||
exportState.printWindow = new WebviewWindow(windowLabel, {
|
|
||||||
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
|
|
||||||
title: 'Opening Print Dialog',
|
|
||||||
width: 960,
|
|
||||||
height: 1100,
|
|
||||||
center: true,
|
|
||||||
visible: true,
|
|
||||||
focus: true,
|
|
||||||
skipTaskbar: true,
|
|
||||||
resizable: false,
|
|
||||||
parent: 'main',
|
|
||||||
});
|
|
||||||
|
|
||||||
void exportState.printWindow.once('tauri://error', (event) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
exportState.unlistenReady?.();
|
|
||||||
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(reject);
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF export failed:', error);
|
console.error('PDF export failed:', error);
|
||||||
const pendingPrintWindow = exportState.printWindow;
|
|
||||||
if (pendingPrintWindow) {
|
|
||||||
void pendingPrintWindow.close().catch(() => undefined);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await message('Failed to open the print-to-PDF view. Please try again.', {
|
await showDesktopMessage('Failed to export the PDF. Please try again.', {
|
||||||
title: 'Export Failed',
|
title: 'Export Failed',
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
});
|
});
|
||||||
@@ -333,10 +284,6 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
console.error('Could not show error dialog');
|
console.error('Could not show error dialog');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
const disposeReadyListener = exportState.unlistenReady;
|
|
||||||
if (disposeReadyListener) {
|
|
||||||
disposeReadyListener();
|
|
||||||
}
|
|
||||||
setIsExportingPDF(false);
|
setIsExportingPDF(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -401,13 +348,13 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
setHasUnsavedChanges(true);
|
setHasUnsavedChanges(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
await message(`Attachment uploaded successfully!`, {
|
await showDesktopMessage('Attachment uploaded successfully!', {
|
||||||
title: 'Upload Complete',
|
title: 'Upload Complete',
|
||||||
kind: 'info',
|
kind: 'info',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload failed:', error);
|
console.error('Upload failed:', error);
|
||||||
await message(`Failed to upload attachment: ${error}`, {
|
await showDesktopMessage(`Failed to upload attachment: ${error}`, {
|
||||||
title: 'Upload Failed',
|
title: 'Upload Failed',
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
});
|
});
|
||||||
@@ -719,13 +666,20 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
|||||||
? 'text-blue-500 cursor-wait'
|
? 'text-blue-500 cursor-wait'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
title={isExportingPDF ? "Opening print dialog..." : "Print Note"}
|
title={isExportingPDF ? `${exportActionLabel} in progress...` : exportActionLabel}
|
||||||
>
|
>
|
||||||
{isExportingPDF ? (
|
{isExportingPDF ? (
|
||||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
) : desktopRuntime === 'electron' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 16V4" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12l4 4 4-4" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 20h14" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20v-2a1 1 0 011-1h8a1 1 0 011 1v2" />
|
||||||
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9V4a1 1 0 011-1h10a1 1 0 011 1v5" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9V4a1 1 0 011-1h10a1 1 0 011 1v5" />
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ export interface PrintExportPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
|
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
|
||||||
const PRINT_EXPORT_STORAGE_PREFIX = 'print-export:';
|
|
||||||
|
|
||||||
export const getPrintExportStorageKey = (jobId: string) =>
|
const PRINT_DOCUMENT_CSP = [
|
||||||
`${PRINT_EXPORT_STORAGE_PREFIX}${jobId}`;
|
"default-src 'none'",
|
||||||
|
"style-src 'unsafe-inline'",
|
||||||
|
"img-src data: blob: https: http:",
|
||||||
|
"font-src data:",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
export const getNoteTitleFromContent = (content: string) => {
|
export const getNoteTitleFromContent = (content: string) => {
|
||||||
const firstLine = content
|
const firstLine = content
|
||||||
@@ -26,3 +31,225 @@ export const sanitizeFileName = (name: string) =>
|
|||||||
.replace(/[\\/:*?"<>|]/g, '-')
|
.replace(/[\\/:*?"<>|]/g, '-')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim() || 'note';
|
.trim() || 'note';
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
const escapeFontFamily = (value: string) =>
|
||||||
|
value.replace(/["\\]/g, '\\$&');
|
||||||
|
|
||||||
|
export const buildPrintDocument = (payload: PrintExportPayload) => {
|
||||||
|
const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`;
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="${PRINT_DOCUMENT_CSP}" />
|
||||||
|
<title>${escapeHtml(payload.fileName)}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: ${fontFamily};
|
||||||
|
font-size: ${payload.previewFontSize}px;
|
||||||
|
line-height: 1.7;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 18mm 16mm 18mm 16mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h1,
|
||||||
|
.print-note h2,
|
||||||
|
.print-note h3 {
|
||||||
|
color: #020617;
|
||||||
|
font-weight: 700;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 1.35em;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h2 {
|
||||||
|
font-size: 1.55em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 1.25em 0 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1.1em 0 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note p {
|
||||||
|
margin: 0 0 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ul,
|
||||||
|
.print-note ol {
|
||||||
|
margin: 0.75em 0 1em;
|
||||||
|
padding-left: 1.7em;
|
||||||
|
list-style-position: outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li {
|
||||||
|
margin: 0.28em 0;
|
||||||
|
padding-left: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li::marker {
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote {
|
||||||
|
margin: 1.15em 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid #cbd5e1;
|
||||||
|
color: #475569;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note pre {
|
||||||
|
margin: 1.15em 0 1.3em;
|
||||||
|
padding: 0.95em 1.05em;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #0f172a;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note pre code {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 0.92em;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note code {
|
||||||
|
font-family: "SFMono-Regular", "SF Mono", "JetBrains Mono", "Fira Code", "Source Code Pro", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 0.08em 0.38em;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 0.42em;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #cbd5e1;
|
||||||
|
margin: 1.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.2em auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0 1.2em;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note th,
|
||||||
|
.print-note td {
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
padding: 0.5em 0.65em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note th {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="print-note">${payload.html}</article>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|||||||
139
src/services/desktop.ts
Normal file
139
src/services/desktop.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
buildPrintDocument,
|
||||||
|
PrintExportPayload,
|
||||||
|
PRINT_EXPORT_QUERY_PARAM,
|
||||||
|
} from '../printExport';
|
||||||
|
|
||||||
|
export interface DesktopMessageOptions {
|
||||||
|
title?: string;
|
||||||
|
kind?: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportPdfResult {
|
||||||
|
canceled: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isElectronRuntime = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.electronDesktop !== 'undefined';
|
||||||
|
|
||||||
|
const isTauriRuntime = () =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(
|
||||||
|
typeof (window as Window & { __TAURI__?: unknown }).__TAURI__ !== 'undefined' ||
|
||||||
|
typeof (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== 'undefined'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getDesktopRuntime = () => {
|
||||||
|
if (isElectronRuntime()) return 'electron';
|
||||||
|
if (isTauriRuntime()) return 'tauri';
|
||||||
|
return 'browser';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showDesktopMessage = async (
|
||||||
|
messageText: string,
|
||||||
|
options: DesktopMessageOptions = {}
|
||||||
|
) => {
|
||||||
|
if (window.electronDesktop) {
|
||||||
|
await window.electronDesktop.showMessage({
|
||||||
|
message: messageText,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
const { message } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
await message(messageText, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.alert(messageText);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportPdfDocument = async (
|
||||||
|
payload: PrintExportPayload
|
||||||
|
): Promise<ExportPdfResult> => {
|
||||||
|
if (window.electronDesktop) {
|
||||||
|
return window.electronDesktop.exportPdf({
|
||||||
|
...payload,
|
||||||
|
documentHtml: buildPrintDocument(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
await exportPdfDocumentWithTauri(payload);
|
||||||
|
return { canceled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('PDF export is only available in a desktop runtime.');
|
||||||
|
};
|
||||||
|
|
||||||
|
async function exportPdfDocumentWithTauri(payload: PrintExportPayload) {
|
||||||
|
const [{ emitTo, listen }, { WebviewWindow }] = await Promise.all([
|
||||||
|
import('@tauri-apps/api/event'),
|
||||||
|
import('@tauri-apps/api/webviewWindow'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const windowLabel = `print-export-${jobId}`;
|
||||||
|
let printWindow: any = null;
|
||||||
|
let unlistenReady: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
unlistenReady?.();
|
||||||
|
reject(new Error('Print view initialization timed out.'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
listen<{ jobId: string }>('print-export-ready', (event) => {
|
||||||
|
if (settled || event.payload.jobId !== jobId) return;
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
unlistenReady?.();
|
||||||
|
|
||||||
|
void emitTo(windowLabel, 'print-export-payload', payload)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
})
|
||||||
|
.then((unlisten) => {
|
||||||
|
unlistenReady = unlisten;
|
||||||
|
printWindow = new WebviewWindow(windowLabel, {
|
||||||
|
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
|
||||||
|
title: 'Opening Print Dialog',
|
||||||
|
width: 960,
|
||||||
|
height: 1100,
|
||||||
|
center: true,
|
||||||
|
visible: true,
|
||||||
|
focus: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
parent: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
void printWindow.once('tauri://error', (event: { payload?: unknown }) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
unlistenReady?.();
|
||||||
|
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (printWindow) {
|
||||||
|
void printWindow.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (typeof unlistenReady === 'function') {
|
||||||
|
unlistenReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/services/runtimeFetch.ts
Normal file
155
src/services/runtimeFetch.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
interface ElectronHttpRequest {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyText?: string;
|
||||||
|
bodyBase64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronHttpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: {
|
||||||
|
get(name: string): string | null;
|
||||||
|
};
|
||||||
|
text(): Promise<string>;
|
||||||
|
json<T>(): Promise<T>;
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
blob(): Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
const bytesToBase64 = (bytes: Uint8Array) => {
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||||
|
const chunk = bytes.subarray(index, index + 0x8000);
|
||||||
|
binary += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64ToBytes = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeHeaders = (headers: Record<string, string>) => {
|
||||||
|
const normalized = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
normalized.set(key.toLowerCase(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRuntimeResponse = (response: ElectronHttpResponse): RuntimeResponse => {
|
||||||
|
const headers = normalizeHeaders(response.headers);
|
||||||
|
const bytes = base64ToBytes(response.bodyBase64);
|
||||||
|
const contentType = headers.get('content-type') || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: {
|
||||||
|
get(name: string) {
|
||||||
|
return headers.get(name.toLowerCase()) ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return textDecoder.decode(bytes);
|
||||||
|
},
|
||||||
|
async json<T>() {
|
||||||
|
return JSON.parse(textDecoder.decode(bytes)) as T;
|
||||||
|
},
|
||||||
|
async arrayBuffer() {
|
||||||
|
return bytes.buffer.slice(
|
||||||
|
bytes.byteOffset,
|
||||||
|
bytes.byteOffset + bytes.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
},
|
||||||
|
async blob() {
|
||||||
|
return new Blob([bytes], { type: contentType });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const headersToObject = (headers?: HeadersInit) => {
|
||||||
|
if (!headers) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(new Headers(headers).entries());
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeBody = async (body?: BodyInit | null) => {
|
||||||
|
if (body == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
return { bodyText: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof ArrayBuffer) {
|
||||||
|
return { bodyBase64: bytesToBase64(new Uint8Array(body)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ArrayBuffer.isView(body)) {
|
||||||
|
return {
|
||||||
|
bodyBase64: bytesToBase64(
|
||||||
|
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof Blob) {
|
||||||
|
return {
|
||||||
|
bodyBase64: bytesToBase64(new Uint8Array(await body.arrayBuffer())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported request body for Electron runtime.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtimeFetch = async (
|
||||||
|
url: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<Response | RuntimeResponse> => {
|
||||||
|
if (!window.electronDesktop?.httpRequest) {
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ElectronHttpRequest = {
|
||||||
|
url,
|
||||||
|
method: init.method,
|
||||||
|
headers: headersToObject(init.headers),
|
||||||
|
...(await serializeBody(init.body)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await window.electronDesktop.httpRequest(payload);
|
||||||
|
return createRuntimeResponse(response);
|
||||||
|
};
|
||||||
45
src/vite-env.d.ts
vendored
45
src/vite-env.d.ts
vendored
@@ -1 +1,46 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ElectronDesktopMessageOptions {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
kind?: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopExportPayload {
|
||||||
|
fileName: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
previewFont: string;
|
||||||
|
previewFontSize: number;
|
||||||
|
documentHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopExportResult {
|
||||||
|
canceled: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopHttpRequest {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyText?: string;
|
||||||
|
bodyBase64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopHttpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electronDesktop?: {
|
||||||
|
showMessage: (options: ElectronDesktopMessageOptions) => Promise<void>;
|
||||||
|
exportPdf: (payload: ElectronDesktopExportPayload) => Promise<ElectronDesktopExportResult>;
|
||||||
|
httpRequest: (payload: ElectronDesktopHttpRequest) => Promise<ElectronDesktopHttpResponse>;
|
||||||
|
getRuntime: () => 'electron';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export default defineConfig(async () => ({
|
|||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
|
headers: {
|
||||||
|
"Content-Security-Policy":
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
|
||||||
|
},
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
|
|||||||
Reference in New Issue
Block a user