Fix internal note linking in TipTap with custom protocols, structured link insertion, and paste handlers
This commit is contained in:
1 parent
969b6ef083
commit
49ec3bc645
1 file changed
+142
-8
@@ -58,6 +58,18 @@ export function extractInternalNoteId(href: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sanitizeOrUnescapeLinks(html: string): string {
|
||||||
|
if (!html) return html;
|
||||||
|
let cleaned = html
|
||||||
|
.replace(/<a href="(everclone:\/\/note\/[^&]+)">(.*?)<\/a>/gi, '<a href="$1">$2</a>')
|
||||||
|
.replace(/<a href="(everclone:\/\/note\/[^"]+)">(.*?)<\/a>/gi, '<a href="$1">$2</a>')
|
||||||
|
.replace(/<a href=(everclone:\/\/note\/[^\s&>]+)>(.*?)<\/a>/gi, '<a href="$1">$2</a>')
|
||||||
|
.replace(/<a href<a href="(everclone:\/\/note\/[^&]+)">(.*?)<\/a>/gi, '<a href="$1">$2</a>')
|
||||||
|
.replace(/<a href<a href="(everclone:\/\/note\/[^"]+)">(.*?)<\/a>/gi, '<a href="$1">$2</a>')
|
||||||
|
.replace(/<a href<a href=/gi, '<a href=');
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
interface RichNoteEditorProps {
|
interface RichNoteEditorProps {
|
||||||
noteId: string;
|
noteId: string;
|
||||||
contentHtml: string;
|
contentHtml: string;
|
||||||
@@ -106,6 +118,21 @@ export default function RichNoteEditor({
|
|||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
autolink: true,
|
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({
|
Image.configure({
|
||||||
inline: false,
|
inline: false,
|
||||||
@@ -126,7 +153,7 @@ export default function RichNoteEditor({
|
|||||||
placeholder: 'Start writing, drag files here, or type / for formatting…',
|
placeholder: 'Start writing, drag files here, or type / for formatting…',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
content: contentHtml || '<p></p>',
|
content: sanitizeOrUnescapeLinks(contentHtml || '<p></p>'),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handleClick: (view, pos, event) => {
|
handleClick: (view, pos, event) => {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
@@ -181,6 +208,98 @@ export default function RichNoteEditor({
|
|||||||
}
|
}
|
||||||
return false;
|
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 }) => {
|
onUpdate: ({ editor: ed }) => {
|
||||||
onChange(ed.getHTML());
|
onChange(ed.getHTML());
|
||||||
@@ -192,7 +311,7 @@ export default function RichNoteEditor({
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (currentNoteIdRef.current !== noteId) {
|
if (currentNoteIdRef.current !== noteId) {
|
||||||
currentNoteIdRef.current = noteId;
|
currentNoteIdRef.current = noteId;
|
||||||
editor.commands.setContent(contentHtml || '<p></p>', false);
|
editor.commands.setContent(sanitizeOrUnescapeLinks(contentHtml || '<p></p>'), false);
|
||||||
}
|
}
|
||||||
}, [noteId, contentHtml, editor]);
|
}, [noteId, contentHtml, editor]);
|
||||||
|
|
||||||
@@ -234,14 +353,29 @@ export default function RichNoteEditor({
|
|||||||
const handleApplyLink = (url: string, text?: string) => {
|
const handleApplyLink = (url: string, text?: string) => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { from, to } = editor.state.selection;
|
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) {
|
if (!hasSelection || (text && text.trim() !== selectedText.trim())) {
|
||||||
editor.chain().focus().insertContent(`<a href="${url}">${text}</a> `).run();
|
editor
|
||||||
} else if (text && text !== selectedText) {
|
.chain()
|
||||||
editor.chain().focus().insertContent(`<a href="${url}">${text}</a>`).run();
|
.focus()
|
||||||
|
.deleteSelection()
|
||||||
|
.insertContent({
|
||||||
|
type: 'text',
|
||||||
|
text: label,
|
||||||
|
marks: [{ type: 'link', attrs: { href: url } }],
|
||||||
|
})
|
||||||
|
.insertContent(' ')
|
||||||
|
.run();
|
||||||
} else {
|
} else {
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.extendMarkRange('link')
|
||||||
|
.setLink({ href: url })
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user