From bbfde5bac0b77b581d3644139d14434cc472616c Mon Sep 17 00:00:00 2001 From: Arkaprava Gaine Date: Tue, 4 Aug 2026 20:43:48 +0530 Subject: [PATCH] custom utility component of tiptap to solve the thing : the --- src/lib/components/tiptap/NoteLink.ts | 101 ++++++++++++++++++ .../tiptap/note-link-markdown.test.ts | 33 ++++++ .../components/tiptap/note-link-markdown.ts | 22 ++++ 3 files changed, 156 insertions(+) create mode 100644 src/lib/components/tiptap/NoteLink.ts create mode 100644 src/lib/components/tiptap/note-link-markdown.test.ts create mode 100644 src/lib/components/tiptap/note-link-markdown.ts diff --git a/src/lib/components/tiptap/NoteLink.ts b/src/lib/components/tiptap/NoteLink.ts new file mode 100644 index 0000000..24ffbd6 --- /dev/null +++ b/src/lib/components/tiptap/NoteLink.ts @@ -0,0 +1,101 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js'; + +export interface NoteLinkOptions { + titleIndexRef: { index: Map }; + onOpenNote: (noteId: string) => void; +} + +export const NoteLink = Node.create({ + name: 'noteLink', + group: 'inline', + inline: true, + atom: true, + + addOptions() { + return { + titleIndexRef: { index: new Map() }, + onOpenNote: () => {} + }; + }, + + addAttributes() { + return { + noteId: { + default: null, + parseHTML: (element) => element.getAttribute('data-note-id'), + renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId }) + } + }; + }, + + parseHTML() { + return [ + { + tag: 'a[href^="crumb-note://"]', + priority: 100, + getAttrs: (element) => { + const href = (element as HTMLElement).getAttribute('href') ?? ''; + const noteId = parseNoteLinkHref(href); + return noteId ? { noteId } : false; + } + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })]; + }, + + addStorage() { + return { + markdown: { + serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => { + state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index)); + } + } + }; + }, + + addNodeView() { + return ({ node, editor, getPos }) => { + const noteId = node.attrs.noteId as string; + const title = this.options.titleIndexRef.index.get(noteId); + + const dom = document.createElement('span'); + dom.setAttribute('data-note-id', noteId); + dom.className = + 'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm'; + + const label = document.createElement('span'); + if (title !== undefined) { + label.textContent = title; + label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]'; + label.addEventListener('click', (event) => { + event.preventDefault(); + this.options.onOpenNote(noteId); + }); + } else { + label.textContent = 'Note not found'; + label.className = 'text-[var(--text-muted)] opacity-60'; + } + dom.appendChild(label); + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.textContent = '×'; + removeButton.setAttribute('aria-label', 'Remove note link'); + removeButton.className = + 'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]'; + removeButton.addEventListener('click', (event) => { + event.preventDefault(); + const pos = getPos(); + if (pos === undefined) return; + editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run(); + }); + dom.appendChild(removeButton); + + return { dom }; + }; + } +}); diff --git a/src/lib/components/tiptap/note-link-markdown.test.ts b/src/lib/components/tiptap/note-link-markdown.test.ts new file mode 100644 index 0000000..3c976c9 --- /dev/null +++ b/src/lib/components/tiptap/note-link-markdown.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js'; + +describe('parseNoteLinkHref', () => { + it('should extract the note ID from a crumb-note:// href', () => { + expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123'); + }); + + it('should return null for a non-crumb-note href', () => { + expect(parseNoteLinkHref('https://example.com')).toBeNull(); + }); + + it('should return null for an empty string', () => { + expect(parseNoteLinkHref('')).toBeNull(); + }); +}); + +describe('serializeNoteLinkMarkdown', () => { + it('should produce a markdown link with the title from the index', () => { + const titleIndex = new Map([['abc-123', 'Grocery List']]); + expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)'); + }); + + it('should fall back to Untitled when the ID is not in the index', () => { + const titleIndex = new Map(); + expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)'); + }); + + it('should escape markdown-special characters in the title', () => { + const titleIndex = new Map([['id-1', 'Buy [milk] today']]); + expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)'); + }); +}); diff --git a/src/lib/components/tiptap/note-link-markdown.ts b/src/lib/components/tiptap/note-link-markdown.ts new file mode 100644 index 0000000..50a2fda --- /dev/null +++ b/src/lib/components/tiptap/note-link-markdown.ts @@ -0,0 +1,22 @@ +const NOTE_LINK_SCHEME = 'crumb-note://'; + +/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */ +export function parseNoteLinkHref(href: string): string | null { + if (!href.startsWith(NOTE_LINK_SCHEME)) return null; + return href.slice(NOTE_LINK_SCHEME.length) || null; +} + +/** + * Escapes markdown-special characters the same way a plain link's visible + * text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt + * the surrounding markdown syntax. + */ +function escapeMarkdownText(text: string): string { + return text.replace(/[[\]\\*_`]/g, '\\$&'); +} + +/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */ +export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map): string { + const title = titleIndex.get(noteId) ?? 'Untitled'; + return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`; +}