Copying to the Clipboard from a Context Menu
Copy text to the clipboard from an MV3 context menu click: why the service worker can't write it, using an offscreen document with the CLIPBOARD reason, writing from the page via scripting, rich formats and feedback.
Table of Contents
The context menu item says “Copy as Markdown link”. The user clicks it, the service worker builds [Title](https://…), calls navigator.clipboard.writeText — and gets “navigator.clipboard is undefined”, because service workers have no Clipboard API. In MV2 the background page could use document.execCommand("copy") on a hidden textarea; MV3 has no background document. There are two working routes: run the copy in the page itself through chrome.scripting under the click’s activeTab grant, or use an offscreen document created with the CLIPBOARD reason. Each has trade-offs around permissions, focus and restricted pages. This guide implements both and picks between them. It belongs to context menus and right-click actions.
Why the copy needs a document
The asynchronous Clipboard API (navigator.clipboard.writeText) is exposed on documents, not on service workers, and in most browsers it requires the document to have focus or recent user activation. The older document.execCommand("copy") needs a document with a selected element. A context menu click is a user gesture, but it is delivered to the service worker, which has neither. So the copy must be performed by a document: the page the user right-clicked (via an injected function, which runs with the gesture’s activeTab grant), or a hidden offscreen document owned by the extension. The page route works on ordinary sites without extra permissions; the offscreen route works even on pages where injection is impossible, but needs the offscreen permission and a correct reason.
Step-by-step: reliable copy from a menu click
1. Create the menu item
1// sw.js
2chrome.runtime.onInstalled.addListener(() => {
3 chrome.contextMenus.create({ id: "copy-md-link", title: "Copy as Markdown link", contexts: ["page", "link", "selection"] });
4});
Execution context: the service worker. Offering the item on pages, links and selections lets one command adapt: the link’s URL and text when right-clicking a link, the page’s title and URL otherwise.
2. Build the text from the click info
1function markdownFor(info, tab) {
2 const url = info.linkUrl ?? info.pageUrl ?? tab?.url ?? "";
3 const text = (info.selectionText || info.linkText || tab?.title || url).replace(/[\[\]]/g, "\\$&").trim();
4 return `[${text}](${url.replace(/\)/g, "%29")})`;
5}
Execution context: the service worker. info carries everything needed: linkUrl and (in recent Chrome) linkText for links, selectionText for selections, pageUrl otherwise. Escape characters that would break Markdown syntax. No page access is needed to build the string.
3. Write from the page under activeTab
1chrome.contextMenus.onClicked.addListener(async (info, tab) => {
2 if (info.menuItemId !== "copy-md-link") return;
3 const text = markdownFor(info, tab);
4 try {
5 const [{ result }] = await chrome.scripting.executeScript({
6 target: { tabId: tab.id, frameIds: [info.frameId ?? 0] },
7 func: async (t) => { try { await navigator.clipboard.writeText(t); return true; } catch { return false; } },
8 args: [text],
9 });
10 if (result) return confirmCopied(tab.id);
11 } catch { /* restricted page or no access */ }
12 await copyViaOffscreen(text);
13 confirmCopied(tab?.id);
14});
Execution context: the service worker injecting into the clicked frame, which has focus because the user just right-clicked in it. The context menu click grants activeTab, so no host permission is needed (declare activeTab and scripting). Targeting info.frameId writes from the frame the user clicked, which matters when the click was inside an iframe. Some pages restrict clipboard access through a Permissions Policy; the injected function reports failure and the fallback takes over.
4. Copy in an offscreen document as the fallback
1// sw.js
2async function copyViaOffscreen(text) {
3 const docs = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
4 if (!docs.length) {
5 await chrome.offscreen.createDocument({ url: "offscreen.html", reasons: ["CLIPBOARD"], justification: "Copy text chosen from the context menu" });
6 }
7 await chrome.runtime.sendMessage({ target: "offscreen", type: "copy", text });
8}
9
10// offscreen.js
11chrome.runtime.onMessage.addListener((m) => {
12 if (m.target !== "offscreen" || m.type !== "copy") return;
13 const ta = document.createElement("textarea");
14 ta.value = m.text;
15 document.body.append(ta);
16 ta.select();
17 document.execCommand("copy");
18 ta.remove();
19});
Execution context: the service worker and an offscreen document. Offscreen documents cannot be focused, so navigator.clipboard.writeText usually fails there; execCommand("copy") on a selected textarea works with the CLIPBOARD reason. Declare offscreen (and clipboardWrite for broad compatibility) in permissions. This route works on browser pages and other places injection cannot reach. See reading and writing the clipboard in MV3.
5. Copy rich formats when it helps
1func: async (html, plain) => {
2 const item = new ClipboardItem({
3 "text/html": new Blob([html], { type: "text/html" }),
4 "text/plain": new Blob([plain], { type: "text/plain" }),
5 });
6 await navigator.clipboard.write([item]);
7 return true;
8},
9args: [`<a href="${escapeAttr(url)}">${escapeHtml(title)}</a>`, markdownFor(info, tab)],
Execution context: the injected page function. Writing both HTML and plain text lets rich editors paste a real link while plain-text fields get Markdown. Escape the HTML yourself — page titles are untrusted. ClipboardItem support varies by engine; fall back to writeText when it is missing.
6. Confirm the copy
1async function confirmCopied(tabId) {
2 if (!tabId) return;
3 await chrome.action.setBadgeText({ tabId, text: "✓" });
4 setTimeout(() => chrome.action.setBadgeText({ tabId, text: "" }), 1500);
5}
Execution context: the service worker. A context menu disappears on click, so the user gets no feedback that anything happened. A brief badge tick on the toolbar icon is unobtrusive; the timeout clears it while the worker is still awake from the click.
7. Respect privacy
Clipboard contents are sensitive. Only write in response to an explicit user action, never read the clipboard without a clear feature and permission, and never send copied content anywhere unless the feature says so.
Common mistakes
- Calling
navigator.clipboardin the worker. It does not exist there. writeTextin an offscreen document. It usually fails without focus; useexecCommand.- Injecting into the top frame when the click was in an iframe. Use
info.frameId. - No fallback for restricted pages. Copy silently fails on browser pages.
- No feedback. Users try again, doubting it worked.
Cross-browser variation
- Chrome / Edge: both routes work; offscreen documents need Chrome 109+.
- Firefox: no offscreen API, but the background event page has a document, so
navigator.clipboard.writeTextwithclipboardWritepermission works directly in the background. - Safari: inject into the page under
activeTab; Safari is strict about user activation, so write immediately in the injected function.
Verification
- Right-click a link on an ordinary page, choose the item, and paste into a text editor: correct Markdown.
- Right-click inside an iframe and confirm the frame’s link is copied.
- Right-click on
chrome://extensions(with the action context) and confirm the offscreen fallback copies. - Paste into a rich editor and confirm the HTML link is used when available.
FAQ
Do I need the clipboardWrite permission?
For the page route under a user gesture, usually not; for the offscreen route, declare it for consistent behaviour across versions.
Can I copy images?
Yes, with ClipboardItem and an image/png Blob, from the page route. Support varies; test per engine.
Can the extension read what the user copied?
Only with clipboardRead, which adds an install warning. Avoid it unless reading is the feature.
Why close the offscreen document after copying?
An idle offscreen document costs memory. Close it with chrome.offscreen.closeDocument() after a short delay when no further copies arrive, and recreate it on the next fallback copy.
What if the page has a strict Content Security Policy?
Functions injected with chrome.scripting.executeScript run in the isolated world and are not blocked by the page’s CSP, so the page route still works. A Permissions Policy that disables clipboard-write is different — it can block the write, which is why the offscreen fallback matters.
Related
- Handling selection and link context menu clicks — the click info used here.
- Reading and writing the clipboard in MV3 — the offscreen clipboard in depth.
- Choosing the right offscreen reason — CLIPBOARD and other reasons.
- Context menus and right-click actions — the parent topic.