From 6e31caa43631ce58827d16e2a551eb19db4274ce Mon Sep 17 00:00:00 2001 From: saxumcordis Date: Wed, 1 Apr 2026 19:27:42 +0300 Subject: [PATCH 1/2] chore(links): do not insert `http://` for links from buffer --- .../markdown/Link/LinkSpecs/index.ts | 2 + .../Link/linkifyRawHrefPlugin.test.ts | 28 +++++++++++ .../markdown/Link/linkifyRawHrefPlugin.ts | 46 +++++++++++++++++++ .../markdown/Link/paste-plugin.test.ts | 17 ++++++- .../extensions/markdown/Link/paste-plugin.ts | 42 ++++++++++++----- 5 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.test.ts create mode 100644 packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.ts 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; From 077bb24be2b7861683b4a99dfeac03f779d95311 Mon Sep 17 00:00:00 2001 From: saxumcordis Date: Wed, 29 Apr 2026 18:38:20 +0300 Subject: [PATCH 2/2] updts --- .../markdown/Link/LinkSpecs/index.ts | 2 - .../Link/linkifyRawHrefPlugin.test.ts | 28 ----------- .../markdown/Link/linkifyRawHrefPlugin.ts | 46 ------------------- .../markdown/Link/paste-plugin.test.ts | 6 +-- .../extensions/markdown/Link/paste-plugin.ts | 8 ++-- 5 files changed, 8 insertions(+), 82 deletions(-) delete mode 100644 packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.test.ts delete mode 100644 packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.ts diff --git a/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts b/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts index 64696682c..9036603da 100644 --- a/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts +++ b/packages/editor/src/extensions/markdown/Link/LinkSpecs/index.ts @@ -2,7 +2,6 @@ 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); @@ -16,7 +15,6 @@ 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 deleted file mode 100644 index d90a24b8b..000000000 --- a/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 3d1ce1a52..000000000 --- a/packages/editor/src/extensions/markdown/Link/linkifyRawHrefPlugin.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 f6e642e7c..de7e8a64d 100644 --- a/packages/editor/src/extensions/markdown/Link/paste-plugin.test.ts +++ b/packages/editor/src/extensions/markdown/Link/paste-plugin.test.ts @@ -31,7 +31,7 @@ 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'}, + lnkYa: {markType: linkMarkName, [LinkAttr.Href]: 'http://ya.ru'}, }); const {same} = createMarkupChecker({parser, serializer}); @@ -42,7 +42,7 @@ describe('link paste plugin', () => { expect(match?.[0]?.raw).toBe('http://example.com'); }); - it('pastes bare hostname without adding scheme to href', () => { + it('pastes bare hostname: text without scheme, href with http', () => { const startDoc = doc(p('')); const state = EditorState.create({ schema, @@ -53,7 +53,7 @@ describe('link paste plugin', () => { 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); + same('[ya.ru](http://ya.ru)', view.state.doc); }); it('pastes url ending with question mark as link for selected text', () => { diff --git a/packages/editor/src/extensions/markdown/Link/paste-plugin.ts b/packages/editor/src/extensions/markdown/Link/paste-plugin.ts index 940e28d1c..70757edf8 100644 --- a/packages/editor/src/extensions/markdown/Link/paste-plugin.ts +++ b/packages/editor/src/extensions/markdown/Link/paste-plugin.ts @@ -91,14 +91,16 @@ function getPastedLink(data: DataTransfer | null, parser: Parser): PastedLink | const text = data.getData(DataTransferType.Text); const match = parser.matchLinks(text); if (match?.[0]) { - const {raw} = match[0]; + const m = match[0]; + const {raw} = m; if (raw === text) { - const href = parser.normalizeLink(text); + const href = parser.normalizeLink(m.schema ? text : m.url); if (!parser.validateLink(href)) { return null; } - return {href, label: text}; + const label = m.schema ? text : m.raw; + return {href, label}; } if (text.endsWith('?') && raw + '?' === text && parser.validateLink(text)) { const href = parser.normalizeLink(text);