Bug 1940377 - part 1: Make WhiteSpaceVisibilityKeeper::InsertTextOrInsertOrUpdateCompositionString support blink compatible white-space sequence r=m_kato

First, it makes it deletes invisible white-spaces at the insertion point.
This makes the further processing simpler.

Second, it extends the range to update in `Text` which will contain the
insertion string when it's not used for inserting nor updating composition
string, as containing all surrounding white-spaces at the insertion point.

Next, it normalizes the inserting string with the surrounding white-spaces with
the same rules as the other browsers especially as Chrome.
1. a collapsible white-space at start or end of a `Text` is always an NBSP.
2. a collapsible white-space immediately before or after a preformatted line
break is always an NBSP.
3. a collapsible white-space surrounded by non-collapsible chars is always an
ASCII white-space.
4. collapsible white-spaces are pairs of an NBSP and an ASCII white-space.

Then, we can make `HTMLEditor::OnEndHandlingTopLevelEditSubActionInternal()`
stop normalizing white-spaces when inserting text.

Differential Revision: https://phabricator.services.mozilla.com/D239463
This commit is contained in:
Masayuki Nakano
2025-03-08 00:23:14 +00:00
parent c0f2fac503
commit f7f5dd6a64
13 changed files with 1504 additions and 670 deletions

View File

@@ -35,6 +35,7 @@
#include "mozilla/Maybe.h"
#include "mozilla/OwningNonNull.h"
#include "mozilla/PresShell.h"
#include "mozilla/StaticPrefs_editor.h"
#include "mozilla/TextComposition.h"
#include "mozilla/UniquePtr.h"
#include "mozilla/Unused.h"
@@ -552,45 +553,47 @@ nsresult HTMLEditor::OnEndHandlingTopLevelEditSubActionInternal() {
// attempt to transform any unneeded nbsp's into spaces after doing various
// operations
switch (GetTopLevelEditSubAction()) {
case EditSubAction::eDeleteSelectedContent:
if (TopLevelEditSubActionDataRef().mDidNormalizeWhitespaces) {
break;
}
[[fallthrough]];
case EditSubAction::eInsertText:
case EditSubAction::eInsertTextComingFromIME:
case EditSubAction::eInsertLineBreak:
case EditSubAction::eInsertParagraphSeparator:
case EditSubAction::ePasteHTMLContent:
case EditSubAction::eInsertHTMLSource: {
// Due to the replacement of white-spaces in
// WhiteSpaceVisibilityKeeper::NormalizeVisibleWhiteSpacesAt(),
// selection ranges may be changed since DOM ranges track the DOM
// mutation by themselves. However, we want to keep selection as-is.
// Therefore, we should restore `Selection` after replacing
// white-spaces.
AutoSelectionRestorer restoreSelection(this);
// TODO: Temporarily, WhiteSpaceVisibilityKeeper replaces ASCII
// white-spaces with NPSPs and then, we'll replace them with ASCII
// white-spaces here. We should avoid this overwriting things as
// far as possible because replacing characters in text nodes
// causes running mutation event listeners which are really
// expensive.
// Adjust end of composition string if there is composition string.
auto pointToAdjust = GetLastIMESelectionEndPoint<EditorDOMPoint>();
if (!pointToAdjust.IsInContentNode()) {
// Otherwise, adjust current selection start point.
pointToAdjust = GetFirstSelectionStartPoint<EditorDOMPoint>();
if (NS_WARN_IF(!pointToAdjust.IsInContentNode())) {
return NS_ERROR_FAILURE;
}
}
const RefPtr<Element> editingHost =
ComputeEditingHost(LimitInBodyElement::No);
if (MOZ_UNLIKELY(!editingHost)) {
break;
const bool needToNormalizeWhiteSpaces = [&]() {
switch (GetTopLevelEditSubAction()) {
case EditSubAction::eDeleteSelectedContent:
return !TopLevelEditSubActionDataRef().mDidNormalizeWhitespaces;
case EditSubAction::eInsertText:
case EditSubAction::eInsertTextComingFromIME:
return !StaticPrefs::
editor_white_space_normalization_blink_compatible();
case EditSubAction::eInsertLineBreak:
case EditSubAction::eInsertParagraphSeparator:
case EditSubAction::ePasteHTMLContent:
case EditSubAction::eInsertHTMLSource:
return true;
default:
return false;
}
}();
if (needToNormalizeWhiteSpaces) {
// Due to the replacement of white-spaces in
// WhiteSpaceVisibilityKeeper::NormalizeVisibleWhiteSpacesAt(), selection
// ranges may be changed since DOM ranges track the DOM mutation by
// themselves. However, we want to keep selection as-is. Therefore, we
// should restore `Selection` after replacing white-spaces.
AutoSelectionRestorer restoreSelection(this);
// TODO: Temporarily, WhiteSpaceVisibilityKeeper replaces ASCII
// white-spaces with NPSPs and then, we'll replace them with ASCII
// white-spaces here. We should avoid this overwriting things as
// far as possible because replacing characters in text nodes
// causes running mutation event listeners which are really
// expensive.
// Adjust end of composition string if there is composition string.
auto pointToAdjust = GetLastIMESelectionEndPoint<EditorDOMPoint>();
if (!pointToAdjust.IsInContentNode()) {
// Otherwise, adjust current selection start point.
pointToAdjust = GetFirstSelectionStartPoint<EditorDOMPoint>();
if (NS_WARN_IF(!pointToAdjust.IsInContentNode())) {
return NS_ERROR_FAILURE;
}
}
if (const RefPtr<Element> editingHost =
ComputeEditingHost(LimitInBodyElement::No)) {
if (EditorUtils::IsEditableContent(
*pointToAdjust.ContainerAs<nsIContent>(), EditorType::HTML)) {
AutoTrackDOMPoint trackPointToAdjust(RangeUpdaterRef(),
@@ -654,10 +657,7 @@ nsresult HTMLEditor::OnEndHandlingTopLevelEditSubActionInternal() {
"WhiteSpaceVisibilityKeeper::NormalizeVisibleWhiteSpacesAt() "
"failed, but ignored");
}
break;
}
default:
break;
}
// Adjust selection for insert text, html paste, and delete actions if
@@ -1236,7 +1236,8 @@ Result<EditActionResult, nsresult> HTMLEditor::HandleInsertText(
*this, aInsertionString,
compositionEndPoint.IsSet()
? EditorDOMRange(pointToInsert, compositionEndPoint)
: EditorDOMRange(pointToInsert));
: EditorDOMRange(pointToInsert),
aPurpose);
if (MOZ_UNLIKELY(replaceTextResult.isErr())) {
NS_WARNING("WhiteSpaceVisibilityKeeper::ReplaceText() failed");
return replaceTextResult.propagateErr();
@@ -2948,13 +2949,86 @@ HTMLEditor::GetInclusiveNextCharPointDataForNormalizingWhiteSpaces(
HTMLEditor::GetCharPointType(nextCharPoint));
}
// static
void HTMLEditor::NormalizeAllWhiteSpaceSequences(
nsString& aResult, const CharPointData& aPreviousCharPointData,
const CharPointData& aNextCharPointData, Linefeed aLinefeed) {
MOZ_ASSERT(!aResult.IsEmpty());
const auto IsCollapsibleChar = [&](char16_t aChar) {
if (aChar == HTMLEditUtils::kNewLine) {
return aLinefeed == Linefeed::Preformatted;
}
return nsCRT::IsAsciiSpace(aChar);
};
const auto IsCollapsibleCharOrNBSP = [&](char16_t aChar) {
return aChar == HTMLEditUtils::kNBSP || IsCollapsibleChar(aChar);
};
const uint32_t length = aResult.Length();
for (uint32_t offset = 0; offset < length; offset++) {
const char16_t ch = aResult[offset];
if (!IsCollapsibleCharOrNBSP(ch)) {
continue;
}
const CharPointData previousCharData = [&]() {
if (offset) {
const char16_t prevChar = aResult[offset - 1u];
return CharPointData::InSameTextNode(
prevChar == HTMLEditUtils::kNewLine
? CharPointType::PreformattedLineBreak
: CharPointType::VisibleChar);
}
return aPreviousCharPointData;
}();
const uint32_t endOffset = [&]() {
for (const uint32_t i : IntegerRange(offset, length)) {
if (IsCollapsibleCharOrNBSP(aResult[i])) {
continue;
}
return i;
}
return length;
}();
const CharPointData nextCharData = [&]() {
if (endOffset < length) {
const char16_t nextChar = aResult[endOffset];
return CharPointData::InSameTextNode(
nextChar == HTMLEditUtils::kNewLine
? CharPointType::PreformattedLineBreak
: CharPointType::VisibleChar);
}
return aNextCharPointData;
}();
HTMLEditor::ReplaceStringWithNormalizedWhiteSpaceSequence(
aResult, offset, endOffset - offset, previousCharData, nextCharData);
offset = endOffset;
}
}
// static
void HTMLEditor::GenerateWhiteSpaceSequence(
nsAString& aResult, uint32_t aLength,
nsString& aResult, uint32_t aLength,
const CharPointData& aPreviousCharPointData,
const CharPointData& aNextCharPointData) {
MOZ_ASSERT(aResult.IsEmpty());
MOZ_ASSERT(aLength);
aResult.SetLength(aLength);
HTMLEditor::ReplaceStringWithNormalizedWhiteSpaceSequence(
aResult, 0u, aLength, aPreviousCharPointData, aNextCharPointData);
}
// static
void HTMLEditor::ReplaceStringWithNormalizedWhiteSpaceSequence(
nsString& aResult, uint32_t aOffset, uint32_t aLength,
const CharPointData& aPreviousCharPointData,
const CharPointData& aNextCharPointData) {
MOZ_ASSERT(!aResult.IsEmpty());
MOZ_ASSERT(aLength);
MOZ_ASSERT(aOffset < aResult.Length());
MOZ_ASSERT(aOffset + aLength <= aResult.Length());
// For now, this method does not assume that result will be append to
// white-space sequence in the text node.
MOZ_ASSERT(aPreviousCharPointData.AcrossTextNodeBoundary() ||
@@ -2971,38 +3045,39 @@ void HTMLEditor::GenerateWhiteSpaceSequence(
// without preformatted style. However, Chrome has same issue too.
if (aPreviousCharPointData.Type() == CharPointType::VisibleChar &&
aNextCharPointData.Type() == CharPointType::VisibleChar) {
aResult.Assign(HTMLEditUtils::kSpace);
aResult.SetCharAt(HTMLEditUtils::kSpace, aOffset);
return;
}
// If it's start or end of text, put an NBSP.
if (aPreviousCharPointData.Type() == CharPointType::TextEnd ||
aNextCharPointData.Type() == CharPointType::TextEnd) {
aResult.Assign(HTMLEditUtils::kNBSP);
aResult.SetCharAt(HTMLEditUtils::kNBSP, aOffset);
return;
}
// If the character is next to a preformatted linefeed, we need to put
// an NBSP for avoiding collapsed into the linefeed.
if (aPreviousCharPointData.Type() == CharPointType::PreformattedLineBreak ||
aNextCharPointData.Type() == CharPointType::PreformattedLineBreak) {
aResult.Assign(HTMLEditUtils::kNBSP);
aResult.SetCharAt(HTMLEditUtils::kNBSP, aOffset);
return;
}
// Now, the white-space will be inserted to a white-space sequence, but not
// end of text. We can put an ASCII white-space only when both sides are
// not ASCII white-spaces.
aResult.Assign(
aResult.SetCharAt(
aPreviousCharPointData.Type() == CharPointType::ASCIIWhiteSpace ||
aNextCharPointData.Type() == CharPointType::ASCIIWhiteSpace
? HTMLEditUtils::kNBSP
: HTMLEditUtils::kSpace);
: HTMLEditUtils::kSpace,
aOffset);
return;
}
// Generate pairs of NBSP and ASCII white-space.
aResult.SetLength(aLength);
bool appendNBSP = true; // Basically, starts with an NBSP.
char16_t* lastChar = aResult.EndWriting() - 1;
for (char16_t* iter = aResult.BeginWriting(); iter != lastChar; iter++) {
char16_t* const lastChar = aResult.BeginWriting() + aOffset + aLength - 1;
for (char16_t* iter = aResult.BeginWriting() + aOffset; iter != lastChar;
iter++) {
*iter = appendNBSP ? HTMLEditUtils::kNBSP : HTMLEditUtils::kSpace;
appendNBSP = !appendNBSP;
}
@@ -3023,10 +3098,142 @@ void HTMLEditor::GenerateWhiteSpaceSequence(
: HTMLEditUtils::kSpace;
}
HTMLEditor::NormalizedStringToInsertText
HTMLEditor::NormalizeWhiteSpacesToInsertText(
const EditorDOMPoint& aPointToInsert, const nsAString& aStringToInsert,
NormalizeSurroundingWhiteSpaces aNormalizeSurroundingWhiteSpaces) const {
MOZ_ASSERT(aPointToInsert.IsSet());
// If white-spaces are preformatted, we don't need to normalize white-spaces.
if (EditorUtils::IsWhiteSpacePreformatted(
*aPointToInsert.ContainerAs<nsIContent>())) {
return NormalizedStringToInsertText(aStringToInsert, aPointToInsert);
}
Text* const textNode = aPointToInsert.GetContainerAs<Text>();
const nsTextFragment* const textFragment =
textNode ? &textNode->TextFragment() : nullptr;
const bool isNewLineCollapsible = !EditorUtils::IsNewLinePreformatted(
*aPointToInsert.ContainerAs<nsIContent>());
// We don't want to make invisible things visible with this normalization.
// Therefore, we need to know whether there are invisible leading and/or
// trailing white-spaces in the `Text`.
// Then, compute visible white-space length before/after the insertion point.
// Note that these lengths may contain invisible white-spaces.
const uint32_t precedingWhiteSpaceLength = [&]() {
if (!textNode || !aNormalizeSurroundingWhiteSpaces ||
aPointToInsert.IsStartOfContainer()) {
return 0u;
}
const auto nonWhiteSpaceOffset =
HTMLEditUtils::GetPreviousNonCollapsibleCharOffset(
*textNode, aPointToInsert.Offset(),
{HTMLEditUtils::WalkTextOption::TreatNBSPsCollapsible});
const uint32_t firstWhiteSpaceOffset =
nonWhiteSpaceOffset ? *nonWhiteSpaceOffset + 1u : 0u;
return aPointToInsert.Offset() - firstWhiteSpaceOffset;
}();
const uint32_t followingWhiteSpaceLength = [&]() {
if (!textNode || !aNormalizeSurroundingWhiteSpaces ||
aPointToInsert.IsEndOfContainer()) {
return 0u;
}
MOZ_ASSERT(textFragment);
const auto nonWhiteSpaceOffset =
HTMLEditUtils::GetInclusiveNextNonCollapsibleCharOffset(
*textNode, aPointToInsert.Offset(),
{HTMLEditUtils::WalkTextOption::TreatNBSPsCollapsible});
MOZ_ASSERT(nonWhiteSpaceOffset.valueOr(textFragment->GetLength()) >=
aPointToInsert.Offset());
return nonWhiteSpaceOffset.valueOr(textFragment->GetLength()) -
aPointToInsert.Offset();
}();
// Now, we can know invisible white-space length in precedingWhiteSpaceLength
// and followingWhiteSpaceLength.
const uint32_t precedingInvisibleWhiteSpaceCount =
textNode
? HTMLEditUtils::GetInvisibleWhiteSpaceCount(
*textNode, aPointToInsert.Offset() - precedingWhiteSpaceLength,
precedingWhiteSpaceLength)
: 0u;
MOZ_ASSERT(precedingWhiteSpaceLength >= precedingInvisibleWhiteSpaceCount);
const uint32_t newPrecedingWhiteSpaceLength =
precedingWhiteSpaceLength - precedingInvisibleWhiteSpaceCount;
const uint32_t followingInvisibleSpaceCount =
textNode
? HTMLEditUtils::GetInvisibleWhiteSpaceCount(
*textNode, aPointToInsert.Offset(), followingWhiteSpaceLength)
: 0u;
MOZ_ASSERT(followingWhiteSpaceLength >= followingInvisibleSpaceCount);
const uint32_t newFollowingWhiteSpaceLength =
followingWhiteSpaceLength - followingInvisibleSpaceCount;
const nsAutoString stringToInsertWithSurroundingSpaces =
[&]() -> nsAutoString {
if (!newPrecedingWhiteSpaceLength && !newFollowingWhiteSpaceLength) {
return nsAutoString(aStringToInsert);
}
nsAutoString str;
str.SetCapacity(aStringToInsert.Length() + newPrecedingWhiteSpaceLength +
newFollowingWhiteSpaceLength);
for ([[maybe_unused]] auto unused :
IntegerRange(newPrecedingWhiteSpaceLength)) {
str.Append(' ');
}
str.Append(aStringToInsert);
for ([[maybe_unused]] auto unused :
IntegerRange(newFollowingWhiteSpaceLength)) {
str.Append(' ');
}
return str;
}();
const uint32_t insertionOffsetInTextNode =
aPointToInsert.IsInTextNode() ? aPointToInsert.Offset() : 0u;
NormalizedStringToInsertText result(
stringToInsertWithSurroundingSpaces, insertionOffsetInTextNode,
insertionOffsetInTextNode - precedingWhiteSpaceLength, // replace start
precedingWhiteSpaceLength + followingWhiteSpaceLength, // replace length
newPrecedingWhiteSpaceLength, newFollowingWhiteSpaceLength);
// Now, normalize the inserting string.
// Note that if the caller does not want to normalize the following
// white-spaces, we always need to guarantee that neither the first character
// nor the last character of the insertion string is not collapsible, i.e., if
// each one is a collapsible white-space, we need to replace them an NBSP to
// keep the visibility of the collapsible white-spaces. Therefore, if
// aNormalizeSurroundingWhiteSpaces is "No", we need to treat the insertion
// string is the only characters in the `Text`.
HTMLEditor::NormalizeAllWhiteSpaceSequences(
result.mNormalizedString,
CharPointData::InSameTextNode(
!textFragment || !result.mReplaceStartOffset ||
!aNormalizeSurroundingWhiteSpaces
? CharPointType::TextEnd
: (textFragment->CharAt(result.mReplaceStartOffset - 1u) ==
HTMLEditUtils::kNewLine
? CharPointType::PreformattedLineBreak
: CharPointType::VisibleChar)),
CharPointData::InSameTextNode(
!textFragment ||
result.mReplaceEndOffset >= textFragment->GetLength() ||
!aNormalizeSurroundingWhiteSpaces
? CharPointType::TextEnd
: (textFragment->CharAt(result.mReplaceEndOffset) ==
HTMLEditUtils::kNewLine
? CharPointType::PreformattedLineBreak
: CharPointType::VisibleChar)),
isNewLineCollapsible ? Linefeed::Collapsible : Linefeed::Preformatted);
return result;
}
void HTMLEditor::ExtendRangeToDeleteWithNormalizingWhiteSpaces(
EditorDOMPointInText& aStartToDelete, EditorDOMPointInText& aEndToDelete,
nsAString& aNormalizedWhiteSpacesInStartNode,
nsAString& aNormalizedWhiteSpacesInEndNode) const {
nsString& aNormalizedWhiteSpacesInStartNode,
nsString& aNormalizedWhiteSpacesInEndNode) const {
MOZ_ASSERT(aStartToDelete.IsSetAndValid());
MOZ_ASSERT(aEndToDelete.IsSetAndValid());
MOZ_ASSERT(aStartToDelete.EqualsOrIsBefore(aEndToDelete));