1 Commits
dev ... main

Author SHA1 Message Date
drelich
00e1f47511 Migrate to Electron and implement native PDF export
Major changes:
- Migrate from Tauri to Electron as primary desktop runtime
- Implement native print dialog for PDF export via Electron webview
- Add desktop runtime abstraction layer (supports both Electron and Tauri)
- Implement task list rendering in preview mode
- Add favorite notes sorting to display starred notes at top
- Add attachment upload functionality with file picker
- Improve sync reliability and Unicode filename support
- Add category color sync across devices via WebDAV
- Update documentation for Electron workflow

Technical improvements:
- Add Electron main process and preload bridge
- Create desktop service layer for runtime-agnostic operations
- Implement runtimeFetch for proxying network requests through Electron
- Add PrintView component for native print rendering
- Extract print/PDF utilities to shared module
- Update build configuration for Electron integration
2026-04-06 10:16:18 +02:00
21 changed files with 7394 additions and 448 deletions

1
.gitignore vendored
View File

@@ -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

168
README.md
View File

@@ -1,85 +1,143 @@
![nextcloud-notes-tauri.png](src/assets/nextcloud-notes-tauri.png) ![nextcloud-notes-tauri.png](src/assets/nextcloud-notes-tauri.png)
# Tauri + React + Typescript # Nextcloud Notes Desktop
# Nextcloud Notes - Cross-Platform Desktop App A desktop client for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with React, TypeScript, Vite, and Electron.
A modern, cross-platform desktop application for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with Tauri + React + TypeScript. This project started life as a Tauri app and has now been migrated to Electron for desktop runtime support, PDF export, and simpler cross-platform desktop behavior during development.
## Features ## What It Does
- **Cross-platform**: macOS, Linux, Windows - Sign in to a Nextcloud server with Notes enabled
- **Lightweight**: ~600KB binary (vs 150MB+ Electron) - Sync notes from WebDAV and favorite state from the Notes API
- **Modern UI**: React + TailwindCSS - Create, edit, move, rename, and delete notes
- **Full sync**: Create, edit, delete, favorite notes - Organize notes into categories, including nested categories
- **Search & filter**: Find notes quickly, filter by favorites - Mark notes as favorites
- **Auto-save**: Changes save automatically after 1.5s - Cache notes locally for faster startup and offline viewing
- **Secure**: Credentials stored in system keychain (localStorage for now) - Upload and render note attachments
- **Background sync**: Auto-sync every 5 minutes - Preview Markdown while editing
- Export notes to PDF
- Use a focus mode for distraction-free editing
## Prerequisites ## Current Runtime
- **Rust**: Install from https://rustup.rs/ - Primary desktop runtime: Electron
- **Node.js**: v18+ recommended - Frontend: React 19 + TypeScript + Vite
- **Nextcloud instance** with Notes app enabled - Styling: Tailwind CSS
- Local cache: IndexedDB
- Nextcloud integration:
- WebDAV for note files, folders, attachments, and category color storage
- Notes API for favorite metadata
Some Tauri-related code and dependencies are still present in the repository, mainly because parts of the app were built during the earlier Tauri phase. The Electron path is the actively used desktop runtime.
## Requirements
- Node.js 18 or newer
- npm
- A Nextcloud instance with the Notes app enabled
## Install
```bash
npm install
```
## Development ## Development
Run the Electron app with the Vite dev server:
```bash ```bash
# Install dependencies npm run dev:desktop
npm install ```
# Run in development mode Useful scripts:
npm run tauri dev
# Build for production ```bash
npm run tauri build npm run dev:renderer # Vite frontend only
npm run dev:electron # Electron only, expects renderer on port 1420
npm run build # TypeScript + Vite production build
npm run desktop # Run Electron against the built dist/
npm run dist:dir # Build an unpacked Electron app in release/
npm run dist:mac # Build macOS .dmg and .zip packages in release/
```
## Production-Like Local Run
Build the frontend, then start Electron against the generated `dist/` files:
```bash
npm run build
npm run desktop
``` ```
## First Launch ## First Launch
1. Enter your Nextcloud server URL (e.g., `https://cloud.example.com`) 1. Enter your Nextcloud server URL, for example `https://cloud.example.com`
2. Enter your username 2. Enter your username
3. Enter your password or **App Password** (recommended) 3. Enter your password or, preferably, a Nextcloud app password
- Generate at: Settings → Security → Devices & Sessions in Nextcloud 4. Wait for the initial sync to finish
4. Click **Connect**
## Building for Distribution Using an app password is strongly recommended.
### macOS ## Notable Behavior
```bash
npm run tauri build ### Sync model
# Output: src-tauri/target/release/bundle/macos/
- Note files are synced through WebDAV
- Favorite status is synced through the Nextcloud Notes API
- Notes are cached locally and can still be viewed when offline
- Background sync runs periodically while the app is open
### PDF export
- In Electron, the toolbar export action saves a PDF directly to disk
- Embedded note images are resolved before export when possible
### Category colors
- Category color preferences are stored in `.category-colors.json` inside your Nextcloud Notes WebDAV folder
## Project Structure
```text
electron/ Electron main process and preload bridge
src/api/ Nextcloud API and WebDAV client logic
src/components/ React UI
src/db/ Local IndexedDB cache
src/services/ Desktop runtime helpers and sync logic
src/printExport.ts Shared print/PDF document generation
``` ```
### Linux ## Security Notes
```bash
npm run tauri build
# Output: src-tauri/target/release/bundle/appimage/ or .deb
```
### Windows - Electron runs with `contextIsolation: true`
```bash - `nodeIntegration` is disabled in renderer windows
npm run tauri build - Network requests that need desktop privileges are routed through Electron IPC instead of renderer-side browser fetch
# Output: src-tauri/target/release/bundle/msi/
```
## Tech Stack Current limitation:
- **Tauri**: Rust-based native wrapper (~600KB) - Login credentials are still persisted in `localStorage`
- **React 18**: UI framework
- **TypeScript**: Type safety
- **TailwindCSS**: Utility-first styling
- **Vite**: Fast build tool
## Advantages over Native Swift App That is convenient for development, but it is not the right long-term storage mechanism for a production desktop app. A future improvement should move credentials into the OS keychain or another secure secret store.
-**Cross-platform**: One codebase for macOS, Linux, Windows ## Packaging
-**No SwiftUI state issues**: React's state management is mature
-**Smaller binary**: Tauri is much lighter than Electron
-**Easier to maintain**: Web technologies vs platform-specific code
-**No Xcode required**: Build on any platform
## Recommended IDE Setup Electron packaging is set up with `electron-builder`.
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) Current packaging commands:
- `npm run dist:dir` creates an unpacked app bundle in `release/`
- `npm run dist:mac` creates macOS `.dmg` and `.zip` artifacts in `release/`
The current mac build is unsigned and not notarized, which is fine for local use and testing but not enough for friction-free public distribution through Gatekeeper.
Windows and Linux targets are also configured in `package.json`, but they have not been validated in this repository yet.
## Legacy Tauri Script
There is still a `npm run tauri` script in `package.json`, but the README and current workflow are centered on Electron.
## License
No license file is currently included in this repository.

164
electron/main.cjs Normal file
View File

@@ -0,0 +1,164 @@
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 tempHtmlPath = path.join(
app.getPath('temp'),
`nextcloud-notes-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.html`
);
const pdfWindow = new BrowserWindow({
width: 960,
height: 1100,
show: false,
parent: ownerWindow ?? undefined,
webPreferences: {
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
spellcheck: false,
},
});
try {
await fs.writeFile(tempHtmlPath, payload.documentHtml, 'utf8');
await pdfWindow.loadFile(tempHtmlPath);
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 {
await fs.unlink(tempHtmlPath).catch(() => undefined);
if (!pdfWindow.isDestroyed()) {
pdfWindow.destroy();
}
}
});

8
electron/preload.cjs Normal file
View 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',
});

View File

@@ -2,15 +2,19 @@
<html lang="en"> <html lang="en">
<head> <head>
<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">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="./src/main.tsx"></script>
</body> </body>
</html> </html>

5011
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,18 @@
"name": "nextcloud-notes-tauri", "name": "nextcloud-notes-tauri",
"private": true, "private": true,
"version": "0.2.2", "version": "0.2.2",
"description": "Desktop client for Nextcloud Notes built with Electron, React, and TypeScript.",
"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 .",
"dist:dir": "npm run build && electron-builder --dir",
"dist:mac": "npm run build && electron-builder --mac dmg zip",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
@@ -34,9 +42,50 @@
"@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",
"electron-builder": "^26.8.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"
},
"build": {
"appId": "cz.davidrelich.nextcloud-notes-desktop",
"productName": "Nextcloud Notes Desktop",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"asar": true,
"npmRebuild": false,
"mac": {
"category": "public.app-category.productivity",
"icon": "src-tauri/icons/icon.icns",
"target": [
"dmg",
"zip"
]
},
"win": {
"icon": "src-tauri/icons/icon.ico",
"target": [
"nsis",
"zip"
]
},
"linux": {
"icon": "src-tauri/icons/icon.png",
"target": [
"AppImage",
"deb"
]
}
} }
} }

View File

@@ -2,9 +2,11 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main", "print-export-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-print",
"opener:default", "opener:default",
"http:default", "http:default",
{ {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { lazy, Suspense, useEffect, useRef, 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,12 +9,89 @@ 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 { PRINT_EXPORT_QUERY_PARAM } from './printExport';
function App() { const LazyPrintView = lazy(async () => {
const module = await import('./components/PrintView');
return { default: module.PrintView };
});
const AUTOSAVE_DELAY_MS = 1000;
interface SaveController {
timerId: number | null;
revision: number;
inFlight: Promise<void> | null;
inFlightRevision: number;
}
interface FlushSaveOptions {
force?: boolean;
}
const sortNotes = (notes: Note[]) =>
[...notes].sort((a, b) => b.modified - a.modified);
const toStoredNote = (note: Note): Note => ({
...note,
isSaving: false,
});
const getNoteDraftId = (note: Note | null | undefined) => note?.draftId ?? null;
const createDraftId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
};
const getRemoteCategory = (note: Note) => {
if (note.path) {
const pathParts = note.path.split('/');
return pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
}
if (typeof note.id === 'string') {
const idParts = note.id.split('/');
return idParts.length > 1 ? idParts.slice(0, -1).join('/') : '';
}
return note.category;
};
const splitNoteContent = (content: string) => {
const [firstLine = '', ...rest] = content.split('\n');
return {
title: firstLine.replace(/^#+\s*/, '').trim(),
body: rest.join('\n'),
};
};
const canAutosaveLocalNote = (note: Note) => {
if (!note.localOnly) {
return true;
}
const { title } = splitNoteContent(note.content);
return title.length > 0 && note.content.includes('\n');
};
const canForceSaveLocalNote = (note: Note) => {
if (!note.localOnly) {
return true;
}
const { title } = splitNoteContent(note.content);
return title.length > 0;
};
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);
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [selectedNoteId, setSelectedNoteId] = useState<number | string | null>(null); const [selectedNoteDraftId, setSelectedNoteDraftId] = useState<string | null>(null);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [selectedCategory, setSelectedCategory] = useState(''); const [selectedCategory, setSelectedCategory] = useState('');
@@ -24,7 +101,6 @@ function App() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system'); const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light'); const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [editorFont, setEditorFont] = useState('Source Code Pro'); const [editorFont, setEditorFont] = useState('Source Code Pro');
const [editorFontSize, setEditorFontSize] = useState(14); const [editorFontSize, setEditorFontSize] = useState(14);
const [previewFont, setPreviewFont] = useState('Merriweather'); const [previewFont, setPreviewFont] = useState('Merriweather');
@@ -32,6 +108,89 @@ function App() {
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle'); const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
const [pendingSyncCount, setPendingSyncCount] = useState(0); const [pendingSyncCount, setPendingSyncCount] = useState(0);
const isOnline = useOnlineStatus(); const isOnline = useOnlineStatus();
const notesRef = useRef<Note[]>([]);
const saveControllersRef = useRef<Map<string, SaveController>>(new Map());
const savedSnapshotsRef = useRef<Map<string, Note>>(new Map());
const setSortedNotes = (updater: Note[] | ((previous: Note[]) => Note[])) => {
setNotes((previous) => {
const nextNotes = typeof updater === 'function'
? (updater as (previous: Note[]) => Note[])(previous)
: updater;
const sortedNotes = sortNotes(nextNotes);
notesRef.current = sortedNotes;
return sortedNotes;
});
};
const getNoteByDraftId = (draftId: string | null) =>
draftId ? notesRef.current.find(note => note.draftId === draftId) ?? null : null;
const persistNoteToCache = (note: Note) => {
void localDB.saveNote(toStoredNote(note)).catch((error) => {
console.error('Failed to persist note locally:', error);
});
};
const ensureSaveController = (draftId: string) => {
let controller = saveControllersRef.current.get(draftId);
if (!controller) {
controller = {
timerId: null,
revision: 0,
inFlight: null,
inFlightRevision: 0,
};
saveControllersRef.current.set(draftId, controller);
}
return controller;
};
const clearSaveTimer = (draftId: string) => {
const controller = saveControllersRef.current.get(draftId);
if (controller?.timerId) {
window.clearTimeout(controller.timerId);
controller.timerId = null;
}
};
const applyLoadedNotes = (loadedNotes: Note[]) => {
const normalizedNotes = sortNotes(loadedNotes);
const incomingDraftIds = new Set<string>();
normalizedNotes.forEach((note) => {
if (!note.draftId) {
return;
}
incomingDraftIds.add(note.draftId);
if (!note.pendingSave) {
savedSnapshotsRef.current.set(note.draftId, {
...note,
pendingSave: false,
isSaving: false,
saveError: null,
});
}
});
for (const draftId of Array.from(savedSnapshotsRef.current.keys())) {
if (!incomingDraftIds.has(draftId)) {
savedSnapshotsRef.current.delete(draftId);
}
}
notesRef.current = normalizedNotes;
setNotes(normalizedNotes);
setSelectedNoteDraftId((current) => {
if (current && normalizedNotes.some(note => note.draftId === current)) {
return current;
}
return getNoteDraftId(normalizedNotes[0]);
});
};
useEffect(() => { useEffect(() => {
const initApp = async () => { const initApp = async () => {
@@ -113,7 +272,7 @@ function App() {
// Reload notes from cache after background sync completes // Reload notes from cache after background sync completes
// Don't call loadNotes() as it triggers another sync - just reload from cache // Don't call loadNotes() as it triggers another sync - just reload from cache
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await localDB.getAllNotes();
setNotes(cachedNotes.sort((a, b) => b.modified - a.modified)); applyLoadedNotes(cachedNotes);
}); });
}, []); }, []);
@@ -125,13 +284,43 @@ function App() {
} }
}, [api, isLoggedIn]); }, [api, isLoggedIn]);
useEffect(() => {
if (!isLoggedIn || !isOnline) {
return;
}
notesRef.current
.filter(note => note.pendingSave && note.draftId)
.forEach((note) => {
const draftId = note.draftId as string;
const controller = ensureSaveController(draftId);
if (!controller.inFlight && !controller.timerId) {
controller.timerId = window.setTimeout(() => {
controller.timerId = null;
void flushNoteSave(draftId);
}, 0);
}
});
}, [isLoggedIn, isOnline]);
useEffect(() => {
const handleBeforeUnload = () => {
notesRef.current
.filter(note => note.pendingSave && note.draftId)
.forEach((note) => {
clearSaveTimer(note.draftId!);
void flushNoteSave(note.draftId!, { force: true });
});
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
const loadNotes = async () => { const loadNotes = async () => {
try { try {
const loadedNotes = await syncManager.loadNotes(); const loadedNotes = await syncManager.loadNotes();
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified)); applyLoadedNotes(loadedNotes);
if (!selectedNoteId && loadedNotes.length > 0) {
setSelectedNoteId(loadedNotes[0].id);
}
} catch (error) { } catch (error) {
console.error('Failed to load notes:', error); console.error('Failed to load notes:', error);
} }
@@ -139,6 +328,7 @@ function App() {
const syncNotes = async () => { const syncNotes = async () => {
try { try {
await flushAllPendingSaves();
await syncManager.syncWithServer(); await syncManager.syncWithServer();
await loadNotes(); await loadNotes();
} catch (error) { } catch (error) {
@@ -169,8 +359,11 @@ function App() {
categoryColorsSync.setAPI(null); categoryColorsSync.setAPI(null);
setUsername(''); setUsername('');
setNotes([]); setNotes([]);
setSelectedNoteId(null); notesRef.current = [];
setSelectedNoteDraftId(null);
setIsLoggedIn(false); setIsLoggedIn(false);
saveControllersRef.current.clear();
savedSnapshotsRef.current.clear();
}; };
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
@@ -199,25 +392,66 @@ function App() {
}; };
const handleToggleFavorite = async (note: Note, favorite: boolean) => { const handleToggleFavorite = async (note: Note, favorite: boolean) => {
const draftId = note.draftId;
if (!draftId) {
return;
}
const optimisticNote = {
...note,
favorite,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(currentNote =>
currentNote.draftId === draftId ? optimisticNote : currentNote
)
);
persistNoteToCache(optimisticNote);
try { try {
await syncManager.updateFavoriteStatus(note, favorite); await syncManager.updateFavoriteStatus(optimisticNote, favorite);
// Update local state const snapshot = savedSnapshotsRef.current.get(draftId);
setNotes(prevNotes => if (snapshot) {
prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n) savedSnapshotsRef.current.set(draftId, {
); ...snapshot,
favorite,
});
}
} catch (error) { } catch (error) {
console.error('Toggle favorite failed:', error); console.error('Toggle favorite failed:', error);
} }
}; };
const handleCreateNote = async () => { const handleCreateNote = async () => {
try { const draftId = createDraftId();
const note = await syncManager.createNote('New Note', '', selectedCategory); const note: Note = {
setNotes([note, ...notes]); id: `local:${draftId}`,
setSelectedNoteId(note.id); etag: '',
} catch (error) { readonly: false,
console.error('Create note failed:', error); content: '',
} title: 'Untitled',
category: selectedCategory,
favorite: false,
modified: Math.floor(Date.now() / 1000),
draftId,
localOnly: true,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: undefined,
};
savedSnapshotsRef.current.set(draftId, {
...note,
pendingSave: false,
isSaving: false,
saveError: null,
});
setSortedNotes(previousNotes => [note, ...previousNotes]);
persistNoteToCache(note);
setSelectedNoteDraftId(draftId);
}; };
const handleCreateCategory = (name: string) => { const handleCreateCategory = (name: string) => {
@@ -233,7 +467,19 @@ function App() {
for (const note of notesToMove) { for (const note of notesToMove) {
try { try {
const movedNote = await syncManager.moveNote(note, newName); const movedNote = await syncManager.moveNote(note, newName);
setNotes(prevNotes => prevNotes.map(n => n.id === note.id ? movedNote : n)); if (movedNote.draftId) {
savedSnapshotsRef.current.set(movedNote.draftId, {
...movedNote,
pendingSave: false,
isSaving: false,
saveError: null,
});
}
setSortedNotes(previousNotes =>
previousNotes.map(currentNote =>
currentNote.draftId === note.draftId ? movedNote : currentNote
)
);
} catch (error) { } catch (error) {
console.error(`Failed to move note ${note.id}:`, error); console.error(`Failed to move note ${note.id}:`, error);
} }
@@ -250,53 +496,280 @@ function App() {
} }
}; };
const handleUpdateNote = async (updatedNote: Note) => { const persistNoteToServer = async (note: Note) => {
try { if (note.localOnly) {
const originalNote = notes.find(n => n.id === updatedNote.id); const { title, body } = splitNoteContent(note.content);
const createdNote = await syncManager.createNote(title, body, note.category);
// If category changed, use moveNote instead of updateNote return {
if (originalNote && originalNote.category !== updatedNote.category) { ...createdNote,
const movedNote = await syncManager.moveNote(originalNote, updatedNote.category); content: note.content,
// If content/title also changed, update the moved note title,
if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) { favorite: note.favorite,
const finalNote = await syncManager.updateNote({ draftId: note.draftId,
...movedNote, localOnly: false,
title: updatedNote.title, };
content: updatedNote.content,
favorite: updatedNote.favorite,
});
setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n));
// Update selected note ID if it changed
if (selectedNoteId === originalNote.id && finalNote.id !== originalNote.id) {
setSelectedNoteId(finalNote.id);
}
} else {
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
// Update selected note ID if it changed
if (selectedNoteId === originalNote.id && movedNote.id !== originalNote.id) {
setSelectedNoteId(movedNote.id);
}
}
} else {
const updated = await syncManager.updateNote(updatedNote);
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
// Update selected note ID if it changed (e.g., filename changed due to first line edit)
if (selectedNoteId === updatedNote.id && updated.id !== updatedNote.id) {
setSelectedNoteId(updated.id);
}
}
} catch (error) {
console.error('Update note failed:', error);
} }
const remoteCategory = getRemoteCategory(note);
if (remoteCategory !== note.category) {
const movedNote = await syncManager.moveNote(note, note.category);
return syncManager.updateNote({
...movedNote,
draftId: note.draftId,
title: note.title,
content: note.content,
favorite: note.favorite,
localOnly: false,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: note.lastSavedAt,
});
}
return syncManager.updateNote(note);
};
const flushNoteSave = async (draftId: string, options: FlushSaveOptions = {}): Promise<void> => {
const controller = ensureSaveController(draftId);
clearSaveTimer(draftId);
const currentNote = getNoteByDraftId(draftId);
if (!currentNote?.pendingSave) {
return;
}
const canPersist = options.force ? canForceSaveLocalNote(currentNote) : canAutosaveLocalNote(currentNote);
if (!canPersist) {
return;
}
if (controller.inFlight) {
await controller.inFlight;
return;
}
controller.inFlightRevision = controller.revision;
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId
? {
...note,
isSaving: true,
saveError: null,
}
: note
)
);
const savePromise = (async () => {
try {
const noteToPersist = getNoteByDraftId(draftId);
if (!noteToPersist?.pendingSave) {
return;
}
const canPersistLatest = options.force ? canForceSaveLocalNote(noteToPersist) : canAutosaveLocalNote(noteToPersist);
if (!canPersistLatest) {
return;
}
const savedNote = {
...(await persistNoteToServer(noteToPersist)),
draftId,
localOnly: false,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: Date.now(),
};
if (noteToPersist.id !== savedNote.id) {
void localDB.deleteNote(noteToPersist.id).catch((error) => {
console.error('Failed to remove stale local note cache entry:', error);
});
}
savedSnapshotsRef.current.set(draftId, {
...savedNote,
pendingSave: false,
isSaving: false,
saveError: null,
});
const latestNote = getNoteByDraftId(draftId);
const hasNewerChanges = controller.revision > controller.inFlightRevision && latestNote;
if (latestNote && hasNewerChanges) {
const mergedPendingNote: Note = {
...savedNote,
content: latestNote.content,
title: latestNote.title,
category: latestNote.category,
favorite: latestNote.favorite,
modified: latestNote.modified,
localOnly: false,
pendingSave: true,
isSaving: false,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? mergedPendingNote : note
)
);
persistNoteToCache(mergedPendingNote);
scheduleNoteSave(draftId, 0);
return;
}
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? savedNote : note
)
);
persistNoteToCache(savedNote);
} catch (error) {
console.error('Update note failed:', error);
const failedNote = getNoteByDraftId(draftId);
if (!failedNote) {
return;
}
const erroredNote = {
...failedNote,
pendingSave: true,
isSaving: false,
saveError: error instanceof Error ? error.message : 'Failed to save note.',
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? erroredNote : note
)
);
persistNoteToCache(erroredNote);
} finally {
controller.inFlight = null;
}
})();
controller.inFlight = savePromise;
await savePromise;
};
const scheduleNoteSave = (draftId: string, delayMs = AUTOSAVE_DELAY_MS) => {
const controller = ensureSaveController(draftId);
clearSaveTimer(draftId);
controller.timerId = window.setTimeout(() => {
controller.timerId = null;
void flushNoteSave(draftId);
}, delayMs);
};
const flushAllPendingSaves = async () => {
const pendingDraftIds = notesRef.current
.filter(note => note.pendingSave && note.draftId)
.map(note => note.draftId as string);
await Promise.all(pendingDraftIds.map(draftId => flushNoteSave(draftId, { force: true })));
};
const handleDraftChange = (updatedNote: Note) => {
const draftId = updatedNote.draftId;
if (!draftId) {
return;
}
const localNote = {
...updatedNote,
modified: Math.floor(Date.now() / 1000),
pendingSave: true,
isSaving: false,
saveError: null,
};
const controller = ensureSaveController(draftId);
controller.revision += 1;
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? localNote : note
)
);
persistNoteToCache(localNote);
scheduleNoteSave(draftId);
};
const handleManualSave = async (draftId: string) => {
await flushNoteSave(draftId, { force: true });
};
const handleDiscardNote = (draftId: string) => {
const snapshot = savedSnapshotsRef.current.get(draftId);
if (!snapshot) {
return;
}
clearSaveTimer(draftId);
const controller = ensureSaveController(draftId);
controller.revision += 1;
const cleanSnapshot = {
...snapshot,
pendingSave: false,
isSaving: false,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? cleanSnapshot : note
)
);
persistNoteToCache(cleanSnapshot);
};
const handleSelectNote = async (draftId: string) => {
if (draftId === selectedNoteDraftId) {
return;
}
if (selectedNoteDraftId) {
await flushNoteSave(selectedNoteDraftId, { force: true });
}
setSelectedNoteDraftId(draftId);
}; };
const handleDeleteNote = async (note: Note) => { const handleDeleteNote = async (note: Note) => {
const draftId = note.draftId;
if (!draftId) {
return;
}
try { try {
await syncManager.deleteNote(note); clearSaveTimer(draftId);
const remainingNotes = notes.filter(n => n.id !== note.id); if (!note.localOnly) {
setNotes(remainingNotes); await syncManager.deleteNote(note);
if (selectedNoteId === note.id) { } else {
setSelectedNoteId(remainingNotes[0]?.id || null); await localDB.deleteNote(note.id);
}
saveControllersRef.current.delete(draftId);
savedSnapshotsRef.current.delete(draftId);
const remainingNotes = notesRef.current.filter(currentNote => currentNote.draftId !== draftId);
notesRef.current = sortNotes(remainingNotes);
setNotes(notesRef.current);
if (selectedNoteDraftId === draftId) {
setSelectedNoteDraftId(getNoteDraftId(notesRef.current[0]));
} }
} catch (error) { } catch (error) {
console.error('Delete note failed:', error); console.error('Delete note failed:', error);
@@ -315,9 +788,16 @@ function App() {
note.content.toLowerCase().includes(search); note.content.toLowerCase().includes(search);
} }
return true; return true;
}).sort((a, b) => {
// Sort favorites first, then by modified date (newest first)
if (a.favorite !== b.favorite) {
return a.favorite ? -1 : 1;
}
return b.modified - a.modified;
}); });
const selectedNote = notes.find(n => n.id === selectedNoteId) || null; const selectedNote = notes.find(n => n.draftId === selectedNoteDraftId) || null;
const hasUnsavedChanges = Boolean(selectedNote?.pendingSave);
if (!isLoggedIn) { if (!isLoggedIn) {
return <LoginView onLogin={handleLogin} />; return <LoginView onLogin={handleLogin} />;
@@ -350,8 +830,8 @@ function App() {
/> />
<NotesList <NotesList
notes={filteredNotes} notes={filteredNotes}
selectedNoteId={selectedNoteId} selectedNoteDraftId={selectedNoteDraftId}
onSelectNote={setSelectedNoteId} onSelectNote={handleSelectNote}
onCreateNote={handleCreateNote} onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote} onDeleteNote={handleDeleteNote}
onSync={syncNotes} onSync={syncNotes}
@@ -368,9 +848,10 @@ function App() {
)} )}
<NoteEditor <NoteEditor
note={selectedNote} note={selectedNote}
onUpdateNote={handleUpdateNote} onChangeNote={handleDraftChange}
onSaveNote={handleManualSave}
onDiscardNote={handleDiscardNote}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onUnsavedChanges={setHasUnsavedChanges}
categories={categories} categories={categories}
isFocusMode={isFocusMode} isFocusMode={isFocusMode}
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)} onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
@@ -384,4 +865,19 @@ function App() {
); );
} }
function App() {
const params = new URLSearchParams(window.location.search);
const printJobId = params.get(PRINT_EXPORT_QUERY_PARAM);
if (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 />;
}
export default App; export default App;

View File

@@ -1,5 +1,22 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { Note, APIConfig } from '../types'; import { Note, APIConfig } from '../types';
import { runtimeFetch } from '../services/runtimeFetch';
type HttpStatusError = Error & { status?: number };
const createHttpStatusError = (message: string, status: number): HttpStatusError => {
const error = new Error(message) as HttpStatusError;
error.status = status;
return error;
};
const getHttpStatus = (error: unknown): number | null => {
if (typeof error !== 'object' || error === null || !('status' in error)) {
return null;
}
const status = (error as { status?: unknown }).status;
return typeof status === 'number' ? status : null;
};
export class NextcloudAPI { export class NextcloudAPI {
private baseURL: string; private baseURL: string;
@@ -16,7 +33,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 +141,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 +191,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 +205,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 +227,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 +255,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,
@@ -277,11 +294,77 @@ export class NextcloudAPI {
return note.content; return note.content;
} }
private buildNoteWebDAVPath(category: string, filename: string): string {
const categoryPath = category ? `/${category}` : '';
return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
}
private async delay(ms: number): Promise<void> {
await new Promise((resolve) => window.setTimeout(resolve, ms));
}
private async fetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number }> {
const webdavPath = this.buildNoteWebDAVPath(category, filename);
const response = await runtimeFetch(`${this.serverURL}${webdavPath}`, {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
'Depth': '0',
'Content-Type': 'application/xml',
},
body: `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
</d:prop>
</d:propfind>`,
});
if (!response.ok) {
throw createHttpStatusError(`Failed to fetch note metadata: ${response.status}`, response.status);
}
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
const responseNode = xmlDoc.getElementsByTagNameNS('DAV:', 'response')[0];
const propstat = responseNode?.getElementsByTagNameNS('DAV:', 'propstat')[0];
const prop = propstat?.getElementsByTagNameNS('DAV:', 'prop')[0];
const etag = prop?.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : Math.floor(Date.now() / 1000);
return { etag, modified };
}
private async tryFetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number } | null> {
try {
return await this.fetchNoteMetadataWebDAV(category, filename);
} catch (error) {
const status = getHttpStatus(error);
if (status === 404) {
return null;
}
throw error;
}
}
private async refreshNoteWebDAVMetadata(note: Note): Promise<Note> {
const metadata = await this.fetchNoteMetadataWebDAV(note.category, note.filename!);
return {
...note,
etag: metadata.etag || note.etag,
modified: metadata.modified || note.modified,
};
}
async fetchNotesWebDAV(): Promise<Note[]> { async fetchNotesWebDAV(): Promise<Note[]> {
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 +444,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 +466,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 },
}); });
@@ -392,9 +475,9 @@ export class NextcloudAPI {
} }
} }
const noteContent = `${title}\n${content}`; const noteContent = content ? `${title}\n${content}` : title;
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -416,7 +499,7 @@ export class NextcloudAPI {
path: category ? `${category}/${filename}` : filename, path: category ? `${category}/${filename}` : filename,
etag, etag,
readonly: false, readonly: false,
content, content: noteContent,
title, title,
category, category,
favorite: false, favorite: false,
@@ -437,21 +520,42 @@ export class NextcloudAPI {
// Rename the file first, then update content // Rename the file first, then update content
const renamedNote = await this.renameNoteWebDAV(note, newFilename); const renamedNote = await this.renameNoteWebDAV(note, newFilename);
// Now update the content of the renamed file // Now update the content of the renamed file
return this.updateNoteContentWebDAV(renamedNote); return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote));
} else { } else {
// Just update content // Just update content
return this.updateNoteContentWebDAV(note); return this.updateNoteContentWithRetryWebDAV(note);
} }
} }
private async updateNoteContentWithRetryWebDAV(note: Note, maxRetries = 2): Promise<Note> {
let currentNote = note;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
return await this.updateNoteContentWebDAV(currentNote);
} catch (error) {
const status = getHttpStatus(error);
const canRetry = status === 412 || status === 423;
if (!canRetry || attempt === maxRetries) {
throw error;
}
await this.delay(150 * (attempt + 1));
currentNote = await this.refreshNoteWebDAVMetadata(currentNote);
}
}
return this.updateNoteContentWebDAV(currentNote);
}
private async updateNoteContentWebDAV(note: Note): Promise<Note> { private async updateNoteContentWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : ''; const webdavPath = this.buildNoteWebDAVPath(note.category, 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 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,
@@ -463,9 +567,12 @@ export class NextcloudAPI {
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
if (response.status === 412) { if (response.status === 412) {
throw new Error('Note was modified by another client. Please refresh.'); throw createHttpStatusError('Note was modified by another client. Please refresh.', response.status);
} }
throw new Error(`Failed to update note: ${response.status}`); if (response.status === 423) {
throw createHttpStatusError('Note is temporarily locked. Retrying...', response.status);
}
throw createHttpStatusError(`Failed to update note: ${response.status}`, response.status);
} }
const etag = response.headers.get('etag') || note.etag; const etag = response.headers.get('etag') || note.etag;
@@ -478,23 +585,39 @@ export class NextcloudAPI {
} }
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> { private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : ''; const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; const newPath = this.buildNoteWebDAVPath(note.category, 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,
'Destination': `${this.serverURL}${newPath}`, 'Destination': `${this.serverURL}${newPath}`,
'If-Match': note.etag,
}, },
}); });
if (!response.ok && response.status !== 201 && response.status !== 204) { if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error(`Failed to rename note: ${response.status}`); if (response.status === 404) {
const existingMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
if (existingMetadata) {
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
return {
...note,
id: newId,
filename: newFilename,
path: note.category ? `${note.category}/${newFilename}` : newFilename,
etag: existingMetadata.etag || note.etag,
modified: existingMetadata.modified || Math.floor(Date.now() / 1000),
};
}
}
throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status);
} }
// Also rename attachment folder if it exists // Also rename attachment folder if it exists
const categoryPath = note.category ? `/${note.category}` : '';
const oldNoteIdStr = String(note.id); const oldNoteIdStr = String(note.id);
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr; const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, ''); const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
@@ -509,7 +632,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,
@@ -520,6 +643,7 @@ export class NextcloudAPI {
// Attachment folder might not exist, that's ok // Attachment folder might not exist, that's ok
} }
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
const newId = note.category ? `${note.category}/${newFilename}` : newFilename; const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
return { return {
@@ -527,6 +651,8 @@ export class NextcloudAPI {
id: newId, id: newId,
filename: newFilename, filename: newFilename,
path: note.category ? `${note.category}/${newFilename}` : newFilename, path: note.category ? `${note.category}/${newFilename}` : newFilename,
etag: refreshedMetadata?.etag || note.etag,
modified: refreshedMetadata?.modified || Math.floor(Date.now() / 1000),
}; };
} }
@@ -535,13 +661,13 @@ 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 },
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204 && response.status !== 404) {
throw new Error(`Failed to delete note: ${response.status}`); throw createHttpStatusError(`Failed to delete note: ${response.status}`, response.status);
} }
} }
@@ -560,7 +686,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 +696,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 +723,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,

View File

@@ -1,17 +1,27 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { marked } from 'marked'; import { marked } from 'marked';
import jsPDF from 'jspdf';
import { message } from '@tauri-apps/plugin-dialog';
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 {
getNoteTitleFromContent,
loadPrintFontFaceCss,
PrintExportPayload,
sanitizeFileName,
} from '../printExport';
interface NoteEditorProps { interface NoteEditorProps {
note: Note | null; note: Note | null;
onUpdateNote: (note: Note) => void; onChangeNote: (note: Note) => void;
onSaveNote: (draftId: string) => void | Promise<void>;
onDiscardNote: (draftId: string) => void;
onToggleFavorite?: (note: Note, favorite: boolean) => void; onToggleFavorite?: (note: Note, favorite: boolean) => void;
onUnsavedChanges?: (hasChanges: boolean) => void;
categories: string[]; categories: string[];
isFocusMode?: boolean; isFocusMode?: boolean;
onToggleFocusMode?: () => void; onToggleFocusMode?: () => void;
@@ -24,26 +34,30 @@ interface NoteEditorProps {
const imageCache = new Map<string, string>(); const imageCache = new Map<string, string>();
// Configure marked to support task lists
marked.use({
gfm: true,
breaks: true,
});
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onToggleFavorite, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
const [localContent, setLocalContent] = useState(''); const [localContent, setLocalContent] = useState('');
const [localCategory, setLocalCategory] = useState(''); const [localCategory, setLocalCategory] = useState('');
const [localFavorite, setLocalFavorite] = useState(false); const [localFavorite, setLocalFavorite] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false);
const [processedContent, setProcessedContent] = useState(''); const [processedContent, setProcessedContent] = useState('');
const [isLoadingImages, setIsLoadingImages] = useState(false); const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const previousNoteIdRef = useRef<number | string | null>(null); const previousDraftIdRef = useRef<string | null>(null);
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();
useEffect(() => { const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
onUnsavedChanges?.(hasUnsavedChanges); const hasUnsavedChanges = Boolean(note?.pendingSave);
}, [hasUnsavedChanges, onUnsavedChanges]); const isSaving = Boolean(note?.isSaving);
const saveError = note?.saveError;
const hasSavedState = Boolean(note?.lastSavedAt) && !hasUnsavedChanges && !isSaving && !saveError;
// Handle Escape key to exit focus mode // Handle Escape key to exit focus mode
useEffect(() => { useEffect(() => {
@@ -87,8 +101,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
// Guard: Only process if localContent has been updated for the current note // Guard: Only process if localContent has been updated for the current note
// This prevents processing stale content from the previous note // This prevents processing stale content from the previous note
if (previousNoteIdRef.current !== note.id) { if (previousDraftIdRef.current !== note.draftId) {
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`); console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousDraftIdRef: ${previousDraftIdRef.current})`);
return; return;
} }
@@ -134,93 +148,56 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
}; };
processImages(); processImages();
}, [isPreviewMode, localContent, note?.id, api]); }, [isPreviewMode, localContent, note?.draftId, note?.id, api]);
useEffect(() => { useEffect(() => {
const loadNewNote = () => { if (!note) {
if (note) { setLocalContent('');
setLocalContent(note.content); setLocalCategory('');
setLocalCategory(note.category || ''); setLocalFavorite(false);
setLocalFavorite(note.favorite); previousDraftIdRef.current = null;
setHasUnsavedChanges(false); return;
}
previousNoteIdRef.current = note.id; if (previousDraftIdRef.current !== note.draftId) {
previousNoteContentRef.current = note.content;
}
};
// Switching to a different note
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
setProcessedContent(''); setProcessedContent('');
if (hasUnsavedChanges) { previousDraftIdRef.current = note.draftId ?? null;
handleSave();
}
loadNewNote();
} }
// Same note but content changed from server (and no unsaved local changes)
else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) { if (note.content !== localContent) {
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`); setLocalContent(note.content);
loadNewNote();
} }
// Initial load if ((note.category || '') !== localCategory) {
else if (!note || previousNoteIdRef.current === null) { setLocalCategory(note.category || '');
loadNewNote();
} }
// Favorite status changed (e.g., from sync) if (note.favorite !== localFavorite) {
else if (note && note.favorite !== localFavorite) {
setLocalFavorite(note.favorite); setLocalFavorite(note.favorite);
} }
}, [note?.id, note?.content, note?.modified, note?.favorite]); }, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]);
const handleSave = () => { const emitNoteChange = (content: string, category: string, favorite: boolean) => {
if (!note || !hasUnsavedChanges) return; if (!note) {
return;
}
console.log('Saving note content length:', localContent.length); onChangeNote({
console.log('Last 50 chars:', localContent.slice(-50));
setIsSaving(true);
setHasUnsavedChanges(false);
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({
...note, ...note,
title, title: getNoteTitleFromContent(content),
content: localContent, content,
category: localCategory, category,
favorite: localFavorite, favorite,
}); });
setTimeout(() => setIsSaving(false), 500);
}; };
const handleContentChange = (value: string) => { const handleContentChange = (value: string) => {
setLocalContent(value); setLocalContent(value);
setHasUnsavedChanges(true); emitNoteChange(value, localCategory, localFavorite);
}; };
const handleDiscard = () => { const handleDiscard = () => {
if (!note) return; if (!note?.draftId) return;
setLocalContent(note.content); onDiscardNote(note.draftId);
setLocalCategory(note.category || '');
setLocalFavorite(note.favorite);
setHasUnsavedChanges(false);
};
const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
const response = await fetch(fontPath);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
// Remove data URL prefix to get just the base64 string
resolve(base64.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}; };
const handleExportPDF = async () => { const handleExportPDF = async () => {
@@ -229,66 +206,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setIsExportingPDF(true); setIsExportingPDF(true);
try { try {
// Create PDF let contentForPrint = localContent;
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Load and add custom fonts based on preview font selection
const fontMap: { [key: string]: { regular: string; italic: string; name: string } } = {
'Merriweather': {
regular: '/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf',
italic: '/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
name: 'Merriweather'
},
'Crimson Pro': {
regular: '/fonts/CrimsonPro-VariableFont_wght.ttf',
italic: '/fonts/CrimsonPro-Italic-VariableFont_wght.ttf',
name: 'CrimsonPro'
},
'Roboto Serif': {
regular: '/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
italic: '/fonts/RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
name: 'RobotoSerif'
},
'Average': {
regular: '/fonts/Average-Regular.ttf',
italic: '/fonts/Average-Regular.ttf', // No italic variant
name: 'Average'
}
};
const selectedFont = fontMap[previewFont];
if (selectedFont) {
try {
const regularBase64 = await loadFontAsBase64(selectedFont.regular);
pdf.addFileToVFS(`${selectedFont.name}-normal.ttf`, regularBase64);
pdf.addFont(`${selectedFont.name}-normal.ttf`, selectedFont.name, 'normal');
const italicBase64 = await loadFontAsBase64(selectedFont.italic);
pdf.addFileToVFS(`${selectedFont.name}-italic.ttf`, italicBase64);
pdf.addFont(`${selectedFont.name}-italic.ttf`, selectedFont.name, 'italic');
// Set the custom font as default
pdf.setFont(selectedFont.name, 'normal');
} catch (fontError) {
console.error('Failed to load custom font, using default:', fontError);
}
}
// Add Source Code Pro for code blocks
try {
const codeFont = await loadFontAsBase64('/fonts/SourceCodePro-VariableFont_wght.ttf');
pdf.addFileToVFS('SourceCodePro-normal.ttf', codeFont);
pdf.addFont('SourceCodePro-normal.ttf', 'SourceCodePro', 'normal');
} catch (codeFontError) {
console.error('Failed to load code font:', codeFontError);
}
// Process images to embed them as data URLs
let contentForPDF = localContent;
if (api) { if (api) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...localContent.matchAll(imageRegex)]; const matches = [...localContent.matchAll(imageRegex)];
@@ -305,100 +223,46 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const cacheKey = `${note.id}:${imagePath}`; const cacheKey = `${note.id}:${imagePath}`;
if (imageCache.has(cacheKey)) { if (imageCache.has(cacheKey)) {
const dataUrl = imageCache.get(cacheKey)!; const dataUrl = imageCache.get(cacheKey)!;
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
continue; continue;
} }
try { try {
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category); const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
imageCache.set(cacheKey, dataUrl); imageCache.set(cacheKey, dataUrl);
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
} catch (error) { } catch (error) {
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error); console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
} }
} }
} }
const container = document.createElement('div'); const title = getNoteTitleFromContent(localContent);
container.style.fontFamily = `"${previewFont}", Georgia, serif`; const fileName = `${sanitizeFileName(title)}.pdf`;
container.style.fontSize = `${previewFontSize}px`; const noteHtml = marked.parse(contentForPrint || '', { async: false }) as string;
container.style.lineHeight = '1.6'; const payload: PrintExportPayload = {
container.style.color = '#000000'; fileName,
title,
html: noteHtml,
previewFont,
previewFontSize,
previewFontFaceCss: await loadPrintFontFaceCss(previewFont),
};
const titleElement = document.createElement('h1'); await exportPdfDocument({
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); ...payload,
titleElement.textContent = firstLine || 'Untitled';
titleElement.style.marginTop = '0';
titleElement.style.marginBottom = '20px';
titleElement.style.fontSize = '24px';
titleElement.style.fontWeight = 'bold';
titleElement.style.color = '#000000';
titleElement.style.textAlign = 'center';
titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.appendChild(titleElement);
const contentElement = document.createElement('div');
const html = marked.parse(contentForPDF || '', { async: false }) as string;
contentElement.innerHTML = html;
contentElement.style.fontSize = `${previewFontSize}px`;
contentElement.style.lineHeight = '1.6';
contentElement.style.color = '#000000';
container.appendChild(contentElement);
const style = document.createElement('style');
style.textContent = `
body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; }
code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
code { padding: 0; }
h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; }
h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; }
h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; }
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; }
em { font-style: italic; vertical-align: baseline; }
strong { font-weight: bold; vertical-align: baseline; line-height: inherit; }
img { max-width: 100%; height: auto; display: block; margin: 1em 0; }
`;
container.appendChild(style);
// Use jsPDF's html() method with custom font set
await pdf.html(container, {
callback: async (doc) => {
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const fileName = `${firstLine || 'note'}.pdf`;
doc.save(fileName);
setTimeout(async () => {
try {
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
title: 'Export Complete',
kind: 'info',
});
} catch (err) {
console.log('Dialog shown successfully or not available');
}
setIsExportingPDF(false);
}, 500);
},
margin: [20, 20, 20, 20],
autoPaging: 'text',
width: 170,
windowWidth: 650,
}); });
} catch (error) { } catch (error) {
console.error('PDF export failed:', error); console.error('PDF export failed:', error);
try { try {
await message('Failed to export PDF. Please try again.', { await showDesktopMessage('Failed to export the PDF. Please try again.', {
title: 'Export Failed', title: 'Export Failed',
kind: 'error', kind: 'error',
}); });
} catch (err) { } catch (err) {
console.error('Could not show error dialog'); console.error('Could not show error dialog');
} }
} finally {
setIsExportingPDF(false); setIsExportingPDF(false);
} }
}; };
@@ -408,26 +272,13 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setLocalFavorite(newFavorite); setLocalFavorite(newFavorite);
if (note && onToggleFavorite) { if (note && onToggleFavorite) {
// Use dedicated favorite toggle callback if provided
onToggleFavorite(note, newFavorite); onToggleFavorite(note, newFavorite);
} else if (note) {
// Fallback to full update if no callback provided
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({
...note,
title,
content: localContent,
category: localCategory,
favorite: newFavorite,
});
} }
}; };
const handleCategoryChange = (category: string) => { const handleCategoryChange = (category: string) => {
setLocalCategory(category); setLocalCategory(category);
setHasUnsavedChanges(true); emitNoteChange(localContent, category, localFavorite);
}; };
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -450,7 +301,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const cursorPos = textarea.selectionStart; const cursorPos = textarea.selectionStart;
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
setLocalContent(newContent); setLocalContent(newContent);
setHasUnsavedChanges(true); emitNoteChange(newContent, localCategory, localFavorite);
// Move cursor after inserted text // Move cursor after inserted text
setTimeout(() => { setTimeout(() => {
@@ -460,17 +311,18 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
}, 0); }, 0);
} else { } else {
// Append to end // Append to end
setLocalContent(localContent + '\n' + markdownLink); const newContent = `${localContent}\n${markdownLink}`;
setHasUnsavedChanges(true); setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
} }
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',
}); });
@@ -491,7 +343,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const markdownLink = `[${text}](${url})`; const markdownLink = `[${text}](${url})`;
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
setLocalContent(newContent); setLocalContent(newContent);
setHasUnsavedChanges(true); emitNoteChange(newContent, localCategory, localFavorite);
setTimeout(() => { setTimeout(() => {
textarea.focus(); textarea.focus();
@@ -631,7 +483,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end); const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
setLocalContent(newContent); setLocalContent(newContent);
setHasUnsavedChanges(true); emitNoteChange(newContent, localCategory, localFavorite);
setTimeout(() => { setTimeout(() => {
textarea.focus(); textarea.focus();
@@ -717,13 +569,17 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Status */} {/* Status */}
{(hasUnsavedChanges || isSaving) && ( {(hasUnsavedChanges || isSaving || saveError || hasSavedState) && (
<span className={`text-xs px-2 py-1 rounded-full ${ <span className={`text-xs px-2 py-1 rounded-full ${
isSaving saveError
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400' ? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400' : isSaving
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
: hasUnsavedChanges
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
}`}> }`}>
{isSaving ? 'Saving...' : 'Unsaved'} {saveError ? 'Save failed' : isSaving ? 'Saving...' : hasUnsavedChanges ? 'Unsaved' : 'Saved'}
</span> </span>
)} )}
@@ -745,8 +601,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</button> </button>
<button <button
onClick={handleSave} onClick={() => {
disabled={!hasUnsavedChanges || isSaving} if (note?.draftId) {
void onSaveNote(note.draftId);
}
}}
disabled={!hasUnsavedChanges || isSaving || !note?.draftId}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
hasUnsavedChanges && !isSaving hasUnsavedChanges && !isSaving
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30' ? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
@@ -782,16 +642,26 @@ 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 ? "Generating PDF..." : "Export as PDF"} 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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <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 18H5a2 2 0 01-2-2v-5a2 2 0 012-2h14a2 2 0 012 2v5a2 2 0 01-2 2h-1" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 14h8v7H8z" />
<circle cx="17" cy="11.5" r="0.75" fill="currentColor" stroke="none" />
</svg> </svg>
)} )}
</button> </button>

View File

@@ -5,8 +5,8 @@ import { categoryColorsSync } from '../services/categoryColorsSync';
interface NotesListProps { interface NotesListProps {
notes: Note[]; notes: Note[];
selectedNoteId: number | string | null; selectedNoteDraftId: string | null;
onSelectNote: (id: number | string) => void; onSelectNote: (draftId: string) => void | Promise<void>;
onCreateNote: () => void; onCreateNote: () => void;
onDeleteNote: (note: Note) => void; onDeleteNote: (note: Note) => void;
onSync: () => void; onSync: () => void;
@@ -22,7 +22,7 @@ interface NotesListProps {
export function NotesList({ export function NotesList({
notes, notes,
selectedNoteId, selectedNoteDraftId,
onSelectNote, onSelectNote,
onCreateNote, onCreateNote,
onDeleteNote, onDeleteNote,
@@ -93,11 +93,6 @@ export function NotesList({
const handleDeleteClick = (note: Note, e: React.MouseEvent) => { const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// Prevent deletion if there are unsaved changes on a different note
if (hasUnsavedChanges && note.id !== selectedNoteId) {
return;
}
if (deleteClickedId === note.id) { if (deleteClickedId === note.id) {
// Second click - actually delete // Second click - actually delete
onDeleteNote(note); onDeleteNote(note);
@@ -249,22 +244,18 @@ export function NotesList({
) : ( ) : (
notes.map((note) => ( notes.map((note) => (
<div <div
key={note.id} key={note.draftId ?? note.id}
onClick={() => { onClick={() => {
// Prevent switching if current note has unsaved changes if (note.draftId) {
if (hasUnsavedChanges && note.id !== selectedNoteId) { void onSelectNote(note.draftId);
return;
} }
onSelectNote(note.id);
}} }}
className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${ className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${
note.id === selectedNoteId note.draftId === selectedNoteDraftId
? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500' ? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500'
: hasUnsavedChanges : 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
}`} }`}
title={hasUnsavedChanges && note.id !== selectedNoteId ? 'Save current note before switching' : ''} title={hasUnsavedChanges && note.draftId !== selectedNoteDraftId ? 'Saving current note before switching' : ''}
> >
<div className="flex items-start justify-between mb-1"> <div className="flex items-start justify-between mb-1">
<div className="flex items-center flex-1 min-w-0"> <div className="flex items-center flex-1 min-w-0">

View File

@@ -0,0 +1,366 @@
import { useEffect, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window';
import {
PrintExportPayload,
} from '../printExport';
interface PrintViewProps {
jobId: string;
}
const waitForImages = async () => {
const images = Array.from(document.images).filter((image) => !image.complete);
await Promise.all(
images.map(
(image) =>
new Promise<void>((resolve) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => resolve(), { once: true });
})
)
);
};
export function PrintView({ jobId }: PrintViewProps) {
const [payload, setPayload] = useState<PrintExportPayload | null>(null);
const [error, setError] = useState('');
useEffect(() => {
const currentWindow = getCurrentWindow();
let timeoutId = 0;
let cleanup = () => {};
void (async () => {
cleanup = await listen<PrintExportPayload>(
'print-export-payload',
(event) => {
window.clearTimeout(timeoutId);
setPayload(event.payload);
},
{
target: { kind: 'WebviewWindow', label: currentWindow.label },
}
);
await emit('print-export-ready', { jobId });
timeoutId = window.setTimeout(() => {
setError('Print data was not received. Please close this window and try exporting again.');
}, 5000);
})();
return () => {
window.clearTimeout(timeoutId);
cleanup();
};
}, [jobId]);
useEffect(() => {
if (!payload) return;
const currentWindow = getCurrentWindow();
let cancelled = false;
let printFlowStarted = false;
let lostFocusDuringPrint = false;
let closeTimerId = 0;
let destroyIntervalId = 0;
let removeFocusListener = () => {};
document.title = payload.fileName;
const closePrintWindow = () => {
if (cancelled) return;
if (destroyIntervalId) return;
window.clearTimeout(closeTimerId);
closeTimerId = window.setTimeout(() => {
destroyIntervalId = window.setInterval(() => {
void currentWindow.destroy().catch(() => undefined);
}, 250);
void currentWindow.destroy().catch(() => undefined);
}, 150);
};
const handleAfterPrint = () => {
closePrintWindow();
};
window.addEventListener('afterprint', handleAfterPrint);
void (async () => {
try {
removeFocusListener = await currentWindow.onFocusChanged(({ payload: focused }) => {
if (!printFlowStarted) return;
if (!focused) {
lostFocusDuringPrint = true;
return;
}
if (lostFocusDuringPrint) {
closePrintWindow();
}
});
if ('fonts' in document) {
await document.fonts.ready;
}
await waitForImages();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
if (cancelled) return;
await currentWindow.show().catch(() => undefined);
await currentWindow.setFocus().catch(() => undefined);
window.setTimeout(async () => {
if (cancelled) return;
try {
printFlowStarted = true;
await invoke('plugin:webview|print', {
label: currentWindow.label,
});
await currentWindow
.setSize(new LogicalSize(520, 260))
.catch(() => undefined);
closePrintWindow();
} catch (err) {
console.error('Native webview print failed, falling back to window.print():', err);
printFlowStarted = true;
window.print();
}
}, 120);
} catch (err) {
console.error('Failed to initialize print view:', err);
setError('The print view could not be prepared. Please close this window and try again.');
}
})();
return () => {
cancelled = true;
window.clearTimeout(closeTimerId);
window.clearInterval(destroyIntervalId);
removeFocusListener();
window.removeEventListener('afterprint', handleAfterPrint);
};
}, [jobId, payload]);
if (error) {
return (
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
<h1 className="mb-3 text-2xl font-semibold">Print Export Failed</h1>
<p>{error}</p>
</div>
</div>
);
}
if (!payload) {
return (
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
Preparing print view...
</div>
</div>
);
}
return (
<>
<style>{`
:root {
color-scheme: light;
}
body {
margin: 0;
background: #e5e7eb;
color: #0f172a;
}
@page {
size: A4;
margin: 18mm 16mm 18mm 16mm;
}
@media print {
body {
background: #ffffff;
}
.print-shell {
display: block !important;
}
.print-status {
display: none !important;
}
}
`}</style>
<div className="print-status min-h-screen bg-slate-100 px-6 py-6 text-slate-900">
<div className="mx-auto flex max-w-md items-center gap-4 rounded-2xl bg-white px-5 py-4 shadow-lg">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-200 border-t-slate-600" />
<div>
<div className="text-sm font-semibold">Opening system print dialog...</div>
<div className="text-sm text-slate-500">{payload.fileName}</div>
</div>
</div>
</div>
<div className="print-shell absolute left-[-200vw] top-0 min-h-screen w-[820px] bg-gray-200 px-6 py-8 print:static print:min-h-0 print:w-auto print:bg-white print:px-0 print:py-0">
<article
className="mx-auto min-h-[calc(100vh-4rem)] max-w-[820px] rounded-[20px] bg-white px-14 py-12 text-slate-900 shadow-xl print:min-h-0 print:max-w-none print:rounded-none print:px-0 print:py-0 print:shadow-none"
style={{
fontFamily: `"${payload.previewFont}", Georgia, serif`,
fontSize: `${payload.previewFontSize}px`,
lineHeight: 1.7,
}}
>
<style>{`
.print-note {
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 code {
font-family: "Source Code Pro", "Courier New", monospace;
font-size: 0.92em;
}
.print-note :not(pre) > code {
padding: 0.08em 0.28em;
border-radius: 4px;
border: 1px solid #dbe4f0;
background: #f8fafc;
color: #1e293b;
}
.print-note pre {
margin: 1em 0 1.15em;
padding: 0.9em 1em;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #0f172a;
white-space: pre-wrap;
overflow-wrap: anywhere;
page-break-inside: avoid;
}
.print-note pre code {
font-size: 0.92em;
line-height: 1.55;
}
.print-note a {
color: #1d4ed8;
text-decoration: underline;
}
.print-note img {
display: block;
max-width: 100%;
height: auto;
margin: 1.25em auto;
border-radius: 10px;
page-break-inside: avoid;
}
.print-note hr {
border: 0;
border-top: 1px solid #cbd5e1;
margin: 1.6em 0;
}
`}</style>
<div
className="print-note"
dangerouslySetInnerHTML={{ __html: payload.html }}
/>
</article>
</div>
</>
);
}

View File

@@ -213,3 +213,29 @@ code {
.dark .ProseMirror hr { .dark .ProseMirror hr {
border-top-color: #374151; border-top-color: #374151;
} }
/* Task list styling for preview mode */
.prose ul li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.5em;
display: flex;
align-items: flex-start;
}
.prose ul:has(li > input[type="checkbox"]) {
list-style: none;
padding-left: 1.5em;
}
.prose input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
margin-top: 0.32rem;
cursor: default;
flex-shrink: 0;
}
.prose input[type="checkbox"]:checked {
accent-color: #16a34a;
}

369
src/printExport.ts Normal file
View File

@@ -0,0 +1,369 @@
export interface PrintExportPayload {
fileName: string;
title: string;
html: string;
previewFont: string;
previewFontSize: number;
previewFontFaceCss?: string;
}
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
const PRINT_DOCUMENT_CSP = [
"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) => {
const firstLine = content
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return (firstLine || 'Untitled').replace(/^#+\s*/, '').trim();
};
export const sanitizeFileName = (name: string) =>
name
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, ' ')
.trim() || 'note';
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escapeFontFamily = (value: string) =>
value.replace(/["\\]/g, '\\$&');
interface PrintFontAsset {
fileName: string;
fontStyle: 'normal' | 'italic';
fontWeight: string;
}
const PRINT_FONT_ASSETS: Record<string, PrintFontAsset[]> = {
Merriweather: [
{
fileName: 'Merriweather-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '300 900',
},
{
fileName: 'Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '300 900',
},
],
'Crimson Pro': [
{
fileName: 'CrimsonPro-VariableFont_wght.ttf',
fontStyle: 'normal',
fontWeight: '200 900',
},
{
fileName: 'CrimsonPro-Italic-VariableFont_wght.ttf',
fontStyle: 'italic',
fontWeight: '200 900',
},
],
'Roboto Serif': [
{
fileName: 'RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '100 900',
},
{
fileName: 'RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '100 900',
},
],
Average: [
{
fileName: 'Average-Regular.ttf',
fontStyle: 'normal',
fontWeight: '400',
},
],
};
const fontDataUrlCache = new Map<string, Promise<string>>();
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error('Failed to read font file.'));
reader.readAsDataURL(blob);
});
const getBundledFontUrl = (fileName: string) =>
new URL(`./fonts/${fileName}`, window.location.href).toString();
const loadBundledFontDataUrl = async (fileName: string) => {
const cached = fontDataUrlCache.get(fileName);
if (cached) {
return cached;
}
const pending = (async () => {
const response = await fetch(getBundledFontUrl(fileName));
if (!response.ok) {
throw new Error(`Failed to load bundled font ${fileName}: ${response.status}`);
}
return blobToDataUrl(await response.blob());
})();
fontDataUrlCache.set(fileName, pending);
return pending;
};
export const loadPrintFontFaceCss = async (fontFamily: string) => {
const fontAssets = PRINT_FONT_ASSETS[fontFamily];
if (!fontAssets) {
return '';
}
const rules = await Promise.all(
fontAssets.map(async ({ fileName, fontStyle, fontWeight }) => {
try {
const dataUrl = await loadBundledFontDataUrl(fileName);
return `@font-face {
font-family: "${escapeFontFamily(fontFamily)}";
font-style: ${fontStyle};
font-weight: ${fontWeight};
font-display: swap;
src: url("${dataUrl}") format("truetype");
}`;
} catch (error) {
console.error(`Failed to embed preview font "${fontFamily}" from ${fileName}:`, error);
return '';
}
})
);
return rules.filter(Boolean).join('\n');
};
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>
${payload.previewFontFaceCss ?? ''}
: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
View 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();
}
}
}

View 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);
};

View File

@@ -4,6 +4,41 @@ import { localDB } from '../db/localDB';
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
const createDraftId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
};
const withLocalNoteFields = (note: Note, existing?: Note): Note => ({
...note,
draftId: note.draftId ?? existing?.draftId ?? createDraftId(),
localOnly: note.localOnly ?? existing?.localOnly ?? false,
pendingSave: note.pendingSave ?? existing?.pendingSave ?? false,
isSaving: false,
saveError: note.saveError ?? existing?.saveError ?? null,
lastSavedAt: note.lastSavedAt ?? existing?.lastSavedAt,
});
const toStoredNote = (note: Note): Note => ({
...note,
isSaving: false,
});
const getCachedNotes = async (): Promise<Note[]> => {
const rawNotes = await localDB.getAllNotes();
const normalizedNotes = rawNotes.map(note => withLocalNoteFields(note));
const needsNormalization = rawNotes.some((note) => !note.draftId || note.isSaving);
if (needsNormalization) {
await localDB.saveNotes(normalizedNotes.map(toStoredNote));
}
return normalizedNotes;
};
export class SyncManager { export class SyncManager {
private api: NextcloudAPI | null = null; private api: NextcloudAPI | null = null;
private isOnline: boolean = navigator.onLine; private isOnline: boolean = navigator.onLine;
@@ -46,7 +81,7 @@ export class SyncManager {
// Load notes: cache-first, then sync in background // Load notes: cache-first, then sync in background
async loadNotes(): Promise<Note[]> { async loadNotes(): Promise<Note[]> {
// Try to load from cache first (instant) // Try to load from cache first (instant)
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await getCachedNotes();
// If we have cached notes and we're offline, return them // If we have cached notes and we're offline, return them
if (!this.isOnline) { if (!this.isOnline) {
@@ -68,7 +103,9 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const notes = await this.fetchAndCacheNotes(); await this.fetchAndCacheNotes();
await this.syncFavoriteStatus();
const notes = await getCachedNotes();
this.notifyStatus('idle', 0); this.notifyStatus('idle', 0);
return notes; return notes;
} catch (error) { } catch (error) {
@@ -87,7 +124,7 @@ export class SyncManager {
// Get metadata for all notes (fast - no content) // Get metadata for all notes (fast - no content)
const serverNotes = await this.api.fetchNotesWebDAV(); const serverNotes = await this.api.fetchNotesWebDAV();
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await getCachedNotes();
// Build maps for comparison // Build maps for comparison
const serverMap = new Map(serverNotes.map(n => [n.id, n])); const serverMap = new Map(serverNotes.map(n => [n.id, n]));
@@ -97,16 +134,23 @@ export class SyncManager {
const notesToFetch: Note[] = []; const notesToFetch: Note[] = [];
for (const serverNote of serverNotes) { for (const serverNote of serverNotes) {
const cached = cachedMap.get(serverNote.id); const cached = cachedMap.get(serverNote.id);
if (cached?.pendingSave) {
continue;
}
if (!cached || cached.etag !== serverNote.etag) { if (!cached || cached.etag !== serverNote.etag) {
notesToFetch.push(serverNote); notesToFetch.push(withLocalNoteFields(serverNote, cached));
} }
} }
// Fetch content for changed notes // Fetch content for changed notes
for (const note of notesToFetch) { for (const note of notesToFetch) {
try { try {
const fullNote = await this.api.fetchNoteContentWebDAV(note); const fullNote = withLocalNoteFields(
await localDB.saveNote(fullNote); await this.api.fetchNoteContentWebDAV(note),
cachedMap.get(note.id)
);
await localDB.saveNote(toStoredNote(fullNote));
} catch (error) { } catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error); console.error(`Failed to fetch note ${note.id}:`, error);
} }
@@ -114,9 +158,13 @@ export class SyncManager {
// Remove deleted notes from cache (but protect recently modified notes) // Remove deleted notes from cache (but protect recently modified notes)
for (const cachedNote of cachedNotes) { for (const cachedNote of cachedNotes) {
if (cachedNote.localOnly) {
continue;
}
if (!serverMap.has(cachedNote.id)) { if (!serverMap.has(cachedNote.id)) {
// Don't delete notes that were recently created/updated (race condition protection) // Don't delete notes that were recently created/updated (race condition protection)
if (!this.recentlyModifiedNotes.has(cachedNote.id)) { if (!cachedNote.pendingSave && !this.recentlyModifiedNotes.has(cachedNote.id)) {
await localDB.deleteNote(cachedNote.id); await localDB.deleteNote(cachedNote.id);
} }
} }
@@ -146,7 +194,7 @@ export class SyncManager {
try { try {
console.log('Syncing favorite status from API...'); console.log('Syncing favorite status from API...');
const apiMetadata = await this.api.fetchNotesMetadata(); const apiMetadata = await this.api.fetchNotesMetadata();
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await getCachedNotes();
// Map API notes by modified timestamp + category for reliable matching // Map API notes by modified timestamp + category for reliable matching
// (titles can differ between API and WebDAV) // (titles can differ between API and WebDAV)
@@ -163,6 +211,10 @@ export class SyncManager {
// Update favorite status in cache for matching notes // Update favorite status in cache for matching notes
for (const cachedNote of cachedNotes) { for (const cachedNote of cachedNotes) {
if (cachedNote.localOnly) {
continue;
}
// Try timestamp match first (most reliable) // Try timestamp match first (most reliable)
const timestampKey = `${cachedNote.modified}:${cachedNote.category}`; const timestampKey = `${cachedNote.modified}:${cachedNote.category}`;
let apiData = apiByTimestamp.get(timestampKey); let apiData = apiByTimestamp.get(timestampKey);
@@ -176,7 +228,7 @@ export class SyncManager {
if (apiData && cachedNote.favorite !== apiData.favorite) { if (apiData && cachedNote.favorite !== apiData.favorite) {
console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`); console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`);
cachedNote.favorite = apiData.favorite; cachedNote.favorite = apiData.favorite;
await localDB.saveNote(cachedNote); await localDB.saveNote(toStoredNote(cachedNote));
} }
} }
@@ -191,14 +243,19 @@ export class SyncManager {
private async fetchAndCacheNotes(): Promise<Note[]> { private async fetchAndCacheNotes(): Promise<Note[]> {
if (!this.api) throw new Error('API not initialized'); if (!this.api) throw new Error('API not initialized');
const cachedNotes = await getCachedNotes();
const cachedMap = new Map(cachedNotes.map(note => [note.id, note]));
const serverNotes = await this.api.fetchNotesWebDAV(); const serverNotes = await this.api.fetchNotesWebDAV();
const notesWithContent: Note[] = []; const notesWithContent: Note[] = [];
for (const note of serverNotes) { for (const note of serverNotes) {
try { try {
const fullNote = await this.api.fetchNoteContentWebDAV(note); const fullNote = withLocalNoteFields(
await this.api.fetchNoteContentWebDAV(note),
cachedMap.get(note.id)
);
notesWithContent.push(fullNote); notesWithContent.push(fullNote);
await localDB.saveNote(fullNote); await localDB.saveNote(toStoredNote(fullNote));
} catch (error) { } catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error); console.error(`Failed to fetch note ${note.id}:`, error);
} }
@@ -218,8 +275,8 @@ export class SyncManager {
} }
try { try {
const fullNote = await this.api.fetchNoteContentWebDAV(note); const fullNote = withLocalNoteFields(await this.api.fetchNoteContentWebDAV(note), note);
await localDB.saveNote(fullNote); await localDB.saveNote(toStoredNote(fullNote));
return fullNote; return fullNote;
} catch (error) { } catch (error) {
throw error; throw error;
@@ -239,8 +296,8 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const note = await this.api.createNoteWebDAV(title, content, category); const note = withLocalNoteFields(await this.api.createNoteWebDAV(title, content, category));
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
// Protect this note from being deleted by background sync for a short window // Protect this note from being deleted by background sync for a short window
this.protectNote(note.id); this.protectNote(note.id);
@@ -266,7 +323,7 @@ export class SyncManager {
if (!this.isOnline) { if (!this.isOnline) {
// Update locally, will sync when back online // Update locally, will sync when back online
note.favorite = favorite; note.favorite = favorite;
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
return; return;
} }
@@ -284,12 +341,12 @@ export class SyncManager {
// Update local cache // Update local cache
note.favorite = favorite; note.favorite = favorite;
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
} catch (error) { } catch (error) {
console.error('Failed to update favorite status:', error); console.error('Failed to update favorite status:', error);
// Still update locally // Still update locally
note.favorite = favorite; note.favorite = favorite;
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
} }
} }
@@ -307,14 +364,14 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const oldId = note.id; const oldId = note.id;
const updatedNote = await this.api.updateNoteWebDAV(note); const updatedNote = withLocalNoteFields(await this.api.updateNoteWebDAV(note), note);
// If the note ID changed (due to filename change), delete the old cache entry // If the note ID changed (due to filename change), delete the old cache entry
if (oldId !== updatedNote.id) { if (oldId !== updatedNote.id) {
await localDB.deleteNote(oldId); await localDB.deleteNote(oldId);
} }
await localDB.saveNote(updatedNote); await localDB.saveNote(toStoredNote(updatedNote));
// Protect this note from being deleted by background sync for a short window // Protect this note from being deleted by background sync for a short window
this.protectNote(updatedNote.id); this.protectNote(updatedNote.id);
@@ -366,9 +423,9 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const movedNote = await this.api.moveNoteWebDAV(note, newCategory); const movedNote = withLocalNoteFields(await this.api.moveNoteWebDAV(note, newCategory), note);
await localDB.deleteNote(note.id); await localDB.deleteNote(note.id);
await localDB.saveNote(movedNote); await localDB.saveNote(toStoredNote(movedNote));
// Protect the moved note from being deleted by background sync // Protect the moved note from being deleted by background sync
this.protectNote(movedNote.id); this.protectNote(movedNote.id);

View File

@@ -9,6 +9,12 @@ export interface Note {
modified: number; modified: number;
filename?: string; // WebDAV: actual filename on server filename?: string; // WebDAV: actual filename on server
path?: string; // WebDAV: full path including category path?: string; // WebDAV: full path including category
draftId?: string; // stable client-side identity across renames/moves
localOnly?: boolean; // exists only in local cache until first successful server create
pendingSave?: boolean; // local-first dirty flag
isSaving?: boolean; // local transient UI state
saveError?: string | null; // last save error, if any
lastSavedAt?: number; // local timestamp for "Saved" feedback
} }
export interface APIConfig { export interface APIConfig {

45
src/vite-env.d.ts vendored
View File

@@ -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';
};
}

View File

@@ -5,8 +5,9 @@ import react from "@vitejs/plugin-react";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async ({ command }) => ({
plugins: [react()], plugins: [react()],
base: command === "build" ? "./" : "/",
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
@@ -17,6 +18,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",