From e62404d4c0928fe186a8fe6cbeec3dfcea75b3ff Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 3 Aug 2026 00:27:18 +0200 Subject: [PATCH] Fix dragging selected notes to notebooks --- src/lib/components/NoteList.svelte | 23 ++++++++++++++---- src/lib/components/Sidebar.svelte | 39 +++++++++++++++++++----------- src/lib/utils/note-drag.ts | 7 ++++++ tests/note-drag.test.mjs | 28 +++++++++++++++++++++ 4 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 src/lib/utils/note-drag.ts create mode 100644 tests/note-drag.test.mjs diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index 64b1b33..4938860 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -40,6 +40,7 @@ } from '$lib/api'; import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time'; import { openNoteWindow } from '$lib/utils/window'; + import { encodeNoteDragPaths } from '$lib/utils/note-drag'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; import TasksView from './TasksView.svelte'; @@ -265,6 +266,19 @@ if (listContainer) listContainer.scrollTop = 0; }); + // Notes can also be removed from this list by a drop handled in the sidebar. + $effect(() => { + if (selectedPaths.size === 0) return; + const availablePaths = new Set($notes.map((note) => note.path)); + const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path))); + if (remaining.size === selectedPaths.size) return; + if (remaining.size === 0) { + clearSelection(); + } else { + selectedPaths = remaining; + } + }); + // Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle) $effect(() => { $quickAccessPaths; @@ -1259,11 +1273,10 @@ e.dataTransfer!.effectAllowed = 'move'; return; } - if (selectedPaths.size > 1 && selectedPaths.has(note.path)) { - e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n')); - } else { - e.dataTransfer!.setData('text/plain', note.path); - } + const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path) + ? selectedPaths + : [note.path]; + e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths)); e.dataTransfer!.effectAllowed = 'move'; }} ondragover={(e) => { diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 86ba958..1bff3e4 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -27,6 +27,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'; import type { NotebookEntry } from '$lib/types'; import { isMobile } from '$lib/platform'; + import { decodeNoteDragPaths } from '$lib/utils/note-drag'; let { onViewChanged = () => {} }: { onViewChanged?: () => void; @@ -340,22 +341,32 @@ async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) { e.preventDefault(); dropTargetPath = null; - const notePath = e.dataTransfer?.getData('text/plain'); - if (!notePath) return; - // Don't move if already in this notebook - const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); - if (noteDir === nb.path) return; - try { - const newPath = await moveNote(notePath, nb.path); - $notes = $notes.filter(n => n.path !== notePath); - if ($activeNotePath === notePath) { - $activeNotePath = newPath; - $activeNote = await readNote(newPath); + const payload = e.dataTransfer?.getData('text/plain') ?? ''; + const notePaths = [...new Set(decodeNoteDragPaths(payload))] + .filter((path) => norm(parentOf(path)) !== norm(nb.path)); + if (notePaths.length === 0) return; + + const movedPaths = new Map(); + for (const notePath of notePaths) { + try { + movedPaths.set(notePath, await moveNote(notePath, nb.path)); + } catch (e) { + console.error('Failed to move note:', notePath, e); } - await refresh(); - } catch (e) { - console.error('Failed to move note:', e); } + if (movedPaths.size === 0) return; + + $notes = $notes.filter((note) => !movedPaths.has(note.path)); + const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined; + if (activeNewPath) { + $activeNotePath = activeNewPath; + try { + $activeNote = await readNote(activeNewPath); + } catch (e) { + console.error('Failed to reload moved note:', e); + } + } + await refresh(); } async function handleNotebookDrop(e: DragEvent, destPath: string) { diff --git a/src/lib/utils/note-drag.ts b/src/lib/utils/note-drag.ts new file mode 100644 index 0000000..b49fe47 --- /dev/null +++ b/src/lib/utils/note-drag.ts @@ -0,0 +1,7 @@ +export function encodeNoteDragPaths(paths: Iterable): string { + return [...paths].join("\n"); +} + +export function decodeNoteDragPaths(payload: string): string[] { + return payload.split(/\r?\n/).filter((path) => path.length > 0); +} diff --git a/tests/note-drag.test.mjs b/tests/note-drag.test.mjs new file mode 100644 index 0000000..8680412 --- /dev/null +++ b/tests/note-drag.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { transformWithEsbuild } from 'vite'; + +const source = await readFile( + new URL('../src/lib/utils/note-drag.ts', import.meta.url), + 'utf8' +); +const { code } = await transformWithEsbuild(source, 'note-drag.ts', { + loader: 'ts', + format: 'esm', + target: 'esnext' +}); +const noteDrag = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`); + +test('round-trips every selected note path in a drag payload', () => { + const paths = ['/vault/Alpha.md', '/vault/Projects/Beta.md']; + assert.deepEqual(noteDrag.decodeNoteDragPaths(noteDrag.encodeNoteDragPaths(paths)), paths); +}); + +test('keeps single-note and Windows path payloads compatible', () => { + assert.deepEqual(noteDrag.decodeNoteDragPaths('/vault/Alpha.md'), ['/vault/Alpha.md']); + assert.deepEqual( + noteDrag.decodeNoteDragPaths('C:\\Vault\\Alpha.md\r\nC:\\Vault\\Beta.md'), + ['C:\\Vault\\Alpha.md', 'C:\\Vault\\Beta.md'] + ); +});