forked from mirror/crumbs-original
adding UI for note-linking
This commit is contained in:
committed by
Fred Cabassut
parent
1eebd009ac
commit
e6d1f18311
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user