Skip to content

Commit 7b7f8fb

Browse files
committed
editor: add support for pasting unformatted links
Signed-off-by: 01zulfi <[email protected]>
1 parent b296f5d commit 7b7f8fb

File tree

4 files changed

+178
-44
lines changed

4 files changed

+178
-44
lines changed

packages/editor/src/extensions/link/helpers/pasteHandler.ts

+51-4
Original file line numberDiff line numberDiff line change
@@ -21,31 +21,78 @@ import { Editor } from "@tiptap/core";
2121
import { MarkType } from "@tiptap/pm/model";
2222
import { Plugin, PluginKey } from "@tiptap/pm/state";
2323
import { find } from "linkifyjs";
24+
import { linkRegex } from "../link";
25+
26+
function linkifyHtml(text: string): string {
27+
const links = find(text).filter((i) => i.isLink);
28+
let out = "";
29+
let lastIndex = 0;
30+
links.forEach((link) => {
31+
out += text.slice(lastIndex, link.start);
32+
out += `<a href="${link.href}" target="_blank" rel="noopener noreferrer nofollow">${link.value}</a>`;
33+
lastIndex = link.end;
34+
});
35+
out += text.slice(lastIndex);
36+
return out;
37+
}
2438

2539
type PasteHandlerOptions = {
2640
editor: Editor;
2741
type: MarkType;
42+
linkOnPaste: boolean;
2843
};
2944

3045
export function pasteHandler(options: PasteHandlerOptions): Plugin {
46+
let shiftKey = false;
3147
return new Plugin({
3248
key: new PluginKey("handlePasteLink"),
3349
props: {
50+
handleKeyDown(_, event) {
51+
shiftKey = event.shiftKey;
52+
return false;
53+
},
3454
handlePaste: (view, event, slice) => {
35-
const { state } = view;
36-
const { selection } = state;
37-
const { empty } = selection;
55+
if (!options.linkOnPaste) {
56+
return false;
57+
}
3858

39-
if (empty) {
59+
const clipboardHtmlData = event.clipboardData?.getData("text/html");
60+
if (clipboardHtmlData) {
4061
return false;
4162
}
4263

64+
const { state } = view;
65+
const { selection } = state;
66+
const { empty } = selection;
67+
4368
let textContent = "";
4469

4570
slice.content.forEach((node) => {
4671
textContent += node.textContent;
4772
});
4873

74+
// don't deal with markdown links in this handler
75+
if (linkRegex.test(textContent)) {
76+
// reset the regex since we don't want the above test method to affect other places where the regex is used
77+
linkRegex.lastIndex = 0;
78+
return false;
79+
}
80+
81+
if (shiftKey) {
82+
shiftKey = false;
83+
return false;
84+
}
85+
86+
if (empty) {
87+
const clipboardPlainData = event.clipboardData?.getData("text/plain");
88+
if (clipboardPlainData) {
89+
const html = linkifyHtml(clipboardPlainData);
90+
options.editor.commands.insertContent(html);
91+
return true;
92+
}
93+
return false;
94+
}
95+
4996
const link = find(textContent).find(
5097
(item) => item.isLink && item.value === textContent
5198
);

packages/editor/src/extensions/link/link.ts

+7-37
Original file line numberDiff line numberDiff line change
@@ -227,35 +227,6 @@ export const Link = Mark.create<LinkOptions>({
227227
href: regExp.exec(match[0])?.[1]
228228
};
229229
}
230-
}),
231-
markPasteRule({
232-
find: (text) => {
233-
const foundLinks: PasteRuleMatch[] = [];
234-
235-
if (text) {
236-
const links = find(text).filter((item) => item.isLink);
237-
238-
if (links.length) {
239-
links.forEach((link) =>
240-
foundLinks.push({
241-
text: link.value,
242-
data: {
243-
href: link.href
244-
},
245-
index: link.start
246-
})
247-
);
248-
}
249-
}
250-
251-
return foundLinks;
252-
},
253-
type: this.type,
254-
getAttributes: (match) => {
255-
return {
256-
href: match.data?.href
257-
};
258-
}
259230
})
260231
];
261232
},
@@ -281,14 +252,13 @@ export const Link = Mark.create<LinkOptions>({
281252
);
282253
}
283254

284-
if (this.options.linkOnPaste) {
285-
plugins.push(
286-
pasteHandler({
287-
editor: this.editor,
288-
type: this.type
289-
})
290-
);
291-
}
255+
plugins.push(
256+
pasteHandler({
257+
editor: this.editor,
258+
type: this.type,
259+
linkOnPaste: this.options.linkOnPaste
260+
})
261+
);
292262

293263
return plugins;
294264
},
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
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" href="http://example.com">example.com</a></p></div></div>"`;
4+
35
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" href="example.com">test</a></p></div></div>"`;
46

7+
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" href="http://example.com">example.com</a> <a target="_blank" rel="noopener noreferrer nofollow" href="http://example2.com">example2.com</a></p></div></div>"`;
8+
59
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" href="example.com">test</a> some text <a target="_blank" rel="noopener noreferrer nofollow" href="example2.com">test2</a></p></div></div>"`;
10+
11+
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>"`;
12+
13+
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" href="example.com">asdf</a></p></div></div>"`;

packages/editor/src/extensions/link/tests/link.test.ts

+112-3
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,10 @@ describe("paste text", () => {
7373
cancelable: true,
7474
composed: true
7575
});
76-
7776
(clipboardEvent as unknown as any)["clipboardData"] = {
7877
getData: (type: string) =>
7978
type === "text/plain" ? "[test](example.com)" : undefined
8079
};
81-
8280
editor.view.dom.dispatchEvent(clipboardEvent);
8381

8482
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -100,14 +98,125 @@ describe("paste text", () => {
10098
cancelable: true,
10199
composed: true
102100
});
103-
104101
(clipboardEvent as unknown as any)["clipboardData"] = {
105102
getData: (type: string) =>
106103
type === "text/plain"
107104
? "[test](example.com) some text [test2](example2.com)"
108105
: undefined
109106
};
107+
editor.view.dom.dispatchEvent(clipboardEvent);
110108

109+
await new Promise((resolve) => setTimeout(resolve, 100));
110+
111+
expect(editorElement.outerHTML).toMatchSnapshot();
112+
});
113+
114+
test("with link", async () => {
115+
const editorElement = h("div");
116+
const { editor } = createEditor({
117+
element: editorElement,
118+
extensions: {
119+
link: Link
120+
}
121+
});
122+
123+
const clipboardEvent = new Event("paste", {
124+
bubbles: true,
125+
cancelable: true,
126+
composed: true
127+
});
128+
(clipboardEvent as unknown as any)["clipboardData"] = {
129+
getData: (type: string) =>
130+
type === "text/plain" ? "example.com" : undefined
131+
};
132+
editor.view.dom.dispatchEvent(clipboardEvent);
133+
134+
await new Promise((resolve) => setTimeout(resolve, 100));
135+
136+
expect(editorElement.outerHTML).toMatchSnapshot();
137+
});
138+
139+
test("with multiple links", async () => {
140+
const editorElement = h("div");
141+
const { editor } = createEditor({
142+
element: editorElement,
143+
extensions: {
144+
link: Link
145+
}
146+
});
147+
148+
const clipboardEvent = new Event("paste", {
149+
bubbles: true,
150+
cancelable: true,
151+
composed: true
152+
});
153+
(clipboardEvent as unknown as any)["clipboardData"] = {
154+
getData: (type: string) =>
155+
type === "text/plain" ? "example.com example2.com" : undefined
156+
};
157+
editor.view.dom.dispatchEvent(clipboardEvent);
158+
159+
await new Promise((resolve) => setTimeout(resolve, 100));
160+
161+
expect(editorElement.outerHTML).toMatchSnapshot();
162+
});
163+
});
164+
165+
describe("unformatted paste text", () => {
166+
test("with markdown link should still format as link", async () => {
167+
const editorElement = h("div");
168+
const { editor } = createEditor({
169+
element: editorElement,
170+
extensions: {
171+
link: Link
172+
}
173+
});
174+
175+
const keyboardEvent = new KeyboardEvent("keydown", {
176+
key: "v",
177+
shiftKey: true
178+
});
179+
const clipboardEvent = new Event("paste", {
180+
bubbles: true,
181+
cancelable: true,
182+
composed: true
183+
});
184+
(clipboardEvent as unknown as any)["clipboardData"] = {
185+
getData: (type: string) =>
186+
type === "text/plain" ? "[asdf](example.com)" : undefined
187+
};
188+
editor.view.dom.dispatchEvent(keyboardEvent);
189+
editor.view.dom.dispatchEvent(clipboardEvent);
190+
191+
await new Promise((resolve) => setTimeout(resolve, 100));
192+
193+
expect(editorElement.outerHTML).toMatchSnapshot();
194+
});
195+
196+
test("with link shouldn't format as link", async () => {
197+
const editorElement = h("div");
198+
const { editor } = createEditor({
199+
element: editorElement,
200+
extensions: {
201+
link: Link
202+
}
203+
});
204+
205+
const keyboardEvent = new KeyboardEvent("keydown", {
206+
key: "v",
207+
ctrlKey: true,
208+
shiftKey: true
209+
});
210+
const clipboardEvent = new Event("paste", {
211+
bubbles: true,
212+
cancelable: true,
213+
composed: true
214+
});
215+
(clipboardEvent as unknown as any)["clipboardData"] = {
216+
getData: (type: string) =>
217+
type === "text/plain" ? "https://example.com" : undefined
218+
};
219+
editor.view.dom.dispatchEvent(keyboardEvent);
111220
editor.view.dom.dispatchEvent(clipboardEvent);
112221

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

0 commit comments

Comments
 (0)