diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html
index 1b722167644376..d5c9c09450c8ed 100644
--- a/src/vs/workbench/contrib/webview/browser/pre/index.html
+++ b/src/vs/workbench/contrib/webview/browser/pre/index.html
@@ -5,7 +5,7 @@
+ content="default-src 'none'; script-src 'sha256-16BLv6/XoUIoGh6GYyw8Oe2djmnpAaz7GvreLJIfAFE=' 'self'; frame-src 'self'; style-src 'unsafe-inline';">
{
+ lastContextMenuImageTarget = undefined;
+ lastContextMenuImageTargetTime = 0;
+ lastContextMenuImageTargetTimer = undefined;
+ }, contextMenuImageTargetTtl);
+ }
+ }
+
+ /**
+ * Copies an image element to the clipboard as a bitmap.
+ *
+ * Runs inside the content frame's window so that the `clipboard-write` permission and the
+ * frame's origin apply. Tries to read the rendered image off a canvas first, then falls back
+ * to re-fetching the source, and finally to copying the image url as text.
+ *
+ * @param {Window} frameWindow The content frame's window.
+ * @param {HTMLImageElement} image The image to copy.
+ * @param {number} retries
+ */
+ async function copyImageToClipboard(frameWindow, image, retries = 5) {
+ const frameDocument = frameWindow.document;
+
+ // `navigator.clipboard.write` requires the document to be focused. The copy can be triggered
+ // while focus is still moving back to the webview, so nudge focus and wait briefly before
+ // retrying. The wait is kept short to preserve the transient user activation from the click.
+ if (!frameDocument.hasFocus() && retries > 0) {
+ try {
+ frameWindow.focus();
+ } catch (e) {
+ // ignore - focusing is best effort
+ }
+ setTimeout(() => { copyImageToClipboard(frameWindow, image, retries - 1); }, 20);
+ return;
+ }
+
+ const clipboard = frameWindow.navigator.clipboard;
+ // Resolve `ClipboardItem` from the content frame's realm, since that is where the write runs.
+ const ClipboardItemCtor = /** @type {typeof ClipboardItem | undefined} */ (/** @type {any} */ (frameWindow).ClipboardItem);
+ if (!clipboard || !ClipboardItemCtor) {
+ console.error('Cannot copy image: the clipboard API is not available in this webview.');
+ return;
+ }
+
+ try {
+ // Guard against allocating an unreasonably large bitmap for very large images, which
+ // could spike memory or hang the webview. A canvas allocates 4 bytes (RGBA) per pixel,
+ // so cap the backing buffer to ~256 MB and also cap any single dimension. Oversized
+ // images fall back to re-fetching the original bytes instead.
+ const maxCanvasBytes = 256 * 1024 * 1024;
+ const maxCanvasDimension = 16384;
+ const width = image.naturalWidth;
+ const height = image.naturalHeight;
+ if (!width || !height
+ || width > maxCanvasDimension || height > maxCanvasDimension
+ || width * height * 4 > maxCanvasBytes) {
+ throw new Error('Image is too large to copy via canvas');
+ }
+ await clipboard.write([new ClipboardItemCtor({
+ 'image/png': new Promise((resolve, reject) => {
+ const canvas = frameDocument.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext('2d');
+ if (!context) {
+ reject(new Error('Could not get canvas context'));
+ return;
+ }
+ context.drawImage(image, 0, 0);
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob);
+ } else {
+ reject(new Error('Could not create image blob'));
+ }
+ canvas.remove();
+ }, 'image/png');
+ })
+ })]);
+ } catch (e) {
+ // Drawing the image to a canvas fails for cross-origin images (tainted canvas) or when
+ // the image is too large. Fall back to re-fetching the bytes, then to copying the url.
+ // Prefer `currentSrc` so the copied content matches the rendered `srcset`/`` source.
+ const imageSrc = image.currentSrc || image.src;
+ try {
+ const response = await frameWindow.fetch(imageSrc);
+ const blob = response.ok ? await response.blob() : undefined;
+ // Only write an actual image to the clipboard. A non-ok response (e.g. a 404 html
+ // page) or a non-image blob must not be written under an image mime type.
+ if (!blob || !blob.type.startsWith('image/')) {
+ throw new Error('Fetched resource is not an image');
+ }
+ await clipboard.write([new ClipboardItemCtor({ [blob.type]: blob })]);
+ } catch (fetchError) {
+ try {
+ await clipboard.writeText(imageSrc);
+ } catch (textError) {
+ console.error(textError);
+ }
+ }
+ }
+ }
+
const getPendingFrame = () => {
return /** @type {HTMLIFrameElement | undefined} */ (document.getElementById('pending-frame'));
};
@@ -1184,6 +1319,11 @@
e.preventDefault();
+ // Remember if the context menu was opened on an image so that the "Copy" action
+ // can copy the image even when there is no active selection.
+ const targetElement = /** @type {HTMLElement | null} */ (e.target);
+ setContextMenuImageTarget(/** @type {HTMLImageElement | undefined} */ (targetElement?.closest?.('img') ?? undefined));
+
/** @type { Record} */
let context = {};
@@ -1263,7 +1403,27 @@
if (!target) {
return;
}
- assertIsDefined(target.contentDocument).execCommand(data);
+ const contentWindow = target.contentWindow;
+ const contentDocument = assertIsDefined(target.contentDocument);
+
+ // When copying with no active selection but the context menu was opened on an image,
+ // copy the image itself to the clipboard. `execCommand('copy')` only copies the current
+ // selection (which is empty for a right-clicked image), so we instead write the image
+ // bitmap to the clipboard directly. A real text selection still takes precedence, the
+ // remembered image is consumed so it cannot affect later copies, and it is only honored
+ // briefly after the context menu was opened so an unrelated later copy ignores it.
+ if (data === 'copy' && contentWindow && lastContextMenuImageTarget && lastContextMenuImageTarget.ownerDocument === contentDocument) {
+ const image = lastContextMenuImageTarget;
+ const isFresh = Date.now() - lastContextMenuImageTargetTime <= contextMenuImageTargetTtl;
+ setContextMenuImageTarget(undefined);
+ const selection = contentDocument.getSelection();
+ if (isFresh && (!selection || selection.isCollapsed)) {
+ void copyImageToClipboard(contentWindow, image);
+ return;
+ }
+ }
+
+ contentDocument.execCommand(data);
});
/** @type {string | undefined} */