allow images in markdown preview editor to be copied (#184432)

* allow images in markdown preview editor to be copied

* resolved feedback

* added findPreview method

* removed copy image command from showPreview

* clean up

---------

Co-authored-by: Meghan Kulkarni <t-mekulkarni@microsoft.com>
This commit is contained in:
MeghanKulkarni 2023-06-07 11:08:22 -07:00 committed by GitHub
parent 218c6d4f9b
commit 67cc0965b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 120 additions and 1 deletions

View File

@ -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",

View File

@ -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 <br> 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.",

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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));

View File

@ -442,8 +442,8 @@ export interface IManagedMarkdownPreview {
readonly onDispose: vscode.Event<void>;
readonly onDidChangeViewState: vscode.Event<vscode.WebviewPanelOnDidChangeViewStateEvent>;
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<void>());
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<void>());
public readonly onDispose = this._onDisposeEmitter.event;

View File

@ -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) {

View File

@ -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
;
}