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
This commit is contained in:
drelich
2026-04-06 10:10:17 +02:00
parent e21e443a59
commit aba090a8ad
6 changed files with 835 additions and 214 deletions

View File

@@ -18,9 +18,10 @@ import {
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;
@@ -39,27 +40,24 @@ marked.use({
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);
const desktopRuntime = getDesktopRuntime();
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
useEffect(() => {
onUnsavedChanges?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChanges]);
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(() => {
@@ -103,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;
}
@@ -150,77 +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 title = getNoteTitleFromContent(localContent);
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;
onDiscardNote(note.draftId);
};
const handleExportPDF = async () => {
@@ -295,25 +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 title = getNoteTitleFromContent(localContent);
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>) => {
@@ -336,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(() => {
@@ -346,8 +311,9 @@ 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 showDesktopMessage('Attachment uploaded successfully!', {
@@ -377,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();
@@ -517,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();
@@ -603,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>
)}
@@ -631,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'

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