forked from mirror/crumbs-original
feat: auto-save notes with debounced persistence
Notes are now automatically saved 2 seconds after the user stops editing, preventing data loss when the browser tab is closed without closing the editor first. A beforeunload handler provides an additional safety net. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
575bb7ebe8
commit
5e260a7d56
@@ -63,6 +63,77 @@
|
|||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let currentlyNew = $state(isNew);
|
let currentlyNew = $state(isNew);
|
||||||
|
|
||||||
|
// Auto-save: track last-saved state to detect real changes
|
||||||
|
let lastSavedTitle = note?.title ?? '';
|
||||||
|
let lastSavedContent = note?.content ?? '';
|
||||||
|
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
|
||||||
|
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
|
||||||
|
let isSaving = false;
|
||||||
|
let savingPromise: Promise<void> | null = null;
|
||||||
|
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function hasUnsavedChanges(): boolean {
|
||||||
|
return (
|
||||||
|
title !== lastSavedTitle ||
|
||||||
|
content !== lastSavedContent ||
|
||||||
|
color !== lastSavedColor ||
|
||||||
|
checklistMode !== lastSavedChecklistMode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSave() {
|
||||||
|
if (isSaving) return;
|
||||||
|
if (!title.trim() && !content.trim()) return;
|
||||||
|
if (!hasUnsavedChanges()) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
if (currentlyNew) {
|
||||||
|
const created = await createNote({ title, content, color, checklistMode });
|
||||||
|
if (created) {
|
||||||
|
noteId = created.id;
|
||||||
|
currentlyNew = false;
|
||||||
|
}
|
||||||
|
} else if (noteId) {
|
||||||
|
await updateNote(noteId, { title, content, color, checklistMode });
|
||||||
|
}
|
||||||
|
lastSavedTitle = title;
|
||||||
|
lastSavedContent = content;
|
||||||
|
lastSavedColor = color;
|
||||||
|
lastSavedChecklistMode = checklistMode;
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced auto-save: triggers 2s after the user stops editing
|
||||||
|
$effect(() => {
|
||||||
|
const _t = title;
|
||||||
|
const _c = content;
|
||||||
|
const _col = color;
|
||||||
|
const _cm = checklistMode;
|
||||||
|
|
||||||
|
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
|
||||||
|
if (!_t.trim() && !_c.trim()) return;
|
||||||
|
|
||||||
|
autoSaveTimer = setTimeout(() => {
|
||||||
|
savingPromise = performSave();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(autoSaveTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safety net: save on tab close / navigation
|
||||||
|
$effect(() => {
|
||||||
|
function handleBeforeUnload() {
|
||||||
|
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
});
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||||
|
|
||||||
@@ -242,15 +313,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAndClose() {
|
async function saveAndClose() {
|
||||||
|
clearTimeout(autoSaveTimer);
|
||||||
|
// Wait for any in-flight auto-save to finish
|
||||||
|
if (savingPromise) await savingPromise;
|
||||||
|
|
||||||
if (!title.trim() && !content.trim()) {
|
if (!title.trim() && !content.trim()) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentlyNew) {
|
// Final save if auto-save hasn't caught up yet
|
||||||
await createNote({ title, content, color, checklistMode });
|
if (hasUnsavedChanges()) {
|
||||||
} else if (noteId) {
|
await performSave();
|
||||||
await updateNote(noteId, { title, content, color, checklistMode });
|
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -258,18 +332,14 @@
|
|||||||
/** Auto-save a new note without closing, returns the new note ID */
|
/** Auto-save a new note without closing, returns the new note ID */
|
||||||
async function autoSave(): Promise<string | null> {
|
async function autoSave(): Promise<string | null> {
|
||||||
if (!currentlyNew) return noteId;
|
if (!currentlyNew) return noteId;
|
||||||
const created = await createNote({
|
clearTimeout(autoSaveTimer);
|
||||||
title: title || 'Untitled',
|
if (savingPromise) await savingPromise;
|
||||||
content,
|
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||||
color,
|
const prevTitle = title;
|
||||||
checklistMode
|
if (!title.trim()) title = 'Untitled';
|
||||||
});
|
await performSave();
|
||||||
if (created) {
|
if (!prevTitle.trim()) title = prevTitle;
|
||||||
noteId = created.id;
|
return noteId;
|
||||||
currentlyNew = false;
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleImageUpload() {
|
async function toggleImageUpload() {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||||
|
|
||||||
|
test.describe('Auto-save', () => {
|
||||||
|
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
|
||||||
|
// When the user starts a new note and types a title and content
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
await page.getByTestId('note-title-input').fill('Auto-saved Note');
|
||||||
|
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially('This should be saved automatically');
|
||||||
|
|
||||||
|
// Then the note is persisted after the auto-save delay (2s debounce)
|
||||||
|
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// And closing the editor preserves the note
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
|
||||||
|
// Given a note titled "Edit Me" exists
|
||||||
|
await createNote(page, 'Edit Me', 'Original content');
|
||||||
|
|
||||||
|
// When the user opens the note and changes the title
|
||||||
|
await noteCard(page, 'Edit Me').click();
|
||||||
|
await page.getByTestId('note-title-input').clear();
|
||||||
|
await page.getByTestId('note-title-input').fill('Edited Title');
|
||||||
|
|
||||||
|
// Then the updated title is auto-saved after the debounce delay
|
||||||
|
// Wait for the API call that confirms the save
|
||||||
|
await page.waitForResponse(
|
||||||
|
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the note card reflects the change while editor is still open
|
||||||
|
await expect(noteCard(page, 'Edited Title')).toBeVisible();
|
||||||
|
|
||||||
|
// Cleanup: close the editor
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
|
||||||
|
// When the user opens a new note but leaves it empty
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
|
||||||
|
// And waits longer than the auto-save delay
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Then no note card appears in the list
|
||||||
|
const noteCards = page.locator('[data-testid="note-card"]');
|
||||||
|
const countBefore = await noteCards.count();
|
||||||
|
|
||||||
|
// Close the empty editor
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
|
||||||
|
// And no new note was created
|
||||||
|
const countAfter = await noteCards.count();
|
||||||
|
expect(countAfter).toBe(countBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
|
||||||
|
// When the user creates a note and waits for auto-save
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
await page.getByTestId('note-title-input').fill('Survive Reload');
|
||||||
|
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially('Content that survives');
|
||||||
|
|
||||||
|
// Wait for auto-save to fire
|
||||||
|
await page.waitForResponse(
|
||||||
|
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the page is reloaded without closing the editor
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Then the note is still visible
|
||||||
|
await expect(noteCard(page, 'Survive Reload')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user