Path: blob/main/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts
3294 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as path from 'path';6import * as vscode from 'vscode';7import * as URI from 'vscode-uri';8import { ITextDocument } from '../../types/textDocument';9import { getDocumentDir } from '../../util/document';10import { Schemes } from '../../util/schemes';11import { UriList } from '../../util/uriList';12import { resolveSnippet } from './snippets';13import { mediaFileExtensions, MediaKind } from '../../util/mimes';1415/** Base kind for any sort of markdown link, including both path and media links */16export const baseLinkEditKind = vscode.DocumentDropOrPasteEditKind.Empty.append('markdown', 'link');1718/** Kind for normal markdown links, i.e. `[text](path/to/file.md)` */19export const linkEditKind = baseLinkEditKind.append('uri');2021export const imageEditKind = baseLinkEditKind.append('image');22export const audioEditKind = baseLinkEditKind.append('audio');23export const videoEditKind = baseLinkEditKind.append('video');2425export function getSnippetLabelAndKind(counter: { readonly insertedAudioCount: number; readonly insertedVideoCount: number; readonly insertedImageCount: number; readonly insertedLinkCount: number }): {26label: string;27kind: vscode.DocumentDropOrPasteEditKind;28} {29if (counter.insertedVideoCount > 0 || counter.insertedAudioCount > 0) {30// Any media plus links31if (counter.insertedLinkCount > 0) {32return {33label: vscode.l10n.t('Insert Markdown Media and Links'),34kind: baseLinkEditKind,35};36}3738// Any media plus images39if (counter.insertedImageCount > 0) {40return {41label: vscode.l10n.t('Insert Markdown Media and Images'),42kind: baseLinkEditKind,43};44}4546// Audio only47if (counter.insertedAudioCount > 0 && !counter.insertedVideoCount) {48return {49label: vscode.l10n.t('Insert Markdown Audio'),50kind: audioEditKind,51};52}5354// Video only55if (counter.insertedVideoCount > 0 && !counter.insertedAudioCount) {56return {57label: vscode.l10n.t('Insert Markdown Video'),58kind: videoEditKind,59};60}6162// Mix of audio and video63return {64label: vscode.l10n.t('Insert Markdown Media'),65kind: baseLinkEditKind,66};67} else if (counter.insertedImageCount > 0) {68// Mix of images and links69if (counter.insertedLinkCount > 0) {70return {71label: vscode.l10n.t('Insert Markdown Images and Links'),72kind: baseLinkEditKind,73};74}7576// Just images77return {78label: counter.insertedImageCount > 179? vscode.l10n.t('Insert Markdown Images')80: vscode.l10n.t('Insert Markdown Image'),81kind: imageEditKind,82};83} else {84return {85label: counter.insertedLinkCount > 186? vscode.l10n.t('Insert Markdown Links')87: vscode.l10n.t('Insert Markdown Link'),88kind: linkEditKind,89};90}91}9293export function createInsertUriListEdit(94document: ITextDocument,95ranges: readonly vscode.Range[],96urlList: UriList,97options?: UriListSnippetOptions,98): { edits: vscode.SnippetTextEdit[]; label: string; kind: vscode.DocumentDropOrPasteEditKind } | undefined {99if (!ranges.length || !urlList.entries.length) {100return;101}102103const edits: vscode.SnippetTextEdit[] = [];104105let insertedLinkCount = 0;106let insertedImageCount = 0;107let insertedAudioCount = 0;108let insertedVideoCount = 0;109110// Use 1 for all empty ranges but give non-empty range unique indices starting after 1111let placeHolderStartIndex = 1 + urlList.entries.length;112113// Sort ranges by start position114const orderedRanges = [...ranges].sort((a, b) => a.start.compareTo(b.start));115const allRangesAreEmpty = orderedRanges.every(range => range.isEmpty);116117for (const range of orderedRanges) {118const snippet = createUriListSnippet(document.uri, urlList.entries, {119placeholderText: range.isEmpty ? undefined : document.getText(range),120placeholderStartIndex: allRangesAreEmpty ? 1 : placeHolderStartIndex,121...options,122});123if (!snippet) {124continue;125}126127insertedLinkCount += snippet.insertedLinkCount;128insertedImageCount += snippet.insertedImageCount;129insertedAudioCount += snippet.insertedAudioCount;130insertedVideoCount += snippet.insertedVideoCount;131132placeHolderStartIndex += urlList.entries.length;133134edits.push(new vscode.SnippetTextEdit(range, snippet.snippet));135}136137const { label, kind } = getSnippetLabelAndKind({ insertedAudioCount, insertedVideoCount, insertedImageCount, insertedLinkCount });138return { edits, label, kind };139}140141interface UriListSnippetOptions {142readonly placeholderText?: string;143144readonly placeholderStartIndex?: number;145146/**147* Hints how links should be inserted, e.g. as normal markdown link or as an image.148*149* By default this is inferred from the uri. If you use `media`, we will insert the resource as an image, video, or audio.150*/151readonly linkKindHint?: vscode.DocumentDropOrPasteEditKind | 'media';152153readonly separator?: string;154155/**156* Prevents uris from being made relative to the document.157*158* This is mostly useful for `file:` uris.159*/160readonly preserveAbsoluteUris?: boolean;161}162163164export interface UriSnippet {165readonly snippet: vscode.SnippetString;166readonly insertedLinkCount: number;167readonly insertedImageCount: number;168readonly insertedVideoCount: number;169readonly insertedAudioCount: number;170}171172export function createUriListSnippet(173document: vscode.Uri,174uris: ReadonlyArray<{175readonly uri: vscode.Uri;176readonly str?: string;177readonly kind?: MediaKind;178}>,179options?: UriListSnippetOptions,180): UriSnippet | undefined {181if (!uris.length) {182return;183}184185const documentDir = getDocumentDir(document);186const config = vscode.workspace.getConfiguration('markdown', document);187const title = options?.placeholderText || 'Title';188189let insertedLinkCount = 0;190let insertedImageCount = 0;191let insertedAudioCount = 0;192let insertedVideoCount = 0;193194const snippet = new vscode.SnippetString();195let placeholderIndex = options?.placeholderStartIndex ?? 1;196197uris.forEach((uri, i) => {198const mdPath = (!options?.preserveAbsoluteUris ? getRelativeMdPath(documentDir, uri.uri) : undefined) ?? uri.str ?? uri.uri.toString();199200const desiredKind = getDesiredLinkKind(uri.uri, uri.kind, options);201202if (desiredKind === DesiredLinkKind.Link) {203insertedLinkCount++;204snippet.appendText('[');205snippet.appendPlaceholder(escapeBrackets(options?.placeholderText ?? 'text'), placeholderIndex);206snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);207} else {208const insertAsVideo = desiredKind === DesiredLinkKind.Video;209const insertAsAudio = desiredKind === DesiredLinkKind.Audio;210if (insertAsVideo || insertAsAudio) {211if (insertAsVideo) {212insertedVideoCount++;213} else {214insertedAudioCount++;215}216const mediaSnippet = insertAsVideo217? config.get<string>('editor.filePaste.videoSnippet', '<video controls src="${src}" title="${title}"></video>')218: config.get<string>('editor.filePaste.audioSnippet', '<audio controls src="${src}" title="${title}"></audio>');219snippet.value += resolveSnippet(mediaSnippet, new Map<string, string>([220['src', mdPath],221['title', `\${${placeholderIndex++}:${title}}`],222]));223} else {224insertedImageCount++;225snippet.appendText('})`);229}230}231232if (i < uris.length - 1 && uris.length > 1) {233snippet.appendText(options?.separator ?? ' ');234}235});236237return { snippet, insertedAudioCount, insertedVideoCount, insertedImageCount, insertedLinkCount };238}239240enum DesiredLinkKind {241Link,242Image,243Video,244Audio,245}246247function getDesiredLinkKind(uri: vscode.Uri, uriFileKind: MediaKind | undefined, options: UriListSnippetOptions | undefined): DesiredLinkKind {248if (options?.linkKindHint instanceof vscode.DocumentDropOrPasteEditKind) {249if (linkEditKind.contains(options.linkKindHint)) {250return DesiredLinkKind.Link;251} else if (imageEditKind.contains(options.linkKindHint)) {252return DesiredLinkKind.Image;253} else if (audioEditKind.contains(options.linkKindHint)) {254return DesiredLinkKind.Audio;255} else if (videoEditKind.contains(options.linkKindHint)) {256return DesiredLinkKind.Video;257}258}259260if (typeof uriFileKind !== 'undefined') {261switch (uriFileKind) {262case MediaKind.Video: return DesiredLinkKind.Video;263case MediaKind.Audio: return DesiredLinkKind.Audio;264case MediaKind.Image: return DesiredLinkKind.Image;265}266}267268const normalizedExt = URI.Utils.extname(uri).toLowerCase().replace('.', '');269if (options?.linkKindHint === 'media' || mediaFileExtensions.has(normalizedExt)) {270switch (mediaFileExtensions.get(normalizedExt)) {271case MediaKind.Video: return DesiredLinkKind.Video;272case MediaKind.Audio: return DesiredLinkKind.Audio;273default: return DesiredLinkKind.Image;274}275}276277return DesiredLinkKind.Link;278}279280function getRelativeMdPath(dir: vscode.Uri | undefined, file: vscode.Uri): string | undefined {281if (dir && dir.scheme === file.scheme && dir.authority === file.authority) {282if (file.scheme === Schemes.file) {283// On windows, we must use the native `path.relative` to generate the relative path284// so that drive-letters are resolved cast insensitively. However we then want to285// convert back to a posix path to insert in to the document.286const relativePath = path.relative(dir.fsPath, file.fsPath);287return path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep));288}289290return path.posix.relative(dir.path, file.path);291}292return undefined;293}294295function escapeMarkdownLinkPath(mdPath: string): string {296if (needsBracketLink(mdPath)) {297return '<' + mdPath.replaceAll('<', '\\<').replaceAll('>', '\\>') + '>';298}299300return mdPath;301}302303function escapeBrackets(value: string): string {304value = value.replace(/[\[\]]/g, '\\$&'); // CodeQL [SM02383] The Markdown is fully sanitized after being rendered.305return value;306}307308function needsBracketLink(mdPath: string): boolean {309// Links with whitespace or control characters must be enclosed in brackets310if (mdPath.startsWith('<') || /\s|[\u007F\u0000-\u001f]/.test(mdPath)) {311return true;312}313314// Check if the link has mis-matched parens315if (!/[\(\)]/.test(mdPath)) {316return false;317}318319let previousChar = '';320let nestingCount = 0;321for (const char of mdPath) {322if (char === '(' && previousChar !== '\\') {323nestingCount++;324} else if (char === ')' && previousChar !== '\\') {325nestingCount--;326}327328if (nestingCount < 0) {329return true;330}331previousChar = char;332}333334return nestingCount > 0;335}336337export interface DropOrPasteEdit {338readonly snippet: vscode.SnippetString;339readonly kind: vscode.DocumentDropOrPasteEditKind;340readonly label: string;341readonly additionalEdits: vscode.WorkspaceEdit;342readonly yieldTo: vscode.DocumentDropOrPasteEditKind[];343}344345346