diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 8240a451ea9..bcac9df9e23 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -121,6 +121,10 @@ } ], "commands": [ + { + "command": "_markdown.copyImage", + "title": "%markdown.copyImage.title%" + }, { "command": "markdown.showPreview", "title": "%markdown.preview.title%", @@ -182,6 +186,12 @@ } ], "menus": { + "webview/context": [ + { + "command": "_markdown.copyImage", + "when": "webviewId == 'markdown.preview' && webviewSection == 'image'" + } + ], "editor/title": [ { "command": "markdown.showPreviewToSide", diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index 8049ad94473..9f7d5f4bce6 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -1,6 +1,7 @@ { "displayName": "Markdown Language Features", "description": "Provides rich language support for Markdown.", + "markdown.copyImage.title": "Copy Image", "markdown.preview.breaks.desc": "Sets how line-breaks are rendered in the Markdown preview. Setting it to 'true' creates a
for newlines inside paragraphs.", "markdown.preview.linkify": "Convert URL-like text to links in the Markdown preview.", "markdown.preview.typographer": "Enable some language-neutral replacement and quotes beautification in the Markdown preview.", diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index 4b012c5eeb1..bc922024b5b 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -63,6 +63,7 @@ function doAfterImagesLoaded(cb: () => void) { onceDocumentLoaded(() => { const scrollProgress = state.scrollProgress; + addImageContexts(); if (typeof scrollProgress === 'number' && !settings.settings.fragment) { doAfterImagesLoaded(() => { scrollDisabledCount += 1; @@ -125,9 +126,58 @@ window.addEventListener('resize', () => { updateScrollProgress(); }, true); +function addImageContexts() { + const images = document.getElementsByTagName('img'); + let idNumber = 0; + for (const img of images) { + img.id = 'image-' + idNumber; + idNumber += 1; + img.setAttribute('data-vscode-context', JSON.stringify({ webviewSection: 'image', id: img.id, 'preventDefaultContextMenuItems': true, resource: documentResource })); + } +} + +async function copyImage(image: HTMLImageElement, retries = 5) { + if (!document.hasFocus() && retries > 0) { + // copyImage is called at the same time as webview.reveal, which means this function is running whilst the webview is gaining focus. + // Since navigator.clipboard.write requires the document to be focused, we need to wait for focus. + // We cannot use a listener, as there is a high chance the focus is gained during the setup of the listener resulting in us missing it. + setTimeout(() => { copyImage(image, retries - 1); }, 20); + return; + } + + try { + await navigator.clipboard.write([new ClipboardItem({ + 'image/png': new Promise((resolve) => { + const canvas = document.createElement('canvas'); + if (canvas !== null) { + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d'); + context?.drawImage(image, 0, 0); + } + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } + canvas.remove(); + }, 'image/png'); + }) + })]); + } catch (e) { + console.error(e); + } +} + window.addEventListener('message', async event => { const data = event.data as ToWebviewMessage.Type; switch (data.type) { + case 'copyImage': { + const img = document.getElementById(data.id); + if (img instanceof HTMLImageElement) { + copyImage(img); + } + return; + } case 'onDidChangeTextEditorSelection': if (data.source === documentResource) { marker.onDidChangeTextEditorSelection(data.line, documentVersion); @@ -239,6 +289,7 @@ window.addEventListener('message', async event => { ++documentVersion; window.dispatchEvent(new CustomEvent('vscode.markdown.updateContent')); + addImageContexts(); break; } } diff --git a/extensions/markdown-language-features/src/commands/copyImage.ts b/extensions/markdown-language-features/src/commands/copyImage.ts new file mode 100644 index 00000000000..86fd349c730 --- /dev/null +++ b/extensions/markdown-language-features/src/commands/copyImage.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Command } from '../commandManager'; +import { MarkdownPreviewManager } from '../preview/previewManager'; + +export class CopyImageCommand implements Command { + public readonly id = '_markdown.copyImage'; + + public constructor( + private readonly _webviewManager: MarkdownPreviewManager, + ) { } + + public execute(args: { id: string; resource: string }) { + const source = vscode.Uri.parse(args.resource); + this._webviewManager.findPreview(source)?.copyImage(args.id); + } +} diff --git a/extensions/markdown-language-features/src/commands/index.ts b/extensions/markdown-language-features/src/commands/index.ts index 8904d4a547c..e1a4f2b41ff 100644 --- a/extensions/markdown-language-features/src/commands/index.ts +++ b/extensions/markdown-language-features/src/commands/index.ts @@ -14,6 +14,7 @@ import { RefreshPreviewCommand } from './refreshPreview'; import { ReloadPlugins } from './reloadPlugins'; import { RenderDocument } from './renderDocument'; import { ShowLockedPreviewToSideCommand, ShowPreviewCommand, ShowPreviewToSideCommand } from './showPreview'; +import { CopyImageCommand } from './copyImage'; import { ShowPreviewSecuritySelectorCommand } from './showPreviewSecuritySelector'; import { ShowSourceCommand } from './showSource'; import { ToggleLockCommand } from './toggleLock'; @@ -27,6 +28,7 @@ export function registerMarkdownCommands( ): vscode.Disposable { const previewSecuritySelector = new PreviewSecuritySelector(cspArbiter, previewManager); + commandManager.register(new CopyImageCommand(previewManager)); commandManager.register(new ShowPreviewCommand(previewManager, telemetryReporter)); commandManager.register(new ShowPreviewToSideCommand(previewManager, telemetryReporter)); commandManager.register(new ShowLockedPreviewToSideCommand(previewManager, telemetryReporter)); diff --git a/extensions/markdown-language-features/src/preview/preview.ts b/extensions/markdown-language-features/src/preview/preview.ts index 828442a76e5..de58e54784a 100644 --- a/extensions/markdown-language-features/src/preview/preview.ts +++ b/extensions/markdown-language-features/src/preview/preview.ts @@ -442,8 +442,8 @@ export interface IManagedMarkdownPreview { readonly onDispose: vscode.Event; readonly onDidChangeViewState: vscode.Event; + copyImage(id: string): void; dispose(): void; - refresh(): void; updateConfiguration(): void; @@ -515,6 +515,15 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow })); } + copyImage(id: string) { + this._webviewPanel.reveal(); + this._preview.postMessage({ + type: 'copyImage', + source: this.resource.toString(), + id: id + }); + } + private readonly _onDispose = this._register(new vscode.EventEmitter()); public readonly onDispose = this._onDispose.event; @@ -661,6 +670,15 @@ export class DynamicMarkdownPreview extends Disposable implements IManagedMarkdo })); } + copyImage(id: string) { + this._webviewPanel.reveal(); + this._preview.postMessage({ + type: 'copyImage', + source: this.resource.toString(), + id: id + }); + } + private readonly _onDisposeEmitter = this._register(new vscode.EventEmitter()); public readonly onDispose = this._onDisposeEmitter.event; diff --git a/extensions/markdown-language-features/src/preview/previewManager.ts b/extensions/markdown-language-features/src/preview/previewManager.ts index 7f6de5023b3..3b46c2a4322 100644 --- a/extensions/markdown-language-features/src/preview/previewManager.ts +++ b/extensions/markdown-language-features/src/preview/previewManager.ts @@ -147,6 +147,15 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview return this._activePreview?.resourceColumn; } + public findPreview(resource: vscode.Uri): IManagedMarkdownPreview | undefined { + for (const preview of [...this._dynamicPreviews, ...this._staticPreviews]) { + if (preview.resource.fsPath === resource.fsPath) { + return preview; + } + } + return undefined; + } + public toggleLock() { const preview = this._activePreview; if (preview instanceof DynamicMarkdownPreview) { diff --git a/extensions/markdown-language-features/types/previewMessaging.d.ts b/extensions/markdown-language-features/types/previewMessaging.d.ts index ee8f91f596f..05d10af6597 100644 --- a/extensions/markdown-language-features/types/previewMessaging.d.ts +++ b/extensions/markdown-language-features/types/previewMessaging.d.ts @@ -65,9 +65,16 @@ export namespace ToWebviewMessage { readonly content: string; } + export interface CopyImageContent extends BaseMessage { + readonly type: 'copyImage'; + readonly source: string; + readonly id: string; + } + export type Type = | OnDidChangeTextEditorSelection | UpdateView | UpdateContent + | CopyImageContent ; }