From 341435ae0d3dc58b9a33b97aec108234a846f401 Mon Sep 17 00:00:00 2001 From: Parasaran Vedanarayanan Date: Sat, 6 Jun 2026 15:23:08 +0530 Subject: [PATCH] Fix copying webview image via context menu (#292567) Right-clicking an image in a webview and choosing "Copy" copied nothing. The webview context menu "Copy" action runs `document.execCommand('copy')`, which only copies the current selection; a right-clicked image has no selection, so nothing was placed on the clipboard. Remember the image the context menu was opened on, and when "Copy" is invoked on it, write the image bitmap to the clipboard directly. The copy runs inside the content frame's window (where the `clipboard-write` permission and origin apply): it draws the image to a canvas and uses `navigator.clipboard.write([new ClipboardItem(...)])`, falling back to re-fetching the image bytes (for cross-origin/tainted images) and finally to copying the image url as text. A short focus-retry loop handles the clipboard API's requirement that the document be focused. Also updates the inline-script CSP hash to match the new preload script. --- .../contrib/webview/browser/pre/index.html | 164 +++++++++++++++++- 1 file changed, 162 insertions(+), 2 deletions(-) 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} */