blob: e66b7394c4cecd8635d9e789c88e1e54fb604d18 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "third_party/blink/renderer/core/editing/suggestion/text_suggestion_controller.h"
#include "services/service_manager/public/cpp/interface_provider.h"
#include "third_party/blink/renderer/core/clipboard/data_transfer_access_policy.h"
#include "third_party/blink/renderer/core/editing/editing_utilities.h"
#include "third_party/blink/renderer/core/editing/editor.h"
#include "third_party/blink/renderer/core/editing/ephemeral_range.h"
#include "third_party/blink/renderer/core/editing/frame_selection.h"
#include "third_party/blink/renderer/core/editing/markers/document_marker_controller.h"
#include "third_party/blink/renderer/core/editing/markers/spell_check_marker.h"
#include "third_party/blink/renderer/core/editing/markers/suggestion_marker.h"
#include "third_party/blink/renderer/core/editing/markers/suggestion_marker_replacement_scope.h"
#include "third_party/blink/renderer/core/editing/plain_text_range.h"
#include "third_party/blink/renderer/core/editing/position.h"
#include "third_party/blink/renderer/core/editing/selection_template.h"
#include "third_party/blink/renderer/core/editing/spellcheck/spell_checker.h"
#include "third_party/blink/renderer/core/editing/suggestion/text_suggestion_info.h"
#include "third_party/blink/renderer/core/frame/frame_view.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/settings.h"
#include "third_party/blink/renderer/core/layout/layout_theme.h"
namespace blink {
namespace {
bool ShouldDeleteNextCharacter(const Node& marker_text_node,
const DocumentMarker& marker) {
// If the character immediately following the range to be deleted is a space,
// delete it if either of these conditions holds:
// - We're deleting at the beginning of the editable text (to avoid ending up
// with a space at the beginning)
// - The character immediately before the range being deleted is also a space
// (to avoid ending up with two adjacent spaces)
const EphemeralRange next_character_range =
PlainTextRange(marker.EndOffset(), marker.EndOffset() + 1)
.CreateRange(*marker_text_node.parentNode());
// No character immediately following the range (so it can't be a space)
if (next_character_range.IsNull())
return false;
const String next_character_str =
PlainText(next_character_range, TextIteratorBehavior::Builder().Build());
const UChar next_character = next_character_str[0];
// Character immediately following the range is not a space
if (next_character != kSpaceCharacter &&
next_character != kNoBreakSpaceCharacter)
return false;
// First case: we're deleting at the beginning of the editable text
if (marker.StartOffset() == 0)
return true;
const EphemeralRange prev_character_range =
PlainTextRange(marker.StartOffset() - 1, marker.StartOffset())
.CreateRange(*marker_text_node.parentNode());
// Not at beginning, but there's no character immediately before the range
// being deleted (so it can't be a space)
if (prev_character_range.IsNull())
return false;
const String prev_character_str =
PlainText(prev_character_range, TextIteratorBehavior::Builder().Build());
// Return true if the character immediately before the range is a space, false
// otherwise
const UChar prev_character = prev_character_str[0];
return prev_character == kSpaceCharacter ||
prev_character == kNoBreakSpaceCharacter;
}
EphemeralRangeInFlatTree ComputeRangeSurroundingCaret(
const PositionInFlatTree& caret_position) {
const Node* const position_node = caret_position.ComputeContainerNode();
const bool is_text_node = position_node->IsTextNode();
const unsigned position_offset_in_node =
caret_position.ComputeOffsetInContainerNode();
// If we're in the interior of a text node, we can avoid calling
// PreviousPositionOf/NextPositionOf for better efficiency.
if (is_text_node && position_offset_in_node != 0 &&
position_offset_in_node != ToText(position_node)->length()) {
return EphemeralRangeInFlatTree(
PositionInFlatTree(position_node, position_offset_in_node - 1),
PositionInFlatTree(position_node, position_offset_in_node + 1));
}
const PositionInFlatTree& previous_position =
PreviousPositionOf(caret_position, PositionMoveType::kGraphemeCluster);
const PositionInFlatTree& next_position =
NextPositionOf(caret_position, PositionMoveType::kGraphemeCluster);
return EphemeralRangeInFlatTree(
previous_position.IsNull() ? caret_position : previous_position,
next_position.IsNull() ? caret_position : next_position);
}
struct SuggestionInfosWithNodeAndHighlightColor {
STACK_ALLOCATED();
Persistent<Node> text_node;
Color highlight_color;
Vector<TextSuggestionInfo> suggestion_infos;
};
SuggestionInfosWithNodeAndHighlightColor ComputeSuggestionInfos(
const HeapVector<std::pair<Member<Node>, Member<DocumentMarker>>>&
node_suggestion_marker_pairs,
size_t max_number_of_suggestions) {
// We look at all suggestion markers touching or overlapping the touched
// location to pull suggestions from. We preferentially draw suggestions from
// shorter markers first (since we assume they're more specific to the tapped
// location) until we hit our limit.
HeapVector<std::pair<Member<Node>, Member<DocumentMarker>>>
node_suggestion_marker_pairs_sorted_by_length =
node_suggestion_marker_pairs;
std::sort(node_suggestion_marker_pairs_sorted_by_length.begin(),
node_suggestion_marker_pairs_sorted_by_length.end(),
[](const std::pair<Node*, DocumentMarker*>& pair1,
const std::pair<Node*, DocumentMarker*>& pair2) {
const int length1 =
pair1.second->EndOffset() - pair1.second->StartOffset();
const int length2 =
pair2.second->EndOffset() - pair2.second->StartOffset();
return length1 < length2;
});
SuggestionInfosWithNodeAndHighlightColor
suggestion_infos_with_node_and_highlight_color;
// In theory, a user could tap right before/after the start of a node and we'd
// want to pull in suggestions from either side of the tap. However, this is
// an edge case that's unlikely to matter in practice (the user will most
// likely just tap in the node where they want to apply the suggestions) and
// it complicates implementation, so we require that all suggestions come
// from the same text node.
suggestion_infos_with_node_and_highlight_color.text_node =
node_suggestion_marker_pairs_sorted_by_length.front().first;
// The highlight color comes from the shortest suggestion marker touching or
// intersecting the tapped location. If there's no color set, we use the
// default text selection color.
const SuggestionMarker& first_suggestion_marker = *ToSuggestionMarker(
node_suggestion_marker_pairs_sorted_by_length.front().second);
suggestion_infos_with_node_and_highlight_color.highlight_color =
(first_suggestion_marker.SuggestionHighlightColor() == 0)
? LayoutTheme::TapHighlightColor()
: first_suggestion_marker.SuggestionHighlightColor();
Vector<TextSuggestionInfo>& suggestion_infos =
suggestion_infos_with_node_and_highlight_color.suggestion_infos;
for (const std::pair<Node*, DocumentMarker*>& node_marker_pair :
node_suggestion_marker_pairs_sorted_by_length) {
if (node_marker_pair.first !=
suggestion_infos_with_node_and_highlight_color.text_node)
continue;
if (suggestion_infos.size() == max_number_of_suggestions)
break;
const SuggestionMarker* marker =
ToSuggestionMarker(node_marker_pair.second);
const Vector<String>& marker_suggestions = marker->Suggestions();
for (size_t suggestion_index = 0;
suggestion_index < marker_suggestions.size(); ++suggestion_index) {
const String& suggestion = marker_suggestions[suggestion_index];
if (suggestion_infos.size() == max_number_of_suggestions)
break;
if (std::find_if(suggestion_infos.begin(), suggestion_infos.end(),
[marker, &suggestion](const TextSuggestionInfo& info) {
return info.span_start ==
(int32_t)marker->StartOffset() &&
info.span_end == (int32_t)marker->EndOffset() &&
info.suggestion == suggestion;
}) != suggestion_infos.end())
continue;
TextSuggestionInfo suggestion_info;
suggestion_info.marker_tag = marker->Tag();
suggestion_info.suggestion_index = suggestion_index;
suggestion_info.span_start = marker->StartOffset();
suggestion_info.span_end = marker->EndOffset();
suggestion_info.suggestion = suggestion;
suggestion_infos.push_back(suggestion_info);
}
}
return suggestion_infos_with_node_and_highlight_color;
}
} // namespace
TextSuggestionController::TextSuggestionController(LocalFrame& frame)
: is_suggestion_menu_open_(false), frame_(&frame) {}
void TextSuggestionController::DocumentAttached(Document* document) {
DCHECK(document);
SetContext(document);
}
bool TextSuggestionController::IsMenuOpen() const {
return is_suggestion_menu_open_;
}
void TextSuggestionController::HandlePotentialSuggestionTap(
const PositionInFlatTree& caret_position) {
// TODO(crbug.com/779126): add support for suggestions in immersive mode.
if (GetDocument().GetSettings()->GetImmersiveModeEnabled())
return;
// It's theoretically possible, but extremely unlikely, that the user has
// managed to tap on some text after TextSuggestionController has told the
// browser to open the text suggestions menu, but before the browser has
// actually done so. In this case, we should just ignore the tap.
if (is_suggestion_menu_open_)
return;
const EphemeralRangeInFlatTree& range_to_check =
ComputeRangeSurroundingCaret(caret_position);
const std::pair<const Node*, const DocumentMarker*>& node_and_marker =
FirstMarkerIntersectingRange(
range_to_check, DocumentMarker::kSpelling | DocumentMarker::kGrammar |
DocumentMarker::kSuggestion);
if (!node_and_marker.first)
return;
if (!text_suggestion_host_) {
GetFrame().GetInterfaceProvider().GetInterface(
mojo::MakeRequest(&text_suggestion_host_));
}
text_suggestion_host_->StartSuggestionMenuTimer();
}
void TextSuggestionController::Trace(blink::Visitor* visitor) {
visitor->Trace(frame_);
DocumentShutdownObserver::Trace(visitor);
}
void TextSuggestionController::ReplaceActiveSuggestionRange(
const String& suggestion) {
const VisibleSelectionInFlatTree& selection =
GetFrame().Selection().ComputeVisibleSelectionInFlatTree();
if (selection.IsNone())
return;
const EphemeralRangeInFlatTree& range_to_check =
selection.IsRange() ? selection.ToNormalizedEphemeralRange()
: ComputeRangeSurroundingCaret(selection.Start());
const HeapVector<std::pair<Member<Node>, Member<DocumentMarker>>>&
node_marker_pairs =
GetFrame().GetDocument()->Markers().MarkersIntersectingRange(
range_to_check, DocumentMarker::kActiveSuggestion);
if (node_marker_pairs.IsEmpty())
return;
Node* const marker_text_node = node_marker_pairs.front().first;
const DocumentMarker* const marker = node_marker_pairs.front().second;
const EphemeralRange& range_to_replace =
EphemeralRange(Position(marker_text_node, marker->StartOffset()),
Position(marker_text_node, marker->EndOffset()));
ReplaceRangeWithText(range_to_replace, suggestion);
}
void TextSuggestionController::ApplySpellCheckSuggestion(
const String& suggestion) {
ReplaceActiveSuggestionRange(suggestion);
OnSuggestionMenuClosed();
}
void TextSuggestionController::ApplyTextSuggestion(int32_t marker_tag,
uint32_t suggestion_index) {
const VisibleSelectionInFlatTree& selection =
GetFrame().Selection().ComputeVisibleSelectionInFlatTree();
if (selection.IsNone()) {
OnSuggestionMenuClosed();
return;
}
const EphemeralRangeInFlatTree& range_to_check =
selection.IsRange() ? selection.ToNormalizedEphemeralRange()
: ComputeRangeSurroundingCaret(selection.Start());
const HeapVector<std::pair<Member<Node>, Member<DocumentMarker>>>&
node_marker_pairs =
GetFrame().GetDocument()->Markers().MarkersIntersectingRange(
range_to_check, DocumentMarker::kSuggestion);
const Node* marker_text_node = nullptr;
SuggestionMarker* marker = nullptr;
for (const std::pair<Member<Node>, Member<DocumentMarker>>& node_marker_pair :
node_marker_pairs) {
SuggestionMarker* suggestion_marker =
ToSuggestionMarker(node_marker_pair.second);
if (suggestion_marker->Tag() == marker_tag) {
marker_text_node = node_marker_pair.first;
marker = suggestion_marker;
break;
}
}
if (!marker) {
OnSuggestionMenuClosed();
return;
}
const EphemeralRange& range_to_replace =
EphemeralRange(Position(marker_text_node, marker->StartOffset()),
Position(marker_text_node, marker->EndOffset()));
const String& replacement = marker->Suggestions()[suggestion_index];
const String& new_suggestion = PlainText(range_to_replace);
{
SuggestionMarkerReplacementScope scope;
ReplaceRangeWithText(range_to_replace, replacement);
}
if (marker->IsMisspelling()) {
GetFrame().GetDocument()->Markers().RemoveSuggestionMarkerByTag(
marker_text_node, marker->Tag());
} else {
marker->SetSuggestion(suggestion_index, new_suggestion);
}
OnSuggestionMenuClosed();
}
void TextSuggestionController::DeleteActiveSuggestionRange() {
AttemptToDeleteActiveSuggestionRange();
OnSuggestionMenuClosed();
}
void TextSuggestionController::OnNewWordAddedToDictionary(const String& word) {
// Android pops up a dialog to let the user confirm they actually want to add
// the word to the dictionary; this method gets called as soon as the dialog
// is shown. So the word isn't actually in the dictionary here, even if the
// user will end up confirming the dialog, and we shouldn't try to re-run
// spellcheck here.
// Note: this actually matches the behavior in native Android text boxes
GetDocument().Markers().RemoveSpellingMarkersUnderWords(
Vector<String>({word}));
OnSuggestionMenuClosed();
}
void TextSuggestionController::OnSuggestionMenuClosed() {
if (!IsAvailable())
return;
GetDocument().Markers().RemoveMarkersOfTypes(
DocumentMarker::kActiveSuggestion);
GetFrame().Selection().SetCaretVisible(true);
is_suggestion_menu_open_ = false;
}
void TextSuggestionController::SuggestionMenuTimeoutCallback(
size_t max_number_of_suggestions) {
if (!IsAvailable())
return;
const VisibleSelectionInFlatTree& selection =
GetFrame().Selection().ComputeVisibleSelectionInFlatTree();
if (selection.IsNone())
return;
const EphemeralRangeInFlatTree& range_to_check =
selection.IsRange() ? selection.ToNormalizedEphemeralRange()
: ComputeRangeSurroundingCaret(selection.Start());
// We can show a menu if the user tapped on either a spellcheck marker or a
// suggestion marker. Suggestion markers take precedence (we don't even try
// to draw both underlines, suggestion wins).
const HeapVector<std::pair<Member<Node>, Member<DocumentMarker>>>&
node_suggestion_marker_pairs =
GetFrame().GetDocument()->Markers().MarkersIntersectingRange(
range_to_check, DocumentMarker::kSuggestion);
if (!node_suggestion_marker_pairs.IsEmpty()) {
ShowSuggestionMenu(node_suggestion_marker_pairs, max_number_of_suggestions);
return;
}
// If we didn't find any suggestion markers, look for spell check markers.
const HeapVector<std::pair<Member<Node>, Member<DocumentMarker>>>
node_spelling_marker_pairs =
GetFrame().GetDocument()->Markers().MarkersIntersectingRange(
range_to_check, DocumentMarker::MisspellingMarkers());
if (!node_spelling_marker_pairs.IsEmpty())
ShowSpellCheckMenu(node_spelling_marker_pairs.front());
// If we get here, that means the user tapped on a spellcheck or suggestion
// marker a few hundred milliseconds ago (to start the double-click timer)
// but it's gone now. Oh well...
}
void TextSuggestionController::ShowSpellCheckMenu(
const std::pair<Node*, DocumentMarker*>& node_spelling_marker_pair) {
Node* const marker_text_node = node_spelling_marker_pair.first;
SpellCheckMarker* const marker =
ToSpellCheckMarker(node_spelling_marker_pair.second);
const EphemeralRange active_suggestion_range =
EphemeralRange(Position(marker_text_node, marker->StartOffset()),
Position(marker_text_node, marker->EndOffset()));
const String& misspelled_word = PlainText(active_suggestion_range);
const String& description = marker->Description();
is_suggestion_menu_open_ = true;
GetFrame().Selection().SetCaretVisible(false);
GetDocument().Markers().AddActiveSuggestionMarker(
active_suggestion_range, SK_ColorTRANSPARENT,
ui::mojom::ImeTextSpanThickness::kNone,
LayoutTheme::GetTheme().PlatformActiveSpellingMarkerHighlightColor());
Vector<String> suggestions;
description.Split('\n', suggestions);
Vector<mojom::blink::SpellCheckSuggestionPtr> suggestion_ptrs;
for (const String& suggestion : suggestions) {
mojom::blink::SpellCheckSuggestionPtr info_ptr(
mojom::blink::SpellCheckSuggestion::New());
info_ptr->suggestion = suggestion;
suggestion_ptrs.push_back(std::move(info_ptr));
}
const IntRect& absolute_bounds = GetFrame().Selection().AbsoluteCaretBounds();
const IntRect& viewport_bounds =
GetFrame().View()->FrameToViewport(absolute_bounds);
text_suggestion_host_->ShowSpellCheckSuggestionMenu(
viewport_bounds.X(), viewport_bounds.MaxY(), std::move(misspelled_word),
std::move(suggestion_ptrs));
}
void TextSuggestionController::ShowSuggestionMenu(
const HeapVector<std::pair<Member<Node>, Member<DocumentMarker>>>&
node_suggestion_marker_pairs,
size_t max_number_of_suggestions) {
DCHECK(!node_suggestion_marker_pairs.IsEmpty());
SuggestionInfosWithNodeAndHighlightColor
suggestion_infos_with_node_and_highlight_color = ComputeSuggestionInfos(
node_suggestion_marker_pairs, max_number_of_suggestions);
Vector<TextSuggestionInfo>& suggestion_infos =
suggestion_infos_with_node_and_highlight_color.suggestion_infos;
int span_union_start = suggestion_infos[0].span_start;
int span_union_end = suggestion_infos[0].span_end;
for (size_t i = 1; i < suggestion_infos.size(); ++i) {
span_union_start =
std::min(span_union_start, suggestion_infos[i].span_start);
span_union_end = std::max(span_union_end, suggestion_infos[i].span_end);
}
const Node* text_node =
suggestion_infos_with_node_and_highlight_color.text_node;
for (TextSuggestionInfo& info : suggestion_infos) {
const EphemeralRange prefix_range(Position(text_node, span_union_start),
Position(text_node, info.span_start));
const String& prefix = PlainText(prefix_range);
const EphemeralRange suffix_range(Position(text_node, info.span_end),
Position(text_node, span_union_end));
const String& suffix = PlainText(suffix_range);
info.prefix = prefix;
info.suffix = suffix;
}
const EphemeralRange marker_range(Position(text_node, span_union_start),
Position(text_node, span_union_end));
GetDocument().Markers().AddActiveSuggestionMarker(
marker_range, SK_ColorTRANSPARENT, ui::mojom::ImeTextSpanThickness::kThin,
suggestion_infos_with_node_and_highlight_color.highlight_color);
is_suggestion_menu_open_ = true;
GetFrame().Selection().SetCaretVisible(false);
const String& misspelled_word = PlainText(marker_range);
CallMojoShowTextSuggestionMenu(
suggestion_infos_with_node_and_highlight_color.suggestion_infos,
misspelled_word);
}
void TextSuggestionController::CallMojoShowTextSuggestionMenu(
const Vector<TextSuggestionInfo>& text_suggestion_infos,
const String& misspelled_word) {
Vector<mojom::blink::TextSuggestionPtr> suggestion_info_ptrs;
for (const blink::TextSuggestionInfo& info : text_suggestion_infos) {
mojom::blink::TextSuggestionPtr info_ptr(
mojom::blink::TextSuggestion::New());
info_ptr->marker_tag = info.marker_tag;
info_ptr->suggestion_index = info.suggestion_index;
info_ptr->prefix = info.prefix;
info_ptr->suggestion = info.suggestion;
info_ptr->suffix = info.suffix;
suggestion_info_ptrs.push_back(std::move(info_ptr));
}
const IntRect& absolute_bounds = GetFrame().Selection().AbsoluteCaretBounds();
const IntRect& viewport_bounds =
GetFrame().View()->FrameToViewport(absolute_bounds);
text_suggestion_host_->ShowTextSuggestionMenu(
viewport_bounds.X(), viewport_bounds.MaxY(), misspelled_word,
std::move(suggestion_info_ptrs));
}
Document& TextSuggestionController::GetDocument() const {
DCHECK(IsAvailable());
return *LifecycleContext();
}
bool TextSuggestionController::IsAvailable() const {
return LifecycleContext();
}
LocalFrame& TextSuggestionController::GetFrame() const {
DCHECK(frame_);
return *frame_;
}
std::pair<const Node*, const DocumentMarker*>
TextSuggestionController::FirstMarkerIntersectingRange(
const EphemeralRangeInFlatTree& range,
DocumentMarker::MarkerTypes types) const {
const Node* const range_start_container =
range.StartPosition().ComputeContainerNode();
const unsigned range_start_offset =
range.StartPosition().ComputeOffsetInContainerNode();
const Node* const range_end_container =
range.EndPosition().ComputeContainerNode();
const unsigned range_end_offset =
range.EndPosition().ComputeOffsetInContainerNode();
for (const Node& node : range.Nodes()) {
if (!node.IsTextNode())
continue;
const unsigned start_offset =
node == range_start_container ? range_start_offset : 0;
const unsigned end_offset = node == range_end_container
? range_end_offset
: ToText(node).length();
const DocumentMarker* const found_marker =
GetFrame().GetDocument()->Markers().FirstMarkerIntersectingOffsetRange(
ToText(node), start_offset, end_offset, types);
if (found_marker)
return std::make_pair(&node, found_marker);
}
return {};
}
std::pair<const Node*, const DocumentMarker*>
TextSuggestionController::FirstMarkerTouchingSelection(
DocumentMarker::MarkerTypes types) const {
const VisibleSelectionInFlatTree& selection =
GetFrame().Selection().ComputeVisibleSelectionInFlatTree();
if (selection.IsNone())
return {};
const EphemeralRangeInFlatTree& range_to_check =
selection.IsRange()
? EphemeralRangeInFlatTree(selection.Start(), selection.End())
: ComputeRangeSurroundingCaret(selection.Start());
return FirstMarkerIntersectingRange(range_to_check, types);
}
void TextSuggestionController::AttemptToDeleteActiveSuggestionRange() {
const std::pair<const Node*, const DocumentMarker*>& node_and_marker =
FirstMarkerTouchingSelection(DocumentMarker::kActiveSuggestion);
if (!node_and_marker.first)
return;
const Node* const marker_text_node = node_and_marker.first;
const DocumentMarker* const marker = node_and_marker.second;
const bool delete_next_char =
ShouldDeleteNextCharacter(*marker_text_node, *marker);
const EphemeralRange range_to_delete = EphemeralRange(
Position(marker_text_node, marker->StartOffset()),
Position(marker_text_node, marker->EndOffset() + delete_next_char));
ReplaceRangeWithText(range_to_delete, "");
}
void TextSuggestionController::ReplaceRangeWithText(const EphemeralRange& range,
const String& replacement) {
GetFrame().Selection().SetSelectionAndEndTyping(
SelectionInDOMTree::Builder().SetBaseAndExtent(range).Build());
// TODO(editing-dev): We should check whether |TextSuggestionController| is
// available or not.
// TODO(editing-dev): The use of updateStyleAndLayoutIgnorePendingStylesheets
// needs to be audited. See http://crbug.com/590369 for more details.
GetFrame().GetDocument()->UpdateStyleAndLayoutIgnorePendingStylesheets();
// Dispatch 'beforeinput'.
Element* const target = FindEventTargetFrom(
GetFrame(), GetFrame().Selection().ComputeVisibleSelectionInDOMTree());
DataTransfer* const data_transfer = DataTransfer::Create(
DataTransfer::DataTransferType::kInsertReplacementText,
DataTransferAccessPolicy::kReadable,
DataObject::CreateFromString(replacement));
const bool is_canceled =
DispatchBeforeInputDataTransfer(
target, InputEvent::InputType::kInsertReplacementText,
data_transfer) != DispatchEventResult::kNotCanceled;
// 'beforeinput' event handler may destroy target frame.
if (!IsAvailable())
return;
// TODO(editing-dev): The use of updateStyleAndLayoutIgnorePendingStylesheets
// needs to be audited. See http://crbug.com/590369 for more details.
GetFrame().GetDocument()->UpdateStyleAndLayoutIgnorePendingStylesheets();
if (is_canceled)
return;
GetFrame().GetEditor().ReplaceSelectionWithText(
replacement, false, false, InputEvent::InputType::kInsertReplacementText);
}
} // namespace blink