Bug 1930277 - Make nsHTMLCopyEncoder::RangeNodeContext::IncludeInContext not treat inline editing host as an contextual inline element r=edgar,dom-core

It treats some inline elements as contextual elements.  Then, they will be
preserved in copied HTML fragment.  However, if an inline element is an editing
host, we don't want to contain it to the copied fragment because pasting it
causes duplicating same style into same editing host.  So, if the style includes
relative style like `font-size: 2em`, it will cause bigger text than the
surrounding text.  Additionally, the inline editing host usually has a border
but we don't want to make it appear in editable text.

Unfortunately, with this change, we stop copying the text style specified to
the inline editing host.  However, this is same behavior as when the editing
host is a block element like `<div>`.  Note that pasted text will be merged into
the inline editing host style.  Therefore, if and only if the destination has
different style from the editing host, the result might be different from the
expected one by the user.  However, this is a long standing issue, see
bug 1428046, for example.

Differential Revision: https://phabricator.services.mozilla.com/D228623
This commit is contained in:
Masayuki Nakano 2024-11-20 04:35:32 +00:00
parent 816d5e833d
commit 43a805714a
3 changed files with 174 additions and 2 deletions

View File

@ -1799,9 +1799,16 @@ nsHTMLCopyEncoder::EncodeToStringWithContext(nsAString& aContextString,
bool nsHTMLCopyEncoder::RangeNodeContext::IncludeInContext(
nsINode& aNode) const {
nsCOMPtr<nsIContent> content(nsIContent::FromNodeOrNull(&aNode));
const nsIContent* const content = nsIContent::FromNodeOrNull(&aNode);
if (!content) {
return false;
}
if (!content) return false;
// If it's an inline editing host, we should not treat it gives a context to
// avoid to duplicate its style.
if (content->IsEditingHost()) {
return false;
}
return content->IsAnyOfHTMLElements(
nsGkAtoms::b, nsGkAtoms::i, nsGkAtoms::u, nsGkAtoms::a, nsGkAtoms::tt,

View File

@ -3527,6 +3527,134 @@ async function doTest() {
document.removeEventListener("drop", onDrop);
})();
// -------- Test dragging inline contenteditable to same contenteditable
await (async function test_dragging_from_inline_contenteditable_to_itself() {
const description = "dragging text in inline contenteditable to same contenteditable";
container.innerHTML = `<span contenteditable style="font-size:2em">dragme!!<span>MMMM</span></span>`;
const contenteditable = document.querySelector("span[contenteditable]");
const span = contenteditable.querySelector("span");
selection.setBaseAndExtent(contenteditable.firstChild, 0, contenteditable.firstChild, "dragme".length);
beforeinputEvents = [];
inputEvents = [];
dragEvents = [];
const onDrop = aEvent => {
dragEvents.push(aEvent);
is(aEvent.dataTransfer.getData("text/plain"), "dragme",
`${description}: dataTransfer should have selected text as "text/plain"`);
is(aEvent.dataTransfer.getData("text/html"), "dragme",
`${description}: dataTransfer should have selected text as "text/html"`);
};
document.addEventListener("drop", onDrop);
if (
await trySynthesizePlainDragAndDrop(
description,
{
srcSelection: selection,
destElement: span,
}
)
) {
const kExpectedOffsets = isAndroidException ? [4,4] : [2,2];
if (isAndroidException) {
todo_is(contenteditable.innerHTML, "!!<span>MM</span>dragme<span>MM</span>",
`${description}: dragged range should be moved in inline contenteditable`);
is(contenteditable.innerHTML, "!!<span>MMMM</span>dragme",
`${description}: dragged range should be moved in inline contenteditable`);
} else {
is(contenteditable.innerHTML, "!!<span>MM</span>dragme<span>MM</span>",
`${description}: dragged range should be moved in inline contenteditable`);
}
is(beforeinputEvents.length, 2,
`${description}: 2 "beforeinput" events should be fired on inline contenteditable`);
checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null,
[{startContainer: contenteditable.firstChild, startOffset: 0,
endContainer: contenteditable.firstChild, endOffset: "dragme".length}],
description);
checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null,
[{type: "text/html", data: "dragme"},
{type: "text/plain", data: "dragme"}],
[{startContainer: span.firstChild, startOffset: kExpectedOffsets[0],
endContainer: span.firstChild, endOffset: kExpectedOffsets[1]}],
description);
is(inputEvents.length, 2,
`${description}: 2 "input" events should be fired on inline contenteditable`);
checkInputEvent(inputEvents[0], contenteditable, "deleteByDrag", null, null, [], description);
checkInputEvent(inputEvents[1], contenteditable, "insertFromDrop", null,
[{type: "text/html", data: "dragme"},
{type: "text/plain", data: "dragme"}],
[],
description);
is(dragEvents.length, 1,
`${description}: only one "drop" event should be fired on inline contenteditable`);
}
document.removeEventListener("drop", onDrop);
})();
// -------- Test dragging inline contenteditable to other inline contenteditable
await (async function test_dragging_from_inline_contenteditable_to_other_inline_contenteditable() {
const description = "dragging text in inline contenteditable to other inline contenteditable";
container.innerHTML = '<span contenteditable style="font-size:2em">dragme!!</span><hr><span contenteditable style="font-size:em">MM</span>';
const contenteditable = document.querySelector("div#container > span[contenteditable]");
const otherContenteditable = document.querySelector("div#container > span[contenteditable] ~ span[contenteditable]");
selection.setBaseAndExtent(contenteditable.firstChild, 0, contenteditable.firstChild, "dragme".length);
beforeinputEvents = [];
inputEvents = [];
dragEvents = [];
const onDrop = aEvent => {
dragEvents.push(aEvent);
is(aEvent.dataTransfer.getData("text/plain"), "dragme",
`${description}: dataTransfer should have selected text as "text/plain"`);
is(aEvent.dataTransfer.getData("text/html"), "dragme",
`${description}: dataTransfer should have selected nodes as "text/html"`);
};
document.addEventListener("drop", onDrop);
if (
await trySynthesizePlainDragAndDrop(
description,
{
srcSelection: selection,
destElement: otherContenteditable,
}
)
) {
const kExpectedOffsets = isAndroidException ? [2,2] : [1,1];
is(contenteditable.innerHTML, "!!",
`${description}: dragged range should be removed from inline contenteditable`);
if (isAndroidException) {
todo_is(otherContenteditable.innerHTML, "MdragmeM",
`${description}: dragged content should be inserted into other inline contenteditable`);
is(otherContenteditable.innerHTML, "MMdragme",
`${description}: dragged content should be inserted into other inline contenteditable`);
} else {
is(otherContenteditable.innerHTML, "MdragmeM",
`${description}: dragged content should be inserted into other inline contenteditable`);
}
is(beforeinputEvents.length, 2,
`${description}: 2 "beforeinput" events should be fired on inline contenteditable`);
checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null,
[{startContainer: contenteditable.firstChild, startOffset: 0,
endContainer: contenteditable.firstChild, endOffset: "dragme".length}],
description);
checkInputEvent(beforeinputEvents[1], otherContenteditable, "insertFromDrop", null,
[{type: "text/html", data: "dragme"},
{type: "text/plain", data: "dragme"}],
[{startContainer: otherContenteditable.firstChild, startOffset: kExpectedOffsets[0],
endContainer: otherContenteditable.firstChild, endOffset: kExpectedOffsets[1]}],
description);
is(inputEvents.length, 2,
`${description}: 2 "input" events should be fired on inline contenteditable`);
checkInputEvent(inputEvents[0], contenteditable, "deleteByDrag", null, null, [], description);
checkInputEvent(inputEvents[1], otherContenteditable, "insertFromDrop", null,
[{type: "text/html", data: "dragme"},
{type: "text/plain", data: "dragme"}],
[],
description);
is(dragEvents.length, 1,
`${description}: only one "drop" event should be fired on other inline contenteditable`);
}
document.removeEventListener("drop", onDrop);
})();
// We need to clean up contenteditable=plaintext-only before the pref enabling it is cleared.
container.innerHTML = "";

View File

@ -0,0 +1,37 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="timeout" content="long">
<title>Copying text in styled inline editing host should not duplicate the editing host</title>
<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 src="../include/editor-test-utils.js"></script>
<script>
"use strict";
addEventListener("DOMContentLoaded", () => {
promise_test(async () => {
const editingHost = document.querySelector("span[contenteditable]");
editingHost.focus();
await test_driver.click(editingHost);
const utils = new EditorTestUtils(editingHost);
utils.setupEditingHost("ABC [DEF ]GHI");
await utils.sendCopyShortcutKey();
getSelection().collapse(editingHost.firstChild, editingHost.firstChild.length);
await utils.sendPasteShortcutKey();
assert_equals(
editingHost.innerHTML.replace("&nbsp;", " "),
"ABC DEF GHIDEF "
);
}, `Copying text in styled inline editing host should not duplicate the editing host`);
}, {once: true});
</script>
</head>
<body>
<span contenteditable style="font-size:2em;font-weight:bold;border:1px solid">ABC</span><br>
</body>
</html>