diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 19ce7e98f4b..8240a451ea9 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -456,13 +456,36 @@ "markdownDescription": "%configuration.markdown.editor.drop.enabled%", "scope": "resource" }, - "markdown.experimental.editor.pasteLinks.enabled": { + "markdown.editor.drop.copyIntoWorkspace": { + "type": "string", + "markdownDescription": "%configuration.markdown.editor.drop.copyIntoWorkspace%", + "default": "mediaFiles", + "enum": [ + "mediaFiles", + "never" + ], + "markdownEnumDescriptions": [ + "%configuration.copyIntoWorkspace.mediaFiles%", + "%configuration.copyIntoWorkspace.never%" + ] + }, + "markdown.editor.filePaste.enabled": { "type": "boolean", "scope": "resource", - "markdownDescription": "%configuration.markdown.editor.pasteLinks.enabled%", - "default": true, - "tags": [ - "experimental" + "markdownDescription": "%configuration.markdown.editor.filePaste.enabled%", + "default": true + }, + "markdown.editor.filePaste.copyIntoWorkspace": { + "type": "string", + "markdownDescription": "%configuration.markdown.editor.filePaste.copyIntoWorkspace%", + "default": "mediaFiles", + "enum": [ + "mediaFiles", + "never" + ], + "markdownEnumDescriptions": [ + "%configuration.copyIntoWorkspace.mediaFiles%", + "%configuration.copyIntoWorkspace.never%" ] }, "markdown.validate.enabled": { @@ -588,13 +611,26 @@ "description": "%configuration.markdown.occurrencesHighlight.enabled%", "scope": "resource" }, - "markdown.experimental.copyFiles.destination": { + "markdown.copyFiles.destination": { "type": "object", "markdownDescription": "%configuration.markdown.copyFiles.destination%", "additionalProperties": { "type": "string" } }, + "markdown.copyFiles.overwriteBehavior": { + "type": "string", + "markdownDescription": "%configuration.markdown.copyFiles.overwriteBehavior%", + "default": "nameIncrementally", + "enum": [ + "nameIncrementally", + "overwrite" + ], + "markdownEnumDescriptions": [ + "%configuration.markdown.copyFiles.overwriteBehavior.nameIncrementally%", + "%configuration.markdown.copyFiles.overwriteBehavior.overwrite%" + ] + }, "markdown.preferredMdPathExtensionStyle": { "type": "string", "default": "auto", diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index b09bb1040bb..158dfdd0ac0 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -32,17 +32,21 @@ "configuration.markdown.links.openLocation.currentGroup": "Open links in the active editor group.", "configuration.markdown.links.openLocation.beside": "Open links beside the active editor.", "configuration.markdown.suggest.paths.enabled.description": "Enable path suggestions while writing links in Markdown files.", - "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions": "Enable suggestions for headers in other Markdown files in the current workspace. Accepting one of these suggestions inserts the full path to header in that file, for example `[link text](/path/to/file.md#header)`.", + "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions": "Enable suggestions for headers in other Markdown files in the current workspace. Accepting one of these suggestions inserts the full path to header in that file, for example: `[link text](/path/to/file.md#header)`.", "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.never": "Disable workspace header suggestions.", - "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onDoubleHash": "Enable workspace header suggestions after typing `##` in a path, for example `[link text](##`.", - "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onSingleOrDoubleHash": "Enable workspace header suggestions after typing either `##` or `#` in a path, for example `[link text](#` or `[link text](##`.", + "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onDoubleHash": "Enable workspace header suggestions after typing `##` in a path, for example: `[link text](##`.", + "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onSingleOrDoubleHash": "Enable workspace header suggestions after typing either `##` or `#` in a path, for example: `[link text](#` or `[link text](##`.", "configuration.markdown.editor.drop.enabled": "Enable dropping files into a Markdown editor while holding Shift. Requires enabling `#editor.dropIntoEditor.enabled#`.", - "configuration.markdown.editor.pasteLinks.enabled": "Enable pasting files into a Markdown editor inserts Markdown links. Requires enabling `#editor.pasteAs.enabled#`.", + "configuration.markdown.editor.drop.copyIntoWorkspace": "Controls if files outside of the workspace that are dropped into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied dropped files should be created", + "configuration.markdown.editor.filePaste.enabled": "Enable pasting files into a Markdown editor to create Markdown links. Requires enabling `#editor.pasteAs.enabled#`.", + "configuration.markdown.editor.filePaste.copyIntoWorkspace": "Controls if files outside of the workspace that are pasted into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied files should be created.", + "configuration.copyIntoWorkspace.mediaFiles": "Try to copy external image and video files into the workspace.", + "configuration.copyIntoWorkspace.never": "Do not copy external files into the workspace.", "configuration.markdown.validate.enabled.description": "Enable all error reporting in Markdown files.", - "configuration.markdown.validate.referenceLinks.enabled.description": "Validate reference links in Markdown files, for example `[link][ref]`. Requires enabling `#markdown.validate.enabled#`.", - "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#`.", + "configuration.markdown.validate.referenceLinks.enabled.description": "Validate reference links in Markdown files, for example: `[link][ref]`. Requires enabling `#markdown.validate.enabled#`.", + "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#`.", "configuration.markdown.validate.fileLinks.enabled.description": "Validate links to other files in Markdown files, for example `[link](/path/to/file.md)`. This checks that the target files exists. Requires enabling `#markdown.validate.enabled#`.", - "configuration.markdown.validate.fileLinks.markdownFragmentLinks.description": "Validate the fragment part of links to headers in other files in Markdown files, for example `[link](/path/to/file.md#header)`. Inherits the setting value from `#markdown.validate.fragmentLinks.enabled#` by default.", + "configuration.markdown.validate.fileLinks.markdownFragmentLinks.description": "Validate the fragment part of links to headers in other files in Markdown files, for example: `[link](/path/to/file.md#header)`. Inherits the setting value from `#markdown.validate.fragmentLinks.enabled#` by default.", "configuration.markdown.validate.ignoredLinks.description": "Configure links that should not be validated. For example adding `/about` would not validate the link `[about](/about)`, while the glob `/assets/**/*.svg` would let you skip validation for any link to `.svg` files under the `assets` directory.", "configuration.markdown.validate.unusedLinkDefinitions.description": "Validate link definitions that are unused in the current file.", "configuration.markdown.validate.duplicateLinkDefinitions.description": "Validate duplicated definitions in the current file.", @@ -54,7 +58,10 @@ "configuration.markdown.updateLinksOnFileMove.include.property": "The glob pattern to match file paths against. Set to true to enable the pattern.", "configuration.markdown.updateLinksOnFileMove.enableForDirectories": "Enable updating links when a directory is moved or renamed in the workspace.", "configuration.markdown.occurrencesHighlight.enabled": "Enable highlighting link occurrences in the current document.", - "configuration.markdown.copyFiles.destination": "Defines where files copied into a Markdown document should be created. This is a map from globs that match on the Markdown document to destinations.\n\nThe destinations may use the following variables:\n\n- `${documentFileName}` — The full filename of the Markdown document, for example `readme.md`.\n- `${documentBaseName}` — The basename of Markdown document, for example `readme`.\n- `${documentExtName}` — The extension of the Markdown document, for example `md`.\n- `${documentDirName}` — The name of the Markdown document's parent directory.\n- `${documentWorkspaceFolder}` — The workspace folder for the Markdown document, for examples, `/Users/me/myProject`. This is the same as `${documentDirName}` if the file is not part of in a workspace.\n- `${fileName}` — The file name of the dropped file, for example `image.png`.", + "configuration.markdown.copyFiles.destination": "Defines where files copied created by drop or paste should be created. This is a map from globs that match on the Markdown document to destinations.\n\nThe destinations may use the following variables:\n\n- `${documentFileName}` — The full filename of the Markdown document, for example: `readme.md`.\n- `${documentBaseName}` — The basename of Markdown document, for example: `readme`.\n- `${documentExtName}` — The extension of the Markdown document, for example: `md`.\n- `${documentDirName}` — The name of the Markdown document's parent directory.\n- `${documentWorkspaceFolder}` — The workspace folder for the Markdown document, for example: `/Users/me/myProject`. This is the same as `${documentDirName}` if the file is not part of in a workspace.\n- `${fileName}` — The file name of the dropped file, for example: `image.png`.", + "configuration.markdown.copyFiles.overwriteBehavior": "Controls if files created by drop or paste should overwrite existing files.", + "configuration.markdown.copyFiles.overwriteBehavior.nameIncrementally": "If a file with the same name already exists, append a number to the file name, for example: `image.png` becomes `image-1.png`.", + "configuration.markdown.copyFiles.overwriteBehavior.overwrite": "If a file with the same name already exists, overwrite it.", "configuration.markdown.preferredMdPathExtensionStyle": "Controls if file extensions (e.g. `.md`) are added or not for links to Markdown files. This setting is used when file paths are added by tooling such as path completions or file renames.", "configuration.markdown.preferredMdPathExtensionStyle.auto": "For existing paths, try to maintain the file extension style. For new paths, add file extensions.", "configuration.markdown.preferredMdPathExtensionStyle.includeExtension": "Prefer including the file extension. For example, path completions to a file named `file.md` will insert `file.md`.", diff --git a/extensions/markdown-language-features/src/commands/insertResource.ts b/extensions/markdown-language-features/src/commands/insertResource.ts index 694933ef349..f5d0cf8fe13 100644 --- a/extensions/markdown-language-features/src/commands/insertResource.ts +++ b/extensions/markdown-language-features/src/commands/insertResource.ts @@ -6,8 +6,9 @@ import * as vscode from 'vscode'; import { Utils } from 'vscode-uri'; import { Command } from '../commandManager'; -import { createUriListSnippet, getParentDocumentUri, imageFileExtensions } from '../languageFeatures/copyFiles/dropIntoEditor'; +import { createUriListSnippet, mediaFileExtensions } from '../languageFeatures/copyFiles/shared'; import { coalesce } from '../util/arrays'; +import { getParentDocumentUri } from '../util/document'; import { Schemes } from '../util/schemes'; @@ -47,7 +48,7 @@ export class InsertImageFromWorkspace implements Command { canSelectFolders: false, canSelectMany: true, filters: { - [vscode.l10n.t("Images")]: Array.from(imageFileExtensions) + [vscode.l10n.t("Media")]: Array.from(mediaFileExtensions.keys()) }, openLabel: vscode.l10n.t("Insert image"), title: vscode.l10n.t("Insert image"), @@ -75,14 +76,14 @@ async function insertLink(activeEditor: vscode.TextEditor, selectedFiles: vscode await vscode.workspace.applyEdit(edit); } -function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsImage: boolean) { +function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsMedia: boolean) { const snippetEdits = coalesce(activeEditor.selections.map((selection, i): vscode.SnippetTextEdit | undefined => { const selectionText = activeEditor.document.getText(selection); const snippet = createUriListSnippet(activeEditor.document, selectedFiles, { - insertAsImage: insertAsImage, + insertAsMedia, placeholderText: selectionText, placeholderStartIndex: (i + 1) * selectedFiles.length, - separator: insertAsImage ? '\n' : ' ', + separator: insertAsMedia ? '\n' : ' ', }); return snippet ? new vscode.SnippetTextEdit(selection, snippet.snippet) : undefined; diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts index 60092e882c3..280aa541e15 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts @@ -6,14 +6,41 @@ import * as path from 'path'; import * as picomatch from 'picomatch'; import * as vscode from 'vscode'; import { Utils } from 'vscode-uri'; -import { getParentDocumentUri } from './dropIntoEditor'; +import { getParentDocumentUri } from '../../util/document'; + +type OverwriteBehavior = 'overwrite' | 'nameIncrementally'; + +interface CopyFileConfiguration { + readonly destination: Record; + readonly overwriteBehavior: OverwriteBehavior; +} + +function getCopyFileConfiguration(document: vscode.TextDocument): CopyFileConfiguration { + const config = vscode.workspace.getConfiguration('markdown', document); + return { + destination: config.get>('copyFiles.destination') ?? {}, + overwriteBehavior: readOverwriteBehavior(config), + }; +} + +function readOverwriteBehavior(config: vscode.WorkspaceConfiguration): OverwriteBehavior { + switch (config.get('copyFiles.overwriteBehavior')) { + case 'overwrite': return 'overwrite'; + default: return 'nameIncrementally'; + } +} export class NewFilePathGenerator { private readonly _usedPaths = new Set(); - async getNewFilePath(document: vscode.TextDocument, file: vscode.DataTransferFile, token: vscode.CancellationToken): Promise { - const desiredPath = getDesiredNewFilePath(document, file); + async getNewFilePath( + document: vscode.TextDocument, + file: vscode.DataTransferFile, + token: vscode.CancellationToken, + ): Promise<{ readonly uri: vscode.Uri; readonly overwrite: boolean } | undefined> { + const config = getCopyFileConfiguration(document); + const desiredPath = getDesiredNewFilePath(config, document, file); const root = Utils.dirname(desiredPath); const ext = path.extname(file.name); @@ -29,13 +56,20 @@ export class NewFilePathGenerator { continue; } + // Try overwriting if it already exists + if (config.overwriteBehavior === 'overwrite') { + this._usedPaths.add(uri.toString()); + return { uri, overwrite: true }; + } + + // Otherwise we need to check the fs to see if it exists try { await vscode.workspace.fs.stat(uri); } catch { if (!this._wasPathAlreadyUsed(uri)) { // Does not exist this._usedPaths.add(uri.toString()); - return uri; + return { uri, overwrite: false }; } } } @@ -46,10 +80,9 @@ export class NewFilePathGenerator { } } -function getDesiredNewFilePath(document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri { +function getDesiredNewFilePath(config: CopyFileConfiguration, document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri { const docUri = getParentDocumentUri(document); - const config = vscode.workspace.getConfiguration('markdown').get>('experimental.copyFiles.destination') ?? {}; - for (const [rawGlob, rawDest] of Object.entries(config)) { + for (const [rawGlob, rawDest] of Object.entries(config.destination)) { for (const glob of parseGlob(rawGlob)) { if (picomatch.isMatch(docUri.path, glob)) { return resolveCopyDestination(docUri, file.name, rawDest, uri => vscode.workspace.getWorkspaceFolder(uri)?.uri); diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts index 7d09c006bcd..ed6d99bd973 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts @@ -4,18 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { coalesce } from '../../util/arrays'; import { Schemes } from '../../util/schemes'; -import { NewFilePathGenerator } from './copyFiles'; -import { createUriListSnippet, tryGetUriListSnippet } from './dropIntoEditor'; - -const supportedImageMimes = new Set([ - 'image/bmp', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/webp', -]); +import { createEditForMediaFiles, mediaMimes, tryGetUriListSnippet } from './shared'; class PasteEditProvider implements vscode.DocumentPasteEditProvider { @@ -27,12 +17,12 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider { dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken, ): Promise { - const enabled = vscode.workspace.getConfiguration('markdown', document).get('experimental.editor.pasteLinks.enabled', true); + const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.filePaste.enabled', true); if (!enabled) { return; } - const createEdit = await this._makeCreateImagePasteEdit(document, dataTransfer, token); + const createEdit = await this._getMediaFilesEdit(document, dataTransfer, token); if (createEdit) { return createEdit; } @@ -47,56 +37,23 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider { return uriEdit; } - private async _makeCreateImagePasteEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { + private async _getMediaFilesEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { if (document.uri.scheme === Schemes.untitled) { return; } - interface FileEntry { - readonly uri: vscode.Uri; - readonly newFileContents?: vscode.DataTransferFile; - } - - const pathGenerator = new NewFilePathGenerator(); - const fileEntries = coalesce(await Promise.all(Array.from(dataTransfer, async ([mime, item]): Promise => { - if (!supportedImageMimes.has(mime)) { - return; - } - - const file = item?.asFile(); - if (!file) { - return; - } - - if (file.uri) { - // If the file is already in a workspace, we don't want to create a copy of it - const workspaceFolder = vscode.workspace.getWorkspaceFolder(file.uri); - if (workspaceFolder) { - return { uri: file.uri }; - } - } - - const uri = await pathGenerator.getNewFilePath(document, file, token); - return uri ? { uri, newFileContents: file } : undefined; - }))); - if (!fileEntries.length) { + const copyFilesIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.filePaste.copyIntoWorkspace', 'mediaFiles'); + if (copyFilesIntoWorkspace === 'never') { return; } - const workspaceEdit = new vscode.WorkspaceEdit(); - for (const entry of fileEntries) { - if (entry.newFileContents) { - workspaceEdit.createFile(entry.uri, { contents: entry.newFileContents }); - } - } - - const snippet = createUriListSnippet(document, fileEntries.map(entry => entry.uri)); - if (!snippet) { + const edit = await createEditForMediaFiles(document, dataTransfer, token); + if (!edit) { return; } - const pasteEdit = new vscode.DocumentPasteEdit(snippet.snippet, this._id, snippet.label); - pasteEdit.additionalEdit = workspaceEdit; + const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, this._id, edit.label); + pasteEdit.additionalEdit = edit.additionalEdits; pasteEdit.priority = this._getPriority(dataTransfer); return pasteEdit; } @@ -114,7 +71,7 @@ export function registerPasteSupport(selector: vscode.DocumentSelector,) { return vscode.languages.registerDocumentPasteEditProvider(selector, new PasteEditProvider(), { pasteMimeTypes: [ 'text/uri-list', - ...supportedImageMimes, + ...mediaMimes, ] }); } diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts index ff8663a0b5e..cc88a8c9996 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts @@ -3,186 +3,71 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; import * as vscode from 'vscode'; -import * as URI from 'vscode-uri'; +import { createEditForMediaFiles as createEditForMediaFiles, tryGetUriListSnippet } from './shared'; import { Schemes } from '../../util/schemes'; -export const imageFileExtensions = new Set([ - 'bmp', - 'gif', - 'ico', - 'jpe', - 'jpeg', - 'jpg', - 'png', - 'psd', - 'svg', - 'tga', - 'tif', - 'tiff', - 'webp', -]); -const videoFileExtensions = new Set([ - 'ogg', - 'mp4' -]); +class MarkdownImageDropProvider implements vscode.DocumentDropEditProvider { + private readonly _id = 'insertLink'; + + async provideDocumentDropEdits(document: vscode.TextDocument, _position: vscode.Position, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { + const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true); + if (!enabled) { + return; + } + + const filesEdit = await this._getMediaFilesEdit(document, dataTransfer, token); + if (filesEdit) { + return filesEdit; + } + + if (token.isCancellationRequested) { + return; + } + + return this._getUriListEdit(document, dataTransfer, token); + } + + private async _getUriListEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { + const snippet = await tryGetUriListSnippet(document, dataTransfer, token); + if (!snippet) { + return undefined; + } + + const edit = new vscode.DocumentDropEdit(snippet.snippet); + edit.id = this._id; + edit.label = snippet.label; + return edit; + } + + private async _getMediaFilesEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { + if (document.uri.scheme === Schemes.untitled) { + return; + } + + const copyIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.drop.copyIntoWorkspace', 'mediaFiles'); + if (copyIntoWorkspace !== 'mediaFiles') { + return; + } + + const filesEdit = await createEditForMediaFiles(document, dataTransfer, token); + if (!filesEdit) { + return; + } + + const edit = new vscode.DocumentDropEdit(filesEdit.snippet); + edit.id = this._id; + edit.label = filesEdit.label; + edit.additionalEdit = filesEdit.additionalEdits; + return edit; + } +} export function registerDropIntoEditorSupport(selector: vscode.DocumentSelector) { - return vscode.languages.registerDocumentDropEditProvider(selector, new class implements vscode.DocumentDropEditProvider { - async provideDocumentDropEdits(document: vscode.TextDocument, _position: vscode.Position, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { - const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true); - if (!enabled) { - return undefined; - } - - const snippet = await tryGetUriListSnippet(document, dataTransfer, token); - if (!snippet) { - return undefined; - } - - const edit = new vscode.DocumentDropEdit(snippet.snippet); - edit.id = 'insertLink'; - edit.label = snippet.label; - return edit; - } - }, { + return vscode.languages.registerDocumentDropEditProvider(selector, new MarkdownImageDropProvider(), { dropMimeTypes: [ 'text/uri-list' ] }); } - -export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> { - const urlList = await dataTransfer.get('text/uri-list')?.asString(); - if (!urlList || token.isCancellationRequested) { - return undefined; - } - - const uris: vscode.Uri[] = []; - for (const resource of urlList.split(/\r?\n/g)) { - try { - uris.push(vscode.Uri.parse(resource)); - } catch { - // noop - } - } - - return createUriListSnippet(document, uris); -} - -interface UriListSnippetOptions { - readonly placeholderText?: string; - - readonly placeholderStartIndex?: number; - - /** - * Should the snippet be for an image? - * - * If `undefined`, tries to infer this from the uri. - */ - readonly insertAsImage?: boolean; - - readonly separator?: string; -} - - -export function createUriListSnippet(document: vscode.TextDocument, uris: readonly vscode.Uri[], options?: UriListSnippetOptions): { snippet: vscode.SnippetString; label: string } | undefined { - if (!uris.length) { - return undefined; - } - - const dir = getDocumentDir(document); - - const snippet = new vscode.SnippetString(); - - let insertedLinkCount = 0; - let insertedImageCount = 0; - - uris.forEach((uri, i) => { - const mdPath = getMdPath(dir, uri); - - const ext = URI.Utils.extname(uri).toLowerCase().replace('.', ''); - const insertAsImage = typeof options?.insertAsImage === 'undefined' ? imageFileExtensions.has(ext) : !!options.insertAsImage; - const insertAsVideo = videoFileExtensions.has(ext); - - if (insertAsVideo) { - insertedImageCount++; - snippet.appendText(`'); - } else { - if (insertAsImage) { - insertedImageCount++; - } else { - insertedLinkCount++; - } - - snippet.appendText(insertAsImage ? '![' : '['); - - const placeholderText = options?.placeholderText ?? (insertAsImage ? 'Alt text' : 'label'); - const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : undefined; - snippet.appendPlaceholder(placeholderText, placeholderIndex); - - snippet.appendText(`](${mdPath})`); - } - - if (i < uris.length - 1 && uris.length > 1) { - snippet.appendText(options?.separator ?? ' '); - } - }); - - let label: string; - if (insertedImageCount > 0 && insertedLinkCount > 0) { - label = vscode.l10n.t('Insert Markdown Images and Links'); - } else if (insertedImageCount > 0) { - label = insertedImageCount > 1 - ? vscode.l10n.t('Insert Markdown Images') - : vscode.l10n.t('Insert Markdown Image'); - } else { - label = insertedLinkCount > 1 - ? vscode.l10n.t('Insert Markdown Links') - : vscode.l10n.t('Insert Markdown Link'); - } - - return { snippet, label }; -} - -function getMdPath(dir: vscode.Uri | undefined, file: vscode.Uri) { - if (dir && dir.scheme === file.scheme && dir.authority === file.authority) { - if (file.scheme === Schemes.file) { - // On windows, we must use the native `path.relative` to generate the relative path - // so that drive-letters are resolved cast insensitively. However we then want to - // convert back to a posix path to insert in to the document. - const relativePath = path.relative(dir.fsPath, file.fsPath); - return encodeURI(path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep))); - } - - return encodeURI(path.posix.relative(dir.path, file.path)); - } - - return file.toString(false); -} - -function getDocumentDir(document: vscode.TextDocument): vscode.Uri | undefined { - const docUri = getParentDocumentUri(document); - if (docUri.scheme === Schemes.untitled) { - return vscode.workspace.workspaceFolders?.[0]?.uri; - } - return URI.Utils.dirname(docUri); -} - -export function getParentDocumentUri(document: vscode.TextDocument): vscode.Uri { - if (document.uri.scheme === Schemes.notebookCell) { - for (const notebook of vscode.workspace.notebookDocuments) { - for (const cell of notebook.getCells()) { - if (cell.document === document) { - return notebook.uri; - } - } - } - } - - return document.uri; -} diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts new file mode 100644 index 00000000000..3892941b7ce --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as URI from 'vscode-uri'; +import { Schemes } from '../../util/schemes'; +import { NewFilePathGenerator } from './copyFiles'; +import { coalesce } from '../../util/arrays'; +import { getDocumentDir } from '../../util/document'; + +enum MediaKind { + Image, + Video, +} + +export const mediaFileExtensions = new Map([ + // Images + ['bmp', MediaKind.Image], + ['gif', MediaKind.Image], + ['ico', MediaKind.Image], + ['jpe', MediaKind.Image], + ['jpeg', MediaKind.Image], + ['jpg', MediaKind.Image], + ['png', MediaKind.Image], + ['psd', MediaKind.Image], + ['svg', MediaKind.Image], + ['tga', MediaKind.Image], + ['tif', MediaKind.Image], + ['tiff', MediaKind.Image], + ['webp', MediaKind.Image], + + // Videos + ['ogg', MediaKind.Video], + ['mp4', MediaKind.Video], +]); + +export const mediaMimes = new Set([ + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', + 'video/mp4', + 'video/ogg', +]); + + +export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> { + const urlList = await dataTransfer.get('text/uri-list')?.asString(); + if (!urlList || token.isCancellationRequested) { + return undefined; + } + + const uris: vscode.Uri[] = []; + for (const resource of urlList.split(/\r?\n/g)) { + try { + uris.push(vscode.Uri.parse(resource)); + } catch { + // noop + } + } + + return createUriListSnippet(document, uris); +} + +interface UriListSnippetOptions { + readonly placeholderText?: string; + + readonly placeholderStartIndex?: number; + + /** + * Should the snippet be for an image link or video? + * + * If `undefined`, tries to infer this from the uri. + */ + readonly insertAsMedia?: boolean; + + readonly separator?: string; +} + + +export function createUriListSnippet( + document: vscode.TextDocument, + uris: readonly vscode.Uri[], + options?: UriListSnippetOptions +): { snippet: vscode.SnippetString; label: string } | undefined { + if (!uris.length) { + return; + } + + const dir = getDocumentDir(document); + + const snippet = new vscode.SnippetString(); + + let insertedLinkCount = 0; + let insertedImageCount = 0; + + uris.forEach((uri, i) => { + const mdPath = getMdPath(dir, uri); + + const ext = URI.Utils.extname(uri).toLowerCase().replace('.', ''); + const insertAsMedia = typeof options?.insertAsMedia === 'undefined' ? mediaFileExtensions.has(ext) : !!options.insertAsMedia; + const insertAsVideo = mediaFileExtensions.get(ext) === MediaKind.Video; + + if (insertAsVideo) { + insertedImageCount++; + snippet.appendText(`'); + } else { + if (insertAsMedia) { + insertedImageCount++; + } else { + insertedLinkCount++; + } + + snippet.appendText(insertAsMedia ? '![' : '['); + + const placeholderText = options?.placeholderText ?? (insertAsMedia ? 'Alt text' : 'label'); + const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : undefined; + snippet.appendPlaceholder(placeholderText, placeholderIndex); + + snippet.appendText(`](${mdPath})`); + } + + if (i < uris.length - 1 && uris.length > 1) { + snippet.appendText(options?.separator ?? ' '); + } + }); + + let label: string; + if (insertedImageCount > 0 && insertedLinkCount > 0) { + label = vscode.l10n.t('Insert Markdown Images and Links'); + } else if (insertedImageCount > 0) { + label = insertedImageCount > 1 + ? vscode.l10n.t('Insert Markdown Images') + : vscode.l10n.t('Insert Markdown Image'); + } else { + label = insertedLinkCount > 1 + ? vscode.l10n.t('Insert Markdown Links') + : vscode.l10n.t('Insert Markdown Link'); + } + + return { snippet, label }; +} + +/** + * Create a new edit from the image files in a data transfer. + * + * This tries copying files outside of the workspace into the workspace. + */ +export async function createEditForMediaFiles( + document: vscode.TextDocument, + dataTransfer: vscode.DataTransfer, + token: vscode.CancellationToken +): Promise<{ snippet: vscode.SnippetString; label: string; additionalEdits: vscode.WorkspaceEdit } | undefined> { + if (document.uri.scheme === Schemes.untitled) { + return; + } + + interface FileEntry { + readonly uri: vscode.Uri; + readonly newFile?: { readonly contents: vscode.DataTransferFile; readonly overwrite: boolean }; + } + + const pathGenerator = new NewFilePathGenerator(); + const fileEntries = coalesce(await Promise.all(Array.from(dataTransfer, async ([mime, item]): Promise => { + if (!mediaMimes.has(mime)) { + return; + } + + const file = item?.asFile(); + if (!file) { + return; + } + + if (file.uri) { + // If the file is already in a workspace, we don't want to create a copy of it + const workspaceFolder = vscode.workspace.getWorkspaceFolder(file.uri); + if (workspaceFolder) { + return { uri: file.uri }; + } + } + + const newFile = await pathGenerator.getNewFilePath(document, file, token); + if (!newFile) { + return; + } + return { uri: newFile.uri, newFile: { contents: file, overwrite: newFile.overwrite } }; + }))); + if (!fileEntries.length) { + return; + } + + const workspaceEdit = new vscode.WorkspaceEdit(); + for (const entry of fileEntries) { + if (entry.newFile) { + workspaceEdit.createFile(entry.uri, { + contents: entry.newFile.contents, + overwrite: entry.newFile.overwrite, + }); + } + } + + const snippet = createUriListSnippet(document, fileEntries.map(entry => entry.uri)); + if (!snippet) { + return; + } + + return { + snippet: snippet.snippet, + label: snippet.label, + additionalEdits: workspaceEdit, + }; +} + +function getMdPath(dir: vscode.Uri | undefined, file: vscode.Uri) { + if (dir && dir.scheme === file.scheme && dir.authority === file.authority) { + if (file.scheme === Schemes.file) { + // On windows, we must use the native `path.relative` to generate the relative path + // so that drive-letters are resolved cast insensitively. However we then want to + // convert back to a posix path to insert in to the document. + const relativePath = path.relative(dir.fsPath, file.fsPath); + return encodeURI(path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep))); + } + + return encodeURI(path.posix.relative(dir.path, file.path)); + } + + return file.toString(false); +} diff --git a/extensions/markdown-language-features/src/util/document.ts b/extensions/markdown-language-features/src/util/document.ts new file mode 100644 index 00000000000..9c192227ee3 --- /dev/null +++ b/extensions/markdown-language-features/src/util/document.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Schemes } from './schemes'; +import { Utils } from 'vscode-uri'; + +export function getDocumentDir(document: vscode.TextDocument): vscode.Uri | undefined { + const docUri = getParentDocumentUri(document); + if (docUri.scheme === Schemes.untitled) { + return vscode.workspace.workspaceFolders?.[0]?.uri; + } + return Utils.dirname(docUri); +} + +export function getParentDocumentUri(document: vscode.TextDocument): vscode.Uri { + if (document.uri.scheme === Schemes.notebookCell) { + for (const notebook of vscode.workspace.notebookDocuments) { + for (const cell of notebook.getCells()) { + if (cell.document === document) { + return notebook.uri; + } + } + } + } + + return document.uri; +}