mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
custom utility component of tiptap to solve the thing : the
This commit is contained in:
committed by
Fred Cabassut
parent
f33a4037b1
commit
bbfde5bac0
@@ -0,0 +1,101 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||||
|
|
||||||
|
export interface NoteLinkOptions {
|
||||||
|
titleIndexRef: { index: Map<string, string> };
|
||||||
|
onOpenNote: (noteId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoteLink = Node.create<NoteLinkOptions>({
|
||||||
|
name: 'noteLink',
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
titleIndexRef: { index: new Map<string, string>() },
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<string, string>();
|
||||||
|
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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, string>): string {
|
||||||
|
const title = titleIndex.get(noteId) ?? 'Untitled';
|
||||||
|
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user