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 {
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<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 viewportMargin = 10;
@@ -84,6 +115,7 @@
function closeDropdowns() {
openDropdown = null;
dropdownAnchorEl = null;
noteSearchResults = [];
}
function closeDropdownsAfterPositionChange() {
@@ -437,6 +469,7 @@
<input
bind:this={linkInput}
bind:value={linkUrl}
oninput={handleLinkInput}
type="url"
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)]"
@@ -475,6 +508,21 @@
<Trash2 size={16} />
</button>
</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>
{:else if openDropdown === 'table'}
<div
+23 -2
View File
@@ -43,9 +43,10 @@
isNew?: boolean;
initialChecklistMode?: boolean;
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();
// 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 @@
</div>
{/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 -->
{#if !rawMarkdownMode && !checklistMode}
<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>
{/if}
+36 -4
View File
@@ -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}
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
{/if}
+48 -33
View File
@@ -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<string, string>() };
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<string, any>).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<string, any>).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();
};
});
</script>