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 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<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) {
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 @@
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} />
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
</div>
</div>
@@ -1028,7 +1050,7 @@
{/if}
<div class="editor-panel">
<Editor bind:this={editor} />
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
{#if $viewMode === 'tasks' && !taskNoteOpened}
<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">
+91 -14
View File
@@ -57,6 +57,10 @@
import { isMobile, isAndroid } from '$lib/platform';
import ResizeHandle from './ResizeHandle.svelte';
let { onMoveToTrash = async () => false }: {
onMoveToTrash?: (path: string) => Promise<boolean>;
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const sourceHighlighter = hljs.newInstance();
sourceHighlighter.registerLanguage('markdown', markdownLanguage);
@@ -103,6 +107,7 @@
let pendingContent = $state<string | null>(null);
let ignoreNextUpdate = false;
let isLoadingNote = false;
let trashingNote = $state(false);
let fixingBlobsPromise: Promise<void> = Promise.resolve();
let hasPendingBlobs = false;
let lastSourceMode = $sourceMode;
@@ -2993,8 +2998,16 @@
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 () => {
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<boolean> {
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<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>
{#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}
<div class="toolbar-actions">
{#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" />
</svg>
</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>
{/if}
</div>
@@ -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;
-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) {
contextMenu = null;