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
12 changed files with 575 additions and 170 deletions

View File

@@ -60,6 +60,8 @@ 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
@@ -129,10 +131,12 @@ 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 and Linux targets are also configured in `package.json`, but they have not been validated in this repository yet.
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

View File

@@ -1,6 +1,6 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
const { app, BrowserWindow, dialog, ipcMain, net } = require('electron');
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
const isDev = Boolean(rendererUrl);
@@ -90,23 +90,55 @@ ipcMain.handle('desktop:http-request', async (_event, payload) => {
const body =
payload.bodyBase64 != null
? Buffer.from(payload.bodyBase64, 'base64')
: payload.bodyText;
: payload.bodyText != null
? Buffer.from(payload.bodyText, 'utf8')
: null;
const response = await fetch(payload.url, {
method: payload.method || 'GET',
headers: payload.headers,
body,
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();
});
const buffer = Buffer.from(await response.arrayBuffer());
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
bodyBase64: buffer.toString('base64'),
};
});
ipcMain.handle('desktop:export-pdf', async (event, payload) => {

View File

@@ -3,6 +3,11 @@
"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": {
@@ -10,10 +15,14 @@
"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"
},
@@ -81,7 +90,9 @@
]
},
"linux": {
"icon": "src-tauri/icons/icon.png",
"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)}`);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -510,10 +510,21 @@ function MainApp() {
};
}
const remoteCategory = getRemoteCategory(note);
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, 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,
draftId: note.draftId,

View File

@@ -127,17 +127,8 @@ export class NextcloudAPI {
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
// 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);
@@ -167,13 +158,7 @@ export class NextcloudAPI {
async uploadAttachment(noteId: number | string, file: File, noteCategory?: string): Promise<string> {
// 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);
@@ -183,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);
@@ -191,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 runtimeFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
await runtimeFetch(`${this.serverURL}${this.buildAttachmentWebDAVPath(noteCategory, attachmentDir)}`, {
method: 'MKCOL',
headers: {
'Authorization': this.authHeader,
@@ -223,7 +208,7 @@ 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 {
@@ -250,7 +235,7 @@ 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);
@@ -294,9 +279,99 @@ 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 {
const categoryPath = category ? `/${category}` : '';
return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
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> {
@@ -361,7 +436,7 @@ export class NextcloudAPI {
}
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 runtimeFetch(url, {
@@ -439,9 +514,8 @@ 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 runtimeFetch(url, {
@@ -458,21 +532,12 @@ 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 runtimeFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist
}
await this.ensureCategoryDirectoryExists(category);
}
const noteContent = content ? `${title}\n${content}` : title;
@@ -616,31 +681,32 @@ export class NextcloudAPI {
throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status);
}
// Also rename attachment folder if it exists
const categoryPath = note.category ? `/${note.category}` : '';
const oldNoteIdStr = String(note.id);
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`;
const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, '');
const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`;
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${oldAttachmentFolder}`;
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`;
try {
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
'Destination': `${this.serverURL}${newAttachmentPath}`,
},
});
} catch (e) {
// Attachment folder might not exist, that's ok
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)$/, '');
const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`;
const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, '');
const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`;
const oldAttachmentPath = this.buildAttachmentWebDAVPath(note.category, oldAttachmentFolder);
const newAttachmentPath = this.buildAttachmentWebDAVPath(note.category, newAttachmentFolder);
try {
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
'Destination': `${this.serverURL}${newAttachmentPath}`,
},
});
} catch (e) {
// Attachment folder might not exist, that's ok
}
}
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
@@ -657,8 +723,7 @@ export class NextcloudAPI {
}
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 runtimeFetch(url, {
@@ -672,28 +737,14 @@ export class NextcloudAPI {
}
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 runtimeFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist, continue
}
}
await this.ensureCategoryDirectoryExists(newCategory);
}
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
@@ -705,48 +756,56 @@ 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
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}`;
console.log(`Attempting to move attachment folder:`);
console.log(` From: ${oldAttachmentPath}`);
console.log(` To: ${newAttachmentPath}`);
try {
const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
'Destination': `${this.serverURL}${newAttachmentPath}`,
},
});
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 = this.buildAttachmentWebDAVPath(remoteCategory, attachmentFolder);
const newAttachmentPath = this.buildAttachmentWebDAVPath(newCategory, attachmentFolder);
console.log(`Attachment folder MOVE response status: ${attachmentResponse.status}`);
console.log(`Attempting to move attachment folder:`);
console.log(` From: ${oldAttachmentPath}`);
console.log(` To: ${newAttachmentPath}`);
if (attachmentResponse.ok || attachmentResponse.status === 201 || attachmentResponse.status === 204) {
console.log(`✓ Successfully moved attachment folder: ${attachmentFolder}`);
} else {
console.log(`✗ Failed to move attachment folder (status ${attachmentResponse.status})`);
try {
const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
'Destination': `${this.serverURL}${newAttachmentPath}`,
},
});
console.log(`Attachment folder MOVE response status: ${attachmentResponse.status}`);
if (attachmentResponse.ok || attachmentResponse.status === 201 || attachmentResponse.status === 204) {
console.log(`✓ Successfully moved attachment folder: ${attachmentFolder}`);
} else {
console.log(`✗ Failed to move attachment folder (status ${attachmentResponse.status})`);
}
} catch (e) {
console.log(`✗ Error moving attachment folder:`, e);
}
} 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
@@ -217,6 +229,28 @@ export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploa
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</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}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { marked } from 'marked';
import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud';
@@ -34,12 +34,80 @@ interface NoteEditorProps {
const imageCache = new Map<string, string>();
const TASK_LIST_ITEM_REGEX = /^(\s*(?:[-+*]|\d+\.)\s)\[( |x|X)\]\s/;
// 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('');
@@ -51,7 +119,10 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
const [isUploading, setIsUploading] = useState(false);
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);
const pendingScrollTopRef = useRef<number | null>(null);
const desktopRuntime = getDesktopRuntime();
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
const hasUnsavedChanges = Boolean(note?.pendingSave);
@@ -71,26 +142,39 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
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;
}
}, 0);
const captureEditorScrollPosition = () => {
if (editorScrollContainerRef.current) {
pendingScrollTopRef.current = editorScrollContainerRef.current.scrollTop;
}
}, [localContent, isPreviewMode, editorFontSize]);
};
// Keep the editor pane anchored when the textarea grows with new content.
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || isPreviewMode) {
pendingScrollTopRef.current = null;
return;
}
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(() => {
@@ -175,6 +259,36 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
}
}, [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);
};
previewElement.addEventListener('change', handleTaskToggle);
return () => previewElement.removeEventListener('change', handleTaskToggle);
}, [isPreviewMode, localContent, localCategory, localFavorite]);
const emitNoteChange = (content: string, category: string, favorite: boolean) => {
if (!note) {
return;
@@ -190,6 +304,7 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
};
const handleContentChange = (value: string) => {
captureEditorScrollPosition();
setLocalContent(value);
emitNoteChange(value, localCategory, localFavorite);
};
@@ -298,6 +413,7 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
// 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);
@@ -339,6 +455,7 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
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);
@@ -356,6 +473,44 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
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;
@@ -482,6 +637,7 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
}
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
captureEditorScrollPosition();
setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
@@ -693,7 +849,7 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
</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">
@@ -706,11 +862,12 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
Loading images...
</div>
)}
<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>
@@ -719,7 +876,9 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
<FloatingToolbar onFormat={handleFormat} textareaRef={textareaRef} />
<InsertToolbar
textareaRef={textareaRef}
onInsertLink={handleInsertLink}
onInsertLink={handleInsertLink}
onInsertTodoItem={handleInsertTodoItem}
onInsertTable={handleInsertTable}
onInsertFile={handleInsertFile}
isUploading={isUploading}
/>
@@ -733,12 +892,7 @@ export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onTo
<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

@@ -289,6 +289,28 @@ export function PrintView({ jobId }: PrintViewProps) {
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;

View File

@@ -232,10 +232,20 @@ code {
height: 1.25rem;
margin-right: 0.75rem;
margin-top: 0.32rem;
cursor: default;
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%;
}

View File

@@ -261,6 +261,28 @@ export const buildPrintDocument = (payload: PrintExportPayload) => {
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;