From e6d1f183119c4020e3fa5bb60cc82505740a5066 Mon Sep 17 00:00:00 2001 From: Arkaprava Gaine Date: Tue, 4 Aug 2026 20:37:09 +0530 Subject: [PATCH] adding UI for note-linking --- src/lib/components/FormattingToolbar.svelte | 50 ++++++++++++- src/lib/components/NoteEditor.svelte | 25 ++++++- src/lib/components/NotesView.svelte | 40 +++++++++- src/lib/components/TiptapEditor.svelte | 81 ++++++++++++--------- 4 files changed, 156 insertions(+), 40 deletions(-) diff --git a/src/lib/components/FormattingToolbar.svelte b/src/lib/components/FormattingToolbar.svelte index 3a45c91..24b1474 100644 --- a/src/lib/components/FormattingToolbar.svelte +++ b/src/lib/components/FormattingToolbar.svelte @@ -33,9 +33,10 @@ interface Props { tick?: number; editor: Editor | undefined; + currentNoteId?: string | null; } - let { editor, tick }: Props = $props(); + let { editor, tick, currentNoteId = null }: Props = $props(); // A stale `editor` reference can still point at an instance mid-teardown (e.g. while // NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its @@ -52,6 +53,36 @@ let linkInput: HTMLInputElement | undefined = $state(); let ignorePositionInvalidationUntil = 0; + let noteSearchResults: { id: string; title: string }[] = $state([]); + let noteSearchTimer: ReturnType | undefined; + + async function searchNotesForLink(query: string) { + const requestId = ++noteSearchRequestId; + if (!query.trim()) { + noteSearchResults = []; + return; + } + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + if (requestId !== noteSearchRequestId) return; + if (!res.ok) return; + const results: { id: string; title: string }[] = await res.json(); + noteSearchResults = results.filter((n) => n.id !== currentNoteId); + } + + let noteSearchRequestId = 0; + + function handleLinkInput() { + clearTimeout(noteSearchTimer); + noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200); + } + + function insertNoteLink(noteId: string) { + if (!editor) return; + editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run(); + noteSearchResults = []; + closeDropdowns(); + } + const dropdownGap = 4; const viewportMargin = 10; @@ -84,6 +115,7 @@ function closeDropdowns() { openDropdown = null; dropdownAnchorEl = null; + noteSearchResults = []; } function closeDropdownsAfterPositionChange() { @@ -437,6 +469,7 @@ + {#if noteSearchResults.length > 0} +
+ {#each noteSearchResults as result (result.id)} + + {/each} +
+ {/if} {:else if openDropdown === 'table'}
void; + onOpenNote: (noteId: string) => void; } - const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props(); + const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props(); const prefs = getPreferences(); // svelte-ignore state_referenced_locally @@ -597,6 +598,7 @@ onUpdate={(md) => (content = md)} onEditor={(e) => (tiptapEditor = e)} onTransaction={() => editorTick++} + {onOpenNote} placeholder="Add a crumb..." /> {/if} @@ -616,10 +618,29 @@
{/if} + + {#if note?.backlinks && note.backlinks.length > 0} +
+

Referenced by

+
+ {#each note.backlinks as backlink (backlink.id)} + + {/each} +
+
+ {/if} + {#if !rawMarkdownMode && !checklistMode}
- +
{/if} diff --git a/src/lib/components/NotesView.svelte b/src/lib/components/NotesView.svelte index c292e2c..2abca02 100644 --- a/src/lib/components/NotesView.svelte +++ b/src/lib/components/NotesView.svelte @@ -9,6 +9,7 @@ import Plus from 'lucide-svelte/icons/plus'; import SquareCheck from 'lucide-svelte/icons/square-check'; import { tooltip } from '$lib/utils/tooltip.js'; + import { getNote as getIdbNote } from '$lib/sync/idb.js'; interface Props { filter: NoteFilter; @@ -31,8 +32,18 @@ // Use SvelteKit's replaceState (not the raw history API) so the router keeps // ownership of the entry — NoteEditor's close-on-back relies on popstate being // handled by SvelteKit. - function openEditor(note: Note) { - editingNote = note; + async function openEditor(note: Note) { + try { + const res = await fetch(`/api/notes/${note.id}`); + if (res.ok) { + const full = await res.json(); + editingNote = { ...note, backlinks: full.backlinks ?? [] }; + } else { + editingNote = note; + } + } catch { + editingNote = note; + } replaceState(`#${note.id}`, {}); } @@ -43,6 +54,27 @@ replaceState(location.pathname, {}); } + async function openNoteById(noteId: string) { + showNewNote = false; + newNoteChecklist = false; + editingNote = null; + + try { + const res = await fetch(`/api/notes/${noteId}`); + if (res.ok) { + const full = await res.json(); + editingNote = full; + replaceState(`#${noteId}`, {}); + } + } catch { + const note = await getIdbNote(noteId); + if (note) { + editingNote = note; + replaceState(`#${noteId}`, {}); + } + } + } + function handleReorder(noteIds: string[]) { const orders = noteIds.map((id, index) => ({ id, sortOrder: index })); updateSortOrders(orders); @@ -137,9 +169,9 @@ {/if} {#if showNewNote} - + {/if} {#if editingNote} - + {/if} diff --git a/src/lib/components/TiptapEditor.svelte b/src/lib/components/TiptapEditor.svelte index da78c9a..4df4d07 100644 --- a/src/lib/components/TiptapEditor.svelte +++ b/src/lib/components/TiptapEditor.svelte @@ -13,20 +13,25 @@ import TaskList from '@tiptap/extension-task-list'; import TaskItem from '@tiptap/extension-task-item'; import { Markdown } from 'tiptap-markdown'; + import { NoteLink } from './tiptap/NoteLink.js'; + import { getAllNotes } from '$lib/sync/idb.js'; interface Props { content: string; onUpdate: (markdown: string) => void; onEditor?: (editor: Editor) => void; onTransaction?: () => void; + onOpenNote?: (noteId: string) => void; placeholder?: string; } - let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props(); + let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props(); let element: HTMLDivElement | undefined = $state(); let editor: Editor | undefined = $state(); + const titleIndexRef = { index: new Map() }; + function isTaskCheckboxTarget(target: EventTarget | null): boolean { if (target instanceof Element) { const checkbox = target.matches('input[type="checkbox"]') @@ -76,43 +81,53 @@ } onMount(() => { - editor = new Editor({ - element: element!, - extensions: [ - StarterKit.configure({ link: false, underline: false }), - Link.configure({ openOnClick: false }), - Underline, - TextAlign.configure({ types: ['heading', 'paragraph'] }), - Placeholder.configure({ placeholder }), - Table.configure({ resizable: false }), - TableRow, - TableHeader, - TableCell, + let destroyed = false; + let editorInstance: Editor | undefined; + + (async () => { + const allNotes = await getAllNotes(); + if (destroyed) return; + titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title])); + + editorInstance = new Editor({ + element: element!, + extensions: [ + StarterKit.configure({ link: false, underline: false }), + Link.configure({ openOnClick: false }), + Underline, + TextAlign.configure({ types: ['heading', 'paragraph'] }), + Placeholder.configure({ placeholder }), + Table.configure({ resizable: false }), + TableRow, + TableHeader, + TableCell, TaskList, - TaskItem.configure({ nested: true }), - Markdown - ], - content, - onUpdate: ({ editor: e }) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onUpdate((e.storage as Record).markdown.getMarkdown()); - }, - onTransaction: () => { - // Trigger Svelte reactivity for active state checks - editor = editor; - onTransaction?.(); + TaskItem.configure({ nested: true }), + NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }), + Markdown + ], + content, + onUpdate: ({ editor: e }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onUpdate((e.storage as Record).markdown.getMarkdown()); + }, + onTransaction: () => { + editor = editorInstance; + onTransaction?.(); + } + }); + editor = editorInstance; + + if (element && !destroyed) { + (element as any).__tiptapEditor = editorInstance; } - }); - // Expose editor on DOM element for e2e testing - if (element) { - (element as any).__tiptapEditor = editor; - } - - onEditor?.(editor); + onEditor?.(editorInstance); + })(); return () => { - editor?.destroy(); + destroyed = true; + editorInstance?.destroy(); }; });