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 {
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user