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;