| /* |
| * Copyright (C) 2019 Apple Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions |
| * are met: |
| * 1. Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * 2. Redistributions in binary form must reproduce the above copyright |
| * notice, this list of conditions and the following disclaimer in the |
| * documentation and/or other materials provided with the distribution. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' |
| * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, |
| * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS |
| * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF |
| * THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| #include "config.h" |
| #include "TextManipulationController.h" |
| |
| #include "CharacterData.h" |
| #include "Editing.h" |
| #include "ElementAncestorIterator.h" |
| #include "EventLoop.h" |
| #include "NodeTraversal.h" |
| #include "PseudoElement.h" |
| #include "Range.h" |
| #include "ScriptDisallowedScope.h" |
| #include "Text.h" |
| #include "TextIterator.h" |
| #include "VisibleUnits.h" |
| |
| namespace WebCore { |
| |
| inline bool TextManipulationController::ExclusionRule::match(const Element& element) const |
| { |
| return switchOn(rule, [&element] (ElementRule rule) { |
| return rule.localName == element.localName(); |
| }, [&element] (AttributeRule rule) { |
| return equalIgnoringASCIICase(element.getAttribute(rule.name), rule.value); |
| }, [&element] (ClassRule rule) { |
| return element.hasClass() && element.classNames().contains(rule.className); |
| }); |
| } |
| |
| class ExclusionRuleMatcher { |
| public: |
| using ExclusionRule = TextManipulationController::ExclusionRule; |
| using Type = TextManipulationController::ExclusionRule::Type; |
| |
| ExclusionRuleMatcher(const Vector<ExclusionRule>& rules) |
| : m_rules(rules) |
| { } |
| |
| bool isExcluded(Node* node) |
| { |
| if (!node) |
| return false; |
| |
| RefPtr<Element> startingElement = is<Element>(*node) ? downcast<Element>(node) : node->parentElement(); |
| if (!startingElement) |
| return false; |
| |
| Type type = Type::Include; |
| RefPtr<Element> matchingElement; |
| for (auto& element : elementLineage(startingElement.get())) { |
| if (auto typeOrNullopt = typeForElement(element)) { |
| type = *typeOrNullopt; |
| matchingElement = &element; |
| break; |
| } |
| } |
| |
| for (auto& element : elementLineage(startingElement.get())) { |
| m_cache.set(element, type); |
| if (&element == matchingElement) |
| break; |
| } |
| |
| return type == Type::Exclude; |
| } |
| |
| Optional<Type> typeForElement(Element& element) |
| { |
| auto it = m_cache.find(element); |
| if (it != m_cache.end()) |
| return it->value; |
| |
| for (auto& rule : m_rules) { |
| if (rule.match(element)) |
| return rule.type; |
| } |
| |
| return WTF::nullopt; |
| } |
| |
| private: |
| const Vector<ExclusionRule>& m_rules; |
| HashMap<Ref<Element>, ExclusionRule::Type> m_cache; |
| }; |
| |
| TextManipulationController::TextManipulationController(Document& document) |
| : m_document(makeWeakPtr(document)) |
| { |
| } |
| |
| void TextManipulationController::startObservingParagraphs(ManipulationItemCallback&& callback, Vector<ExclusionRule>&& exclusionRules) |
| { |
| auto document = makeRefPtr(m_document.get()); |
| if (!document) |
| return; |
| |
| m_callback = WTFMove(callback); |
| m_exclusionRules = WTFMove(exclusionRules); |
| |
| VisiblePosition start = firstPositionInNode(m_document.get()); |
| VisiblePosition end = lastPositionInNode(m_document.get()); |
| |
| observeParagraphs(start, end); |
| } |
| |
| void TextManipulationController::observeParagraphs(VisiblePosition& start, VisiblePosition& end) |
| { |
| auto document = makeRefPtr(start.deepEquivalent().document()); |
| ASSERT(document); |
| TextIterator iterator { start.deepEquivalent(), end.deepEquivalent() }; |
| if (document != start.deepEquivalent().document() || document != end.deepEquivalent().document()) |
| return; // TextIterator's constructor may have updated the layout and executed arbitrary scripts. |
| |
| ExclusionRuleMatcher exclusionRuleMatcher(m_exclusionRules); |
| Vector<ManipulationToken> tokensInCurrentParagraph; |
| Position startOfCurrentParagraph = start.deepEquivalent(); |
| while (!iterator.atEnd()) { |
| StringView currentText = iterator.text(); |
| |
| if (startOfCurrentParagraph.isNull()) |
| startOfCurrentParagraph = iterator.range()->startPosition(); |
| |
| size_t endOfLastNewLine = 0; |
| size_t offsetOfNextNewLine = 0; |
| while ((offsetOfNextNewLine = currentText.find('\n', endOfLastNewLine)) != notFound) { |
| if (endOfLastNewLine < offsetOfNextNewLine) { |
| auto stringUntilEndOfLine = currentText.substring(endOfLastNewLine, offsetOfNextNewLine - endOfLastNewLine).toString(); |
| tokensInCurrentParagraph.append(ManipulationToken { m_tokenIdentifier.generate(), stringUntilEndOfLine, exclusionRuleMatcher.isExcluded(iterator.node()) }); |
| } |
| |
| auto lastRange = iterator.range(); |
| if (offsetOfNextNewLine < currentText.length()) { |
| lastRange->setStart(firstPositionInOrBeforeNode(iterator.node())); // Move the start to the beginning of the current node. |
| TextIterator::subrange(lastRange, 0, offsetOfNextNewLine); |
| } |
| Position endOfCurrentParagraph = lastRange->endPosition(); |
| |
| if (!tokensInCurrentParagraph.isEmpty()) |
| addItem(startOfCurrentParagraph, endOfCurrentParagraph, WTFMove(tokensInCurrentParagraph)); |
| startOfCurrentParagraph.clear(); |
| endOfLastNewLine = offsetOfNextNewLine + 1; |
| } |
| |
| auto remainingText = currentText.substring(endOfLastNewLine); |
| if (remainingText.length()) |
| tokensInCurrentParagraph.append(ManipulationToken { m_tokenIdentifier.generate(), remainingText.toString(), exclusionRuleMatcher.isExcluded(iterator.node()) }); |
| |
| iterator.advance(); |
| } |
| |
| if (!tokensInCurrentParagraph.isEmpty()) |
| addItem(startOfCurrentParagraph, end.deepEquivalent(), WTFMove(tokensInCurrentParagraph)); |
| } |
| |
| void TextManipulationController::didCreateRendererForElement(Element& element) |
| { |
| if (m_recentlyInsertedElements.contains(element)) |
| return; |
| |
| if (m_mutatedElements.computesEmpty()) |
| scheduleObservartionUpdate(); |
| |
| if (is<PseudoElement>(element)) { |
| if (auto* host = downcast<PseudoElement>(element).hostElement()) |
| m_mutatedElements.add(*host); |
| } else |
| m_mutatedElements.add(element); |
| } |
| |
| using PositionTuple = std::tuple<RefPtr<Node>, unsigned, unsigned>; |
| static const PositionTuple makePositionTuple(const Position& position) |
| { |
| return { position.anchorNode(), static_cast<unsigned>(position.anchorType()), position.anchorType() == Position::PositionIsOffsetInAnchor ? position.offsetInContainerNode() : 0 }; |
| } |
| |
| static const std::pair<PositionTuple, PositionTuple> makeHashablePositionRange(const VisiblePosition& start, const VisiblePosition& end) |
| { |
| return { makePositionTuple(start.deepEquivalent()), makePositionTuple(end.deepEquivalent()) }; |
| } |
| |
| void TextManipulationController::scheduleObservartionUpdate() |
| { |
| if (!m_document) |
| return; |
| |
| m_document->eventLoop().queueTask(TaskSource::InternalAsyncTask, [weakThis = makeWeakPtr(*this)] { |
| auto* controller = weakThis.get(); |
| if (!controller) |
| return; |
| |
| HashSet<Ref<Element>> mutatedElements; |
| for (auto& weakElement : controller->m_mutatedElements) |
| mutatedElements.add(weakElement); |
| controller->m_mutatedElements.clear(); |
| |
| HashSet<Ref<Element>> filteredElements; |
| for (auto& element : mutatedElements) { |
| auto* parentElement = element->parentElement(); |
| if (!parentElement || !mutatedElements.contains(parentElement)) |
| filteredElements.add(element.copyRef()); |
| } |
| mutatedElements.clear(); |
| |
| HashSet<std::pair<PositionTuple, PositionTuple>> paragraphSets; |
| for (auto& element : filteredElements) { |
| auto start = startOfParagraph(firstPositionInOrBeforeNode(element.ptr())); |
| auto end = endOfParagraph(lastPositionInOrAfterNode(element.ptr())); |
| |
| auto key = makeHashablePositionRange(start, end); |
| if (!paragraphSets.add(key).isNewEntry) |
| continue; |
| |
| auto* controller = weakThis.get(); |
| if (!controller) |
| return; // Finding the start/end of paragraph may have updated layout & executed arbitrary scripts. |
| |
| controller->observeParagraphs(start, end); |
| } |
| }); |
| } |
| |
| void TextManipulationController::addItem(const Position& startOfParagraph, const Position& endOfParagraph, Vector<ManipulationToken>&& tokens) |
| { |
| ASSERT(m_document); |
| auto result = m_items.add(m_itemIdentifier.generate(), ManipulationItem { startOfParagraph, endOfParagraph, WTFMove(tokens) }); |
| m_callback(*m_document, result.iterator->key, result.iterator->value.tokens); |
| } |
| |
| auto TextManipulationController::completeManipulation(ItemIdentifier itemIdentifier, const Vector<ManipulationToken>& replacementTokens) -> ManipulationResult |
| { |
| if (!itemIdentifier) |
| return ManipulationResult::InvalidItem; |
| |
| auto itemIterator = m_items.find(itemIdentifier); |
| if (itemIterator == m_items.end()) |
| return ManipulationResult::InvalidItem; |
| |
| ManipulationItem item; |
| std::exchange(item, itemIterator->value); |
| m_items.remove(itemIterator); |
| |
| return replace(item, replacementTokens); |
| } |
| |
| struct TokenExchangeData { |
| RefPtr<Node> node; |
| String originalContent; |
| bool isExcluded { false }; |
| bool isConsumed { false }; |
| }; |
| |
| struct ReplacementData { |
| Ref<Node> originalNode; |
| String newData; |
| }; |
| |
| struct NodeInsertion { |
| RefPtr<Node> parentIfDifferentFromCommonAncestor; |
| Ref<Node> child; |
| }; |
| |
| auto TextManipulationController::replace(const ManipulationItem& item, const Vector<ManipulationToken>& replacementTokens) -> ManipulationResult |
| { |
| if (item.start.isOrphan() || item.end.isOrphan()) |
| return ManipulationResult::ContentChanged; |
| |
| TextIterator iterator { item.start, item.end }; |
| size_t currentTokenIndex = 0; |
| HashMap<TokenIdentifier, TokenExchangeData> tokenExchangeMap; |
| |
| RefPtr<Node> commonAncestor; |
| while (!iterator.atEnd()) { |
| auto string = iterator.text().toString(); |
| if (currentTokenIndex >= item.tokens.size()) |
| return ManipulationResult::ContentChanged; |
| auto& currentToken = item.tokens[currentTokenIndex]; |
| if (iterator.text() != currentToken.content) |
| return ManipulationResult::ContentChanged; |
| |
| auto currentNode = makeRefPtr(iterator.node()); |
| tokenExchangeMap.set(currentToken.identifier, TokenExchangeData { currentNode.copyRef(), currentToken.content, currentToken.isExcluded }); |
| |
| if (currentNode) { |
| // FIXME: Take care of when currentNode is nullptr. |
| if (!commonAncestor) |
| commonAncestor = currentNode; |
| else if (!currentNode->isDescendantOf(commonAncestor.get())) { |
| commonAncestor = Range::commonAncestorContainer(commonAncestor.get(), currentNode.get()); |
| ASSERT(commonAncestor); |
| } |
| } |
| |
| iterator.advance(); |
| ++currentTokenIndex; |
| } |
| ASSERT(commonAncestor); |
| |
| RefPtr<Node> nodeAfterStart = item.start.computeNodeAfterPosition(); |
| if (!nodeAfterStart) |
| nodeAfterStart = item.start.containerNode(); |
| |
| RefPtr<Node> nodeAfterEnd = item.end.computeNodeAfterPosition(); |
| if (!nodeAfterEnd) |
| nodeAfterEnd = NodeTraversal::nextSkippingChildren(*item.end.containerNode()); |
| |
| HashSet<Ref<Node>> nodesToRemove; |
| for (RefPtr<Node> currentNode = nodeAfterStart; currentNode && currentNode != nodeAfterEnd; currentNode = NodeTraversal::next(*currentNode)) { |
| if (commonAncestor == currentNode) |
| commonAncestor = currentNode->parentNode(); |
| nodesToRemove.add(*currentNode); |
| } |
| |
| Vector<Ref<Node>> currentElementStack; |
| HashSet<Ref<Node>> reusedOriginalNodes; |
| Vector<NodeInsertion> insertions; |
| for (auto& newToken : replacementTokens) { |
| auto it = tokenExchangeMap.find(newToken.identifier); |
| if (it == tokenExchangeMap.end()) |
| return ManipulationResult::InvalidToken; |
| |
| auto& exchangeData = it->value; |
| |
| RefPtr<Node> contentNode; |
| if (exchangeData.isExcluded) { |
| if (exchangeData.isConsumed) |
| return ManipulationResult::ExclusionViolation; |
| exchangeData.isConsumed = true; |
| if (!newToken.content.isNull() && newToken.content != exchangeData.originalContent) |
| return ManipulationResult::ExclusionViolation; |
| contentNode = Text::create(commonAncestor->document(), exchangeData.originalContent); |
| } else |
| contentNode = Text::create(commonAncestor->document(), newToken.content); |
| |
| auto& originalNode = exchangeData.node ? *exchangeData.node : *commonAncestor; |
| RefPtr<ContainerNode> currentNode = is<ContainerNode>(originalNode) ? &downcast<ContainerNode>(originalNode) : originalNode.parentNode(); |
| |
| Vector<Ref<Node>> currentAncestors; |
| for (; currentNode && currentNode != commonAncestor; currentNode = currentNode->parentNode()) |
| currentAncestors.append(*currentNode); |
| currentAncestors.reverse(); |
| |
| size_t i =0; |
| while (i < currentElementStack.size() && i < currentAncestors.size() && currentElementStack[i].ptr() == currentAncestors[i].ptr()) |
| ++i; |
| |
| if (i == currentElementStack.size() && i == currentAncestors.size()) |
| insertions.append(NodeInsertion { currentElementStack.size() ? currentElementStack.last().ptr() : nullptr, contentNode.releaseNonNull() }); |
| else { |
| if (i < currentElementStack.size()) |
| currentElementStack.shrink(i); |
| for (;i < currentAncestors.size(); ++i) { |
| Ref<Node> currentNode = currentAncestors[i].copyRef(); |
| if (!reusedOriginalNodes.add(currentNode.copyRef()).isNewEntry) { |
| auto clonedNode = currentNode->cloneNodeInternal(currentNode->document(), Node::CloningOperation::OnlySelf); |
| if (auto* data = currentNode->eventTargetData()) |
| data->eventListenerMap.copyEventListenersNotCreatedFromMarkupToTarget(clonedNode.ptr()); |
| currentNode = WTFMove(clonedNode); |
| } |
| |
| insertions.append(NodeInsertion { currentElementStack.size() ? currentElementStack.last().ptr() : nullptr, currentNode.copyRef() }); |
| currentElementStack.append(WTFMove(currentNode)); |
| } |
| insertions.append(NodeInsertion { currentElementStack.size() ? currentElementStack.last().ptr() : nullptr, contentNode.releaseNonNull() }); |
| } |
| } |
| |
| Position insertionPoint = item.start; |
| while (insertionPoint.containerNode() != commonAncestor) |
| insertionPoint = positionInParentBeforeNode(insertionPoint.containerNode()); |
| ASSERT(!insertionPoint.isNull()); |
| |
| for (auto& node : nodesToRemove) |
| node->remove(); |
| |
| for (auto& insertion : insertions) { |
| if (!insertion.parentIfDifferentFromCommonAncestor) |
| insertionPoint.containerNode()->insertBefore(insertion.child, insertionPoint.computeNodeBeforePosition()); |
| else |
| insertion.parentIfDifferentFromCommonAncestor->appendChild(insertion.child); |
| if (is<Element>(insertion.child.get())) |
| m_recentlyInsertedElements.add(downcast<Element>(insertion.child.get())); |
| } |
| m_document->eventLoop().queueTask(TaskSource::InternalAsyncTask, [weakThis = makeWeakPtr(*this)] { |
| if (auto strongThis = weakThis.get()) |
| strongThis->m_recentlyInsertedElements.clear(); |
| }); |
| |
| return ManipulationResult::Success; |
| } |
| |
| } // namespace WebCore |