Fix copying webview image via context menu (#292567)#320234
Fix copying webview image via context menu (#292567)#320234Parasaran-Python wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds support for copying images from the webview context menu by writing the image bitmap to the clipboard (with fallbacks), and updates the CSP to match the changed inline script.
Changes:
- Updated CSP
script-srchash for the modified inline script - Tracked the image element under the last context-menu invocation
- Implemented image-to-clipboard copy flow (canvas → refetch blob → copy URL)
bdfadde to
ed907af
Compare
| const clipboard = frameWindow.navigator.clipboard; | ||
| if (!clipboard || typeof ClipboardItem === 'undefined') { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| await clipboard.write([new ClipboardItem({ | ||
| 'image/png': new Promise((resolve, reject) => { |
There was a problem hiding this comment.
Addressed: ClipboardItem is now resolved from the content frame's realm (frameWindow.ClipboardItem) and used for both the guard and the new calls, so it no longer bails out or constructs against the outer realm.
| // 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); | ||
| lastContextMenuImageTarget = /** @type {HTMLImageElement | undefined} */ (targetElement?.closest?.('img') ?? undefined); | ||
|
|
There was a problem hiding this comment.
The remembered target is already tightly scoped: it is reset on every contextmenu event and consumed (set to undefined) the moment a copy is serviced, and the image branch only runs when the frame selection is empty/collapsed. I deliberately did not clear it on context-menu dismissal: the host sends set-context-menu-visible {visible:false} from onDidHideContextMenu, which fires when the menu closes — including when the user clicks "Copy". The ordering of that hide message vs the execCommand('copy') message is not guaranteed, so clearing on dismiss would race the copy and could break the feature. The only residual case is right-click-image → never copy → later bare Ctrl+C with no selection, which copies the image instead of nothing — a benign outcome that matches the analogous Markdown-preview behavior.
There was a problem hiding this comment.
Follow-up: now also implemented a time-based expiry as suggested. The remembered image is only honored when the copy arrives within 5s of the context menu opening (contextMenuImageTargetTtl), in addition to being consumed on use, reset on every contextmenu, and gated on an empty selection. This scopes the state to the menu invocation without clearing on menu-dismiss, which would have raced the copy message.
| if (data === 'copy' && contentWindow && lastContextMenuImageTarget && lastContextMenuImageTarget.ownerDocument === contentDocument) { | ||
| const image = lastContextMenuImageTarget; | ||
| lastContextMenuImageTarget = undefined; | ||
| const selection = contentDocument.getSelection(); | ||
| if (!selection || selection.isCollapsed) { | ||
| copyImageToClipboard(contentWindow, image); | ||
| return; | ||
| } | ||
| } |
There was a problem hiding this comment.
The remembered target is already tightly scoped: it is reset on every contextmenu event and consumed (set to undefined) the moment a copy is serviced, and the image branch only runs when the frame selection is empty/collapsed. I deliberately did not clear it on context-menu dismissal: the host sends set-context-menu-visible {visible:false} from onDidHideContextMenu, which fires when the menu closes — including when the user clicks "Copy". The ordering of that hide message vs the execCommand('copy') message is not guaranteed, so clearing on dismiss would race the copy and could break the feature. The only residual case is right-click-image → never copy → later bare Ctrl+C with no selection, which copies the image instead of nothing — a benign outcome that matches the analogous Markdown-preview behavior.
There was a problem hiding this comment.
Follow-up: now also implemented a time-based expiry as suggested. The remembered image is only honored when the copy arrives within 5s of the context menu opening (contextMenuImageTargetTtl), in addition to being consumed on use, reset on every contextmenu, and gated on an empty selection. This scopes the state to the menu invocation without clearing on menu-dismiss, which would have raced the copy message.
| const canvas = frameDocument.createElement('canvas'); | ||
| canvas.width = image.naturalWidth; | ||
| canvas.height = image.naturalHeight; | ||
| const context = canvas.getContext('2d'); |
There was a problem hiding this comment.
Addressed: added a max-pixel guard (16384 * 16384) before allocating the canvas. Images exceeding it (or with no natural dimensions) skip the canvas path and fall back to re-fetching the bytes, avoiding large-bitmap memory spikes/hangs.
5bab0c7 to
774c5e4
Compare
| // Guard against allocating an unreasonably large bitmap for very large images, which | ||
| // could spike memory or hang the webview. Fall back to re-fetching the bytes instead. | ||
| const maxCanvasPixels = 16384 * 16384; | ||
| if (!image.naturalWidth || !image.naturalHeight || image.naturalWidth * image.naturalHeight > maxCanvasPixels) { | ||
| throw new Error('Image is too large to copy via canvas'); | ||
| } |
There was a problem hiding this comment.
Addressed: replaced the flat pixel cap with a memory-based limit — the canvas backing buffer is bounded to ~256 MB (width * height * 4) and each dimension is capped at 16384. Images exceeding either fall back to re-fetching the original bytes.
| 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) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Addressed: the early return now logs via console.error ("Cannot copy image: the clipboard API is not available in this webview.") so the reason image-copy didn't happen is diagnosable.
| let lastContextMenuImageTarget = undefined; | ||
|
|
||
| /** | ||
| * Timestamp (ms) of when {@link lastContextMenuImageTarget} was last set. The remembered image | ||
| * is only honored for a copy that arrives shortly after the context menu was opened, so that a | ||
| * later unrelated copy (e.g. a keyboard shortcut) cannot pick up a stale target. | ||
| */ | ||
| let lastContextMenuImageTargetTime = 0; | ||
|
|
||
| /** How long (ms) a remembered context-menu image stays valid for a copy action. */ | ||
| const contextMenuImageTargetTtl = 5000; |
There was a problem hiding this comment.
Addressed: setting the target now goes through a setContextMenuImageTarget() helper that arms a setTimeout to drop the reference after contextMenuImageTargetTtl, so the DOM node is released even if no copy occurs. Any prior timer is cleared on each set, and the copy handler routes its clear through the same helper.
774c5e4 to
dbc99b3
Compare
| // `navigator.clipboard.write` requires the document to be focused. The copy can be triggered | ||
| // while focus is still moving back to the webview, so wait briefly for focus before writing. | ||
| if (!frameDocument.hasFocus() && retries > 0) { | ||
| setTimeout(() => { copyImageToClipboard(frameWindow, image, retries - 1); }, 20); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Addressed (conservatively): kept the short 20ms focus-retry that we verified works in practice, but added a best-effort frameWindow.focus() before the wait to help reacquire focus, and kept the wait short to preserve the transient user activation. I avoided a larger rewrite of the retry mechanism since this path is the activation-sensitive part and is shared with the proven Markdown-preview behavior.
| const response = await frameWindow.fetch(image.src); | ||
| 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(image.src); | ||
| } catch (textError) { |
There was a problem hiding this comment.
Addressed: the fallback fetch and writeText now use image.currentSrc || image.src, so the copied content matches the rendered srcset/<picture> source.
| function setContextMenuImageTarget(image) { | ||
| clearTimeout(lastContextMenuImageTargetTimer); | ||
| lastContextMenuImageTarget = image; | ||
| lastContextMenuImageTargetTime = Date.now(); | ||
| if (image) { | ||
| lastContextMenuImageTargetTimer = setTimeout(() => { | ||
| lastContextMenuImageTarget = undefined; | ||
| }, contextMenuImageTargetTtl); | ||
| } | ||
| } |
There was a problem hiding this comment.
Addressed: clearing now fully resets the state machine — setContextMenuImageTarget(undefined) sets the target to undefined, the timestamp to 0, and the timer id to undefined, and the TTL timeout callback does the same. No stale timer id or timestamp is retained.
| copyImageToClipboard(contentWindow, image); | ||
| return; |
There was a problem hiding this comment.
Addressed: the call is now explicitly marked fire-and-forget with void copyImageToClipboard(...). The function also handles its own errors internally, so no rejection escapes.
dbc99b3 to
1d34669
Compare
| if (!frameDocument.hasFocus() && retries > 0) { | ||
| try { | ||
| frameWindow.focus(); | ||
| } catch (e) { | ||
| // ignore - focusing is best effort | ||
| } | ||
| setTimeout(() => { copyImageToClipboard(frameWindow, image, retries - 1); }, 20); | ||
| return; | ||
| } |
| } 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`/`<picture>` 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); | ||
| } | ||
| } | ||
| } |
|
|
||
| <meta http-equiv="Content-Security-Policy" | ||
| content="default-src 'none'; script-src 'sha256-nXjtuhBilO++r8hfxl5VjEScSmdm07wDAk6jw228DgM=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> | ||
| content="default-src 'none'; script-src 'sha256-16BLv6/XoUIoGh6GYyw8Oe2djmnpAaz7GvreLJIfAFE=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> |
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.
1d34669 to
ba3984a
Compare
Fixes #292567
Problem
Right-clicking an image in a webview and choosing Copy from the context menu copied nothing to the clipboard. The only workaround was to manually select the image first and then copy.
The webview context-menu Copy action runs
document.execCommand('copy'), which only copies the current selection. A right-clicked image creates no selection, soexecCommand('copy')succeeds but copies empty content — the image never reaches the clipboard.Fix
In the webview preload (
src/vs/workbench/contrib/webview/browser/pre/index.html):execCommand('copy').The copy runs inside the content frame's window, where the
clipboard-writepermission and the frame's origin apply. It draws the image to a canvas and usesnavigator.clipboard.write([new ClipboardItem(...)]), with fallbacks:image/png(primary).writeText(src)as a last resort.A short focus-retry loop handles the clipboard API's requirement that the document be focused (the copy can fire while focus is still returning to the webview). This mirrors the existing, proven image-copy implementation in the Markdown preview, generalized so every webview benefits with no extension changes.
The inline-script CSP hash in the preload is updated to match the new script content.
Testing
Verified with the reporter's reproduction extension (
vscode-image-copy-to-clipboard-webview): right-click image → Copy → paste now yields the image bitmap, without the select-first workaround. Also confirmed copying selected text and non-image right-clicks behave as before, and the webview loads with no CSP violations.