Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts
3294 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as path from 'path';
7
import * as vscode from 'vscode';
8
import * as URI from 'vscode-uri';
9
import { ITextDocument } from '../../types/textDocument';
10
import { getDocumentDir } from '../../util/document';
11
import { Schemes } from '../../util/schemes';
12
import { UriList } from '../../util/uriList';
13
import { resolveSnippet } from './snippets';
14
import { mediaFileExtensions, MediaKind } from '../../util/mimes';
15
16
/** Base kind for any sort of markdown link, including both path and media links */
17
export const baseLinkEditKind = vscode.DocumentDropOrPasteEditKind.Empty.append('markdown', 'link');
18
19
/** Kind for normal markdown links, i.e. `[text](path/to/file.md)` */
20
export const linkEditKind = baseLinkEditKind.append('uri');
21
22
export const imageEditKind = baseLinkEditKind.append('image');
23
export const audioEditKind = baseLinkEditKind.append('audio');
24
export const videoEditKind = baseLinkEditKind.append('video');
25
26
export function getSnippetLabelAndKind(counter: { readonly insertedAudioCount: number; readonly insertedVideoCount: number; readonly insertedImageCount: number; readonly insertedLinkCount: number }): {
27
label: string;
28
kind: vscode.DocumentDropOrPasteEditKind;
29
} {
30
if (counter.insertedVideoCount > 0 || counter.insertedAudioCount > 0) {
31
// Any media plus links
32
if (counter.insertedLinkCount > 0) {
33
return {
34
label: vscode.l10n.t('Insert Markdown Media and Links'),
35
kind: baseLinkEditKind,
36
};
37
}
38
39
// Any media plus images
40
if (counter.insertedImageCount > 0) {
41
return {
42
label: vscode.l10n.t('Insert Markdown Media and Images'),
43
kind: baseLinkEditKind,
44
};
45
}
46
47
// Audio only
48
if (counter.insertedAudioCount > 0 && !counter.insertedVideoCount) {
49
return {
50
label: vscode.l10n.t('Insert Markdown Audio'),
51
kind: audioEditKind,
52
};
53
}
54
55
// Video only
56
if (counter.insertedVideoCount > 0 && !counter.insertedAudioCount) {
57
return {
58
label: vscode.l10n.t('Insert Markdown Video'),
59
kind: videoEditKind,
60
};
61
}
62
63
// Mix of audio and video
64
return {
65
label: vscode.l10n.t('Insert Markdown Media'),
66
kind: baseLinkEditKind,
67
};
68
} else if (counter.insertedImageCount > 0) {
69
// Mix of images and links
70
if (counter.insertedLinkCount > 0) {
71
return {
72
label: vscode.l10n.t('Insert Markdown Images and Links'),
73
kind: baseLinkEditKind,
74
};
75
}
76
77
// Just images
78
return {
79
label: counter.insertedImageCount > 1
80
? vscode.l10n.t('Insert Markdown Images')
81
: vscode.l10n.t('Insert Markdown Image'),
82
kind: imageEditKind,
83
};
84
} else {
85
return {
86
label: counter.insertedLinkCount > 1
87
? vscode.l10n.t('Insert Markdown Links')
88
: vscode.l10n.t('Insert Markdown Link'),
89
kind: linkEditKind,
90
};
91
}
92
}
93
94
export function createInsertUriListEdit(
95
document: ITextDocument,
96
ranges: readonly vscode.Range[],
97
urlList: UriList,
98
options?: UriListSnippetOptions,
99
): { edits: vscode.SnippetTextEdit[]; label: string; kind: vscode.DocumentDropOrPasteEditKind } | undefined {
100
if (!ranges.length || !urlList.entries.length) {
101
return;
102
}
103
104
const edits: vscode.SnippetTextEdit[] = [];
105
106
let insertedLinkCount = 0;
107
let insertedImageCount = 0;
108
let insertedAudioCount = 0;
109
let insertedVideoCount = 0;
110
111
// Use 1 for all empty ranges but give non-empty range unique indices starting after 1
112
let placeHolderStartIndex = 1 + urlList.entries.length;
113
114
// Sort ranges by start position
115
const orderedRanges = [...ranges].sort((a, b) => a.start.compareTo(b.start));
116
const allRangesAreEmpty = orderedRanges.every(range => range.isEmpty);
117
118
for (const range of orderedRanges) {
119
const snippet = createUriListSnippet(document.uri, urlList.entries, {
120
placeholderText: range.isEmpty ? undefined : document.getText(range),
121
placeholderStartIndex: allRangesAreEmpty ? 1 : placeHolderStartIndex,
122
...options,
123
});
124
if (!snippet) {
125
continue;
126
}
127
128
insertedLinkCount += snippet.insertedLinkCount;
129
insertedImageCount += snippet.insertedImageCount;
130
insertedAudioCount += snippet.insertedAudioCount;
131
insertedVideoCount += snippet.insertedVideoCount;
132
133
placeHolderStartIndex += urlList.entries.length;
134
135
edits.push(new vscode.SnippetTextEdit(range, snippet.snippet));
136
}
137
138
const { label, kind } = getSnippetLabelAndKind({ insertedAudioCount, insertedVideoCount, insertedImageCount, insertedLinkCount });
139
return { edits, label, kind };
140
}
141
142
interface UriListSnippetOptions {
143
readonly placeholderText?: string;
144
145
readonly placeholderStartIndex?: number;
146
147
/**
148
* Hints how links should be inserted, e.g. as normal markdown link or as an image.
149
*
150
* By default this is inferred from the uri. If you use `media`, we will insert the resource as an image, video, or audio.
151
*/
152
readonly linkKindHint?: vscode.DocumentDropOrPasteEditKind | 'media';
153
154
readonly separator?: string;
155
156
/**
157
* Prevents uris from being made relative to the document.
158
*
159
* This is mostly useful for `file:` uris.
160
*/
161
readonly preserveAbsoluteUris?: boolean;
162
}
163
164
165
export interface UriSnippet {
166
readonly snippet: vscode.SnippetString;
167
readonly insertedLinkCount: number;
168
readonly insertedImageCount: number;
169
readonly insertedVideoCount: number;
170
readonly insertedAudioCount: number;
171
}
172
173
export function createUriListSnippet(
174
document: vscode.Uri,
175
uris: ReadonlyArray<{
176
readonly uri: vscode.Uri;
177
readonly str?: string;
178
readonly kind?: MediaKind;
179
}>,
180
options?: UriListSnippetOptions,
181
): UriSnippet | undefined {
182
if (!uris.length) {
183
return;
184
}
185
186
const documentDir = getDocumentDir(document);
187
const config = vscode.workspace.getConfiguration('markdown', document);
188
const title = options?.placeholderText || 'Title';
189
190
let insertedLinkCount = 0;
191
let insertedImageCount = 0;
192
let insertedAudioCount = 0;
193
let insertedVideoCount = 0;
194
195
const snippet = new vscode.SnippetString();
196
let placeholderIndex = options?.placeholderStartIndex ?? 1;
197
198
uris.forEach((uri, i) => {
199
const mdPath = (!options?.preserveAbsoluteUris ? getRelativeMdPath(documentDir, uri.uri) : undefined) ?? uri.str ?? uri.uri.toString();
200
201
const desiredKind = getDesiredLinkKind(uri.uri, uri.kind, options);
202
203
if (desiredKind === DesiredLinkKind.Link) {
204
insertedLinkCount++;
205
snippet.appendText('[');
206
snippet.appendPlaceholder(escapeBrackets(options?.placeholderText ?? 'text'), placeholderIndex);
207
snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);
208
} else {
209
const insertAsVideo = desiredKind === DesiredLinkKind.Video;
210
const insertAsAudio = desiredKind === DesiredLinkKind.Audio;
211
if (insertAsVideo || insertAsAudio) {
212
if (insertAsVideo) {
213
insertedVideoCount++;
214
} else {
215
insertedAudioCount++;
216
}
217
const mediaSnippet = insertAsVideo
218
? config.get<string>('editor.filePaste.videoSnippet', '<video controls src="${src}" title="${title}"></video>')
219
: config.get<string>('editor.filePaste.audioSnippet', '<audio controls src="${src}" title="${title}"></audio>');
220
snippet.value += resolveSnippet(mediaSnippet, new Map<string, string>([
221
['src', mdPath],
222
['title', `\${${placeholderIndex++}:${title}}`],
223
]));
224
} else {
225
insertedImageCount++;
226
snippet.appendText('![');
227
const placeholderText = escapeBrackets(options?.placeholderText || 'alt text');
228
snippet.appendPlaceholder(placeholderText, placeholderIndex);
229
snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);
230
}
231
}
232
233
if (i < uris.length - 1 && uris.length > 1) {
234
snippet.appendText(options?.separator ?? ' ');
235
}
236
});
237
238
return { snippet, insertedAudioCount, insertedVideoCount, insertedImageCount, insertedLinkCount };
239
}
240
241
enum DesiredLinkKind {
242
Link,
243
Image,
244
Video,
245
Audio,
246
}
247
248
function getDesiredLinkKind(uri: vscode.Uri, uriFileKind: MediaKind | undefined, options: UriListSnippetOptions | undefined): DesiredLinkKind {
249
if (options?.linkKindHint instanceof vscode.DocumentDropOrPasteEditKind) {
250
if (linkEditKind.contains(options.linkKindHint)) {
251
return DesiredLinkKind.Link;
252
} else if (imageEditKind.contains(options.linkKindHint)) {
253
return DesiredLinkKind.Image;
254
} else if (audioEditKind.contains(options.linkKindHint)) {
255
return DesiredLinkKind.Audio;
256
} else if (videoEditKind.contains(options.linkKindHint)) {
257
return DesiredLinkKind.Video;
258
}
259
}
260
261
if (typeof uriFileKind !== 'undefined') {
262
switch (uriFileKind) {
263
case MediaKind.Video: return DesiredLinkKind.Video;
264
case MediaKind.Audio: return DesiredLinkKind.Audio;
265
case MediaKind.Image: return DesiredLinkKind.Image;
266
}
267
}
268
269
const normalizedExt = URI.Utils.extname(uri).toLowerCase().replace('.', '');
270
if (options?.linkKindHint === 'media' || mediaFileExtensions.has(normalizedExt)) {
271
switch (mediaFileExtensions.get(normalizedExt)) {
272
case MediaKind.Video: return DesiredLinkKind.Video;
273
case MediaKind.Audio: return DesiredLinkKind.Audio;
274
default: return DesiredLinkKind.Image;
275
}
276
}
277
278
return DesiredLinkKind.Link;
279
}
280
281
function getRelativeMdPath(dir: vscode.Uri | undefined, file: vscode.Uri): string | undefined {
282
if (dir && dir.scheme === file.scheme && dir.authority === file.authority) {
283
if (file.scheme === Schemes.file) {
284
// On windows, we must use the native `path.relative` to generate the relative path
285
// so that drive-letters are resolved cast insensitively. However we then want to
286
// convert back to a posix path to insert in to the document.
287
const relativePath = path.relative(dir.fsPath, file.fsPath);
288
return path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep));
289
}
290
291
return path.posix.relative(dir.path, file.path);
292
}
293
return undefined;
294
}
295
296
function escapeMarkdownLinkPath(mdPath: string): string {
297
if (needsBracketLink(mdPath)) {
298
return '<' + mdPath.replaceAll('<', '\\<').replaceAll('>', '\\>') + '>';
299
}
300
301
return mdPath;
302
}
303
304
function escapeBrackets(value: string): string {
305
value = value.replace(/[\[\]]/g, '\\$&'); // CodeQL [SM02383] The Markdown is fully sanitized after being rendered.
306
return value;
307
}
308
309
function needsBracketLink(mdPath: string): boolean {
310
// Links with whitespace or control characters must be enclosed in brackets
311
if (mdPath.startsWith('<') || /\s|[\u007F\u0000-\u001f]/.test(mdPath)) {
312
return true;
313
}
314
315
// Check if the link has mis-matched parens
316
if (!/[\(\)]/.test(mdPath)) {
317
return false;
318
}
319
320
let previousChar = '';
321
let nestingCount = 0;
322
for (const char of mdPath) {
323
if (char === '(' && previousChar !== '\\') {
324
nestingCount++;
325
} else if (char === ')' && previousChar !== '\\') {
326
nestingCount--;
327
}
328
329
if (nestingCount < 0) {
330
return true;
331
}
332
previousChar = char;
333
}
334
335
return nestingCount > 0;
336
}
337
338
export interface DropOrPasteEdit {
339
readonly snippet: vscode.SnippetString;
340
readonly kind: vscode.DocumentDropOrPasteEditKind;
341
readonly label: string;
342
readonly additionalEdits: vscode.WorkspaceEdit;
343
readonly yieldTo: vscode.DocumentDropOrPasteEditKind[];
344
}
345
346