Handling Selection and Link Context Menu Clicks
Act on a context menu click in MV3 — what OnClickData contains for selections, links and images, when you need the full selection from the page, and what the click grants you.
Table of Contents
The click arrives in the service worker as an OnClickData object and a Tab, and most of what you need is already in it — the selected text, the link URL, the image source, the frame. What is not in it is the thing people most often want: the full selection with its formatting, the element that was clicked, or anything about the page’s DOM. Knowing which is which decides whether the handler is three lines or a round trip into the page. This guide is part of context menus and right-click actions.
What the click event carries
Step-by-step
1. Dispatch by menu id at the top level
1const HANDLERS = {
2 "define-selection": defineSelection,
3 "open-issue": openIssue,
4 "save-image": saveImage,
5 "copy-clean-link": copyCleanLink,
6};
7
8chrome.contextMenus.onClicked.addListener((info, tab) => {
9 return HANDLERS[info.menuItemId]?.(info, tab);
10});
Execution context: the service worker, registered synchronously — the click wakes the worker and is dispatched at the end of the first pass, so a late registration loses it, as set out in registering listeners at the top level. Returning the handler’s promise keeps the worker alive until it settles.
2. Use selectionText when plain text is enough
1async function defineSelection(info) {
2 const term = info.selectionText?.trim().slice(0, 80);
3 if (!term) return;
4 const url = `https://dictionary.example.com/?q=${encodeURIComponent(term)}`;
5 await chrome.tabs.create({ url, index: undefined });
6}
Execution context: the service worker. selectionText is whitespace-normalised and may be truncated for very long selections; for a lookup, both are fine. Always encode it — it is page-controlled text heading into a URL.
3. Fetch the real selection when you need more
For a “save quote” feature that must keep paragraph breaks, links or the full length, read the selection in the frame that was clicked.
1async function saveQuote(info, tab) {
2 const [{ result }] = await chrome.scripting.executeScript({
3 target: { tabId: tab.id, frameIds: [info.frameId ?? 0] },
4 func: () => {
5 const sel = getSelection();
6 if (!sel || sel.isCollapsed) return null;
7 const range = sel.getRangeAt(0);
8 const div = document.createElement("div");
9 div.append(range.cloneContents());
10 return { text: sel.toString(), html: div.innerHTML, url: location.href };
11 },
12 });
13 if (result) await storeQuote(result);
14}
Execution context: the injected function runs in the isolated world of the clicked frame; the surrounding call runs in the service worker. The context-menu click grants activeTab for that tab, so this works without a host permission. Targeting info.frameId matters on pages with iframes — the selection lives in one specific frame, and injecting into the top frame would find nothing. The returned html is page-controlled and must be sanitised before rendering, as in sanitising untrusted page data in an extension.
4. Handle links with the URL you were given
1async function openIssue(info) {
2 const m = info.linkUrl?.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
3 if (!m) return; // pattern matched loosely, validate precisely
4 const [, owner, repo, num] = m;
5 await chrome.tabs.create({ url: `https://tracker.example.com/gh/${owner}/${repo}/${num}` });
6}
Execution context: the service worker. The menu’s targetUrlPatterns got the item shown only on issue links; the handler still validates exactly, because match patterns cannot express “a number after /issues/”. The filter side is covered in context menu contexts and target filters.
5. Act on images by URL, not by fetching blindly
1async function saveImage(info) {
2 if (!info.srcUrl || info.srcUrl.startsWith("data:") && info.srcUrl.length > 2_000_000) return;
3 const res = await fetch(info.srcUrl, { credentials: "omit" });
4 if (!res.ok || !res.headers.get("content-type")?.startsWith("image/")) return;
5 await putImage({ src: info.srcUrl, page: info.pageUrl, blob: await res.blob() });
6}
Execution context: the service worker. credentials: "omit" avoids sending the user’s cookies for that image host to a URL the page controls. A cross-origin fetch needs a host permission for the image’s origin; without one, fall back to injecting into the page and drawing the image to a canvas there.
6. Know what the click grants
A context-menu click is a user gesture. In Chrome it grants activeTab for the tab, which lasts until the tab navigates — enough for executeScript, captureVisibleTab and reading tab.url.
1chrome.contextMenus.onClicked.addListener(async (info, tab) => {
2 // activeTab is live here, so tab.url is populated even without "tabs"
3 console.debug("[menu]", info.menuItemId, "on", new URL(tab.url).hostname);
4});
Execution context: the service worker. The grant does not extend to permissions.request, which needs a gesture in a visible extension page, and it does not survive a navigation — the lifecycle described in injecting only after a user gesture with activeTab.
Feedback after the click
The menu closes the instant the user clicks, and the handler runs in a context they cannot see. Without feedback, a successful save and a silent failure look identical, and users click again.
Three levels of feedback, from lightest to heaviest:
- A badge tick on the action, cleared after a minute by an alarm. Enough for “saved”.
- An in-page toast injected into the tab — richer, and appropriate when the action produced something the user will want to see.
- A notification — reserve for long-running actions that complete after the user has moved on.
1async function confirmInPage(tabId, message) {
2 await chrome.scripting.executeScript({
3 target: { tabId },
4 func: (msg) => {
5 const host = document.createElement("div");
6 host.attachShadow({ mode: "closed" }).innerHTML =
7 `<div style="position:fixed;bottom:16px;right:16px;padding:8px 12px;border-radius:8px;
8 background:#0f172a;color:#fff;font:13px system-ui;z-index:2147483647"></div>`;
9 host.shadowRoot?.firstElementChild?.append(msg);
10 document.documentElement.append(host);
11 setTimeout(() => host.remove(), 2500);
12 },
13 args: [message],
14 });
15}
Execution context: the injected function runs in the page’s isolated world; the message is passed as an argument and appended as text, never interpolated into markup. The shadow root keeps the page’s CSS away from the toast — the technique from injecting UI with shadow DOM without breaking the page. Note that with a closed root, host.shadowRoot is null; keep a reference to the root returned by attachShadow in real code.
Cross-browser variation
- Chrome / Edge:
OnClickDataincludesselectionText,linkUrl,srcUrl,pageUrl,frameId,frameUrlandeditable. The click grantsactiveTab. - Firefox:
browser.menus.onClickedaddslinkText,targetElementId(usable withmenus.getTargetElementin a content script to get the actual element), andmodifiersfor Shift/Ctrl-click variants. - Safari: supports the core fields;
frameIdhas been less reliable, so a Safari fallback that injects into all frames and picks the one with a non-empty selection is worth having. - All three:
selectionTextis plain text only. Anything that depends on formatting, surrounding context or the DOM needs an injection.
Verification
- Right-click a selection inside an iframe and confirm the handler receives a non-zero
frameIdand the capture returns the text:
1chrome.contextMenus.onClicked.addListener((info) => console.debug(info.menuItemId, info.frameId, info.selectionText));
Execution context: the service worker console. A frameId of 0 for a selection you know is inside an iframe means the click was attributed to the top frame — check the menu’s contexts include selection and not only page.
- Save an image from a cross-origin CDN and confirm the fetch omits cookies — check the request in the worker’s Network panel.
- Confirm feedback appears for each action and clears.
- Right-click on a restricted page and confirm the items are absent rather than failing on click.
FAQ
Can I get the DOM element that was right-clicked?
In Firefox, yes — info.targetElementId with menus.getTargetElement in a content script. In Chrome there is no equivalent; a content script listening for the contextmenu event can record the target just before the menu opens, and the handler can ask for it.
Why is selectionText shorter than what I selected?
The browser truncates it. Inject into the frame and read getSelection().toString() for the full text.
Does the click work if my worker was asleep?
Yes — the click is an event that wakes the worker. It fails only if the listener was registered late.
Related
- Context menu contexts and target filters — deciding where items appear.
- Reading and writing the clipboard in MV3 — copying from a menu click.
- Injecting into iframes and all frames — frame targeting in depth.
- Context menus and right-click actions — the parent guide.