Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e1f47511 | ||
|
|
12b50c2304 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
release
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
168
README.md
168
README.md
@@ -1,85 +1,143 @@
|
|||||||

|

|
||||||
|
|
||||||
# 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
164
electron/main.cjs
Normal 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
8
electron/preload.cjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronDesktop', {
|
||||||
|
showMessage: (options) => ipcRenderer.invoke('desktop:show-message', options),
|
||||||
|
exportPdf: (payload) => ipcRenderer.invoke('desktop:export-pdf', payload),
|
||||||
|
httpRequest: (payload) => ipcRenderer.invoke('desktop:http-request', payload),
|
||||||
|
getRuntime: () => 'electron',
|
||||||
|
});
|
||||||
10
index.html
10
index.html
@@ -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
5011
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Nextcloud Notes",
|
"title": "Nextcloud Notes",
|
||||||
"width": 1200,
|
"width": 1300,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"minWidth": 900,
|
"minWidth": 800,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"devtools": true
|
"devtools": true
|
||||||
}
|
}
|
||||||
|
|||||||
633
src/App.tsx
633
src/App.tsx
@@ -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,34 +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 timestamp = new Date().toLocaleString('en-US', {
|
const note: Note = {
|
||||||
year: 'numeric',
|
id: `local:${draftId}`,
|
||||||
month: '2-digit',
|
etag: '',
|
||||||
day: '2-digit',
|
readonly: false,
|
||||||
hour: '2-digit',
|
content: '',
|
||||||
minute: '2-digit',
|
title: 'Untitled',
|
||||||
hour12: false,
|
category: selectedCategory,
|
||||||
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
favorite: false,
|
||||||
|
modified: Math.floor(Date.now() / 1000),
|
||||||
const note = await syncManager.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
draftId,
|
||||||
setNotes([note, ...notes]);
|
localOnly: true,
|
||||||
setSelectedNoteId(note.id);
|
pendingSave: false,
|
||||||
} catch (error) {
|
isSaving: false,
|
||||||
console.error('Create note failed:', error);
|
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) => {
|
||||||
@@ -242,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);
|
||||||
}
|
}
|
||||||
@@ -259,41 +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));
|
|
||||||
} else {
|
|
||||||
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const updated = await syncManager.updateNote(updatedNote);
|
|
||||||
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
|
|
||||||
}
|
|
||||||
} 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);
|
||||||
@@ -312,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} />;
|
||||||
@@ -347,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}
|
||||||
@@ -365,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)}
|
||||||
@@ -381,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;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -259,7 +276,7 @@ export class NextcloudAPI {
|
|||||||
const title = firstLine || filename.replace(/\.(md|txt)$/, '');
|
const title = firstLine || filename.replace(/\.(md|txt)$/, '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${category}/${filename}`,
|
id: category ? `${category}/${filename}` : filename,
|
||||||
filename,
|
filename,
|
||||||
path: category ? `${category}/${filename}` : filename,
|
path: category ? `${category}/${filename}` : filename,
|
||||||
etag,
|
etag,
|
||||||
@@ -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,
|
||||||
@@ -358,10 +441,10 @@ export class NextcloudAPI {
|
|||||||
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
|
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
|
||||||
const categoryPath = note.category ? `/${note.category}` : '';
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
|
const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,16 +457,16 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
|
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
|
||||||
const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.md`;
|
const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`;
|
||||||
const categoryPath = category ? `/${category}` : '';
|
const categoryPath = category ? `/${category}` : '';
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${filename}`;
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
// Ensure category directory exists
|
// Ensure category directory exists
|
||||||
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,
|
||||||
@@ -411,12 +494,12 @@ export class NextcloudAPI {
|
|||||||
const modified = Math.floor(Date.now() / 1000);
|
const modified = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${category}/${filename}`,
|
id: category ? `${category}/${filename}` : filename,
|
||||||
filename,
|
filename,
|
||||||
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,
|
||||||
@@ -425,13 +508,54 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateNoteWebDAV(note: Note): Promise<Note> {
|
async updateNoteWebDAV(note: Note): Promise<Note> {
|
||||||
const categoryPath = note.category ? `/${note.category}` : '';
|
// Extract new title from first line of content
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${note.filename}`;
|
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||||
|
const newTitle = firstLine || 'New Note';
|
||||||
|
const newFilename = `${newTitle.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`;
|
||||||
|
|
||||||
|
// Check if filename needs to change
|
||||||
|
const needsRename = note.filename !== newFilename;
|
||||||
|
|
||||||
|
if (needsRename) {
|
||||||
|
// Rename the file first, then update content
|
||||||
|
const renamedNote = await this.renameNoteWebDAV(note, newFilename);
|
||||||
|
// Now update the content of the renamed file
|
||||||
|
return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote));
|
||||||
|
} else {
|
||||||
|
// Just update content
|
||||||
|
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> {
|
||||||
|
const webdavPath = this.buildNoteWebDAVPath(note.category, 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,
|
||||||
@@ -443,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;
|
||||||
@@ -457,26 +584,98 @@ export class NextcloudAPI {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
|
||||||
|
const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!);
|
||||||
|
const newPath = this.buildNoteWebDAVPath(note.category, newFilename);
|
||||||
|
|
||||||
|
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||||
|
method: 'MOVE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Destination': `${this.serverURL}${newPath}`,
|
||||||
|
'If-Match': note.etag,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
||||||
|
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
|
||||||
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
|
const oldNoteIdStr = String(note.id);
|
||||||
|
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
|
||||||
|
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
|
||||||
|
const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`;
|
||||||
|
|
||||||
|
const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, '');
|
||||||
|
const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`;
|
||||||
|
|
||||||
|
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${oldAttachmentFolder}`;
|
||||||
|
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||||
|
method: 'MOVE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Destination': `${this.serverURL}${newAttachmentPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Attachment folder might not exist, that's ok
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
|
||||||
|
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
id: newId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: note.category ? `${note.category}/${newFilename}` : newFilename,
|
||||||
|
etag: refreshedMetadata?.etag || note.etag,
|
||||||
|
modified: refreshedMetadata?.modified || Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async deleteNoteWebDAV(note: Note): Promise<void> {
|
async deleteNoteWebDAV(note: Note): Promise<void> {
|
||||||
const categoryPath = note.category ? `/${note.category}` : '';
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
|
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
|
||||||
const oldCategoryPath = note.category ? `/${note.category}` : '';
|
const oldCategoryPath = note.category ? `/${note.category}` : '';
|
||||||
const newCategoryPath = newCategory ? `/${newCategory}` : '';
|
const newCategoryPath = newCategory ? `/${newCategory}` : '';
|
||||||
const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${note.filename}`;
|
const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${note.filename}`;
|
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
|
|
||||||
// Ensure new category directory exists (including nested subdirectories)
|
// Ensure new category directory exists (including nested subdirectories)
|
||||||
if (newCategory) {
|
if (newCategory) {
|
||||||
@@ -487,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 },
|
||||||
});
|
});
|
||||||
@@ -497,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,
|
||||||
@@ -524,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,
|
||||||
@@ -547,7 +746,7 @@ export class NextcloudAPI {
|
|||||||
...note,
|
...note,
|
||||||
category: newCategory,
|
category: newCategory,
|
||||||
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '',
|
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '',
|
||||||
id: `${newCategory}/${note.filename}`,
|
id: newCategory ? `${newCategory}/${note.filename}` : (note.filename || ''),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
previousNoteContentRef.current = note.content;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Switching to a different note
|
if (previousDraftIdRef.current !== note.draftId) {
|
||||||
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) {
|
|
||||||
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`);
|
|
||||||
loadNewNote();
|
|
||||||
}
|
}
|
||||||
// Initial load
|
|
||||||
else if (!note || previousNoteIdRef.current === null) {
|
if (note.content !== localContent) {
|
||||||
loadNewNote();
|
setLocalContent(note.content);
|
||||||
}
|
}
|
||||||
// Favorite status changed (e.g., from sync)
|
if ((note.category || '') !== localCategory) {
|
||||||
else if (note && note.favorite !== localFavorite) {
|
setLocalCategory(note.category || '');
|
||||||
|
}
|
||||||
|
if (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);
|
}
|
||||||
console.log('Last 50 chars:', localContent.slice(-50));
|
|
||||||
setIsSaving(true);
|
onChangeNote({
|
||||||
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);
|
|
||||||
setLocalCategory(note.category || '');
|
|
||||||
setLocalFavorite(note.favorite);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
|
onDiscardNote(note.draftId);
|
||||||
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, ``);
|
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||||
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, ``);
|
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||||
} 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,
|
||||||
const titleElement = document.createElement('h1');
|
html: noteHtml,
|
||||||
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
|
previewFont,
|
||||||
titleElement.textContent = firstLine || 'Untitled';
|
previewFontSize,
|
||||||
titleElement.style.marginTop = '0';
|
previewFontFaceCss: await loadPrintFontFaceCss(previewFont),
|
||||||
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 exportPdfDocument({
|
||||||
await pdf.html(container, {
|
...payload,
|
||||||
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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -125,9 +120,17 @@ export function NotesList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPreview = (content: string) => {
|
const getPreview = (content: string) => {
|
||||||
// grab first 100 characters of note's content, remove markdown syntax from the preview
|
// Skip first line (title) and find first non-empty line
|
||||||
const previewContent = content.substring(0, 100);
|
const lines = content.split('\n');
|
||||||
const cleanedPreview = previewContent.replace(/[#*`]/g, '');
|
const contentLines = lines.slice(1); // Skip first line
|
||||||
|
|
||||||
|
// Find first non-empty line
|
||||||
|
const firstContentLine = contentLines.find(line => line.trim().length > 0);
|
||||||
|
if (!firstContentLine) return '';
|
||||||
|
|
||||||
|
// Take up to 100 characters from the content lines
|
||||||
|
const previewContent = contentLines.join(' ').substring(0, 100);
|
||||||
|
const cleanedPreview = previewContent.replace(/[#*`]/g, '').trim();
|
||||||
return cleanedPreview;
|
return cleanedPreview;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ export function NotesList({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col relative flex-shrink-0"
|
className="bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col relative flex-shrink-0"
|
||||||
style={{ width: `${width}px`, minWidth: '240px', maxWidth: '600px' }}
|
style={{ width: `${width}px`, minWidth: '340px', maxWidth: '600px' }}
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -241,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">
|
||||||
|
|||||||
366
src/components/PrintView.tsx
Normal file
366
src/components/PrintView.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
369
src/printExport.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
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
139
src/services/desktop.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
buildPrintDocument,
|
||||||
|
PrintExportPayload,
|
||||||
|
PRINT_EXPORT_QUERY_PARAM,
|
||||||
|
} from '../printExport';
|
||||||
|
|
||||||
|
export interface DesktopMessageOptions {
|
||||||
|
title?: string;
|
||||||
|
kind?: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportPdfResult {
|
||||||
|
canceled: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isElectronRuntime = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.electronDesktop !== 'undefined';
|
||||||
|
|
||||||
|
const isTauriRuntime = () =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(
|
||||||
|
typeof (window as Window & { __TAURI__?: unknown }).__TAURI__ !== 'undefined' ||
|
||||||
|
typeof (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== 'undefined'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getDesktopRuntime = () => {
|
||||||
|
if (isElectronRuntime()) return 'electron';
|
||||||
|
if (isTauriRuntime()) return 'tauri';
|
||||||
|
return 'browser';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showDesktopMessage = async (
|
||||||
|
messageText: string,
|
||||||
|
options: DesktopMessageOptions = {}
|
||||||
|
) => {
|
||||||
|
if (window.electronDesktop) {
|
||||||
|
await window.electronDesktop.showMessage({
|
||||||
|
message: messageText,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
const { message } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
await message(messageText, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.alert(messageText);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportPdfDocument = async (
|
||||||
|
payload: PrintExportPayload
|
||||||
|
): Promise<ExportPdfResult> => {
|
||||||
|
if (window.electronDesktop) {
|
||||||
|
return window.electronDesktop.exportPdf({
|
||||||
|
...payload,
|
||||||
|
documentHtml: buildPrintDocument(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
await exportPdfDocumentWithTauri(payload);
|
||||||
|
return { canceled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('PDF export is only available in a desktop runtime.');
|
||||||
|
};
|
||||||
|
|
||||||
|
async function exportPdfDocumentWithTauri(payload: PrintExportPayload) {
|
||||||
|
const [{ emitTo, listen }, { WebviewWindow }] = await Promise.all([
|
||||||
|
import('@tauri-apps/api/event'),
|
||||||
|
import('@tauri-apps/api/webviewWindow'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const windowLabel = `print-export-${jobId}`;
|
||||||
|
let printWindow: any = null;
|
||||||
|
let unlistenReady: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
unlistenReady?.();
|
||||||
|
reject(new Error('Print view initialization timed out.'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
listen<{ jobId: string }>('print-export-ready', (event) => {
|
||||||
|
if (settled || event.payload.jobId !== jobId) return;
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
unlistenReady?.();
|
||||||
|
|
||||||
|
void emitTo(windowLabel, 'print-export-payload', payload)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
})
|
||||||
|
.then((unlisten) => {
|
||||||
|
unlistenReady = unlisten;
|
||||||
|
printWindow = new WebviewWindow(windowLabel, {
|
||||||
|
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
|
||||||
|
title: 'Opening Print Dialog',
|
||||||
|
width: 960,
|
||||||
|
height: 1100,
|
||||||
|
center: true,
|
||||||
|
visible: true,
|
||||||
|
focus: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
parent: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
void printWindow.once('tauri://error', (event: { payload?: unknown }) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
unlistenReady?.();
|
||||||
|
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (printWindow) {
|
||||||
|
void printWindow.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (typeof unlistenReady === 'function') {
|
||||||
|
unlistenReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/services/runtimeFetch.ts
Normal file
155
src/services/runtimeFetch.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
interface ElectronHttpRequest {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyText?: string;
|
||||||
|
bodyBase64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronHttpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: {
|
||||||
|
get(name: string): string | null;
|
||||||
|
};
|
||||||
|
text(): Promise<string>;
|
||||||
|
json<T>(): Promise<T>;
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
blob(): Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
const bytesToBase64 = (bytes: Uint8Array) => {
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||||
|
const chunk = bytes.subarray(index, index + 0x8000);
|
||||||
|
binary += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64ToBytes = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeHeaders = (headers: Record<string, string>) => {
|
||||||
|
const normalized = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
normalized.set(key.toLowerCase(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRuntimeResponse = (response: ElectronHttpResponse): RuntimeResponse => {
|
||||||
|
const headers = normalizeHeaders(response.headers);
|
||||||
|
const bytes = base64ToBytes(response.bodyBase64);
|
||||||
|
const contentType = headers.get('content-type') || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: {
|
||||||
|
get(name: string) {
|
||||||
|
return headers.get(name.toLowerCase()) ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return textDecoder.decode(bytes);
|
||||||
|
},
|
||||||
|
async json<T>() {
|
||||||
|
return JSON.parse(textDecoder.decode(bytes)) as T;
|
||||||
|
},
|
||||||
|
async arrayBuffer() {
|
||||||
|
return bytes.buffer.slice(
|
||||||
|
bytes.byteOffset,
|
||||||
|
bytes.byteOffset + bytes.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
},
|
||||||
|
async blob() {
|
||||||
|
return new Blob([bytes], { type: contentType });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const headersToObject = (headers?: HeadersInit) => {
|
||||||
|
if (!headers) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(new Headers(headers).entries());
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeBody = async (body?: BodyInit | null) => {
|
||||||
|
if (body == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
return { bodyText: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof ArrayBuffer) {
|
||||||
|
return { bodyBase64: bytesToBase64(new Uint8Array(body)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ArrayBuffer.isView(body)) {
|
||||||
|
return {
|
||||||
|
bodyBase64: bytesToBase64(
|
||||||
|
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof Blob) {
|
||||||
|
return {
|
||||||
|
bodyBase64: bytesToBase64(new Uint8Array(await body.arrayBuffer())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported request body for Electron runtime.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtimeFetch = async (
|
||||||
|
url: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<Response | RuntimeResponse> => {
|
||||||
|
if (!window.electronDesktop?.httpRequest) {
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ElectronHttpRequest = {
|
||||||
|
url,
|
||||||
|
method: init.method,
|
||||||
|
headers: headersToObject(init.headers),
|
||||||
|
...(await serializeBody(init.body)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await window.electronDesktop.httpRequest(payload);
|
||||||
|
return createRuntimeResponse(response);
|
||||||
|
};
|
||||||
@@ -4,12 +4,49 @@ 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;
|
||||||
private syncInProgress: boolean = false;
|
private syncInProgress: boolean = false;
|
||||||
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
|
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
|
||||||
private syncCompleteCallback: (() => void) | null = null;
|
private syncCompleteCallback: (() => void) | null = null;
|
||||||
|
private recentlyModifiedNotes: Set<number | string> = new Set();
|
||||||
|
private readonly PROTECTION_WINDOW_MS = 10000;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', () => {
|
||||||
@@ -44,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) {
|
||||||
@@ -66,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) {
|
||||||
@@ -85,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]));
|
||||||
@@ -95,25 +134,39 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deleted notes from cache
|
// 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)) {
|
||||||
await localDB.deleteNote(cachedNote.id);
|
// Don't delete notes that were recently created/updated (race condition protection)
|
||||||
|
if (!cachedNote.pendingSave && !this.recentlyModifiedNotes.has(cachedNote.id)) {
|
||||||
|
await localDB.deleteNote(cachedNote.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,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)
|
||||||
@@ -158,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);
|
||||||
@@ -171,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,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);
|
||||||
}
|
}
|
||||||
@@ -213,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;
|
||||||
@@ -234,8 +296,12 @@ 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
|
||||||
|
this.protectNote(note.id);
|
||||||
|
|
||||||
this.notifyStatus('idle', 0);
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
// Trigger background sync to fetch any other changes
|
// Trigger background sync to fetch any other changes
|
||||||
@@ -257,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +363,19 @@ export class SyncManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.notifyStatus('syncing', 0);
|
this.notifyStatus('syncing', 0);
|
||||||
const updatedNote = await this.api.updateNoteWebDAV(note);
|
const oldId = note.id;
|
||||||
await localDB.saveNote(updatedNote);
|
const updatedNote = withLocalNoteFields(await this.api.updateNoteWebDAV(note), note);
|
||||||
|
|
||||||
|
// If the note ID changed (due to filename change), delete the old cache entry
|
||||||
|
if (oldId !== updatedNote.id) {
|
||||||
|
await localDB.deleteNote(oldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await localDB.saveNote(toStoredNote(updatedNote));
|
||||||
|
|
||||||
|
// Protect this note from being deleted by background sync for a short window
|
||||||
|
this.protectNote(updatedNote.id);
|
||||||
|
|
||||||
this.notifyStatus('idle', 0);
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
// Trigger background sync to fetch any other changes
|
// Trigger background sync to fetch any other changes
|
||||||
@@ -346,9 +423,13 @@ 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
|
||||||
|
this.protectNote(movedNote.id);
|
||||||
|
|
||||||
this.notifyStatus('idle', 0);
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
// Trigger background sync to fetch any other changes
|
// Trigger background sync to fetch any other changes
|
||||||
@@ -370,6 +451,14 @@ export class SyncManager {
|
|||||||
getOnlineStatus(): boolean {
|
getOnlineStatus(): boolean {
|
||||||
return this.isOnline;
|
return this.isOnline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protect a note from being deleted during background sync for a short window
|
||||||
|
private protectNote(noteId: number | string): void {
|
||||||
|
this.recentlyModifiedNotes.add(noteId);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.recentlyModifiedNotes.delete(noteId);
|
||||||
|
}, this.PROTECTION_WINDOW_MS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncManager = new SyncManager();
|
export const syncManager = new SyncManager();
|
||||||
|
|||||||
@@ -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
45
src/vite-env.d.ts
vendored
@@ -1 +1,46 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ElectronDesktopMessageOptions {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
kind?: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopExportPayload {
|
||||||
|
fileName: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
previewFont: string;
|
||||||
|
previewFontSize: number;
|
||||||
|
documentHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopExportResult {
|
||||||
|
canceled: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopHttpRequest {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyText?: string;
|
||||||
|
bodyBase64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopHttpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electronDesktop?: {
|
||||||
|
showMessage: (options: ElectronDesktopMessageOptions) => Promise<void>;
|
||||||
|
exportPdf: (payload: ElectronDesktopExportPayload) => Promise<ElectronDesktopExportResult>;
|
||||||
|
httpRequest: (payload: ElectronDesktopHttpRequest) => Promise<ElectronDesktopHttpResponse>;
|
||||||
|
getRuntime: () => 'electron';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user