/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #ifndef mozilla_SelectionState_h #define mozilla_SelectionState_h #include "mozilla/EditorDOMPoint.h" #include "mozilla/EditorForwards.h" #include "mozilla/Maybe.h" #include "mozilla/OwningNonNull.h" #include "mozilla/dom/Document.h" #include "nsCOMPtr.h" #include "nsDirection.h" #include "nsINode.h" #include "nsRange.h" #include "nsTArray.h" #include "nscore.h" class nsCycleCollectionTraversalCallback; class nsRange; namespace mozilla { namespace dom { class Element; class Selection; class Text; } // namespace dom /** * A helper struct for saving/setting ranges. */ struct RangeItem final { RangeItem() : mStartOffset(0), mEndOffset(0) {} private: // Private destructor, to discourage deletion outside of Release(): ~RangeItem() = default; public: void StoreRange(const nsRange& aRange); void StoreRange(const EditorRawDOMPoint& aStartPoint, const EditorRawDOMPoint& aEndPoint) { MOZ_ASSERT(aStartPoint.IsSet()); MOZ_ASSERT(aEndPoint.IsSet()); mStartContainer = aStartPoint.GetContainer(); mStartOffset = aStartPoint.Offset(); mEndContainer = aEndPoint.GetContainer(); mEndOffset = aEndPoint.Offset(); } void Clear() { mStartContainer = mEndContainer = nullptr; mStartOffset = mEndOffset = 0; } already_AddRefed GetRange() const; // Same as the API of dom::AbstractRange [[nodiscard]] nsINode* GetRoot() const; [[nodiscard]] bool Collapsed() const { return mStartContainer == mEndContainer && mStartOffset == mEndOffset; } [[nodiscard]] bool IsPositioned() const { return mStartContainer && mEndContainer; } [[nodiscard]] bool Equals(const RangeItem& aOther) const { return mStartContainer == aOther.mStartContainer && mEndContainer == aOther.mEndContainer && mStartOffset == aOther.mStartOffset && mEndOffset == aOther.mEndOffset; } template EditorDOMPointType StartPoint() const { return EditorDOMPointType(mStartContainer, mStartOffset); } template EditorDOMPointType EndPoint() const { return EditorDOMPointType(mEndContainer, mEndOffset); } NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(RangeItem) NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(RangeItem) nsCOMPtr mStartContainer; nsCOMPtr mEndContainer; uint32_t mStartOffset; uint32_t mEndOffset; }; /** * mozilla::SelectionState * * Class for recording selection info. Stores selection as collection of * { {startnode, startoffset} , {endnode, endoffset} } tuples. Can't store * ranges since dom gravity will possibly change the ranges. */ class SelectionState final { public: SelectionState() = default; explicit SelectionState(const AutoClonedSelectionRangeArray& aRanges); /** * Same as the API as dom::Selection */ [[nodiscard]] bool IsCollapsed() const { if (mArray.Length() != 1) { return false; } return mArray[0]->Collapsed(); } void RemoveAllRanges() { mArray.Clear(); mDirection = eDirNext; } [[nodiscard]] uint32_t RangeCount() const { return mArray.Length(); } /** * Saving all ranges of aSelection. */ void SaveSelection(dom::Selection& aSelection); /** * Setting aSelection to have all ranges stored by this instance. */ MOZ_CAN_RUN_SCRIPT_BOUNDARY nsresult RestoreSelection(dom::Selection& aSelection); /** * Setting aRanges to have all ranges stored by this instance. */ void ApplyTo(AutoClonedSelectionRangeArray& aRanges); /** * HasOnlyCollapsedRange() returns true only when there is a positioned range * which is collapsed. I.e., the selection represents a caret point. */ [[nodiscard]] bool HasOnlyCollapsedRange() const { if (mArray.Length() != 1) { return false; } if (!mArray[0]->IsPositioned() || !mArray[0]->Collapsed()) { return false; } return true; } /** * Equals() returns true only when there are same number of ranges and * all their containers and offsets are exactly same. This won't check * the validity of each range with the current DOM tree. */ [[nodiscard]] bool Equals(const SelectionState& aOther) const; /** * Returns common root node of all ranges' start and end containers. * Some of them have different root nodes, this returns nullptr. */ [[nodiscard]] nsINode* GetCommonRootNode() const { nsINode* rootNode = nullptr; for (const RefPtr& rangeItem : mArray) { nsINode* newRootNode = rangeItem->GetRoot(); if (!newRootNode || (rootNode && rootNode != newRootNode)) { return nullptr; } rootNode = newRootNode; } return rootNode; } private: CopyableAutoTArray, 1> mArray; nsDirection mDirection = eDirNext; friend class RangeUpdater; friend void ImplCycleCollectionTraverse(nsCycleCollectionTraversalCallback&, SelectionState&, const char*, uint32_t); friend void ImplCycleCollectionUnlink(SelectionState&); }; inline void ImplCycleCollectionTraverse( nsCycleCollectionTraversalCallback& aCallback, SelectionState& aField, const char* aName, uint32_t aFlags = 0) { ImplCycleCollectionTraverse(aCallback, aField.mArray, aName, aFlags); } inline void ImplCycleCollectionUnlink(SelectionState& aField) { ImplCycleCollectionUnlink(aField.mArray); } class MOZ_STACK_CLASS RangeUpdater final { public: RangeUpdater(); void RegisterRangeItem(RangeItem& aRangeItem); void DropRangeItem(RangeItem& aRangeItem); void RegisterSelectionState(SelectionState& aSelectionState); void DropSelectionState(SelectionState& aSelectionState); // editor selection gravity routines. Note that we can't always depend on // DOM Range gravity to do what we want to the "real" selection. For // instance, if you move a node, that corresponds to deleting it and // reinserting it. DOM Range gravity will promote the selection out of the // node on deletion, which is not what you want if you know you are // reinserting it. template nsresult SelAdjCreateNode(const EditorDOMPointBase& aPoint); template nsresult SelAdjInsertNode(const EditorDOMPointBase& aPoint); void SelAdjDeleteNode(nsINode& aNode); /** * SelAdjSplitNode() is called immediately after spliting aOriginalNode * and inserted aNewContent into the DOM tree. * * @param aOriginalContent The node which was split. * @param aSplitOffset The old offset in aOriginalContent at splitting * it. * @param aNewContent The new content node which was inserted into * the DOM tree. */ nsresult SelAdjSplitNode(nsIContent& aOriginalContent, uint32_t aSplitOffset, nsIContent& aNewContent); /** * SelAdjJoinNodes() is called immediately after joining aRemovedContent and * the container of aStartOfRightContent. * * @param aStartOfRightContent The container is joined content node which * now has all children or text data which were * in aRemovedContent. And this points where * the joined position. * @param aRemovedContent The removed content. * @param aOldPointAtRightContent The point where the right content node was * before joining them. The offset must have * been initialized before the joining. */ nsresult SelAdjJoinNodes(const EditorRawDOMPoint& aStartOfRightContent, const nsIContent& aRemovedContent, const EditorDOMPoint& aOldPointAtRightContent); void SelAdjInsertText(const dom::Text& aTextNode, uint32_t aOffset, uint32_t aInsertedLength); void SelAdjDeleteText(const dom::Text& aTextNode, uint32_t aOffset, uint32_t aDeletedLength); void SelAdjReplaceText(const dom::Text& aTextNode, uint32_t aOffset, uint32_t aReplacedLength, uint32_t aInsertedLength); // the following gravity routines need will/did sandwiches, because the other // gravity routines will be called inside of these sandwiches, but should be // ignored. void WillReplaceContainer() { // XXX Isn't this possible with mutation event listener? NS_WARNING_ASSERTION(!mLocked, "Has already been locked"); mLocked = true; } void DidReplaceContainer(const dom::Element& aRemovedElement, dom::Element& aInsertedElement); void WillRemoveContainer() { // XXX Isn't this possible with mutation event listener? NS_WARNING_ASSERTION(!mLocked, "Has already been locked"); mLocked = true; } void DidRemoveContainer(const dom::Element& aRemovedElement, nsINode& aRemovedElementContainerNode, uint32_t aOldOffsetOfRemovedElement, uint32_t aOldChildCountOfRemovedElement); void WillInsertContainer() { // XXX Isn't this possible with mutation event listener? NS_WARNING_ASSERTION(!mLocked, "Has already been locked"); mLocked = true; } void DidInsertContainer() { NS_WARNING_ASSERTION(mLocked, "Not locked"); mLocked = false; } template struct SimpleEditorDOMPointBase { SimpleEditorDOMPointBase() = default; SimpleEditorDOMPointBase(const nsINode* aContainer, const nsIContent* aChild, uint32_t aOffset) : mContainer(const_cast(aContainer)), mChild(const_cast(aChild)), mOffset(Some(aOffset)) {} SimpleEditorDOMPointBase(const nsIContent* aChild, uint32_t aOffset) : mContainer(aChild->GetParentNode()), mChild(const_cast(aChild)), mOffset(Some(aOffset)) {} SimpleEditorDOMPointBase(const nsINode* aContainer, const nsIContent* aChild) : mContainer(const_cast(aContainer)), mChild(const_cast(aChild)) {} explicit SimpleEditorDOMPointBase(const nsIContent* aChild) : mContainer(aChild->GetParentNode()), mChild(const_cast(aChild)) {} uint32_t Offset() const { if (!mOffset && mContainer) { mOffset = mContainer->ComputeIndexOf(mChild); } return mOffset.valueOr(0); } nsIContent* GetNextSiblingOfChild() const { return mChild->GetNextSibling(); } PT mContainer; CT mChild; mutable Maybe mOffset; }; using SimpleEditorDOMPoint = SimpleEditorDOMPointBase, nsCOMPtr>; using SimpleEditorRawDOMPoint = SimpleEditorDOMPointBase; void DidMoveNodes(const nsTArray& aOldPoints, const SimpleEditorDOMPoint& aExpectedDestination, const nsTArray& aNewPoints); private: // TODO: A lot of loop in these methods check whether each item `nullptr` or // not. We should make it not nullable later. nsTArray> mArray; bool mLocked; }; enum class StopTracking : bool { No, Yes }; /** * Helper class for using SelectionState. Stack based class for doing * preservation of dom points across editor actions. */ class MOZ_STACK_CLASS AutoTrackDOMPoint final { public: AutoTrackDOMPoint() = delete; AutoTrackDOMPoint(RangeUpdater& aRangeUpdater, CaretPoint* aCaretPoint); AutoTrackDOMPoint(RangeUpdater& aRangeUpdater, EditorDOMPoint* aPoint) : mRangeUpdater(aRangeUpdater), mPoint(*aPoint), mRangeItem(do_AddRef(new RangeItem())) { Init(); } private: void Init() { if (!mPoint.IsSet()) { mIsTracking = false; mWasConnected = false; return; // Nothing should be tracked. } mRangeItem->mStartContainer = mPoint.GetContainer(); mRangeItem->mEndContainer = mPoint.GetContainer(); mRangeItem->mStartOffset = mPoint.Offset(); mRangeItem->mEndOffset = mPoint.Offset(); mDocument = mPoint.GetContainer()->OwnerDoc(); mWasConnected = mPoint.IsInComposedDoc(); mIsTracking = true; mRangeUpdater.RegisterRangeItem(mRangeItem); } public: ~AutoTrackDOMPoint() { Flush(StopTracking::Yes); } void Flush(StopTracking aStopTracking) { if (!mIsTracking) { return; } if (static_cast(aStopTracking)) { mIsTracking = false; } if (!mIsTracking) { mRangeUpdater.DropRangeItem(mRangeItem); } // Setting `mPoint` with invalid DOM point causes hitting `NS_ASSERTION()` // and the number of times may be too many. (E.g., 1533913.html hits // over 700 times!) We should just put warning instead. if (NS_WARN_IF(!mRangeItem->mStartContainer)) { mPoint.Clear(); return; } // If the node was removed from the original document, clear the instance // since the user should not keep handling the adopted or orphan node // anymore. if (NS_WARN_IF(mWasConnected && !mRangeItem->mStartContainer->IsInComposedDoc()) || NS_WARN_IF(mRangeItem->mStartContainer->OwnerDoc() != mDocument)) { mPoint.Clear(); return; } if (NS_WARN_IF(mRangeItem->mStartContainer->Length() < mRangeItem->mStartOffset)) { mPoint.SetToEndOf(mRangeItem->mStartContainer); return; } mPoint.Set(mRangeItem->mStartContainer, mRangeItem->mStartOffset); } void StopTracking() { if (mIsTracking) { mIsTracking = false; mRangeUpdater.DropRangeItem(mRangeItem); } } /** * Restart to track for the current mPoint value. I.e., the caller must have * changed mPoint and wants to track the new point. Be aware, this does not * flush, of course. Therefore, you'll completely lose the old point * completely even if this fails to restart to track the new point. */ void RestartToTrack() { StopTracking(); MOZ_ASSERT(mPoint.IsSetAndValid()); MOZ_ASSERT(mPoint.GetContainer()->OwnerDoc() == mDocument); Init(); } private: RangeUpdater& mRangeUpdater; EditorDOMPoint& mPoint; OwningNonNull mRangeItem; RefPtr mDocument; bool mIsTracking = false; bool mWasConnected = false; }; class MOZ_STACK_CLASS AutoTrackDOMRange final { public: AutoTrackDOMRange() = delete; AutoTrackDOMRange(RangeUpdater& aRangeUpdater, EditorDOMPoint* aStartPoint, EditorDOMPoint* aEndPoint) : mRangeRefPtr(nullptr), mRangeOwningNonNull(nullptr) { mStartPointTracker.emplace(aRangeUpdater, aStartPoint); mEndPointTracker.emplace(aRangeUpdater, aEndPoint); } AutoTrackDOMRange(RangeUpdater& aRangeUpdater, EditorDOMRange* aRange) : mRangeRefPtr(nullptr), mRangeOwningNonNull(nullptr) { mStartPointTracker.emplace( aRangeUpdater, const_cast(&aRange->StartRef())); mEndPointTracker.emplace(aRangeUpdater, const_cast(&aRange->EndRef())); } AutoTrackDOMRange(RangeUpdater& aRangeUpdater, const RefPtr* aRange) : mStartPoint((*aRange)->StartRef()), mEndPoint((*aRange)->EndRef()), mRangeRefPtr(aRange), mRangeOwningNonNull(nullptr) { mStartPointTracker.emplace(aRangeUpdater, &mStartPoint); mEndPointTracker.emplace(aRangeUpdater, &mEndPoint); } AutoTrackDOMRange(RangeUpdater& aRangeUpdater, const OwningNonNull* aRange) : mStartPoint((*aRange)->StartRef()), mEndPoint((*aRange)->EndRef()), mRangeRefPtr(nullptr), mRangeOwningNonNull(aRange) { mStartPointTracker.emplace(aRangeUpdater, &mStartPoint); mEndPointTracker.emplace(aRangeUpdater, &mEndPoint); } ~AutoTrackDOMRange() { Flush(StopTracking::Yes); } void Flush(StopTracking aStopTracking) { if (!mStartPointTracker && !mEndPointTracker) { return; } if (static_cast(aStopTracking)) { mStartPointTracker.reset(); mEndPointTracker.reset(); } else { if (mStartPointTracker) { mStartPointTracker->Flush(StopTracking::No); if (MOZ_UNLIKELY(!mStartPoint.IsSet())) { mStartPointTracker.reset(); } } if (mEndPointTracker) { mEndPointTracker->Flush(StopTracking::No); if (MOZ_UNLIKELY(!mEndPoint.IsSet())) { mEndPointTracker.reset(); } } } if (!mRangeRefPtr && !mRangeOwningNonNull) { // This must be created with EditorDOMRange or EditorDOMPoints. In the // cases, destroying mStartPointTracker and mEndPointTracker has done // everything which we need to do. return; } // Otherwise, update the DOM ranges by ourselves. if (mRangeRefPtr) { if (!mStartPoint.IsSet() || !mEndPoint.IsSet()) { (*mRangeRefPtr)->Reset(); return; } (*mRangeRefPtr) ->SetStartAndEnd(mStartPoint.ToRawRangeBoundary(), mEndPoint.ToRawRangeBoundary()); return; } if (mRangeOwningNonNull) { if (!mStartPoint.IsSet() || !mEndPoint.IsSet()) { (*mRangeOwningNonNull)->Reset(); return; } (*mRangeOwningNonNull) ->SetStartAndEnd(mStartPoint.ToRawRangeBoundary(), mEndPoint.ToRawRangeBoundary()); return; } } void StopTracking() { if (mStartPointTracker) { mStartPointTracker->StopTracking(); } if (mEndPointTracker) { mEndPointTracker->StopTracking(); } } private: Maybe mStartPointTracker; Maybe mEndPointTracker; EditorDOMPoint mStartPoint; EditorDOMPoint mEndPoint; const RefPtr* mRangeRefPtr; const OwningNonNull* mRangeOwningNonNull; }; class MOZ_STACK_CLASS AutoTrackDOMMoveNodeResult final { public: AutoTrackDOMMoveNodeResult() = delete; AutoTrackDOMMoveNodeResult(RangeUpdater& aRangeUpdater, MoveNodeResult* aMoveNodeResult); void Flush(StopTracking aStopTracking) { mTrackCaretPoint.Flush(aStopTracking); mTrackNextInsertionPoint.Flush(aStopTracking); mTrackMovedContentRange.Flush(aStopTracking); } void StopTracking() { mTrackCaretPoint.StopTracking(); mTrackNextInsertionPoint.StopTracking(); mTrackMovedContentRange.StopTracking(); } private: AutoTrackDOMPoint mTrackCaretPoint; AutoTrackDOMPoint mTrackNextInsertionPoint; AutoTrackDOMRange mTrackMovedContentRange; }; class MOZ_STACK_CLASS AutoTrackDOMDeleteRangeResult final { public: AutoTrackDOMDeleteRangeResult() = delete; AutoTrackDOMDeleteRangeResult(RangeUpdater& aRangeUpdater, DeleteRangeResult* aDeleteRangeResult); void Flush(StopTracking aStopTracking) { mTrackCaretPoint.Flush(aStopTracking); mTrackDeleteRange.Flush(aStopTracking); } void StopTracking() { mTrackCaretPoint.StopTracking(); mTrackDeleteRange.StopTracking(); } private: AutoTrackDOMPoint mTrackCaretPoint; AutoTrackDOMRange mTrackDeleteRange; }; class MOZ_STACK_CLASS AutoTrackLineBreak final { public: AutoTrackLineBreak() = delete; AutoTrackLineBreak(RangeUpdater& aRangeUpdater, EditorLineBreak* aLineBreak); ~AutoTrackLineBreak() { Flush(StopTracking::Yes); } void Flush(StopTracking aStopTracking); void StopTracking() { mTracker.StopTracking(); } private: EditorLineBreak* mLineBreak; EditorDOMPoint mPoint; AutoTrackDOMPoint mTracker; }; /** * Another helper class for SelectionState. Stack based class for doing * Will/DidReplaceContainer() */ class MOZ_STACK_CLASS AutoReplaceContainerSelNotify final { public: AutoReplaceContainerSelNotify() = delete; // FYI: Marked as `MOZ_CAN_RUN_SCRIPT` for avoiding to use strong pointers // for the members. MOZ_CAN_RUN_SCRIPT AutoReplaceContainerSelNotify(RangeUpdater& aRangeUpdater, dom::Element& aOriginalElement, dom::Element& aNewElement) : mRangeUpdater(aRangeUpdater), mOriginalElement(aOriginalElement), mNewElement(aNewElement) { mRangeUpdater.WillReplaceContainer(); } ~AutoReplaceContainerSelNotify() { mRangeUpdater.DidReplaceContainer(mOriginalElement, mNewElement); } private: RangeUpdater& mRangeUpdater; dom::Element& mOriginalElement; dom::Element& mNewElement; }; /** * Another helper class for SelectionState. Stack based class for doing * Will/DidRemoveContainer() */ class MOZ_STACK_CLASS AutoRemoveContainerSelNotify final { public: AutoRemoveContainerSelNotify() = delete; AutoRemoveContainerSelNotify(RangeUpdater& aRangeUpdater, const EditorRawDOMPoint& aAtRemovingElement) : mRangeUpdater(aRangeUpdater), mRemovingElement(*aAtRemovingElement.GetChild()->AsElement()), mParentNode(*aAtRemovingElement.GetContainer()), mOffsetInParent(aAtRemovingElement.Offset()), mChildCountOfRemovingElement(mRemovingElement->GetChildCount()) { MOZ_ASSERT(aAtRemovingElement.IsSet()); mRangeUpdater.WillRemoveContainer(); } ~AutoRemoveContainerSelNotify() { mRangeUpdater.DidRemoveContainer(mRemovingElement, mParentNode, mOffsetInParent, mChildCountOfRemovingElement); } private: RangeUpdater& mRangeUpdater; OwningNonNull mRemovingElement; OwningNonNull mParentNode; uint32_t mOffsetInParent; uint32_t mChildCountOfRemovingElement; }; /** * Another helper class for SelectionState. Stack based class for doing * Will/DidInsertContainer() * XXX The lock state isn't useful if the edit action is triggered from * a mutation event listener so that looks like that we can remove * this class. */ class MOZ_STACK_CLASS AutoInsertContainerSelNotify final { private: RangeUpdater& mRangeUpdater; public: AutoInsertContainerSelNotify() = delete; explicit AutoInsertContainerSelNotify(RangeUpdater& aRangeUpdater) : mRangeUpdater(aRangeUpdater) { mRangeUpdater.WillInsertContainer(); } ~AutoInsertContainerSelNotify() { mRangeUpdater.DidInsertContainer(); } }; /** * Another helper class for SelectionState. Stack based class for doing * DidMoveNode() */ class MOZ_STACK_CLASS AutoMoveNodeSelNotify final { public: using SimpleEditorDOMPoint = RangeUpdater::SimpleEditorDOMPoint; AutoMoveNodeSelNotify() = delete; explicit AutoMoveNodeSelNotify(RangeUpdater& aRangeUpdater, const EditorRawDOMPoint& aExpectedDestination) : mRangeUpdater(aRangeUpdater), mExpectedDestination(aExpectedDestination.GetContainer(), nullptr, aExpectedDestination.Offset()) {} AutoMoveNodeSelNotify(RangeUpdater& aRangeUpdater, nsIContent& aContent, const EditorRawDOMPoint& aExpectedDestination) : mRangeUpdater(aRangeUpdater), mExpectedDestination(aExpectedDestination.GetContainer(), nullptr, aExpectedDestination.Offset()) { if (aContent.GetParentNode()) { mOldPoints.AppendElement(SimpleEditorDOMPoint( &aContent, aContent.ComputeIndexInParentNode().valueOr(0))); return; } mOldPoints.AppendElement(SimpleEditorDOMPoint(&aContent)); } void AppendContentWhichWillBeMoved(nsIContent& aContent) { if (!mOldPoints.IsEmpty() && mOldPoints.LastElement().GetNextSiblingOfChild() == &aContent) { mOldPoints.AppendElement(SimpleEditorDOMPoint( &aContent, mOldPoints.LastElement().Offset() + 1)); return; } if (aContent.GetParentNode()) { mOldPoints.AppendElement(SimpleEditorDOMPoint( &aContent, aContent.ComputeIndexInParentNode().valueOr(0))); return; } mOldPoints.AppendElement(SimpleEditorDOMPoint(&aContent)); } void DidMoveContent(nsIContent& aContent) { if (!mNewPoints.IsEmpty() && mNewPoints.LastElement().GetNextSiblingOfChild() == &aContent) { mNewPoints.AppendElement(SimpleEditorDOMPoint( &aContent, mNewPoints.LastElement().Offset() + 1)); return; } // Compute offset when we need it. mNewPoints.AppendElement(SimpleEditorDOMPoint(&aContent)); } ~AutoMoveNodeSelNotify() { mRangeUpdater.DidMoveNodes(mOldPoints, mExpectedDestination, mNewPoints); } [[nodiscard]] size_t MovingContentCount() const { return mOldPoints.Length(); } [[nodiscard]] nsIContent* GetContentAt(size_t index) const { return mOldPoints[index].mChild; } private: RangeUpdater& mRangeUpdater; SimpleEditorDOMPoint mExpectedDestination; AutoTArray mOldPoints; AutoTArray mNewPoints; }; } // namespace mozilla #endif // #ifndef mozilla_SelectionState_h