parentNode ); $parent = $b; do { // While walking up the parents of $b we found $a is a parent of $b or even identical if ( $parent === $a ) { return $a; } $ancestorsB[] = $parent; } while ( $parent = $parent->parentNode ); $node = null; // Start with the top-most (hopefully) identical root node, walk down, skip everything // that's identical, and stop at the first mismatch $indexA = count( $ancestorsA ); $indexB = count( $ancestorsB ); while ( $indexA-- && $indexB-- && $ancestorsA[$indexA] === $ancestorsB[$indexB] ) { // Remember the last match closest to $a and $b $node = $ancestorsA[$indexA]; } if ( !$node ) { throw new DOMException( 'Nodes are not in the same document' ); } return $node; } /** * Get the root ancestor of a node * * @param Node $node * @return Node */ private static function getRootNode( Node $node ): Node { while ( $node->parentNode ) { $node = $node->parentNode; '@phan-var Node $node'; } return $node; } /** * @param Node $startNode * @param int $startOffset * @param Node $endNode * @param int $endOffset */ public function __construct( Node $startNode, int $startOffset, Node $endNode, int $endOffset ) { $this->mStartContainer = $startNode; $this->mStartOffset = $startOffset; $this->mEndContainer = $endNode; $this->mEndOffset = $endOffset; } /** * @param string $field Field name * @return mixed */ public function __get( string $field ) { switch ( $field ) { case 'collapsed': return $this->mStartContainer === $this->mEndContainer && $this->mStartOffset === $this->mEndOffset; case 'commonAncestorContainer': if ( !$this->mCommonAncestorContainer ) { $this->mCommonAncestorContainer = static::findCommonAncestorContainer( $this->mStartContainer, $this->mEndContainer ); } return $this->mCommonAncestorContainer; case 'endContainer': return $this->mEndContainer; case 'endOffset': return $this->mEndOffset; case 'startContainer': return $this->mStartContainer; case 'startOffset': return $this->mStartOffset; default: throw new RuntimeException( 'Invalid property: ' . $field ); } } /** * Clone range with a new start position * * @param Node $startNode * @param int $startOffset * @return self */ public function setStart( Node $startNode, int $startOffset ): self { return $this->setStartOrEnd( 'start', $startNode, $startOffset ); } /** * Clone range with a new end position * * @param Node $endNode * @param int $endOffset * @return self */ public function setEnd( Node $endNode, int $endOffset ): self { return $this->setStartOrEnd( 'end', $endNode, $endOffset ); } /** * Sets the start or end boundary point for the Range. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * @see https://dom.spec.whatwg.org/#concept-range-bp-set * * @param string $type Which boundary point should be set. Valid values are start or end. * @param Node $node The Node that will become the boundary. * @param int $offset The offset within the given Node that will be the boundary. * @return self */ private function setStartOrEnd( string $type, Node $node, int $offset ): self { if ( $node instanceof DocumentType ) { throw new DOMException(); } switch ( $type ) { case 'start': $endContainer = $this->mEndContainer; $endOffset = $this->mEndOffset; if ( self::getRootNode( $this->mStartContainer ) !== self::getRootNode( $node ) || $this->computePosition( $node, $offset, $this->mEndContainer, $this->mEndOffset ) === 'after' ) { $endContainer = $node; $endOffset = $offset; } return new self( $node, $offset, $endContainer, $endOffset ); case 'end': $startContainer = $this->mStartContainer; $startOffset = $this->mStartOffset; if ( self::getRootNode( $this->mStartContainer ) !== self::getRootNode( $node ) || $this->computePosition( $node, $offset, $this->mStartContainer, $this->mStartOffset ) === 'before' ) { $startContainer = $node; $startOffset = $offset; } return new self( $startContainer, $startOffset, $node, $offset ); } } /** * Returns true if only a portion of the Node is contained within the Range. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * @see https://dom.spec.whatwg.org/#partially-contained * * @param Node $node The Node to check against. * @return bool */ private function isPartiallyContainedNode( Node $node ): bool { return CommentUtils::contains( $node, $this->mStartContainer ) xor CommentUtils::contains( $node, $this->mEndContainer ); } /** * Returns true if the entire Node is within the Range, otherwise false. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * @see https://dom.spec.whatwg.org/#contained * * @param Node $node The Node to check against. * @return bool */ private function isFullyContainedNode( Node $node ): bool { return static::getRootNode( $node ) === static::getRootNode( $this->mStartContainer ) && $this->computePosition( $node, 0, $this->mStartContainer, $this->mStartOffset ) === 'after' && $this->computePosition( // @phan-suppress-next-line PhanUndeclaredProperty $node, $node->length ?? $node->childNodes->length, $this->mEndContainer, $this->mEndOffset ) === 'before'; } /** * Extracts the content of the Range from the node tree and places it in a * DocumentFragment. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * @see https://dom.spec.whatwg.org/#dom-range-extractcontents * * @return DocumentFragment */ public function extractContents(): DocumentFragment { $fragment = $this->mStartContainer->ownerDocument->createDocumentFragment(); if ( $this->mStartContainer === $this->mEndContainer && $this->mStartOffset === $this->mEndOffset ) { return $fragment; } $originalStartNode = $this->mStartContainer; $originalStartOffset = $this->mStartOffset; $originalEndNode = $this->mEndContainer; $originalEndOffset = $this->mEndOffset; if ( $originalStartNode === $originalEndNode && ( $originalStartNode instanceof Text || $originalStartNode instanceof ProcessingInstruction || $originalStartNode instanceof Comment ) ) { $clone = $originalStartNode->cloneNode(); Assert::precondition( $clone instanceof CharacterData, 'TODO' ); $clone->data = $originalStartNode->substringData( $originalStartOffset, $originalEndOffset - $originalStartOffset ); $fragment->appendChild( $clone ); $originalStartNode->replaceData( $originalStartOffset, $originalEndOffset - $originalStartOffset, '' ); return $fragment; } $commonAncestor = $this->commonAncestorContainer; // It should be impossible for common ancestor to be null here since both nodes should be // in the same tree. Assert::precondition( $commonAncestor !== null, 'TODO' ); $firstPartiallyContainedChild = null; if ( !CommentUtils::contains( $originalStartNode, $originalEndNode ) ) { foreach ( $commonAncestor->childNodes as $node ) { if ( $this->isPartiallyContainedNode( $node ) ) { $firstPartiallyContainedChild = $node; break; } } } $lastPartiallyContainedChild = null; if ( !CommentUtils::contains( $originalEndNode, $originalStartNode ) ) { $node = $commonAncestor->lastChild; while ( $node ) { if ( $this->isPartiallyContainedNode( $node ) ) { $lastPartiallyContainedChild = $node; break; } $node = $node->previousSibling; } } $containedChildren = []; foreach ( $commonAncestor->childNodes as $childNode ) { if ( $this->isFullyContainedNode( $childNode ) ) { if ( $childNode instanceof DocumentType ) { throw new DOMException(); } $containedChildren[] = $childNode; } } if ( CommentUtils::contains( $originalStartNode, $originalEndNode ) ) { $newNode = $originalStartNode; $newOffset = $originalStartOffset; } else { $referenceNode = $originalStartNode; $parent = $referenceNode->parentNode; while ( $parent && !CommentUtils::contains( $parent, $originalEndNode ) ) { $referenceNode = $parent; $parent = $referenceNode->parentNode; } // Note: If reference node’s parent is null, it would be the root of range, so would be an inclusive // ancestor of original end node, and we could not reach this point. Assert::precondition( $parent !== null, 'TODO' ); $newNode = $parent; $newOffset = CommentUtils::childIndexOf( $referenceNode ) + 1; } if ( $firstPartiallyContainedChild instanceof Text || $firstPartiallyContainedChild instanceof ProcessingInstruction || $firstPartiallyContainedChild instanceof Comment ) { // Note: In this case, first partially contained child is original start node. Assert::precondition( $originalStartNode instanceof CharacterData, 'TODO' ); $clone = $originalStartNode->cloneNode(); Assert::precondition( $clone instanceof CharacterData, 'TODO' ); $clone->data = $originalStartNode->substringData( $originalStartOffset, $originalStartNode->length - $originalStartOffset ); $fragment->appendChild( $clone ); $originalStartNode->replaceData( $originalStartOffset, $originalStartNode->length - $originalStartOffset, '' ); } elseif ( $firstPartiallyContainedChild ) { $clone = $firstPartiallyContainedChild->cloneNode(); $fragment->appendChild( $clone ); $subrange = clone $this; $subrange->mStartContainer = $originalStartNode; $subrange->mStartOffset = $originalStartOffset; $subrange->mEndContainer = $firstPartiallyContainedChild; $subrange->mEndOffset = count( $firstPartiallyContainedChild->childNodes ); $subfragment = $subrange->extractContents(); $clone->appendChild( $subfragment ); } foreach ( $containedChildren as $child ) { $fragment->appendChild( $child ); } if ( $lastPartiallyContainedChild instanceof Text || $lastPartiallyContainedChild instanceof ProcessingInstruction || $lastPartiallyContainedChild instanceof Comment ) { // Note: In this case, last partially contained child is original end node. Assert::precondition( $originalEndNode instanceof CharacterData, 'TODO' ); $clone = $originalEndNode->cloneNode(); Assert::precondition( $clone instanceof CharacterData, 'TODO' ); $clone->data = $originalEndNode->substringData( 0, $originalEndOffset ); $fragment->appendChild( $clone ); $originalEndNode->replaceData( 0, $originalEndOffset, '' ); } elseif ( $lastPartiallyContainedChild ) { $clone = $lastPartiallyContainedChild->cloneNode(); $fragment->appendChild( $clone ); $subrange = clone $this; $subrange->mStartContainer = $lastPartiallyContainedChild; $subrange->mStartOffset = 0; $subrange->mEndContainer = $originalEndNode; $subrange->mEndOffset = $originalEndOffset; $subfragment = $subrange->extractContents(); $clone->appendChild( $subfragment ); } $this->mStartContainer = $newNode; $this->mStartOffset = $newOffset; $this->mEndContainer = $newNode; $this->mEndOffset = $newOffset; return $fragment; } /** * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * @see https://dom.spec.whatwg.org/#dom-range-clonecontents * * @return DocumentFragment */ public function cloneContents(): DocumentFragment { $ownerDocument = $this->mStartContainer->ownerDocument; $fragment = $ownerDocument->createDocumentFragment(); if ( $this->mStartContainer === $this->mEndContainer && $this->mStartOffset === $this->mEndOffset ) { return $fragment; } $originalStartContainer = $this->mStartContainer; $originalStartOffset = $this->mStartOffset; $originalEndContainer = $this->mEndContainer; $originalEndOffset = $this->mEndOffset; if ( $originalStartContainer === $originalEndContainer && ( $originalStartContainer instanceof Text || $originalStartContainer instanceof ProcessingInstruction || $originalStartContainer instanceof Comment ) ) { $clone = $originalStartContainer->cloneNode(); $clone->nodeValue = $originalStartContainer->substringData( $originalStartOffset, $originalEndOffset - $originalStartOffset ); $fragment->appendChild( $clone ); return $fragment; } $commonAncestor = static::findCommonAncestorContainer( $originalStartContainer, $originalEndContainer ); $firstPartiallyContainedChild = null; if ( !CommentUtils::contains( $originalStartContainer, $originalEndContainer ) ) { foreach ( $commonAncestor->childNodes as $node ) { if ( $this->isPartiallyContainedNode( $node ) ) { $firstPartiallyContainedChild = $node; break; } } } $lastPartiallyContainedChild = null; // Upstream uses lastChild then iterates over previousSibling, however this // is much slower that copying all the nodes to an array, at least when using // a native DOMNode, presumably because previousSibling is lazy-evaluated. if ( !CommentUtils::contains( $originalEndContainer, $originalStartContainer ) ) { $childNodes = iterator_to_array( $commonAncestor->childNodes ); foreach ( array_reverse( $childNodes ) as $node ) { if ( $this->isPartiallyContainedNode( $node ) ) { $lastPartiallyContainedChild = $node; break; } } } $containedChildrenStart = null; $containedChildrenEnd = null; $child = $firstPartiallyContainedChild ?: $commonAncestor->firstChild; for ( ; $child; $child = $child->nextSibling ) { if ( $this->isFullyContainedNode( $child ) ) { $containedChildrenStart = $child; break; } } $child = $lastPartiallyContainedChild ?: $commonAncestor->lastChild; for ( ; $child !== $containedChildrenStart; $child = $child->previousSibling ) { if ( $this->isFullyContainedNode( $child ) ) { $containedChildrenEnd = $child; break; } } if ( !$containedChildrenEnd ) { $containedChildrenEnd = $containedChildrenStart; } // $containedChildrenStart and $containedChildrenEnd may be null here, but this loop still works correctly for ( $child = $containedChildrenStart; $child !== $containedChildrenEnd; $child = $child->nextSibling ) { if ( $child instanceof DocumentType ) { throw new DOMException(); } } if ( $firstPartiallyContainedChild instanceof Text || $firstPartiallyContainedChild instanceof ProcessingInstruction || $firstPartiallyContainedChild instanceof Comment ) { $clone = $originalStartContainer->cloneNode(); Assert::precondition( $firstPartiallyContainedChild === $originalStartContainer, 'Only possible when the node is the startContainer' ); $clone->nodeValue = $firstPartiallyContainedChild->substringData( $originalStartOffset, $firstPartiallyContainedChild->length - $originalStartOffset ); $fragment->appendChild( $clone ); } elseif ( $firstPartiallyContainedChild ) { $clone = $firstPartiallyContainedChild->cloneNode(); $fragment->appendChild( $clone ); $subrange = new self( $originalStartContainer, $originalStartOffset, $firstPartiallyContainedChild, // @phan-suppress-next-line PhanUndeclaredProperty $firstPartiallyContainedChild->length ?? $firstPartiallyContainedChild->childNodes->length ); $subfragment = $subrange->cloneContents(); if ( $subfragment->hasChildNodes() ) { $clone->appendChild( $subfragment ); } } // $containedChildrenStart and $containedChildrenEnd may be null here, but this loop still works correctly for ( $child = $containedChildrenStart; $child !== $containedChildrenEnd; $child = $child->nextSibling ) { $clone = $child->cloneNode( true ); $fragment->appendChild( $clone ); } // If not null, this node wasn't processed by the loop if ( $containedChildrenEnd ) { $clone = $containedChildrenEnd->cloneNode( true ); $fragment->appendChild( $clone ); } if ( $lastPartiallyContainedChild instanceof Text || $lastPartiallyContainedChild instanceof ProcessingInstruction || $lastPartiallyContainedChild instanceof Comment ) { Assert::precondition( $lastPartiallyContainedChild === $originalEndContainer, 'Only possible when the node is the endContainer' ); $clone = $lastPartiallyContainedChild->cloneNode(); $clone->nodeValue = $lastPartiallyContainedChild->substringData( 0, $originalEndOffset ); $fragment->appendChild( $clone ); } elseif ( $lastPartiallyContainedChild ) { $clone = $lastPartiallyContainedChild->cloneNode(); $fragment->appendChild( $clone ); $subrange = new self( $lastPartiallyContainedChild, 0, $originalEndContainer, $originalEndOffset ); $subfragment = $subrange->cloneContents(); if ( $subfragment->hasChildNodes() ) { $clone->appendChild( $subfragment ); } } return $fragment; } /** * Inserts a new Node into at the start of the Range. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * * @see https://dom.spec.whatwg.org/#dom-range-insertnode * * @param Node $node The Node to be inserted. * @return void */ public function insertNode( Node $node ): void { if ( ( $this->mStartContainer instanceof ProcessingInstruction || $this->mStartContainer instanceof Comment ) || ( $this->mStartContainer instanceof Text && $this->mStartContainer->parentNode === null ) ) { throw new DOMException(); } $referenceNode = null; if ( $this->mStartContainer instanceof Text ) { $referenceNode = $this->mStartContainer; } else { $referenceNode = $this ->mStartContainer ->childNodes ->item( $this->mStartOffset ); } $parent = !$referenceNode ? $this->mStartContainer : $referenceNode->parentNode; // TODO: Restore this validation check? // $parent->ensurePreinsertionValidity( $node, $referenceNode ); if ( $this->mStartContainer instanceof Text ) { $referenceNode = $this->mStartContainer->splitText( $this->mStartOffset ); } if ( $node === $referenceNode ) { $referenceNode = $referenceNode->nextSibling; } if ( $node->parentNode ) { $node->parentNode->removeChild( $node ); } // TODO: Restore this validation check? // $parent->preinsertNode( $node, $referenceNode ); // $referenceNode may be null, this is okay $parent->insertBefore( $node, $referenceNode ); } /** * Wraps the content of Range in a new Node and inserts it in to the Document. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * * @see https://dom.spec.whatwg.org/#dom-range-surroundcontents * * @param Node $newParent New parent node for contents * @return void */ public function surroundContents( Node $newParent ): void { $commonAncestor = $this->commonAncestorContainer; if ( $commonAncestor ) { $tw = new TreeWalker( $commonAncestor ); $node = $tw->nextNode(); while ( $node ) { if ( !$node instanceof Text && $this->isPartiallyContainedNode( $node ) ) { throw new DOMException(); } $node = $tw->nextNode(); } } if ( $newParent instanceof Document || $newParent instanceof DocumentType || $newParent instanceof DocumentFragment ) { throw new DOMException(); } $fragment = $this->extractContents(); while ( $newParent->firstChild ) { $newParent->removeChild( $newParent->firstChild ); } $this->insertNode( $newParent ); $newParent->appendChild( $fragment ); // TODO: Return new range? } /** * Compares the position of two boundary points. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * @internal * * @see https://dom.spec.whatwg.org/#concept-range-bp-position * * @param Node $nodeA * @param int $offsetA * @param Node $nodeB * @param int $offsetB * @return string 'before'|'after'|'equal' */ private function computePosition( Node $nodeA, int $offsetA, Node $nodeB, int $offsetB ): string { // 1. Assert: nodeA and nodeB have the same root. // Removed, not necessary for our usage // 2. If nodeA is nodeB, then return equal if offsetA is offsetB, before if offsetA is less than offsetB, and // after if offsetA is greater than offsetB. if ( $nodeA === $nodeB ) { if ( $offsetA === $offsetB ) { return 'equal'; } elseif ( $offsetA < $offsetB ) { return 'before'; } else { return 'after'; } } $commonAncestor = $this->findCommonAncestorContainer( $nodeB, $nodeA ); if ( $commonAncestor === $nodeA ) { $AFollowsB = false; } elseif ( $commonAncestor === $nodeB ) { $AFollowsB = true; } else { // A was not found inside B. Traverse both A & B up to the nodes // before their common ancestor, then see if A is in the nextSibling // chain of B. $b = $nodeB; while ( $b->parentNode !== $commonAncestor ) { $b = $b->parentNode; } $a = $nodeA; while ( $a->parentNode !== $commonAncestor ) { $a = $a->parentNode; } $AFollowsB = false; while ( $b ) { if ( $a === $b ) { $AFollowsB = true; break; } $b = $b->nextSibling; } } if ( $AFollowsB ) { // Swap variables [ $nodeB, $nodeA ] = [ $nodeA, $nodeB ]; [ $offsetB, $offsetA ] = [ $offsetA, $offsetB ]; } $ancestor = $nodeB->parentNode; while ( $ancestor ) { if ( $ancestor === $nodeA ) { break; } $ancestor = $ancestor->parentNode; } if ( $ancestor ) { $child = $nodeB; while ( $child ) { if ( $child->parentNode === $nodeA ) { break; } $child = $child->parentNode; } // Phan complains that $child may be null here, but that can't happen, because at this point // we know that $nodeA is an ancestor of $nodeB, so the loop above will stop before the root. // @phan-suppress-next-line PhanTypeMismatchArgumentNullable if ( CommentUtils::childIndexOf( $child ) < $offsetA ) { return $AFollowsB ? 'before' : 'after'; } } return $AFollowsB ? 'after' : 'before'; } public const START_TO_START = 0; public const START_TO_END = 1; public const END_TO_END = 2; public const END_TO_START = 3; /** * Compares the boundary points of this Range with another Range. * * Ported from https://github.com/TRowbotham/PHPDOM (MIT) * * @see https://dom.spec.whatwg.org/#dom-range-compareboundarypoints * * @param int $how One of ImmutableRange::END_TO_END, ImmutableRange::END_TO_START, * ImmutableRange::START_TO_END, ImmutableRange::START_TO_START * @param ImmutableRange $sourceRange A Range whose boundary points are to be compared. * @return int -1, 0, or 1 */ public function compareBoundaryPoints( int $how, self $sourceRange ): int { if ( static::getRootNode( $this->mStartContainer ) !== static::getRootNode( $sourceRange->startContainer ) ) { throw new DOMException(); } switch ( $how ) { case static::START_TO_START: $thisPoint = [ $this->mStartContainer, $this->mStartOffset ]; $otherPoint = [ $sourceRange->startContainer, $sourceRange->startOffset ]; break; case static::START_TO_END: $thisPoint = [ $this->mEndContainer, $this->mEndOffset ]; $otherPoint = [ $sourceRange->startContainer, $sourceRange->startOffset ]; break; case static::END_TO_END: $thisPoint = [ $this->mEndContainer, $this->mEndOffset ]; $otherPoint = [ $sourceRange->endContainer, $sourceRange->endOffset ]; break; case static::END_TO_START: $thisPoint = [ $this->mStartContainer, $this->mStartOffset ]; $otherPoint = [ $sourceRange->endContainer, $sourceRange->endOffset ]; break; default: throw new DOMException(); } switch ( $this->computePosition( ...$thisPoint, ...$otherPoint ) ) { case 'before': return -1; case 'equal': return 0; case 'after': return 1; default: throw new DOMException(); } } }