Skip to content

Commit 2ba9f17

Browse files
making the markdown link paste feature smart (microsoft#188119)
* making markdown link pasting feature smarter * Update settings description Co-authored-by: Joyce Er <[email protected]> * made checkPaste more concise * won't paste md link in fenced code or math * updated the smart md link pasting * link validation and naming changes * resolving comments and tests * resolving comments & writing tests --------- Co-authored-by: Joyce Er <[email protected]>
1 parent aa7bc85 commit 2ba9f17

File tree

6 files changed

+244
-62
lines changed

6 files changed

+244
-62
lines changed

extensions/markdown-language-features/package.nls.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@
4444
"configuration.copyIntoWorkspace.mediaFiles": "Try to copy external image and video files into the workspace.",
4545
"configuration.copyIntoWorkspace.never": "Do not copy external files into the workspace.",
4646
"configuration.markdown.editor.pasteUrlAsFormattedLink.enabled": "Controls how a Markdown link is created when a URL is pasted into the Markdown editor. Requires enabling `#editor.pasteAs.enabled#`.",
47-
"configuration.pasteUrlAsFormattedLink.always": "Always create a Markdown link when a URL is pasted into the Markdown editor.",
48-
"configuration.pasteUrlAsFormattedLink.smart": "Does not create a Markdown link within a link snippet or code bracket.",
49-
"configuration.pasteUrlAsFormattedLink.never": "Never create a Markdown link when a URL is pasted into the Markdown editor.",
47+
"configuration.pasteUrlAsFormattedLink.always": "Always creates a Markdown link when a URL is pasted into the Markdown editor.",
48+
"configuration.pasteUrlAsFormattedLink.smart": "Smartly avoids creating a Markdown link in specific cases, such as within code brackets or inside an existing Markdown link.",
49+
"configuration.pasteUrlAsFormattedLink.never": "Never creates a Markdown link when a URL is pasted into the Markdown editor.",
5050
"configuration.markdown.validate.enabled.description": "Enable all error reporting in Markdown files.",
5151
"configuration.markdown.validate.referenceLinks.enabled.description": "Validate reference links in Markdown files, for example: `[link][ref]`. Requires enabling `#markdown.validate.enabled#`.",
5252
"configuration.markdown.validate.fragmentLinks.enabled.description": "Validate fragment links to headers in the current Markdown file, for example: `[link](#header)`. Requires enabling `#markdown.validate.enabled#`.",

extensions/markdown-language-features/src/commands/insertResource.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ async function insertLink(activeEditor: vscode.TextEditor, selectedFiles: vscode
7676
await vscode.workspace.applyEdit(edit);
7777
}
7878

79-
function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsMedia: boolean, title = '', placeholderValue = 0, smartPaste = false) {
79+
function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsMedia: boolean, title = '', placeholderValue = 0, smartPaste = false, isExternalLink = false) {
8080
const snippetEdits = coalesce(activeEditor.selections.map((selection, i): vscode.SnippetTextEdit | undefined => {
8181
const selectionText = activeEditor.document.getText(selection);
82-
const snippet = createUriListSnippet(activeEditor.document, selectedFiles, title, placeholderValue, smartPaste, {
82+
const snippet = createUriListSnippet(activeEditor.document, selectedFiles, title, placeholderValue, smartPaste, isExternalLink, {
8383
insertAsMedia,
8484
placeholderText: selectionText,
8585
placeholderStartIndex: (i + 1) * selectedFiles.length,

extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import * as vscode from 'vscode';
77
import { Schemes } from '../../util/schemes';
8-
import { createEditForMediaFiles, getMarkdownLink, mediaMimes } from './shared';
8+
import { createEditForMediaFiles, createEditAddingLinksForUriList, mediaMimes } from './shared';
99

1010
class PasteEditProvider implements vscode.DocumentPasteEditProvider {
1111

@@ -32,7 +32,7 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider {
3232
if (!urlList) {
3333
return;
3434
}
35-
const pasteEdit = await getMarkdownLink(document, ranges, urlList, token);
35+
const pasteEdit = await createEditAddingLinksForUriList(document, ranges, urlList, token, false);
3636
if (!pasteEdit) {
3737
return;
3838
}

extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPasteLinks.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as vscode from 'vscode';
7-
import { getMarkdownLink } from './shared';
7+
import { externalUriSchemes, createEditAddingLinksForUriList } from './shared';
88
class PasteLinkEditProvider implements vscode.DocumentPasteEditProvider {
99

1010
readonly id = 'insertMarkdownLink';
@@ -19,20 +19,22 @@ class PasteLinkEditProvider implements vscode.DocumentPasteEditProvider {
1919
return;
2020
}
2121

22-
// Check if dataTransfer contains a URL
2322
const item = dataTransfer.get('text/plain');
24-
try {
25-
new URL(await item?.value);
26-
} catch (error) {
23+
const urlList = await item?.asString();
24+
25+
if (urlList === undefined) {
26+
return;
27+
}
28+
29+
if (!validateLink(urlList)) {
2730
return;
2831
}
2932

3033
const uriEdit = new vscode.DocumentPasteEdit('', this.id, '');
31-
const urlList = await item?.asString();
3234
if (!urlList) {
3335
return undefined;
3436
}
35-
const pasteEdit = await getMarkdownLink(document, ranges, urlList, token);
37+
const pasteEdit = await createEditAddingLinksForUriList(document, ranges, urlList, token, true);
3638
if (!pasteEdit) {
3739
return;
3840
}
@@ -43,6 +45,14 @@ class PasteLinkEditProvider implements vscode.DocumentPasteEditProvider {
4345
}
4446
}
4547

48+
export function validateLink(urlList: string): boolean {
49+
const url = urlList?.split(/\s+/);
50+
if (url.length > 1 || !externalUriSchemes.includes(vscode.Uri.parse(url[0]).scheme)) {
51+
return false;
52+
}
53+
return true;
54+
}
55+
4656
export function registerLinkPasteSupport(selector: vscode.DocumentSelector,) {
4757
return vscode.languages.registerDocumentPasteEditProvider(selector, new PasteLinkEditProvider(), {
4858
pasteMimeTypes: [

extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts

Lines changed: 87 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ enum MediaKind {
1717
Audio,
1818
}
1919

20-
const externalUriSchemes = [
20+
export const externalUriSchemes = [
2121
'http',
2222
'https',
23+
'mailto',
24+
'ftp',
2325
];
2426

2527
export const mediaFileExtensions = new Map<string, MediaKind>([
@@ -61,30 +63,53 @@ export const mediaMimes = new Set([
6163
'audio/x-wav',
6264
]);
6365

64-
export async function getMarkdownLink(document: vscode.TextDocument, ranges: readonly vscode.Range[], urlList: string, token: vscode.CancellationToken): Promise<{ additionalEdits: vscode.WorkspaceEdit; label: string } | undefined> {
66+
const smartPasteRegexes = [
67+
{ regex: /\[.*\]\(.*\)/g, is_markdown_link: true }, // Is a Markdown Link
68+
{ regex: /!\[.*\]\(.*\)/g, is_markdown_link: true }, // Is a Markdown Image Link
69+
{ regex: /\[([^\]]*)\]\(([^)]*)\)/g, is_markdown_link: false }, // In a Markdown link
70+
{ regex: /^```[\s\S]*?```$/gm, is_markdown_link: false }, // In a fenced code block
71+
{ regex: /^\$\$[\s\S]*?\$\$$/gm, is_markdown_link: false }, // In a fenced math block
72+
{ regex: /`[^`]*`/g, is_markdown_link: false }, // In inline code
73+
{ regex: /\$[^$]*\$/g, is_markdown_link: false }, // In inline math
74+
];
75+
export interface SmartPaste {
76+
77+
/**
78+
* `true` if the link is not being pasted within a markdown link, code, or math.
79+
*/
80+
pasteAsMarkdownLink: boolean;
81+
82+
/**
83+
* `true` if the link is being pasted over a markdown link.
84+
*/
85+
updateTitle: boolean;
86+
87+
}
88+
89+
export async function createEditAddingLinksForUriList(document: vscode.TextDocument, ranges: readonly vscode.Range[], urlList: string, token: vscode.CancellationToken, isExternalLink: boolean): Promise<{ additionalEdits: vscode.WorkspaceEdit; label: string } | undefined> {
6590
if (ranges.length === 0) {
6691
return;
6792
}
6893
const enabled = vscode.workspace.getConfiguration('markdown', document).get<'always' | 'smart' | 'never'>('editor.pasteUrlAsFormattedLink.enabled', 'always');
69-
7094
const edits: vscode.SnippetTextEdit[] = [];
7195
let placeHolderValue: number = ranges.length;
7296
let label: string = '';
73-
let smartPaste: boolean = false;
97+
let smartPaste = { pasteAsMarkdownLink: true, updateTitle: false };
98+
7499
for (let i = 0; i < ranges.length; i++) {
100+
101+
let title = document.getText(ranges[i]);
75102
if (enabled === 'smart') {
76-
const inMarkdownLink = checkPaste(document, ranges, /\[([^\]]*)\]\(([^)]*)\)/g, i);
77-
const inFencedCode = checkPaste(document, ranges, /^```[\s\S]*?```$/gm, i);
78-
const inFencedMath = checkPaste(document, ranges, /^\$\$[\s\S]*?\$\$$/gm, i);
79-
smartPaste = (inMarkdownLink || inFencedCode || inFencedMath);
103+
smartPaste = checkSmartPaste(document.getText(), document.offsetAt(ranges[i].start), document.offsetAt(ranges[i].end));
104+
title = smartPaste.updateTitle ? '' : document.getText(ranges[i]);
80105
}
81106

82-
const snippet = await tryGetUriListSnippet(document, urlList, token, document.getText(ranges[i]), placeHolderValue, smartPaste);
107+
const snippet = await tryGetUriListSnippet(document, urlList, token, title, placeHolderValue, smartPaste.pasteAsMarkdownLink, isExternalLink);
83108
if (!snippet) {
84109
return;
85110
}
86111

87-
smartPaste = false;
112+
smartPaste.pasteAsMarkdownLink = true;
88113
placeHolderValue--;
89114
edits.push(new vscode.SnippetTextEdit(ranges[i], snippet.snippet));
90115
label = snippet.label;
@@ -96,20 +121,25 @@ export async function getMarkdownLink(document: vscode.TextDocument, ranges: rea
96121
return { additionalEdits, label };
97122
}
98123

99-
function checkPaste(document: vscode.TextDocument, ranges: readonly vscode.Range[], regex: RegExp, index: number): boolean {
100-
const rangeStartOffset = document.offsetAt(ranges[index].start);
101-
const rangeEndOffset = document.offsetAt(ranges[index].end);
102-
const matches = [...document.getText().matchAll(regex)];
103-
for (const match of matches) {
104-
if (match.index !== undefined && rangeStartOffset > match.index && rangeEndOffset < match.index + match[0].length) {
105-
return true;
124+
export function checkSmartPaste(documentText: string, start: number, end: number): SmartPaste {
125+
const SmartPaste: SmartPaste = { pasteAsMarkdownLink: true, updateTitle: false };
126+
for (const regex of smartPasteRegexes) {
127+
const matches = [...documentText.matchAll(regex.regex)];
128+
for (const match of matches) {
129+
if (match.index !== undefined) {
130+
const useDefaultPaste = start > match.index && end < match.index + match[0].length;
131+
SmartPaste.pasteAsMarkdownLink = !useDefaultPaste;
132+
SmartPaste.updateTitle = regex.is_markdown_link && start === match.index && end === match.index + match[0].length;
133+
if (!SmartPaste.pasteAsMarkdownLink || SmartPaste.updateTitle) {
134+
return SmartPaste;
135+
}
136+
}
106137
}
107138
}
108-
109-
return false;
139+
return SmartPaste;
110140
}
111141

112-
export async function tryGetUriListSnippet(document: vscode.TextDocument, urlList: String, token: vscode.CancellationToken, title = '', placeHolderValue = 0, smartPaste = false): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> {
142+
export async function tryGetUriListSnippet(document: vscode.TextDocument, urlList: String, token: vscode.CancellationToken, title = '', placeHolderValue = 0, pasteAsMarkdownLink = true, isExternalLink = false): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> {
113143
if (token.isCancellationRequested) {
114144
return undefined;
115145
}
@@ -123,7 +153,7 @@ export async function tryGetUriListSnippet(document: vscode.TextDocument, urlLis
123153
}
124154
}
125155

126-
return createUriListSnippet(document, uris, title, placeHolderValue, smartPaste);
156+
return createUriListSnippet(document, uris, title, placeHolderValue, pasteAsMarkdownLink, isExternalLink);
127157
}
128158

129159
interface UriListSnippetOptions {
@@ -141,28 +171,48 @@ interface UriListSnippetOptions {
141171
readonly separator?: string;
142172
}
143173

174+
export function createLinkSnippet(
175+
pasteAsMarkdownLink: boolean,
176+
mdPath: string,
177+
title: string,
178+
uri: vscode.Uri,
179+
placeholderValue: number,
180+
isExternalLink: boolean,
181+
): vscode.SnippetString {
182+
const uriString = uri.toString(true);
183+
const snippet = new vscode.SnippetString();
184+
if (pasteAsMarkdownLink) {
185+
snippet.appendText('[');
186+
snippet.appendPlaceholder(escapeBrackets(title) || 'Title', placeholderValue);
187+
snippet.appendText(isExternalLink ? `](${uriString})` : `](${escapeMarkdownLinkPath(mdPath)})`);
188+
} else {
189+
snippet.appendText(isExternalLink ? uriString : escapeMarkdownLinkPath(mdPath));
190+
}
191+
return snippet;
192+
}
193+
144194
export function createUriListSnippet(
145195
document: vscode.TextDocument,
146196
uris: readonly vscode.Uri[],
147197
title = '',
148198
placeholderValue = 0,
149-
smartPaste = false,
199+
pasteAsMarkdownLink = true,
200+
isExternalLink = false,
150201
options?: UriListSnippetOptions,
151202
): { snippet: vscode.SnippetString; label: string } | undefined {
152203
if (!uris.length) {
153204
return;
154205
}
155206

156-
const dir = getDocumentDir(document);
157-
158-
const snippet = new vscode.SnippetString();
207+
const documentDir = getDocumentDir(document);
159208

209+
let snippet = new vscode.SnippetString();
160210
let insertedLinkCount = 0;
161211
let insertedImageCount = 0;
162212
let insertedAudioVideoCount = 0;
163213

164214
uris.forEach((uri, i) => {
165-
const mdPath = getMdPath(dir, uri);
215+
const mdPath = getMdPath(documentDir, uri);
166216

167217
const ext = URI.Utils.extname(uri).toLowerCase().replace('.', '');
168218
const insertAsMedia = typeof options?.insertAsMedia === 'undefined' ? mediaFileExtensions.has(ext) : !!options.insertAsMedia;
@@ -179,33 +229,22 @@ export function createUriListSnippet(
179229
snippet.appendText(`<audio src="${escapeHtmlAttribute(mdPath)}" controls title="`);
180230
snippet.appendPlaceholder(escapeBrackets(title) || 'Title', placeholderValue);
181231
snippet.appendText('"></audio>');
182-
} else {
232+
} else if (insertAsMedia) {
183233
if (insertAsMedia) {
184234
insertedImageCount++;
185-
snippet.appendText('![');
186-
const placeholderText = escapeBrackets(title) || options?.placeholderText || 'Alt text';
187-
const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : (placeholderValue === 0 ? undefined : placeholderValue);
188-
snippet.appendPlaceholder(placeholderText, placeholderIndex);
189-
snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);
190-
} else {
191-
insertedLinkCount++;
192-
if (smartPaste) {
193-
if (externalUriSchemes.includes(uri.scheme)) {
194-
snippet.appendText(uri.toString(true));
195-
} else {
196-
snippet.appendText(escapeMarkdownLinkPath(mdPath));
197-
}
235+
if (pasteAsMarkdownLink) {
236+
snippet.appendText('![');
237+
const placeholderText = escapeBrackets(title) || options?.placeholderText || 'Alt text';
238+
const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : (placeholderValue === 0 ? undefined : placeholderValue);
239+
snippet.appendPlaceholder(placeholderText, placeholderIndex);
240+
snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);
198241
} else {
199-
snippet.appendText('[');
200-
snippet.appendPlaceholder(escapeBrackets(title) || 'Title', placeholderValue);
201-
if (externalUriSchemes.includes(uri.scheme)) {
202-
const uriString = uri.toString(true);
203-
snippet.appendText(`](${uriString})`);
204-
} else {
205-
snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);
206-
}
242+
snippet.appendText(escapeMarkdownLinkPath(mdPath));
207243
}
208244
}
245+
} else {
246+
insertedLinkCount++;
247+
snippet = createLinkSnippet(pasteAsMarkdownLink, mdPath, title, uri, placeholderValue, isExternalLink);
209248
}
210249

211250
if (i < uris.length - 1 && uris.length > 1) {

0 commit comments

Comments
 (0)