Bug 1677566 - part 3: Ignore non-deletable ranges in `HTMLEditor::HandleDeleteSelection()` r=m_kato

For making delete handlers simpler, and set better target ranges to the
corresponding `beforeinput` event, we should ignore non-editable ranges
before handling deletion.

This patch makes editor stop handling deleteion when a range crosses editing
host boundaries.  In this case, Gecko has done nothing, but fired
`beforeinput` event.  Note that Blink deletes editable contents in the range
**until** it meets first non-editable content, but I don't think this is
a good behavior because it makes things complicated.  Therefore, I filed
a spec issue: https://github.com/w3c/editing/issues/283

On the other hand, this behavior change causes different behavior in
https://searchfox.org/mozilla-central/source/editor/libeditor/crashtests/1345015.html

It tries to insert paragraph into `<html>` element, but our editor currently
does not support it.  Therefore, it hits `MOZ_ASSERT`.  Therefore, this patch
added a new check into `HTMLEditor::InsertParagraphSeparatorAsSubAction()`.

Differential Revision: https://phabricator.services.mozilla.com/D107588
This commit is contained in:
Masayuki Nakano 2021-03-09 23:57:54 +00:00
Родитель 1229430221
Коммит 7af10cee55
13 изменённых файлов: 762 добавлений и 53 удалений

Просмотреть файл

@ -5385,6 +5385,11 @@ nsresult EditorBase::AutoEditActionDataSetter::MaybeDispatchBeforeInputEvent(
NS_WARNING("HTMLEditor::ComputeTargetRanges() destroyed the editor");
return NS_ERROR_EDITOR_DESTROYED;
}
if (rv == NS_ERROR_EDITOR_NO_EDITABLE_RANGE) {
// For now, keep dispatching `beforeinput` event even if no selection
// range can be editable.
rv = NS_OK;
}
NS_WARNING_ASSERTION(
NS_SUCCEEDED(rv),
"HTMLEditor::ComputeTargetRanges() failed, but ignored");

Просмотреть файл

@ -2084,6 +2084,13 @@ class EditorBase : public nsIEditor,
// DOM_SUCCESS_DOM_NO_OPERATION here.
case NS_ERROR_EDITOR_ACTION_CANCELED:
return NS_SUCCESS_DOM_NO_OPERATION;
// If there is no selection range or editable selection ranges, editor
// needs to stop handling it. However, editor shouldn't return error for
// the callers to avoid throwing exception. However, they may want to
// check whether it works or not. Therefore, we should return
// NS_SUCCESS_DOM_NO_OPERATION instead.
case NS_ERROR_EDITOR_NO_EDITABLE_RANGE:
return NS_SUCCESS_DOM_NO_OPERATION;
default:
return aRv;
}

Просмотреть файл

@ -85,6 +85,56 @@ EditActionResult& EditActionResult::operator|=(
* mozilla::AutoRangeArray
*****************************************************************************/
// static
bool AutoRangeArray::IsEditableRange(const dom::AbstractRange& aRange,
const Element& aEditingHost) {
// TODO: Perhaps, we should check whether the start/end boundaries are
// first/last point of non-editable element.
// See https://github.com/w3c/editing/issues/283#issuecomment-788654850
EditorRawDOMPoint atStart(aRange.StartRef());
const bool isStartEditable =
atStart.IsInContentNode() &&
EditorUtils::IsEditableContent(*atStart.ContainerAsContent(),
EditorUtils::EditorType::HTML);
if (!isStartEditable) {
return false;
}
if (!aRange.Collapsed()) {
EditorRawDOMPoint atEnd(aRange.EndRef());
const bool isEndEditable =
atEnd.IsInContentNode() &&
EditorUtils::IsEditableContent(*atEnd.ContainerAsContent(),
EditorUtils::EditorType::HTML);
if (!isEndEditable) {
return false;
}
// Now, both start and end points are editable, but if they are in
// different editing host, we cannot edit the range.
if (atStart.ContainerAsContent() != atEnd.ContainerAsContent() &&
atStart.ContainerAsContent()->GetEditingHost() !=
atEnd.ContainerAsContent()->GetEditingHost()) {
return false;
}
}
// HTMLEditor does not support modifying outside `<body>` element for now.
nsINode* commonAncestor = aRange.GetClosestCommonInclusiveAncestor();
return commonAncestor && commonAncestor->IsContent() &&
commonAncestor->IsInclusiveDescendantOf(&aEditingHost);
}
void AutoRangeArray::EnsureOnlyEditableRanges(const Element& aEditingHost) {
for (size_t i = mRanges.Length(); i > 0; i--) {
const OwningNonNull<nsRange>& range = mRanges[i - 1];
if (!AutoRangeArray::IsEditableRange(range, aEditingHost)) {
mRanges.RemoveElementAt(i - 1);
}
}
mAnchorFocusRange = mRanges.IsEmpty() ? nullptr : mRanges.LastElement().get();
}
Result<nsIEditor::EDirection, nsresult>
AutoRangeArray::ExtendAnchorFocusRangeFor(
const EditorBase& aEditorBase, nsIEditor::EDirection aDirectionAndAmount) {
@ -116,6 +166,14 @@ AutoRangeArray::ExtendAnchorFocusRangeFor(
return Err(NS_ERROR_NOT_INITIALIZED);
}
RefPtr<Element> editingHost;
if (aEditorBase.IsHTMLEditor()) {
editingHost = aEditorBase.AsHTMLEditor()->GetActiveEditingHost();
if (!editingHost) {
return Err(NS_ERROR_FAILURE);
}
}
Result<RefPtr<nsRange>, nsresult> result(NS_ERROR_UNEXPECTED);
nsIEditor::EDirection directionAndAmountResult = aDirectionAndAmount;
switch (aDirectionAndAmount) {
@ -243,6 +301,12 @@ AutoRangeArray::ExtendAnchorFocusRangeFor(
return directionAndAmountResult;
}
// If the new range isn't editable, keep using the original range.
if (aEditorBase.IsHTMLEditor() &&
!AutoRangeArray::IsEditableRange(*extendedRange, *editingHost)) {
return aDirectionAndAmount;
}
if (NS_WARN_IF(!frameSelection->IsValidSelectionPoint(
extendedRange->GetStartContainer())) ||
NS_WARN_IF(!frameSelection->IsValidSelectionPoint(

Просмотреть файл

@ -756,6 +756,15 @@ class MOZ_STACK_CLASS AutoRangeArray final {
}
}
/**
* EnsureOnlyEditableRanges() removes ranges which cannot modify.
* Note that this is designed only for `HTMLEditor` because this must not
* be required by `TextEditor`.
*/
void EnsureOnlyEditableRanges(const dom::Element& aEditingHost);
static bool IsEditableRange(const dom::AbstractRange& aRange,
const dom::Element& aEditingHost);
auto& Ranges() { return mRanges; }
const auto& Ranges() const { return mRanges; }
auto& FirstRangeRef() { return mRanges[0]; }
@ -928,6 +937,12 @@ class MOZ_STACK_CLASS AutoRangeArray final {
return FocusRef().IsSet() ? FocusRef().GetChildAtOffset() : nullptr;
}
void RemoveAllRanges() {
mRanges.Clear();
mAnchorFocusRange = nullptr;
mDirection = nsDirection::eDirNext;
}
private:
AutoTArray<mozilla::OwningNonNull<nsRange>, 8> mRanges;
RefPtr<nsRange> mAnchorFocusRange;

Просмотреть файл

@ -1247,6 +1247,19 @@ EditActionResult HTMLEditor::InsertParagraphSeparatorAsSubAction() {
if (NS_WARN_IF(!editingHost)) {
return EditActionIgnored(NS_ERROR_FAILURE);
}
// If the editing host parent element is editable, it means that the editing
// host must be a <body> element and the selection may be outside the body
// element. If the selection is outside the editing host, we should not
// insert new paragraph nor <br> element.
// XXX Currently, we don't support editing outside <body> element, but Blink
// does it.
if (editingHost->GetParentElement() &&
HTMLEditUtils::IsSimplyEditableNode(*editingHost->GetParentElement()) &&
(!atStartOfSelection.IsInContentNode() ||
!nsContentUtils::ContentIsFlattenedTreeDescendantOf(
atStartOfSelection.ContainerAsContent(), editingHost))) {
return EditActionHandled(NS_ERROR_EDITOR_NO_EDITABLE_RANGE);
}
// Look for the nearest parent block. However, don't return error even if
// there is no block parent here because in such case, i.e., editing host

Просмотреть файл

@ -6118,7 +6118,9 @@ bool HTMLEditor::IsActiveInDOMWindow() const {
return true;
}
Element* HTMLEditor::GetActiveEditingHost() const {
Element* HTMLEditor::GetActiveEditingHost(
LimitInBodyElement aLimitInBodyElement /* = LimitInBodyElement::Yes */)
const {
Document* document = GetDocument();
if (NS_WARN_IF(!document)) {
return nullptr;
@ -6145,7 +6147,17 @@ Element* HTMLEditor::GetActiveEditingHost() const {
content->HasIndependentSelection()) {
return nullptr;
}
return content->GetEditingHost();
Element* candidateEditingHost = content->GetEditingHost();
if (!candidateEditingHost) {
return nullptr;
}
// Currently, we don't support editing outside of `<body>` element.
return aLimitInBodyElement != LimitInBodyElement::Yes ||
(document->GetBodyElement() &&
nsContentUtils::ContentIsFlattenedTreeDescendantOf(
candidateEditingHost, document->GetBodyElement()))
? candidateEditingHost
: document->GetBodyElement();
}
void HTMLEditor::NotifyEditingHostMaybeChanged() {
@ -6408,7 +6420,7 @@ nsresult HTMLEditor::GetPreferredIMEState(IMEState* aState) {
}
already_AddRefed<Element> HTMLEditor::GetInputEventTargetElement() const {
RefPtr<Element> target = GetActiveEditingHost();
RefPtr<Element> target = GetActiveEditingHost(LimitInBodyElement::No);
if (target) {
return target.forget();
}

Просмотреть файл

@ -631,7 +631,9 @@ class HTMLEditor final : public TextEditor,
* Get an active editor's editing host in DOM window. If this editor isn't
* active in the DOM window, this returns NULL.
*/
Element* GetActiveEditingHost() const;
enum class LimitInBodyElement { No, Yes };
Element* GetActiveEditingHost(
LimitInBodyElement aLimitInBodyElement = LimitInBodyElement::Yes) const;
/**
* Retruns true if we're in designMode.

Просмотреть файл

@ -1033,6 +1033,12 @@ nsresult HTMLEditor::ComputeTargetRanges(
AutoRangeArray& aRangesToDelete) {
MOZ_ASSERT(IsEditActionDataAvailable());
Element* editingHost = GetActiveEditingHost();
if (!editingHost) {
aRangesToDelete.RemoveAllRanges();
return NS_ERROR_EDITOR_NO_EDITABLE_RANGE;
}
// First check for table selection mode. If so, hand off to table editor.
SelectedTableCellScanner scanner(aRangesToDelete);
if (scanner.IsInTableCellSelectionMode()) {
@ -1047,6 +1053,7 @@ nsresult HTMLEditor::ComputeTargetRanges(
if (HTMLEditUtils::GetTableCellElementIfOnlyOneSelected(
aRangesToDelete.Ranges()[i - removedRanges]) !=
scanner.ElementsRef()[i]) {
// XXX Need to manage anchor-focus range too!
aRangesToDelete.Ranges().RemoveElementAt(i - removedRanges);
removedRanges++;
}
@ -1054,7 +1061,14 @@ nsresult HTMLEditor::ComputeTargetRanges(
return NS_OK;
}
aRangesToDelete.EnsureOnlyEditableRanges(*editingHost);
if (aRangesToDelete.Ranges().IsEmpty()) {
NS_WARNING(
"There is no range which we can delete entire of or around the caret");
return NS_ERROR_EDITOR_NO_EDITABLE_RANGE;
}
AutoDeleteRangesHandler deleteHandler;
// Should we delete target ranges which cannot delete actually?
nsresult rv = deleteHandler.ComputeRangesToDelete(*this, aDirectionAndAmount,
aRangesToDelete);
NS_WARNING_ASSERTION(
@ -1071,7 +1085,12 @@ EditActionResult HTMLEditor::HandleDeleteSelection(
aStripWrappers == nsIEditor::eNoStrip);
if (!SelectionRefPtr()->RangeCount()) {
return EditActionCanceled();
return EditActionHandled(NS_ERROR_EDITOR_NO_EDITABLE_RANGE);
}
Element* editingHost = GetActiveEditingHost();
if (!editingHost) {
return EditActionHandled(NS_ERROR_EDITOR_NO_EDITABLE_RANGE);
}
// Remember that we did a selection deletion. Used by
@ -1097,6 +1116,13 @@ EditActionResult HTMLEditor::HandleDeleteSelection(
}
AutoRangeArray rangesToDelete(*SelectionRefPtr());
rangesToDelete.EnsureOnlyEditableRanges(*editingHost);
if (rangesToDelete.Ranges().IsEmpty()) {
NS_WARNING(
"There is no range which we can delete entire the ranges or around the "
"caret");
return EditActionHandled(NS_ERROR_EDITOR_NO_EDITABLE_RANGE);
}
AutoDeleteRangesHandler deleteHandler;
EditActionResult result = deleteHandler.Run(*this, aDirectionAndAmount,
aStripWrappers, rangesToDelete);

Просмотреть файл

@ -27,7 +27,7 @@ load 499844-1.html
load 503709-1.xhtml
load 513375-1.xhtml
load 535632-1.xhtml
load 574558-1.xhtml
asserts(1) load 574558-1.xhtml # assertion in constructor of TextFragmentData (initialized for non-editable content)
load 580151-1.xhtml
load 582138-1.xhtml
load 612565-1.html
@ -82,7 +82,7 @@ load 1348851.html
load 1350772.html
load 1364133.html
load 1366176.html
asserts(2) load 1375131.html # assertion in WSRunScanner::GetEditableBlockParentOrTopmostEditableInlineContent()
load 1375131.html
load 1381541.html
load 1383747.html
load 1383755.html
@ -138,4 +138,4 @@ load 1659717.html
load 1663725.html # throws
load 1655508.html
pref(dom.document.exec_command.nested_calls_allowed,true) load 1666556.html
asserts(2) load 1677566.html # assertion in constructor of TextFragmentData (initialized for non-editable content)
asserts(1) load 1677566.html # assertion in constructor of TextFragmentData (initialized for non-editable content)

Просмотреть файл

@ -69,10 +69,8 @@ async function runTests() {
// Root element never can be edit target. If the editTarget is the root
// element, replace with its body.
let isEditTargetIsDescendantOfEditingHost = false;
if (editTarget == aDocument.documentElement) {
editTarget = body;
isEditTargetIsDescendantOfEditingHost = true;
}
editTarget.innerHTML = "";
@ -397,9 +395,7 @@ async function runTests() {
synthesizeKey("KEY_Enter", {}, aWindow);
},
{
innerHTML: !isEditTargetIsDescendantOfEditingHost
? "<div>B</div><div><br></div>"
: "B<br><br>",
innerHTML: "<div>B</div><div><br></div>",
beforeInputEvent: {
cancelable: true,
inputType: "insertParagraph",
@ -423,13 +419,8 @@ async function runTests() {
});
function test_typing_C_in_empty_last_line(aTestData) {
if (!isEditTargetIsDescendantOfEditingHost) {
editTarget.innerHTML = "<div>B</div><div><br></div>";
selection.collapse(editTarget.querySelector("div + div"), 0);
} else {
editTarget.innerHTML = "B<br><br>";
selection.collapse(editTarget, 2);
}
editTarget.innerHTML = "<div>B</div><div><br></div>";
selection.collapse(editTarget.querySelector("div + div"), 0);
runTest(
aTestData,
@ -437,9 +428,7 @@ async function runTests() {
synthesizeKey("C", {shiftKey: true}, aWindow);
},
{
innerHTML: !isEditTargetIsDescendantOfEditingHost
? "<div>B</div><div>C<br></div>"
: "B<br>C<br>",
innerHTML: "<div>B</div><div>C<br></div>",
beforeInputEvent: {
cancelable: true,
inputType: "insertText",
@ -463,13 +452,8 @@ async function runTests() {
});
function test_typing_enter_in_non_empty_last_line(aTestData) {
if (!isEditTargetIsDescendantOfEditingHost) {
editTarget.innerHTML = "<div>B</div><div>C<br></div>";
selection.collapse(editTarget.querySelector("div + div").firstChild, 1);
} else {
editTarget.innerHTML = "B<br>C<br>";
selection.collapse(editTarget.querySelector("br").nextSibling, 1);
}
editTarget.innerHTML = "<div>B</div><div>C<br></div>";
selection.collapse(editTarget.querySelector("div + div").firstChild, 1);
runTest(
aTestData,
@ -477,9 +461,7 @@ async function runTests() {
synthesizeKey("KEY_Enter", {}, aWindow);
},
{
innerHTML: !isEditTargetIsDescendantOfEditingHost
? "<div>B</div><div>C</div><div><br></div>"
: "B<br>C<br><br>",
innerHTML: "<div>B</div><div>C</div><div><br></div>",
beforeInputEvent: {
cancelable: true,
inputType: "insertParagraph",

Просмотреть файл

@ -1,24 +1,4 @@
[select-all-and-delete-in-html-element-having-contenteditable.html]
[Select All, then, Backspace]
expected:
if (os == "linux"): FAIL
PASS
[Select All, then, Delete]
expected:
if (os == "linux"): FAIL
PASS
[Select All, then, execCommand("forwarddelete")]
expected:
if (os == "linux"): FAIL
PASS
[Select All, then, execCommand("delete")]
expected:
if (os == "linux"): FAIL
PASS
[getSelection().selectAllChildren(document.documentElement), then, Backspace]
expected: FAIL

Просмотреть файл

@ -0,0 +1,598 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>InputEvent.getTargetRanges() of deleting a range across editing host boundaries</title>
<div contenteditable></div>
<script src="input-events-get-target-ranges.js"></script>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="/resources/testdriver-actions.js"></script>
<script>
"use strict";
// This test just check whether the deleted content range(s) and target ranges of `beforeinput`
// are match or different. The behavior should be defined by editing API.
// https://github.com/w3c/editing/issues/283
promise_test(async () => {
initializeTest('<p>ab[c<span contenteditable="false">no]n-editable</span>def</p>');
await sendBackspaceKey();
const kNothingDeletedCase = '<p>abc<span contenteditable="false">non-editable</span>def</p>';
const kOnlyEditableTextDeletedCase = '<p>ab<span contenteditable="false">non-editable</span>def</p>';
const kNonEditableElementDeleteCase = '<p>abdef</p>';
if (gEditor.innerHTML === kNothingDeletedCase) {
if (gBeforeinput.length === 0) {
assert_true(true, "If nothing changed, `beforeinput` event may not be fired");
assert_equals(gInput.length, 0, "If nothing changed, `input` event should not be fired");
return;
}
assert_equals(gBeforeinput.length, 1,
"If nothing changed, `beforeinput` event can be fired for web apps can handle by themselves");
assert_equals(gBeforeinput[0].cachedRanges.length, 0,
`If nothing changed but \`beforeinput\` event is fired, its target range should be empty array (got ${
getRangeDescription(gBeforeinput[0].cachedRanges[0])
})`);
assert_equals(gBeforeinput[0].inputType, "deleteContentBackward",
"If nothing changed but `beforeinput` event is fired, its input type should be deleteContentBackward");
assert_equals(gInput.length, 0,
"If nothing changed but `beforeinput` event is fired, `input` event should not be fired");
return;
}
if (gEditor.innerHTML === kOnlyEditableTextDeletedCase) {
assert_equals(gBeforeinput.length, 1,
"If only editable text is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If only editable text is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild,
startOffset: 2,
endContainer: gEditor.firstChild.firstChild,
endOffset: 3,
}),
"If only editable text is deleted, its target range should be the deleted text range");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If only editable text is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If only editable text is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kNonEditableElementDeleteCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should have a target range");
assert_in_array(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
[
getRangeDescription({
startContainer: gEditor.firstChild.firstChild,
startOffset: 2,
endContainer: gEditor.firstChild,
endOffset: 2,
}),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild,
startOffset: 2,
endContainer: gEditor.firstChild.firstChild.nextSibling,
endOffset: 0,
}),
],
"If editable text and non-editable element are deleted, its target range should include the deleted non-editable element");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text and non-editable element are deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text and non-editable element are deleted, `input` event should be fired");
return;
}
assert_in_array(gEditor.innerHTML,
[
kNothingDeletedCase,
kOnlyEditableTextDeletedCase,
kNonEditableElementDeleteCase,
], "The result content is unexpected");
}, 'Backspace at "<p>ab[c<span contenteditable="false">no]n-editable</span>def</p>"');
promise_test(async () => {
initializeTest('<p>abc<span contenteditable="false">non-[editable</span>de]f</p>');
await sendBackspaceKey();
const kNothingDeletedCase = '<p>abc<span contenteditable="false">non-editable</span>def</p>';
const kOnlyEditableTextDeletedCase = '<p>abc<span contenteditable="false">non-editable</span>f</p>';
const kNonEditableElementDeletedCase = '<p>abcf</p>';;
if (gEditor.innerHTML === kNothingDeletedCase) {
if (gBeforeinput.length === 0) {
assert_true(true, "If nothing changed, `beforeinput` event may not be fired");
assert_equals(gInput.length, 0, "If nothing changed, `input` event should not be fired");
return;
}
assert_equals(gBeforeinput.length, 1,
"If nothing changed, `beforeinput` event can be fired for web apps can handle by themselves");
assert_equals(gBeforeinput[0].cachedRanges.length, 0,
`If nothing changed but \`beforeinput\` event is fired, its target range should be empty array (got ${
getRangeDescription(gBeforeinput[0].cachedRanges[0])
})`);
assert_equals(gBeforeinput[0].inputType, "deleteContentBackward",
"If nothing changed but `beforeinput` event is fired, its input type should be deleteContentBackward");
assert_equals(gInput.length, 0,
"If nothing changed but `beforeinput` event is fired, `input` event should not be fired");
return;
}
if (gEditor.innerHTML === kOnlyEditableTextDeletedCase) {
assert_equals(gBeforeinput.length, 1,
"If only editable text is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If only editable text is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.lastChild,
startOffset: 0,
endContainer: gEditor.firstChild.lastChild,
endOffset: 2,
}),
"If only editable text is deleted, its target range should be the deleted text range");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If only editable text is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If only editable text is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kNonEditableElementDeletedCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.lastChild,
endOffset: 2,
}),
"If editable text and non-editable element are deleted, its target range should include the deleted non-editable element");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text and non-editable element are deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text and non-editable element are deleted, `input` event should be fired");
return;
}
assert_in_array(gEditor.innerHTML,
[
kNothingDeletedCase,
kOnlyEditableTextDeletedCase,
kNonEditableElementDeletedCase,
], "The result content is unexpected");
}, 'Backspace at "<p>abc<span contenteditable="false">non-[editable</span>de]f</p>"');
promise_test(async () => {
initializeTest('<p contenteditable="false"><span contenteditable>a[bc</span>non-editable<span contenteditable>de]f</span></p>');
let firstRange = gSelection.getRangeAt(0);
if (!firstRange ||
firstRange.startContainer != gEditor.firstChild.firstChild.firstChild ||
firstRange.startOffset != 1 ||
firstRange.endContainer != gEditor.firstChild.lastChild.firstChild ||
firstRange.endOffset != 2) {
assert_true(true, "Selection couldn't set across editing host boundaries");
return;
}
await sendBackspaceKey();
const kNothingDeletedCase = '<p contenteditable="false"><span contenteditable="">abc</span>non-editable<span contenteditable="">def</span></p>';
const kOnlyEditableContentDeletedCase = '<p contenteditable="false"><span contenteditable="">a</span>non-editable<span contenteditable="">f</span></p>';
const kNonEditableElementDeletedCase = '<p contenteditable="false"><span contenteditable="">af</span></p>';
const kDeleteEditableContentBeforeNonEditableContentCase = '<p contenteditable="false"><span contenteditable="">a</span>non-editable<span contenteditable="">def</span></p>';
const kDeleteEditableContentAfterNonEditableContentCase = '<p contenteditable="false"><span contenteditable="">abc</span>non-editable<span contenteditable="">f</span></p>';
if (gEditor.innerHTML === kNothingDeletedCase) {
if (gBeforeinput.length === 0) {
assert_true(true, "If nothing changed, `beforeinput` event may not be fired");
assert_equals(gInput.length, 0, "If nothing changed, `input` event should not be fired");
return;
}
assert_equals(gBeforeinput.length, 1,
"If nothing changed, `beforeinput` event can be fired for web apps can handle by themselves");
assert_equals(gBeforeinput[0].cachedRanges.length, 0,
`If nothing changed but \`beforeinput\` event is fired, its target range should be empty array (got ${
getRangeDescription(gBeforeinput[0].cachedRanges[0])
})`);
assert_equals(gBeforeinput[0].inputType, "deleteContentBackward",
"If nothing changed but `beforeinput` event is fired, its input type should be deleteContentBackward");
assert_equals(gInput.length, 0,
"If nothing changed but `beforeinput` event is fired, `input` event should not be fired");
return;
}
if (gEditor.innerHTML === kOnlyEditableContentDeletedCase) {
assert_equals(gBeforeinput.length, 1,
"If only editable text is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 2,
"If only editable text is deleted, `beforeinput` event should have 2 target ranges");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild.firstChild,
startOffset: 1,
endContainer: gEditor.lastChild,
endOffset: 3,
}),
"If only editable text is deleted, its first target range should be the deleted text range in the first text node");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[1]),
getRangeDescription({
startContainer: gEditor.firstChild.last.firstChild,
startOffset: 0,
endContainer: gEditor.firstChild.last.firstChild,
endOffset: 2,
}),
"If only editable text is deleted, its second target range should be the deleted text range in the last text node");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If only editable text is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If only editable text is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kNonEditableElementDeletedCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild.firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.lastChild.firstChild,
endOffset: 2,
}),
"If editable text and non-editable element are deleted, its target range should include the deleted non-editable element");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text and non-editable element are deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text and non-editable element are deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kDeleteEditableContentBeforeNonEditableContentCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text before non-editable element is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text before non-editable element is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild.firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.firstChild.firstChild,
endOffset: 3,
}),
"If editable text before non-editable element is deleted, its target range should be only the deleted text");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text before non-editable element is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text before non-editable element is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kDeleteEditableContentAfterNonEditableContentCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text after non-editable element is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text after non-editable element is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.lastChild.firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.lastChild.firstChild,
endOffset: 3,
}),
"If editable text after non-editable element is deleted, its target range should be only the deleted text");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text after non-editable element is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text after non-editable element is deleted, `input` event should be fired");
return;
}
assert_in_array(gEditor.innerHTML,
[
kNothingDeletedCase,
kOnlyEditableContentDeletedCase,
kNonEditableElementDeletedCase,
kDeleteEditableContentBeforeNonEditableContentCase,
kDeleteEditableContentAfterNonEditableContentCase,
], "The result content is unexpected");
}, 'Backspace at "<p contenteditable="false"><span contenteditable>a[bc</span>non-editable<span contenteditable>de]f</span></p>"');
promise_test(async () => {
initializeTest('<p>a[bc<span contenteditable="false">non-editable<span contenteditable>de]f</span></span></p>');
let firstRange = gSelection.getRangeAt(0);
if (!firstRange ||
firstRange.startContainer != gEditor.firstChild.firstChild ||
firstRange.startOffset != 1 ||
firstRange.endContainer != gEditor.querySelector("span span").firstChild ||
firstRange.endOffset != 2) {
assert_true(true, "Selection couldn't set across editing host boundaries");
return;
}
await sendBackspaceKey();
const kNothingDeletedCase = '<p>abc<span contenteditable="false">non-editable<span contenteditable="">def</span></span></p>';
const kOnlyEditableContentDeletedCase = '<p>a<span contenteditable="false">non-editable<span contenteditable="">f</span></span></p>';
const kNonEditableElementDeletedCase1 = '<p>af</p>';
const kNonEditableElementDeletedCase2 = '<p>a<span contenteditable="">f</span></p>';
const kDeleteEditableContentBeforeNonEditableContentCase ='<p>a<span contenteditable="false">non-editable<span contenteditable="">def</span></span></p>';
const kDeleteEditableContentAfterNonEditableContentCase ='<p>abc<span contenteditable="false">non-editable<span contenteditable="">f</span></span></p>';
if (gEditor.innerHTML === kNothingDeletedCase) {
if (gBeforeinput.length === 0) {
assert_true(true, "If nothing changed, `beforeinput` event may not be fired");
assert_equals(gInput.length, 0, "If nothing changed, `input` event should not be fired");
return;
}
assert_equals(gBeforeinput.length, 1,
"If nothing changed, `beforeinput` event can be fired for web apps can handle by themselves");
assert_equals(gBeforeinput[0].cachedRanges.length, 0,
`If nothing changed but \`beforeinput\` event is fired, its target range should be empty array (got ${
getRangeDescription(gBeforeinput[0].cachedRanges[0])
})`);
assert_equals(gBeforeinput[0].inputType, "deleteContentBackward",
"If nothing changed but `beforeinput` event is fired, its input type should be deleteContentBackward");
assert_equals(gInput.length, 0,
"If nothing changed but `beforeinput` event is fired, `input` event should not be fired");
return;
}
if (gEditor.innerHTML === kOnlyEditableContentDeletedCase) {
assert_equals(gBeforeinput.length, 1,
"If only editable text is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 2,
"If only editable text is deleted, `beforeinput` event should have 2 target ranges");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.firstChild,
endOffset: 3,
}),
"If only editable text is deleted, its first target range should be the deleted text range in the first text node");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[1]),
getRangeDescription({
startContainer: gEditor.querySelector("span span").firstChild,
startOffset: 0,
endContainer: gEditor.querySelector("span span").firstChild,
endOffset: 2,
}),
"If only editable text is deleted, its second target range should be the deleted text range in the last text node");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If only editable text is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If only editable text is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kNonEditableElementDeletedCase1) {
assert_equals(gBeforeinput.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should have a target range");
// XXX If the text nodes are merged, we need to cache it for here.
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.lastChild,
endOffset: 2,
}),
"If editable text and non-editable element are deleted, its target range should include the deleted non-editable element");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text and non-editable element are deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text and non-editable element are deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kNonEditableElementDeletedCase2) {
assert_equals(gBeforeinput.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild,
startOffset: 1,
endContainer: gEditor.querySelector("span").firstChild,
endOffset: 2,
}),
"If editable text and non-editable element are deleted, its target range should include the deleted non-editable element");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text and non-editable element are deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text and non-editable element are deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kDeleteEditableContentBeforeNonEditableContentCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text before non-editable element is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text before non-editable element is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.firstChild,
endOffset: 3,
}),
"If editable text before non-editable element is deleted, its target range should be only the deleted text");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text before non-editable element is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text before non-editable element is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kDeleteEditableContentAfterNonEditableContentCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text after non-editable element is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text after non-editable element is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.querySelector("span").firstChild,
startOffset: 0,
endContainer: gEditor.querySelector("span").firstChild,
endOffset: 2,
}),
"If editable text after non-editable element is deleted, its target range should be only the deleted text");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text after non-editable element is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text after non-editable element is deleted, `input` event should be fired");
return;
}
assert_in_array(gEditor.innerHTML,
[
kNothingDeletedCase,
kOnlyEditableContentDeletedCase,
kNonEditableElementDeletedCase1,
kNonEditableElementDeletedCase2,
kDeleteEditableContentBeforeNonEditableContentCase,
kDeleteEditableContentAfterNonEditableContentCase,
], "The result content is unexpected");
}, 'Backspace at "<p>a[bc<span contenteditable="false">non-editable<span contenteditable>de]f</span></span></p>"');
promise_test(async () => {
initializeTest('<p><span contenteditable="false"><span contenteditable>a[bc</span>non-editable</span>de]f</p>');
let firstRange = gSelection.getRangeAt(0);
if (!firstRange ||
firstRange.startContainer != gEditor.querySelector("span span").firstChild ||
firstRange.startOffset != 1 ||
firstRange.endContainer != gEditor.firstChild.lastChild.firstChild ||
firstRange.endOffset != 2) {
assert_true(true, "Selection couldn't set across editing host boundaries");
return;
}
await sendBackspaceKey();
const kNothingDeletedCase = '<p><span contenteditable="false"><span contenteditable="">abc</span>non-editable</span>def</p>';
const kOnlyEditableContentDeletedCase = '<p><span contenteditable="false"><span contenteditable="">a</span>non-editable</span>f</p>';
const kNonEditableElementDeletedCase1 = '<p><span contenteditable="false"><span contenteditable="">af</span></span></p>';
const kNonEditableElementDeletedCase2 = '<p><span contenteditable="false"><span contenteditable="">a</span></span>f</p>';
const kDeleteEditableContentBeforeNonEditableContentCase = '<p><span contenteditable="false"><span contenteditable="">a</span>non-editable</span>def</p>';
const kDeleteEditableContentAfterNonEditableContentCase = '<p><span contenteditable="false"><span contenteditable="">abc</span>non-editable</span>f</p>';
if (gEditor.innerHTML === kNothingDeletedCase) {
if (gBeforeinput.length === 0) {
assert_true(true, "If nothing changed, `beforeinput` event may not be fired");
assert_equals(gInput.length, 0, "If nothing changed, `input` event should not be fired");
return;
}
assert_equals(gBeforeinput.length, 1,
"If nothing changed, `beforeinput` event can be fired for web apps can handle by themselves");
assert_equals(gBeforeinput[0].cachedRanges.length, 0,
`If nothing changed but \`beforeinput\` event is fired, its target range should be empty array (got ${
getRangeDescription(gBeforeinput[0].cachedRanges[0])
})`);
assert_equals(gBeforeinput[0].inputType, "deleteContentBackward",
"If nothing changed but `beforeinput` event is fired, its input type should be deleteContentBackward");
assert_equals(gInput.length, 0,
"If nothing changed but `beforeinput` event is fired, `input` event should not be fired");
return;
}
if (gEditor.innerHTML === kOnlyEditableContentDeletedCase) {
assert_equals(gBeforeinput.length, 1,
"If only editable text is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 2,
"If only editable text is deleted, `beforeinput` event should have 2 target ranges");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.querySelector("span span").firstChild,
startOffset: 1,
endContainer: gEditor.querySelector("span span").firstChild,
endOffset: 3,
}),
"If only editable text is deleted, its first target range should be the deleted text range in the first text node");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[1]),
getRangeDescription({
startContainer: gEditor.firstChild.lastChild,
startOffset: 0,
endContainer: gEditor.firstChild.lastChild,
endOffset: 2,
}),
"If only editable text is deleted, its second target range should be the deleted text range in the last text node");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If only editable text is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If only editable text is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kNonEditableElementDeletedCase1) {
assert_equals(gBeforeinput.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should have a target range");
// XXX If the text nodes are merged, we need to cache it for here.
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.querySelector("span span").firstChild,
startOffset: 1,
endContainer: gEditor.querySelector("span span").lastChild,
endOffset: 2,
}),
"If editable text and non-editable element are deleted, its target range should include the deleted non-editable element");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text and non-editable element are deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text and non-editable element are deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kNonEditableElementDeletedCase2) {
assert_equals(gBeforeinput.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text and non-editable element are deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.querySelector("span span").firstChild,
startOffset: 1,
endContainer: gEditor.firstChild.lastChild,
endOffset: 2,
}),
"If editable text and non-editable element are deleted, its target range should include the deleted non-editable element");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text and non-editable element are deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text and non-editable element are deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kDeleteEditableContentBeforeNonEditableContentCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text before non-editable element is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text before non-editable element is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.querySelector("span span").firstChild,
startOffset: 1,
endContainer: gEditor.querySelector("span span").firstChild,
endOffset: 3,
}),
"If editable text before non-editable element is deleted, its target range should be only the deleted text");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text before non-editable element is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text before non-editable element is deleted, `input` event should be fired");
return;
}
if (gEditor.innerHTML === kDeleteEditableContentAfterNonEditableContentCase) {
assert_equals(gBeforeinput.length, 1,
"If editable text after non-editable element is deleted, `beforeinput` event should be fired");
assert_equals(gBeforeinput[0].cachedRanges.length, 1,
"If editable text after non-editable element is deleted, `beforeinput` event should have a target range");
assert_equals(getRangeDescription(gBeforeinput[0].cachedRanges[0]),
getRangeDescription({
startContainer: gEditor.firstChild.lastChild,
startOffset: 0,
endContainer: gEditor.firstChild.lastChild,
endOffset: 2,
}),
"If editable text after non-editable element is deleted, its target range should be only the deleted text");
assert_equals(gBeforeinput[0].inputType, "deleteContent",
"If editable text after non-editable element is deleted, its input type should be deleteContent");
assert_equals(gInput.length, 1,
"If editable text after non-editable element is deleted, `input` event should be fired");
return;
}
assert_in_array(gEditor.innerHTML,
[
kNothingDeletedCase,
kOnlyEditableContentDeletedCase,
kNonEditableElementDeletedCase1,
kNonEditableElementDeletedCase2,
kDeleteEditableContentBeforeNonEditableContentCase,
kDeleteEditableContentAfterNonEditableContentCase,
], "The result content is unexpected");
}, 'Backspace at "<p><span contenteditable="false"><span contenteditable>a[bc</span>non-editable</span>de]f</p>"');
</script>

Просмотреть файл

@ -829,6 +829,11 @@ with modules["EDITOR"]:
# and may keep handling the operation unexpectedly.
errors["NS_ERROR_EDITOR_ACTION_CANCELED"] = FAILURE(3)
# An error code that indicates that there is no editable selection ranges.
# E.g., Selection has no range, caret is in non-editable element,
# non-collapsed range crosses editing host boundaries.
errors["NS_ERROR_EDITOR_NO_EDITABLE_RANGE"] = FAILURE(4)
errors["NS_SUCCESS_EDITOR_ELEMENT_NOT_FOUND"] = SUCCESS(1)
errors["NS_SUCCESS_EDITOR_FOUND_TARGET"] = SUCCESS(2)