Bug 1840519 - Make typing surrogate pair behavior switchable with prefs r=m_kato,smaug

A lone surrogate should not appear in `DOMString` at least when the attribute
values of events because of ill-formed UTF-16 string.

`TextEventDispatcher` does not handle surrogate pairs correctly. It should not
split surrogate pairs when it sets `KeyboardEvent.key` value for avoiding the
problem in some DOM API wrappers, e.g., Rust-running-as-wasm.

On the other hand, `.charCode` is an unsigned long attribute and web apps
may use `String.fromCharCode(event.charCode)` to convert the input to string,
and unfortunately, `fromCharCode` does not support Unicode character code
points over `0xFFFF`.  Therefore, we may need to keep dispatching 2 `keypress`
events per surrogate pair for the backward compatibility.

Therefore, this patch creates 2 prefs.  One is for using single-keypress
event model and double-keypress event model.  The other is for the latter,
whether `.key` value never has ill-formed UTF-16 or it's allowed.

If using the single-keypress event model --this is compatible with Safari and
Chrome in non-Windows platforms--, one `keypress` event is dispatched for
typing a surrogate pair.  Then, its `.charCode` is over `0xFFFF` which can
work with `String.fromCodePoint()` instead of `String.fromCharCode()` and
`.key` value is set to simply the surrogate pair (i.e., its length is 2).

If using the double-keypress event model and disallowing ill-formed UTF-16
--this is the new default behavior for both avoiding ill-formed UTF-16 string
creation and keeping backward compatibility with not-maintained web apps using
`String.fromCharCode`--, 2 `keypress` events are dispatched.  `.charCode` for
first one is the code of the high-surrogate, but `.key` is the surrogate pair.
Then, `.charCode` for second one is the low-surrogate and `.key` is empty
string.  In this mode, `TextEditor` and `HTMLEditor` ignores the second
`keypress`.  Therefore, web apps can cancel it only with the first `keypress`,
but it indicates the `keypress` introduces a surrogate pair with `.key`
attribute.

Otherwise, if using the double-keypress event model and allowing ill-formed
UTF-16 --this is the traditional our behavior and compatible with Chrome in
Windows--, 2 `keypress` events are dispatched with same `.charCode` values as
the previous mode, but first `.key` is the high-surrogate and the other's is
the low surrogate.  Therefore, web apps can cancel either one of them or
both of them.

Finally, this patch makes `TextEditor` and `HTMLEditor` handle text input
with `keypress` events properly.  Except in the last mode, `beforeinput` and
`input` events are fired once and their `data` values are the surrogate pair.
On the other hand, in the last mode, 2 sets of `beforeinput` and `input` are
fired and their `.data` values has only the surrogate so that ill-formed
UTF-16 values.

Note that this patch also fixes an issue on Windows.  Windows may send a high
surrogate and a low surrogate with 2 sets of `WM_KEYDOWN` and `WM_KEYUP` whose
virtual keycode is `VK_PACKET` (typically, this occurs with `SendInput` API).
For handling this correctly, this patch changes `NativeKey` class to make it
just store the high surrogate for the first `WM_KEYDOWN` and `WM_KEYUP` and use
it when it'll receive another `WM_KEYDOWN` for a low surrogate.

Differential Revision: https://phabricator.services.mozilla.com/D182142
This commit is contained in:
Masayuki Nakano 2023-08-23 01:16:59 +00:00
parent 3abfdd94e9
commit 8224e1138c
12 changed files with 835 additions and 32 deletions

View File

@ -2444,7 +2444,7 @@ function runConsumingKeydownBeforeCompositionTests()
window.removeEventListener("keyup", handler, false);
}
function runKeyTests()
async function runKeyTests()
{
var description = "runKeyTests(): ";
const kModifiers =
@ -2637,6 +2637,127 @@ function runKeyTests()
is(input.value, "abc",
description + "input.value should be \"abc\"");
// Emulates pressing and releasing a key which introduces a surrogate pair.
async function test_press_and_release_surrogate_pair_key(
aTestPerSurrogateKeyPress,
aTestIllFormedUTF16KeyValue = false
) {
await SpecialPowers.pushPrefEnv({
set: [
["dom.event.keypress.dispatch_once_per_surrogate_pair", !aTestPerSurrogateKeyPress],
["dom.event.keypress.key.allow_lone_surrogate", aTestIllFormedUTF16KeyValue],
],
});
const settingDescription =
`aTestPerSurrogateKeyPress=${aTestPerSurrogateKeyPress}, aTestIllFormedUTF16KeyValue=${aTestIllFormedUTF16KeyValue}`;
const allowIllFormedUTF16 = aTestPerSurrogateKeyPress && aTestIllFormedUTF16KeyValue;
const keySurrogatePair = new KeyboardEvent("", { key: "\uD842\uDFB7", code: "KeyA", keyCode: KeyboardEvent.DOM_VK_A });
input.value = "";
reset();
doDefaultKeydown = TIP.keydown(keySurrogatePair);
doDefaultKeyup = TIP.keyup(keySurrogatePair);
is(
doDefaultKeydown,
TIP.KEYPRESS_IS_CONSUMED,
`${
description
}TIP.keydown(keySurrogatePair), ${
settingDescription
}, should return 0x02 because the keypress event should be consumed by the input element`
);
is(
doDefaultKeyup,
true,
`${description}TIP.keyup(keySurrogatePair) should return true`
);
is(
events.length,
aTestPerSurrogateKeyPress ? 4 : 3,
`${description}TIP.keydown(keySurrogatePair), ${
settingDescription
}, should cause keydown, keypress${
aTestPerSurrogateKeyPress ? ", keypress" : ""
} and keyup event`
);
checkKeyAttrs(
`${description}TIP.keydown(keySurrogatePair), ${settingDescription}`,
events[0],
{
type: "keydown",
key: "\uD842\uDFB7",
code: "KeyA",
keyCode: KeyboardEvent.DOM_VK_A,
charCode: 0,
}
);
if (aTestPerSurrogateKeyPress) {
checkKeyAttrs(
`${description}TIP.keydown(keySurrogatePair), ${settingDescription}`,
events[1],
{
type: "keypress",
key: allowIllFormedUTF16
? "\uD842"
: "\uD842\uDFB7", // First keypress should have the surrogate pair
code: "KeyA",
keyCode: 0,
charCode: "\uD842\uDFB7".charCodeAt(0),
defaultPrevented: true,
}
);
checkKeyAttrs(
`${description}TIP.keydown(keySurrogatePair), ${settingDescription}`,
events[2],
{
type: "keypress",
key: allowIllFormedUTF16
? "\uDFB7"
: "", // But the following keypress should have empty string, instead
code: "KeyA",
keyCode: 0,
charCode: "\uD842\uDFB7".charCodeAt(1),
defaultPrevented: true,
}
);
} else {
checkKeyAttrs(
`${description}TIP.keydown(keySurrogatePair), ${settingDescription}`,
events[1],
{
type: "keypress",
key: "\uD842\uDFB7",
code: "KeyA",
keyCode: 0,
charCode: 0x20BB7,
defaultPrevented: true,
}
);
}
checkKeyAttrs(
`${description}TIP.keyup(keySurrogatePair), ${settingDescription}`,
events[aTestPerSurrogateKeyPress ? 3 : 2],
{
type: "keyup",
key: "\uD842\uDFB7",
code: "KeyA",
keyCode: KeyboardEvent.DOM_VK_A,
charCode: 0,
}
);
is(
input.value,
"\uD842\uDFB7",
`${description}${settingDescription}, input.value should be the surrogate pair`
);
};
await test_press_and_release_surrogate_pair_key(true, true);
await test_press_and_release_surrogate_pair_key(true, false);
await test_press_and_release_surrogate_pair_key(false);
// If KEY_FORCE_PRINTABLE_KEY is specified, registered key names can be a printable key which inputs the specified value.
input.value = "";
var keyEnterPrintable = new KeyboardEvent("", { key: "Enter", code: "Enter", keyCode: KeyboardEvent.DOM_VK_RETURN });
@ -4730,7 +4851,7 @@ async function runTests()
runCompositionTests();
runCompositionWithKeyEventTests();
runConsumingKeydownBeforeCompositionTests();
runKeyTests();
await runKeyTests();
runErrorTests();
runCommitCompositionTests();
await runCallbackTests(false);

View File

@ -1448,7 +1448,21 @@ nsresult HTMLEditor::HandleKeyPressEvent(WidgetKeyboardEvent* aKeyboardEvent) {
return NS_OK;
}
aKeyboardEvent->PreventDefault();
nsAutoString str(aKeyboardEvent->mCharCode);
// If we dispatch 2 keypress events for a surrogate pair and we set only
// first `.key` value to the surrogate pair, the preceding one has it and the
// other has empty string. In this case, we should handle only the first one
// with the key value.
if (!StaticPrefs::dom_event_keypress_dispatch_once_per_surrogate_pair() &&
!StaticPrefs::dom_event_keypress_key_allow_lone_surrogate() &&
aKeyboardEvent->mKeyValue.IsEmpty() &&
IS_SURROGATE(aKeyboardEvent->mCharCode)) {
return NS_OK;
}
nsAutoString str(aKeyboardEvent->mKeyValue);
if (str.IsEmpty()) {
str.Assign(static_cast<char16_t>(aKeyboardEvent->mCharCode));
}
// FYI: DIfferent from TextEditor, we can treat \r (CR) as-is in HTMLEditor.
nsresult rv = OnInputText(str);
NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "EditorBase::OnInputText() failed");
return rv;

View File

@ -24,6 +24,7 @@
#include "mozilla/mozalloc.h"
#include "mozilla/Preferences.h"
#include "mozilla/PresShell.h"
#include "mozilla/StaticPrefs_dom.h"
#include "mozilla/StaticPrefs_editor.h"
#include "mozilla/TextComposition.h"
#include "mozilla/TextEvents.h"
@ -317,18 +318,35 @@ nsresult TextEditor::HandleKeyPressEvent(WidgetKeyboardEvent* aKeyboardEvent) {
// we don't PreventDefault() here or keybindings like control-x won't work
return NS_OK;
}
// Our widget shouldn't set `\r` to `mCharCode`, but it may be synthesized
aKeyboardEvent->PreventDefault();
// If we dispatch 2 keypress events for a surrogate pair and we set only
// first `.key` value to the surrogate pair, the preceding one has it and the
// other has empty string. In this case, we should handle only the first one
// with the key value.
if (!StaticPrefs::dom_event_keypress_dispatch_once_per_surrogate_pair() &&
!StaticPrefs::dom_event_keypress_key_allow_lone_surrogate() &&
aKeyboardEvent->mKeyValue.IsEmpty() &&
IS_SURROGATE(aKeyboardEvent->mCharCode)) {
return NS_OK;
}
// Our widget shouldn't set `\r` to `mKeyValue`, but it may be synthesized
// keyboard event and its value may be `\r`. In such case, we should treat
// it as `\n` for the backward compatibility because we stopped converting
// `\r` and `\r\n` to `\n` at getting `HTMLInputElement.value` and
// `HTMLTextAreaElement.value` for the performance (i.e., we don't need to
// take care in `HTMLEditor`).
char16_t charCode =
static_cast<char16_t>(aKeyboardEvent->mCharCode) == nsCRT::CR
? nsCRT::LF
: static_cast<char16_t>(aKeyboardEvent->mCharCode);
aKeyboardEvent->PreventDefault();
nsAutoString str(charCode);
nsAutoString str(aKeyboardEvent->mKeyValue);
if (str.IsEmpty()) {
MOZ_ASSERT(aKeyboardEvent->mCharCode <= 0xFFFF,
"Non-BMP character needs special handling");
str.Assign(aKeyboardEvent->mCharCode == nsCRT::CR
? static_cast<char16_t>(nsCRT::LF)
: static_cast<char16_t>(aKeyboardEvent->mCharCode));
} else {
MOZ_ASSERT(str.Find(u"\r\n"_ns) == kNotFound,
"This assumes that typed text does not include CRLF");
str.ReplaceChar('\r', '\n');
}
nsresult rv = OnInputText(str);
NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "EditorBase::OnInputText() failed");
return rv;

View File

@ -29,7 +29,7 @@ const kIsMac = navigator.platform.includes("Mac");
const kIsWin = navigator.platform.includes("Win");
const kIsLinux = navigator.platform.includes("Linux") || navigator.platform.includes("SunOS");
function runTests() {
async function runTests() {
document.execCommand("stylewithcss", false, "true");
document.execCommand("defaultParagraphSeparator", false, "div");
@ -83,8 +83,13 @@ function runTests() {
SpecialPowers.addSystemEventListener(window, "keypress", listener, false);
// eslint-disable-next-line complexity
function doTest(aElement, aDescription,
aIsReadonly, aIsTabbable, aIsPlaintext) {
async function doTest(
aElement,
aDescription,
aIsReadonly,
aIsTabbable,
aIsPlaintext
) {
function reset(aText) {
capturingPhase.fired = false;
capturingPhase.prevented = false;
@ -560,50 +565,193 @@ function runTests() {
: "Mozilla <br>";
})(),
aDescription + "typed \"Mozilla \"");
// typing non-BMP character:
async function test_typing_surrogate_pair(
aTestPerSurrogateKeyPress,
aTestIllFormedUTF16KeyValue = false
) {
await SpecialPowers.pushPrefEnv({
set: [
["dom.event.keypress.dispatch_once_per_surrogate_pair", !aTestPerSurrogateKeyPress],
["dom.event.keypress.key.allow_lone_surrogate", aTestIllFormedUTF16KeyValue],
],
});
reset("");
let events = [];
function pushIntoEvents(aEvent) {
events.push(aEvent);
}
function getEventData(aKeyboardEventOrInputEvent) {
if (!aKeyboardEventOrInputEvent) {
return "{}";
}
switch (aKeyboardEventOrInputEvent.type) {
case "keydown":
case "keypress":
case "keyup":
return `{ type: "${aKeyboardEventOrInputEvent.type}", key="${
aKeyboardEventOrInputEvent.key
}", charCode=0x${
aKeyboardEventOrInputEvent.charCode.toString(16).toUpperCase()
} }`;
default:
return `{ type: "${aKeyboardEventOrInputEvent.type}", inputType="${
aKeyboardEventOrInputEvent.inputType
}", data="${aKeyboardEventOrInputEvent.data}" }`;
}
}
function getEventArrayData(aEvents) {
if (!aEvents.length) {
return "[]";
}
let result = "[\n";
for (const e of aEvents) {
result += ` ${getEventData(e)}\n`;
}
return result + "]";
}
aElement.addEventListener("keydown", pushIntoEvents);
aElement.addEventListener("keypress", pushIntoEvents);
aElement.addEventListener("keyup", pushIntoEvents);
aElement.addEventListener("beforeinput", pushIntoEvents);
aElement.addEventListener("input", pushIntoEvents);
synthesizeKey("\uD842\uDFB7");
aElement.removeEventListener("keydown", pushIntoEvents);
aElement.removeEventListener("keypress", pushIntoEvents);
aElement.removeEventListener("keyup", pushIntoEvents);
aElement.removeEventListener("beforeinput", pushIntoEvents);
aElement.removeEventListener("input", pushIntoEvents);
const settingDescription =
`aTestPerSurrogateKeyPress=${
aTestPerSurrogateKeyPress
}, aTestIllFormedUTF16KeyValue=${aTestIllFormedUTF16KeyValue}`;
const allowIllFormedUTF16 =
aTestPerSurrogateKeyPress && aTestIllFormedUTF16KeyValue;
check(`${aDescription}, ${settingDescription}a surrogate pair`, true, true, !aIsReadonly);
is(
aElement.textContent,
!aIsReadonly ? "\uD842\uDFB7" : "",
`${aDescription}, ${settingDescription}, The typed surrogate pair should've been inserted`
);
if (aIsReadonly) {
is(
getEventArrayData(events),
getEventArrayData(
aTestPerSurrogateKeyPress
? (
allowIllFormedUTF16
? [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842", charCode: 0xD842 },
{ type: "keypress", key: "\uDFB7", charCode: 0xDFB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0xD842 },
{ type: "keypress", key: "", charCode: 0xDFB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
)
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0x20BB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
),
`${aDescription}, ${
settingDescription
}, Typing a surrogate pair in readonly editor should not cause input events`
);
} else {
is(
getEventArrayData(events),
getEventArrayData(
aTestPerSurrogateKeyPress
? (
allowIllFormedUTF16
? [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842", charCode: 0xD842 },
{ type: "beforeinput", data: "\uD842", inputType: "insertText" },
{ type: "input", data: "\uD842", inputType: "insertText" },
{ type: "keypress", key: "\uDFB7", charCode: 0xDFB7 },
{ type: "beforeinput", data: "\uDFB7", inputType: "insertText" },
{ type: "input", data: "\uDFB7", inputType: "insertText" },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0xD842 },
{ type: "beforeinput", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "input", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "keypress", key: "", charCode: 0xDFB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
)
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0x20BB7 },
{ type: "beforeinput", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "input", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
),
`${aDescription}, ${
settingDescription
}, Typing a surrogate pair in editor should cause input events`
);
}
}
await test_typing_surrogate_pair(true, true);
await test_typing_surrogate_pair(true, false);
await test_typing_surrogate_pair(false);
}
doTest(htmlEditor, "contenteditable=\"true\"", false, true, false);
await doTest(htmlEditor, "contenteditable=\"true\"", false, true, false);
const nsIEditor = SpecialPowers.Ci.nsIEditor;
var editor = SpecialPowers.wrap(window).docShell.editor;
var flags = editor.flags;
// readonly
editor.flags = flags | nsIEditor.eEditorReadonlyMask;
doTest(htmlEditor, "readonly HTML editor", true, true, false);
await doTest(htmlEditor, "readonly HTML editor", true, true, false);
// non-tabbable
editor.flags = flags & ~(nsIEditor.eEditorAllowInteraction);
doTest(htmlEditor, "non-tabbable HTML editor", false, false, false);
await doTest(htmlEditor, "non-tabbable HTML editor", false, false, false);
// readonly and non-tabbable
editor.flags =
(flags | nsIEditor.eEditorReadonlyMask) &
~(nsIEditor.eEditorAllowInteraction);
doTest(htmlEditor, "readonly and non-tabbable HTML editor",
await doTest(htmlEditor, "readonly and non-tabbable HTML editor",
true, false, false);
// plaintext
editor.flags = flags | nsIEditor.eEditorPlaintextMask;
doTest(htmlEditor, "HTML editor but plaintext mode", false, true, true);
await doTest(htmlEditor, "HTML editor but plaintext mode", false, true, true);
// plaintext and non-tabbable
editor.flags = (flags | nsIEditor.eEditorPlaintextMask) &
~(nsIEditor.eEditorAllowInteraction);
doTest(htmlEditor, "non-tabbable HTML editor but plaintext mode",
await doTest(htmlEditor, "non-tabbable HTML editor but plaintext mode",
false, false, true);
// readonly and plaintext
editor.flags = flags | nsIEditor.eEditorPlaintextMask |
nsIEditor.eEditorReadonlyMask;
doTest(htmlEditor, "readonly HTML editor but plaintext mode",
await doTest(htmlEditor, "readonly HTML editor but plaintext mode",
true, true, true);
// readonly, plaintext and non-tabbable
editor.flags = (flags | nsIEditor.eEditorPlaintextMask |
nsIEditor.eEditorReadonlyMask) &
~(nsIEditor.eEditorAllowInteraction);
doTest(htmlEditor, "readonly and non-tabbable HTML editor but plaintext mode",
await doTest(htmlEditor, "readonly and non-tabbable HTML editor but plaintext mode",
true, false, true);
SpecialPowers.removeSystemEventListener(window, "keypress", listener, true);

View File

@ -31,7 +31,7 @@ const kIsMac = navigator.platform.includes("Mac");
const kIsWin = navigator.platform.includes("Win");
const kIsLinux = navigator.platform.includes("Linux");
function runTests() {
async function runTests() {
var fm = SpecialPowers.Services.focus;
var capturingPhase = { fired: false, prevented: false };
@ -85,7 +85,7 @@ function runTests() {
SpecialPowers.addSystemEventListener(parentElement, "keypress", listener,
false);
function doTest(aElement, aDescription, aIsSingleLine, aIsReadonly) {
async function doTest(aElement, aDescription, aIsSingleLine, aIsReadonly) {
function reset(aText) {
capturingPhase.fired = false;
capturingPhase.prevented = false;
@ -293,22 +293,169 @@ function runTests() {
check(aDescription + "' '", true, true, !aIsReadonly);
is(aElement.value, !aIsReadonly ? "Mozilla " : "",
aDescription + "typed \"Mozilla \"");
// typing non-BMP character:
async function test_typing_surrogate_pair(
aTestPerSurrogateKeyPress,
aTestIllFormedUTF16KeyValue = false
) {
await SpecialPowers.pushPrefEnv({
set: [
["dom.event.keypress.dispatch_once_per_surrogate_pair", !aTestPerSurrogateKeyPress],
["dom.event.keypress.key.allow_lone_surrogate", aTestIllFormedUTF16KeyValue],
],
});
reset("");
let events = [];
function pushIntoEvents(aEvent) {
events.push(aEvent);
}
function getEventData(aKeyboardEventOrInputEvent) {
if (!aKeyboardEventOrInputEvent) {
return "{}";
}
switch (aKeyboardEventOrInputEvent.type) {
case "keydown":
case "keypress":
case "keyup":
return `{ type: "${aKeyboardEventOrInputEvent.type}", key="${
aKeyboardEventOrInputEvent.key
}", charCode=0x${
aKeyboardEventOrInputEvent.charCode.toString(16).toUpperCase()
} }`;
default:
return `{ type: "${aKeyboardEventOrInputEvent.type}", inputType="${
aKeyboardEventOrInputEvent.inputType
}", data="${aKeyboardEventOrInputEvent.data}" }`;
}
}
function getEventArrayData(aEvents) {
if (!aEvents.length) {
return "[]";
}
let result = "[\n";
for (const e of aEvents) {
result += ` ${getEventData(e)}\n`;
}
return result + "]";
}
aElement.addEventListener("keydown", pushIntoEvents);
aElement.addEventListener("keypress", pushIntoEvents);
aElement.addEventListener("keyup", pushIntoEvents);
aElement.addEventListener("beforeinput", pushIntoEvents);
aElement.addEventListener("input", pushIntoEvents);
synthesizeKey("\uD842\uDFB7");
aElement.removeEventListener("keydown", pushIntoEvents);
aElement.removeEventListener("keypress", pushIntoEvents);
aElement.removeEventListener("keyup", pushIntoEvents);
aElement.removeEventListener("beforeinput", pushIntoEvents);
aElement.removeEventListener("input", pushIntoEvents);
const settingDescription =
`aTestPerSurrogateKeyPress=${
aTestPerSurrogateKeyPress
}, aTestIllFormedUTF16KeyValue=${aTestIllFormedUTF16KeyValue}`;
const allowIllFormedUTF16 =
aTestPerSurrogateKeyPress && aTestIllFormedUTF16KeyValue;
check(`${aDescription}, ${settingDescription}a surrogate pair`, true, true, !aIsReadonly);
is(
aElement.value,
!aIsReadonly ? "\uD842\uDFB7" : "",
`${aDescription}, ${
settingDescription
}, The typed surrogate pair should've been inserted`
);
if (aIsReadonly) {
is(
getEventArrayData(events),
getEventArrayData(
// eslint-disable-next-line no-nested-ternary
aTestPerSurrogateKeyPress
? (
allowIllFormedUTF16
? [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842", charCode: 0xD842 },
{ type: "keypress", key: "\uDFB7", charCode: 0xDFB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0xD842 },
{ type: "keypress", key: "", charCode: 0xDFB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
)
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0x20BB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
),
`${aDescription}, ${
settingDescription
}, Typing a surrogate pair in readonly editor should not cause input events`
);
} else {
is(
getEventArrayData(events),
getEventArrayData(
// eslint-disable-next-line no-nested-ternary
aTestPerSurrogateKeyPress
? (
allowIllFormedUTF16
? [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842", charCode: 0xD842 },
{ type: "beforeinput", data: "\uD842", inputType: "insertText" },
{ type: "input", data: "\uD842", inputType: "insertText" },
{ type: "keypress", key: "\uDFB7", charCode: 0xDFB7 },
{ type: "beforeinput", data: "\uDFB7", inputType: "insertText" },
{ type: "input", data: "\uDFB7", inputType: "insertText" },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0xD842 },
{ type: "beforeinput", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "input", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "keypress", key: "", charCode: 0xDFB7 },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
)
: [
{ type: "keydown", key: "\uD842\uDFB7", charCode: 0 },
{ type: "keypress", key: "\uD842\uDFB7", charCode: 0x20BB7 },
{ type: "beforeinput", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "input", data: "\uD842\uDFB7", inputType: "insertText" },
{ type: "keyup", key: "\uD842\uDFB7", charCode: 0 },
]
),
`${aDescription}, ${
settingDescription
}, Typing a surrogate pair in editor should cause input events`
);
}
}
await test_typing_surrogate_pair(true, true);
await test_typing_surrogate_pair(true, false);
await test_typing_surrogate_pair(false);
}
doTest(inputField, "<input type=\"text\">", true, false);
await doTest(inputField, "<input type=\"text\">", true, false);
inputField.setAttribute("readonly", "readonly");
doTest(inputField, "<input type=\"text\" readonly>", true, true);
await doTest(inputField, "<input type=\"text\" readonly>", true, true);
doTest(passwordField, "<input type=\"password\">", true, false);
await doTest(passwordField, "<input type=\"password\">", true, false);
passwordField.setAttribute("readonly", "readonly");
doTest(passwordField, "<input type=\"password\" readonly>", true, true);
await doTest(passwordField, "<input type=\"password\" readonly>", true, true);
doTest(textarea, "<textarea>", false, false);
await doTest(textarea, "<textarea>", false, false);
textarea.setAttribute("readonly", "readonly");
doTest(textarea, "<textarea readonly>", false, true);
await doTest(textarea, "<textarea readonly>", false, true);
SpecialPowers.removeSystemEventListener(parentElement, "keypress", listener,
true);

View File

@ -2474,6 +2474,29 @@
value: @IS_NOT_NIGHTLY_BUILD@
mirror: always
# If this pref is set to true, typing a surrogate pair causes one `keypress`
# event whose `charCode` stores the unicode code point over 0xFFFF. This is
# compatible with Safari and Chrome in non-Windows platforms.
# Otherwise, typing a surrogate pair causes two `keypress` events. This is
# compatible with legacy web apps which does
# `String.fromCharCode(event.charCode)`.
- name: dom.event.keypress.dispatch_once_per_surrogate_pair
type: bool
value: false
mirror: always
# This is meaningful only when `dispatch_once_per_surrogate_pair` is false.
# If this pref is set to true, `.key` of the first `keypress` is set to the
# high-surrogate and `.key` of the other is set to the low-surrogate.
# Therefore, setting this exposing ill-formed UTF-16 string with `.key`.
# (And also `InputEvent.data` if pressed in an editable element.)
# Otherwise, `.key` of the first `keypress` is set to the surrogate pair, and
# `.key` of the second `keypress` is set to the empty string.
- name: dom.event.keypress.key.allow_lone_surrogate
type: bool
value: @IS_NOT_EARLY_BETA_OR_EARLIER@
mirror: always
# Whether wheel event target's should be grouped. When enabled, all wheel
# events that occur in a given wheel transaction have the same event target.
- name: dom.event.wheel-event-groups.enabled

View File

@ -452,8 +452,25 @@ function sendChar(aChar, aWindow) {
* key state on US keyboard layout.
*/
function sendString(aStr, aWindow) {
for (var i = 0; i < aStr.length; ++i) {
sendChar(aStr.charAt(i), aWindow);
for (let i = 0; i < aStr.length; ++i) {
// Do not split a surrogate pair to call synthesizeKey. Dispatching two
// sets of keydown and keyup caused by two calls of synthesizeKey is not
// good behavior. It could happen due to a bug, but a surrogate pair should
// be introduced with one key press operation. Therefore, calling it with
// a surrogate pair is the right thing.
// Note that TextEventDispatcher will consider whether a surrogate pair
// should cause one or two keypress events automatically. Therefore, we
// don't need to check the related prefs here.
if (
(aStr.charCodeAt(i) & 0xfc00) == 0xd800 &&
i + 1 < aStr.length &&
(aStr.charCodeAt(i + 1) & 0xfc00) == 0xdc00
) {
sendChar(aStr.substring(i, i + 2));
i++;
} else {
sendChar(aStr.charAt(i), aWindow);
}
}
}

View File

@ -11,6 +11,7 @@
#include "mozilla/Preferences.h"
#include "mozilla/StaticPrefs_dom.h"
#include "nsCharTraits.h"
#include "nsIFrame.h"
#include "nsIWidget.h"
#include "nsPIDOMWindow.h"
@ -637,7 +638,60 @@ bool TextEventDispatcher::DispatchKeyboardEventInternal(
// eKeyPress events are dispatched for every character.
// So, each key value of eKeyPress events should be a character.
if (ch) {
keyEvent.mKeyValue.Assign(ch);
if (!IS_SURROGATE(ch)) {
keyEvent.mKeyValue.Assign(ch);
} else {
const bool isHighSurrogateFollowedByLowSurrogate =
aIndexOfKeypress + 1 < keyEvent.mKeyValue.Length() &&
NS_IS_HIGH_SURROGATE(ch) &&
NS_IS_LOW_SURROGATE(keyEvent.mKeyValue[aIndexOfKeypress + 1]);
const bool isLowSurrogateFollowingHighSurrogate =
!isHighSurrogateFollowedByLowSurrogate && aIndexOfKeypress > 0 &&
NS_IS_LOW_SURROGATE(ch) &&
NS_IS_HIGH_SURROGATE(keyEvent.mKeyValue[aIndexOfKeypress - 1]);
NS_WARNING_ASSERTION(isHighSurrogateFollowedByLowSurrogate ||
isLowSurrogateFollowingHighSurrogate,
"Lone surrogate input should not happen");
if (StaticPrefs::
dom_event_keypress_dispatch_once_per_surrogate_pair()) {
if (isHighSurrogateFollowedByLowSurrogate) {
keyEvent.mKeyValue.Assign(
keyEvent.mKeyValue.BeginReading() + aIndexOfKeypress, 2);
keyEvent.SetCharCode(
SURROGATE_TO_UCS4(ch, keyEvent.mKeyValue[1]));
} else if (isLowSurrogateFollowingHighSurrogate) {
// Although not dispatching eKeyPress event (because it's already
// dispatched for the low surrogate above), the caller should
// treat that this dispatched eKeyPress event normally so that
// return true here.
return true;
}
// Do not expose ill-formed UTF-16 string because it's a
// problematic for Rust-running-as-wasm for example.
else {
keyEvent.mKeyValue.Truncate();
}
} else if (!StaticPrefs::
dom_event_keypress_key_allow_lone_surrogate()) {
// If it's a high surrogate followed by a low surrogate, we should
// expose the surrogate pair with .key value.
if (isHighSurrogateFollowedByLowSurrogate) {
keyEvent.mKeyValue.Assign(
keyEvent.mKeyValue.BeginReading() + aIndexOfKeypress, 2);
}
// Do not expose low surrogate which should be handled by the
// preceding keypress event. And also do not expose ill-formed
// UTF-16 because it's a problematic for Rust-running-as-wasm for
// example.
else {
keyEvent.mKeyValue.Truncate();
}
} else {
// Here is a path for traditional behavior. We set `.key` to
// high-surrogate and low-surrogate separately.
keyEvent.mKeyValue.Assign(ch);
}
}
} else {
keyEvent.mKeyValue.Truncate();
}

View File

@ -115,6 +115,8 @@ skip-if = toolkit != "cocoa" # Cocoa widget test
[test_standalone_native_menu.xhtml]
skip-if = toolkit != "cocoa" # Cocoa widget test
support-files = standalone_native_menu_window.xhtml
[test_surrogate_pair_native_key_handling.xhtml]
skip-if = toolkit != "windows" # Windows widget test
[test_system_font_changes.xhtml]
support-files = system_font_changes.xhtml
run-if = toolkit == 'gtk' # Currently the test works on only gtk3

View File

@ -0,0 +1,178 @@
<?xml version="1.0"?>
<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
<window id="window1" title="Test handling of native key input for surrogate pairs"
xmlns:html="http://www.w3.org/1999/xhtml"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/>
<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/>
<script src="chrome://mochikit/content/tests/SimpleTest/NativeKeyCodes.js"/>
<!-- test results are displayed in the html:body -->
<body xmlns="http://www.w3.org/1999/xhtml">
<p id="display"></p>
<div id="content" style="display: none"></div>
<pre id="test"></pre>
</body>
<script type="application/javascript"><![CDATA[
SimpleTest.waitForExplicitFinish();
SimpleTest.waitForFocus(async () => {
function promiseSynthesizeNativeKey(
aNativeKeyCode,
aChars,
) {
return new Promise(resolve => {
synthesizeNativeKey(
KEYBOARD_LAYOUT_EN_US,
aNativeKeyCode,
{},
aChars,
aChars,
resolve
);
});
}
function getEventData(aEvent) {
return `{ type: "${aEvent.type}", key: "${aEvent.key}", code: "${
aEvent.code
}", keyCode: 0x${
aEvent.keyCode.toString(16).toUpperCase()
}, charCode: 0x${aEvent.charCode.toString(16).toUpperCase()} }`;
}
function getEventArrayData(aEvents) {
if (!aEvents.length) {
return "[]";
}
let result = "[\n";
for (const e of aEvents) {
result += ` ${getEventData(e)}\n`;
}
return result + "]";
}
let events = [];
function onKey(aEvent) {
events.push(aEvent);
}
window.addEventListener("keydown", onKey);
window.addEventListener("keypress", onKey);
window.addEventListener("keyup", onKey);
async function runTests(
aTestPerSurrogateKeyPress,
aTestIllFormedUTF16KeyValue = false
) {
await SpecialPowers.pushPrefEnv({
set: [
["dom.event.keypress.dispatch_once_per_surrogate_pair", !aTestPerSurrogateKeyPress],
["dom.event.keypress.key.allow_lone_surrogate", aTestIllFormedUTF16KeyValue],
],
});
const settingDescription =
`aTestPerSurrogateKeyPress=${
aTestPerSurrogateKeyPress
}, aTestIllFormedUTF16KeyValue=${aTestIllFormedUTF16KeyValue}`;
const allowIllFormedUTF16 =
aTestPerSurrogateKeyPress && aTestIllFormedUTF16KeyValue;
// If the keyboard layout has a key to introduce a surrogate pair,
// one set of WM_KEYDOWN and WM_KEYUP are generated and it's translated
// to two WM_CHARs.
await (async function test_one_key_press() {
events = [];
await promiseSynthesizeNativeKey(WIN_VK_A, "\uD83E\uDD14");
const keyCodeA = "A".charCodeAt(0);
is(
getEventArrayData(events),
getEventArrayData(
// eslint-disable-next-line no-nested-ternary
aTestPerSurrogateKeyPress
? (
allowIllFormedUTF16
? [
{ type: "keydown", key: "\uD83E\uDD14", code: "KeyA", keyCode: keyCodeA, charCode: 0 },
{ type: "keypress", key: "\uD83E", code: "KeyA", keyCode: 0, charCode: 0xD83E },
{ type: "keypress", key: "\uDD14", code: "KeyA", keyCode: 0, charCode: 0xDD14 },
{ type: "keyup", key: "a", code: "KeyA", keyCode: keyCodeA, charCode: 0 }, // Cannot set .key properly without a hack
]
: [
{ type: "keydown", key: "\uD83E\uDD14", code: "KeyA", keyCode: keyCodeA, charCode: 0 },
{ type: "keypress", key: "\uD83E\uDD14", code: "KeyA", keyCode: 0, charCode: 0xD83E },
{ type: "keypress", key: "", code: "KeyA", keyCode: 0, charCode: 0xDD14 },
{ type: "keyup", key: "a", code: "KeyA", keyCode: keyCodeA, charCode: 0 }, // Cannot set .key properly without a hack
]
)
: [
{ type: "keydown", key: "\uD83E\uDD14", code: "KeyA", keyCode: keyCodeA, charCode: 0 },
{ type: "keypress", key: "\uD83E\uDD14", code: "KeyA", keyCode: 0, charCode: 0x1F914 },
{ type: "keyup", key: "a", code: "KeyA", keyCode: keyCodeA, charCode: 0 }, // Cannot set .key properly without a hack
]
),
`test_one_key_press(${
settingDescription
}): Typing surrogate pair should cause one set of keydown and keyup events with ${
aTestPerSurrogateKeyPress ? "2 keypress events" : "a keypress event"
}`
);
})();
// If a surrogate pair is sent with SendInput API, 2 sets of keyboard
// events are generated. E.g., Emojis in the touch keyboard on Win10 or
// later.
await (async function test_send_input() {
events = [];
// Virtual keycode for the WM_KEYDOWN/WM_KEYUP is VK_PACKET.
await promiseSynthesizeNativeKey(WIN_VK_PACKET, "\uD83E");
await promiseSynthesizeNativeKey(WIN_VK_PACKET, "\uDD14");
// WM_KEYDOWN, WM_CHAR and WM_KEYUP for the high surrogate input
// shouldn't cause DOM events.
is(
getEventArrayData(events),
getEventArrayData(
// eslint-disable-next-line no-nested-ternary
aTestPerSurrogateKeyPress
? (
allowIllFormedUTF16
? [
{ type: "keydown", key: "\uD83E\uDD14", code: "", keyCode: 0, charCode: 0 },
{ type: "keypress", key: "\uD83E", code: "", keyCode: 0, charCode: 0xD83E },
{ type: "keypress", key: "\uDD14", code: "", keyCode: 0, charCode: 0xDD14 },
{ type: "keyup", key: "", code: "", keyCode: 0, charCode: 0 }, // Cannot set .key properly without a hack
]
: [
{ type: "keydown", key: "\uD83E\uDD14", code: "", keyCode: 0, charCode: 0 },
{ type: "keypress", key: "\uD83E\uDD14", code: "", keyCode: 0, charCode: 0xD83E },
{ type: "keypress", key: "", code: "", keyCode: 0, charCode: 0xDD14 },
{ type: "keyup", key: "", code: "", keyCode: 0, charCode: 0 }, // Cannot set .key properly without a hack
]
)
: [
{ type: "keydown", key: "\uD83E\uDD14", code: "", keyCode: 0, charCode: 0 },
{ type: "keypress", key: "\uD83E\uDD14", code: "", keyCode: 0, charCode: 0x1F914 },
{ type: "keyup", key: "", code: "", keyCode: 0, charCode: 0 }, // Cannot set .key properly without a hack
]
),
`test_send_input(${
settingDescription
}): Inputting surrogate pair should cause one set of keydown and keyup events ${
aTestPerSurrogateKeyPress ? "2 keypress events" : "a keypress event"
}`
);
})();
}
await runTests(true, true);
await runTests(true, false);
await runTests(false);
window.removeEventListener("keydown", onKey);
window.removeEventListener("keypress", onKey);
window.removeEventListener("keyup", onKey);
SimpleTest.finish();
});
]]></script>
</window>

View File

@ -33,6 +33,7 @@
#include "npapi.h"
#include <windows.h>
#include <winnls.h>
#include <winuser.h>
#include <algorithm>
@ -1244,6 +1245,7 @@ NativeKey* NativeKey::sLatestInstance = nullptr;
const MSG NativeKey::sEmptyMSG = {};
MSG NativeKey::sLastKeyOrCharMSG = {};
MSG NativeKey::sLastKeyMSG = {};
char16_t NativeKey::sPendingHighSurrogate = 0;
NativeKey::NativeKey(nsWindow* aWidget, const MSG& aMessage,
const ModifierKeyState& aModKeyState,
@ -1413,11 +1415,14 @@ void NativeKey::InitIsSkippableForKeyOrChar(const MSG& aLastKeyMSG) {
void NativeKey::InitWithKeyOrChar() {
MSG lastKeyMSG = sLastKeyMSG;
char16_t pendingHighSurrogate = sPendingHighSurrogate;
mScanCode = WinUtils::GetScanCode(mMsg.lParam);
mIsExtended = WinUtils::IsExtendedScanCode(mMsg.lParam);
switch (mMsg.message) {
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
sPendingHighSurrogate = 0;
[[fallthrough]];
case WM_KEYUP:
case WM_SYSKEYUP: {
// Modify sLastKeyMSG now since retrieving following char messages may
@ -1527,6 +1532,7 @@ void NativeKey::InitWithKeyOrChar() {
case WM_CHAR:
case WM_UNICHAR:
case WM_SYSCHAR:
sPendingHighSurrogate = 0;
// If there is another instance and it is trying to remove a char message
// from the queue, this message should be handled in the old instance.
if (IsAnotherInstanceRemovingCharMessage()) {
@ -1602,6 +1608,55 @@ void NativeKey::InitWithKeyOrChar() {
Unused << NS_WARN_IF(charMsg.hwnd != mMsg.hwnd);
mFollowingCharMsgs.AppendElement(charMsg);
}
if (mFollowingCharMsgs.Length() == 1) {
// If we receive a keydown message for a high-surrogate, a low-surrogate
// keydown message **will** and should follow it. We cannot translate the
// following WM_KEYDOWN message for the low-surrogate right now since
// it's not yet queued into the message queue yet. Therefore, we need to
// wait next one to dispatch keypress event with setting its `.key` value
// to a surrogate pair rather than setting it to a lone surrogate.
// FYI: This may happen with typing a non-BMP character on the touch
// keyboard on Windows 10 or later except when an IME is installed. (If
// IME is installed, composition is used instead.)
if (IS_HIGH_SURROGATE(mFollowingCharMsgs[0].wParam)) {
if (pendingHighSurrogate) {
MOZ_LOG(gKeyLog, LogLevel::Warning,
("%p NativeKey::InitWithKeyOrChar(), there is pending "
"high surrogate input, but received another high surrogate "
"input. The previous one is discarded",
this));
}
sPendingHighSurrogate = mFollowingCharMsgs[0].wParam;
mFollowingCharMsgs.Clear();
} else if (IS_LOW_SURROGATE(mFollowingCharMsgs[0].wParam)) {
// If we stopped dispathing a keypress event for a preceding
// high-surrogate, treat this keydown (for a low-surrogate) as
// introducing both the high surrogate and the low surrogate.
if (pendingHighSurrogate) {
MSG charMsg = mFollowingCharMsgs[0];
mFollowingCharMsgs[0].wParam = pendingHighSurrogate;
mFollowingCharMsgs.AppendElement(std::move(charMsg));
} else {
MOZ_LOG(
gKeyLog, LogLevel::Warning,
("%p NativeKey::InitWithKeyOrChar(), there is no pending high "
"surrogate input, but received lone low surrogate input",
this));
}
} else {
MOZ_LOG(gKeyLog, LogLevel::Warning,
("%p NativeKey::InitWithKeyOrChar(), there is pending "
"high surrogate input, but received non-surrogate input. "
"The high surrogate input is discarded",
this));
}
} else {
MOZ_LOG(gKeyLog, LogLevel::Warning,
("%p NativeKey::InitWithKeyOrChar(), there is pending "
"high surrogate input, but received 2 or more character input. "
"The high surrogate input is discarded",
this));
}
}
keyboardLayout->InitNativeKey(*this);
@ -2418,6 +2473,18 @@ bool NativeKey::HandleKeyDownMessage(bool* aEventDispatched) const {
return false;
}
if (sPendingHighSurrogate) {
MOZ_LOG(gKeyLog, LogLevel::Info,
("%p NativeKey::HandleKeyDownMessage(), doesn't dispatch keydown "
"event because the key introduced only a high surrotate, so we "
"should wait the following low surrogate input",
this));
if (RedirectedKeyDownMessageManager::IsRedirectedMessage(mMsg)) {
RedirectedKeyDownMessageManager::Forget();
}
return false;
}
// If the widget has gone, we should do nothing.
if (mWidget->Destroyed()) {
MOZ_LOG(
@ -2778,6 +2845,15 @@ bool NativeKey::HandleKeyUpMessage(bool* aEventDispatched) const {
return false;
}
if (sPendingHighSurrogate) {
MOZ_LOG(gKeyLog, LogLevel::Info,
("%p NativeKey::HandleKeyUpMessage(), doesn't dispatch keyup "
"event because the key introduced only a high surrotate, so we "
"should wait the following low surrogate input",
this));
return false;
}
// If the widget has gone, we should do nothing.
if (mWidget->Destroyed()) {
MOZ_LOG(

View File

@ -787,6 +787,11 @@ class MOZ_STACK_CLASS NativeKey final {
static MSG sLastKeyMSG;
// Set to non-zero if we receive a WM_KEYDOWN message which introduces only
// a high surrogate. Then, it'll be cleared when next keydown or char message
// is received.
static char16_t sPendingHighSurrogate;
static bool IsEmptyMSG(const MSG& aMSG) {
return !memcmp(&aMSG, &sEmptyMSG, sizeof(MSG));
}