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:
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user