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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -15,6 +16,7 @@ export enum LinkAttr {
}

export const LinkSpecs: ExtensionAuto = (builder) => {
builder.configureMd((md) => linkifyRawHrefPlugin(md));
builder.addMark(
linkMarkName,
() => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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('<a href="ya.ru">ya.ru</a> dasda');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, the linkifyRawHrefPlugin was added to avoid adding the protocol when the user hasn't inserted it. In my opinion, the correct approach is to always use the protocol in the link itself, as before, but not display it in the text.

So, the plugin seems unnecessary

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if i paste fdqn for ssh?

});

it('keeps explicit scheme in href', () => {
const md = mdWithPlugin();
expect(md.renderInline('https://ya.ru x')).toBe(
'<a href="https://ya.ru">https://ya.ru</a> x',
);
});

it('keeps mailto href for emails', () => {
const md = mdWithPlugin();
expect(md.renderInline('a@b.co rest')).toBe('<a href="mailto:a@b.co">a@b.co</a> rest');
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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('<a>'));
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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, when inserting a link, it will be inserted with the protocol. After this fix, judging by the test, there won't be any protocol.

I suggest changing it to

same('[ya.ru](http://ya.ru)', view.state.doc);

});

it('pastes url ending with question mark as link for selected text', () => {
const startDoc = doc(p('<a>test text<b>'));
const state = EditorState.create({
Expand Down
42 changes: 30 additions & 12 deletions packages/editor/src/extensions/markdown/Link/paste-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
);
Expand All @@ -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));
Expand All @@ -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;
Expand Down
Loading