commit 82a139a8f0edddec8ef2e8456c12c7270ca671ab parent 08081920e2cc174658807ed95c7edc2d42bc6e9e Author: Masayuki Nakano <masayuki@d-toybox.com> Date: Fri, 21 Nov 2025 00:14:38 +0000 Bug 1998858 - part 2: Make `Selection` stop splitting user selected range to exclude non-selectable nodes r=smaug,emilio,edgar,TYLin,layout-reviewers The other browsers do not support multiple selection ranges. Therefore, they never split selection ranges to exclude non-selectable nodes. To align the behavior, we should do so. However, to keep current look of `Selection` which is similar to Chrome's behavior, we should make the selection painters ignore normal selections if their content is not selectable. Note that this patch allows painting selected text when there are some IME selections even if `user-select` is `none` because the normal selection is also a part of IME selections. Although our widget currently do not use non-collapsed normal selection during a composition, it may be changed to support new IME in the future. Differential Revision: https://phabricator.services.mozilla.com/D272187 Diffstat:
23 files changed, 715 insertions(+), 247 deletions(-)
diff --git a/browser/base/content/test/forms/browser_selectpopup.js b/browser/base/content/test/forms/browser_selectpopup.js @@ -89,7 +89,7 @@ const PAGECONTENT_TRANSLATED = "<html><body>" + "<div id='div'>" + "<iframe id='frame' width='320' height='295' style='border: none;'" + - " src='data:text/html,<select id=select><option>he he he</option><option>boo boo</option><option>baz baz</option></select>'" + + " src='data:text/html,<select id=select><option>he he he</option><option>boo boo</option><option>baz baz</option></select>'>" + "</iframe>" + "</div></body></html>"; @@ -178,7 +178,7 @@ async function doSelectTests(contentType, content) { [{ isWindows }], function (args) { Assert.equal( - String(content.getSelection()), + String(content.getSelection()).trim(), args.isWindows ? "Text" : "", "Select all while popup is open" ); @@ -357,6 +357,7 @@ add_task(async function () { // This test opens a select popup that is isn't a frame and has some translations applied. add_task(async function () { const pageUrl = "data:text/html," + escape(PAGECONTENT_TRANSLATED); + info(`pageUrl: data:text/html,${PAGECONTENT_TRANSLATED}`); let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl); // We need to explicitly call Element.focus() since dataURL is treated as @@ -437,6 +438,7 @@ add_task(async function () { expectedX += step[2]; expectedY += step[3]; + // FIXME: These expectations are not aware of HiDPI environment. let popupRect = selectPopup.getBoundingClientRect(); is(popupRect.left, expectedX, "step " + (stepIndex + 1) + " x"); is(popupRect.top, expectedY, "step " + (stepIndex + 1) + " y"); diff --git a/dom/base/Element.cpp b/dom/base/Element.cpp @@ -135,6 +135,7 @@ #include "nsCOMPtr.h" #include "nsCSSPseudoElements.h" #include "nsCompatibility.h" +#include "nsComputedDOMStyle.h" #include "nsContainerFrame.h" #include "nsContentList.h" #include "nsContentListDeclarations.h" @@ -288,6 +289,56 @@ nsIFrame* nsIContent::GetPrimaryFrame(mozilla::FlushType aType) { return frame; } +bool nsIContent::IsSelectable() const { + if (!IsInComposedDoc() || + // Generated content is not selectable. + IsGeneratedContentContainerForBefore() || + IsGeneratedContentContainerForAfter() || + // Fully invisible nodes like `Comment` should not be selectable. + (!IsElement() && !IsText() && !IsShadowRoot())) { + return false; + } + // If this is editable, this should be selectable even if `user-select` is set + // to `none`. + if (IsEditable()) { + return true; + } + // ...and same if this is a text control. + if (const auto* const textControlElement = + mozilla::TextControlElement::FromNode(this)) { + if (textControlElement->IsSingleLineTextControlOrTextArea()) { + return true; + } + } + // Otherwise, check `user-select` style with the layout if there is. + for (const nsIContent* content = this; content; + content = content->GetFlattenedTreeParent()) { + // First, ask the primary frame. + if (nsIFrame* const frame = content->GetPrimaryFrame()) { + // FYI: This does the same checks which were done before this loop so that + // return true for editable content or text control. + return frame->IsSelectable(); + } + if (!content->IsElement()) { + // Okay, we're a `Text` or `ShadowRoot` in a `display:none` element. Let's + // check the frame or style of the ancestors in the flattened tree. + continue; + } + // Okay, we're an element whose `display` is `contents` or `none` or which + // is in a `display:none` ancestors, we should check whether this element is + // directly specified the `user-select` style and if it's not `auto`, + // consider whether this is selectable not unselectable. + const RefPtr<const mozilla::ComputedStyle> elementStyle = + nsComputedDOMStyle::GetComputedStyleNoFlush(content->AsElement()); + if (elementStyle && + elementStyle->UserSelect() != mozilla::StyleUserSelect::Auto) { + return elementStyle->UserSelect() != mozilla::StyleUserSelect::None; + } + // Finally, if `user-select:auto`, let's check the parent. + } + return false; +} + namespace mozilla::dom { const DOMTokenListSupportedToken Element::sSupportedBlockingValues[] = { diff --git a/dom/base/Selection.cpp b/dom/base/Selection.cpp @@ -1113,8 +1113,9 @@ static void UserSelectRangesToAdd(nsRange* aItem, // We cannot directly call IsEditorSelection() because we may be in an // inconsistent state during Collapse() (we're cleared already but we haven't // got a new focus node yet). - if (IsEditorNode(aItem->GetStartContainer()) && - IsEditorNode(aItem->GetEndContainer())) { + if (!StaticPrefs::dom_selection_exclude_non_selectable_nodes() || + (IsEditorNode(aItem->GetStartContainer()) && + IsEditorNode(aItem->GetEndContainer()))) { // Don't mess with the selection ranges for editing, editor doesn't really // deal well with multi-range selections. aRangesToAdd.AppendElement(aItem); @@ -2114,8 +2115,7 @@ void Selection::SelectFramesOfFlattenedTreeOfContent(nsIContent* aContent, UniquePtr<SelectionDetails> Selection::LookUpSelection( nsIContent* aContent, uint32_t aContentOffset, uint32_t aContentLength, - UniquePtr<SelectionDetails> aDetailsHead, SelectionType aSelectionType, - bool aSlowCheck) { + UniquePtr<SelectionDetails> aDetailsHead, SelectionType aSelectionType) { if (!aContent) { return aDetailsHead; } diff --git a/dom/base/Selection.h b/dom/base/Selection.h @@ -299,6 +299,10 @@ class Selection final : public nsSupportsWeakReference, * See mStyledRanges.mRanges. */ nsRange* GetRangeAt(uint32_t aIndex) const; + nsRange* GetFirstRange() const { return GetRangeAt(0); } + nsRange* GetLastRange() const { + return RangeCount() ? GetRangeAt(RangeCount() - 1u) : nullptr; + } /** * @brief Get the |AbstractRange| at |aIndex|. @@ -336,8 +340,7 @@ class Selection final : public nsSupportsWeakReference, UniquePtr<SelectionDetails> LookUpSelection( nsIContent* aContent, uint32_t aContentOffset, uint32_t aContentLength, - UniquePtr<SelectionDetails> aDetailsHead, SelectionType aSelectionType, - bool aSlowCheck); + UniquePtr<SelectionDetails> aDetailsHead, SelectionType aSelectionType); NS_IMETHOD Repaint(nsPresContext* aPresContext); diff --git a/dom/base/crashtests/crashtests.list b/dom/base/crashtests/crashtests.list @@ -210,7 +210,7 @@ load 1396466.html load 1397795.html load 1400701.html load 1403377.html -load 1405771.html +asserts(1) load 1405771.html load 1406109-1.html load 1411473.html load 1413815.html diff --git a/dom/base/nsIContent.h b/dom/base/nsIContent.h @@ -537,6 +537,21 @@ class nsIContent : public nsINode { */ nsIFrame* GetPrimaryFrame(mozilla::FlushType aType); + /** + * Return true if the related frame is selectable or we need to treat the + * content as selectable (e.g., an editable node, a text control). If the + * content does not have primary frame due to e.g., `display:contents`, + * `display:none`, `ShadowRoot`, etc, this refers the computed `user-select` + * style of this node. If the `user-select` is `auto`, referring the same + * things of closest ancestor elements or shadow DOM host. + * NOTE: If this is a generated content like ::before or ::after or not + * connected to a Document, this returns false. I.e., this returns false for + * DocumentFragment. + * NOTE: Returning true does NOT mean that the content is selectable with a + * user's operation. E.g., can be selectable but invisible. + */ + [[nodiscard]] bool IsSelectable() const; + // Defined in nsIContentInlines.h because it needs nsIFrame. inline void SetPrimaryFrame(nsIFrame* aFrame); diff --git a/dom/base/nsRange.cpp b/dom/base/nsRange.cpp @@ -3358,6 +3358,7 @@ void nsRange::ExcludeNonSelectableNodes(nsTArray<RefPtr<nsRange>>* aOutRanges) { selectable = false; } if (selectable) { + // FIXME: Use content->IsSelectable() nsIFrame* frame = content->GetPrimaryFrame(); for (nsIContent* p = content; !frame && (p = p->GetParent());) { frame = p->GetPrimaryFrame(); diff --git a/dom/base/test/test_user_select.html b/dom/base/test/test_user_select.html @@ -56,6 +56,8 @@ bbbbbbb</div> function test() { + const excludeNonSelectableNodes = SpecialPowers.getBoolPref("dom.selection.exclude_non_selectable_nodes"); + function clear(w) { var sel = (w ? w : window).getSelection(); @@ -152,7 +154,10 @@ function test() var e = document.getElementById('test1'); dragSelect(e, 20, 340); checkText('aaaaaacc', e); - checkRanges([[0,1,-1,1], [2,0,2,2]], e); + checkRanges( + excludeNonSelectableNodes ? [[0,1,-1,1], [2,0,2,2]] : [[0,1,2,2]], + e + ); clear(); dragSelect(e, 20, 260, 120); @@ -183,7 +188,10 @@ function test() e = document.getElementById('test4'); dragSelect(e, 20, 340); checkText('aaaaaacc', e); - checkRanges([[0,1,1,0], [2,0,2,2]], e); + checkRanges( + excludeNonSelectableNodes ? [[0,1,1,0], [2,0,2,2]] : [[0,1,2,2]], + e + ); doneTest(e); clear(); @@ -204,7 +212,10 @@ function test() e = document.getElementById('test7'); dragSelect(e, 20, 340); checkText('aaaaaacccccc', e); - checkRanges([[0,1,1,0], [2,0,2,6]], e); + checkRanges( + excludeNonSelectableNodes ? [[0,1,1,0], [2,0,2,6]] : [[0,1,2,6]], + e + ); doneTest(e); clear(); @@ -240,17 +251,22 @@ function test() e = document.getElementById('testE'); dragSelect(e, 20, 360, 295); checkText('aa\nbbbb\nee', e); - checkRangeCount(3, e); - checkRange(0, [0,1,-1,1], e); - checkRange(1, [1,0,-1,2], e.children[0]); - checkRange(2, [2,0,2,2], e); + if (excludeNonSelectableNodes) { + checkRangeCount(3, e); + checkRange(0, [0,1,-1,1], e); + checkRange(1, [1,0,-1,2], e.children[0]); + checkRange(2, [2,0,2,2], e); + } else { + checkRangeCount(1, e); + checkRanges([[0,1,2,2]], e); + } doneTest(e); clear(); e = document.getElementById('testI'); dragSelect(e, 200, 80); checkText('bbd', e); - checkRangeCount(2, e); + checkRangeCount(excludeNonSelectableNodes ? 2 : 1, e); doneTest(e); // ====================================================== @@ -264,9 +280,14 @@ function test() checkRangeText('aaaaaaa', 0); checkText('', e); shiftClick(e, 340); - checkRangeText('bbbbbbbbcc', 0); checkText('bbbbbbbbcc', e); - checkRanges([[-1,1,1,10]], e); + if (excludeNonSelectableNodes) { + checkRangeText('bbbbbbbbcc', 0); + checkRanges([[-1,1,1,10]], e); + } else { + checkRangeText('aaaaaaabbbbbbbbcc', 0); + checkRanges([[0,0,1,10]], e); + } doneTest(e); // test extending a selection that end in a -moz-user-select:none node @@ -278,7 +299,10 @@ function test() shiftClick(e, 20); checkRangeText('aaaaaabbbbbbbb', 0); checkText('aaaaaabbbbbbbb', e); - checkRanges([[0,1,-1,1]], e); + checkRanges( + excludeNonSelectableNodes ? [[0,1,-1,1]] : [[0,1,1,0]], + e + ); doneTest(e); clear(); @@ -286,7 +310,10 @@ function test() synthesizeMouse(e, 1, 1, {}); synthesizeMouse(e, 400, 100, { shiftKey: true }); checkText("aaaa bbbb", e); - checkRanges([[0,0,-1,1],[6,0,6,5]], e); + checkRanges( + excludeNonSelectableNodes ? [[0,0,-1,1],[6,0,6,5]] : [[0,0,6,5]], + e + ); doneTest(e); clear(); @@ -294,7 +321,10 @@ function test() synthesizeMouse(e, 1, 1, {}); synthesizeMouse(e, 400, 180, { shiftKey: true }); checkText("aaaa\n\n\n\nbbbb", e); - checkRanges([[0,0,-1,1],[2,0,-1,3],[4,0,-1,5],[6,0,6,5]], e); + checkRanges( + excludeNonSelectableNodes ? [[0,0,-1,1],[2,0,-1,3],[4,0,-1,5],[6,0,6,5]] : [[0,0,6,5]], + e + ); doneTest(e); clear(); @@ -304,7 +334,10 @@ function test() synthesizeMouse(e, 50, 90, { shiftKey: true }); synthesizeMouse(e, 70, 90, { shiftKey: true }); checkText("aaaa\n\nbbb", e); - checkRanges([[0,0,-1,1],[-1,2,3,4]], e); + checkRanges( + excludeNonSelectableNodes ? [[0,0,-1,1],[-1,2,3,4]] : [[0,0,3,4]], + e + ); doneTest(e); // ====================================================== @@ -333,9 +366,14 @@ function test() sel = e.contentWindow.getSelection(); is(window.getSelection().rangeCount, 0, "testD: no selection in outer window"); is(sel.toString(), 'aaaacccc', "testD: kbd selection"); - is(sel.rangeCount, 2, "testD: kbd selection is filtered"); - is(sel.getRangeAt(0).toString(), 'aaaa', "testD: kbd selection is filtered"); - is(sel.getRangeAt(1).toString(), 'cccc', "testD: kbd selection is filtered"); + if (excludeNonSelectableNodes) { + is(sel.rangeCount, 2, "testD: kbd selection is filtered"); + is(sel.getRangeAt(0).toString(), 'aaaa', "testD: kbd selection is filtered"); + is(sel.getRangeAt(1).toString(), 'cccc', "testD: kbd selection is filtered"); + } else { + is(sel.rangeCount, 1, "testD: kbd selection is filtered"); + is(sel.getRangeAt(0).toString(), 'aaaabbbbcccc', "testD: kbd selection is filtered"); + } doneTest(e); clear(); diff --git a/layout/base/AccessibleCaretManager.cpp b/layout/base/AccessibleCaretManager.cpp @@ -14,6 +14,7 @@ #include "mozilla/AsyncEventDispatcher.h" #include "mozilla/AutoRestore.h" #include "mozilla/CaretAssociationHint.h" +#include "mozilla/ContentIterator.h" #include "mozilla/FocusModel.h" #include "mozilla/IMEStateManager.h" #include "mozilla/IntegerPrintfMacros.h" @@ -305,17 +306,22 @@ void AccessibleCaretManager::UpdateCaretsForSelectionMode( const UpdateCaretsHintSet& aHints) { AC_LOG("%s: selection: %p", __FUNCTION__, GetSelection()); + // NOTE: Here needs to call CompareTreePosition() which is overridden by + // MockAccessibleCaretManager() to make it always return true. Therefore, + // we cannot return earlier when mPresShell is nullptr or the result of + // GetFrameForRangeStart() is nullptr. int32_t startOffset = 0; - nsCOMPtr<nsIContent> startContent; - nsIFrame* startFrame = GetFrameForFirstRangeStartOrLastRangeEnd( - eDirNext, &startOffset, getter_AddRefs(startContent)); - + nsIFrame* startFrame = + mPresShell ? GetFrameForRangeStart(*GetSelection()->GetFirstRange(), + &startOffset) + : nullptr; int32_t endOffset = 0; - nsCOMPtr<nsIContent> endContent; - nsIFrame* endFrame = GetFrameForFirstRangeStartOrLastRangeEnd( - eDirPrevious, &endOffset, getter_AddRefs(endContent)); + nsIFrame* endFrame = + mPresShell + ? GetFrameForRangeEnd(*GetSelection()->GetLastRange(), &endOffset) + : nullptr; - if (!CompareTreePosition(startFrame, endFrame, startContent, endContent)) { + if (!CompareTreePosition(startFrame, startOffset, endFrame, endOffset)) { // XXX: Do we really have to hide carets if this condition isn't satisfied? HideCaretsAndDispatchCaretStateChangedEvent(); return; @@ -1036,84 +1042,133 @@ void AccessibleCaretManager::LayoutFlusher::MaybeFlush( } } -nsIFrame* AccessibleCaretManager::GetFrameForFirstRangeStartOrLastRangeEnd( - nsDirection aDirection, int32_t* aOutOffset, nsIContent** aOutContent, - int32_t* aOutContentOffset) const { +nsIFrame* AccessibleCaretManager::GetFrameForRangeStart( + nsRange& aRange, int32_t* aOutOffsetInFrameContent, + nsIContent** aOutContent /* = nullptr */, + int32_t* aOutOffsetInContent /* = nullptr */) const { if (!mPresShell) { return nullptr; } MOZ_ASSERT(GetCaretMode() == CaretMode::Selection); - MOZ_ASSERT(aOutOffset, "aOutOffset shouldn't be nullptr!"); - - const nsRange* range = nullptr; - RefPtr<nsINode> startNode; - RefPtr<nsINode> endNode; - int32_t nodeOffset = 0; - CaretAssociationHint hint; + MOZ_ASSERT(aOutOffsetInFrameContent, + "aOutOffsetInFrameContent shouldn't be nullptr!"); + + nsIContent* const startContent = + nsIContent::FromNodeOrNull(aRange.GetStartContainer()); + if (startContent && startContent->IsSelectable()) { + // FIXME: GetFrameForNodeOffset() may return unselectable frame. It should + // have an option to stop digging it if it reaches unselectable child. + uint32_t outOffset = 0; + nsIFrame* const startFrame = SelectionMovementUtils::GetFrameForNodeOffset( + startContent, aRange.StartOffset(), CaretAssociationHint::After, + &outOffset); + if (startFrame) { + MOZ_DIAGNOSTIC_ASSERT(startFrame->GetContent()); + *aOutOffsetInFrameContent = static_cast<int32_t>(outOffset); + if (aOutContent) { + *aOutContent = do_AddRef(startContent).take(); + } + if (aOutOffsetInContent) { + *aOutOffsetInContent = static_cast<int32_t>(aRange.StartOffset()); + } + return startFrame; + } + } - RefPtr<Selection> selection = GetSelection(); - bool findInFirstRangeStart = aDirection == eDirNext; - - if (findInFirstRangeStart) { - range = selection->GetRangeAt(0); - startNode = range->GetStartContainer(); - endNode = range->GetEndContainer(); - nodeOffset = range->StartOffset(); - hint = CaretAssociationHint::After; - } else { - MOZ_ASSERT(selection->RangeCount() > 0); - range = selection->GetRangeAt(selection->RangeCount() - 1); - startNode = range->GetEndContainer(); - endNode = range->GetStartContainer(); - nodeOffset = range->EndOffset(); - hint = CaretAssociationHint::Before; - } - - nsCOMPtr<nsIContent> startContent = nsIContent::FromNodeOrNull(startNode); - uint32_t outOffset = 0; - nsIFrame* startFrame = SelectionMovementUtils::GetFrameForNodeOffset( - startContent, nodeOffset, hint, &outOffset); - *aOutOffset = static_cast<int32_t>(outOffset); - - if (!startFrame) { - ErrorResult err; - RefPtr<TreeWalker> walker = mPresShell->GetDocument()->CreateTreeWalker( - *startNode, dom::NodeFilter_Binding::SHOW_ALL, nullptr, err); - - if (!walker) { - return nullptr; + UnsafePreContentIterator iter; + if (NS_WARN_IF(NS_FAILED(iter.Init(&aRange)))) { + return nullptr; + } + for (; !iter.IsDone(); iter.Next()) { + nsIContent* const content = nsIContent::FromNode(iter.GetCurrentNode()); + if (!content || !content->IsSelectable()) { + continue; + } + // FIXME: GetFrameForNodeOffset() may return unselectable frame. It should + // have an option to stop digging it if it reaches unselectable child. + uint32_t outOffset = 0; + nsIFrame* const firstFrame = SelectionMovementUtils::GetFrameForNodeOffset( + content, 0, CaretAssociationHint::After, &outOffset); + if (NS_WARN_IF(!firstFrame)) { + continue; + } + MOZ_DIAGNOSTIC_ASSERT(firstFrame->GetContent()); + *aOutOffsetInFrameContent = static_cast<int32_t>(outOffset); + if (aOutContent) { + *aOutContent = do_AddRef(content).take(); + } + if (aOutOffsetInContent) { + *aOutOffsetInContent = 0; } + return firstFrame; + } + return nullptr; +} - startFrame = startContent ? startContent->GetPrimaryFrame() : nullptr; - while (!startFrame && startNode != endNode) { - startNode = findInFirstRangeStart ? walker->NextNode(err) - : walker->PreviousNode(err); +nsIFrame* AccessibleCaretManager::GetFrameForRangeEnd( + nsRange& aRange, int32_t* aOutOffsetInFrameContent, + nsIContent** aOutContent /* = nullptr */, + int32_t* aOutOffsetInContent /* = nullptr */) const { + if (!mPresShell) { + return nullptr; + } - if (!startNode) { - break; + MOZ_ASSERT(GetCaretMode() == CaretMode::Selection); + MOZ_ASSERT(aOutOffsetInFrameContent, + "aOutOffsetInFrameContent shouldn't be nullptr!"); + + nsIContent* const endContent = + nsIContent::FromNodeOrNull(aRange.GetEndContainer()); + if (endContent && endContent->IsSelectable()) { + // FIXME: GetFrameForNodeOffset() may return unselectable frame. It should + // have an option to stop digging it if it reaches unselectable child. + uint32_t outOffset = 0; + nsIFrame* const endFrame = SelectionMovementUtils::GetFrameForNodeOffset( + endContent, aRange.EndOffset(), CaretAssociationHint::Before, + &outOffset); + if (endFrame) { + MOZ_DIAGNOSTIC_ASSERT(endFrame->GetContent()); + *aOutOffsetInFrameContent = static_cast<int32_t>(outOffset); + if (aOutContent) { + *aOutContent = do_AddRef(endContent).take(); } - - startContent = startNode->AsContent(); - startFrame = startContent ? startContent->GetPrimaryFrame() : nullptr; + if (aOutOffsetInContent) { + *aOutOffsetInContent = static_cast<int32_t>(aRange.EndOffset()); + } + return endFrame; } - - // We are walking among the nodes in the content tree, so the node offset - // relative to startNode should be set to 0. - nodeOffset = 0; - *aOutOffset = 0; } - if (startFrame) { + UnsafePostContentIterator iter; + if (NS_WARN_IF(NS_FAILED(iter.Init(&aRange)))) { + return nullptr; + } + iter.Last(); + for (; !iter.IsDone(); iter.Prev()) { + nsIContent* const content = nsIContent::FromNode(iter.GetCurrentNode()); + if (!content || !content->IsSelectable()) { + continue; + } + // FIXME: GetFrameForNodeOffset() may return unselectable frame. It should + // have an option to stop digging it if it reaches unselectable child. + uint32_t outOffset = 0; + nsIFrame* const lastFrame = SelectionMovementUtils::GetFrameForNodeOffset( + content, content->Length(), CaretAssociationHint::Before, &outOffset); + if (NS_WARN_IF(!lastFrame)) { + continue; + } + MOZ_DIAGNOSTIC_ASSERT(lastFrame->GetContent()); + *aOutOffsetInFrameContent = static_cast<int32_t>(outOffset); if (aOutContent) { - startContent.forget(aOutContent); + *aOutContent = do_AddRef(content).take(); } - if (aOutContentOffset) { - *aOutContentOffset = nodeOffset; + if (aOutOffsetInContent) { + *aOutOffsetInContent = static_cast<int32_t>(content->Length()); } + return lastFrame; } - - return startFrame; + return nullptr; } bool AccessibleCaretManager::RestrictCaretDraggingOffsets( @@ -1126,22 +1181,29 @@ bool AccessibleCaretManager::RestrictCaretDraggingOffsets( nsDirection dir = mActiveCaret == mCarets.GetFirst() ? eDirPrevious : eDirNext; - int32_t offset = 0; + int32_t offsetInFrameContent = 0; nsCOMPtr<nsIContent> content; - int32_t contentOffset = 0; - nsIFrame* frame = GetFrameForFirstRangeStartOrLastRangeEnd( - dir, &offset, getter_AddRefs(content), &contentOffset); - + int32_t offsetInContent = 0; + nsIFrame* frame = + dir == eDirNext + ? GetFrameForRangeStart(*GetSelection()->GetFirstRange(), + &offsetInFrameContent, + getter_AddRefs(content), &offsetInContent) + : GetFrameForRangeEnd(*GetSelection()->GetLastRange(), + &offsetInFrameContent, getter_AddRefs(content), + &offsetInContent); if (!frame) { return false; } // Compare the active caret's new position (aOffsets) to the inactive caret's // position. - NS_ASSERTION(contentOffset >= 0, "contentOffset should not be negative"); + NS_ASSERTION(offsetInFrameContent >= 0, + "offsetInFrameContent should not be negative"); const Maybe<int32_t> cmpToInactiveCaretPos = nsContentUtils::ComparePoints_AllowNegativeOffsets( - aOffsets.content, aOffsets.StartOffset(), content, contentOffset); + aOffsets.content, aOffsets.StartOffset(), frame->GetContent(), + offsetInFrameContent); if (NS_WARN_IF(!cmpToInactiveCaretPos)) { // Potentially handle this properly when Selection across Shadow DOM // boundary is implemented @@ -1152,12 +1214,12 @@ bool AccessibleCaretManager::RestrictCaretDraggingOffsets( // Move one character (in the direction of dir) from the inactive caret's // position. This is the limit for the active caret's new position. PeekOffsetStruct limit( - eSelectCluster, dir, offset, nsPoint(0, 0), + eSelectCluster, dir, offsetInFrameContent, nsPoint(0, 0), {PeekOffsetOption::JumpLines, PeekOffsetOption::StopAtScroller}); nsresult rv = frame->PeekOffset(&limit); if (NS_FAILED(rv)) { limit.mResultContent = content; - limit.mContentOffset = contentOffset; + limit.mContentOffset = offsetInContent; } // Compare the active caret's new position (aOffsets) to the limit. @@ -1217,13 +1279,23 @@ bool AccessibleCaretManager::RestrictCaretDraggingOffsets( return true; } -bool AccessibleCaretManager::CompareTreePosition( - const nsIFrame* aStartFrame, const nsIFrame* aEndFrame, - const nsIContent* aStartContent, const nsIContent* aEndContent) const { - // nsContentUtils::CompareTreePosition expects non-null content pointers. - return aStartFrame && aEndFrame && aStartContent && aEndContent && - nsContentUtils::CompareTreePosition<TreeKind::DOM>( - aStartContent, aEndContent, nullptr) <= 0; +bool AccessibleCaretManager::CompareTreePosition(const nsIFrame* aStartFrame, + int32_t aStartOffset, + const nsIFrame* aEndFrame, + int32_t aEndOffset) const { + if (MOZ_UNLIKELY(!aStartFrame || !aStartFrame->GetContent() || !aEndFrame || + !aEndFrame->GetContent())) { + return false; + } + if (aStartFrame->GetContent() == aEndFrame->GetContent()) { + return aStartOffset <= aEndOffset; + } + return nsContentUtils::ComparePoints( + ConstRawRangeBoundary(aStartFrame->GetContent(), + static_cast<uint32_t>(aStartOffset)), + ConstRawRangeBoundary(aEndFrame->GetContent(), + static_cast<uint32_t>(aEndOffset))) + .valueOr(1) <= 0; } nsresult AccessibleCaretManager::DragCaretInternal(const nsPoint& aPoint) { @@ -1279,9 +1351,19 @@ nsresult AccessibleCaretManager::DragCaretInternal(const nsPoint& aPoint) { (GetCaretMode() == CaretMode::Selection) ? nsFrameSelection::FocusMode::kExtendSelection : nsFrameSelection::FocusMode::kCollapseToNewPoint; - fs->HandleClick(MOZ_KnownLive(offsets.content) /* bug 1636889 */, - offsets.StartOffset(), offsets.EndOffset(), focusMode, - offsets.associate); + // While dragging the active caret for collapsed selection, we should not + // extend it. However, when crossing an unselectable node, + // GetContentOffsetsFromPoint() above may return the secondary offset. + // Therefore we need to ignore the secondary offset in that case. + int32_t startOffset, endOffset; + if (focusMode == nsFrameSelection::FocusMode::kCollapseToNewPoint) { + startOffset = endOffset = offsets.offset; + } else { + startOffset = offsets.StartOffset(); + endOffset = offsets.EndOffset(); + } + fs->HandleClick(MOZ_KnownLive(offsets.content) /* bug 1636889 */, startOffset, + endOffset, focusMode, offsets.associate); return NS_OK; } diff --git a/layout/base/AccessibleCaretManager.h b/layout/base/AccessibleCaretManager.h @@ -221,14 +221,63 @@ class AccessibleCaretManager { void SetSelectionDirection(nsDirection aDir) const; - // If aDirection is eDirNext, get the frame for the range start in the first - // range from the current selection, and return the offset into that frame as - // well as the range start content and the content offset. Otherwise, get the - // frame and the offset for the range end in the last range instead. - nsIFrame* GetFrameForFirstRangeStartOrLastRangeEnd( - nsDirection aDirection, int32_t* aOutOffset, - nsIContent** aOutContent = nullptr, - int32_t* aOutContentOffset = nullptr) const; + /** + * Return a frame and offset in aOutContent where the meaningful start of + * aRange. E.g., if aRange starts with non-selectable elements, this returns + * the first selectable content's frame and its start offset. + * + * @param aRange The range, typically the first range of `Selection`. + * @param aOutOffsetInFrameContent + * Must not be nullptr. If the result is not nullptr, this + * will be set to the offset in result->GetContent(). + * NOTE: {result->GetContent(), *aOutOffsetInFrameContent} + * means that it's the start boundary of visible/meaningful + * selection start boundary at a leaf node like a `Text`. + * @param aOutContent [optional] If set, this will be set to the first + * selectable container in aRange. It's typically a + * container element. + * @param aOutOffsetInContent + * [optional] If set, this will be set to the offset in + * the first selectable content (i.e., aOutContent). + * NOTE: {*aOutContent, *aOutOffsetInContent} means that + * it's the start boundary of actual selectable range at a + * container element. + * @return The first meaningful frame whose content is selected by + * aRange. Typically, a text frame or a image frame. + */ + nsIFrame* GetFrameForRangeStart(nsRange& aRange, + int32_t* aOutOffsetInFrameContent, + nsIContent** aOutContent = nullptr, + int32_t* aOutOffsetInContent = nullptr) const; + + /** + * Return a frame and offset in aOutContent where the meaningful end of + * aRange. E.g., if aRange ends with non-selectable elements, this returns + * the last selectable content's frame and its end offset. + * + * @param aRange The range, typically the last range of `Selection`. + * @param aOutOffsetInFrameContent + * Must not be nullptr. If the result is not nullptr, this + * will be set to the offset in result->GetContent(). + * NOTE: {result->GetContent(), *aOutOffsetInFrameContent} + * means that it's the end boundary of visible/meaningful + * selection end boundary at a leaf node like a `Text`. + * @param aOutContent [optional] If set, this will be set to the last + * selectable container in aRange. It's typically a + * container element. + * @param aOutOffsetInContent + * [optional] If set, this will be set to the offset in + * the last selectable container (i.e., aOutContent). + * NOTE: {*aOutContent, *aOutOffsetInContent} means that + * it's the end boundary of actual selectable range at a + * container element. + * @return The last meaningful frame whose content is selected by + * aRange. Typically, a text frame or a image frame. + */ + nsIFrame* GetFrameForRangeEnd(nsRange& aRange, + int32_t* aOutOffsetInFrameContent, + nsIContent** aOutContent = nullptr, + int32_t* aOutOffsetInContent = nullptr) const; MOZ_CAN_RUN_SCRIPT nsresult DragCaretInternal(const nsPoint& aPoint); nsPoint AdjustDragBoundary(const nsPoint& aPoint) const; @@ -277,11 +326,11 @@ class AccessibleCaretManager { // Get caret mode based on current selection. virtual CaretMode GetCaretMode() const; - // @return true if aStartContent comes before aEndContent. + // @return true if aStartFrame comes before aEndFrame. virtual bool CompareTreePosition(const nsIFrame* aStartFrame, + int32_t aStartOffset, const nsIFrame* aEndFrame, - const nsIContent* aStartContent, - const nsIContent* aEndContent) const; + int32_t aEndOffset) const; // Check if the two carets is overlapping to become tilt. // @return true if the two carets become tilt; false, otherwise. diff --git a/layout/base/gtest/TestAccessibleCaretManager.cpp b/layout/base/gtest/TestAccessibleCaretManager.cpp @@ -75,10 +75,9 @@ class AccessibleCaretManagerTester : public ::testing::Test { return static_cast<MockAccessibleCaret&>(*mCarets.GetSecond()); } - bool CompareTreePosition(const nsIFrame* aStartFrame, + bool CompareTreePosition(const nsIFrame* aStartFrame, int32_t aStartOffset, const nsIFrame* aEndFrame, - const nsIContent* aStartContent, - const nsIContent* aEndContent) const override { + int32_t aEndOffset) const override { return true; } diff --git a/layout/base/tests/multi-range-user-select.html b/layout/base/tests/multi-range-user-select.html @@ -12,9 +12,16 @@ src: url("Ahem.ttf"); } html,body { margin:0; padding: 0; } -body,pre { font-family: Ahem; font-size: 20px; } -span { -moz-user-select:none; } -x { -moz-user-select:text; } +body,pre { + font-family: Ahem; + font-size: 20px; +} +span { + user-select: none; +} +x { + user-select: text; +} </style> </head> <body> @@ -36,6 +43,8 @@ window.is = parent.is; window.isnot = parent.isnot; window.ok = parent.ok; +const pre = document.getElementById("select"); + var sel = window.getSelection(); function enableSelection(id) { @@ -44,13 +53,11 @@ function enableSelection(id) { } function setupPrevSelection() { - var e = document.querySelector('#select'); - dragSelectPoints(e, 300, 125, 200, 5); + dragSelectPoints(pre, 300, 125, 200, 5); } function setupNextSelection() { - var e = document.querySelector('#select'); - dragSelectPoints(e, 199, 5, 300, 125); + dragSelectPoints(pre, 199, 5, 300, 125); } var ops = { @@ -62,10 +69,24 @@ var ops = { } function runTest() { + const excludeNonSelectableNodes = SpecialPowers.getBoolPref("dom.selection.exclude_non_selectable_nodes"); + + const firstText = pre.childNodes[0]; // [0] 1st line: 0-9 (+ leading linefeed 1) + const xBeforeSpan2 = firstText.nextSibling; // [1] 1st line: 0-4 + const span2 = document.getElementById("span2"); // [2] 1st line: 0-13 + // 2nd line: 14-42 + // 3rd line: 43-49 + const xAfterSpan2 = span2.nextSibling; // [3] 3rd line: 0-5 + const span3 = document.getElementById("span3"); // [4] 3rd line: 0-16 + // 4th line: 17-43 + // 5th line: 44-74 + // 6th line: 75-79 + const xAfterSpan3 = span3.nextSibling; // [5] 6th line: 0-9 + const lastText = xAfterSpan3.nextSibling; // [6] 6th line: 0-12 (+ trailing linefeed 1) + sel = window.getSelection(); sel.removeAllRanges(); document.body.offsetHeight; - var e = document.querySelector('#select'); var hash = window.location.hash if (hash.substring(0,5)=="#prev") setupPrevSelection(); @@ -78,137 +99,309 @@ function runTest() { if (test == "#prev1") { if (action == keyLeft) { keyLeft({shiftKey:true}, 2) - checkRanges([[0,8,-1,2], [3,0,-1,4], [5,0,6,0]], e); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 8, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 0], + ] + : [[firstText, 8, lastText, 0]], + {id: "prev1: after Shift+ArrowLeft twice"} + ); } else if (action == keyRight) { keyRight({shiftKey:true}, 2) - checkRanges([[e.childNodes[1].firstChild,2,-1,2], [3,0,-1,4], [5,0,6,0]], e); + checkRanges( + excludeNonSelectableNodes + ? [ + [xBeforeSpan2.firstChild, 2, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 0], + ] + : [[xBeforeSpan2.firstChild, 2, lastText, 0]], + {id: "prev1: after Shift+ArrowRight twice"} + ); } else if (action == accelDragSelect) { - accelDragSelect(e, 30, 50); - checkRanges([[0,1,0,2], [e.childNodes[1].firstChild,0,-1,2], [3,0,-1,4], [5,0,6,0]], e); + accelDragSelect(pre, 30, 50); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 1, firstText, 2], + [xBeforeSpan2.firstChild, 0, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 0], + ] + : [ + [firstText, 1, firstText, 2], + [xBeforeSpan2.firstChild, 0, lastText, 0], + ], + {id: "prev1: after accelDragSelect"} + ); } else { - action(e, 30); - checkRanges([[0,1,-1,2], [3,0,-1,4], [5,0,6,0]], e); + action(pre, 30); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 1, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 0], + ] + : [[firstText, 1, lastText, 0]], + {id: `prev1: after action(${action.name})`} + ); } } else if (test == "#prev2") { - action(e, 260); - checkRangeCount(3, e); - checkRange(0, [0,3,-2,2], e.childNodes[1]); - checkRange(1, [3,0,-1,4], e); - checkRange(2, [5,0,6,0], e); + action(pre, 260); + checkRanges( + excludeNonSelectableNodes + ? [ + [xBeforeSpan2.firstChild, 3, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 0], + ] + : [[xBeforeSpan2.firstChild, 3, lastText, 0]], + {id: `prev2: after action(${action.name})`} + ); } else if (test == "#prev3") { enableSelection('span2'); - action(e, 400); - checkRangeCount(2, e); - checkRange(0, [0,5,-2,4], e.childNodes[2]); - checkRange(1, [5,0,6,0], e); + action(pre, 400); + checkRanges( + excludeNonSelectableNodes + ? [ + [span2.firstChild, 5, pre, 4], + [xAfterSpan3, 0, lastText, 0], + ] + : [[span2.firstChild, 5, lastText, 0]], + {id: `prev3: after action(${action.name}) with enabling selection of span2`} + ); } else if (test == "#prev4") { - action(e, 180, 65); - checkRangeCount(2, e); - checkRange(0, [0,2,-2,4], e.childNodes[3]); - checkRange(1, [5,0,6,0], e); + action(pre, 180, 65); + checkRanges( + excludeNonSelectableNodes + ? [ + [xAfterSpan2.firstChild, 2, pre, 4], + [xAfterSpan3, 0, lastText, 0], + ] + : [[xAfterSpan2.firstChild, 2, lastText, 0]], + {id: `prev4: after action(${action.name})`} + ); } else if (test == "#prev5") { enableSelection('span3'); - action(e, 440, 65); - checkRangeCount(1, e); - checkRangePoints(0, [e.childNodes[4].firstChild,10,e.childNodes[6],0], e); + action(pre, 440, 65); + checkRanges( + [[span3.firstChild, 10, lastText, 0]], + {id: `prev5: after action(${action.name}) with enabling selection of span3`} + ); } else if (test == "#prev6") { - action(e, 140, 125); - checkRangeCount(1, e); - checkRangePoints(0, [e.childNodes[5].firstChild,2,e.childNodes[6],0], e); + action(pre, 140, 125); + checkRanges( + [[xAfterSpan3.firstChild, 2, lastText, 0]], + {id: `prev6: after action(${action.name})`} + ); } else if (test == "#prev7") { if (action == accelDragSelect) { - accelDragSelect(e, 460, 500, 125); - checkRanges([[e.childNodes[1].firstChild,0,-1,2], [3,0,-1,4], [5,0,6,0], [6,8,6,10]], e); + accelDragSelect(pre, 460, 500, 125); + checkRanges( + excludeNonSelectableNodes + ? [ + [xBeforeSpan2.firstChild, 0, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 0], + [lastText, 8, lastText, 10], + ] + : [ + [xBeforeSpan2.firstChild, 0, lastText, 0], + [lastText, 8, lastText, 10], + ], + {id: "prev7: after accelDragSelect"} + ); } else { - action(e, 500, 125); - checkRanges([[6,0,6,10]], e); + action(pre, 500, 125); + checkRanges( + [[lastText, 0, lastText, 10]], + {id: `prev7: after action(${action.name})`} + ); } } else if (test == "#prev8") { if (action == accelDragSelect) { sel.removeAllRanges(); - var e = document.querySelector('#select'); - synthesizeMouse(e, 200, 125, {type: "mousedown", accelKey: true}); - synthesizeMouse(e, 200, 120, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 100, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 80, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 210, 60, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 60, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 60, {type: "mouseup", accelKey: true}); - var x3t = e.childNodes[3].firstChild; - var x5 = e.childNodes[5]; - checkRanges([[x3t,3,-1,4], [x5,0,x5.firstChild,5]], e); + synthesizeMouse(pre, 200, 125, {type: "mousedown", accelKey: true}); + synthesizeMouse(pre, 200, 120, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 100, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 80, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 210, 60, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 60, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 60, {type: "mouseup", accelKey: true}); + checkRanges( + excludeNonSelectableNodes + ? [ + [xAfterSpan2.firstChild, 3, pre, 4], + [xAfterSpan3, 0, xAfterSpan3.firstChild, 5], + ] + : [[xAfterSpan2.firstChild, 3, xAfterSpan3.firstChild, 5]], + {id: "prev8: after dragging mouse with pressing accel key"} + ); } } } else { if (test == "#next1") { if (action == keyLeft) { keyLeft({shiftKey:true}, 2) - checkRanges([[0,10,-1,2], [3,0,-1,4], [5,0,e.childNodes[5].firstChild,8]], e); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, xAfterSpan3.firstChild, 8], + ] + : [[firstText, 10, xAfterSpan3.firstChild, 8]], + {id: "next1: after Shift+ArrowLeft twice"} + ); } else if (action == keyRight) { keyRight({shiftKey:true}, 2) - checkRanges([[0,10,-1,2], [3,0,-1,4], [5,0,6,2]], e); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 2], + ] + : [[firstText, 10, lastText, 2]], + {id: "next1: after Shift+ArrowRight twice"} + ); } else if (action == accelDragSelect) { - accelDragSelect(e, 30, 50); - checkRanges([[0,1,0,2], [0,10,-1,2], [3,0,-1,4], [5,0,e.childNodes[5].firstChild,10]], e); + accelDragSelect(pre, 30, 50); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 1, firstText, 2], + [firstText, 10, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, xAfterSpan3.firstChild, 10], + ] + : [ + [firstText, 1, firstText, 2], + [firstText, 10, xAfterSpan3.firstChild, 10], + ], + {id: "next1: after accelDragSelect"} + ); } else { - action(e, 30); - checkRanges([[0,1,0,10]], e); + action(pre, 30); + checkRanges( + [[firstText, 1, firstText, 10]], + {id: `next1: after action(${action.name})`} + ); } } else if (test == "#next2") { - action(e, 260); - checkRangeCount(1, e); - checkRangePoints(0, [e.childNodes[0],10,e.childNodes[1].firstChild,3], e); + action(pre, 260); + checkRanges( + [[firstText, 10, xBeforeSpan2.firstChild, 3]], + {id: `next2: after action(${action.name})`} + ); } else if (test == "#next3") { enableSelection('span2'); - action(e, 400); - checkRangeCount(1, e); - checkRangePoints(0, [e.childNodes[0],10,e.childNodes[2].firstChild,5], e); + action(pre, 400); + checkRanges( + [[firstText, 10, span2.firstChild, 5]], + {id: `next3: after action(${action.name}) with enabling selection in span2`} + ); } else if (test == "#next4") { - action(e, 180, 65); - checkRangeCount(2, e); - checkRange(0, [0,10,-1,2], e); - checkRange(1, [-1,0,0,2], e.childNodes[3]); + action(pre, 180, 65); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, xAfterSpan2.firstChild, 2], + ] + : [[firstText, 10, xAfterSpan2.firstChild, 2]], + {id: `next4: after action(${action.name})`} + ); } else if (test == "#next5") { enableSelection('span3'); - action(e, 440, 65); - checkRangeCount(2, e); - checkRange(0, [0,10,-1,2], e); - checkRangePoints(1, [e.childNodes[3],0,e.childNodes[4].firstChild,10], e); + action(pre, 440, 65); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, span3.firstChild, 10], + ] + : [[firstText, 10, span3.firstChild, 10]], + {id: `next5: after action(${action.name}) with enabling selection in span3`} + ); } else if (test == "#next6") { - action(e, 140, 125); - checkRangeCount(3, e); - checkRange(0, [0,10,-1,2], e); - checkRange(1, [3,0,-1,4], e); - checkRange(2, [-1,0,0,2], e.childNodes[5]); + action(pre, 140, 125); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, xAfterSpan3.firstChild, 2], + ] + : [[firstText, 10, xAfterSpan3.firstChild, 2]], + {id: `next6: after action(${action.name})`} + ); } else if (test == "#next7") { if (action == keyRight) { keyRight({shiftKey:true}, 2) - checkRanges([[0,10,-1,2], [3,0,-1,4], [5,0,6,2]], e); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 2], + ] + : [[firstText, 10, lastText, 2]], + {id: "next7: after Shift+ArrowRight twice"} + ); } else if (action == accelDragSelect) { - accelDragSelect(e, 460, 500, 125); - checkRanges([[0,10,-1,2], [3,0,-1,4], [5,0,e.childNodes[5].firstChild,10], [6,8,6,10]], e); + accelDragSelect(pre, 460, 500, 125); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, xAfterSpan3.firstChild, 10], + [lastText, 8, lastText, 10], + ] + : [ + [firstText, 10, xAfterSpan3.firstChild, 10], + [lastText, 8, lastText, 10], + ], + {id: "next7: after accelDragSelect"} + ); } else { - action(e, 500, 125); - checkRangeCount(3, e); - checkRange(0, [0,10,-1,2], e); - checkRange(1, [3,0,-1,4], e); - checkRangePoints(2, [e.childNodes[5],0,e.childNodes[6],10], e); + action(pre, 500, 125); + checkRanges( + excludeNonSelectableNodes + ? [ + [firstText, 10, pre, 2], + [xAfterSpan2, 0, pre, 4], + [xAfterSpan3, 0, lastText, 10], + ] + : [[firstText, 10, lastText, 10]], + {id: `next7: after action(${action.name}`} + ); } } else if (test == "#next8") { if (action == accelDragSelect) { sel.removeAllRanges(); - var e = document.querySelector('#select'); - synthesizeMouse(e, 200, 60, {type: "mousedown", accelKey: true}); - synthesizeMouse(e, 180, 60, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 80, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 100, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 120, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 190, 125, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 125, {type: "mousemove", accelKey: true}); - synthesizeMouse(e, 200, 125, {type: "mouseup", accelKey: true}); - var x3t = e.childNodes[3].firstChild; - var x5 = e.childNodes[5]; - checkRanges([[x3t,3,-1,4], [x5,0,x5.firstChild,5]], e); + synthesizeMouse(pre, 200, 60, {type: "mousedown", accelKey: true}); + synthesizeMouse(pre, 180, 60, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 80, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 100, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 120, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 190, 125, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 125, {type: "mousemove", accelKey: true}); + synthesizeMouse(pre, 200, 125, {type: "mouseup", accelKey: true}); + checkRanges( + excludeNonSelectableNodes + ? [ + [xAfterSpan2.firstChild, 3, pre, 4], + [xAfterSpan3, 0, xAfterSpan3.firstChild, 5], + ] + : [[xAfterSpan2.firstChild, 3,xAfterSpan3.firstChild, 5]], + {id: "next8: after dragging with pressing Accel key"} + ); } } } diff --git a/layout/base/tests/test_reftests_with_caret.html b/layout/base/tests/test_reftests_with_caret.html @@ -314,7 +314,7 @@ if (AppConstants.platform != "android") { is(SpecialPowers.getIntPref("layout.spellcheckDefault"), 0, "Spellcheck should be turned off for this platform or this if..else check removed"); } -if (AppConstants.platform == "linux") { +if (AppConstants.platform == "linux" || AppConstants.platform == "win") { tests = tests.concat([ // Turn off accessiblecaret to prevent it from interfering with the // multi-range selection. diff --git a/layout/generic/SelectionMovementUtils.cpp b/layout/generic/SelectionMovementUtils.cpp @@ -262,7 +262,7 @@ nsIFrame* SelectionMovementUtils::GetFrameForNodeOffset( nsIFrame *returnFrame = nullptr, *lastFrame = aNode->GetPrimaryFrame(); nsCOMPtr<nsIContent> theNode; - uint32_t offsetInFrameContent, offsetInLastFrameContent = 0; + uint32_t offsetInFrameContent, offsetInLastFrameContent = aOffset; while (true) { if (returnFrame) { diff --git a/layout/generic/nsContainerFrame.cpp b/layout/generic/nsContainerFrame.cpp @@ -494,8 +494,10 @@ void nsContainerFrame::DisplaySelectionOverlay(nsDisplayListBuilder* aBuilder, newContent ? newContent->ComputeIndexOf_Deprecated(mContent) : 0; // look up to see what selection(s) are on this frame - UniquePtr<SelectionDetails> details = - frameSelection->LookUpSelection(newContent, offset, 1, false); + UniquePtr<SelectionDetails> details = frameSelection->LookUpSelection( + newContent, offset, 1, + IsSelectable() ? nsFrameSelection::IgnoreNormalSelection::No + : nsFrameSelection::IgnoreNormalSelection::Yes); if (!details) { return; } diff --git a/layout/generic/nsFrameSelection.cpp b/layout/generic/nsFrameSelection.cpp @@ -1536,7 +1536,7 @@ nsresult nsFrameSelection::TakeFocus(nsIContent& aNewFocus, UniquePtr<SelectionDetails> nsFrameSelection::LookUpSelection( nsIContent* aContent, int32_t aContentOffset, int32_t aContentLength, - bool aSlowCheck) const { + IgnoreNormalSelection aIgnoreNormalSelection) const { if (!aContent || !mPresShell) { return nullptr; } @@ -1550,13 +1550,13 @@ UniquePtr<SelectionDetails> nsFrameSelection::LookUpSelection( } UniquePtr<SelectionDetails> details; - - for (size_t j = 0; j < std::size(mDomSelections); j++) { + for (size_t j = aIgnoreNormalSelection == IgnoreNormalSelection::Yes ? 1 : 0; + j < std::size(mDomSelections); j++) { MOZ_ASSERT(mDomSelections[j]); details = mDomSelections[j]->LookUpSelection( aContent, static_cast<uint32_t>(aContentOffset), static_cast<uint32_t>(aContentLength), std::move(details), - kPresentSelectionTypes[j], aSlowCheck); + kPresentSelectionTypes[j]); } // This may seem counter intuitive at first. Highlight selections need to be @@ -1570,7 +1570,7 @@ UniquePtr<SelectionDetails> nsFrameSelection::LookUpSelection( details = iter.second()->LookUpSelection( aContent, static_cast<uint32_t>(aContentOffset), static_cast<uint32_t>(aContentLength), std::move(details), - SelectionType::eHighlight, aSlowCheck); + SelectionType::eHighlight); } return details; diff --git a/layout/generic/nsFrameSelection.h b/layout/generic/nsFrameSelection.h @@ -431,12 +431,13 @@ class nsFrameSelection final { * @param aContent is the content asking * @param aContentOffset is the starting content boundary * @param aContentLength is the length of the content piece asking - * @param aSlowCheck will check using slow method with no shortcuts + * @param aIgnoreSelection is Yes, this won't return selection details about + * the normal selection. */ - mozilla::UniquePtr<SelectionDetails> LookUpSelection(nsIContent* aContent, - int32_t aContentOffset, - int32_t aContentLength, - bool aSlowCheck) const; + enum class IgnoreNormalSelection : bool { No, Yes }; + mozilla::UniquePtr<SelectionDetails> LookUpSelection( + nsIContent* aContent, int32_t aContentOffset, int32_t aContentLength, + IgnoreNormalSelection aIgnoreNormalSelection) const; /** * Sets the drag state to aState for resons of drag state. diff --git a/layout/generic/nsIFrame.cpp b/layout/generic/nsIFrame.cpp @@ -5042,7 +5042,8 @@ nsresult nsIFrame::MoveCaretToEventPoint(nsPresContext* aPresContext, if (GetContent() && GetContent()->IsMaybeSelected()) { bool inSelection = false; UniquePtr<SelectionDetails> details = frameselection->LookUpSelection( - offsets.content, 0, offsets.EndOffset(), false); + offsets.content, 0, offsets.EndOffset(), + nsFrameSelection::IgnoreNormalSelection::No); // // If there are any details, check to see if the user clicked diff --git a/layout/generic/nsTextFrame.cpp b/layout/generic/nsTextFrame.cpp @@ -5231,7 +5231,14 @@ UniquePtr<SelectionDetails> nsTextFrame::GetSelectionDetails() { return nullptr; } UniquePtr<SelectionDetails> details = frameSelection->LookUpSelection( - mContent, GetContentOffset(), GetContentLength(), false); + mContent, GetContentOffset(), GetContentLength(), + // We don't want to paint text as selected if this is not selectable. + // Note if this is editable, this is always treated as selectable, i.e., + // if `user-select` is specified to `none` so that we never stop painting + // selections when there is IME composition which may need normal + // selection as a part of it. + IsSelectable() ? nsFrameSelection::IgnoreNormalSelection::No + : nsFrameSelection::IgnoreNormalSelection::Yes); for (SelectionDetails* sd = details.get(); sd; sd = sd->mNext.get()) { sd->mStart += mContentOffset; sd->mEnd += mContentOffset; diff --git a/layout/mathml/nsMathMLmoFrame.cpp b/layout/mathml/nsMathMLmoFrame.cpp @@ -55,8 +55,10 @@ bool nsMathMLmoFrame::IsFrameInSelection(nsIFrame* aFrame) { } const nsFrameSelection* frameSelection = aFrame->GetConstFrameSelection(); - UniquePtr<SelectionDetails> details = - frameSelection->LookUpSelection(aFrame->GetContent(), 0, 1, true); + UniquePtr<SelectionDetails> details = frameSelection->LookUpSelection( + aFrame->GetContent(), 0, 1, + aFrame->IsSelectable() ? nsFrameSelection::IgnoreNormalSelection::No + : nsFrameSelection::IgnoreNormalSelection::Yes); return details != nullptr; } diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml @@ -5541,6 +5541,13 @@ value: true mirror: always +# If true, when user selects a range, `Selection` will split the range to +# exclude unselectable content from the range. +- name: dom.selection.exclude_non_selectable_nodes + type: bool + value: false + mirror: always + # Mimic Chrome's window.getSelection().toString() behaviour - name: dom.selection.mimic_chrome_tostring.enabled type: bool diff --git a/testing/mochitest/tests/SimpleTest/SimpleTest.js b/testing/mochitest/tests/SimpleTest/SimpleTest.js @@ -1227,7 +1227,14 @@ SimpleTest.promiseClipboardChange = async function ( let errorMsg = `Timed out while polling clipboard for ${ preExpectedVal ? "initialized" : "requested" - } data, got: ${data}`; + } data, got: ${ + flavor == "text/plain" + ? data + .replaceAll("\\", "\\\\") + .replaceAll("\t", "\\t") + .replaceAll("\n", "\\n") + : data + }`; SimpleTest.ok(expectFailure, errorMsg); if (!expectFailure) { throw new Error(errorMsg); diff --git a/toolkit/components/aboutconfig/test/browser/browser_clipboard.js b/toolkit/components/aboutconfig/test/browser/browser_clipboard.js @@ -103,13 +103,21 @@ add_task(async function test_copy() { add_task(async function test_copy_multiple() { await AboutConfigTest.withNewTab(async function () { // Lines are separated by a single LF character on all platforms. - let expectedString = - "test.aboutconfig.copy.false\tfalse\t\n" + - "test.aboutconfig.copy.number\t10\t\n" + - "test.aboutconfig.copy.spaces.1\t \t\n" + - "test.aboutconfig.copy.spaces.2\t \t\n" + - "test.aboutconfig.copy.spaces.3\t \t\n" + - "test.aboutconfig.copy.string\t010.5"; + let expectedString = Services.prefs.getBoolPref( + "dom.selection.exclude_non_selectable_nodes" + ) + ? "test.aboutconfig.copy.false\tfalse\t\n" + + "test.aboutconfig.copy.number\t10\t\n" + + "test.aboutconfig.copy.spaces.1\t \t\n" + + "test.aboutconfig.copy.spaces.2\t \t\n" + + "test.aboutconfig.copy.spaces.3\t \t\n" + + "test.aboutconfig.copy.string\t010.5" + : "test.aboutconfig.copy.false\tfalse\t\t\n" + + "test.aboutconfig.copy.number\t10\t\t\n" + + "test.aboutconfig.copy.spaces.1\t \t\t\n" + + "test.aboutconfig.copy.spaces.2\t \t\t\n" + + "test.aboutconfig.copy.spaces.3\t \t\t\n" + + "test.aboutconfig.copy.string\t010.5"; this.search("test.aboutconfig.copy."); let startRow = this.getRow("test.aboutconfig.copy.false");