Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions packages/editor/src/extensions/link/helpers/pasteHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,78 @@ import { Editor } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";
import { linkRegex } from "../link";

function linkifyHtml(text: string): string {
const links = find(text).filter((i) => i.isLink);
let out = "";
let lastIndex = 0;
links.forEach((link) => {
out += text.slice(lastIndex, link.start);
out += `<a href="${link.href}" target="_blank" rel="noopener noreferrer nofollow">${link.value}</a>`;
lastIndex = link.end;
});
out += text.slice(lastIndex);
return out;
}

type PasteHandlerOptions = {
editor: Editor;
type: MarkType;
linkOnPaste: boolean;
};

export function pasteHandler(options: PasteHandlerOptions): Plugin {
let shiftKey = false;
return new Plugin({
key: new PluginKey("handlePasteLink"),
props: {
handleKeyDown(_, event) {
shiftKey = event.shiftKey;
return false;
},
handlePaste: (view, event, slice) => {
const { state } = view;
const { selection } = state;
const { empty } = selection;
if (!options.linkOnPaste) {
return false;
}

if (empty) {
const clipboardHtmlData = event.clipboardData?.getData("text/html");
if (clipboardHtmlData) {
return false;
}

const { state } = view;
const { selection } = state;
const { empty } = selection;

let textContent = "";

slice.content.forEach((node) => {
textContent += node.textContent;
});

// don't deal with markdown links in this handler
if (linkRegex.test(textContent)) {
// reset the regex since we don't want the above test method to affect other places where the regex is used
linkRegex.lastIndex = 0;
return false;
}

if (shiftKey) {
shiftKey = false;
return false;
}

if (empty) {
const clipboardPlainData = event.clipboardData?.getData("text/plain");
if (clipboardPlainData) {
const html = linkifyHtml(clipboardPlainData);
options.editor.commands.insertContent(html);
return true;
}
return false;
}

const link = find(textContent).find(
(item) => item.isLink && item.value === textContent
);
Expand Down
45 changes: 7 additions & 38 deletions packages/editor/src/extensions/link/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,36 +230,6 @@ export const Link = Mark.create<LinkOptions>({
href: regExp.exec(match[0])?.[1]
};
}
}),
markPasteRule({
find: (text, ev) => {
const foundLinks: PasteRuleMatch[] = [];
const html = ev?.clipboardData?.getData("text/html");
if (html && html.includes("<a")) return [];
if (text) {
const links = find(text).filter((item) => item.isLink);

if (links.length) {
links.forEach((link) =>
foundLinks.push({
text: link.value,
data: {
href: link.href
},
index: link.start
})
);
}
}

return foundLinks;
},
type: this.type,
getAttributes: (match) => {
return {
href: match.data?.href
};
}
})
];
},
Expand All @@ -285,14 +255,13 @@ export const Link = Mark.create<LinkOptions>({
);
}

if (this.options.linkOnPaste) {
plugins.push(
pasteHandler({
editor: this.editor,
type: this.type
})
);
}
plugins.push(
pasteHandler({
editor: this.editor,
type: this.type,
linkOnPaste: this.options.linkOnPaste
})
);

return plugins;
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`paste text > with html link 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p><a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="nn://note/68798972093c8c6ea22efca2">example.py</a></p></div></div>"`;
exports[`paste text > with link 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p><a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="http://example.com">example.com</a></p></div></div>"`;

exports[`paste text > with markdown link 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p><a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="example.com">test</a></p></div></div>"`;

exports[`paste text > with multiple links 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p><a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="http://example.com">example.com</a> <a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="http://example2.com">example2.com</a></p></div></div>"`;

exports[`paste text > with multiple markdown links 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p><a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="example.com">test</a> some text <a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="example2.com">test2</a></p></div></div>"`;

exports[`unformatted paste text > with html link 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p><a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="nn://note/68798972093c8c6ea22efca2">example.py</a></p></div></div>"`;

exports[`unformatted paste text > with link shouldn't format as link 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p>https://example.com</p></div></div>"`;

exports[`unformatted paste text > with markdown link should still format as link 1`] = `"<div><div contenteditable="true" translate="no" class="tiptap ProseMirror" tabindex="0"><p><a target="_blank" rel="noopener noreferrer nofollow" spellcheck="false" href="example.com">asdf</a></p></div></div>"`;
115 changes: 112 additions & 3 deletions packages/editor/src/extensions/link/tests/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,10 @@ describe("paste text", () => {
cancelable: true,
composed: true
});

(clipboardEvent as unknown as any)["clipboardData"] = {
getData: (type: string) =>
type === "text/plain" ? "[test](example.com)" : undefined
};

editor.view.dom.dispatchEvent(clipboardEvent);

await new Promise((resolve) => setTimeout(resolve, 100));
Expand All @@ -100,14 +98,125 @@ describe("paste text", () => {
cancelable: true,
composed: true
});

(clipboardEvent as unknown as any)["clipboardData"] = {
getData: (type: string) =>
type === "text/plain"
? "[test](example.com) some text [test2](example2.com)"
: undefined
};
editor.view.dom.dispatchEvent(clipboardEvent);

await new Promise((resolve) => setTimeout(resolve, 100));

expect(editorElement.outerHTML).toMatchSnapshot();
});

test("with link", async () => {
const editorElement = h("div");
const { editor } = createEditor({
element: editorElement,
extensions: {
link: Link
}
});

const clipboardEvent = new Event("paste", {
bubbles: true,
cancelable: true,
composed: true
});
(clipboardEvent as unknown as any)["clipboardData"] = {
getData: (type: string) =>
type === "text/plain" ? "example.com" : undefined
};
editor.view.dom.dispatchEvent(clipboardEvent);

await new Promise((resolve) => setTimeout(resolve, 100));

expect(editorElement.outerHTML).toMatchSnapshot();
});

test("with multiple links", async () => {
const editorElement = h("div");
const { editor } = createEditor({
element: editorElement,
extensions: {
link: Link
}
});

const clipboardEvent = new Event("paste", {
bubbles: true,
cancelable: true,
composed: true
});
(clipboardEvent as unknown as any)["clipboardData"] = {
getData: (type: string) =>
type === "text/plain" ? "example.com example2.com" : undefined
};
editor.view.dom.dispatchEvent(clipboardEvent);

await new Promise((resolve) => setTimeout(resolve, 100));

expect(editorElement.outerHTML).toMatchSnapshot();
});
});

describe("unformatted paste text", () => {
test("with markdown link should still format as link", async () => {
const editorElement = h("div");
const { editor } = createEditor({
element: editorElement,
extensions: {
link: Link
}
});

const keyboardEvent = new KeyboardEvent("keydown", {
key: "v",
shiftKey: true
});
const clipboardEvent = new Event("paste", {
bubbles: true,
cancelable: true,
composed: true
});
(clipboardEvent as unknown as any)["clipboardData"] = {
getData: (type: string) =>
type === "text/plain" ? "[asdf](example.com)" : undefined
};
editor.view.dom.dispatchEvent(keyboardEvent);
editor.view.dom.dispatchEvent(clipboardEvent);

await new Promise((resolve) => setTimeout(resolve, 100));

expect(editorElement.outerHTML).toMatchSnapshot();
});

test("with link shouldn't format as link", async () => {
const editorElement = h("div");
const { editor } = createEditor({
element: editorElement,
extensions: {
link: Link
}
});

const keyboardEvent = new KeyboardEvent("keydown", {
key: "v",
ctrlKey: true,
shiftKey: true
});
const clipboardEvent = new Event("paste", {
bubbles: true,
cancelable: true,
composed: true
});
(clipboardEvent as unknown as any)["clipboardData"] = {
getData: (type: string) =>
type === "text/plain" ? "https://example.com" : undefined
};
editor.view.dom.dispatchEvent(keyboardEvent);
editor.view.dom.dispatchEvent(clipboardEvent);

await new Promise((resolve) => setTimeout(resolve, 100));
Expand Down