12 Commits
main ... dev

Author SHA1 Message Date
drelich
3b57fcea75 Add Windows ARM64 packaging support 2026-04-15 16:37:41 +02:00
drelich
e7fbbf710d Add Linux build configuration and package metadata 2026-04-09 13:58:30 +02:00
drelich
995696fea3 Refactor WebDAV path construction and fix note move operations
- Replace Node.js fetch with Electron net.request for better session handling
- Extract WebDAV path building into reusable private methods with proper URL encoding
- Add helper methods for category path encoding and attachment path construction
- Fix note move operations to use remote category/filename from saved snapshots
- Add ensureCategoryDirectoryExists to handle nested category creation
- Only move/rename attachment folders when note has any
2026-04-06 17:40:57 +02:00
drelich
6bc67a3118 Add interactive task lists and table insertion to markdown editor
- Enable task list checkbox toggling in preview mode with live content updates
- Add task list and table insertion buttons to InsertToolbar
- Implement smart block snippet insertion with automatic newline handling
- Add horizontal scroll wrapper for wide tables in preview
- Fix editor scroll position preservation during content updates
- Use useLayoutEffect to prevent scroll jumps when textarea auto-resizes
- Update task list styling
2026-04-06 16:15:43 +02:00
drelich
aba090a8ad Implement optimistic note updates with debounced autosave
- Replace immediate server saves with local-first optimistic updates
- Add 1-second debounced autosave with per-note save controllers
- Track note state with draftId instead of server-assigned id
- Implement save queue with in-flight request tracking and revision numbers
- Add pendingSave/isSaving/saveError flags to note state
- Store saved snapshots to detect content changes
- Flush pending saves on logout, category operations, and window
2026-04-06 10:10:17 +02:00
drelich
e21e443a59 Fix PDF export font embedding and improve sync reliability
- Replace data URL loading with temporary HTML file to avoid URL length limits
- Embed font files as data URLs in print document CSS for offline rendering
- Add font asset registry for Merriweather, Crimson Pro, Roboto Serif, and Average
- Implement font file caching and blob-to-data-URL conversion
- Clean up temporary HTML file after PDF generation
- Fix sync to refresh notes after favorite status sync completes
2026-04-06 09:46:26 +02:00
drelich
6e970f37ea Add electron-builder packaging configuration
- Install electron-builder as dev dependency
- Configure build targets for macOS (dmg, zip), Windows (nsis, zip), and Linux (AppImage, deb)
- Add npm scripts for building unpacked app (dist:dir) and macOS packages (dist:mac)
- Set base path to "./" in production builds for proper asset loading in packaged app
- Fix index.html asset paths to use relative paths (./ prefix)
- Update README to document packaging commands and note unsigned/unnotarized status
2026-04-05 22:39:04 +02:00
drelich
06b4cc4514 refactor: migrate PDF export to desktop runtime abstraction
- Extract desktop-specific logic into src/services/desktop.ts
- Add runtime detection for Electron vs Tauri environments
- Simplify NoteEditor by delegating PDF export to desktop service
- Update printExport.ts to remove unused storage helpers
- Add Electron main process files in electron/ directory
- Update vite config and types for Electron integration
- Update .gitignore and package.json for Electron workflow
2026-04-05 22:13:06 +02:00
drelich
e93ce566d3 docs: rewrite README for Electron migration
Update documentation to reflect the transition from Tauri to Electron:
- Document Electron as primary desktop runtime
- Update development workflow and npm scripts
- Add project structure and security sections
- Note packaging status (Electron Forge needed for releases)
- Keep reference to legacy Tauri script
2026-04-05 22:11:30 +02:00
drelich
42eec85826 Implement native print dialog for PDF export
- Replaced jsPDF client-side generation with Tauri's native print dialog
- Created PrintView component that renders note content in a print-optimized layout
- Added print-export window capability with webview creation and print permissions
- Implemented event-based communication between main window and print window
- Moved shared print/export utilities to printExport.ts (title extraction, filename sanitization)
- Changed export button icon from download
2026-04-05 21:30:31 +02:00
drelich
f5b448808d Implement task list rendering in preview mode
- Enabled GitHub Flavored Markdown (GFM) in marked.js for task list support
- Added custom CSS styling for task lists:
  - Hide bullet points, show only checkboxes
  - Increased checkbox size to 20px for better visibility
  - Green accent color for checked items
  - Flexbox layout for proper multi-line text alignment
- Task lists render as read-only checkboxes in preview mode
2026-03-31 10:26:38 +02:00
drelich
21cd9ced2f Add favorite notes sorting to display starred notes at top
- Favorited/starred notes now appear at the top of the notes list
- Within each group (favorites/non-favorites), notes are sorted by modified date (newest first)
- Matches mobile app behavior
2026-03-31 10:06:02 +02:00
24 changed files with 7940 additions and 589 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
release
*.local
# Editor directories and files

172
README.md
View File

@@ -1,85 +1,147 @@
![nextcloud-notes-tauri.png](src/assets/nextcloud-notes-tauri.png)
# Tauri + React + Typescript
# Nextcloud Notes Desktop
# Nextcloud Notes - Cross-Platform Desktop App
A desktop client for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with React, TypeScript, Vite, and Electron.
A modern, cross-platform desktop application for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with Tauri + React + TypeScript.
This project started life as a Tauri app and has now been migrated to Electron for desktop runtime support, PDF export, and simpler cross-platform desktop behavior during development.
## Features
## What It Does
- **Cross-platform**: macOS, Linux, Windows
- **Lightweight**: ~600KB binary (vs 150MB+ Electron)
- **Modern UI**: React + TailwindCSS
- **Full sync**: Create, edit, delete, favorite notes
- **Search & filter**: Find notes quickly, filter by favorites
- **Auto-save**: Changes save automatically after 1.5s
- **Secure**: Credentials stored in system keychain (localStorage for now)
- **Background sync**: Auto-sync every 5 minutes
- Sign in to a Nextcloud server with Notes enabled
- Sync notes from WebDAV and favorite state from the Notes API
- Create, edit, move, rename, and delete notes
- Organize notes into categories, including nested categories
- Mark notes as favorites
- Cache notes locally for faster startup and offline viewing
- Upload and render note attachments
- 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/
- **Node.js**: v18+ recommended
- **Nextcloud instance** with Notes app enabled
- Primary desktop runtime: Electron
- Frontend: React 19 + TypeScript + Vite
- 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
Run the Electron app with the Vite dev server:
```bash
# Install dependencies
npm install
npm run dev:desktop
```
# Run in development mode
npm run tauri dev
Useful scripts:
# Build for production
npm run tauri build
```bash
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/
npm run dist:win # Build Windows installer + zip for the current Windows architecture
npm run dist:win:arm64 # Build Windows ARM64 installer + zip 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
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
3. Enter your password or **App Password** (recommended)
- Generate at: Settings → Security → Devices & Sessions in Nextcloud
4. Click **Connect**
3. Enter your password or, preferably, a Nextcloud app password
4. Wait for the initial sync to finish
## Building for Distribution
Using an app password is strongly recommended.
### macOS
```bash
npm run tauri build
# Output: src-tauri/target/release/bundle/macos/
## Notable Behavior
### Sync model
- 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
```bash
npm run tauri build
# Output: src-tauri/target/release/bundle/appimage/ or .deb
```
## Security Notes
### Windows
```bash
npm run tauri build
# Output: src-tauri/target/release/bundle/msi/
```
- Electron runs with `contextIsolation: true`
- `nodeIntegration` is disabled in renderer windows
- Network requests that need desktop privileges are routed through Electron IPC instead of renderer-side browser fetch
## Tech Stack
Current limitation:
- **Tauri**: Rust-based native wrapper (~600KB)
- **React 18**: UI framework
- **TypeScript**: Type safety
- **TailwindCSS**: Utility-first styling
- **Vite**: Fast build tool
- Login credentials are still persisted in `localStorage`
## 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
-**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
## Packaging
## 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/`
- `npm run dist:win` creates Windows `.exe` and `.zip` artifacts for the current Windows architecture
- `npm run dist:win:arm64` creates Windows ARM64 `.exe` 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 ARM64 packaging has been validated in this repository with Electron Builder on Windows 11 ARM running under Parallels. Linux targets are still configured in `package.json`, but they have not been validated here 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.

196
electron/main.cjs Normal file
View File

@@ -0,0 +1,196 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { app, BrowserWindow, dialog, ipcMain, net } = 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 != null
? Buffer.from(payload.bodyText, 'utf8')
: null;
return await new Promise((resolve, reject) => {
const request = net.request({
url: payload.url,
method: payload.method || 'GET',
session: BrowserWindow.getAllWindows()[0]?.webContents.session,
});
for (const [name, value] of Object.entries(payload.headers || {})) {
request.setHeader(name, value);
}
request.on('response', (response) => {
const chunks = [];
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on('end', () => {
const headers = {};
for (const [name, value] of Object.entries(response.headers)) {
headers[name] = Array.isArray(value) ? value.join(', ') : String(value ?? '');
}
const buffer = Buffer.concat(chunks);
resolve({
ok: response.statusCode >= 200 && response.statusCode < 300,
status: response.statusCode,
statusText: response.statusMessage || '',
headers,
bodyBase64: buffer.toString('base64'),
});
});
response.on('error', reject);
});
request.on('error', reject);
if (body && body.length > 0) {
request.write(body);
}
request.end();
});
});
ipcMain.handle('desktop:export-pdf', async (event, payload) => {
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
const saveResult = await dialog.showSaveDialog(ownerWindow, {
title: 'Export PDF',
defaultPath: payload.fileName,
filters: [{ name: 'PDF Document', extensions: ['pdf'] }],
});
if (saveResult.canceled || !saveResult.filePath) {
return { canceled: true };
}
const tempHtmlPath = path.join(
app.getPath('temp'),
`nextcloud-notes-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.html`
);
const pdfWindow = new BrowserWindow({
width: 960,
height: 1100,
show: false,
parent: ownerWindow ?? undefined,
webPreferences: {
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
spellcheck: false,
},
});
try {
await fs.writeFile(tempHtmlPath, payload.documentHtml, 'utf8');
await pdfWindow.loadFile(tempHtmlPath);
await pdfWindow.webContents.executeJavaScript(waitForPrintDocument(), true);
const pdfData = await pdfWindow.webContents.printToPDF({
printBackground: true,
preferCSSPageSize: true,
});
await fs.writeFile(saveResult.filePath, pdfData);
return {
canceled: false,
filePath: saveResult.filePath,
};
} finally {
await fs.unlink(tempHtmlPath).catch(() => undefined);
if (!pdfWindow.isDestroyed()) {
pdfWindow.destroy();
}
}
});

8
electron/preload.cjs Normal file
View File

@@ -0,0 +1,8 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronDesktop', {
showMessage: (options) => ipcRenderer.invoke('desktop:show-message', options),
exportPdf: (payload) => ipcRenderer.invoke('desktop:export-pdf', payload),
httpRequest: (payload) => ipcRenderer.invoke('desktop:http-request', payload),
getRuntime: () => 'electron',
});

View File

@@ -2,15 +2,19 @@
<html lang="en">
<head>
<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
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>
<!-- Local fonts for offline support -->
<link rel="stylesheet" href="/fonts/fonts.css">
<link rel="stylesheet" href="./fonts/fonts.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

5011
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,27 @@
"name": "nextcloud-notes-tauri",
"private": true,
"version": "0.2.2",
"description": "Desktop client for Nextcloud Notes built with Electron, React, and TypeScript.",
"homepage": "https://gitea.davidrelich.com/davidrelich/nextcloud-notes-desktop-app",
"author": {
"name": "drelich",
"email": "david.relich@me.com"
},
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"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\"",
"gen:win-icon": "node scripts/generate-win-icon.mjs",
"build": "tsc && vite build",
"desktop": "electron .",
"dist:dir": "npm run build && electron-builder --dir",
"dist:mac": "npm run build && electron-builder --mac dmg zip",
"dist:linux": "npm run build && electron-builder --linux AppImage deb",
"dist:win": "npm run gen:win-icon && npm run build && electron-builder --win nsis zip",
"dist:win:arm64": "npm run gen:win-icon && npm run build && electron-builder --win nsis zip --arm64",
"preview": "vite preview",
"tauri": "tauri"
},
@@ -34,9 +51,52 @@
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.6.0",
"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",
"tailwindcss": "^3.4.19",
"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/128x128@2x.png",
"category": "Office",
"maintainer": "drelich <david.relich@me.com>",
"target": [
"AppImage",
"deb"
]
}
}
}

View File

@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const sourcePngPath = path.join(projectRoot, "src-tauri", "icons", "128x128@2x.png");
const targetIcoPath = path.join(projectRoot, "src-tauri", "icons", "icon.ico");
const pngBytes = fs.readFileSync(sourcePngPath);
if (pngBytes.length < 24 || pngBytes.toString("ascii", 1, 4) !== "PNG") {
throw new Error(`Expected a PNG file at ${sourcePngPath}`);
}
const width = pngBytes.readUInt32BE(16);
const height = pngBytes.readUInt32BE(20);
if (width !== 256 || height !== 256) {
throw new Error(`Expected a 256x256 PNG, received ${width}x${height}`);
}
const headerSize = 6;
const directoryEntrySize = 16;
const imageOffset = headerSize + directoryEntrySize;
const icoBytes = Buffer.alloc(imageOffset + pngBytes.length);
icoBytes.writeUInt16LE(0, 0);
icoBytes.writeUInt16LE(1, 2);
icoBytes.writeUInt16LE(1, 4);
icoBytes.writeUInt8(0, 6);
icoBytes.writeUInt8(0, 7);
icoBytes.writeUInt8(0, 8);
icoBytes.writeUInt8(0, 9);
icoBytes.writeUInt16LE(1, 10);
icoBytes.writeUInt16LE(32, 12);
icoBytes.writeUInt32LE(pngBytes.length, 14);
icoBytes.writeUInt32LE(imageOffset, 18);
pngBytes.copy(icoBytes, imageOffset);
fs.writeFileSync(targetIcoPath, icoBytes);
console.log(`Generated ${path.relative(projectRoot, targetIcoPath)} from ${path.relative(projectRoot, sourcePngPath)}`);

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { LoginView } from './components/LoginView';
import { NotesList } from './components/NotesList';
import { NoteEditor } from './components/NoteEditor';
@@ -9,12 +9,89 @@ import { syncManager, SyncStatus } from './services/syncManager';
import { localDB } from './db/localDB';
import { useOnlineStatus } from './hooks/useOnlineStatus';
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 [api, setApi] = useState<NextcloudAPI | null>(null);
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 [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [selectedCategory, setSelectedCategory] = useState('');
@@ -24,7 +101,6 @@ function App() {
const [username, setUsername] = useState('');
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [editorFont, setEditorFont] = useState('Source Code Pro');
const [editorFontSize, setEditorFontSize] = useState(14);
const [previewFont, setPreviewFont] = useState('Merriweather');
@@ -32,6 +108,89 @@ function App() {
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
const [pendingSyncCount, setPendingSyncCount] = useState(0);
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(() => {
const initApp = async () => {
@@ -113,7 +272,7 @@ function App() {
// Reload notes from cache after background sync completes
// Don't call loadNotes() as it triggers another sync - just reload from cache
const cachedNotes = await localDB.getAllNotes();
setNotes(cachedNotes.sort((a, b) => b.modified - a.modified));
applyLoadedNotes(cachedNotes);
});
}, []);
@@ -125,13 +284,43 @@ function App() {
}
}, [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 () => {
try {
const loadedNotes = await syncManager.loadNotes();
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified));
if (!selectedNoteId && loadedNotes.length > 0) {
setSelectedNoteId(loadedNotes[0].id);
}
applyLoadedNotes(loadedNotes);
} catch (error) {
console.error('Failed to load notes:', error);
}
@@ -139,6 +328,7 @@ function App() {
const syncNotes = async () => {
try {
await flushAllPendingSaves();
await syncManager.syncWithServer();
await loadNotes();
} catch (error) {
@@ -169,8 +359,11 @@ function App() {
categoryColorsSync.setAPI(null);
setUsername('');
setNotes([]);
setSelectedNoteId(null);
notesRef.current = [];
setSelectedNoteDraftId(null);
setIsLoggedIn(false);
saveControllersRef.current.clear();
savedSnapshotsRef.current.clear();
};
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
@@ -199,25 +392,66 @@ function App() {
};
const handleToggleFavorite = async (note: Note, favorite: boolean) => {
try {
await syncManager.updateFavoriteStatus(note, favorite);
// Update local state
setNotes(prevNotes =>
prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n)
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 {
await syncManager.updateFavoriteStatus(optimisticNote, favorite);
const snapshot = savedSnapshotsRef.current.get(draftId);
if (snapshot) {
savedSnapshotsRef.current.set(draftId, {
...snapshot,
favorite,
});
}
} catch (error) {
console.error('Toggle favorite failed:', error);
}
};
const handleCreateNote = async () => {
try {
const note = await syncManager.createNote('New Note', '', selectedCategory);
setNotes([note, ...notes]);
setSelectedNoteId(note.id);
} catch (error) {
console.error('Create note failed:', error);
}
const draftId = createDraftId();
const note: Note = {
id: `local:${draftId}`,
etag: '',
readonly: false,
content: '',
title: 'Untitled',
category: selectedCategory,
favorite: false,
modified: Math.floor(Date.now() / 1000),
draftId,
localOnly: true,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: undefined,
};
savedSnapshotsRef.current.set(draftId, {
...note,
pendingSave: false,
isSaving: false,
saveError: null,
});
setSortedNotes(previousNotes => [note, ...previousNotes]);
persistNoteToCache(note);
setSelectedNoteDraftId(draftId);
};
const handleCreateCategory = (name: string) => {
@@ -233,7 +467,19 @@ function App() {
for (const note of notesToMove) {
try {
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) {
console.error(`Failed to move note ${note.id}:`, error);
}
@@ -250,53 +496,291 @@ function App() {
}
};
const handleUpdateNote = async (updatedNote: Note) => {
try {
const originalNote = notes.find(n => n.id === updatedNote.id);
const persistNoteToServer = async (note: Note) => {
if (note.localOnly) {
const { title, body } = splitNoteContent(note.content);
const createdNote = await syncManager.createNote(title, body, note.category);
return {
...createdNote,
content: note.content,
title,
favorite: note.favorite,
draftId: note.draftId,
localOnly: false,
};
}
// If category changed, use moveNote instead of updateNote
if (originalNote && originalNote.category !== updatedNote.category) {
const movedNote = await syncManager.moveNote(originalNote, updatedNote.category);
// If content/title also changed, update the moved note
if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) {
const finalNote = await syncManager.updateNote({
const savedSnapshot = note.draftId ? savedSnapshotsRef.current.get(note.draftId) : null;
const remoteReference = savedSnapshot ?? note;
const remoteCategory = getRemoteCategory(remoteReference);
if (remoteCategory !== note.category) {
const movedNote = await syncManager.moveNote(
{
...note,
id: remoteReference.id,
path: remoteReference.path,
filename: remoteReference.filename,
category: remoteCategory,
},
note.category,
);
return syncManager.updateNote({
...movedNote,
title: updatedNote.title,
content: updatedNote.content,
favorite: updatedNote.favorite,
draftId: note.draftId,
title: note.title,
content: note.content,
favorite: note.favorite,
localOnly: false,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: note.lastSavedAt,
});
setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n));
// Update selected note ID if it changed
if (selectedNoteId === originalNote.id && finalNote.id !== originalNote.id) {
setSelectedNoteId(finalNote.id);
}
} else {
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
// Update selected note ID if it changed
if (selectedNoteId === originalNote.id && movedNote.id !== originalNote.id) {
setSelectedNoteId(movedNote.id);
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;
}
} else {
const updated = await syncManager.updateNote(updatedNote);
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
// Update selected note ID if it changed (e.g., filename changed due to first line edit)
if (selectedNoteId === updatedNote.id && updated.id !== updatedNote.id) {
setSelectedNoteId(updated.id);
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 draftId = note.draftId;
if (!draftId) {
return;
}
try {
clearSaveTimer(draftId);
if (!note.localOnly) {
await syncManager.deleteNote(note);
const remainingNotes = notes.filter(n => n.id !== note.id);
setNotes(remainingNotes);
if (selectedNoteId === note.id) {
setSelectedNoteId(remainingNotes[0]?.id || null);
} else {
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) {
console.error('Delete note failed:', error);
@@ -315,9 +799,16 @@ function App() {
note.content.toLowerCase().includes(search);
}
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) {
return <LoginView onLogin={handleLogin} />;
@@ -350,8 +841,8 @@ function App() {
/>
<NotesList
notes={filteredNotes}
selectedNoteId={selectedNoteId}
onSelectNote={setSelectedNoteId}
selectedNoteDraftId={selectedNoteDraftId}
onSelectNote={handleSelectNote}
onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote}
onSync={syncNotes}
@@ -368,9 +859,10 @@ function App() {
)}
<NoteEditor
note={selectedNote}
onUpdateNote={handleUpdateNote}
onChangeNote={handleDraftChange}
onSaveNote={handleManualSave}
onDiscardNote={handleDiscardNote}
onToggleFavorite={handleToggleFavorite}
onUnsavedChanges={setHasUnsavedChanges}
categories={categories}
isFocusMode={isFocusMode}
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
@@ -384,4 +876,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;

View File

@@ -1,5 +1,22 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
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 {
private baseURL: string;
@@ -16,7 +33,7 @@ export class NextcloudAPI {
}
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,
headers: {
'Content-Type': 'application/json',
@@ -111,20 +128,11 @@ export class NextcloudAPI {
// The path from markdown is like: .attachments.38479/Screenshot.png
// We need to construct the full WebDAV URL
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
// Add category subfolder if present
if (noteCategory) {
webdavPath += `/${noteCategory}`;
}
// Add the attachment path (already includes .attachments.{id}/filename)
webdavPath += `/${path}`;
const webdavPath = this.buildAttachmentWebDAVPath(noteCategory, path);
const url = `${this.serverURL}${webdavPath}`;
console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url);
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
headers: {
'Authorization': this.authHeader,
},
@@ -151,12 +159,6 @@ export class NextcloudAPI {
// Create .attachments.{noteId} directory path and upload file via WebDAV PUT
// Returns the relative path to insert into markdown
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
if (noteCategory) {
webdavPath += `/${noteCategory}`;
}
// Sanitize note ID: extract just the filename without extension and remove invalid chars
// noteId might be "category/filename.md" or just "filename.md"
const noteIdStr = String(noteId);
@@ -166,7 +168,7 @@ export class NextcloudAPI {
const attachmentDir = `.attachments.${sanitizedNoteId}`;
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
const fullPath = this.buildAttachmentWebDAVPath(noteCategory, attachmentDir, fileName);
const url = `${this.serverURL}${fullPath}`;
console.log('Uploading attachment via WebDAV:', url);
@@ -174,7 +176,7 @@ export class NextcloudAPI {
// First, try to create the attachments directory (MKCOL)
// This may fail if it already exists, which is fine
try {
await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
await runtimeFetch(`${this.serverURL}${this.buildAttachmentWebDAVPath(noteCategory, attachmentDir)}`, {
method: 'MKCOL',
headers: {
'Authorization': this.authHeader,
@@ -188,7 +190,7 @@ export class NextcloudAPI {
const arrayBuffer = await file.arrayBuffer();
// Upload the file via PUT
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
@@ -206,11 +208,11 @@ export class NextcloudAPI {
}
async fetchCategoryColors(): Promise<Record<string, number>> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
const webdavPath = `${this.buildNotesRootWebDAVPath()}/.category-colors.json`;
const url = `${this.serverURL}${webdavPath}`;
try {
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
headers: {
'Authorization': this.authHeader,
},
@@ -233,12 +235,12 @@ export class NextcloudAPI {
}
async saveCategoryColors(colors: Record<string, number>): Promise<void> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
const webdavPath = `${this.buildNotesRootWebDAVPath()}/.category-colors.json`;
const url = `${this.serverURL}${webdavPath}`;
const content = JSON.stringify(colors, null, 2);
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
@@ -277,11 +279,167 @@ export class NextcloudAPI {
return note.content;
}
private buildNotesRootWebDAVPath(): string {
return `/remote.php/dav/files/${this.username}/Notes`;
}
private buildEncodedCategoryPath(category: string): string {
if (!category) {
return '';
}
const encodedSegments = category
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment));
return encodedSegments.length ? `/${encodedSegments.join('/')}` : '';
}
private buildCategoryWebDAVPath(category: string): string {
return `${this.buildNotesRootWebDAVPath()}${this.buildEncodedCategoryPath(category)}`;
}
private getRemoteCategoryForNote(note: Note): string {
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;
}
private getRemoteFilenameForNote(note: Note): string {
if (note.filename) {
return note.filename;
}
if (note.path) {
return note.path.split('/').pop() || 'note.md';
}
if (typeof note.id === 'string') {
return note.id.split('/').pop() || 'note.md';
}
return 'note.md';
}
private buildNoteWebDAVPath(category: string, filename: string): string {
return `${this.buildCategoryWebDAVPath(category)}/${encodeURIComponent(filename)}`;
}
private buildRelativeWebDAVPath(...segments: string[]): string {
return segments
.flatMap((segment) => segment.split('/'))
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
}
private buildAttachmentWebDAVPath(noteCategory: string | undefined, ...relativeSegments: string[]): string {
const relativePath = this.buildRelativeWebDAVPath(...relativeSegments);
return relativePath
? `${this.buildCategoryWebDAVPath(noteCategory || '')}/${relativePath}`
: this.buildCategoryWebDAVPath(noteCategory || '');
}
private noteHasLocalAttachments(note: Note): boolean {
return note.content.includes('.attachments.');
}
private async ensureCategoryDirectoryExists(category: string): Promise<void> {
if (!category) {
return;
}
const parts = category.split('/').filter(Boolean);
for (let index = 0; index < parts.length; index += 1) {
const currentCategory = parts.slice(0, index + 1).join('/');
const categoryUrl = `${this.serverURL}${this.buildCategoryWebDAVPath(currentCategory)}`;
const response = await runtimeFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
if (!response.ok && response.status !== 405) {
throw createHttpStatusError(`Failed to create category folder: ${response.status}`, response.status);
}
}
}
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[]> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
const webdavPath = this.buildNotesRootWebDAVPath();
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
@@ -356,12 +514,11 @@ export class NextcloudAPI {
}
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
const webdavPath = this.buildNoteWebDAVPath(note.category, filename);
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
headers: { 'Authorization': this.authHeader },
});
@@ -375,26 +532,17 @@ export class NextcloudAPI {
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`;
const categoryPath = category ? `/${category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
const webdavPath = this.buildNoteWebDAVPath(category, filename);
const url = `${this.serverURL}${webdavPath}`;
// Ensure category directory exists
if (category) {
try {
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
await tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist
}
await this.ensureCategoryDirectoryExists(category);
}
const noteContent = `${title}\n${content}`;
const noteContent = content ? `${title}\n${content}` : title;
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
@@ -416,7 +564,7 @@ export class NextcloudAPI {
path: category ? `${category}/${filename}` : filename,
etag,
readonly: false,
content,
content: noteContent,
title,
category,
favorite: false,
@@ -437,21 +585,42 @@ export class NextcloudAPI {
// Rename the file first, then update content
const renamedNote = await this.renameNoteWebDAV(note, newFilename);
// Now update the content of the renamed file
return this.updateNoteContentWebDAV(renamedNote);
return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote));
} else {
// Just update content
return this.updateNoteContentWebDAV(note);
return this.updateNoteContentWithRetryWebDAV(note);
}
}
private async updateNoteContentWithRetryWebDAV(note: Note, maxRetries = 2): Promise<Note> {
let currentNote = note;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
return await this.updateNoteContentWebDAV(currentNote);
} catch (error) {
const status = getHttpStatus(error);
const canRetry = status === 412 || status === 423;
if (!canRetry || attempt === maxRetries) {
throw error;
}
await this.delay(150 * (attempt + 1));
currentNote = await this.refreshNoteWebDAVMetadata(currentNote);
}
}
return this.updateNoteContentWebDAV(currentNote);
}
private async updateNoteContentWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const url = `${this.serverURL}${webdavPath}`;
const noteContent = this.formatNoteContent(note);
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
@@ -463,9 +632,12 @@ export class NextcloudAPI {
if (!response.ok && response.status !== 204) {
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;
@@ -478,23 +650,39 @@ export class NextcloudAPI {
}
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`;
const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const newPath = this.buildNoteWebDAVPath(note.category, newFilename);
const response = await tauriFetch(`${this.serverURL}${oldPath}`, {
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) {
throw new Error(`Failed to rename note: ${response.status}`);
if (response.status === 404) {
const existingMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
if (existingMetadata) {
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
return {
...note,
id: newId,
filename: newFilename,
path: note.category ? `${note.category}/${newFilename}` : newFilename,
etag: existingMetadata.etag || note.etag,
modified: existingMetadata.modified || Math.floor(Date.now() / 1000),
};
}
}
// Also rename attachment folder if it exists
throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status);
}
if (this.noteHasLocalAttachments(note)) {
// Also rename attachment folder if the note references local attachments
const oldNoteIdStr = String(note.id);
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
@@ -505,11 +693,11 @@ export class NextcloudAPI {
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}`;
const oldAttachmentPath = this.buildAttachmentWebDAVPath(note.category, oldAttachmentFolder);
const newAttachmentPath = this.buildAttachmentWebDAVPath(note.category, newAttachmentFolder);
try {
await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, {
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
@@ -519,7 +707,9 @@ export class NextcloudAPI {
} 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 {
@@ -527,50 +717,37 @@ export class NextcloudAPI {
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> {
const categoryPath = note.category ? `/${note.category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'DELETE',
headers: { 'Authorization': this.authHeader },
});
if (!response.ok && response.status !== 204) {
throw new Error(`Failed to delete note: ${response.status}`);
if (!response.ok && response.status !== 204 && response.status !== 404) {
throw createHttpStatusError(`Failed to delete note: ${response.status}`, response.status);
}
}
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
const oldCategoryPath = note.category ? `/${note.category}` : '';
const newCategoryPath = newCategory ? `/${newCategory}` : '';
const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${encodeURIComponent(note.filename!)}`;
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`;
const remoteCategory = this.getRemoteCategoryForNote(note);
const remoteFilename = this.getRemoteFilenameForNote(note);
const oldPath = this.buildNoteWebDAVPath(remoteCategory, remoteFilename);
const newPath = this.buildNoteWebDAVPath(newCategory, remoteFilename);
// Ensure new category directory exists (including nested subdirectories)
if (newCategory) {
const parts = newCategory.split('/');
let currentPath = '';
for (const part of parts) {
currentPath += (currentPath ? '/' : '') + part;
try {
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`;
await tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist, continue
}
}
await this.ensureCategoryDirectoryExists(newCategory);
}
const response = await tauriFetch(`${this.serverURL}${oldPath}`, {
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
@@ -579,25 +756,31 @@ export class NextcloudAPI {
});
if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error(`Failed to move note: ${response.status}`);
const details = await response.text().catch(() => '');
const detailSuffix = details ? ` - ${details.slice(0, 300)}` : '';
throw createHttpStatusError(
`Failed to move note: ${response.status}${detailSuffix}. Source: ${oldPath}. Destination: ${newPath}`,
response.status,
);
}
// Move attachment folder if it exists
if (this.noteHasLocalAttachments(note)) {
// Move attachment folder only when the note references local attachments
const noteIdStr = String(note.id);
const justFilename = noteIdStr.split('/').pop() || noteIdStr;
const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, '');
const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const attachmentFolder = `.attachments.${sanitizedNoteId}`;
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${attachmentFolder}`;
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${attachmentFolder}`;
const oldAttachmentPath = this.buildAttachmentWebDAVPath(remoteCategory, attachmentFolder);
const newAttachmentPath = this.buildAttachmentWebDAVPath(newCategory, attachmentFolder);
console.log(`Attempting to move attachment folder:`);
console.log(` From: ${oldAttachmentPath}`);
console.log(` To: ${newAttachmentPath}`);
try {
const attachmentResponse = await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, {
const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
@@ -615,12 +798,14 @@ export class NextcloudAPI {
} catch (e) {
console.log(`✗ Error moving attachment folder:`, e);
}
}
return {
...note,
category: newCategory,
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '',
id: newCategory ? `${newCategory}/${note.filename}` : (note.filename || ''),
filename: remoteFilename,
path: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename,
id: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename,
};
}
}

View File

@@ -3,6 +3,8 @@ import { useEffect, useState, useRef, RefObject } from 'react';
interface InsertToolbarProps {
textareaRef: RefObject<HTMLTextAreaElement | null>;
onInsertLink: (text: string, url: string) => void;
onInsertTodoItem: () => void;
onInsertTable: () => void;
onInsertFile: () => void;
isUploading?: boolean;
}
@@ -13,7 +15,7 @@ interface LinkModalState {
url: string;
}
export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploading }: InsertToolbarProps) {
export function InsertToolbar({ textareaRef, onInsertLink, onInsertTodoItem, onInsertTable, onInsertFile, isUploading }: InsertToolbarProps) {
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [linkModal, setLinkModal] = useState<LinkModalState>({ isOpen: false, text: '', url: '' });
@@ -58,7 +60,7 @@ export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploa
const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth) + 20;
// Keep toolbar within viewport
const toolbarWidth = 100;
const toolbarWidth = 196;
const adjustedLeft = Math.min(left, window.innerWidth - toolbarWidth - 20);
let adjustedTop = top - 16; // Center vertically with cursor line
@@ -137,6 +139,16 @@ export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploa
setIsVisible(false);
};
const handleTodoClick = () => {
onInsertTodoItem();
setIsVisible(false);
};
const handleTableClick = () => {
onInsertTable();
setIsVisible(false);
};
if (!isVisible || !position) return null;
// Link Modal
@@ -218,6 +230,28 @@ export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploa
</svg>
</button>
<button
onClick={handleTodoClick}
className="p-2 rounded hover:bg-gray-700 dark:hover:bg-gray-600 text-white transition-colors"
title="Insert To-Do Item"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h11M9 12h11M9 17h11M4 7h.01M4 12h.01M4 17h.01" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m3.5 12.5 1.5 1.5 3-3" />
</svg>
</button>
<button
onClick={handleTableClick}
className="p-2 rounded hover:bg-gray-700 dark:hover:bg-gray-600 text-white transition-colors"
title="Insert Table"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5h16v14H4V5Z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 10h16M10 5v14" />
</svg>
</button>
<button
onClick={handleFileClick}
disabled={isUploading}

View File

@@ -1,17 +1,27 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { marked } from 'marked';
import jsPDF from 'jspdf';
import { message } from '@tauri-apps/plugin-dialog';
import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud';
import { FloatingToolbar } from './FloatingToolbar';
import { InsertToolbar } from './InsertToolbar';
import {
exportPdfDocument,
getDesktopRuntime,
showDesktopMessage,
} from '../services/desktop';
import {
getNoteTitleFromContent,
loadPrintFontFaceCss,
PrintExportPayload,
sanitizeFileName,
} from '../printExport';
interface NoteEditorProps {
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;
onUnsavedChanges?: (hasChanges: boolean) => void;
categories: string[];
isFocusMode?: boolean;
onToggleFocusMode?: () => void;
@@ -24,26 +34,101 @@ interface NoteEditorProps {
const imageCache = new Map<string, string>();
const TASK_LIST_ITEM_REGEX = /^(\s*(?:[-+*]|\d+\.)\s)\[( |x|X)\]\s/;
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
// Configure marked to support task lists
marked.use({
gfm: true,
breaks: true,
});
function getTaskListMarkers(markdown: string) {
const markers: Array<{ markerStart: number; markerEnd: number }> = [];
const lines = markdown.split('\n');
let offset = 0;
let activeFence: string | null = null;
lines.forEach((line, index) => {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const fence = fenceMatch[1];
if (!activeFence) {
activeFence = fence;
} else if (fence[0] === activeFence[0] && fence.length >= activeFence.length) {
activeFence = null;
}
} else if (!activeFence) {
const taskMatch = line.match(TASK_LIST_ITEM_REGEX);
if (taskMatch) {
const markerStart = offset + taskMatch[1].length;
markers.push({
markerStart,
markerEnd: markerStart + 3,
});
}
}
offset += line.length;
if (index < lines.length - 1) {
offset += 1;
}
});
return markers;
}
function toggleTaskListMarker(markdown: string, taskIndex: number, checked: boolean) {
const taskMarker = getTaskListMarkers(markdown)[taskIndex];
if (!taskMarker) {
return markdown;
}
const nextMarker = checked ? '[x]' : '[ ]';
return `${markdown.slice(0, taskMarker.markerStart)}${nextMarker}${markdown.slice(taskMarker.markerEnd)}`;
}
function renderPreviewHtml(markdown: string) {
const parsedHtml = marked.parse(markdown || '', { async: false }) as string;
const documentFragment = new DOMParser().parseFromString(parsedHtml, 'text/html');
documentFragment.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((input, index) => {
input.removeAttribute('disabled');
input.classList.add('markdown-task-checkbox');
input.setAttribute('data-task-index', String(index));
input.setAttribute('aria-label', `Toggle task ${index + 1}`);
});
documentFragment.querySelectorAll('table').forEach((table) => {
const wrapper = documentFragment.createElement('div');
wrapper.className = 'markdown-table-wrapper';
table.parentNode?.insertBefore(wrapper, table);
wrapper.appendChild(table);
});
return documentFragment.body.innerHTML;
}
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 [localCategory, setLocalCategory] = useState('');
const [localFavorite, setLocalFavorite] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [processedContent, setProcessedContent] = useState('');
const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const previousNoteIdRef = useRef<number | string | null>(null);
const previousNoteContentRef = useRef<string>('');
const previousDraftIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorScrollContainerRef = useRef<HTMLDivElement>(null);
const previewContentRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
onUnsavedChanges?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChanges]);
const pendingScrollTopRef = useRef<number | null>(null);
const desktopRuntime = getDesktopRuntime();
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
const hasUnsavedChanges = Boolean(note?.pendingSave);
const isSaving = Boolean(note?.isSaving);
const saveError = note?.saveError;
const hasSavedState = Boolean(note?.lastSavedAt) && !hasUnsavedChanges && !isSaving && !saveError;
// Handle Escape key to exit focus mode
useEffect(() => {
@@ -57,26 +142,39 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isFocusMode, onToggleFocusMode]);
// Auto-resize textarea when content changes, switching from preview to edit, or font size changes
useEffect(() => {
if (textareaRef.current && !isPreviewMode) {
// Use setTimeout to ensure DOM has updated
setTimeout(() => {
if (textareaRef.current) {
// Save cursor position and scroll position
const cursorPosition = textareaRef.current.selectionStart;
const scrollTop = textareaRef.current.scrollTop;
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
// Restore cursor position and scroll position
textareaRef.current.setSelectionRange(cursorPosition, cursorPosition);
textareaRef.current.scrollTop = scrollTop;
const captureEditorScrollPosition = () => {
if (editorScrollContainerRef.current) {
pendingScrollTopRef.current = editorScrollContainerRef.current.scrollTop;
}
}, 0);
};
// Keep the editor pane anchored when the textarea grows with new content.
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || isPreviewMode) {
pendingScrollTopRef.current = null;
return;
}
}, [localContent, isPreviewMode, editorFontSize]);
const selectionStart = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;
const shouldRestoreSelection = document.activeElement === textarea;
const scrollContainer = editorScrollContainerRef.current;
const scrollTop = pendingScrollTopRef.current ?? scrollContainer?.scrollTop ?? null;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
if (scrollContainer && scrollTop !== null) {
scrollContainer.scrollTop = scrollTop;
}
if (shouldRestoreSelection) {
textarea.setSelectionRange(selectionStart, selectionEnd);
}
pendingScrollTopRef.current = null;
}, [localContent, isPreviewMode, editorFontSize, note?.draftId]);
// Process images when entering preview mode or content changes
useEffect(() => {
@@ -87,8 +185,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
// Guard: Only process if localContent has been updated for the current note
// This prevents processing stale content from the previous note
if (previousNoteIdRef.current !== note.id) {
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`);
if (previousDraftIdRef.current !== note.draftId) {
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousDraftIdRef: ${previousDraftIdRef.current})`);
return;
}
@@ -134,93 +232,87 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
};
processImages();
}, [isPreviewMode, localContent, note?.id, api]);
}, [isPreviewMode, localContent, note?.draftId, note?.id, api]);
useEffect(() => {
const loadNewNote = () => {
if (note) {
setLocalContent(note.content);
setLocalCategory(note.category || '');
setLocalFavorite(note.favorite);
setHasUnsavedChanges(false);
previousNoteIdRef.current = note.id;
previousNoteContentRef.current = note.content;
if (!note) {
setLocalContent('');
setLocalCategory('');
setLocalFavorite(false);
previousDraftIdRef.current = null;
return;
}
if (previousDraftIdRef.current !== note.draftId) {
setProcessedContent('');
previousDraftIdRef.current = note.draftId ?? null;
}
if (note.content !== localContent) {
setLocalContent(note.content);
}
if ((note.category || '') !== localCategory) {
setLocalCategory(note.category || '');
}
if (note.favorite !== localFavorite) {
setLocalFavorite(note.favorite);
}
}, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]);
useEffect(() => {
const previewElement = previewContentRef.current;
if (!previewElement || !isPreviewMode) {
return;
}
const handleTaskToggle = (event: Event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
return;
}
const taskIndex = Number(target.dataset.taskIndex);
if (Number.isNaN(taskIndex)) {
return;
}
const newContent = toggleTaskListMarker(localContent, taskIndex, target.checked);
if (newContent === localContent) {
return;
}
setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
};
// Switching to a different note
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
setProcessedContent('');
if (hasUnsavedChanges) {
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) {
loadNewNote();
}
// Favorite status changed (e.g., from sync)
else if (note && note.favorite !== localFavorite) {
setLocalFavorite(note.favorite);
}
}, [note?.id, note?.content, note?.modified, note?.favorite]);
previewElement.addEventListener('change', handleTaskToggle);
return () => previewElement.removeEventListener('change', handleTaskToggle);
}, [isPreviewMode, localContent, localCategory, localFavorite]);
const handleSave = () => {
if (!note || !hasUnsavedChanges) return;
const emitNoteChange = (content: string, category: string, favorite: boolean) => {
if (!note) {
return;
}
console.log('Saving note content length:', localContent.length);
console.log('Last 50 chars:', localContent.slice(-50));
setIsSaving(true);
setHasUnsavedChanges(false);
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({
onChangeNote({
...note,
title,
content: localContent,
category: localCategory,
favorite: localFavorite,
title: getNoteTitleFromContent(content),
content,
category,
favorite,
});
setTimeout(() => setIsSaving(false), 500);
};
const handleContentChange = (value: string) => {
captureEditorScrollPosition();
setLocalContent(value);
setHasUnsavedChanges(true);
emitNoteChange(value, localCategory, localFavorite);
};
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> => {
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);
});
onDiscardNote(note.draftId);
};
const handleExportPDF = async () => {
@@ -229,66 +321,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setIsExportingPDF(true);
try {
// Create PDF
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;
let contentForPrint = localContent;
if (api) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...localContent.matchAll(imageRegex)];
@@ -305,100 +338,46 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const cacheKey = `${note.id}:${imagePath}`;
if (imageCache.has(cacheKey)) {
const dataUrl = imageCache.get(cacheKey)!;
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
continue;
}
try {
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
imageCache.set(cacheKey, dataUrl);
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
} catch (error) {
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
}
}
}
const container = document.createElement('div');
container.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.style.fontSize = `${previewFontSize}px`;
container.style.lineHeight = '1.6';
container.style.color = '#000000';
const title = getNoteTitleFromContent(localContent);
const fileName = `${sanitizeFileName(title)}.pdf`;
const noteHtml = marked.parse(contentForPrint || '', { async: false }) as string;
const payload: PrintExportPayload = {
fileName,
title,
html: noteHtml,
previewFont,
previewFontSize,
previewFontFaceCss: await loadPrintFontFaceCss(previewFont),
};
const titleElement = document.createElement('h1');
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
titleElement.textContent = firstLine || 'Untitled';
titleElement.style.marginTop = '0';
titleElement.style.marginBottom = '20px';
titleElement.style.fontSize = '24px';
titleElement.style.fontWeight = 'bold';
titleElement.style.color = '#000000';
titleElement.style.textAlign = 'center';
titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.appendChild(titleElement);
const contentElement = document.createElement('div');
const html = marked.parse(contentForPDF || '', { async: false }) as string;
contentElement.innerHTML = html;
contentElement.style.fontSize = `${previewFontSize}px`;
contentElement.style.lineHeight = '1.6';
contentElement.style.color = '#000000';
container.appendChild(contentElement);
const style = document.createElement('style');
style.textContent = `
body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; }
code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
code { padding: 0; }
h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; }
h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; }
h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; }
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; }
em { font-style: italic; vertical-align: baseline; }
strong { font-weight: bold; vertical-align: baseline; line-height: inherit; }
img { max-width: 100%; height: auto; display: block; margin: 1em 0; }
`;
container.appendChild(style);
// Use jsPDF's html() method with custom font set
await pdf.html(container, {
callback: async (doc) => {
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const fileName = `${firstLine || 'note'}.pdf`;
doc.save(fileName);
setTimeout(async () => {
try {
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
title: 'Export Complete',
kind: 'info',
});
} catch (err) {
console.log('Dialog shown successfully or not available');
}
setIsExportingPDF(false);
}, 500);
},
margin: [20, 20, 20, 20],
autoPaging: 'text',
width: 170,
windowWidth: 650,
await exportPdfDocument({
...payload,
});
} catch (error) {
console.error('PDF export failed:', error);
try {
await message('Failed to export PDF. Please try again.', {
await showDesktopMessage('Failed to export the PDF. Please try again.', {
title: 'Export Failed',
kind: 'error',
});
} catch (err) {
console.error('Could not show error dialog');
}
} finally {
setIsExportingPDF(false);
}
};
@@ -408,26 +387,13 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setLocalFavorite(newFavorite);
if (note && onToggleFavorite) {
// Use dedicated favorite toggle callback if provided
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) => {
setLocalCategory(category);
setHasUnsavedChanges(true);
emitNoteChange(localContent, category, localFavorite);
};
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -447,10 +413,11 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
// Insert at cursor position or end of content
const textarea = textareaRef.current;
if (textarea) {
captureEditorScrollPosition();
const cursorPos = textarea.selectionStart;
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
setLocalContent(newContent);
setHasUnsavedChanges(true);
emitNoteChange(newContent, localCategory, localFavorite);
// Move cursor after inserted text
setTimeout(() => {
@@ -460,17 +427,18 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
}, 0);
} else {
// Append to end
setLocalContent(localContent + '\n' + markdownLink);
setHasUnsavedChanges(true);
const newContent = `${localContent}\n${markdownLink}`;
setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
}
await message(`Attachment uploaded successfully!`, {
await showDesktopMessage('Attachment uploaded successfully!', {
title: 'Upload Complete',
kind: 'info',
});
} catch (error) {
console.error('Upload failed:', error);
await message(`Failed to upload attachment: ${error}`, {
await showDesktopMessage(`Failed to upload attachment: ${error}`, {
title: 'Upload Failed',
kind: 'error',
});
@@ -487,11 +455,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const textarea = textareaRef.current;
if (!textarea) return;
captureEditorScrollPosition();
const cursorPos = textarea.selectionStart;
const markdownLink = `[${text}](${url})`;
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
setLocalContent(newContent);
setHasUnsavedChanges(true);
emitNoteChange(newContent, localCategory, localFavorite);
setTimeout(() => {
textarea.focus();
@@ -504,6 +473,44 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
fileInputRef.current?.click();
};
const insertBlockSnippet = (snippet: string, selectionStartOffset: number, selectionEndOffset: number) => {
const textarea = textareaRef.current;
const cursorPos = textarea?.selectionStart ?? localContent.length;
const needsLeadingNewline = cursorPos > 0 && localContent[cursorPos - 1] !== '\n';
const needsTrailingNewline = cursorPos < localContent.length && localContent[cursorPos] !== '\n';
const prefix = needsLeadingNewline ? '\n' : '';
const suffix = needsTrailingNewline ? '\n' : '';
const insertedText = `${prefix}${snippet}${suffix}`;
const newContent = `${localContent.slice(0, cursorPos)}${insertedText}${localContent.slice(cursorPos)}`;
captureEditorScrollPosition();
setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
if (!textarea) {
return;
}
setTimeout(() => {
textarea.focus();
const selectionStart = cursorPos + prefix.length + selectionStartOffset;
const selectionEnd = cursorPos + prefix.length + selectionEndOffset;
textarea.setSelectionRange(selectionStart, selectionEnd);
}, 0);
};
const handleInsertTodoItem = () => {
const snippet = '- [ ] Task';
const placeholderStart = snippet.indexOf('Task');
insertBlockSnippet(snippet, placeholderStart, placeholderStart + 'Task'.length);
};
const handleInsertTable = () => {
const snippet = '| Column 1 | Column 2 |\n| --- | --- |\n| Value 1 | Value 2 |';
const placeholderStart = snippet.indexOf('Column 1');
insertBlockSnippet(snippet, placeholderStart, placeholderStart + 'Column 1'.length);
};
const handleFormat = (format: 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3') => {
if (!textareaRef.current) return;
@@ -630,8 +637,9 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
}
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
captureEditorScrollPosition();
setLocalContent(newContent);
setHasUnsavedChanges(true);
emitNoteChange(newContent, localCategory, localFavorite);
setTimeout(() => {
textarea.focus();
@@ -717,13 +725,17 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
<div className="flex items-center gap-2">
{/* Status */}
{(hasUnsavedChanges || isSaving) && (
{(hasUnsavedChanges || isSaving || saveError || hasSavedState) && (
<span className={`text-xs px-2 py-1 rounded-full ${
isSaving
saveError
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: isSaving
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-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>
)}
@@ -745,8 +757,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</button>
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || isSaving}
onClick={() => {
if (note?.draftId) {
void onSaveNote(note.draftId);
}
}}
disabled={!hasUnsavedChanges || isSaving || !note?.draftId}
className={`p-1.5 rounded-lg transition-colors ${
hasUnsavedChanges && !isSaving
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
@@ -782,16 +798,26 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
? 'text-blue-500 cursor-wait'
: '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 ? (
<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>
<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>
) : 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">
<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>
)}
</button>
@@ -823,7 +849,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div ref={editorScrollContainerRef} className="flex-1 overflow-y-auto">
<div className={`min-h-full ${isFocusMode ? 'max-w-3xl mx-auto w-full' : ''}`}>
{isPreviewMode ? (
<div className="relative">
@@ -837,10 +863,11 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</div>
)}
<div
ref={previewContentRef}
className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono [&_code]:py-0 [&_code]:px-1 [&_code]:align-baseline [&_code]:leading-none [&_img]:max-w-full [&_img]:rounded-lg [&_img]:shadow-md`}
style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }}
dangerouslySetInnerHTML={{
__html: marked.parse(processedContent || '', { async: false }) as string
__html: renderPreviewHtml(processedContent || '')
}}
/>
</div>
@@ -850,6 +877,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
<InsertToolbar
textareaRef={textareaRef}
onInsertLink={handleInsertLink}
onInsertTodoItem={handleInsertTodoItem}
onInsertTable={handleInsertTable}
onInsertFile={handleInsertFile}
isUploading={isUploading}
/>
@@ -863,12 +892,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
<textarea
ref={textareaRef}
value={localContent}
onChange={(e) => {
handleContentChange(e.target.value);
// Auto-resize textarea to fit content
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
onChange={(e) => handleContentChange(e.target.value)}
className="w-full resize-none border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 overflow-hidden"
style={{ fontSize: `${editorFontSize}px`, lineHeight: '1.6', minHeight: '100%', fontFamily: editorFont }}
placeholder="Start writing in markdown..."

View File

@@ -5,8 +5,8 @@ import { categoryColorsSync } from '../services/categoryColorsSync';
interface NotesListProps {
notes: Note[];
selectedNoteId: number | string | null;
onSelectNote: (id: number | string) => void;
selectedNoteDraftId: string | null;
onSelectNote: (draftId: string) => void | Promise<void>;
onCreateNote: () => void;
onDeleteNote: (note: Note) => void;
onSync: () => void;
@@ -22,7 +22,7 @@ interface NotesListProps {
export function NotesList({
notes,
selectedNoteId,
selectedNoteDraftId,
onSelectNote,
onCreateNote,
onDeleteNote,
@@ -93,11 +93,6 @@ export function NotesList({
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
e.stopPropagation();
// Prevent deletion if there are unsaved changes on a different note
if (hasUnsavedChanges && note.id !== selectedNoteId) {
return;
}
if (deleteClickedId === note.id) {
// Second click - actually delete
onDeleteNote(note);
@@ -249,22 +244,18 @@ export function NotesList({
) : (
notes.map((note) => (
<div
key={note.id}
key={note.draftId ?? note.id}
onClick={() => {
// Prevent switching if current note has unsaved changes
if (hasUnsavedChanges && note.id !== selectedNoteId) {
return;
if (note.draftId) {
void onSelectNote(note.draftId);
}
onSelectNote(note.id);
}}
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'
: hasUnsavedChanges
? '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-center flex-1 min-w-0">

View File

@@ -0,0 +1,388 @@
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 ul:has(> li > input[type="checkbox"]) {
list-style: none;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"]) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.55em;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"])::marker {
content: '';
}
.print-note li > input[type="checkbox"] {
flex-shrink: 0;
margin: 0.3em 0 0;
}
.print-note blockquote {
margin: 1.15em 0;
padding-left: 1em;
border-left: 3px solid #cbd5e1;
color: #475569;
font-style: italic;
}
.print-note blockquote > :first-child {
margin-top: 0;
}
.print-note blockquote > :last-child {
margin-bottom: 0;
}
.print-note code {
font-family: "Source Code Pro", "Courier New", monospace;
font-size: 0.92em;
}
.print-note :not(pre) > code {
padding: 0.08em 0.28em;
border-radius: 4px;
border: 1px solid #dbe4f0;
background: #f8fafc;
color: #1e293b;
}
.print-note pre {
margin: 1em 0 1.15em;
padding: 0.9em 1em;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #0f172a;
white-space: pre-wrap;
overflow-wrap: anywhere;
page-break-inside: avoid;
}
.print-note pre code {
font-size: 0.92em;
line-height: 1.55;
}
.print-note a {
color: #1d4ed8;
text-decoration: underline;
}
.print-note img {
display: block;
max-width: 100%;
height: auto;
margin: 1.25em auto;
border-radius: 10px;
page-break-inside: avoid;
}
.print-note hr {
border: 0;
border-top: 1px solid #cbd5e1;
margin: 1.6em 0;
}
`}</style>
<div
className="print-note"
dangerouslySetInnerHTML={{ __html: payload.html }}
/>
</article>
</div>
</>
);
}

View File

@@ -213,3 +213,39 @@ code {
.dark .ProseMirror hr {
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: pointer;
flex-shrink: 0;
}
.prose input[type="checkbox"]:checked {
accent-color: #16a34a;
}
.prose .markdown-table-wrapper {
overflow-x: auto;
margin: 1.5em 0;
}
.prose .markdown-table-wrapper table {
margin: 0;
min-width: 100%;
}

391
src/printExport.ts Normal file
View File

@@ -0,0 +1,391 @@
export interface PrintExportPayload {
fileName: string;
title: string;
html: string;
previewFont: string;
previewFontSize: number;
previewFontFaceCss?: string;
}
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
const PRINT_DOCUMENT_CSP = [
"default-src 'none'",
"style-src 'unsafe-inline'",
"img-src data: blob: https: http:",
"font-src data:",
"object-src 'none'",
"base-uri 'none'",
].join('; ');
export const getNoteTitleFromContent = (content: string) => {
const firstLine = content
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return (firstLine || 'Untitled').replace(/^#+\s*/, '').trim();
};
export const sanitizeFileName = (name: string) =>
name
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, ' ')
.trim() || 'note';
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escapeFontFamily = (value: string) =>
value.replace(/["\\]/g, '\\$&');
interface PrintFontAsset {
fileName: string;
fontStyle: 'normal' | 'italic';
fontWeight: string;
}
const PRINT_FONT_ASSETS: Record<string, PrintFontAsset[]> = {
Merriweather: [
{
fileName: 'Merriweather-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '300 900',
},
{
fileName: 'Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '300 900',
},
],
'Crimson Pro': [
{
fileName: 'CrimsonPro-VariableFont_wght.ttf',
fontStyle: 'normal',
fontWeight: '200 900',
},
{
fileName: 'CrimsonPro-Italic-VariableFont_wght.ttf',
fontStyle: 'italic',
fontWeight: '200 900',
},
],
'Roboto Serif': [
{
fileName: 'RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '100 900',
},
{
fileName: 'RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '100 900',
},
],
Average: [
{
fileName: 'Average-Regular.ttf',
fontStyle: 'normal',
fontWeight: '400',
},
],
};
const fontDataUrlCache = new Map<string, Promise<string>>();
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error('Failed to read font file.'));
reader.readAsDataURL(blob);
});
const getBundledFontUrl = (fileName: string) =>
new URL(`./fonts/${fileName}`, window.location.href).toString();
const loadBundledFontDataUrl = async (fileName: string) => {
const cached = fontDataUrlCache.get(fileName);
if (cached) {
return cached;
}
const pending = (async () => {
const response = await fetch(getBundledFontUrl(fileName));
if (!response.ok) {
throw new Error(`Failed to load bundled font ${fileName}: ${response.status}`);
}
return blobToDataUrl(await response.blob());
})();
fontDataUrlCache.set(fileName, pending);
return pending;
};
export const loadPrintFontFaceCss = async (fontFamily: string) => {
const fontAssets = PRINT_FONT_ASSETS[fontFamily];
if (!fontAssets) {
return '';
}
const rules = await Promise.all(
fontAssets.map(async ({ fileName, fontStyle, fontWeight }) => {
try {
const dataUrl = await loadBundledFontDataUrl(fileName);
return `@font-face {
font-family: "${escapeFontFamily(fontFamily)}";
font-style: ${fontStyle};
font-weight: ${fontWeight};
font-display: swap;
src: url("${dataUrl}") format("truetype");
}`;
} catch (error) {
console.error(`Failed to embed preview font "${fontFamily}" from ${fileName}:`, error);
return '';
}
})
);
return rules.filter(Boolean).join('\n');
};
export const buildPrintDocument = (payload: PrintExportPayload) => {
const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="${PRINT_DOCUMENT_CSP}" />
<title>${escapeHtml(payload.fileName)}</title>
<style>
${payload.previewFontFaceCss ?? ''}
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: #ffffff;
color: #0f172a;
}
body {
font-family: ${fontFamily};
font-size: ${payload.previewFontSize}px;
line-height: 1.7;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
size: A4;
margin: 18mm 16mm 18mm 16mm;
}
article {
color: #0f172a;
}
.print-note h1,
.print-note h2,
.print-note h3 {
color: #020617;
font-weight: 700;
page-break-after: avoid;
}
.print-note h1 {
font-size: 2em;
line-height: 1.15;
margin: 0 0 1.35em;
letter-spacing: -0.015em;
text-align: center;
}
.print-note h2 {
font-size: 1.55em;
line-height: 1.2;
margin: 1.25em 0 0.45em;
}
.print-note h3 {
font-size: 1.25em;
line-height: 1.3;
margin: 1.1em 0 0.4em;
}
.print-note p {
margin: 0 0 0.9em;
}
.print-note ul,
.print-note ol {
margin: 0.75em 0 1em;
padding-left: 1.7em;
list-style-position: outside;
}
.print-note ul {
list-style-type: disc;
}
.print-note ol {
list-style-type: decimal;
}
.print-note li {
margin: 0.28em 0;
padding-left: 0.18em;
}
.print-note li > p {
margin: 0;
}
.print-note li::marker {
color: #334155;
}
.print-note ul:has(> li > input[type="checkbox"]) {
list-style: none;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"]) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.55em;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"])::marker {
content: '';
}
.print-note li > input[type="checkbox"] {
flex-shrink: 0;
margin: 0.3em 0 0;
}
.print-note blockquote {
margin: 1.15em 0;
padding-left: 1em;
border-left: 3px solid #cbd5e1;
color: #475569;
font-style: italic;
}
.print-note blockquote > :first-child {
margin-top: 0;
}
.print-note blockquote > :last-child {
margin-bottom: 0;
}
.print-note pre {
margin: 1.15em 0 1.3em;
padding: 0.95em 1.05em;
overflow-x: auto;
border: 1px solid #dbe4f0;
border-radius: 12px;
background: #f5f7fb;
color: #0f172a;
page-break-inside: avoid;
}
.print-note pre code {
background: transparent;
border: 0;
padding: 0;
border-radius: 0;
font-size: 0.92em;
color: inherit;
}
.print-note code {
font-family: "SFMono-Regular", "SF Mono", "JetBrains Mono", "Fira Code", "Source Code Pro", Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 0.08em 0.38em;
border: 1px solid #dbe4f0;
border-radius: 0.42em;
background: #f5f7fb;
color: #0f172a;
}
.print-note a {
color: #2563eb;
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.14em;
}
.print-note strong {
font-weight: 700;
}
.print-note em {
font-style: italic;
}
.print-note del {
text-decoration: line-through;
}
.print-note hr {
border: 0;
border-top: 1px solid #cbd5e1;
margin: 1.6em 0;
}
.print-note img {
display: block;
max-width: 100%;
height: auto;
margin: 1.2em auto;
border-radius: 12px;
page-break-inside: avoid;
}
.print-note table {
width: 100%;
border-collapse: collapse;
margin: 1em 0 1.2em;
font-size: 0.95em;
}
.print-note th,
.print-note td {
border: 1px solid #dbe4f0;
padding: 0.5em 0.65em;
text-align: left;
vertical-align: top;
}
.print-note th {
background: #f8fafc;
font-weight: 600;
}
</style>
</head>
<body>
<article class="print-note">${payload.html}</article>
</body>
</html>`;
};

139
src/services/desktop.ts Normal file
View File

@@ -0,0 +1,139 @@
import {
buildPrintDocument,
PrintExportPayload,
PRINT_EXPORT_QUERY_PARAM,
} from '../printExport';
export interface DesktopMessageOptions {
title?: string;
kind?: 'info' | 'warning' | 'error';
}
interface ExportPdfResult {
canceled: boolean;
filePath?: string;
}
const isElectronRuntime = () =>
typeof window !== 'undefined' && typeof window.electronDesktop !== 'undefined';
const isTauriRuntime = () =>
typeof window !== 'undefined' &&
(
typeof (window as Window & { __TAURI__?: unknown }).__TAURI__ !== 'undefined' ||
typeof (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== 'undefined'
);
export const getDesktopRuntime = () => {
if (isElectronRuntime()) return 'electron';
if (isTauriRuntime()) return 'tauri';
return 'browser';
};
export const showDesktopMessage = async (
messageText: string,
options: DesktopMessageOptions = {}
) => {
if (window.electronDesktop) {
await window.electronDesktop.showMessage({
message: messageText,
...options,
});
return;
}
if (isTauriRuntime()) {
const { message } = await import('@tauri-apps/plugin-dialog');
await message(messageText, options);
return;
}
window.alert(messageText);
};
export const exportPdfDocument = async (
payload: PrintExportPayload
): Promise<ExportPdfResult> => {
if (window.electronDesktop) {
return window.electronDesktop.exportPdf({
...payload,
documentHtml: buildPrintDocument(payload),
});
}
if (isTauriRuntime()) {
await exportPdfDocumentWithTauri(payload);
return { canceled: false };
}
throw new Error('PDF export is only available in a desktop runtime.');
};
async function exportPdfDocumentWithTauri(payload: PrintExportPayload) {
const [{ emitTo, listen }, { WebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
]);
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const windowLabel = `print-export-${jobId}`;
let printWindow: any = null;
let unlistenReady: any = null;
try {
await new Promise<void>((resolve, reject) => {
let settled = false;
const timeoutId = window.setTimeout(() => {
if (settled) return;
settled = true;
unlistenReady?.();
reject(new Error('Print view initialization timed out.'));
}, 10000);
listen<{ jobId: string }>('print-export-ready', (event) => {
if (settled || event.payload.jobId !== jobId) return;
settled = true;
window.clearTimeout(timeoutId);
unlistenReady?.();
void emitTo(windowLabel, 'print-export-payload', payload)
.then(() => resolve())
.catch(reject);
})
.then((unlisten) => {
unlistenReady = unlisten;
printWindow = new WebviewWindow(windowLabel, {
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
title: 'Opening Print Dialog',
width: 960,
height: 1100,
center: true,
visible: true,
focus: true,
skipTaskbar: true,
resizable: false,
parent: 'main',
});
void printWindow.once('tauri://error', (event: { payload?: unknown }) => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
unlistenReady?.();
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
});
})
.catch(reject);
});
} catch (error) {
if (printWindow) {
void printWindow.close().catch(() => undefined);
}
throw error;
} finally {
if (typeof unlistenReady === 'function') {
unlistenReady();
}
}
}

View File

@@ -0,0 +1,155 @@
interface ElectronHttpRequest {
url: string;
method?: string;
headers?: Record<string, string>;
bodyText?: string;
bodyBase64?: string;
}
interface ElectronHttpResponse {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}
interface RuntimeResponse {
ok: boolean;
status: number;
statusText: string;
headers: {
get(name: string): string | null;
};
text(): Promise<string>;
json<T>(): Promise<T>;
arrayBuffer(): Promise<ArrayBuffer>;
blob(): Promise<Blob>;
}
const textDecoder = new TextDecoder();
const bytesToBase64 = (bytes: Uint8Array) => {
let binary = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
const chunk = bytes.subarray(index, index + 0x8000);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
const base64ToBytes = (value: string) => {
if (!value) {
return new Uint8Array(0);
}
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
};
const normalizeHeaders = (headers: Record<string, string>) => {
const normalized = new Map<string, string>();
for (const [key, value] of Object.entries(headers)) {
normalized.set(key.toLowerCase(), value);
}
return normalized;
};
const createRuntimeResponse = (response: ElectronHttpResponse): RuntimeResponse => {
const headers = normalizeHeaders(response.headers);
const bytes = base64ToBytes(response.bodyBase64);
const contentType = headers.get('content-type') || '';
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: {
get(name: string) {
return headers.get(name.toLowerCase()) ?? null;
},
},
async text() {
return textDecoder.decode(bytes);
},
async json<T>() {
return JSON.parse(textDecoder.decode(bytes)) as T;
},
async arrayBuffer() {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
},
async blob() {
return new Blob([bytes], { type: contentType });
},
};
};
const headersToObject = (headers?: HeadersInit) => {
if (!headers) {
return {};
}
return Object.fromEntries(new Headers(headers).entries());
};
const serializeBody = async (body?: BodyInit | null) => {
if (body == null) {
return {};
}
if (typeof body === 'string') {
return { bodyText: body };
}
if (body instanceof ArrayBuffer) {
return { bodyBase64: bytesToBase64(new Uint8Array(body)) };
}
if (ArrayBuffer.isView(body)) {
return {
bodyBase64: bytesToBase64(
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
),
};
}
if (body instanceof Blob) {
return {
bodyBase64: bytesToBase64(new Uint8Array(await body.arrayBuffer())),
};
}
throw new Error('Unsupported request body for Electron runtime.');
};
export const runtimeFetch = async (
url: string,
init: RequestInit = {},
): Promise<Response | RuntimeResponse> => {
if (!window.electronDesktop?.httpRequest) {
return fetch(url, init);
}
const payload: ElectronHttpRequest = {
url,
method: init.method,
headers: headersToObject(init.headers),
...(await serializeBody(init.body)),
};
const response = await window.electronDesktop.httpRequest(payload);
return createRuntimeResponse(response);
};

View File

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

View File

@@ -9,6 +9,12 @@ export interface Note {
modified: number;
filename?: string; // WebDAV: actual filename on server
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 {

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

@@ -1 +1,46 @@
/// <reference types="vite/client" />
interface ElectronDesktopMessageOptions {
message: string;
title?: string;
kind?: 'info' | 'warning' | 'error';
}
interface ElectronDesktopExportPayload {
fileName: string;
title: string;
html: string;
previewFont: string;
previewFontSize: number;
documentHtml: string;
}
interface ElectronDesktopExportResult {
canceled: boolean;
filePath?: string;
}
interface ElectronDesktopHttpRequest {
url: string;
method?: string;
headers?: Record<string, string>;
bodyText?: string;
bodyBase64?: string;
}
interface ElectronDesktopHttpResponse {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}
interface Window {
electronDesktop?: {
showMessage: (options: ElectronDesktopMessageOptions) => Promise<void>;
exportPdf: (payload: ElectronDesktopExportPayload) => Promise<ElectronDesktopExportResult>;
httpRequest: (payload: ElectronDesktopHttpRequest) => Promise<ElectronDesktopHttpResponse>;
getRuntime: () => 'electron';
};
}

View File

@@ -5,8 +5,9 @@ import react from "@vitejs/plugin-react";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
export default defineConfig(async ({ command }) => ({
plugins: [react()],
base: command === "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,
strictPort: true,
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
? {
protocol: "ws",