commit 08081920e2cc174658807ed95c7edc2d42bc6e9e parent 7a847766931915acd1c68abd8caedd59ff4aab41 Author: Masayuki Nakano <masayuki@d-toybox.com> Date: Fri, 21 Nov 2025 00:14:38 +0000 Bug 1998858 - part 1: Make `nsIFrame` treat editable nodes always selectable r=emilio,jfkthame,layout-reviewers As tested by `css/css-ui/user-select-none-in-editable.html`, we should treat editable nodes as selectable even if `user-select: none` is specified. Then, some tests start failing due to an existing bug of caret move when an inline element starts/ends with invisible white-spaces. Therefore, to make them keep passing, this fixes the bug of `nsTextFrame::PeekOffsetCharacter()`. Differential Revision: https://phabricator.services.mozilla.com/D272608 Diffstat:
16 files changed, 460 insertions(+), 60 deletions(-)
diff --git a/accessible/generic/DocAccessible.cpp b/accessible/generic/DocAccessible.cpp @@ -253,8 +253,7 @@ uint64_t DocAccessible::NativeState() const { // exposed on the root frame. Therefore, we explicitly use the body frame // here (if any). nsIFrame* bodyFrame = mContent ? mContent->GetPrimaryFrame() : nullptr; - if ((state & states::EDITABLE) || - (bodyFrame && bodyFrame->IsSelectable(nullptr))) { + if ((state & states::EDITABLE) || (bodyFrame && bodyFrame->IsSelectable())) { // If the accessible is editable the layout selectable state only disables // mouse selection, but keyboard (shift+arrow) selection is still possible. state |= states::SELECTABLE_TEXT; diff --git a/accessible/generic/HyperTextAccessible.cpp b/accessible/generic/HyperTextAccessible.cpp @@ -76,7 +76,7 @@ uint64_t HyperTextAccessible::NativeState() const { } nsIFrame* frame = GetFrame(); - if ((states & states::EDITABLE) || (frame && frame->IsSelectable(nullptr))) { + if ((states & states::EDITABLE) || (frame && frame->IsSelectable())) { // If the accessible is editable the layout selectable state only disables // mouse selection, but keyboard (shift+arrow) selection is still possible. states |= states::SELECTABLE_TEXT; diff --git a/dom/base/FragmentOrElement.cpp b/dom/base/FragmentOrElement.cpp @@ -1052,7 +1052,7 @@ bool nsIContent::CanStartSelectionAsWebCompatHack() const { if (!frame) { return true; } - if (!frame->IsSelectable(nullptr)) { + if (!frame->IsSelectable()) { return false; } } diff --git a/dom/base/nsRange.cpp b/dom/base/nsRange.cpp @@ -3363,7 +3363,7 @@ void nsRange::ExcludeNonSelectableNodes(nsTArray<RefPtr<nsRange>>* aOutRanges) { frame = p->GetPrimaryFrame(); } if (frame) { - selectable = frame->IsSelectable(nullptr); + selectable = frame->IsSelectable(); } } } diff --git a/dom/serializers/nsDocumentEncoder.cpp b/dom/serializers/nsDocumentEncoder.cpp @@ -946,7 +946,7 @@ nsresult nsDocumentEncoder::NodeSerializer::SerializeToStringRecursive( if (mFlags & SkipInvisibleContent) { if (aNode->IsContent()) { if (nsIFrame* frame = aNode->AsContent()->GetPrimaryFrame()) { - if (!frame->IsSelectable(nullptr)) { + if (!frame->IsSelectable()) { aSerializeRoot = SerializeRoot::eNo; } } diff --git a/editor/libeditor/tests/test_bug1394758.html b/editor/libeditor/tests/test_bug1394758.html @@ -35,7 +35,6 @@ SimpleTest.waitForFocus(function() { synthesizeKey("KEY_ArrowRight"); synthesizeKey("KEY_ArrowRight"); - synthesizeKey("KEY_ArrowRight"); synthesizeKey("KEY_Backspace"); synthesizeKey("KEY_Backspace"); diff --git a/layout/base/AccessibleCaretManager.cpp b/layout/base/AccessibleCaretManager.cpp @@ -616,7 +616,7 @@ nsresult AccessibleCaretManager::SelectWordOrShortcut(const nsPoint& aPoint) { return NS_OK; } - bool selectable = ptFrame->IsSelectable(nullptr); + bool selectable = ptFrame->IsSelectable(); #ifdef DEBUG_FRAME_DUMP AC_LOG("%s: %s %s selectable.", __FUNCTION__, ptFrame->ListTag().get(), @@ -1258,7 +1258,7 @@ nsresult AccessibleCaretManager::DragCaretInternal(const nsPoint& aPoint) { return NS_ERROR_FAILURE; } - if (!newFrame->IsSelectable(nullptr)) { + if (!newFrame->IsSelectable()) { return NS_ERROR_FAILURE; } diff --git a/layout/base/tests/bug1524266-3.html b/layout/base/tests/bug1524266-3.html @@ -8,14 +8,13 @@ outline: 3px solid blue; } span { - -moz-user-select: none; user-select: none; } </style> <div contenteditable="true" spellcheck="false"> xx <span> - NOT EDITABLE + SELECTABLE </span> xxx </div> @@ -27,7 +26,7 @@ SimpleTest.waitForFocus(function() { for (let i = 0; i < 2; ++i) synthesizeKey("KEY_ArrowRight"); // Select whitespace + <span> - for (let i = 0; i < 2; ++i) + for (let i = 0; i < " SELECTABLE ".length; ++i) synthesizeKey("KEY_ArrowRight", { shiftKey: true }); // Rip it off. synthesizeKey("KEY_Delete"); diff --git a/layout/generic/nsIFrame.cpp b/layout/generic/nsIFrame.cpp @@ -2302,7 +2302,7 @@ bool nsIFrame::ShouldHandleSelectionMovementEvents() { if (selType == nsISelectionController::SELECTION_OFF) { return false; } - if (!IsSelectable(nullptr)) { + if (!IsSelectable()) { // Check whether style allows selection. return false; } @@ -4797,7 +4797,15 @@ static StyleUserSelect UsedUserSelect(const nsIFrame* aFrame) { // See https://github.com/w3c/csswg-drafts/issues/3344 to see why we do this // at used-value time instead of at computed-value time. - if (aFrame->IsTextInputFrame() || IsEditingHost(aFrame)) { + // Although the draft does not define that editable elements under an editing + // host should ignore `user-select`, Chrome ignores `user-select:none` and + // `user-select:all` and we've already considered as ignoring the + // `user-select` according to this test: + // https://searchfox.org/firefox-main/rev/6abddcb0a5076c3b888686ede6f4cf7d082460d3/dom/base/test/test_bug1101364.html#73-85 + // https://searchfox.org/firefox-main/rev/4fd0fa7e5814c0b51f1dd075821988377bc56cc1/testing/web-platform/tests/css/css-ui/user-select-none-in-editable.html#23-24,32-36 + // Therefore, we check aFrame->ContentIsEditable() instead of + // IsEditingHost(aFrame). + if (aFrame->IsTextInputFrame() || aFrame->ContentIsEditable()) { // We don't implement 'contain' itself, but we make 'text' behave as // 'contain' for contenteditable and <input> / <textarea> elements anyway so // this is ok. @@ -9354,7 +9362,7 @@ static nsresult GetNextPrevLineFromBlockFrame(PeekOffsetStruct* aPos, if (!aOffsets.content) { return false; } - if (!aFrame->IsSelectable(nullptr)) { + if (!aFrame->IsSelectable()) { return false; } if (aPos->mAncestorLimiter && @@ -9846,7 +9854,7 @@ static nsIFrame* GetFirstSelectableDescendantWithLineIterator( PeekOffsetOption::ForceEditableRegion); auto FoundValidFrame = [aPeekOffsetStruct, forceEditableRegion](const nsIFrame* aFrame) { - if (!aFrame->IsSelectable(nullptr)) { + if (!aFrame->IsSelectable()) { return false; } if (!aPeekOffsetStruct.FrameContentIsInAncestorLimiter(aFrame)) { @@ -10413,10 +10421,9 @@ nsIFrame::SelectablePeekReport nsIFrame::GetFrameFromDirection( return result; } - auto IsSelectable = [aAncestorLimiter, aOptions, - frameSelection](const nsIFrame* aFrame) { - if (!aFrame->IsSelectable(nullptr) || - MOZ_UNLIKELY(!aFrame->GetContent())) { + auto IsSelectableFrame = [aAncestorLimiter, aOptions, + frameSelection](const nsIFrame* aFrame) { + if (!aFrame->IsSelectable() || MOZ_UNLIKELY(!aFrame->GetContent())) { return false; } // If the found frame content is managed by different nsFrameSelection, we @@ -10440,7 +10447,7 @@ nsIFrame::SelectablePeekReport nsIFrame::GetFrameFromDirection( traversedFrame->IsBrFrame()) { for (nsIFrame* current = traversedFrame->GetPrevSibling(); current; current = current->GetPrevSibling()) { - if (!current->IsBlockOutside() && IsSelectable(current)) { + if (!current->IsBlockOutside() && IsSelectableFrame(current)) { if (!current->IsBrFrame()) { result.mIgnoredBrFrame = true; } @@ -10452,13 +10459,13 @@ nsIFrame::SelectablePeekReport nsIFrame::GetFrameFromDirection( } } - selectable = IsSelectable(traversedFrame); + selectable = IsSelectableFrame(traversedFrame); if (MOZ_UNLIKELY(!frameSelection) && selectable && MOZ_LIKELY(traversedFrame->GetContent())) { frameSelection = traversedFrame->GetContent()->GetFrameSelection(); } if (!selectable) { - if (traversedFrame->IsSelectable(nullptr)) { + if (traversedFrame->IsSelectable()) { result.mHasSelectableFrame = true; } result.mMovedOverNonSelectableText = true; diff --git a/layout/generic/nsIFrame.h b/layout/generic/nsIFrame.h @@ -4040,6 +4040,9 @@ class nsIFrame : public nsQueryFrame { /** * Called to discover where this frame, or a parent frame has user-select * style applied, which affects that way that it is selected. + * NOTE: Even if this returns true it does NOT mean the `user-select` style + * is not `none`. If the content is editable or a text control element, this + * returns true. * * @param aSelectStyle out param. Returns the type of selection style found * (using values defined in nsStyleConsts.h). @@ -4047,7 +4050,8 @@ class nsIFrame : public nsQueryFrame { * @return Whether the frame can be selected (i.e. is not affected by * user-select: none) */ - bool IsSelectable(mozilla::StyleUserSelect* aSelectStyle) const; + [[nodiscard]] bool IsSelectable( + mozilla::StyleUserSelect* aSelectStyle = nullptr) const; /** * Returns whether this frame should have the content-block-size of a line, diff --git a/layout/generic/nsTextFrame.cpp b/layout/generic/nsTextFrame.cpp @@ -4897,7 +4897,7 @@ nsTextFrame::~nsTextFrame() = default; nsIFrame::Cursor nsTextFrame::GetCursor(const nsPoint& aPoint) { StyleCursorKind kind = StyleUI()->Cursor().keyword; if (kind == StyleCursorKind::Auto) { - if (!IsSelectable(nullptr)) { + if (!IsSelectable()) { kind = StyleCursorKind::Default; } else { kind = GetWritingMode().IsVertical() ? StyleCursorKind::VerticalText @@ -8831,8 +8831,9 @@ static bool IsAcceptableCaretPosition(const gfxSkipCharsIterator& aIter, nsIFrame::FrameSearchResult nsTextFrame::PeekOffsetCharacter( bool aForward, int32_t* aOffset, PeekOffsetCharacterOptions aOptions) { - int32_t contentLength = GetContentLength(); - NS_ASSERTION(aOffset && *aOffset <= contentLength, "aOffset out of range"); + const int32_t contentLengthInFrame = GetContentLength(); + NS_ASSERTION(aOffset && *aOffset <= contentLengthInFrame, + "aOffset out of range"); if (!aOptions.mIgnoreUserStyleAll) { StyleUserSelect selectStyle; @@ -8846,19 +8847,46 @@ nsIFrame::FrameSearchResult nsTextFrame::PeekOffsetCharacter( if (!mTextRun) { return CONTINUE_EMPTY; } - - TrimmedOffsets trimmed = + const TrimmedOffsets trimmed = GetTrimmedOffsets(CharacterDataBuffer(), TrimmedOffsetFlags::NoTrimAfter); // A negative offset means "end of frame". - int32_t startOffset = - GetContentOffset() + (*aOffset < 0 ? contentLength : *aOffset); + const int32_t offset = + GetContentOffset() + (*aOffset < 0 ? contentLengthInFrame : *aOffset); if (!aForward) { + const int32_t endOffset = [&]() -> int32_t { + const int32_t minEndOffset = std::min(trimmed.GetEnd(), offset); + if (minEndOffset <= trimmed.mStart || + minEndOffset + 1 >= trimmed.GetEnd()) { + return minEndOffset; + } + // If the end offset points a character in this frame and it's skipped + // character, we want to scan previous character of the prceding first + // non-skipped character. + for (const int32_t i : + Reversed(IntegerRange(trimmed.mStart, minEndOffset + 1))) { + iter.SetOriginalOffset(i); + if (!iter.IsOriginalCharSkipped()) { + return i; + } + } + return trimmed.mStart; + }(); + // If we're at the start of a line, look at the next continuation + if (endOffset <= trimmed.mStart) { + *aOffset = 0; + return CONTINUE; + } // If at the beginning of the line, look at the previous continuation - for (int32_t i = std::min(trimmed.GetEnd(), startOffset) - 1; - i >= trimmed.mStart; --i) { + for (const int32_t i : Reversed(IntegerRange(trimmed.mStart, endOffset))) { iter.SetOriginalOffset(i); + // If we entered into a skipped char range again, we should skip all + // of them. However, we cannot know the number of preceding skipped + // chars. Therefore, we cannot skip all of them once. + if (iter.IsOriginalCharSkipped()) { + continue; + } if (IsAcceptableCaretPosition(iter, aOptions.mRespectClusters, mTextRun, this)) { *aOffset = i - mContentOffset; @@ -8866,28 +8894,59 @@ nsIFrame::FrameSearchResult nsTextFrame::PeekOffsetCharacter( } } *aOffset = 0; - } else { - // If we're at the end of a line, look at the next continuation - iter.SetOriginalOffset(startOffset); - if (startOffset <= trimmed.GetEnd() && - !(startOffset < trimmed.GetEnd() && - StyleText()->NewlineIsSignificant(this) && - iter.GetSkippedOffset() < mTextRun->GetLength() && - mTextRun->CharIsNewline(iter.GetSkippedOffset()))) { - for (int32_t i = startOffset + 1; i <= trimmed.GetEnd(); ++i) { - iter.SetOriginalOffset(i); - if (i == trimmed.GetEnd() || - IsAcceptableCaretPosition(iter, aOptions.mRespectClusters, mTextRun, - this)) { - *aOffset = i - mContentOffset; - return FOUND; - } + return CONTINUE; + } + + // If we're at the end of a line, look at the next continuation + if (offset + 1 > trimmed.GetEnd()) { + *aOffset = contentLengthInFrame; + return CONTINUE; + } + + iter.SetOriginalOffset(offset); + + // If we're at a preformatted linefeed, look at the next continutation + if (offset < trimmed.GetEnd() && StyleText()->NewlineIsSignificant(this) && + iter.GetSkippedOffset() < mTextRun->GetLength() && + mTextRun->CharIsNewline(iter.GetSkippedOffset())) { + *aOffset = contentLengthInFrame; + return CONTINUE; + } + + const int32_t scanStartOffset = [&]() -> int32_t { + // If current char is skipped, scan starting from the following + // non-skipped char. + int32_t skippedLength = 0; + if (iter.IsOriginalCharSkipped(&skippedLength)) { + const int32_t skippedLengthInFrame = + std::min(skippedLength, trimmed.GetEnd() - iter.GetOriginalOffset()); + return iter.GetOriginalOffset() + skippedLengthInFrame + 1; + } + return iter.GetOriginalOffset() + 1; + }(); + + for (int32_t i = scanStartOffset; i < trimmed.GetEnd(); i++) { + iter.SetOriginalOffset(i); + // If we entered into a skipped char range again, we should skip all + // of them. + int32_t skippedLength = 0; + if (iter.IsOriginalCharSkipped(&skippedLength)) { + const int32_t skippedLengthInFrame = + std::min(skippedLength, trimmed.GetEnd() - iter.GetOriginalOffset()); + if (skippedLengthInFrame) { + i += skippedLengthInFrame - 1; } + continue; + } + if (IsAcceptableCaretPosition(iter, aOptions.mRespectClusters, mTextRun, + this)) { + *aOffset = i - mContentOffset; + return FOUND; } - *aOffset = contentLength; } - return CONTINUE; + *aOffset = trimmed.GetEnd() - mContentOffset; + return FOUND; } bool ClusterIterator::IsInlineWhitespace() const { diff --git a/layout/tables/nsTableCellFrame.cpp b/layout/tables/nsTableCellFrame.cpp @@ -255,7 +255,7 @@ inline nscolor EnsureDifferentColors(nscolor colorA, nscolor colorB) { void nsTableCellFrame::DecorateForSelection(DrawTarget* aDrawTarget, nsPoint aPt) { NS_ASSERTION(IsSelected(), "Should only be called for selected cells"); - if (!IsSelectable(nullptr)) { + if (!IsSelectable()) { return; } RefPtr<nsFrameSelection> frameSelection = PresShell()->FrameSelection(); diff --git a/testing/web-platform/meta/css/css-ui/user-select-none-in-editable.html.ini b/testing/web-platform/meta/css/css-ui/user-select-none-in-editable.html.ini @@ -1,5 +0,0 @@ -[user-select-none-in-editable.html] - expected: - if (os == "android") and fission: [OK, TIMEOUT] - [Test user-select: none in editable contexts] - expected: FAIL diff --git a/testing/web-platform/meta/editing/run/forwarddelete.html.ini b/testing/web-platform/meta/editing/run/forwarddelete.html.ini @@ -487,9 +487,6 @@ [[["forwarddelete",""\]\] "<div style=white-space:pre-line>foo[\]\\n\\nbar</div>" compare innerHTML] expected: FAIL - [[["forwarddelete",""\]\] "<div style=white-space:nowrap>foo[\] \\nbar</div>" compare innerHTML] - expected: FAIL - [[["forwarddelete",""\]\] "<div style=white-space:nowrap>[\]f\\nbar</div>" compare innerHTML] expected: FAIL diff --git a/testing/web-platform/meta/selection/contenteditable/modify-around-inline-element-boundary.tentative.html.ini b/testing/web-platform/meta/selection/contenteditable/modify-around-inline-element-boundary.tentative.html.ini @@ -0,0 +1,12 @@ +[modify-around-inline-element-boundary.tentative.html] + [Selection.modify("move", "right", "character") when "abc[\] <span>def</span>"] + expected: FAIL + + [Selection.modify("move", "right", "character") when "abc[\]<span> def</span>"] + expected: FAIL + + [Selection.modify("move", "right", "character") when "<span>abc[\] </span>def"] + expected: FAIL + + [Selection.modify("move", "right", "character") when "<span>abc[\]</span> def"] + expected: FAIL diff --git a/testing/web-platform/tests/selection/contenteditable/modify-around-inline-element-boundary.tentative.html b/testing/web-platform/tests/selection/contenteditable/modify-around-inline-element-boundary.tentative.html @@ -0,0 +1,329 @@ +<!doctype html> +<html> +<head> +<meta charset="utf-8"> +<title>Test Selection.modify("move", "left" or "right", "character") around inline element boundaries</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../../editing/include/editor-test-utils.js"></script> +<script> +"use strict"; + +addEventListener("load", () => { + const testContainer = document.getElementById("testBody"); + function stringifySelectionRanges() { + let result = "["; + for (let i = 0; i < getSelection().rangeCount; i++) { + const range = getSelection().getRangeAt(i); + if (result != "[") { + result += ", " + } + result += EditorTestUtils.getRangeDescription(range); + } + return result + "]"; + } + function stringifyExpectedRange(expectedRange) { + return `[${EditorTestUtils.getRangeDescription({ + startContainer: expectedRange.container, + startOffset: expectedRange.offset, + endContainer: expectedRange.container, + endOffset: expectedRange.offset, + })}]`; + } + for (const data of [ + // Basic tests. If moving caret from middle of a Text, shouldn't go out different Text. + // This allows users preserve style at previous cursor position for the new text. + { + innerHTML: "ab[]c<span>def</span>", + direction: "right", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "abc".length }; + }, + }, + { + innerHTML: "abc[]<span>def</span>", + direction: "right", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "d".length }; + }, + }, + { + innerHTML: "<b>ab[]c</b><i>def</i>", + direction: "right", + expectedResult: function () { + return { container: testContainer.querySelector("b").firstChild, offset: "abc".length }; + }, + }, + { + innerHTML: "<b>abc[]</b><i>def</i>", + direction: "right", + expectedResult: function () { + return { container: testContainer.querySelector("i").firstChild, offset: "d".length }; + }, + }, + { + innerHTML: "abc<span>d[]ef</span>", + direction: "left", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: 0 }; + }, + }, + { + innerHTML: "abc<span>[]def</span>", + direction: "left", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "ab".length }; + }, + }, + { + innerHTML: "<b>abc</b><i>d[]ef</i>", + direction: "left", + expectedResult: function () { + return { container: testContainer.querySelector("i").firstChild, offset: 0 }; + }, + }, + { + innerHTML: "<b>abc</b><i>[]def</i>", + direction: "left", + expectedResult: function () { + return { container: testContainer.querySelector("b").firstChild, offset: "ab".length }; + }, + }, + // Don't skip visible white-space around the inline element boundaries. + { + innerHTML: "abc[] <span>def</span>", + direction: "right", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "abc ".length }; + }, + }, + { + innerHTML: "abc[]<span> def</span>", + direction: "right", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: " ".length }; + }, + }, + { + innerHTML: "abc <span>[]def</span>", + direction: "left", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "abc".length }; + }, + }, + { + innerHTML: "abc<span> []def</span>", + direction: "left", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: 0 }; + }, + }, + + // Skip invisible white-spaces around the inline element boundaries. + + // Only the first white-space should be visible when multiple spaces are collapsed. + // Therefore, the following tests expect that selection is collapsed after the first white-space. + { + innerHTML: "abc[] <span>def</span>", + direction: "right", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "abc ".length }; + }, + }, + { + innerHTML: "abc[] <span> def</span>", + direction: "right", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "abc ".length }; + }, + }, + { + innerHTML: "abc[]<span> def</span>", + direction: "right", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: " ".length }; + }, + }, + { + innerHTML: "<span>abc[] </span>def", + direction: "right", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "abc ".length }; + }, + }, + { + innerHTML: "<span>abc[] </span> def", + direction: "right", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "abc ".length }; + }, + }, + { + innerHTML: "<span>abc[]</span> def", + direction: "right", + expectedResult: function () { + return { container: testContainer.lastChild, offset: " ".length }; + }, + }, + // Similarly, these tests expect that selection is collapsed before the first white-space. + { + innerHTML: "abc <span>[]def</span>", + direction: "left", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "abc".length }; + }, + }, + { + innerHTML: "abc <span> []def</span>", + direction: "left", + expectedResult: function () { + return { container: testContainer.firstChild, offset: "abc".length }; + }, + }, + { + innerHTML: "abc<span> []def</span>", + direction: "left", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: 0 }; + }, + }, + { + innerHTML: "<span>abc </span>[]def", + direction: "left", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "abc".length }; + }, + }, + { + innerHTML: "<span>abc </span> []def", + direction: "left", + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "abc".length }; + }, + }, + { + innerHTML: "<span>abc</span> []def", + direction: "left", + expectedResult: function () { + return { container: testContainer.lastChild, offset: 0 }; + }, + }, + // Invisible white-spaces must be skipped by modify(). + { + innerHTML: "abc[] <span>def</span>", + direction: "right", + repeat: 2, + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "d".length }; + }, + }, + { + innerHTML: "abc[] <span> def</span>", + direction: "right", + repeat: 2, + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: " d".length }; + }, + }, + { + innerHTML: "abc[]<span> def</span>", + direction: "right", + repeat: 2, + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: " d".length }; + }, + }, + { + innerHTML: "<span>abc[] </span>def", + direction: "right", + repeat: 2, + expectedResult: function () { + return { container: testContainer.lastChild, offset: "d".length }; + }, + }, + { + innerHTML: "<span>abc[] </span> def", + direction: "right", + repeat: 2, + expectedResult: function () { + return { container: testContainer.lastChild, offset: " d".length }; + }, + }, + { + innerHTML: "<span>abc[]</span> def", + direction: "right", + repeat: 2, + expectedResult: function () { + return { container: testContainer.lastChild, offset: " d".length }; + }, + }, + { + innerHTML: "abc <span>[]def</span>", + direction: "left", + repeat: 2, + expectedResult: function () { + return { container: testContainer.firstChild, offset: "ab".length }; + }, + }, + { + innerHTML: "abc <span> []def</span>", + direction: "left", + repeat: 2, + expectedResult: function () { + return { container: testContainer.firstChild, offset: "ab".length }; + }, + }, + { + innerHTML: "abc<span> []def</span>", + direction: "left", + repeat: 2, + expectedResult: function () { + return { container: testContainer.firstChild, offset: "ab".length }; + }, + }, + { + innerHTML: "<span>abc </span>[]def", + direction: "left", + repeat: 2, + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "ab".length }; + }, + }, + { + innerHTML: "<span>abc </span> []def", + direction: "left", + repeat: 2, + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "ab".length }; + }, + }, + { + innerHTML: "<span>abc</span> []def", + direction: "left", + repeat: 2, + expectedResult: function () { + return { container: testContainer.querySelector("span").firstChild, offset: "ab".length }; + }, + }, + ]) { + test(() => { + const utils = new EditorTestUtils(testContainer); + utils.setupEditingHost(data.innerHTML); + for (let i = 0; i < (data.repeat ?? 1); i++) { + getSelection().modify("move", data.direction, "character"); + } + assert_equals( + stringifySelectionRanges(), + stringifyExpectedRange(data.expectedResult()) + ); + }, `Selection.modify("move", "${data.direction}", "character")${ + data.repeat > 1 ? ` (${data.repeat} times)` : "" + } when "${data.innerHTML}"`); + } +}, {once: true}); +</script> +</head> +<body> + <div id="testBody" contenteditable></div> +</body> +</html>