diff --git a/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts b/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts
index 9036603da..64696682c 100644
--- a/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts
+++ b/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts
@@ -2,6 +2,7 @@ import type {Mark, Node} from 'prosemirror-model';
import type {ExtensionAuto} from '../../../../core';
import {markTypeFactory} from '../../../../utils/schema';
+import {linkifyRawHrefPlugin} from '../linkifyRawHrefPlugin';
export const linkMarkName = 'link';
export const linkType = markTypeFactory(linkMarkName);
@@ -15,6 +16,7 @@ export enum LinkAttr {
}
export const LinkSpecs: ExtensionAuto = (builder) => {
+ builder.configureMd((md) => linkifyRawHrefPlugin(md));
builder.addMark(
linkMarkName,
() => ({
diff --git a/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.test.ts b/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.test.ts
new file mode 100644
index 000000000..d90a24b8b
--- /dev/null
+++ b/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.test.ts
@@ -0,0 +1,28 @@
+import MarkdownIt from 'markdown-it';
+
+import {linkifyRawHrefPlugin} from './linkifyRawHrefPlugin';
+
+function mdWithPlugin() {
+ const md = MarkdownIt({linkify: true});
+ linkifyRawHrefPlugin(md);
+ return md;
+}
+
+describe('linkifyRawHrefPlugin', () => {
+ it('uses raw hostname in href for pasted text with trailing words', () => {
+ const md = mdWithPlugin();
+ expect(md.renderInline('ya.ru dasda')).toBe('ya.ru dasda');
+ });
+
+ it('keeps explicit scheme in href', () => {
+ const md = mdWithPlugin();
+ expect(md.renderInline('https://ya.ru x')).toBe(
+ 'https://ya.ru x',
+ );
+ });
+
+ it('keeps mailto href for emails', () => {
+ const md = mdWithPlugin();
+ expect(md.renderInline('a@b.co rest')).toBe('a@b.co rest');
+ });
+});
diff --git a/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.ts b/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.ts
new file mode 100644
index 000000000..3d1ce1a52
--- /dev/null
+++ b/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.ts
@@ -0,0 +1,46 @@
+import type MarkdownIt from 'markdown-it';
+
+/**
+ * markdown-it linkify sets href to linkify-it's normalized URL (e.g. http://ya.ru for "ya.ru").
+ * Use the matched raw string when there was no explicit schema so href matches pasted text.
+ */
+export function linkifyRawHrefPlugin(md: MarkdownIt): MarkdownIt {
+ md.core.ruler.after('linkify', 'linkify_raw_href', (state) => {
+ for (let j = 0; j < state.tokens.length; j++) {
+ const block = state.tokens[j];
+ if (block.type !== 'inline' || !block.children) continue;
+
+ const children = block.children;
+ for (let i = 0; i < children.length; i++) {
+ const open = children[i];
+ if (
+ open.type !== 'link_open' ||
+ open.markup !== 'linkify' ||
+ open.info !== 'auto'
+ ) {
+ continue;
+ }
+
+ const textTok = children[i + 1];
+ const close = children[i + 2];
+ if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') {
+ continue;
+ }
+
+ const chunk = textTok.content;
+ const matches = md.linkify.match(chunk);
+ if (!matches || matches.length !== 1) continue;
+
+ const m = matches[0];
+ if (m.index !== 0 || m.lastIndex !== chunk.length) continue;
+
+ const source = m.schema ? m.url : m.raw;
+ const href = md.normalizeLink(source);
+ if (md.validateLink(href)) {
+ open.attrSet('href', href);
+ }
+ }
+ }
+ });
+ return md;
+}
diff --git a/packages/editor/src/extensions/markdown/Link/paste-plugin.test.ts b/packages/editor/src/extensions/markdown/Link/paste-plugin.test.ts
index 7f27f9117..f6e642e7c 100644
--- a/packages/editor/src/extensions/markdown/Link/paste-plugin.test.ts
+++ b/packages/editor/src/extensions/markdown/Link/paste-plugin.test.ts
@@ -27,10 +27,11 @@ const {
}).build();
plugins.unshift(LoggerFacet.of(logger));
-const {doc, p, lnk} = builders<'doc' | 'p' | 'lnk'>(schema, {
+const {doc, p, lnk, lnkYa} = builders<'doc' | 'p' | 'lnk' | 'lnkYa'>(schema, {
doc: {nodeType: BaseNode.Doc},
p: {nodeType: BaseNode.Paragraph},
lnk: {markType: linkMarkName, [LinkAttr.Href]: 'http://example.com?'},
+ lnkYa: {markType: linkMarkName, [LinkAttr.Href]: 'ya.ru'},
});
const {same} = createMarkupChecker({parser, serializer});
@@ -41,6 +42,20 @@ describe('link paste plugin', () => {
expect(match?.[0]?.raw).toBe('http://example.com');
});
+ it('pastes bare hostname without adding scheme to href', () => {
+ const startDoc = doc(p(''));
+ const state = EditorState.create({
+ schema,
+ doc: startDoc,
+ selection: TextSelection.create(startDoc, startDoc.tag.a),
+ plugins,
+ });
+ const view = new EditorView(null, {state});
+ dispatchPasteEvent(view, {'text/plain': 'ya.ru'});
+ expect(view.state.doc).toMatchNode(doc(p(lnkYa('ya.ru'))));
+ same('[ya.ru](ya.ru)', view.state.doc);
+ });
+
it('pastes url ending with question mark as link for selected text', () => {
const startDoc = doc(p('test text'));
const state = EditorState.create({
diff --git a/packages/editor/src/extensions/markdown/Link/paste-plugin.ts b/packages/editor/src/extensions/markdown/Link/paste-plugin.ts
index 27a96e2ea..940e28d1c 100644
--- a/packages/editor/src/extensions/markdown/Link/paste-plugin.ts
+++ b/packages/editor/src/extensions/markdown/Link/paste-plugin.ts
@@ -7,6 +7,8 @@ import {imageType} from '../Image';
import {LinkAttr, linkType} from './index';
+type PastedLink = {href: string; label: string};
+
export function linkPasteEnhance({markupParser: parser}: ExtensionDeps) {
return new Plugin({
props: {
@@ -27,15 +29,15 @@ export function linkPasteEnhance({markupParser: parser}: ExtensionDeps) {
) {
const {$from, $to} = sel;
if ($from.pos === $to.pos) {
- const url = getUrl(e.clipboardData, parser);
- if (url) {
+ const pasted = getPastedLink(e.clipboardData, parser);
+ if (pasted) {
const linkMarkType = linkType(state.schema);
tr = state.tr.replaceSelectionWith(
- state.schema.text(url, [
+ state.schema.text(pasted.label, [
...$from
.marks()
.filter((mark) => mark.type !== linkMarkType),
- linkMarkType.create({[LinkAttr.Href]: url}),
+ linkMarkType.create({[LinkAttr.Href]: pasted.href}),
]),
false,
);
@@ -44,13 +46,13 @@ export function linkPasteEnhance({markupParser: parser}: ExtensionDeps) {
});
}
} else if ($from.sameParent($to)) {
- const url = getUrl(e.clipboardData, parser);
- if (url) {
+ const pasted = getPastedLink(e.clipboardData, parser);
+ if (pasted) {
tr = state.tr.addMark(
$from.pos,
$to.pos,
linkType(state.schema).create({
- [LinkAttr.Href]: url,
+ [LinkAttr.Href]: pasted.href,
}),
);
tr.setSelection(TextSelection.create(tr.doc, $to.pos));
@@ -74,17 +76,33 @@ export function linkPasteEnhance({markupParser: parser}: ExtensionDeps) {
});
}
-function getUrl(data: DataTransfer | null, parser: Parser): string | null {
+function getPastedLink(data: DataTransfer | null, parser: Parser): PastedLink | null {
if (!data || data.types.includes(DataTransferType.Yfm)) return null;
- if (isIosSafariShare(data)) return data.getData(DataTransferType.UriList);
+ if (isIosSafariShare(data)) {
+ const href = data.getData(DataTransferType.UriList);
+ if (!href) {
+ return null;
+ }
+
+ const trimmed = href.trim();
+ return {href: trimmed, label: trimmed};
+ }
// TODO: should we process HTML here?
const text = data.getData(DataTransferType.Text);
const match = parser.matchLinks(text);
if (match?.[0]) {
- const {raw, url} = match[0];
- if (raw === text) return url;
+ const {raw} = match[0];
+ if (raw === text) {
+ const href = parser.normalizeLink(text);
+ if (!parser.validateLink(href)) {
+ return null;
+ }
+
+ return {href, label: text};
+ }
if (text.endsWith('?') && raw + '?' === text && parser.validateLink(text)) {
- return parser.normalizeLink(text);
+ const href = parser.normalizeLink(text);
+ return {href, label: text};
}
}
return null;