Skip to content

Commit

Permalink
part 1: Make HTMLEditor paste/drop things as plaintext when `conten…
Browse files Browse the repository at this point in the history
…teditable=plaintext-only

Chrome sets `beforeinput.data` instead of `beforeinput.dataTransfer`, but
Input Events Level 2 spec defines that browsers should set `dataTransfer` when
**contenteditable** [1].  Therefore, the new WPT expects `dataTransfer`.

However, it's unclear that the `dataTransfer` should have `text/html` or only
`text/plain`.  From web apps point of view, `text/html` data may make them
serialize the rich text format to plaintext without any dependencies of browsers
and OS.  On the other hand, they cannot distinguish whether the user tries to
paste with or without formatting when `contenteditable=true`.  Therefore, I
filed a spec issue for this.  We need to be back later about this issue.

1. https://w3c.github.io/input-events/#overview
2. w3c/input-events#162

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

bugzilla-url: https://bugzilla.mozilla.org/show_bug.cgi?id=1920646
gecko-commit: 2e3f866560e2c750fe1e4469b81d89f10bffc6a1
gecko-reviewers: m_kato
  • Loading branch information
masayuki-nakano authored and moz-wptsync-bot committed Oct 3, 2024
1 parent 61bd3c8 commit e0e8311
Show file tree
Hide file tree
Showing 2 changed files with 282 additions and 0 deletions.
18 changes: 18 additions & 0 deletions editing/include/editor-test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ class EditorTestUtils {
);
}

sendCopyShortcutKey() {
return this.sendKey(
"c",
this.window.navigator.platform.includes("Mac")
? this.kMeta
: this.kControl
);
}

sendPasteShortcutKey() {
return this.sendKey(
"v",
this.window.navigator.platform.includes("Mac")
? this.kMeta
: this.kControl
);
}

// Similar to `setupDiv` in editing/include/tests.js, this method sets
// innerHTML value of this.editingHost, and sets multiple selection ranges
// specified with the markers.
Expand Down
264 changes: 264 additions & 0 deletions editing/plaintext-only/paste.https.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="timeout" content="long">
<meta name="variant" content="?white-space=normal">
<meta name="variant" content="?white-space=pre">
<meta name="variant" content="?white-space=pre-line">
<meta name="variant" content="?white-space=pre-wrap">
<title>Pasting rich text into contenteditable=plaintext-only</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";

const searchParams = new URLSearchParams(document.location.search);
const whiteSpace = searchParams.get("white-space");
const useBR = whiteSpace == "normal";

addEventListener("load", () => {
const placeholderForCopy = document.createElement("div");
document.body.appendChild(placeholderForCopy);
const editingHost = document.createElement("div");
editingHost.style.whiteSpace = whiteSpace;
editingHost.contentEditable = "plaintext-only";
document.body.appendChild(editingHost);
editingHost.focus();
editingHost.getBoundingClientRect();
const utils = new EditorTestUtils(editingHost);
let lastBeforeInput;
editingHost.addEventListener("beforeinput", event => lastBeforeInput = event);

/**
* Pasting HTML into contenteditable=plaintext-only should work as pasting
* text which is serialized by the browser or OS. Then, `beforeinput` event
* should have only dataTransfer and it should have "text/html" format to
* make it possible that web apps can serialize the data by themselves to
* avoid the browser/OS dependency. Finally, if white-space style is normal,
* line breaks should appear as <br>. Otherwise, either <br> or \n is fine
* because both breaks the lines.
*/

promise_test(async t => {
placeholderForCopy.innerHTML = "<b>abc</b>";
document.activeElement?.blur();
await test_driver.click(placeholderForCopy);
getSelection().selectAllChildren(placeholderForCopy);
await utils.sendCopyShortcutKey();
utils.setupEditingHost("A[]B");
lastBeforeInput = undefined;
await utils.sendPasteShortcutKey();
test(() => {
assert_equals(lastBeforeInput?.inputType, "insertFromPaste", `inputType should be "insertFromPaste"`);
assert_equals(lastBeforeInput?.data, null, `data should be null`);
assert_true(
String(lastBeforeInput?.dataTransfer?.getData("text/html")).includes(placeholderForCopy.innerHTML),
`dataTransfer should have the copied HTML source`
);
}, `${t.name}: beforeinput`);
test(() => {
assert_equals(editingHost.innerHTML, "AabcB", "<b> should not be pasted");
}, `${t.name}: pasted result`);
}, "Pasting text in <b>");

promise_test(async t => {
placeholderForCopy.innerHTML = "<span>abc</span>";
document.activeElement?.blur();
await test_driver.click(placeholderForCopy);
getSelection().selectAllChildren(placeholderForCopy);
await utils.sendCopyShortcutKey();
utils.setupEditingHost("A[]B");
lastBeforeInput = undefined;
await utils.sendPasteShortcutKey();
test(() => {
assert_equals(lastBeforeInput?.inputType, "insertFromPaste", `inputType should be "insertFromPaste"`);
assert_equals(lastBeforeInput?.data, null, `data should be null`);
assert_true(
String(lastBeforeInput?.dataTransfer?.getData("text/html")).includes(placeholderForCopy.innerHTML),
`dataTransfer should have the copied HTML source`
);
}, `${t.name}: beforeinput`);
test(() => {
assert_equals(editingHost.innerHTML, "AabcB", "<span> should not be pasted");
}, `${t.name}: pasted result`);
}, "Pasting text in <span>");

promise_test(async t => {
placeholderForCopy.innerHTML = "abc";
document.activeElement?.blur();
await test_driver.click(placeholderForCopy);
getSelection().selectAllChildren(placeholderForCopy);
await utils.sendCopyShortcutKey();
utils.setupEditingHost("<b>A[]B</b>");
lastBeforeInput = undefined;
await utils.sendPasteShortcutKey();
test(() => {
assert_equals(lastBeforeInput?.inputType, "insertFromPaste", `inputType should be "insertFromPaste"`);
assert_equals(lastBeforeInput?.data, null, `data should be null`);
assert_true(
String(lastBeforeInput?.dataTransfer?.getData("text/html")).includes(placeholderForCopy.innerHTML),
`dataTransfer should have the copied HTML source`
);
}, `${t.name}: beforeinput`);
test(() => {
assert_equals(editingHost.innerHTML, "<b>AabcB</b>", "text should be inserted into the editable <b>");
}, `${t.name}: pasted result`);
}, "Pasting text into editable <b>");

promise_test(async t => {
placeholderForCopy.innerHTML = "<i>abc</i>";
document.activeElement?.blur();
await test_driver.click(placeholderForCopy);
getSelection().selectAllChildren(placeholderForCopy);
await utils.sendCopyShortcutKey();
utils.setupEditingHost("<b>A[]B</b>");
lastBeforeInput = undefined;
await utils.sendPasteShortcutKey();
test(() => {
assert_equals(lastBeforeInput?.inputType, "insertFromPaste", `inputType should be "insertFromPaste"`);
assert_equals(lastBeforeInput?.data, null, `data should be null`);
assert_true(
String(lastBeforeInput?.dataTransfer?.getData("text/html")).includes(placeholderForCopy.innerHTML),
`dataTransfer should have the copied HTML source`
);
}, `${t.name}: beforeinput`);
test(() => {
assert_equals(editingHost.innerHTML, "<b>AabcB</b>", "text should be inserted into the editable <b> without copied <i>");
}, `${t.name}: pasted result`);
}, "Pasting text in <i> into editable <b>");

promise_test(async t => {
placeholderForCopy.innerHTML = "<div>abc</div><div>def</div>";
document.activeElement?.blur();
await test_driver.click(placeholderForCopy);
getSelection().selectAllChildren(placeholderForCopy);
await utils.sendCopyShortcutKey();
utils.setupEditingHost("A[]B");
lastBeforeInput = undefined;
await utils.sendPasteShortcutKey();
test(() => {
assert_equals(lastBeforeInput?.inputType, "insertFromPaste", `inputType should be "insertFromPaste"`);
assert_equals(lastBeforeInput?.data, null, `data should be null`);
assert_true(
String(lastBeforeInput?.dataTransfer?.getData("text/html")).includes(placeholderForCopy.innerHTML),
`dataTransfer should have the copied HTML source`
);
}, `${t.name}: beforeinput`);
test(() => {
if (useBR) {
assert_in_array(
editingHost.innerHTML,
[
"Aabc<br>defB",
"A<br>abc<br>def<br>B",
],
"Each paragraph should be pasted as a line"
);
} else {
assert_in_array(
editingHost.innerHTML,
[
"Aabc<br>defB",
"Aabc\ndefB",
"A<br>abc<br>def<br>B",
"A\nabc\ndef\nB",
],
"Each paragraph should be pasted as a line"
);
}
}, `${t.name}: pasted result`);
}, "Pasting 2 paragraphs");

promise_test(async t => {
placeholderForCopy.innerHTML = "<div>abc</div><div>def</div>";
document.activeElement?.blur();
await test_driver.click(placeholderForCopy);
getSelection().selectAllChildren(placeholderForCopy);
await utils.sendCopyShortcutKey();
utils.setupEditingHost("<b>A[]B</b>");
lastBeforeInput = undefined;
await utils.sendPasteShortcutKey();
test(() => {
assert_equals(lastBeforeInput?.inputType, "insertFromPaste", `inputType should be "insertFromPaste"`);
assert_equals(lastBeforeInput?.data, null, `data should be null`);
assert_true(
String(lastBeforeInput?.dataTransfer?.getData("text/html")).includes(placeholderForCopy.innerHTML),
`dataTransfer should have the copied HTML source`
);
}, `${t.name}: beforeinput`);
test(() => {
if (useBR) {
assert_in_array(
editingHost.innerHTML,
[
"<b>Aabc<br>defB</b>",
"<b>A<br>abc<br>def<br>B</b>",
],
"Each paragraph should be pasted as a line"
);
} else {
assert_in_array(
editingHost.innerHTML,
[
"<b>Aabc<br>defB</b>",
"<b>Aabc\ndefB</b>",
"<b>A<br>abc<br>def<br>B</b>",
"<b>A\nabc\ndef\nB</b>",
],
"Each paragraph should be pasted as a line"
);
}
}, `${t.name}: pasted result`);
}, "Pasting 2 paragraphs into <b>");

promise_test(async t => {
placeholderForCopy.innerHTML = "<div><b>abc</b></div><div><b>def</b></div>";
document.activeElement?.blur();
await test_driver.click(placeholderForCopy);
getSelection().selectAllChildren(placeholderForCopy);
await utils.sendCopyShortcutKey();
utils.setupEditingHost("A[]B");
lastBeforeInput = undefined;
await utils.sendPasteShortcutKey();
test(() => {
assert_equals(lastBeforeInput?.inputType, "insertFromPaste", `inputType should be "insertFromPaste"`);
assert_equals(lastBeforeInput?.data, null, `data should be null`);
assert_true(
String(lastBeforeInput?.dataTransfer?.getData("text/html")).includes(placeholderForCopy.innerHTML),
`dataTransfer should have the copied HTML source`
);
}, `${t.name}: beforeinput`);
test(() => {
if (useBR) {
assert_in_array(
editingHost.innerHTML,
[
"Aabc<br>defB",
"A<br>abc<br>def<br>B",
],
"Each paragraph should be pasted as a line"
);
} else {
assert_in_array(
editingHost.innerHTML,
[
"Aabc<br>defB",
"Aabc\ndefB",
"A<br>abc<br>def<br>B",
"A\nabc\ndef\nB",
],
"Each paragraph should be pasted as a line"
);
}
}, `${t.name}: pasted result`);
}, "Pasting 2 paragraphs whose text is bold");
}, {once: true});
</script>
</head>
<body></body>
</html>

0 comments on commit e0e8311

Please sign in to comment.