blob: cb94713e2aa65610f84a37ec708eb2e939a85d99 [file] [log] [blame]
/*
* 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 "HTMLElement.h"
#include "HTMLNames.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 : lineageOfType<Element>(*startingElement)) {
if (auto typeOrNullopt = typeForElement(element)) {
type = *typeOrNullopt;
matchingElement = &element;
break;
}
}
for (auto& element : lineageOfType<Element>(*startingElement)) {
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))
{
}
bool TextManipulationController::isInManipulatedElement(Element& element)
{
if (!m_manipulatedElements.capacity())
return false; // Fast path for startObservingParagraphs.
for (auto& ancestorOrSelf : lineageOfType<Element>(element)) {
if (m_manipulatedElements.contains(ancestorOrSelf))
return true;
}
return false;
}
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);
observeParagraphs(firstPositionInNode(m_document.get()), lastPositionInNode(m_document.get()));
flushPendingItemsForCallback();
}
class ParagraphContentIterator {
public:
ParagraphContentIterator(const Position& start, const Position& end)
: m_iterator({ *makeBoundaryPoint(start), *makeBoundaryPoint(end) })
, m_iteratorNode(m_iterator.atEnd() ? nullptr : createLiveRange(m_iterator.range())->firstNode())
, m_currentNodeForFindingInvisibleContent(start.firstNode())
, m_pastEndNode(end.firstNode())
{
}
void advance()
{
// FIXME: Add a node callback to TextIterator instead of traversing the node tree twice like this.
if (m_currentNodeForFindingInvisibleContent != m_iteratorNode && m_currentNodeForFindingInvisibleContent != m_pastEndNode) {
moveCurrentNodeForward();
return;
}
if (m_iterator.atEnd())
return;
auto previousIteratorNode = m_iteratorNode;
m_iterator.advance();
m_iteratorNode = m_iterator.atEnd() ? nullptr : createLiveRange(m_iterator.range())->firstNode();
if (previousIteratorNode != m_iteratorNode)
moveCurrentNodeForward();
}
struct CurrentContent {
RefPtr<Node> node;
StringView text;
bool isTextContent { false };
bool isReplacedContent { false };
};
CurrentContent currentContent()
{
CurrentContent content;
if (m_currentNodeForFindingInvisibleContent && m_currentNodeForFindingInvisibleContent != m_iteratorNode)
content = { m_currentNodeForFindingInvisibleContent.copyRef(), StringView { } };
else
content = { m_iterator.node(), m_iterator.text(), true };
if (content.node) {
if (auto* renderer = content.node->renderer()) {
if (renderer->isRenderReplaced()) {
content.isTextContent = false;
content.isReplacedContent = true;
}
}
}
return content;
}
Position startPosition()
{
return createLegacyEditingPosition(m_iterator.range().start);
}
Position endPosition()
{
return createLegacyEditingPosition(m_iterator.range().end);
}
bool atEnd() const { return m_iterator.atEnd() && m_currentNodeForFindingInvisibleContent == m_pastEndNode; }
private:
void moveCurrentNodeForward()
{
m_currentNodeForFindingInvisibleContent = NodeTraversal::next(*m_currentNodeForFindingInvisibleContent);
if (!m_currentNodeForFindingInvisibleContent)
m_currentNodeForFindingInvisibleContent = m_pastEndNode;
}
TextIterator m_iterator;
RefPtr<Node> m_iteratorNode;
RefPtr<Node> m_currentNodeForFindingInvisibleContent;
RefPtr<Node> m_pastEndNode;
};
static bool isAttributeForTextManipulation(const QualifiedName& nameToCheck)
{
using namespace HTMLNames;
static const QualifiedName* const attributeNames[] = {
&titleAttr.get(),
&altAttr.get(),
&placeholderAttr.get(),
&aria_labelAttr.get(),
&aria_placeholderAttr.get(),
&aria_roledescriptionAttr.get(),
&aria_valuetextAttr.get(),
};
for (auto& entry : attributeNames) {
if (*entry == nameToCheck)
return true;
}
return false;
}
void TextManipulationController::observeParagraphs(const Position& start, const Position& end)
{
auto document = makeRefPtr(start.document());
ASSERT(document);
ParagraphContentIterator iterator { start, end };
VisiblePosition visibleStart = start;
VisiblePosition visibleEnd = end;
if (document != start.document() || document != end.document())
return; // TextIterator's constructor may have updated the layout and executed arbitrary scripts.
ExclusionRuleMatcher exclusionRuleMatcher(m_exclusionRules);
Vector<ManipulationToken> tokensInCurrentParagraph;
Position startOfCurrentParagraph = visibleStart.deepEquivalent();
for (; !iterator.atEnd(); iterator.advance()) {
auto content = iterator.currentContent();
if (content.node) {
if (RefPtr<Element> currentElementAncestor = is<Element>(*content.node) ? downcast<Element>(content.node.get()) : content.node->parentOrShadowHostElement()) {
if (isInManipulatedElement(*currentElementAncestor))
return; // We can exit early here because scheduleObservartionUpdate calls this function on each paragraph separately.
}
if (is<Element>(*content.node)) {
auto& currentElement = downcast<Element>(*content.node);
if (!content.isTextContent && (content.node->hasTagName(HTMLNames::titleTag) || content.node->hasTagName(HTMLNames::optionTag))) {
addItem(ManipulationItemData { Position(), Position(), makeWeakPtr(currentElement), nullQName(),
{ ManipulationToken { m_tokenIdentifier.generate(), currentElement.textContent() } } });
}
if (currentElement.hasAttributes()) {
for (auto& attribute : currentElement.attributesIterator()) {
if (isAttributeForTextManipulation(attribute.name())) {
addItem(ManipulationItemData { Position(), Position(), makeWeakPtr(currentElement), attribute.name(),
{ ManipulationToken { m_tokenIdentifier.generate(), attribute.value() } } });
}
}
}
}
if (startOfCurrentParagraph.isNull() && content.isTextContent)
startOfCurrentParagraph = iterator.startPosition();
}
if (content.isReplacedContent) {
if (startOfCurrentParagraph.isNull())
startOfCurrentParagraph = positionBeforeNode(content.node.get());
tokensInCurrentParagraph.append(ManipulationToken { m_tokenIdentifier.generate(), "[]", true /* isExcluded */});
continue;
}
if (!content.isTextContent)
continue;
size_t startOfCurrentLine = 0;
size_t offsetOfNextNewLine = 0;
StringView currentText = content.text;
while ((offsetOfNextNewLine = currentText.find('\n', startOfCurrentLine)) != notFound) {
if (startOfCurrentLine < offsetOfNextNewLine) {
auto stringUntilEndOfLine = currentText.substring(startOfCurrentLine, offsetOfNextNewLine - startOfCurrentLine).toString();
tokensInCurrentParagraph.append(ManipulationToken { m_tokenIdentifier.generate(), stringUntilEndOfLine, exclusionRuleMatcher.isExcluded(content.node.get()) });
}
if (!tokensInCurrentParagraph.isEmpty()) {
Position endOfCurrentParagraph = iterator.endPosition();
if (is<Text>(content.node)) {
auto& textNode = downcast<Text>(*content.node);
endOfCurrentParagraph = Position(&textNode, offsetOfNextNewLine);
startOfCurrentParagraph = Position(&textNode, offsetOfNextNewLine + 1);
}
addItem(ManipulationItemData { startOfCurrentParagraph, endOfCurrentParagraph, nullptr, nullQName(), WTFMove(tokensInCurrentParagraph) });
startOfCurrentParagraph.clear();
}
startOfCurrentLine = offsetOfNextNewLine + 1;
}
auto remainingText = currentText.substring(startOfCurrentLine);
if (remainingText.length())
tokensInCurrentParagraph.append(ManipulationToken { m_tokenIdentifier.generate(), remainingText.toString(), exclusionRuleMatcher.isExcluded(content.node.get()) });
}
if (!tokensInCurrentParagraph.isEmpty())
addItem(ManipulationItemData { startOfCurrentParagraph, visibleEnd.deepEquivalent(), nullptr, nullQName(), WTFMove(tokensInCurrentParagraph) });
}
void TextManipulationController::didCreateRendererForElement(Element& element)
{
if (isInManipulatedElement(element))
return;
if (m_elementsWithNewRenderer.computesEmpty())
scheduleObservartionUpdate();
if (is<PseudoElement>(element)) {
if (auto* host = downcast<PseudoElement>(element).hostElement())
m_elementsWithNewRenderer.add(*host);
} else
m_elementsWithNewRenderer.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_elementsWithNewRenderer)
mutatedElements.add(weakElement);
controller->m_elementsWithNewRenderer.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.deepEquivalent(), end.deepEquivalent());
}
controller->flushPendingItemsForCallback();
});
}
void TextManipulationController::addItem(ManipulationItemData&& itemData)
{
const unsigned itemCallbackBatchingSize = 128;
ASSERT(m_document);
auto newID = m_itemIdentifier.generate();
m_pendingItemsForCallback.append(ManipulationItem {
newID,
itemData.tokens.map([](auto& token) { return token; })
});
m_items.add(newID, WTFMove(itemData));
if (m_pendingItemsForCallback.size() >= itemCallbackBatchingSize)
flushPendingItemsForCallback();
}
void TextManipulationController::flushPendingItemsForCallback()
{
m_callback(*m_document, m_pendingItemsForCallback);
m_pendingItemsForCallback.clear();
}
auto TextManipulationController::completeManipulation(const Vector<WebCore::TextManipulationController::ManipulationItem>& completionItems) -> Vector<ManipulationFailure>
{
Vector<ManipulationFailure> failures;
for (unsigned i = 0; i < completionItems.size(); ++i) {
auto& itemToComplete = completionItems[i];
auto identifier = itemToComplete.identifier;
if (!identifier) {
failures.append(ManipulationFailure { identifier, i, ManipulationFailureType::InvalidItem });
continue;
}
auto itemDataIterator = m_items.find(identifier);
if (itemDataIterator == m_items.end()) {
failures.append(ManipulationFailure { identifier, i, ManipulationFailureType::InvalidItem });
continue;
}
ManipulationItemData itemData;
std::exchange(itemData, itemDataIterator->value);
m_items.remove(itemDataIterator);
auto failureOrNullopt = replace(itemData, itemToComplete.tokens);
if (failureOrNullopt)
failures.append(ManipulationFailure { identifier, i, *failureOrNullopt });
}
return failures;
}
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 ManipulationItemData& item, const Vector<ManipulationToken>& replacementTokens) -> Optional<ManipulationFailureType>
{
if (item.start.isOrphan() || item.end.isOrphan())
return ManipulationFailureType::ContentChanged;
size_t currentTokenIndex = 0;
HashMap<TokenIdentifier, TokenExchangeData> tokenExchangeMap;
if (item.start.isNull() || item.end.isNull()) {
RELEASE_ASSERT(item.tokens.size() == 1);
auto element = makeRefPtr(item.element.get());
if (!element)
return ManipulationFailureType::ContentChanged;
if (replacementTokens.size() > 1)
return ManipulationFailureType::InvalidToken;
String newValue;
if (!replacementTokens.isEmpty()) {
if (replacementTokens[0].identifier != item.tokens[0].identifier)
return ManipulationFailureType::InvalidToken;
newValue = replacementTokens[0].content;
}
if (item.attributeName == nullQName())
element->setTextContent(newValue);
else
element->setAttribute(item.attributeName, newValue);
return WTF::nullopt;
}
RefPtr<Node> commonAncestor;
RefPtr<Node> firstContentNode;
ParagraphContentIterator iterator { item.start, item.end };
HashSet<Ref<Node>> excludedNodes;
HashSet<Ref<Node>> nodesToRemove;
for (; !iterator.atEnd(); iterator.advance()) {
auto content = iterator.currentContent();
if (content.node)
nodesToRemove.add(*content.node);
if (!content.isReplacedContent && !content.isTextContent)
continue;
if (currentTokenIndex >= item.tokens.size())
return ManipulationFailureType::ContentChanged;
auto& currentToken = item.tokens[currentTokenIndex];
if (!content.isReplacedContent && content.text != currentToken.content)
return ManipulationFailureType::ContentChanged;
tokenExchangeMap.set(currentToken.identifier, TokenExchangeData { content.node.copyRef(), currentToken.content, currentToken.isExcluded });
++currentTokenIndex;
if (!firstContentNode)
firstContentNode = content.node;
// FIXME: Take care of when currentNode is nullptr.
if (RefPtr<Node> parent = content.node ? content.node->parentNode() : nullptr) {
if (!commonAncestor)
commonAncestor = parent;
else if (!parent->isDescendantOf(commonAncestor.get())) {
commonAncestor = Range::commonAncestorContainer(commonAncestor.get(), parent.get());
ASSERT(commonAncestor);
}
}
}
for (auto node = commonAncestor; node; node = node->parentNode())
nodesToRemove.remove(*node);
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 ManipulationFailureType::InvalidToken;
auto& exchangeData = it->value;
RefPtr<Node> contentNode;
if (exchangeData.isExcluded) {
if (exchangeData.isConsumed)
return ManipulationFailureType::ExclusionViolation;
exchangeData.isConsumed = true;
if (!newToken.content.isNull() && newToken.content != exchangeData.originalContent)
return ManipulationFailureType::ExclusionViolation;
contentNode = exchangeData.node;
for (RefPtr<Node> currentDescendentNode = NodeTraversal::next(*contentNode, contentNode.get()); currentDescendentNode; currentDescendentNode = NodeTraversal::next(*currentDescendentNode, contentNode.get()))
nodesToRemove.remove(*currentDescendentNode);
} 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 = positionBeforeNode(firstContentNode.get()).parentAnchoredEquivalent();
while (insertionPoint.containerNode() != commonAncestor)
insertionPoint = positionInParentBeforeNode(insertionPoint.containerNode());
ASSERT(!insertionPoint.isNull());
ASSERT(!insertionPoint.isOrphan());
for (auto& node : nodesToRemove)
node->remove();
for (auto& insertion : insertions) {
if (!insertion.parentIfDifferentFromCommonAncestor) {
insertionPoint.containerNode()->insertBefore(insertion.child, insertionPoint.computeNodeAfterPosition());
insertionPoint = positionInParentAfterNode(insertion.child.ptr());
} else
insertion.parentIfDifferentFromCommonAncestor->appendChild(insertion.child);
if (is<Element>(insertion.child.get()))
m_manipulatedElements.add(downcast<Element>(insertion.child.get()));
}
return WTF::nullopt;
}
} // namespace WebCore