| // Copyright 2018 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/layout/ng/inline/ng_caret_position.h" |
| |
| #include "third_party/blink/renderer/core/editing/bidi_adjustment.h" |
| #include "third_party/blink/renderer/core/editing/position_with_affinity.h" |
| #include "third_party/blink/renderer/core/editing/text_affinity.h" |
| #include "third_party/blink/renderer/core/layout/layout_block_flow.h" |
| #include "third_party/blink/renderer/core/layout/ng/inline/ng_fragment_item.h" |
| #include "third_party/blink/renderer/core/layout/ng/inline/ng_inline_cursor.h" |
| #include "third_party/blink/renderer/core/layout/ng/inline/ng_inline_node.h" |
| #include "third_party/blink/renderer/core/layout/ng/inline/ng_offset_mapping.h" |
| #include "third_party/blink/renderer/core/layout/ng/inline/ng_physical_line_box_fragment.h" |
| |
| namespace blink { |
| |
| namespace { |
| |
| // The calculation takes the following input: |
| // - An inline formatting context as a |LayoutBlockFlow| |
| // - An offset in the |text_content_| string of the above context |
| // - A TextAffinity |
| // |
| // The calculation iterates all inline fragments in the context, and tries to |
| // compute an NGCaretPosition using the "caret resolution process" below: |
| // |
| // The (offset, affinity) pair is compared against each inline fragment to see |
| // if the corresponding caret should be placed in the fragment, using the |
| // |TryResolveCaretPositionInXXX()| functions. These functions may return: |
| // - Failed, indicating that the caret must not be placed in the fragment; |
| // - Resolved, indicating that the care should be placed in the fragment, and |
| // no further search is required. The result NGCaretPosition is returned |
| // together. |
| // - FoundCandidate, indicating that the caret may be placed in the fragment; |
| // however, further search may find a better position. The candidate |
| // NGCaretPosition is also returned together. |
| |
| enum class ResolutionType { kFailed, kFoundCandidate, kResolved }; |
| struct CaretPositionResolution { |
| STACK_ALLOCATED(); |
| |
| public: |
| ResolutionType type = ResolutionType::kFailed; |
| NGCaretPosition caret_position; |
| }; |
| |
| bool CanResolveCaretPositionBeforeFragment(const NGInlineCursor& cursor, |
| TextAffinity affinity) { |
| if (affinity == TextAffinity::kDownstream) |
| return true; |
| if (RuntimeEnabledFeatures::BidiCaretAffinityEnabled()) |
| return false; |
| NGInlineCursor current_line(cursor); |
| current_line.MoveToContainingLine(); |
| // A fragment after line wrap must be the first logical leaf in its line. |
| NGInlineCursor first_logical_leaf(current_line); |
| first_logical_leaf.MoveToFirstLogicalLeaf(); |
| if (cursor != first_logical_leaf) |
| return true; |
| NGInlineCursor last_line(current_line); |
| last_line.MoveToPreviousLine(); |
| return !last_line || !last_line.Current().HasSoftWrapToNextLine(); |
| } |
| |
| bool CanResolveCaretPositionAfterFragment(const NGInlineCursor& cursor, |
| TextAffinity affinity) { |
| if (affinity == TextAffinity::kUpstream) |
| return true; |
| if (RuntimeEnabledFeatures::BidiCaretAffinityEnabled()) |
| return false; |
| NGInlineCursor current_line(cursor); |
| current_line.MoveToContainingLine(); |
| // A fragment before line wrap must be the last logical leaf in its line. |
| NGInlineCursor last_logical_leaf(current_line); |
| last_logical_leaf.MoveToLastLogicalLeaf(); |
| if (cursor != last_logical_leaf) |
| return true; |
| return !current_line.Current().HasSoftWrapToNextLine(); |
| } |
| |
| // Returns a |kFailed| resolution if |offset| doesn't belong to the text |
| // fragment. Otherwise, return either |kFoundCandidate| or |kResolved| depending |
| // on |affinity|. |
| CaretPositionResolution TryResolveCaretPositionInTextFragment( |
| const NGInlineCursor& cursor, |
| unsigned offset, |
| TextAffinity affinity) { |
| if (cursor.Current().IsGeneratedText()) |
| return CaretPositionResolution(); |
| |
| const NGOffsetMapping& mapping = |
| *NGOffsetMapping::GetFor(cursor.Current().GetLayoutObject()); |
| |
| // A text fragment natually allows caret placement in offset range |
| // [StartOffset(), EndOffset()], i.e., from before the first character to |
| // after the last character. |
| // Besides, leading/trailing bidi control characters are ignored since their |
| // two sides are considered the same caret position. Hence, if there are n and |
| // m leading and trailing bidi control characters, then the allowed offset |
| // range is [StartOffset() - n, EndOffset() + m]. |
| // Note that we don't ignore other characters that are not in fragments. For |
| // example, a trailing space of a line is not in any fragment, but its two |
| // sides are still different caret positions, so we don't ignore it. |
| const NGTextOffset current_offset = cursor.Current().TextOffset(); |
| const unsigned start_offset = current_offset.start; |
| const unsigned end_offset = current_offset.end; |
| if (offset < start_offset && |
| !mapping.HasBidiControlCharactersOnly(offset, start_offset)) |
| return CaretPositionResolution(); |
| if (affinity == TextAffinity::kUpstream && offset == current_offset.end + 1 && |
| cursor.Current().Style().NeedsTrailingSpace() && |
| cursor.Current().Style().IsCollapsibleWhiteSpace( |
| mapping.GetText()[offset - 1])) { |
| // |offset| is after soft line wrap, e.g. "abc |xyz". |
| // See http://crbug.com/1183269 and |AdjustForSoftLineWrap()| |
| return {ResolutionType::kResolved, |
| {cursor, NGCaretPositionType::kAtTextOffset, offset - 1}}; |
| } |
| if (offset > current_offset.end && |
| !mapping.HasBidiControlCharactersOnly(end_offset, offset)) |
| return CaretPositionResolution(); |
| |
| offset = std::max(offset, start_offset); |
| offset = std::min(offset, end_offset); |
| NGCaretPosition candidate = {cursor, NGCaretPositionType::kAtTextOffset, |
| offset}; |
| |
| // Offsets in the interior of a fragment can be resolved directly. |
| if (offset > start_offset && offset < end_offset) |
| return {ResolutionType::kResolved, candidate}; |
| |
| if (offset == start_offset && |
| CanResolveCaretPositionBeforeFragment(cursor, affinity)) { |
| return {ResolutionType::kResolved, candidate}; |
| } |
| |
| if (offset == end_offset && !cursor.Current().IsLineBreak() && |
| CanResolveCaretPositionAfterFragment(cursor, affinity)) { |
| return {ResolutionType::kResolved, candidate}; |
| } |
| |
| // We may have a better candidate |
| return {ResolutionType::kFoundCandidate, candidate}; |
| } |
| |
| unsigned GetTextOffsetBefore(const Node& node) { |
| // TODO(xiaochengh): Design more straightforward way to get text offset of |
| // atomic inline box. |
| DCHECK(node.GetLayoutObject()->IsAtomicInlineLevel()); |
| const Position before_node = Position::BeforeNode(node); |
| base::Optional<unsigned> maybe_offset_before = |
| NGOffsetMapping::GetFor(before_node)->GetTextContentOffset(before_node); |
| // We should have offset mapping for atomic inline boxes. |
| DCHECK(maybe_offset_before.has_value()); |
| return *maybe_offset_before; |
| } |
| |
| // Returns a |kFailed| resolution if |offset| doesn't belong to the atomic |
| // inline box fragment. Otherwise, return either |kFoundCandidate| or |
| // |kResolved| depending on |affinity|. |
| CaretPositionResolution TryResolveCaretPositionByBoxFragmentSide( |
| const NGInlineCursor& cursor, |
| unsigned offset, |
| TextAffinity affinity) { |
| const Node* const node = cursor.Current().GetNode(); |
| // There is no caret position at a pseudo or generated box side. |
| if (!node || node->IsPseudoElement()) { |
| // TODO(xiaochengh): This leads to false negatives for, e.g., RUBY, where an |
| // anonymous wrapping inline block is created. |
| return CaretPositionResolution(); |
| } |
| |
| const unsigned offset_before = GetTextOffsetBefore(*node); |
| const unsigned offset_after = offset_before + 1; |
| // TODO(xiaochengh): Ignore bidi control characters before & after the box. |
| if (offset != offset_before && offset != offset_after) |
| return CaretPositionResolution(); |
| const NGCaretPositionType position_type = |
| offset == offset_before ? NGCaretPositionType::kBeforeBox |
| : NGCaretPositionType::kAfterBox; |
| NGCaretPosition candidate{cursor, position_type, base::nullopt}; |
| |
| if (offset == offset_before && |
| CanResolveCaretPositionBeforeFragment(cursor, affinity)) { |
| return {ResolutionType::kResolved, candidate}; |
| } |
| |
| if (offset == offset_after && |
| CanResolveCaretPositionAfterFragment(cursor, affinity)) { |
| return {ResolutionType::kResolved, candidate}; |
| } |
| |
| return {ResolutionType::kFoundCandidate, candidate}; |
| } |
| |
| CaretPositionResolution TryResolveCaretPositionWithFragment( |
| const NGInlineCursor& cursor, |
| unsigned offset, |
| TextAffinity affinity) { |
| if (cursor.Current().IsText()) |
| return TryResolveCaretPositionInTextFragment(cursor, offset, affinity); |
| if (cursor.Current().IsAtomicInline()) |
| return TryResolveCaretPositionByBoxFragmentSide(cursor, offset, affinity); |
| return CaretPositionResolution(); |
| } |
| |
| bool NeedsBidiAdjustment(const NGCaretPosition& caret_position) { |
| if (RuntimeEnabledFeatures::BidiCaretAffinityEnabled()) |
| return false; |
| if (caret_position.IsNull()) |
| return false; |
| if (caret_position.position_type != NGCaretPositionType::kAtTextOffset) |
| return true; |
| DCHECK(caret_position.text_offset.has_value()); |
| const NGTextOffset offset = caret_position.cursor.Current().TextOffset(); |
| const unsigned start_offset = offset.start; |
| const unsigned end_offset = offset.end; |
| DCHECK_GE(*caret_position.text_offset, start_offset); |
| DCHECK_LE(*caret_position.text_offset, end_offset); |
| // Bidi adjustment is needed only for caret positions at bidi boundaries. |
| // Caret positions in the middle of a text fragment can't be at bidi |
| // boundaries, and hence, don't need any adjustment. |
| return *caret_position.text_offset == start_offset || |
| *caret_position.text_offset == end_offset; |
| } |
| |
| NGCaretPosition AdjustCaretPositionForBidiText( |
| const NGCaretPosition& caret_position) { |
| if (!NeedsBidiAdjustment(caret_position)) |
| return caret_position; |
| return BidiAdjustment::AdjustForCaretPositionResolution(caret_position); |
| } |
| |
| bool IsUpstreamAfterLineBreak(const NGCaretPosition& caret_position) { |
| if (caret_position.position_type != NGCaretPositionType::kAtTextOffset) |
| return false; |
| |
| DCHECK(caret_position.cursor.IsNotNull()); |
| DCHECK(caret_position.text_offset.has_value()); |
| |
| if (!caret_position.cursor.Current().IsLineBreak()) |
| return false; |
| return *caret_position.text_offset == |
| caret_position.cursor.Current().TextEndOffset(); |
| } |
| |
| NGCaretPosition BetterCandidateBetween(const NGCaretPosition& current, |
| const NGCaretPosition& other, |
| unsigned offset, |
| TextAffinity affinity) { |
| DCHECK(!other.IsNull()); |
| if (current.IsNull()) |
| return other; |
| |
| // There shouldn't be too many cases where we have multiple candidates. |
| // Make sure all of them are captured and handled here. |
| |
| // Only known case: either |current| or |other| is upstream after line break. |
| DCHECK_EQ(affinity, TextAffinity::kUpstream); |
| if (IsUpstreamAfterLineBreak(current)) { |
| DCHECK(!IsUpstreamAfterLineBreak(other)); |
| return other; |
| } |
| return current; |
| } |
| |
| } // namespace |
| |
| // The main function for compute an NGCaretPosition. See the comments at the top |
| // of this file for details. |
| NGCaretPosition ComputeNGCaretPosition(const LayoutBlockFlow& context, |
| unsigned offset, |
| TextAffinity affinity, |
| const LayoutText* layout_text) { |
| NGInlineCursor cursor(context); |
| |
| NGCaretPosition candidate; |
| if (layout_text && layout_text->HasInlineFragments()) |
| cursor.MoveTo(*layout_text); |
| for (; cursor; cursor.MoveToNext()) { |
| const CaretPositionResolution resolution = |
| TryResolveCaretPositionWithFragment(cursor, offset, affinity); |
| |
| if (resolution.type == ResolutionType::kFailed) |
| continue; |
| |
| // TODO(xiaochengh): Handle caret poisition in empty container (e.g. empty |
| // line box). |
| |
| if (resolution.type == ResolutionType::kResolved) { |
| candidate = resolution.caret_position; |
| if (!layout_text || |
| candidate.cursor.Current().GetLayoutObject() == layout_text) |
| return AdjustCaretPositionForBidiText(resolution.caret_position); |
| continue; |
| } |
| |
| DCHECK_EQ(ResolutionType::kFoundCandidate, resolution.type); |
| candidate = BetterCandidateBetween(candidate, resolution.caret_position, |
| offset, affinity); |
| } |
| |
| return AdjustCaretPositionForBidiText(candidate); |
| } |
| |
| NGCaretPosition ComputeNGCaretPosition( |
| const PositionWithAffinity& position_with_affinity) { |
| const Position& position = position_with_affinity.GetPosition(); |
| LayoutBlockFlow* context = NGInlineFormattingContextOf(position); |
| if (!context) |
| return NGCaretPosition(); |
| |
| const NGOffsetMapping* mapping = NGInlineNode::GetOffsetMapping(context); |
| if (!mapping) { |
| // TODO(yosin): We should find when we reach here[1]. |
| // [1] http://crbug.com/1100481 |
| NOTREACHED() << context; |
| return NGCaretPosition(); |
| } |
| const base::Optional<unsigned> maybe_offset = |
| mapping->GetTextContentOffset(position); |
| if (!maybe_offset.has_value()) { |
| // We can reach here with empty text nodes. |
| if (auto* data = DynamicTo<Text>(position.AnchorNode())) { |
| DCHECK_EQ(data->length(), 0u); |
| } else { |
| // TODO(xiaochengh): Investigate if we reach here. |
| NOTREACHED(); |
| return NGCaretPosition(); |
| } |
| } |
| |
| const LayoutText* const layout_text = |
| position.IsOffsetInAnchor() && IsA<Text>(position.AnchorNode()) |
| ? To<LayoutText>(AssociatedLayoutObjectOf( |
| *position.AnchorNode(), position.OffsetInContainerNode())) |
| : nullptr; |
| |
| const unsigned offset = maybe_offset.value_or(0); |
| const TextAffinity affinity = position_with_affinity.Affinity(); |
| // For upstream position, we use offset before ZWS to distinguish downstream |
| // and upstream position when line breaking before ZWS. |
| // " Zabc" where "Z" represents zero-width-space. |
| // See AccessibilitySelectionTest.FromCurrentSelectionInTextareaWithAffinity |
| const unsigned adjusted_offset = |
| affinity == TextAffinity::kUpstream && offset && |
| mapping->GetText()[offset - 1] == kZeroWidthSpaceCharacter |
| ? offset - 1 |
| : offset; |
| return ComputeNGCaretPosition(*context, adjusted_offset, affinity, |
| layout_text); |
| } |
| |
| Position NGCaretPosition::ToPositionInDOMTree() const { |
| return ToPositionInDOMTreeWithAffinity().GetPosition(); |
| } |
| |
| PositionWithAffinity NGCaretPosition::ToPositionInDOMTreeWithAffinity() const { |
| if (IsNull()) |
| return PositionWithAffinity(); |
| switch (position_type) { |
| case NGCaretPositionType::kBeforeBox: |
| if (const Node* node = cursor.Current().GetNode()) { |
| return PositionWithAffinity(Position::BeforeNode(*node), |
| TextAffinity::kDownstream); |
| } |
| return PositionWithAffinity(); |
| case NGCaretPositionType::kAfterBox: |
| if (const Node* node = cursor.Current().GetNode()) { |
| return PositionWithAffinity(Position::AfterNode(*node), |
| TextAffinity::kUpstreamIfPossible); |
| } |
| return PositionWithAffinity(); |
| case NGCaretPositionType::kAtTextOffset: |
| // In case of ::first-letter, |cursor.Current().GetNode()| is null. |
| DCHECK(text_offset.has_value()); |
| const NGOffsetMapping* mapping = |
| NGOffsetMapping::GetFor(cursor.Current().GetLayoutObject()); |
| if (!mapping) { |
| // TODO(yosin): We're not sure why |mapping| is |nullptr|. It seems |
| // we are attempt to use destroyed/moved |NGFragmentItem|. |
| // See http://crbug.com/1145514 |
| NOTREACHED() << cursor << " " << cursor.Current().GetLayoutObject(); |
| return PositionWithAffinity(); |
| } |
| const TextAffinity affinity = |
| *text_offset == cursor.Current().TextEndOffset() |
| ? TextAffinity::kUpstreamIfPossible |
| : TextAffinity::kDownstream; |
| const Position position = affinity == TextAffinity::kDownstream |
| ? mapping->GetLastPosition(*text_offset) |
| : mapping->GetFirstPosition(*text_offset); |
| if (position.IsNull()) |
| return PositionWithAffinity(); |
| return PositionWithAffinity(position, affinity); |
| } |
| NOTREACHED(); |
| return PositionWithAffinity(); |
| } |
| |
| } // namespace blink |