Fix internal note linking in TipTap with custom protocols, structured link insertion, and paste handlers

This commit is contained in:
chrisf committed 2026-10-23 07:51:29 -06:00
1 parent 969b6ef083
commit 49ec3bc645
1 file changed
+142 -8
+142 -8
View File
@@ -58,6 +58,18 @@ export function extractInternalNoteId(href: string): string | null {
return null;
}
export function sanitizeOrUnescapeLinks(html: string): string {
if (!html) return html;
let cleaned = html
.replace(/&lt;a href=&quot;(everclone:\/\/note\/[^&]+)&quot;&gt;(.*?)&lt;\/a&gt;/gi, '<a href="$1">$2</a>')
.replace(/&lt;a href="(everclone:\/\/note\/[^"]+)"&gt;(.*?)&lt;\/a&gt;/gi, '<a href="$1">$2</a>')
.replace(/&lt;a href=(everclone:\/\/note\/[^\s&>]+)&gt;(.*?)&lt;\/a&gt;/gi, '<a href="$1">$2</a>')
.replace(/&lt;a href&lt;a href=&quot;(everclone:\/\/note\/[^&]+)&quot;&gt;(.*?)&lt;\/a&gt;/gi, '<a href="$1">$2</a>')
.replace(/&lt;a href&lt;a href="(everclone:\/\/note\/[^"]+)"&gt;(.*?)&lt;\/a&gt;/gi, '<a href="$1">$2</a>')
.replace(/<a href<a href=/gi, '<a href=');
return cleaned;
}
interface RichNoteEditorProps {
noteId: string;
contentHtml: string;
@@ -106,6 +118,21 @@ export default function RichNoteEditor({
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
protocols: ['everclone', 'evernote'],
isAllowedUri: (url, ctx) => {
if (!url) return false;
if (
url.startsWith('everclone://') ||
url.startsWith('evernote://') ||
url.startsWith('/?noteId=') ||
url.startsWith('#note-') ||
url.startsWith('/')
) {
return true;
}
return ctx.defaultValidate(url);
},
}),
Image.configure({
inline: false,
@@ -126,7 +153,7 @@ export default function RichNoteEditor({
placeholder: 'Start writing, drag files here, or type / for formatting…',
}),
],
content: contentHtml || '<p></p>',
content: sanitizeOrUnescapeLinks(contentHtml || '<p></p>'),
editorProps: {
handleClick: (view, pos, event) => {
const target = event.target as HTMLElement | null;
@@ -181,6 +208,98 @@ export default function RichNoteEditor({
}
return false;
},
handlePaste: (view, event) => {
const clipboardData = event.clipboardData;
if (!clipboardData) return false;
const text = clipboardData.getData('text/plain')?.trim();
const html = clipboardData.getData('text/html');
// 1. Markdown note link: [Title](everclone://note/...) or [Title](url)
if (text) {
const mdMatch = text.match(/^\[(.*?)\]\((everclone:\/\/note\/[^\s)]+|\S+)\)$/);
if (mdMatch) {
event.preventDefault();
const [, linkText, linkUrl] = mdMatch;
editor
?.chain()
.focus()
.deleteSelection()
.insertContent({
type: 'text',
text: linkText || 'Note Link',
marks: [{ type: 'link', attrs: { href: linkUrl } }],
})
.insertContent(' ')
.run();
return true;
}
// 2. Direct Everclone note URL pasted: everclone://note/cmv...
if (text.startsWith('everclone://note/')) {
event.preventDefault();
const noteId = text.replace('everclone://note/', '').split(/[?#/]/)[0];
fetch(`/api/notes/${noteId}`)
.then((r) => r.json())
.then((data) => {
const title = data.title || 'Note Link';
editor
?.chain()
.focus()
.deleteSelection()
.insertContent({
type: 'text',
text: title,
marks: [{ type: 'link', attrs: { href: text } }],
})
.insertContent(' ')
.run();
})
.catch(() => {
editor
?.chain()
.focus()
.deleteSelection()
.insertContent({
type: 'text',
text: 'Note Link',
marks: [{ type: 'link', attrs: { href: text } }],
})
.insertContent(' ')
.run();
});
return true;
}
}
// 3. HTML containing everclone link
if (html && html.includes('everclone://note/')) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const anchor = doc.querySelector('a[href^="everclone://note/"]');
if (anchor) {
const href = anchor.getAttribute('href')!;
const linkTitle = anchor.textContent?.trim() || 'Note Link';
event.preventDefault();
editor
?.chain()
.focus()
.deleteSelection()
.insertContent({
type: 'text',
text: linkTitle,
marks: [{ type: 'link', attrs: { href } }],
})
.insertContent(' ')
.run();
return true;
}
} catch {}
}
return false;
},
},
onUpdate: ({ editor: ed }) => {
onChange(ed.getHTML());
@@ -192,7 +311,7 @@ export default function RichNoteEditor({
if (!editor) return;
if (currentNoteIdRef.current !== noteId) {
currentNoteIdRef.current = noteId;
editor.commands.setContent(contentHtml || '<p></p>', false);
editor.commands.setContent(sanitizeOrUnescapeLinks(contentHtml || '<p></p>'), false);
}
}, [noteId, contentHtml, editor]);
@@ -234,14 +353,29 @@ export default function RichNoteEditor({
const handleApplyLink = (url: string, text?: string) => {
if (!editor) return;
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
const hasSelection = from !== to;
const selectedText = hasSelection ? editor.state.doc.textBetween(from, to, ' ') : '';
const label = text?.trim() || selectedText.trim() || url;
if (from === to && text) {
editor.chain().focus().insertContent(`<a href="${url}">${text}</a> `).run();
} else if (text && text !== selectedText) {
editor.chain().focus().insertContent(`<a href="${url}">${text}</a>`).run();
if (!hasSelection || (text && text.trim() !== selectedText.trim())) {
editor
.chain()
.focus()
.deleteSelection()
.insertContent({
type: 'text',
text: label,
marks: [{ type: 'link', attrs: { href: url } }],
})
.insertContent(' ')
.run();
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: url })
.run();
}
};