tor-browser

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

commit 217e8f2bb4d02b9267aa533f1397b3861870748c
parent 7b3bb279a64a46a2740f1f648738021d8899f001
Author: Jan-Niklas Jaeschke <jjaschke@mozilla.com>
Date:   Thu,  9 Oct 2025 13:03:59 +0000

Bug 1986373 - Text Fragments: Show context menu entries when either a selection or an existing highlight is present. r=Gijs

This patch mostly reverts the logic introduced in bug 1974126
and introduces a simpler logic as described in comment 12.

With these changes, the copy link to highlight context menu items are visible
- when there's a selection on the page or
- when there is an existing highlight.

If both are present, the selection takes precedence.
This makes the `browser_copy_link_to_existing_highlight.js` test file obsolete,
the simpler logic is tested in `browser_copy_link_to_highlight.js`.

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

Diffstat:
Mbrowser/actors/ContextMenuChild.sys.mjs | 56+++++++++++++++-----------------------------------------
Mbrowser/base/content/browser-context.js | 2+-
Mbrowser/base/content/nsContextMenu.sys.mjs | 3+--
Mbrowser/base/content/test/contextMenu/browser.toml | 2--
Dbrowser/base/content/test/contextMenu/browser_copy_link_to_existing_highlight.js | 136-------------------------------------------------------------------------------
Mbrowser/base/content/test/contextMenu/browser_copy_link_to_highlight.js | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++---
6 files changed, 72 insertions(+), 185 deletions(-)

diff --git a/browser/actors/ContextMenuChild.sys.mjs b/browser/actors/ContextMenuChild.sys.mjs @@ -321,26 +321,22 @@ export class ContextMenuChild extends JSWindowActorChild { ) { return null; } - if (!this.textDirectiveTarget) { - return null; - } const sel = this.contentWindow.getSelection(); - const ranges = - this.textDirectiveTarget === "selection" - ? Array.from({ length: sel.rangeCount }, (_, i) => - sel.getRangeAt(i) - ) - : this.document.fragmentDirective.getTextDirectiveRanges(); - return this.document.fragmentDirective - .createTextDirectiveForRanges(ranges) - .then(textFragment => { - if (textFragment) { - let url = URL.fromURI(this.document?.documentURIObject); - url.hash += `:~:${textFragment}`; - return url.href; - } - return null; - }); + const ranges = !sel.isCollapsed + ? Array.from({ length: sel.rangeCount }, (_, i) => sel.getRangeAt(i)) + : this.document.fragmentDirective.getTextDirectiveRanges(); + return ranges + ? this.document.fragmentDirective + .createTextDirectiveForRanges(ranges) + .then(textFragment => { + if (textFragment) { + let url = URL.fromURI(this.document?.documentURIObject); + url.hash += `:~:${textFragment}`; + return url.href; + } + return null; + }) + : null; } case "ContextMenu:RemoveAllTextFragments": { this.document.fragmentDirective.removeAllTextDirectives(); @@ -936,28 +932,6 @@ export class ContextMenuChild extends JSWindowActorChild { this.document.fragmentDirective?.getTextDirectiveRanges?.() || []; // .hasTextFragments indicates whether the page will show highlights. context.hasTextFragments = !!textDirectiveRanges.length; - const { offsetNode, offset } = - node.ownerDocument.caretPositionFromPoint( - aEvent.clientX, - aEvent.clientY - ) || {}; - const sel = this.contentWindow.getSelection(); - context.textDirectiveTarget = null; - if (offsetNode && offset != null) { - if ( - !sel.isCollapsed && - Array.from({ length: sel.rangeCount }, (_, i) => - sel.getRangeAt(i) - ).some(r => r.isPointInRange(offsetNode, offset)) - ) { - context.textDirectiveTarget = "selection"; - } else if ( - textDirectiveRanges.some(r => r.isPointInRange(offsetNode, offset)) - ) { - context.textDirectiveTarget = "textDirective"; - } - } - this.textDirectiveTarget = context.textDirectiveTarget; // Remember the node and its owner document that was clicked // This may be modifed before sending to nsContextMenu diff --git a/browser/base/content/browser-context.js b/browser/base/content/browser-context.js @@ -315,7 +315,7 @@ document.addEventListener( // attempts to generate the text fragment directive of selected text // Note: This is kicking off an async operation that might update // the context menu while it's open (enables an entry). - if (gContextMenu.textDirectiveTarget) { + if (gContextMenu.isContentSelected || gContextMenu.hasTextFragments) { gContextMenu.getTextDirective(); } break; diff --git a/browser/base/content/nsContextMenu.sys.mjs b/browser/base/content/nsContextMenu.sys.mjs @@ -345,7 +345,6 @@ export class nsContextMenu { } this.hasTextFragments = context.hasTextFragments; - this.textDirectiveTarget = context.textDirectiveTarget; this.textFragmentURL = null; } // setContext @@ -480,7 +479,7 @@ export class nsContextMenu { this.onEditable || this.browser.currentURI.schemeIs("view-source") ) && - this.textDirectiveTarget; + (this.hasTextFragments || this.isContentSelected); this.showItem("context-copy-link-to-highlight", shouldShow); this.showItem("context-copy-clean-link-to-highlight", shouldShow); diff --git a/browser/base/content/test/contextMenu/browser.toml b/browser/base/content/test/contextMenu/browser.toml @@ -89,8 +89,6 @@ support-files = [ ] skip-if = ["os == 'linux' && socketprocess_networking"] -["browser_copy_link_to_existing_highlight.js"] - ["browser_copy_link_to_highlight.js"] ["browser_copy_link_to_highlight_viewsource.js"] diff --git a/browser/base/content/test/contextMenu/browser_copy_link_to_existing_highlight.js b/browser/base/content/test/contextMenu/browser_copy_link_to_existing_highlight.js @@ -1,136 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - * http://creativecommons.org/publicdomain/zero/1.0/ */ - -"use strict"; - -async function openContextMenuAtSelector(browser, selector) { - const menu = document.getElementById("contentAreaContextMenu"); - const shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); - await BrowserTestUtils.synthesizeMouseAtCenter( - selector, - { type: "contextmenu", button: 2 }, - browser - ); - await shown; - return menu; -} - -function makeTestURI() { - const html = - `<p><span id="hello">Hello</span> ` + - `<span id="foo">foo</span> <span id="bar">bar</span> ` + - `<span id="baz">baz</span></p>`; - const encoded = encodeURIComponent(html); - return `data:text/html,${encoded}`; -} - -async function waitForHighlight(browser) { - await SpecialPowers.spawn(browser, [], async () => { - await ContentTaskUtils.waitForCondition( - () => content.document.fragmentDirective.getTextDirectiveRanges().length, - "Fragment highlight range present" - ); - }); -} - -/** - * Opens the context menu at |selector|, asserts that “Copy Link to Highlight” - * is visible + enabled, and verifies the clipboard content after clicking it. - * - * @param {Browser} browser Remote browser for synth clicks - * @param {String} selector CSS selector to right-click - * @param {String} testCase Description used in ok() messages - * @param {String} textFragmentURL Expected clipboard text - */ -async function assertHighlightMenu( - browser, - selector, - testCase, - textFragmentURL = null -) { - const menu = await openContextMenuAtSelector(browser, selector); - const awaitPopupHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden"); - const copy = menu.querySelector("#context-copy-link-to-highlight"); - try { - ok(!copy.hidden, `"Copy Link to Highlight" visible when ${testCase}`); - ok( - !copy.hasAttribute("disabled") || - copy.getAttribute("disabled") === "false", - `"Copy Link to Highlight" enabled when ${testCase}` - ); - - if (textFragmentURL !== null) { - // Click the item and verify clipboard; menu auto-closes. - await SimpleTest.promiseClipboardChange(textFragmentURL, async () => - copy.closest("menupopup").activateItem(copy) - ); - } else { - menu.hidePopup(); - } - } finally { - // Ensure the menu is closed, even if the test failed. - await awaitPopupHidden; - } -} - -add_task(async function test_existing_highlight_paths() { - await SpecialPowers.pushPrefEnv({ - set: [ - ["dom.text_fragments.enabled", true], - ["dom.text_fragments.create_text_fragment.enabled", true], - ], - }); - const TEST_URI = makeTestURI(); - const tab = await BrowserTestUtils.openNewForegroundTab( - gBrowser, - `${TEST_URI}#:~:text=foo%20bar` // Load the page with a highlight - ); - try { - const browser = tab.linkedBrowser; - await waitForHighlight(browser); - - // Case 1: click on selection outside highlight - await SpecialPowers.spawn(browser, [], () => { - const sel = content.getSelection(); - sel.removeAllRanges(); - const node = content.document.querySelector("#baz").firstChild; // “baz” - const range = content.document.createRange(); - range.selectNodeContents(node); - sel.addRange(range); - }); - await assertHighlightMenu( - browser, - "#baz", - "selection outside highlight", - `${TEST_URI}#:~:text=baz` - ); - - // Case 2: click inside highlight, selection is outside of highlight, highlight takes precedence - await assertHighlightMenu( - browser, - "#foo", - "inside highlight (no selection)", - `${TEST_URI}#:~:text=foo%20bar` - ); - - // Case 3: selection overlaps highlight, selection takes precedence - await SpecialPowers.spawn(browser, [], () => { - const sel = content.getSelection(); - sel.removeAllRanges(); - const startNode = content.document.querySelector("#hello").firstChild; - const endNode = content.document.querySelector("#foo").firstChild; - const range = content.document.createRange(); - range.setStart(startNode, 0); - range.setEnd(endNode, 3); - sel.addRange(range); - }); - await assertHighlightMenu( - browser, - "#foo", // click on a section where there are both selection and highlight - "selection overlaps highlight", - `${TEST_URI}#:~:text=Hello%20foo` - ); - } finally { - BrowserTestUtils.removeTab(tab); - } -}); diff --git a/browser/base/content/test/contextMenu/browser_copy_link_to_highlight.js b/browser/base/content/test/contextMenu/browser_copy_link_to_highlight.js @@ -132,6 +132,52 @@ add_task(async function copiesToClipboard() { }); }); +// If there is already a highlight on the page, "Copy Link to Highlight" should work even if no text is selected. +add_task(async function copiesToClipWithExistingHighlightAndNoSelection() { + await testCopyLinkToHighlight({ + testPage: loremIpsumTestPage(false, true), + runTests: async ({ copyLinkToHighlight }) => { + await SimpleTest.promiseClipboardChange( + "https://www.example.com/?stripParam=1234#:~:text=Lorem", + async () => { + await BrowserTestUtils.waitForCondition( + () => + !copyLinkToHighlight.hasAttribute("disabled") || + copyLinkToHighlight.getAttribute("disabled") === "false", + "Waiting for copyLinkToHighlight to become enabled" + ); + copyLinkToHighlight + .closest("menupopup") + .activateItem(copyLinkToHighlight); + } + ); + }, + }); +}); + +// If there is already a highlight on the page and text is selected, +// "Copy Link to Highlight" should use the selection over the existing highlight. +add_task(async function copiesToClipWithExistingHighlightAndSelection() { + await testCopyLinkToHighlight({ + testPage: loremIpsumTestPage(true, true), + runTests: async ({ copyLinkToHighlight }) => { + await SimpleTest.promiseClipboardChange( + "https://www.example.com/?stripParam=1234#:~:text=eiusmod%20tempor%20incididunt&text=labore", + async () => { + await BrowserTestUtils.waitForCondition( + () => + !copyLinkToHighlight.hasAttribute("disabled") || + copyLinkToHighlight.getAttribute("disabled") === "false", + "Waiting for copyLinkToHighlight to become enabled" + ); + copyLinkToHighlight + .closest("menupopup") + .activateItem(copyLinkToHighlight); + } + ); + }, + }); +}); /** * Tests the "Remove Highlight" context menu item. * @@ -290,14 +336,16 @@ function editableTestPage() { * * @param {boolean} isTextSelected If true, two ranges are created in the * document and added to the selection. + * @param {boolean} loadWithExistingHighlight If true, the page is loaded + * with a text fragment in the URL. * @returns Async function which creates the content. */ -function loremIpsumTestPage(isTextSelected) { +function loremIpsumTestPage(isTextSelected, loadWithExistingHighlight = false) { return async function (browser) { await SpecialPowers.spawn( browser, - [isTextSelected], - async function (selectText) { + [isTextSelected, loadWithExistingHighlight], + async function (selectText, existingHighlight) { const textBegin = content.document.createTextNode( "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " ); @@ -320,6 +368,10 @@ function loremIpsumTestPage(isTextSelected) { paragraph.id = "text"; content.document.body.prepend(paragraph); + if (existingHighlight) { + content.location.hash = ":~:text=Lorem"; + } + if (selectText) { const selection = content.getSelection(); const range = content.document.createRange();