tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 11f456595ac43091170bff03ee639757f0428667
parent 42c11c70a45b1a4819c8b4bdf0c8b158ac815ab5
Author: Nishu Sheth <nsheth@mozilla.com>
Date:   Wed, 22 Oct 2025 13:29:01 +0000

Bug 1751335 - Adding right-click select on text in macOS r=masayuki

Differential Revision: https://phabricator.services.mozilla.com/D263429

Diffstat:
Mbrowser/actors/ContextMenuChild.sys.mjs | 40+++++++++++++++++++++++++++++++++++-----
Mbrowser/base/content/test/contextMenu/browser.toml | 2++
Abrowser/base/content/test/contextMenu/browser_right_click_select.js | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlayout/generic/nsIFrame.cpp | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mmodules/libpref/init/StaticPrefList.yaml | 16++++++++++++++++
5 files changed, 420 insertions(+), 15 deletions(-)

diff --git a/browser/actors/ContextMenuChild.sys.mjs b/browser/actors/ContextMenuChild.sys.mjs @@ -662,14 +662,45 @@ export class ContextMenuChild extends JSWindowActorChild { } catch (e) {} } - let selectionInfo = lazy.SelectionUtils.getSelectionDetails( - this.contentWindow - ); - this._setContext(aEvent); let context = this.context; this.target = context.target; + // If right-click select is enabled, we're on a link, and the link + // text is not selected, select the link text. + if ( + context.onLink && + Services.prefs.getBoolPref( + "ui.mouse.right_click.select_under_cursor", + false + ) + ) { + // Check if user-select: none is set on the link or its ancestors + let shouldSelect = true; + let elem = context.link; + while (elem && shouldSelect) { + if (this.contentWindow.getComputedStyle(elem).userSelect === "none") { + shouldSelect = false; + } + elem = elem.parentElement; + } + + if (shouldSelect) { + const range = this.document.createRange(); + range.selectNodeContents(context.link); + if (range.toString().trim().length) { + const sel = this.contentWindow.getSelection(); + if (sel.isCollapsed || !sel.containsNode(context.link, true)) { + sel.removeAllRanges(); + sel.addRange(range); + } + } + } + } + let selectionInfo = lazy.SelectionUtils.getSelectionDetails( + this.contentWindow + ); + let spellInfo = null; let editFlags = null; @@ -1218,7 +1249,6 @@ export class ContextMenuChild extends JSWindowActorChild { context.onTelLink = context.linkProtocol == "tel"; context.onMozExtLink = context.linkProtocol == "moz-extension"; context.onSaveableLink = this._isLinkSaveable(context.link); - context.isSponsoredLink = (elem.ownerDocument.URL === "about:newtab" || elem.ownerDocument.URL === "about:home") && diff --git a/browser/base/content/test/contextMenu/browser.toml b/browser/base/content/test/contextMenu/browser.toml @@ -100,6 +100,8 @@ skip-if = ["os == 'linux' && socketprocess_networking"] ["browser_copy_link_to_highlight_viewsource.js"] +["browser_right_click_select.js"] + ["browser_save_image.js"] ["browser_strip_on_share_link.js"] diff --git a/browser/base/content/test/contextMenu/browser_right_click_select.js b/browser/base/content/test/contextMenu/browser_right_click_select.js @@ -0,0 +1,309 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PREF_RIGHT_CLICK_SELECTS = "ui.mouse.right_click.select_under_cursor"; +const PREF_EDITABLE = "ui.mouse.right_click.select_in_editable"; +const PREF_EAT_SPACE = "layout.word_select.eat_space_to_next_word"; + +const TEST_HTML = `<!doctype html> +<title>Right-click selection</title> +<style> + #noselect { user-select: none; } + .all { user-select: all; } +</style> + <p id="p1">Test Page <a id="link1" href="https://example.com">link text here</a> additional</p> + <span id="plain">some plain selectable text</span> + <div id="noselect">Unselectable box with <a id="nlink" href="https://example.com">link inside</a></div> + <div class="all">user-select: all ancestor: <a id="alink" href="https://example.com">link text here</a></div> + `; + +const EDITABLE_HTML = `<!doctype html> +<title>Editable right-click</title> +<div id="ed" contenteditable>hello world</div> +`; +const EDITABLE_PAGE = "data:text/html," + encodeURIComponent(EDITABLE_HTML); +const TEST_PAGE = "data:text/html," + encodeURIComponent(TEST_HTML); + +// close context menu +async function closeContextMenu(contextMenuEl) { + const menuHidden = BrowserTestUtils.waitForEvent( + contextMenuEl, + "popuphidden" + ); + contextMenuEl.hidePopup(); + await menuHidden; +} + +// Right-click the center of a word +async function openContextMenuOnWord(tabBrowser, elementSelector, targetWord) { + const contextMenuEl = document.getElementById("contentAreaContextMenu"); + const menuShown = BrowserTestUtils.waitForEvent(contextMenuEl, "popupshown"); + await SpecialPowers.spawn( + tabBrowser, + [elementSelector, targetWord], + (selector, word) => { + const doc = content.document; + const element = doc.querySelector(selector); + const textNode = element.firstChild; + const fullText = textNode.textContent; + const startIndex = fullText.indexOf(word); + const range = doc.createRange(); + range.setStart(textNode, startIndex); + range.setEnd(textNode, startIndex + word.length); + const wordRect = range.getBoundingClientRect(); + const x = wordRect.left + wordRect.width / 2; + const y = wordRect.top + wordRect.height / 2; + content.focus(); + content.windowUtils.sendMouseEvent("mousedown", x, y, 2, 1, 0); + content.windowUtils.sendMouseEvent("contextmenu", x, y, 2, 1, 0); + } + ); + await menuShown; + return contextMenuEl; +} + +async function getSelectionText(tabBrowser) { + return SpecialPowers.spawn(tabBrowser, [], () => + content.getSelection().toString() + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_EAT_SPACE, false]], + }); +}); + +add_task(async function right_click_plain_text_selects_clicked_word() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_RIGHT_CLICK_SELECTS, true]] }); + await BrowserTestUtils.withNewTab(TEST_PAGE, async tabBrowser => { + const contextMenuEl = await openContextMenuOnWord( + tabBrowser, + "#plain", + "plain" + ); + const selection = await getSelectionText(tabBrowser); + is(selection, "plain", "Right-click selected just the clicked word"); + const copy = contextMenuEl.querySelector("#context-copy"); + const copyLink = contextMenuEl.querySelector("#context-copylink"); + ok(BrowserTestUtils.isVisible(copy), "Copy is visible"); + ok(!copy.hasAttribute("disabled"), "Copy is enabled"); + ok( + !BrowserTestUtils.isVisible(copyLink), + "Copy Link is hidden on non-link text" + ); + await closeContextMenu(contextMenuEl); + }); +}); + +add_task(async function user_select_none_container_does_not_auto_select() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_RIGHT_CLICK_SELECTS, true]] }); + await BrowserTestUtils.withNewTab(TEST_PAGE, async tabBrowser => { + const contextMenuEl = await openContextMenuOnWord( + tabBrowser, + "#nlink", + "link" + ); + const selection = await getSelectionText(tabBrowser); + is(selection, "", "No selection created in user-select:none region"); + const copyLink = contextMenuEl.querySelector("#context-copylink"); + ok( + BrowserTestUtils.isVisible(copyLink), + "Copy Link visible for link in noselect" + ); + await closeContextMenu(contextMenuEl); + }); +}); + +add_task(async function right_click_link_text_shows_copy_and_copy_link() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_RIGHT_CLICK_SELECTS, true]] }); + await BrowserTestUtils.withNewTab(TEST_PAGE, async tabBrowser => { + const contextMenuEl = await openContextMenuOnWord( + tabBrowser, + "#link1", + "link" + ); + const selection = await getSelectionText(tabBrowser); + is(selection, "link text here", "Right-click selected whole link text"); + const copy = contextMenuEl.querySelector("#context-copy"); + const copyLink = contextMenuEl.querySelector("#context-copylink"); + ok(BrowserTestUtils.isVisible(copy), "Copy is visible"); + ok(!copy.hasAttribute("disabled"), "Copy is enabled"); + ok( + BrowserTestUtils.isVisible(copyLink), + "Copy Link is visible for link text" + ); + await closeContextMenu(contextMenuEl); + }); +}); + +add_task(async function right_click_moves_selection_to_clicked_word() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_RIGHT_CLICK_SELECTS, true]] }); + await BrowserTestUtils.withNewTab(TEST_PAGE, async tabBrowser => { + const contextMenuEl1 = await openContextMenuOnWord( + tabBrowser, + "#plain", + "text" + ); + await closeContextMenu(contextMenuEl1); + + const contextMenuEl2 = await openContextMenuOnWord( + tabBrowser, + "#plain", + "plain" + ); + const selection = await getSelectionText(tabBrowser); + is(selection, "plain", "Selection moved to the clicked word"); + const copy = contextMenuEl2.querySelector("#context-copy"); + ok(BrowserTestUtils.isVisible(copy), "Copy visible"); + await closeContextMenu(contextMenuEl2); + }); +}); + +add_task( + async function right_click_does_not_move_existing_selection_if_already_selected() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_RIGHT_CLICK_SELECTS, true]], + }); + await BrowserTestUtils.withNewTab(TEST_PAGE, async tabBrowser => { + await SpecialPowers.spawn(tabBrowser, [], () => { + const doc = content.document; + const element = doc.getElementById("plain"); + const range = doc.createRange(); + range.selectNodeContents(element); + const selection = content.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + const menu = await openContextMenuOnWord(tabBrowser, "#plain", "plain"); + const selection = await getSelectionText(tabBrowser); + is( + selection, + "some plain selectable text", + "Right-click does not move an existing selection if already selected" + ); + const copy = menu.querySelector("#context-copy"); + ok( + BrowserTestUtils.isVisible(copy), + "Copy visible on existing selection" + ); + await closeContextMenu(menu); + }); + } +); + +add_task(async function pref_off_does_not_select_plain_or_link() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_RIGHT_CLICK_SELECTS, false]] }); + await BrowserTestUtils.withNewTab(TEST_PAGE, async tabBrowser => { + let menu = await openContextMenuOnWord(tabBrowser, "#plain", "plain"); + let sel = await getSelectionText(tabBrowser); + is(sel, "", "pref=false: no selection on plain text"); + ok( + !BrowserTestUtils.isVisible(menu.querySelector("#context-copy")), + "pref=false: Copy hidden without selection on plain text" + ); + await closeContextMenu(menu); + + menu = await openContextMenuOnWord(tabBrowser, "#link1", "link"); + sel = await getSelectionText(tabBrowser); + is(sel, "", "pref=false: no selection on link text"); + ok( + BrowserTestUtils.isVisible(menu.querySelector("#context-copylink")), + "pref=false: Copy Link still visible on link" + ); + await closeContextMenu(menu); + }); +}); + +add_task(async function user_select_all_ancestor_selects_ancestor_contents() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_RIGHT_CLICK_SELECTS, true]] }); + await BrowserTestUtils.withNewTab(TEST_PAGE, async tabBrowser => { + const menu = await openContextMenuOnWord(tabBrowser, "#alink", "link"); + const sel = await getSelectionText(tabBrowser); + is( + sel, + "link text here", + "Select the nearest user-select:all ancestor's contents" + ); + ok( + BrowserTestUtils.isVisible(menu.querySelector("#context-copy")), + "Copy visible with selection" + ); + ok( + BrowserTestUtils.isVisible(menu.querySelector("#context-copylink")), + "Copy Link visible on link" + ); + await closeContextMenu(menu); + }); +}); + +add_task(async function editable_pref_off_does_not_select_word() { + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_RIGHT_CLICK_SELECTS, true], + [PREF_EDITABLE, false], + ], + }); + await BrowserTestUtils.withNewTab(EDITABLE_PAGE, async tabBrowser => { + const menu = await openContextMenuOnWord(tabBrowser, "#ed", "world"); + const sel = await getSelectionText(tabBrowser); + is(sel, "", "pref=false: no text selection should be created in editable"); + await closeContextMenu(menu); + }); +}); + +add_task(async function editable_pref_on_selects_word() { + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_RIGHT_CLICK_SELECTS, true], + [PREF_EDITABLE, true], + ], + }); + await BrowserTestUtils.withNewTab(EDITABLE_PAGE, async tabBrowser => { + const menu = await openContextMenuOnWord(tabBrowser, "#ed", "world"); + const sel = await getSelectionText(tabBrowser); + is( + sel, + "world", + "pref=true: right-click selects the clicked word in editable" + ); + ok( + BrowserTestUtils.isVisible(menu.querySelector("#context-copy")), + "pref=true: Copy visible with a selection" + ); + await closeContextMenu(menu); + }); +}); + +add_task(async function editable_existing_selection_is_preserved() { + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_RIGHT_CLICK_SELECTS, true], + [PREF_EDITABLE, true], + ], + }); + await BrowserTestUtils.withNewTab(EDITABLE_PAGE, async tabBrowser => { + await SpecialPowers.spawn(tabBrowser, [], () => { + const el = content.document.getElementById("ed").firstChild; + const r = content.document.createRange(); + const txt = el.data; + const start = txt.indexOf("hello"); + r.setStart(el, start); + r.setEnd(el, start + "hello world".length); + const sel = content.getSelection(); + sel.removeAllRanges(); + sel.addRange(r); + }); + + const menu = await openContextMenuOnWord(tabBrowser, "#ed", "hello"); + const sel = await getSelectionText(tabBrowser); + is( + sel, + "hello world", + "existing non-collapsed selection in editable is preserved" + ); + await closeContextMenu(menu); + }); +}); diff --git a/layout/generic/nsIFrame.cpp b/layout/generic/nsIFrame.cpp @@ -4962,16 +4962,64 @@ nsresult nsIFrame::MoveCaretToEventPoint(nsPresContext* aPresContext, const bool isSecondaryButton = aMouseEvent->mButton == MouseButton::eSecondary; - if (isSecondaryButton && - !MovingCaretToEventPointAllowedIfSecondaryButtonEvent( - *frameselection, *aMouseEvent, *offsets.content, - // When we collapse selection in nsFrameSelection::TakeFocus, - // we always collapse selection to the start offset. Therefore, - // we can ignore the end offset here. E.g., when an <img> is clicked, - // set the primary offset to after it, but the the secondary offset - // may be before it, see OffsetsForSingleFrame for the detail. - offsets.StartOffset())) { - return NS_OK; + if (isSecondaryButton) { + const bool rightClickSelectIsEnabled = + StaticPrefs::ui_mouse_right_click_select_under_cursor(); + const bool allowEditable = + StaticPrefs::ui_mouse_right_click_select_in_editable(); + const bool isEditableHere = + offsets.content && offsets.content->IsEditable(); + const bool selectClickedWord = + rightClickSelectIsEnabled && (!isEditableHere || allowEditable); + // On right-click, collapse the selection to the click point if enabled + // in prefs, and either the target isn't editable or editable fields are + // allowed. + + // sel is grabbed by hideSelectionChanges, so, it's safe to access this even + // after running script. + const OwningNonNull<Selection> sel = frameselection->NormalSelection(); + const SelectionBatcher hideSelectionChanges( + *sel, __FUNCTION__, nsISelectionListener::MOUSEDOWN_REASON); + const bool clickedOnCaret = + sel->IsCollapsed() && sel->GetAnchorNode() == offsets.content && + sel->AnchorOffset() == static_cast<uint32_t>(offsets.StartOffset()); + if (offsets.content && offsets.offset >= 0 && selectClickedWord && + !nsContentUtils::IsPointInSelection( + *sel, *offsets.content, static_cast<uint32_t>(offsets.offset), + true)) { + // Collapse selection to the clicked point. + nsCOMPtr<nsIContent> content = offsets.content; + fc->HandleClick(content, offsets.StartOffset(), offsets.EndOffset(), + nsFrameSelection::FocusMode::kCollapseToNewPoint, + offsets.associate); + } + + if (!MovingCaretToEventPointAllowedIfSecondaryButtonEvent( + *frameselection, *aMouseEvent, *offsets.content, + // When we collapse selection in nsFrameSelection::TakeFocus, + // we always collapse selection to the start offset. Therefore, + // we can ignore the end offset here. E.g., when an <img> is + // clicked, set the primary offset to after it, but the the + // secondary offset may be before it, see OffsetsForSingleFrame for + // the detail. + offsets.StartOffset())) { + return NS_OK; + } + + if (selectClickedWord) { + // Skip word selection when right-clicking directly on the caret in an + // editable field. Users typically right-click here to open the context + // menu, not to select text. + if (isEditableHere && clickedOnCaret) { + return NS_OK; + } + nsIFrame* frameUnderPoint = + nsLayoutUtils::GetFrameForPoint(RelativeTo{this}, pt); + if (!frameUnderPoint->IsTextFrame()) { + return NS_OK; + } + return SelectByTypeAtPoint(pt, eSelectWord, eSelectWord, 0); + } } if (aMouseEvent->mMessage == eMouseDown && diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml @@ -18305,6 +18305,22 @@ value: false mirror: always +# When true, right-clicking selects the word/link under the cursor. +- name: ui.mouse.right_click.select_under_cursor + type: bool +#ifdef XP_MACOSX + value: @EARLY_BETA_OR_EARLIER@ +#else + value: false +#endif + mirror: always + +# When true, right-clicking selects the word under the cursor in editable +- name: ui.mouse.right_click.select_in_editable + type: bool + value: false + mirror: always + #--------------------------------------------------------------------------- # Prefs starting with "urlclassifier." #---------------------------------------------------------------------------