Migrate to Electron and implement native PDF export

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

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

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) => {
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(note, favorite);
// Update local state
setNotes(prevNotes =>
prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n)
);
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,280 @@ function App() {
}
};
const handleUpdateNote = async (updatedNote: Note) => {
try {
const originalNote = notes.find(n => n.id === updatedNote.id);
// 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({
...movedNote,
title: updatedNote.title,
content: updatedNote.content,
favorite: updatedNote.favorite,
});
setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n));
// Update selected note ID if it changed
if (selectedNoteId === originalNote.id && finalNote.id !== originalNote.id) {
setSelectedNoteId(finalNote.id);
}
} else {
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
// Update selected note ID if it changed
if (selectedNoteId === originalNote.id && movedNote.id !== originalNote.id) {
setSelectedNoteId(movedNote.id);
}
}
} else {
const updated = await syncManager.updateNote(updatedNote);
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
// Update selected note ID if it changed (e.g., filename changed due to first line edit)
if (selectedNoteId === updatedNote.id && updated.id !== updatedNote.id) {
setSelectedNoteId(updated.id);
}
}
} catch (error) {
console.error('Update note failed:', error);
const 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,
};
}
const remoteCategory = getRemoteCategory(note);
if (remoteCategory !== note.category) {
const movedNote = await syncManager.moveNote(note, note.category);
return syncManager.updateNote({
...movedNote,
draftId: note.draftId,
title: note.title,
content: note.content,
favorite: note.favorite,
localOnly: false,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: note.lastSavedAt,
});
}
return syncManager.updateNote(note);
};
const flushNoteSave = async (draftId: string, options: FlushSaveOptions = {}): Promise<void> => {
const controller = ensureSaveController(draftId);
clearSaveTimer(draftId);
const currentNote = getNoteByDraftId(draftId);
if (!currentNote?.pendingSave) {
return;
}
const canPersist = options.force ? canForceSaveLocalNote(currentNote) : canAutosaveLocalNote(currentNote);
if (!canPersist) {
return;
}
if (controller.inFlight) {
await controller.inFlight;
return;
}
controller.inFlightRevision = controller.revision;
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId
? {
...note,
isSaving: true,
saveError: null,
}
: note
)
);
const savePromise = (async () => {
try {
const noteToPersist = getNoteByDraftId(draftId);
if (!noteToPersist?.pendingSave) {
return;
}
const canPersistLatest = options.force ? canForceSaveLocalNote(noteToPersist) : canAutosaveLocalNote(noteToPersist);
if (!canPersistLatest) {
return;
}
const savedNote = {
...(await persistNoteToServer(noteToPersist)),
draftId,
localOnly: false,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: Date.now(),
};
if (noteToPersist.id !== savedNote.id) {
void localDB.deleteNote(noteToPersist.id).catch((error) => {
console.error('Failed to remove stale local note cache entry:', error);
});
}
savedSnapshotsRef.current.set(draftId, {
...savedNote,
pendingSave: false,
isSaving: false,
saveError: null,
});
const latestNote = getNoteByDraftId(draftId);
const hasNewerChanges = controller.revision > controller.inFlightRevision && latestNote;
if (latestNote && hasNewerChanges) {
const mergedPendingNote: Note = {
...savedNote,
content: latestNote.content,
title: latestNote.title,
category: latestNote.category,
favorite: latestNote.favorite,
modified: latestNote.modified,
localOnly: false,
pendingSave: true,
isSaving: false,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? mergedPendingNote : note
)
);
persistNoteToCache(mergedPendingNote);
scheduleNoteSave(draftId, 0);
return;
}
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? savedNote : note
)
);
persistNoteToCache(savedNote);
} catch (error) {
console.error('Update note failed:', error);
const failedNote = getNoteByDraftId(draftId);
if (!failedNote) {
return;
}
const erroredNote = {
...failedNote,
pendingSave: true,
isSaving: false,
saveError: error instanceof Error ? error.message : 'Failed to save note.',
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? erroredNote : note
)
);
persistNoteToCache(erroredNote);
} finally {
controller.inFlight = null;
}
})();
controller.inFlight = savePromise;
await savePromise;
};
const scheduleNoteSave = (draftId: string, delayMs = AUTOSAVE_DELAY_MS) => {
const controller = ensureSaveController(draftId);
clearSaveTimer(draftId);
controller.timerId = window.setTimeout(() => {
controller.timerId = null;
void flushNoteSave(draftId);
}, delayMs);
};
const flushAllPendingSaves = async () => {
const pendingDraftIds = notesRef.current
.filter(note => note.pendingSave && note.draftId)
.map(note => note.draftId as string);
await Promise.all(pendingDraftIds.map(draftId => flushNoteSave(draftId, { force: true })));
};
const handleDraftChange = (updatedNote: Note) => {
const draftId = updatedNote.draftId;
if (!draftId) {
return;
}
const localNote = {
...updatedNote,
modified: Math.floor(Date.now() / 1000),
pendingSave: true,
isSaving: false,
saveError: null,
};
const controller = ensureSaveController(draftId);
controller.revision += 1;
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? localNote : note
)
);
persistNoteToCache(localNote);
scheduleNoteSave(draftId);
};
const handleManualSave = async (draftId: string) => {
await flushNoteSave(draftId, { force: true });
};
const handleDiscardNote = (draftId: string) => {
const snapshot = savedSnapshotsRef.current.get(draftId);
if (!snapshot) {
return;
}
clearSaveTimer(draftId);
const controller = ensureSaveController(draftId);
controller.revision += 1;
const cleanSnapshot = {
...snapshot,
pendingSave: false,
isSaving: false,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? cleanSnapshot : note
)
);
persistNoteToCache(cleanSnapshot);
};
const handleSelectNote = async (draftId: string) => {
if (draftId === selectedNoteDraftId) {
return;
}
if (selectedNoteDraftId) {
await flushNoteSave(selectedNoteDraftId, { force: true });
}
setSelectedNoteDraftId(draftId);
};
const handleDeleteNote = async (note: Note) => {
const draftId = note.draftId;
if (!draftId) {
return;
}
try {
await syncManager.deleteNote(note);
const remainingNotes = notes.filter(n => n.id !== note.id);
setNotes(remainingNotes);
if (selectedNoteId === note.id) {
setSelectedNoteId(remainingNotes[0]?.id || null);
clearSaveTimer(draftId);
if (!note.localOnly) {
await syncManager.deleteNote(note);
} 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 +788,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 +830,8 @@ function App() {
/>
<NotesList
notes={filteredNotes}
selectedNoteId={selectedNoteId}
onSelectNote={setSelectedNoteId}
selectedNoteDraftId={selectedNoteDraftId}
onSelectNote={handleSelectNote}
onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote}
onSync={syncNotes}
@@ -368,9 +848,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 +865,19 @@ function App() {
);
}
function App() {
const params = new URLSearchParams(window.location.search);
const printJobId = params.get(PRINT_EXPORT_QUERY_PARAM);
if (printJobId) {
return (
<Suspense fallback={<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">Preparing print view...</div>}>
<LazyPrintView jobId={printJobId} />
</Suspense>
);
}
return <MainApp />;
}
export default App;

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',
@@ -124,7 +141,7 @@ export class NextcloudAPI {
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,
},
@@ -174,7 +191,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}${webdavPath}/${attachmentDir}`, {
method: 'MKCOL',
headers: {
'Authorization': this.authHeader,
@@ -188,7 +205,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,
@@ -210,7 +227,7 @@ export class NextcloudAPI {
const url = `${this.serverURL}${webdavPath}`;
try {
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
headers: {
'Authorization': this.authHeader,
},
@@ -238,7 +255,7 @@ export class NextcloudAPI {
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 +294,77 @@ export class NextcloudAPI {
return note.content;
}
private buildNoteWebDAVPath(category: string, filename: string): string {
const categoryPath = category ? `/${category}` : '';
return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
}
private async delay(ms: number): Promise<void> {
await new Promise((resolve) => window.setTimeout(resolve, ms));
}
private async fetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number }> {
const webdavPath = this.buildNoteWebDAVPath(category, filename);
const response = await runtimeFetch(`${this.serverURL}${webdavPath}`, {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
'Depth': '0',
'Content-Type': 'application/xml',
},
body: `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
</d:prop>
</d:propfind>`,
});
if (!response.ok) {
throw createHttpStatusError(`Failed to fetch note metadata: ${response.status}`, response.status);
}
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
const responseNode = xmlDoc.getElementsByTagNameNS('DAV:', 'response')[0];
const propstat = responseNode?.getElementsByTagNameNS('DAV:', 'propstat')[0];
const prop = propstat?.getElementsByTagNameNS('DAV:', 'prop')[0];
const etag = prop?.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : Math.floor(Date.now() / 1000);
return { etag, modified };
}
private async tryFetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number } | null> {
try {
return await this.fetchNoteMetadataWebDAV(category, filename);
} catch (error) {
const status = getHttpStatus(error);
if (status === 404) {
return null;
}
throw error;
}
}
private async refreshNoteWebDAVMetadata(note: Note): Promise<Note> {
const metadata = await this.fetchNoteMetadataWebDAV(note.category, note.filename!);
return {
...note,
etag: metadata.etag || note.etag,
modified: metadata.modified || note.modified,
};
}
async fetchNotesWebDAV(): Promise<Note[]> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
@@ -361,7 +444,7 @@ export class NextcloudAPI {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
headers: { 'Authorization': this.authHeader },
});
@@ -383,7 +466,7 @@ export class NextcloudAPI {
if (category) {
try {
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
await tauriFetch(categoryUrl, {
await runtimeFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
@@ -392,9 +475,9 @@ export class NextcloudAPI {
}
}
const noteContent = `${title}\n${content}`;
const noteContent = content ? `${title}\n${content}` : title;
const response = await tauriFetch(url, {
const response = await runtimeFetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
@@ -416,7 +499,7 @@ export class NextcloudAPI {
path: category ? `${category}/${filename}` : filename,
etag,
readonly: false,
content,
content: noteContent,
title,
category,
favorite: false,
@@ -437,21 +520,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 +567,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 +585,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),
};
}
}
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)$/, '');
@@ -509,7 +632,7 @@ export class NextcloudAPI {
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`;
try {
await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, {
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
@@ -520,6 +643,7 @@ export class NextcloudAPI {
// 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,6 +651,8 @@ 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),
};
}
@@ -535,13 +661,13 @@ export class NextcloudAPI {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(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);
}
}
@@ -560,7 +686,7 @@ export class NextcloudAPI {
currentPath += (currentPath ? '/' : '') + part;
try {
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`;
await tauriFetch(categoryUrl, {
await runtimeFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
@@ -570,7 +696,7 @@ export class NextcloudAPI {
}
}
const response = await tauriFetch(`${this.serverURL}${oldPath}`, {
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
method: 'MOVE',
headers: {
'Authorization': this.authHeader,
@@ -597,7 +723,7 @@ export class NextcloudAPI {
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,

View File

@@ -1,17 +1,27 @@
import { useState, useEffect, 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,30 @@ interface NoteEditorProps {
const imageCache = new Map<string, string>();
// Configure marked to support task lists
marked.use({
gfm: true,
breaks: true,
});
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onToggleFavorite, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
const [localContent, setLocalContent] = useState('');
const [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 fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
onUnsavedChanges?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChanges]);
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(() => {
@@ -87,8 +101,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 +148,56 @@ 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;
}
// Switching to a different note
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
if (previousDraftIdRef.current !== note.draftId) {
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();
previousDraftIdRef.current = note.draftId ?? null;
}
// Initial load
else if (!note || previousNoteIdRef.current === null) {
loadNewNote();
if (note.content !== localContent) {
setLocalContent(note.content);
}
// Favorite status changed (e.g., from sync)
else if (note && note.favorite !== localFavorite) {
if ((note.category || '') !== localCategory) {
setLocalCategory(note.category || '');
}
if (note.favorite !== localFavorite) {
setLocalFavorite(note.favorite);
}
}, [note?.id, note?.content, note?.modified, note?.favorite]);
}, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]);
const handleSave = () => {
if (!note || !hasUnsavedChanges) 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({
const emitNoteChange = (content: string, category: string, favorite: boolean) => {
if (!note) {
return;
}
onChangeNote({
...note,
title,
content: localContent,
category: localCategory,
favorite: localFavorite,
title: getNoteTitleFromContent(content),
content,
category,
favorite,
});
setTimeout(() => setIsSaving(false), 500);
};
const handleContentChange = (value: string) => {
setLocalContent(value);
setHasUnsavedChanges(true);
emitNoteChange(value, localCategory, localFavorite);
};
const handleDiscard = () => {
if (!note) return;
setLocalContent(note.content);
setLocalCategory(note.category || '');
setLocalFavorite(note.favorite);
setHasUnsavedChanges(false);
};
if (!note?.draftId) return;
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 +206,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 +223,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 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);
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),
};
// 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 +272,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>) => {
@@ -450,7 +301,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
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 +311,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',
});
@@ -491,7 +343,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
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();
@@ -631,7 +483,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
setLocalContent(newContent);
setHasUnsavedChanges(true);
emitNoteChange(newContent, localCategory, localFavorite);
setTimeout(() => {
textarea.focus();
@@ -717,13 +569,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
? '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'
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'
: 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 +601,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 +642,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>

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

View File

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

369
src/printExport.ts Normal file
View File

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

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

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

View File

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

View File

@@ -4,6 +4,41 @@ import { localDB } from '../db/localDB';
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
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';
};
}