Add open-note trash action (#177)

This commit is contained in:
Yuri Karamian
2026-08-03 10:45:59 +02:00
parent f4e05ed9c4
commit 1a880a4f96
3 changed files with 117 additions and 24 deletions
+26 -4
View File
@@ -61,7 +61,7 @@
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile); 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 { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
@@ -449,6 +449,28 @@
else if ($mobileView === 'notelist') $mobileView = 'sidebar'; else if ($mobileView === 'notelist') $mobileView = 'sidebar';
} }
async function trashOpenNote(path: string): Promise<boolean> {
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) { function handleMouseDown(e: MouseEvent) {
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); } if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
if (e.button === 4) { 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. // Ctrl/Cmd+Shift+Delete: move the open note to the trash.
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) { if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
e.preventDefault(); e.preventDefault();
noteList?.trashActiveNote(); editor?.moveOpenNoteToTrash();
return; return;
} }
@@ -951,7 +973,7 @@
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
</div> </div>
</div> </div>
@@ -1028,7 +1050,7 @@
{/if} {/if}
<div class="editor-panel"> <div class="editor-panel">
<Editor bind:this={editor} /> <Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
{#if $viewMode === 'tasks' && !taskNoteOpened} {#if $viewMode === 'tasks' && !taskNoteOpened}
<div class="tasks-editor-placeholder"> <div class="tasks-editor-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+91 -14
View File
@@ -57,6 +57,10 @@
import { isMobile, isAndroid } from '$lib/platform'; import { isMobile, isAndroid } from '$lib/platform';
import ResizeHandle from './ResizeHandle.svelte'; import ResizeHandle from './ResizeHandle.svelte';
let { onMoveToTrash = async () => false }: {
onMoveToTrash?: (path: string) => Promise<boolean>;
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const sourceHighlighter = hljs.newInstance(); const sourceHighlighter = hljs.newInstance();
sourceHighlighter.registerLanguage('markdown', markdownLanguage); sourceHighlighter.registerLanguage('markdown', markdownLanguage);
@@ -103,6 +107,7 @@
let pendingContent = $state<string | null>(null); let pendingContent = $state<string | null>(null);
let ignoreNextUpdate = false; let ignoreNextUpdate = false;
let isLoadingNote = false; let isLoadingNote = false;
let trashingNote = $state(false);
let fixingBlobsPromise: Promise<void> = Promise.resolve(); let fixingBlobsPromise: Promise<void> = Promise.resolve();
let hasPendingBlobs = false; let hasPendingBlobs = false;
let lastSourceMode = $sourceMode; let lastSourceMode = $sourceMode;
@@ -2993,8 +2998,16 @@
return src; return src;
} }
let saveQueue: Promise<void> = Promise.resolve();
function queueSave(task: () => Promise<void>): Promise<void> {
const queued = saveQueue.then(task);
saveQueue = queued.catch(() => {});
return queued;
}
const autoSave = debounce(async () => { 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; if (!$activeNote || !$activeNotePath || !$editorDirty) return;
// Only fix blob images if a paste occurred (avoids full doc scan on every save) // Only fix blob images if a paste occurred (avoids full doc scan on every save)
if (hasPendingBlobs) { if (hasPendingBlobs) {
@@ -3002,38 +3015,65 @@
fixingBlobsPromise = fixBlobImages(); fixingBlobsPromise = fixBlobImages();
} }
await fixingBlobsPromise; await fixingBlobsPromise;
if (trashingNote || !$activeNote || !$activeNotePath) return;
try { try {
const path = $activeNotePath;
const note = $activeNote;
const body = $sourceMode const body = $sourceMode
? restoreTitleH1(sourceContent) ? restoreTitleH1(sourceContent)
: editorToMarkdown(); : editorToMarkdown();
// Safety: never save empty/near-empty body over a note that had real content // Safety: never save empty/near-empty body over a note that had real content
const trimmed = body.replace(/^#.*\n?/, '').trim(); 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'); console.warn('Auto-save blocked: refusing to overwrite note with empty content');
return; return;
} }
await saveNote($activeNotePath, $activeNote.meta, body); await queueSave(() => saveNote(path, note.meta, body));
$editorDirty = false; if ($activeNotePath === path) $editorDirty = false;
} catch (e) { } catch (e) {
console.error('Auto-save failed:', e); console.error('Auto-save failed:', e);
} }
}, isMobile ? 1500 : 500); }, isMobile ? 1500 : 500);
export async function forceSave() { export async function forceSave(allowWhileTrashing = false): Promise<boolean> {
if (get(viewerNote)) return; // viewer files are never written back if (get(viewerNote) || (trashingNote && !allowWhileTrashing)) return false;
if (!$activeNote || !$activeNotePath) return; if (!$activeNote || !$activeNotePath) return false;
await fixingBlobsPromise; await fixingBlobsPromise;
if (trashingNote && !allowWhileTrashing) return false;
if (!$activeNote || !$activeNotePath) return false;
try { try {
const path = $activeNotePath;
const note = $activeNote;
const body = $sourceMode ? restoreTitleH1(sourceContent) : editorToMarkdown(); const body = $sourceMode ? restoreTitleH1(sourceContent) : editorToMarkdown();
const trimmed = body.replace(/^#.*\n?/, '').trim(); 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'); console.warn('Force-save blocked: refusing to overwrite note with empty content');
return; return false;
} }
await saveNote($activeNotePath, $activeNote.meta, body); await queueSave(() => saveNote(path, note.meta, body));
$editorDirty = false; if ($activeNotePath === path) $editorDirty = false;
return true;
} catch (e) { } catch (e) {
console.error('Save failed:', e); console.error('Save failed:', e);
return false;
}
}
export async function moveOpenNoteToTrash(): Promise<boolean> {
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 @@
}} }}
/> />
</div> </div>
{#if isMobile}
<button type="button" class="icon-btn editor-trash-btn mobile" onclick={moveOpenNoteToTrash} disabled={trashingNote} title="Move to Trash" aria-label="Move note to Trash">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14H5V6"/><path d="M8 6V4h8v2"/><path d="M10 11v5"/><path d="M14 11v5"/>
</svg>
</button>
{/if}
{#if !isMobile} {#if !isMobile}
<div class="toolbar-actions"> <div class="toolbar-actions">
{#if $canGoBack || $canGoForward} {#if $canGoBack || $canGoForward}
@@ -6198,6 +6245,11 @@
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z" /> <path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z" />
</svg> </svg>
</button> </button>
<button type="button" class="icon-btn editor-trash-btn" onclick={moveOpenNoteToTrash} disabled={trashingNote} title="Move to Trash" aria-label="Move note to Trash">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14H5V6"/><path d="M8 6V4h8v2"/><path d="M10 11v5"/><path d="M14 11v5"/>
</svg>
</button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -8156,6 +8208,17 @@
color: var(--text-primary); 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 { .icon-btn.active {
color: var(--text-accent); color: var(--text-accent);
background: var(--accent-light); background: var(--accent-light);
@@ -11295,9 +11358,9 @@
.editor-container.mobile .editor-toolbar { .editor-container.mobile .editor-toolbar {
padding: 8px 16px 6px 16px; padding: 8px 16px 6px 16px;
flex-shrink: 0; flex-shrink: 0;
flex-direction: column; flex-direction: row;
align-items: stretch; align-items: center;
gap: 2px; gap: 8px;
} }
.toolbar-actions.mobile { .toolbar-actions.mobile {
@@ -11322,6 +11385,20 @@
padding: 4px 0; 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 { .editor-container.mobile .editor-body-wrapper {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
-6
View File
@@ -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) { async function handleRestore(note: NoteEntry) {
contextMenu = null; contextMenu = null;