From 641046a11d2d30ffb552e4047b61026efd1179ab Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Fri, 14 Oct 2022 16:05:36 -0700 Subject: [PATCH] Add commands to insert images/links in markdown (#163706) * Add commands to insert images/links in markdown Fixes #162809 * Rename commands and allow passing in uris * Support selecting many images/files --- .../markdown-language-features/package.json | 14 +++ .../package.nls.json | 2 + .../src/commands/index.ts | 44 ++++++++-- .../src/commands/insertResource.ts | 86 +++++++++++++++++++ .../src/extension.shared.ts | 26 +----- .../src/languageFeatures/dropIntoEditor.ts | 57 ++++++++---- .../src/util/arrays.ts | 6 ++ 7 files changed, 186 insertions(+), 49 deletions(-) create mode 100644 extensions/markdown-language-features/src/commands/insertResource.ts diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 250a6ddc8a8..5bcf02e87a3 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -30,6 +30,8 @@ "onCommand:markdown.api.render", "onCommand:markdown.api.reloadPlugins", "onCommand:markdown.findAllFileReferences", + "onCommand:markdown.editor.insertLinkFromWorkspace", + "onCommand:markdown.editor.insertImageFromWorkspace", "onWebviewPanel:markdown.preview", "onCustomEditor:vscode.markdown.preview.editor" ], @@ -175,6 +177,18 @@ "command": "markdown.findAllFileReferences", "title": "%markdown.findAllFileReferences%", "category": "Markdown" + }, + { + "command": "markdown.editor.insertLinkFromWorkspace", + "title": "%markdown.editor.insertLinkFromWorkspace%", + "category": "Markdown", + "enablement": "editorLangId == markdown" + }, + { + "command": "markdown.editor.insertImageFromWorkspace", + "title": "%markdown.editor.insertImageFromWorkspace%", + "category": "Markdown", + "enablement": "editorLangId == markdown" } ], "menus": { diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index 731a8a059e5..f4d1818ff4b 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -22,6 +22,8 @@ "markdown.preview.refresh.title": "Refresh Preview", "markdown.preview.toggleLock.title": "Toggle Preview Locking", "markdown.findAllFileReferences": "Find File References", + "markdown.editor.insertLinkFromWorkspace": "Insert Link to File in Workspace", + "markdown.editor.insertImageFromWorkspace": "Insert Image from Workspace", "configuration.markdown.preview.openMarkdownLinks.description": "Controls how links to other Markdown files in the Markdown preview should be opened.", "configuration.markdown.preview.openMarkdownLinks.inEditor": "Try to open links in the editor.", "configuration.markdown.preview.openMarkdownLinks.inPreview": "Try to open links in the Markdown preview.", diff --git a/extensions/markdown-language-features/src/commands/index.ts b/extensions/markdown-language-features/src/commands/index.ts index b93a9d2ed2a..8904d4a547c 100644 --- a/extensions/markdown-language-features/src/commands/index.ts +++ b/extensions/markdown-language-features/src/commands/index.ts @@ -3,11 +3,41 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export { RefreshPreviewCommand } from './refreshPreview'; -export { ReloadPlugins } from './reloadPlugins'; -export { RenderDocument } from './renderDocument'; -export { ShowLockedPreviewToSideCommand, ShowPreviewCommand, ShowPreviewToSideCommand } from './showPreview'; -export { ShowPreviewSecuritySelectorCommand } from './showPreviewSecuritySelector'; -export { ShowSourceCommand } from './showSource'; -export { ToggleLockCommand } from './toggleLock'; +import * as vscode from 'vscode'; +import { CommandManager } from '../commandManager'; +import { MarkdownItEngine } from '../markdownEngine'; +import { MarkdownPreviewManager } from '../preview/previewManager'; +import { ContentSecurityPolicyArbiter, PreviewSecuritySelector } from '../preview/security'; +import { TelemetryReporter } from '../telemetryReporter'; +import { InsertLinkFromWorkspace, InsertImageFromWorkspace } from './insertResource'; +import { RefreshPreviewCommand } from './refreshPreview'; +import { ReloadPlugins } from './reloadPlugins'; +import { RenderDocument } from './renderDocument'; +import { ShowLockedPreviewToSideCommand, ShowPreviewCommand, ShowPreviewToSideCommand } from './showPreview'; +import { ShowPreviewSecuritySelectorCommand } from './showPreviewSecuritySelector'; +import { ShowSourceCommand } from './showSource'; +import { ToggleLockCommand } from './toggleLock'; +export function registerMarkdownCommands( + commandManager: CommandManager, + previewManager: MarkdownPreviewManager, + telemetryReporter: TelemetryReporter, + cspArbiter: ContentSecurityPolicyArbiter, + engine: MarkdownItEngine, +): vscode.Disposable { + const previewSecuritySelector = new PreviewSecuritySelector(cspArbiter, previewManager); + + commandManager.register(new ShowPreviewCommand(previewManager, telemetryReporter)); + commandManager.register(new ShowPreviewToSideCommand(previewManager, telemetryReporter)); + commandManager.register(new ShowLockedPreviewToSideCommand(previewManager, telemetryReporter)); + commandManager.register(new ShowSourceCommand(previewManager)); + commandManager.register(new RefreshPreviewCommand(previewManager, engine)); + commandManager.register(new ShowPreviewSecuritySelectorCommand(previewSecuritySelector, previewManager)); + commandManager.register(new ToggleLockCommand(previewManager)); + commandManager.register(new RenderDocument(engine)); + commandManager.register(new ReloadPlugins(previewManager, engine)); + commandManager.register(new InsertLinkFromWorkspace()); + commandManager.register(new InsertImageFromWorkspace()); + + return commandManager; +} diff --git a/extensions/markdown-language-features/src/commands/insertResource.ts b/extensions/markdown-language-features/src/commands/insertResource.ts new file mode 100644 index 00000000000..65c0c9249a0 --- /dev/null +++ b/extensions/markdown-language-features/src/commands/insertResource.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as nls from 'vscode-nls'; +import { Command } from '../commandManager'; +import { createUriListSnippet, getParentDocumentUri, imageFileExtensions } from '../languageFeatures/dropIntoEditor'; +import { coalesce } from '../util/arrays'; + +const localize = nls.loadMessageBundle(); + + +export class InsertLinkFromWorkspace implements Command { + public readonly id = 'markdown.editor.insertLinkFromWorkspace'; + + public async execute(resources?: vscode.Uri[]) { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return; + } + + resources ??= await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: true, + canSelectMany: true, + openLabel: localize('insertLink.openLabel', "Insert link"), + title: localize('insertLink.title', "Insert link"), + defaultUri: getParentDocumentUri(activeEditor.document), + }); + + return insertLink(activeEditor, resources ?? [], false); + } +} + +export class InsertImageFromWorkspace implements Command { + public readonly id = 'markdown.editor.insertImageFromWorkspace'; + + public async execute(resources?: vscode.Uri[]) { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return; + } + + resources ??= await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + filters: { + [localize('insertImage.imagesLabel', "Images")]: Array.from(imageFileExtensions) + }, + openLabel: localize('insertImage.openLabel', "Insert image"), + title: localize('insertImage.title', "Insert image"), + defaultUri: getParentDocumentUri(activeEditor.document), + }); + + return insertLink(activeEditor, resources ?? [], true); + } +} + +async function insertLink(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsImage: boolean): Promise { + if (!selectedFiles.length) { + return; + } + + const edit = createInsertLinkEdit(activeEditor, selectedFiles, insertAsImage); + await vscode.workspace.applyEdit(edit); +} + +function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsImage: 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, + placeholderText: selectionText, + placeholderStartIndex: (i + 1) * selectedFiles.length, + }); + + return snippet ? new vscode.SnippetTextEdit(selection, snippet) : undefined; + })); + + const edit = new vscode.WorkspaceEdit(); + edit.set(activeEditor.document.uri, snippetEdits); + return edit; +} diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index 1511398e787..dbfafc2bdb5 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { MdLanguageClient } from './client/client'; import { CommandManager } from './commandManager'; -import * as commands from './commands/index'; +import { registerMarkdownCommands } from './commands/index'; import { registerPasteSupport } from './languageFeatures/copyPaste'; import { registerDiagnosticSupport } from './languageFeatures/diagnostics'; import { registerDropIntoEditorSupport } from './languageFeatures/dropIntoEditor'; @@ -17,8 +17,8 @@ import { MarkdownItEngine } from './markdownEngine'; import { MarkdownContributionProvider } from './markdownExtensions'; import { MdDocumentRenderer } from './preview/documentRenderer'; import { MarkdownPreviewManager } from './preview/previewManager'; -import { ContentSecurityPolicyArbiter, ExtensionContentSecurityPolicyArbiter, PreviewSecuritySelector } from './preview/security'; -import { loadDefaultTelemetryReporter, TelemetryReporter } from './telemetryReporter'; +import { ExtensionContentSecurityPolicyArbiter } from './preview/security'; +import { loadDefaultTelemetryReporter } from './telemetryReporter'; import { MdLinkOpener } from './util/openDocumentLink'; export function activateShared( @@ -63,23 +63,3 @@ function registerMarkdownLanguageFeatures( ); } -function registerMarkdownCommands( - commandManager: CommandManager, - previewManager: MarkdownPreviewManager, - telemetryReporter: TelemetryReporter, - cspArbiter: ContentSecurityPolicyArbiter, - engine: MarkdownItEngine, -): vscode.Disposable { - const previewSecuritySelector = new PreviewSecuritySelector(cspArbiter, previewManager); - - commandManager.register(new commands.ShowPreviewCommand(previewManager, telemetryReporter)); - commandManager.register(new commands.ShowPreviewToSideCommand(previewManager, telemetryReporter)); - commandManager.register(new commands.ShowLockedPreviewToSideCommand(previewManager, telemetryReporter)); - commandManager.register(new commands.ShowSourceCommand(previewManager)); - commandManager.register(new commands.RefreshPreviewCommand(previewManager, engine)); - commandManager.register(new commands.ShowPreviewSecuritySelectorCommand(previewSecuritySelector, previewManager)); - commandManager.register(new commands.ToggleLockCommand(previewManager)); - commandManager.register(new commands.RenderDocument(engine)); - commandManager.register(new commands.ReloadPlugins(previewManager, engine)); - return commandManager; -} diff --git a/extensions/markdown-language-features/src/languageFeatures/dropIntoEditor.ts b/extensions/markdown-language-features/src/languageFeatures/dropIntoEditor.ts index 29eabcaf457..d694886ffbb 100644 --- a/extensions/markdown-language-features/src/languageFeatures/dropIntoEditor.ts +++ b/extensions/markdown-language-features/src/languageFeatures/dropIntoEditor.ts @@ -8,20 +8,20 @@ import * as vscode from 'vscode'; import * as URI from 'vscode-uri'; import { Schemes } from '../util/schemes'; -const imageFileExtensions = new Set([ - '.bmp', - '.gif', - '.ico', - '.jpe', - '.jpeg', - '.jpg', - '.png', - '.psd', - '.svg', - '.tga', - '.tif', - '.tiff', - '.webp', +export const imageFileExtensions = new Set([ + 'bmp', + 'gif', + 'ico', + 'jpe', + 'jpeg', + 'jpg', + 'png', + 'psd', + 'svg', + 'tga', + 'tif', + 'tiff', + 'webp', ]); export function registerDropIntoEditorSupport(selector: vscode.DocumentSelector) { @@ -56,7 +56,20 @@ export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTr return createUriListSnippet(document, uris); } -export function createUriListSnippet(document: vscode.TextDocument, uris: readonly vscode.Uri[]): vscode.SnippetString | undefined { +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; +} + +export function createUriListSnippet(document: vscode.TextDocument, uris: readonly vscode.Uri[], options?: UriListSnippetOptions): vscode.SnippetString | undefined { if (!uris.length) { return undefined; } @@ -69,9 +82,15 @@ export function createUriListSnippet(document: vscode.TextDocument, uris: readon ? encodeURI(path.relative(dir.fsPath, uri.fsPath).replace(/\\/g, '/')) : uri.toString(false); - const ext = URI.Utils.extname(uri).toLowerCase(); - snippet.appendText(imageFileExtensions.has(ext) ? '![' : '['); - snippet.appendTabstop(); + const ext = URI.Utils.extname(uri).toLowerCase().replace('.', ''); + const insertAsImage = typeof options?.insertAsImage === 'undefined' ? imageFileExtensions.has(ext) : !!options.insertAsImage; + + 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) { @@ -89,7 +108,7 @@ function getDocumentDir(document: vscode.TextDocument): vscode.Uri | undefined { return URI.Utils.dirname(docUri); } -function getParentDocumentUri(document: vscode.TextDocument): vscode.Uri { +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()) { diff --git a/extensions/markdown-language-features/src/util/arrays.ts b/extensions/markdown-language-features/src/util/arrays.ts index 10599259901..8163fee0c73 100644 --- a/extensions/markdown-language-features/src/util/arrays.ts +++ b/extensions/markdown-language-features/src/util/arrays.ts @@ -2,6 +2,12 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/** + * @returns New array with all falsy values removed. The original array IS NOT modified. + */ +export function coalesce(array: ReadonlyArray): T[] { + return array.filter(e => !!e); +} export function equals(one: ReadonlyArray, other: ReadonlyArray, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean { if (one.length !== other.length) {