Dispatching contextmenu Mouse event fails to open context menu

I have a content script which, when an image element is right-clicked, sends a setTargetUrl message to a background.js script. The script, upon receiving the setTargetUrl message calls the rebuildContextMenu function which namely contains the following lines:

if (!(targetUrl.includes('youtube.com') || targetUrl.includes('youtu.be'))) buildContextMenuForImages();
if (targetUrl.includes('youtube.com') || targetUrl.includes('youtu.be')) 
buildContextMenuForYouTube();

The content script contains the following function which is called when a right-click occurs:

async function handleRightClickWithoutGrid(e) {
    if (logToConsole) console.log(`Target url sent: ${targetUrlSent}`);

    // If the target url has already been sent then do nothing
    if (!targetUrlSent) {
        const { clientX: x, clientY: y } = e;
        if (logToConsole) console.log(e);

        // If right click is on image
        const elementClicked = e.target;
        const tag = elementClicked.tagName;
        if (tag === 'IMG') {
            if (domain.includes('youtube.com') || domain.includes('youtu.be')) {
                // Get the video url
                const videoUrl = absoluteUrl(getClosestAnchorHref(elementClicked));
                //const videoId = new URL(videoUrl).searchParams.get('v');
                //const downloadUrl = ytDownloadUrl + videoId;
                await sendMessage('setTargetUrl', videoUrl);
                if (logToConsole) console.log(`Video url: ${videoUrl}`);
            } else {
                if (window.getSelection) {
                    window.getSelection().removeAllRanges();
                }
                // Get the image url
                const imgUrl = absoluteUrl(elementClicked.getAttribute('src'));
                await sendMessage('setTargetUrl', imgUrl);
                if (logToConsole) console.log(`Image url: ${imgUrl}`);
            }
            targetUrlSent = true;
        } else {
            const selectedText = getSelectedText();
            if (logToConsole) console.log(selectedText);
            // Send the selected text to background.js
            await sendMessage('setSelection', { selection: selectedText });
        }

        // Dispatch the new event on the original target element
        if (targetUrlSent) {

            // Dispatch the new event on the original target element
            setTimeout(() => {
                // Create a new context menu event
                const newEvent = new MouseEvent('contextmenu', {
                    bubbles: true,
                    cancelable: false,
                    view: window,
                    button: 2,
                    buttons: 2,
                    clientX: x,
                    clientY: y
                });

                // Dispatch the new event
                elementClicked.dispatchEvent(newEvent);
                if (logToConsole) console.log('New contextmenu event fired.');
                if (logToConsole) console.log(`Default prevented: ${newEvent.defaultPrevented}`);
                if (logToConsole) console.log(newEvent.target);
            }, 1000); // Small delay to ensure proper event handling
        }

    } else {
        targetUrlSent = false;
    }
}

The problem I’m encountering is that the context menu is opened before the setTargetUrl has been sent and rebuildContextMenu called. If I add a e.preventDefault() then the context menu is never opened. Is there a solution to this? It appears as though the dispatch of the contextmenu event never takes place.

I’m assuming you’re currently executing your content script logic using one of the following events: contextmenu, mousedown, pointerdown. If so, the problem you’re experiencing is that you’re trying to send your setTargetUrl message on (or too close to) the same event that causes the context menu to open.

In order for you to change the contents of the context menu before it opens, you need to have enough time for the page to send a message to the background, for the background’s event handler to update the context menu entries, and for the browser to synchronize those changes internally.

Typically the way I’ve seen other extension developers do this is by listening to another event that would occur shortly before the context menu is triggered, like mouseover or pointerover event.

It’s also worth noting that the logic you shared that creates a new MouseEvent probably isn’t doing what you expect. Web pages can’t trigger the browser’s context menu by creating a "contextmenu" event. This trigger a context menu implemented by the website itself, but only if the web page doesn’t check the event’s isTrusted property (which can’t be synthesized).

Hi Simeon,

Thank you for your interesting response suggesting to use ‘mouseover’ or ‘pointerover’.

I think I found another way to manage this problem:

Firstly, I use the documentUrlPatterns property to specify the only cases when I’d want the context menu item to appear:

/// Build the context menu for YouTube video downloads
function buildContextMenuForVideoDownload() {
    browser.menus.create({
        id: 'cs-download-video',
        title: 'Download Video',
        documentUrlPatterns: ['*://*.youtube.com/*', '*://*.youtu.be/*', '*://*.youtube-nocookie.com/*', '*://*.vimeo.com/*'],
        contexts: ['all']
    });
}

Secondly, when setting the target url (still in the background.js script), I use browser.menus.update ‘visible’ property to update the context menu:

async function handleSetTargetUrl(data) {
    const nativeMessagingEnabled = await browser.permissions.contains({ permissions: ['nativeMessaging'] });
    let showVideoDownloadMenu;
    if (logToConsole) console.log(`nativeMessaging permisssion: ${nativeMessagingEnabled}`);
    if (logToConsole) console.log(`TargetUrl: ${data}`);
    if (data) targetUrl = data;
    if (targetUrl.includes('youtube.com') || targetUrl.includes('youtu.be') || targetUrl.includes('youtube-nocookie.com') || targetUrl.includes('vimeo.com')) {
        showVideoDownloadMenu = true;
    } else {
        showVideoDownloadMenu = false;
    }
    await browser.menus.update('cs-download-video', {
        visible: nativeMessagingEnabled && showVideoDownloadMenu
    });
    await browser.menus.update('cs-reverse-image-search', {
        visible: !showVideoDownloadMenu
    });
    await browser.menus.update('cs-google-lens', {
        visible: !showVideoDownloadMenu
    });
}

However, I have had some mixed results: sometimes, when visiting vimeo.com, the ‘Download Video’ context menu item doesn’t appear (there seems to be a delay) and other times, when visiting youtube.com, the ‘Download Video’ context menu item appears, but so does the ‘Google Lens’ context menu item (which shouldn’t!). After reloading the web page, things get back into order though. So, it’s not as perfect as I’d like it to be, but it’s OK.

UPDATE: The above turned out not to be reliable. Things are working nicely now with your suggestion to use ‘mouseover’ and the code is a lot cleaner. Thank you.

async function handleMouseOver(e) {
    if (logToConsole) console.log(e);
    const elementClicked = e.target;
    const tag = elementClicked.tagName;

    // If right click is on image or a div with class 'iris-annotation-layer' then send the target url
    if (tag === 'IMG' || (tag === 'DIV' && elementClicked.classList.includes('iris-annotation-layer'))) {
        if (domain.includes('youtube.com') || domain.includes('youtu.be') || domain.includes('youtube-nocookie.com') || domain.includes('vimeo.com')) {
            // Get the video url
            const videoUrl = absoluteUrl(getClosestAnchorHref(elementClicked));
            await sendMessage('setTargetUrl', videoUrl);
            if (logToConsole) console.log(`Video url: ${videoUrl}`);
        } else {
            if (window.getSelection) {
                window.getSelection().removeAllRanges();
            }
            // Get the image url
            const imgUrl = absoluteUrl(elementClicked.getAttribute('src'));
            await sendMessage('setTargetUrl', imgUrl);
            if (logToConsole) console.log(`Image url: ${imgUrl}`);
        }
    }
}

async function handleRightClickWithoutGrid(e) {
    if (logToConsole) console.log(e);

    const elementClicked = e.target;
    const tag = elementClicked.tagName;

    // If right click is NOT on image or a div with class 'iris-annotation-layer' then send the target url
    if (!(tag === 'IMG' || (tag === 'DIV' && elementClicked.classList.includes('iris-annotation-layer')))) {
        const selectedText = getSelectedText();
        if (logToConsole) console.log(selectedText);
        // Send the selected text to background.js
        await sendMessage('setSelection', { selection: selectedText });
    }
}
1 Like

Nice, glad to hear you’ve got it working!

1 Like