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:
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."
#---------------------------------------------------------------------------