From 1a880a4f9699f7b39b433aec8017afa540c83b7d Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 3 Aug 2026 10:45:59 +0200 Subject: [PATCH] Add open-note trash action (#177) --- src/lib/components/AppLayout.svelte | 30 ++++++-- src/lib/components/Editor.svelte | 105 ++++++++++++++++++++++++---- src/lib/components/NoteList.svelte | 6 -- 3 files changed, 117 insertions(+), 24 deletions(-) diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index ce39468..3ed7adc 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -61,7 +61,7 @@ const appWindow = getCurrentWindow(); const isMac = navigator.platform.startsWith('Mac'); const isMobile = $derived($platformIsMobile); - import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api'; + import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api'; import { darkThemes, isAndroid } from '$lib/platform'; import { debounce } from '$lib/utils/debounce'; import { openNoteWindow } from '$lib/utils/window'; @@ -449,6 +449,28 @@ else if ($mobileView === 'notelist') $mobileView = 'sidebar'; } + async function trashOpenNote(path: string): Promise { + if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false; + try { + await deleteNote(path); + if (Object.hasOwn($noteOrder, path)) { + const { [path]: _, ...rest } = $noteOrder; + $noteOrder = rest; + } + $notes = $notes.filter((note) => note.path !== path); + if ($activeNotePath === path) { + $activeNote = null; + $activeNotePath = null; + } + noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error)); + if (isMobile) $mobileView = 'notelist'; + return true; + } catch (error) { + console.error('Failed to move open note to Trash:', error); + return false; + } + } + function handleMouseDown(e: MouseEvent) { if (e.button === 3) { e.preventDefault(); navigateHistory(-1); } if (e.button === 4) { e.preventDefault(); navigateHistory(1); } @@ -467,7 +489,7 @@ // Ctrl/Cmd+Shift+Delete: move the open note to the trash. if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) { e.preventDefault(); - noteList?.trashActiveNote(); + editor?.moveOpenNoteToTrash(); return; } @@ -951,7 +973,7 @@ editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
- +
@@ -1028,7 +1050,7 @@ {/if}
- + {#if $viewMode === 'tasks' && !taskNoteOpened}
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 1db483a..dfccb29 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -57,6 +57,10 @@ import { isMobile, isAndroid } from '$lib/platform'; import ResizeHandle from './ResizeHandle.svelte'; + let { onMoveToTrash = async () => false }: { + onMoveToTrash?: (path: string) => Promise; + } = $props(); + const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const sourceHighlighter = hljs.newInstance(); sourceHighlighter.registerLanguage('markdown', markdownLanguage); @@ -103,6 +107,7 @@ let pendingContent = $state(null); let ignoreNextUpdate = false; let isLoadingNote = false; + let trashingNote = $state(false); let fixingBlobsPromise: Promise = Promise.resolve(); let hasPendingBlobs = false; let lastSourceMode = $sourceMode; @@ -2993,8 +2998,16 @@ return src; } + let saveQueue: Promise = Promise.resolve(); + + function queueSave(task: () => Promise): Promise { + const queued = saveQueue.then(task); + saveQueue = queued.catch(() => {}); + return queued; + } + const autoSave = debounce(async () => { - if (get(viewerNote)) return; // never autosave external viewer files + if (get(viewerNote) || trashingNote) return; // never autosave external viewer files or a note being trashed if (!$activeNote || !$activeNotePath || !$editorDirty) return; // Only fix blob images if a paste occurred (avoids full doc scan on every save) if (hasPendingBlobs) { @@ -3002,38 +3015,65 @@ fixingBlobsPromise = fixBlobImages(); } await fixingBlobsPromise; + if (trashingNote || !$activeNote || !$activeNotePath) return; try { + const path = $activeNotePath; + const note = $activeNote; const body = $sourceMode ? restoreTitleH1(sourceContent) : editorToMarkdown(); // Safety: never save empty/near-empty body over a note that had real content const trimmed = body.replace(/^#.*\n?/, '').trim(); - if (!trimmed && $activeNote.content && $activeNote.content.trim().length > 10) { + if (!trimmed && note.content && note.content.trim().length > 10) { console.warn('Auto-save blocked: refusing to overwrite note with empty content'); return; } - await saveNote($activeNotePath, $activeNote.meta, body); - $editorDirty = false; + await queueSave(() => saveNote(path, note.meta, body)); + if ($activeNotePath === path) $editorDirty = false; } catch (e) { console.error('Auto-save failed:', e); } }, isMobile ? 1500 : 500); - export async function forceSave() { - if (get(viewerNote)) return; // viewer files are never written back - if (!$activeNote || !$activeNotePath) return; + export async function forceSave(allowWhileTrashing = false): Promise { + if (get(viewerNote) || (trashingNote && !allowWhileTrashing)) return false; + if (!$activeNote || !$activeNotePath) return false; await fixingBlobsPromise; + if (trashingNote && !allowWhileTrashing) return false; + if (!$activeNote || !$activeNotePath) return false; try { + const path = $activeNotePath; + const note = $activeNote; const body = $sourceMode ? restoreTitleH1(sourceContent) : editorToMarkdown(); const trimmed = body.replace(/^#.*\n?/, '').trim(); - if (!trimmed && $activeNote.content && $activeNote.content.trim().length > 10) { + if (!trimmed && note.content && note.content.trim().length > 10) { console.warn('Force-save blocked: refusing to overwrite note with empty content'); - return; + return false; } - await saveNote($activeNotePath, $activeNote.meta, body); - $editorDirty = false; + await queueSave(() => saveNote(path, note.meta, body)); + if ($activeNotePath === path) $editorDirty = false; + return true; } catch (e) { console.error('Save failed:', e); + return false; + } + } + + export async function moveOpenNoteToTrash(): Promise { + if (trashingNote || !$activeNotePath || $viewerNote) return false; + const path = $activeNotePath; + const wasDirty = $editorDirty; + trashingNote = true; + try { + // Drain any save already in flight, then persist the latest editor state. + await saveQueue; + if (wasDirty && !(await forceSave(true))) return false; + if ($activeNotePath !== path) return false; + // A queued debounce observes this flag and cannot recreate the moved note. + $editorDirty = false; + return await onMoveToTrash(path); + } finally { + trashingNote = false; } } @@ -6050,6 +6090,13 @@ }} />
+ {#if isMobile} + + {/if} {#if !isMobile}
{#if $canGoBack || $canGoForward} @@ -6198,6 +6245,11 @@ +
{/if}
@@ -8156,6 +8208,17 @@ color: var(--text-primary); } + .editor-trash-btn:hover, + .editor-trash-btn:focus-visible { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); + } + + .editor-trash-btn:disabled { + opacity: 0.45; + cursor: default; + } + .icon-btn.active { color: var(--text-accent); background: var(--accent-light); @@ -11295,9 +11358,9 @@ .editor-container.mobile .editor-toolbar { padding: 8px 16px 6px 16px; flex-shrink: 0; - flex-direction: column; - align-items: stretch; - gap: 2px; + flex-direction: row; + align-items: center; + gap: 8px; } .toolbar-actions.mobile { @@ -11322,6 +11385,20 @@ padding: 4px 0; } + .editor-container.mobile .editor-trash-btn.mobile { + width: 44px; + height: 44px; + padding: 0; + flex-shrink: 0; + justify-content: center; + border-radius: 8px; + } + + .editor-container.mobile .editor-trash-btn.mobile:active { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); + } + .editor-container.mobile .editor-body-wrapper { flex: 1; min-height: 0; diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index 4938860..b5de5cc 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -519,12 +519,6 @@ } } - // Move the currently open note to the trash (keyboard shortcut from the editor). - export async function trashActiveNote() { - if ($viewMode === 'trash' || !$activeNotePath) return; - const note = $notes.find(n => n.path === $activeNotePath); - if (note) await handleDelete(note); - } async function handleRestore(note: NoteEntry) { contextMenu = null;