adding UI for note-linking

This commit is contained in:
Arkaprava Gaine
2026-08-05 10:51:08 +02:00
committed by Fred Cabassut
parent 1eebd009ac
commit e6d1f18311
4 changed files with 156 additions and 40 deletions
+49 -1
View File
@@ -33,9 +33,10 @@
interface Props { interface Props {
tick?: number; tick?: number;
editor: Editor | undefined; 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 // 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 // NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
@@ -52,6 +53,36 @@
let linkInput: HTMLInputElement | undefined = $state(); let linkInput: HTMLInputElement | undefined = $state();
let ignorePositionInvalidationUntil = 0; let ignorePositionInvalidationUntil = 0;
let noteSearchResults: { id: string; title: string }[] = $state([]);
let noteSearchTimer: ReturnType<typeof setTimeout> | 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 dropdownGap = 4;
const viewportMargin = 10; const viewportMargin = 10;
@@ -84,6 +115,7 @@
function closeDropdowns() { function closeDropdowns() {
openDropdown = null; openDropdown = null;
dropdownAnchorEl = null; dropdownAnchorEl = null;
noteSearchResults = [];
} }
function closeDropdownsAfterPositionChange() { function closeDropdownsAfterPositionChange() {
@@ -437,6 +469,7 @@
<input <input
bind:this={linkInput} bind:this={linkInput}
bind:value={linkUrl} bind:value={linkUrl}
oninput={handleLinkInput}
type="url" type="url"
placeholder="Paste a link..." placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]" class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
@@ -475,6 +508,21 @@
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</form> </form>
{#if noteSearchResults.length > 0}
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
{#each noteSearchResults as result (result.id)}
<button
type="button"
onmousedown={preventToolbarMouseFocus}
onclick={() => insertNoteLink(result.id)}
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="format-link-note-result"
>
{result.title || 'Untitled'}
</button>
{/each}
</div>
{/if}
</div> </div>
{:else if openDropdown === 'table'} {:else if openDropdown === 'table'}
<div <div
+23 -2
View File
@@ -43,9 +43,10 @@
isNew?: boolean; isNew?: boolean;
initialChecklistMode?: boolean; initialChecklistMode?: boolean;
onClose: () => void; onClose: () => 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(); const prefs = getPreferences();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
@@ -597,6 +598,7 @@
onUpdate={(md) => (content = md)} onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)} onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++} onTransaction={() => editorTick++}
{onOpenNote}
placeholder="Add a crumb..." placeholder="Add a crumb..."
/> />
{/if} {/if}
@@ -616,10 +618,29 @@
</div> </div>
{/if} {/if}
<!-- Backlinks -->
{#if note?.backlinks && note.backlinks.length > 0}
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
<div class="flex flex-wrap gap-1.5">
{#each note.backlinks as backlink (backlink.id)}
<button
type="button"
onclick={() => onOpenNote(backlink.id)}
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
data-testid="backlink-chip"
>
{backlink.title || 'Untitled'}
</button>
{/each}
</div>
</div>
{/if}
<!-- Formatting toolbar --> <!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode} {#if !rawMarkdownMode && !checklistMode}
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}> <div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} /> <FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
</div> </div>
{/if} {/if}
+36 -4
View File
@@ -9,6 +9,7 @@
import Plus from 'lucide-svelte/icons/plus'; import Plus from 'lucide-svelte/icons/plus';
import SquareCheck from 'lucide-svelte/icons/square-check'; import SquareCheck from 'lucide-svelte/icons/square-check';
import { tooltip } from '$lib/utils/tooltip.js'; import { tooltip } from '$lib/utils/tooltip.js';
import { getNote as getIdbNote } from '$lib/sync/idb.js';
interface Props { interface Props {
filter: NoteFilter; filter: NoteFilter;
@@ -31,8 +32,18 @@
// Use SvelteKit's replaceState (not the raw history API) so the router keeps // 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 // ownership of the entry — NoteEditor's close-on-back relies on popstate being
// handled by SvelteKit. // handled by SvelteKit.
function openEditor(note: Note) { async function openEditor(note: Note) {
editingNote = 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}`, {}); replaceState(`#${note.id}`, {});
} }
@@ -43,6 +54,27 @@
replaceState(location.pathname, {}); 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[]) { function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index })); const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders); updateSortOrders(orders);
@@ -137,9 +169,9 @@
{/if} {/if}
{#if showNewNote} {#if showNewNote}
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} /> <NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
{/if} {/if}
{#if editingNote} {#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} /> <NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
{/if} {/if}
+48 -33
View File
@@ -13,20 +13,25 @@
import TaskList from '@tiptap/extension-task-list'; import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item'; import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown'; import { Markdown } from 'tiptap-markdown';
import { NoteLink } from './tiptap/NoteLink.js';
import { getAllNotes } from '$lib/sync/idb.js';
interface Props { interface Props {
content: string; content: string;
onUpdate: (markdown: string) => void; onUpdate: (markdown: string) => void;
onEditor?: (editor: Editor) => void; onEditor?: (editor: Editor) => void;
onTransaction?: () => void; onTransaction?: () => void;
onOpenNote?: (noteId: string) => void;
placeholder?: string; 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 element: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state(); let editor: Editor | undefined = $state();
const titleIndexRef = { index: new Map<string, string>() };
function isTaskCheckboxTarget(target: EventTarget | null): boolean { function isTaskCheckboxTarget(target: EventTarget | null): boolean {
if (target instanceof Element) { if (target instanceof Element) {
const checkbox = target.matches('input[type="checkbox"]') const checkbox = target.matches('input[type="checkbox"]')
@@ -76,43 +81,53 @@
} }
onMount(() => { onMount(() => {
editor = new Editor({ let destroyed = false;
element: element!, let editorInstance: Editor | undefined;
extensions: [
StarterKit.configure({ link: false, underline: false }), (async () => {
Link.configure({ openOnClick: false }), const allNotes = await getAllNotes();
Underline, if (destroyed) return;
TextAlign.configure({ types: ['heading', 'paragraph'] }), titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }), editorInstance = new Editor({
TableRow, element: element!,
TableHeader, extensions: [
TableCell, 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, TaskList,
TaskItem.configure({ nested: true }), TaskItem.configure({ nested: true }),
Markdown NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
], Markdown
content, ],
onUpdate: ({ editor: e }) => { content,
// eslint-disable-next-line @typescript-eslint/no-explicit-any onUpdate: ({ editor: e }) => {
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown()); // eslint-disable-next-line @typescript-eslint/no-explicit-any
}, onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
onTransaction: () => { },
// Trigger Svelte reactivity for active state checks onTransaction: () => {
editor = editor; editor = editorInstance;
onTransaction?.(); onTransaction?.();
}
});
editor = editorInstance;
if (element && !destroyed) {
(element as any).__tiptapEditor = editorInstance;
} }
});
// Expose editor on DOM element for e2e testing onEditor?.(editorInstance);
if (element) { })();
(element as any).__tiptapEditor = editor;
}
onEditor?.(editor);
return () => { return () => {
editor?.destroy(); destroyed = true;
editorInstance?.destroy();
}; };
}); });
</script> </script>