Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
51.73% |
209 / 404 |
|
40.00% |
6 / 15 |
CRAP | |
0.00% |
0 / 1 |
ImmutableRange | |
51.73% |
209 / 404 |
|
40.00% |
6 / 15 |
2509.26 | |
0.00% |
0 / 1 |
findCommonAncestorContainer | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
7 | |||
getRootNode | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
__get | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
10.02 | |||
setStart | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setEnd | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setStartOrEnd | |
53.85% |
14 / 26 |
|
0.00% |
0 / 1 |
14.29 | |||
isPartiallyContainedNode | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
isFullyContainedNode | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
extractContents | |
0.00% |
0 / 110 |
|
0.00% |
0 / 1 |
812 | |||
cloneContents | |
74.07% |
80 / 108 |
|
0.00% |
0 / 1 |
54.14 | |||
insertNode | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
110 | |||
surroundContents | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
90 | |||
computePosition | |
100.00% |
40 / 40 |
|
100.00% |
1 / 1 |
19 | |||
compareBoundaryPoints | |
88.89% |
24 / 27 |
|
0.00% |
0 / 1 |
11.17 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\DiscussionTools; |
4 | |
5 | use DOMException; |
6 | use RuntimeException; |
7 | use Wikimedia\Assert\Assert; |
8 | use Wikimedia\Parsoid\DOM\CharacterData; |
9 | use Wikimedia\Parsoid\DOM\Comment; |
10 | use Wikimedia\Parsoid\DOM\Document; |
11 | use Wikimedia\Parsoid\DOM\DocumentFragment; |
12 | use Wikimedia\Parsoid\DOM\DocumentType; |
13 | use Wikimedia\Parsoid\DOM\Node; |
14 | use Wikimedia\Parsoid\DOM\ProcessingInstruction; |
15 | use Wikimedia\Parsoid\DOM\Text; |
16 | |
17 | /** |
18 | * ImmutableRange has a similar API to the DOM Range class. |
19 | * |
20 | * start/endContainer and offsets can be accessed, as can commonAncestorContainer |
21 | * which is lazy evaluated. |
22 | * |
23 | * setStart and setEnd are still available but return a cloned range. |
24 | * |
25 | * @property bool $collapsed |
26 | * @property Node $commonAncestorContainer |
27 | * @property Node $endContainer |
28 | * @property int $endOffset |
29 | * @property Node $startContainer |
30 | * @property int $startOffset |
31 | */ |
32 | class ImmutableRange { |
33 | private ?Node $mCommonAncestorContainer = null; |
34 | private Node $mEndContainer; |
35 | private int $mEndOffset; |
36 | private Node $mStartContainer; |
37 | private int $mStartOffset; |
38 | |
39 | /** |
40 | * Find the common ancestor container of two nodes |
41 | * |
42 | * @param Node $a |
43 | * @param Node $b |
44 | * @return Node Common ancestor container |
45 | */ |
46 | private static function findCommonAncestorContainer( Node $a, Node $b ): Node { |
47 | $ancestorsA = []; |
48 | $ancestorsB = []; |
49 | |
50 | $parent = $a; |
51 | do { |
52 | // While walking up the parents of $a we found $b is a parent of $a or even identical |
53 | if ( $parent === $b ) { |
54 | return $b; |
55 | } |
56 | $ancestorsA[] = $parent; |
57 | } while ( $parent = $parent->parentNode ); |
58 | |
59 | $parent = $b; |
60 | do { |
61 | // While walking up the parents of $b we found $a is a parent of $b or even identical |
62 | if ( $parent === $a ) { |
63 | return $a; |
64 | } |
65 | $ancestorsB[] = $parent; |
66 | } while ( $parent = $parent->parentNode ); |
67 | |
68 | $node = null; |
69 | // Start with the top-most (hopefully) identical root node, walk down, skip everything |
70 | // that's identical, and stop at the first mismatch |
71 | $indexA = count( $ancestorsA ); |
72 | $indexB = count( $ancestorsB ); |
73 | while ( $indexA-- && $indexB-- && $ancestorsA[$indexA] === $ancestorsB[$indexB] ) { |
74 | // Remember the last match closest to $a and $b |
75 | $node = $ancestorsA[$indexA]; |
76 | } |
77 | |
78 | if ( !$node ) { |
79 | throw new DOMException( 'Nodes are not in the same document' ); |
80 | } |
81 | |
82 | return $node; |
83 | } |
84 | |
85 | /** |
86 | * Get the root ancestor of a node |
87 | */ |
88 | private static function getRootNode( Node $node ): Node { |
89 | while ( $node->parentNode ) { |
90 | $node = $node->parentNode; |
91 | '@phan-var Node $node'; |
92 | } |
93 | |
94 | return $node; |
95 | } |
96 | |
97 | public function __construct( |
98 | Node $startNode, int $startOffset, Node $endNode, int $endOffset |
99 | ) { |
100 | $this->mStartContainer = $startNode; |
101 | $this->mStartOffset = $startOffset; |
102 | $this->mEndContainer = $endNode; |
103 | $this->mEndOffset = $endOffset; |
104 | } |
105 | |
106 | /** |
107 | * @param string $field Field name |
108 | * @return mixed |
109 | */ |
110 | public function __get( string $field ) { |
111 | switch ( $field ) { |
112 | case 'collapsed': |
113 | return $this->mStartContainer === $this->mEndContainer && |
114 | $this->mStartOffset === $this->mEndOffset; |
115 | case 'commonAncestorContainer': |
116 | if ( !$this->mCommonAncestorContainer ) { |
117 | $this->mCommonAncestorContainer = |
118 | static::findCommonAncestorContainer( $this->mStartContainer, $this->mEndContainer ); |
119 | } |
120 | return $this->mCommonAncestorContainer; |
121 | case 'endContainer': |
122 | return $this->mEndContainer; |
123 | case 'endOffset': |
124 | return $this->mEndOffset; |
125 | case 'startContainer': |
126 | return $this->mStartContainer; |
127 | case 'startOffset': |
128 | return $this->mStartOffset; |
129 | default: |
130 | throw new RuntimeException( 'Invalid property: ' . $field ); |
131 | } |
132 | } |
133 | |
134 | /** |
135 | * Clone range with a new start position |
136 | */ |
137 | public function setStart( Node $startNode, int $startOffset ): self { |
138 | return $this->setStartOrEnd( 'start', $startNode, $startOffset ); |
139 | } |
140 | |
141 | /** |
142 | * Clone range with a new end position |
143 | */ |
144 | public function setEnd( Node $endNode, int $endOffset ): self { |
145 | return $this->setStartOrEnd( 'end', $endNode, $endOffset ); |
146 | } |
147 | |
148 | /** |
149 | * Sets the start or end boundary point for the Range. |
150 | * |
151 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
152 | * @see https://dom.spec.whatwg.org/#concept-range-bp-set |
153 | * |
154 | * @param string $type Which boundary point should be set. Valid values are start or end. |
155 | * @param Node $node The Node that will become the boundary. |
156 | * @param int $offset The offset within the given Node that will be the boundary. |
157 | * @return self |
158 | */ |
159 | private function setStartOrEnd( string $type, Node $node, int $offset ): self { |
160 | if ( $node instanceof DocumentType ) { |
161 | throw new DOMException(); |
162 | } |
163 | |
164 | switch ( $type ) { |
165 | case 'start': |
166 | $endContainer = $this->mEndContainer; |
167 | $endOffset = $this->mEndOffset; |
168 | if ( |
169 | self::getRootNode( $this->mStartContainer ) !== self::getRootNode( $node ) || |
170 | $this->computePosition( |
171 | $node, $offset, $this->mEndContainer, $this->mEndOffset |
172 | ) === 'after' |
173 | ) { |
174 | $endContainer = $node; |
175 | $endOffset = $offset; |
176 | } |
177 | |
178 | return new self( |
179 | $node, $offset, $endContainer, $endOffset |
180 | ); |
181 | |
182 | case 'end': |
183 | $startContainer = $this->mStartContainer; |
184 | $startOffset = $this->mStartOffset; |
185 | if ( |
186 | self::getRootNode( $this->mStartContainer ) !== self::getRootNode( $node ) || |
187 | $this->computePosition( |
188 | $node, $offset, $this->mStartContainer, $this->mStartOffset |
189 | ) === 'before' |
190 | ) { |
191 | $startContainer = $node; |
192 | $startOffset = $offset; |
193 | } |
194 | |
195 | return new self( |
196 | $startContainer, $startOffset, $node, $offset |
197 | ); |
198 | } |
199 | } |
200 | |
201 | /** |
202 | * Returns true if only a portion of the Node is contained within the Range. |
203 | * |
204 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
205 | * @see https://dom.spec.whatwg.org/#partially-contained |
206 | * |
207 | * @param Node $node The Node to check against. |
208 | * @return bool |
209 | */ |
210 | private function isPartiallyContainedNode( Node $node ): bool { |
211 | return CommentUtils::contains( $node, $this->mStartContainer ) xor |
212 | CommentUtils::contains( $node, $this->mEndContainer ); |
213 | } |
214 | |
215 | /** |
216 | * Returns true if the entire Node is within the Range, otherwise false. |
217 | * |
218 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
219 | * @see https://dom.spec.whatwg.org/#contained |
220 | * |
221 | * @param Node $node The Node to check against. |
222 | * @return bool |
223 | */ |
224 | private function isFullyContainedNode( Node $node ): bool { |
225 | return static::getRootNode( $node ) === static::getRootNode( $this->mStartContainer ) |
226 | && $this->computePosition( $node, 0, $this->mStartContainer, $this->mStartOffset ) === 'after' |
227 | && $this->computePosition( |
228 | // @phan-suppress-next-line PhanUndeclaredProperty |
229 | $node, $node->length ?? $node->childNodes->length, |
230 | $this->mEndContainer, $this->mEndOffset |
231 | ) === 'before'; |
232 | } |
233 | |
234 | /** |
235 | * Extracts the content of the Range from the node tree and places it in a |
236 | * DocumentFragment. |
237 | * |
238 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
239 | * @see https://dom.spec.whatwg.org/#dom-range-extractcontents |
240 | */ |
241 | public function extractContents(): DocumentFragment { |
242 | $fragment = $this->mStartContainer->ownerDocument->createDocumentFragment(); |
243 | |
244 | if ( |
245 | $this->mStartContainer === $this->mEndContainer |
246 | && $this->mStartOffset === $this->mEndOffset |
247 | ) { |
248 | return $fragment; |
249 | } |
250 | |
251 | $originalStartNode = $this->mStartContainer; |
252 | $originalStartOffset = $this->mStartOffset; |
253 | $originalEndNode = $this->mEndContainer; |
254 | $originalEndOffset = $this->mEndOffset; |
255 | |
256 | if ( |
257 | $originalStartNode === $originalEndNode |
258 | && ( $originalStartNode instanceof Text |
259 | || $originalStartNode instanceof ProcessingInstruction |
260 | || $originalStartNode instanceof Comment ) |
261 | ) { |
262 | $clone = $originalStartNode->cloneNode(); |
263 | Assert::precondition( $clone instanceof CharacterData, 'TODO' ); |
264 | $clone->data = $originalStartNode->substringData( |
265 | $originalStartOffset, |
266 | $originalEndOffset - $originalStartOffset |
267 | ); |
268 | $fragment->appendChild( $clone ); |
269 | $originalStartNode->replaceData( |
270 | $originalStartOffset, |
271 | $originalEndOffset - $originalStartOffset, |
272 | '' |
273 | ); |
274 | |
275 | return $fragment; |
276 | } |
277 | |
278 | $commonAncestor = $this->commonAncestorContainer; |
279 | // It should be impossible for common ancestor to be null here since both nodes should be |
280 | // in the same tree. |
281 | Assert::precondition( $commonAncestor !== null, 'TODO' ); |
282 | $firstPartiallyContainedChild = null; |
283 | |
284 | if ( !CommentUtils::contains( $originalStartNode, $originalEndNode ) ) { |
285 | foreach ( $commonAncestor->childNodes as $node ) { |
286 | if ( $this->isPartiallyContainedNode( $node ) ) { |
287 | $firstPartiallyContainedChild = $node; |
288 | |
289 | break; |
290 | } |
291 | } |
292 | } |
293 | |
294 | $lastPartiallyContainedChild = null; |
295 | |
296 | if ( !CommentUtils::contains( $originalEndNode, $originalStartNode ) ) { |
297 | $node = $commonAncestor->lastChild; |
298 | |
299 | while ( $node ) { |
300 | if ( $this->isPartiallyContainedNode( $node ) ) { |
301 | $lastPartiallyContainedChild = $node; |
302 | |
303 | break; |
304 | } |
305 | |
306 | $node = $node->previousSibling; |
307 | } |
308 | } |
309 | |
310 | $containedChildren = []; |
311 | |
312 | foreach ( $commonAncestor->childNodes as $childNode ) { |
313 | if ( $this->isFullyContainedNode( $childNode ) ) { |
314 | if ( $childNode instanceof DocumentType ) { |
315 | throw new DOMException(); |
316 | } |
317 | |
318 | $containedChildren[] = $childNode; |
319 | } |
320 | } |
321 | |
322 | if ( CommentUtils::contains( $originalStartNode, $originalEndNode ) ) { |
323 | $newNode = $originalStartNode; |
324 | $newOffset = $originalStartOffset; |
325 | } else { |
326 | $referenceNode = $originalStartNode; |
327 | $parent = $referenceNode->parentNode; |
328 | |
329 | while ( $parent && !CommentUtils::contains( $parent, $originalEndNode ) ) { |
330 | $referenceNode = $parent; |
331 | $parent = $referenceNode->parentNode; |
332 | } |
333 | |
334 | // Note: If reference node’s parent is null, it would be the root of range, so would be an inclusive |
335 | // ancestor of original end node, and we could not reach this point. |
336 | Assert::precondition( $parent !== null, 'TODO' ); |
337 | $newNode = $parent; |
338 | $newOffset = CommentUtils::childIndexOf( $referenceNode ) + 1; |
339 | } |
340 | |
341 | if ( |
342 | $firstPartiallyContainedChild instanceof Text |
343 | || $firstPartiallyContainedChild instanceof ProcessingInstruction |
344 | || $firstPartiallyContainedChild instanceof Comment |
345 | ) { |
346 | // Note: In this case, first partially contained child is original start node. |
347 | Assert::precondition( $originalStartNode instanceof CharacterData, 'TODO' ); |
348 | $clone = $originalStartNode->cloneNode(); |
349 | Assert::precondition( $clone instanceof CharacterData, 'TODO' ); |
350 | $clone->data = $originalStartNode->substringData( |
351 | $originalStartOffset, |
352 | $originalStartNode->length - $originalStartOffset |
353 | ); |
354 | $fragment->appendChild( $clone ); |
355 | $originalStartNode->replaceData( |
356 | $originalStartOffset, |
357 | $originalStartNode->length - $originalStartOffset, |
358 | '' |
359 | ); |
360 | } elseif ( $firstPartiallyContainedChild ) { |
361 | $clone = $firstPartiallyContainedChild->cloneNode(); |
362 | $fragment->appendChild( $clone ); |
363 | $subrange = clone $this; |
364 | $subrange->mStartContainer = $originalStartNode; |
365 | $subrange->mStartOffset = $originalStartOffset; |
366 | $subrange->mEndContainer = $firstPartiallyContainedChild; |
367 | $subrange->mEndOffset = count( $firstPartiallyContainedChild->childNodes ); |
368 | $subfragment = $subrange->extractContents(); |
369 | $clone->appendChild( $subfragment ); |
370 | } |
371 | |
372 | foreach ( $containedChildren as $child ) { |
373 | $fragment->appendChild( $child ); |
374 | } |
375 | |
376 | if ( |
377 | $lastPartiallyContainedChild instanceof Text |
378 | || $lastPartiallyContainedChild instanceof ProcessingInstruction |
379 | || $lastPartiallyContainedChild instanceof Comment |
380 | ) { |
381 | // Note: In this case, last partially contained child is original end node. |
382 | Assert::precondition( $originalEndNode instanceof CharacterData, 'TODO' ); |
383 | $clone = $originalEndNode->cloneNode(); |
384 | Assert::precondition( $clone instanceof CharacterData, 'TODO' ); |
385 | $clone->data = $originalEndNode->substringData( 0, $originalEndOffset ); |
386 | $fragment->appendChild( $clone ); |
387 | $originalEndNode->replaceData( 0, $originalEndOffset, '' ); |
388 | } elseif ( $lastPartiallyContainedChild ) { |
389 | $clone = $lastPartiallyContainedChild->cloneNode(); |
390 | $fragment->appendChild( $clone ); |
391 | $subrange = clone $this; |
392 | $subrange->mStartContainer = $lastPartiallyContainedChild; |
393 | $subrange->mStartOffset = 0; |
394 | $subrange->mEndContainer = $originalEndNode; |
395 | $subrange->mEndOffset = $originalEndOffset; |
396 | $subfragment = $subrange->extractContents(); |
397 | $clone->appendChild( $subfragment ); |
398 | } |
399 | |
400 | $this->mStartContainer = $newNode; |
401 | $this->mStartOffset = $newOffset; |
402 | $this->mEndContainer = $newNode; |
403 | $this->mEndOffset = $newOffset; |
404 | |
405 | return $fragment; |
406 | } |
407 | |
408 | /** |
409 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
410 | * @see https://dom.spec.whatwg.org/#dom-range-clonecontents |
411 | */ |
412 | public function cloneContents(): DocumentFragment { |
413 | $ownerDocument = $this->mStartContainer->ownerDocument; |
414 | $fragment = $ownerDocument->createDocumentFragment(); |
415 | |
416 | if ( $this->mStartContainer === $this->mEndContainer |
417 | && $this->mStartOffset === $this->mEndOffset |
418 | ) { |
419 | return $fragment; |
420 | } |
421 | |
422 | $originalStartContainer = $this->mStartContainer; |
423 | $originalStartOffset = $this->mStartOffset; |
424 | $originalEndContainer = $this->mEndContainer; |
425 | $originalEndOffset = $this->mEndOffset; |
426 | |
427 | if ( $originalStartContainer === $originalEndContainer |
428 | && ( $originalStartContainer instanceof Text |
429 | || $originalStartContainer instanceof ProcessingInstruction |
430 | || $originalStartContainer instanceof Comment ) |
431 | ) { |
432 | $clone = $originalStartContainer->cloneNode(); |
433 | $clone->nodeValue = $originalStartContainer->substringData( |
434 | $originalStartOffset, |
435 | $originalEndOffset - $originalStartOffset |
436 | ); |
437 | $fragment->appendChild( $clone ); |
438 | |
439 | return $fragment; |
440 | } |
441 | |
442 | $commonAncestor = static::findCommonAncestorContainer( |
443 | $originalStartContainer, |
444 | $originalEndContainer |
445 | ); |
446 | $firstPartiallyContainedChild = null; |
447 | |
448 | if ( !CommentUtils::contains( $originalStartContainer, $originalEndContainer ) ) { |
449 | foreach ( $commonAncestor->childNodes as $node ) { |
450 | if ( $this->isPartiallyContainedNode( $node ) ) { |
451 | $firstPartiallyContainedChild = $node; |
452 | break; |
453 | } |
454 | } |
455 | } |
456 | |
457 | $lastPartiallyContainedChild = null; |
458 | |
459 | // Upstream uses lastChild then iterates over previousSibling, however this |
460 | // is much slower that copying all the nodes to an array, at least when using |
461 | // a native DOMNode, presumably because previousSibling is lazy-evaluated. |
462 | if ( !CommentUtils::contains( $originalEndContainer, $originalStartContainer ) ) { |
463 | $childNodes = iterator_to_array( $commonAncestor->childNodes ); |
464 | |
465 | foreach ( array_reverse( $childNodes ) as $node ) { |
466 | if ( $this->isPartiallyContainedNode( $node ) ) { |
467 | $lastPartiallyContainedChild = $node; |
468 | break; |
469 | } |
470 | } |
471 | } |
472 | |
473 | $containedChildrenStart = null; |
474 | $containedChildrenEnd = null; |
475 | |
476 | $child = $firstPartiallyContainedChild ?: $commonAncestor->firstChild; |
477 | for ( ; $child; $child = $child->nextSibling ) { |
478 | if ( $this->isFullyContainedNode( $child ) ) { |
479 | $containedChildrenStart = $child; |
480 | break; |
481 | } |
482 | } |
483 | |
484 | $child = $lastPartiallyContainedChild ?: $commonAncestor->lastChild; |
485 | for ( ; $child !== $containedChildrenStart; $child = $child->previousSibling ) { |
486 | if ( $this->isFullyContainedNode( $child ) ) { |
487 | $containedChildrenEnd = $child; |
488 | break; |
489 | } |
490 | } |
491 | if ( !$containedChildrenEnd ) { |
492 | $containedChildrenEnd = $containedChildrenStart; |
493 | } |
494 | |
495 | // $containedChildrenStart and $containedChildrenEnd may be null here, but this loop still works correctly |
496 | for ( $child = $containedChildrenStart; $child !== $containedChildrenEnd; $child = $child->nextSibling ) { |
497 | if ( $child instanceof DocumentType ) { |
498 | throw new DOMException(); |
499 | } |
500 | } |
501 | |
502 | if ( $firstPartiallyContainedChild instanceof Text |
503 | || $firstPartiallyContainedChild instanceof ProcessingInstruction |
504 | || $firstPartiallyContainedChild instanceof Comment |
505 | ) { |
506 | $clone = $originalStartContainer->cloneNode(); |
507 | Assert::precondition( |
508 | $firstPartiallyContainedChild === $originalStartContainer, |
509 | 'Only possible when the node is the startContainer' |
510 | ); |
511 | $clone->nodeValue = $firstPartiallyContainedChild->substringData( |
512 | $originalStartOffset, |
513 | $firstPartiallyContainedChild->length - $originalStartOffset |
514 | ); |
515 | $fragment->appendChild( $clone ); |
516 | } elseif ( $firstPartiallyContainedChild ) { |
517 | $clone = $firstPartiallyContainedChild->cloneNode(); |
518 | $fragment->appendChild( $clone ); |
519 | $subrange = new self( |
520 | $originalStartContainer, $originalStartOffset, |
521 | $firstPartiallyContainedChild, |
522 | // @phan-suppress-next-line PhanUndeclaredProperty |
523 | $firstPartiallyContainedChild->length ?? $firstPartiallyContainedChild->childNodes->length |
524 | ); |
525 | $subfragment = $subrange->cloneContents(); |
526 | if ( $subfragment->hasChildNodes() ) { |
527 | $clone->appendChild( $subfragment ); |
528 | } |
529 | } |
530 | |
531 | // $containedChildrenStart and $containedChildrenEnd may be null here, but this loop still works correctly |
532 | for ( $child = $containedChildrenStart; $child !== $containedChildrenEnd; $child = $child->nextSibling ) { |
533 | $clone = $child->cloneNode( true ); |
534 | $fragment->appendChild( $clone ); |
535 | } |
536 | // If not null, this node wasn't processed by the loop |
537 | if ( $containedChildrenEnd ) { |
538 | $clone = $containedChildrenEnd->cloneNode( true ); |
539 | $fragment->appendChild( $clone ); |
540 | } |
541 | |
542 | if ( $lastPartiallyContainedChild instanceof Text |
543 | || $lastPartiallyContainedChild instanceof ProcessingInstruction |
544 | || $lastPartiallyContainedChild instanceof Comment |
545 | ) { |
546 | Assert::precondition( |
547 | $lastPartiallyContainedChild === $originalEndContainer, |
548 | 'Only possible when the node is the endContainer' |
549 | ); |
550 | $clone = $lastPartiallyContainedChild->cloneNode(); |
551 | $clone->nodeValue = $lastPartiallyContainedChild->substringData( |
552 | 0, |
553 | $originalEndOffset |
554 | ); |
555 | $fragment->appendChild( $clone ); |
556 | } elseif ( $lastPartiallyContainedChild ) { |
557 | $clone = $lastPartiallyContainedChild->cloneNode(); |
558 | $fragment->appendChild( $clone ); |
559 | $subrange = new self( |
560 | $lastPartiallyContainedChild, 0, |
561 | $originalEndContainer, $originalEndOffset |
562 | ); |
563 | $subfragment = $subrange->cloneContents(); |
564 | if ( $subfragment->hasChildNodes() ) { |
565 | $clone->appendChild( $subfragment ); |
566 | } |
567 | } |
568 | |
569 | return $fragment; |
570 | } |
571 | |
572 | /** |
573 | * Inserts a new Node into at the start of the Range. |
574 | * |
575 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
576 | * |
577 | * @see https://dom.spec.whatwg.org/#dom-range-insertnode |
578 | * |
579 | * @param Node $node The Node to be inserted. |
580 | * @return void |
581 | */ |
582 | public function insertNode( Node $node ): void { |
583 | if ( ( $this->mStartContainer instanceof ProcessingInstruction |
584 | || $this->mStartContainer instanceof Comment ) |
585 | || ( $this->mStartContainer instanceof Text |
586 | && $this->mStartContainer->parentNode === null ) |
587 | ) { |
588 | throw new DOMException(); |
589 | } |
590 | |
591 | $referenceNode = null; |
592 | |
593 | if ( $this->mStartContainer instanceof Text ) { |
594 | $referenceNode = $this->mStartContainer; |
595 | } else { |
596 | $referenceNode = $this |
597 | ->mStartContainer |
598 | ->childNodes |
599 | ->item( $this->mStartOffset ); |
600 | } |
601 | |
602 | $parent = !$referenceNode |
603 | ? $this->mStartContainer |
604 | : $referenceNode->parentNode; |
605 | // TODO: Restore this validation check? |
606 | // $parent->ensurePreinsertionValidity( $node, $referenceNode ); |
607 | |
608 | if ( $this->mStartContainer instanceof Text ) { |
609 | $referenceNode = $this->mStartContainer->splitText( $this->mStartOffset ); |
610 | } |
611 | |
612 | if ( $node === $referenceNode ) { |
613 | $referenceNode = $referenceNode->nextSibling; |
614 | } |
615 | |
616 | if ( $node->parentNode ) { |
617 | $node->parentNode->removeChild( $node ); |
618 | } |
619 | |
620 | // TODO: Restore this validation check? |
621 | // $parent->preinsertNode( $node, $referenceNode ); |
622 | |
623 | // $referenceNode may be null, this is okay |
624 | $parent->insertBefore( $node, $referenceNode ); |
625 | } |
626 | |
627 | /** |
628 | * Wraps the content of Range in a new Node and inserts it in to the Document. |
629 | * |
630 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
631 | * |
632 | * @see https://dom.spec.whatwg.org/#dom-range-surroundcontents |
633 | * |
634 | * @param Node $newParent New parent node for contents |
635 | * @return void |
636 | */ |
637 | public function surroundContents( Node $newParent ): void { |
638 | $commonAncestor = $this->commonAncestorContainer; |
639 | |
640 | if ( $commonAncestor ) { |
641 | $tw = new TreeWalker( $commonAncestor ); |
642 | $node = $tw->nextNode(); |
643 | |
644 | while ( $node ) { |
645 | if ( !$node instanceof Text && $this->isPartiallyContainedNode( $node ) ) { |
646 | throw new DOMException(); |
647 | } |
648 | |
649 | $node = $tw->nextNode(); |
650 | } |
651 | } |
652 | |
653 | if ( |
654 | $newParent instanceof Document |
655 | || $newParent instanceof DocumentType |
656 | || $newParent instanceof DocumentFragment |
657 | ) { |
658 | throw new DOMException(); |
659 | } |
660 | |
661 | $fragment = $this->extractContents(); |
662 | |
663 | while ( $newParent->firstChild ) { |
664 | $newParent->removeChild( $newParent->firstChild ); |
665 | } |
666 | |
667 | $this->insertNode( $newParent ); |
668 | $newParent->appendChild( $fragment ); |
669 | // TODO: Return new range? |
670 | } |
671 | |
672 | /** |
673 | * Compares the position of two boundary points. |
674 | * |
675 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
676 | * @internal |
677 | * |
678 | * @see https://dom.spec.whatwg.org/#concept-range-bp-position |
679 | * |
680 | * @param Node $nodeA |
681 | * @param int $offsetA |
682 | * @param Node $nodeB |
683 | * @param int $offsetB |
684 | * @return string 'before'|'after'|'equal' |
685 | */ |
686 | private function computePosition( |
687 | Node $nodeA, int $offsetA, Node $nodeB, int $offsetB |
688 | ): string { |
689 | // 1. Assert: nodeA and nodeB have the same root. |
690 | // Removed, not necessary for our usage |
691 | |
692 | // 2. If nodeA is nodeB, then return equal if offsetA is offsetB, before if offsetA is less than offsetB, and |
693 | // after if offsetA is greater than offsetB. |
694 | if ( $nodeA === $nodeB ) { |
695 | if ( $offsetA === $offsetB ) { |
696 | return 'equal'; |
697 | } elseif ( $offsetA < $offsetB ) { |
698 | return 'before'; |
699 | } else { |
700 | return 'after'; |
701 | } |
702 | } |
703 | |
704 | $commonAncestor = $this->findCommonAncestorContainer( $nodeB, $nodeA ); |
705 | if ( $commonAncestor === $nodeA ) { |
706 | $AFollowsB = false; |
707 | } elseif ( $commonAncestor === $nodeB ) { |
708 | $AFollowsB = true; |
709 | } else { |
710 | // A was not found inside B. Traverse both A & B up to the nodes |
711 | // before their common ancestor, then see if A is in the nextSibling |
712 | // chain of B. |
713 | $b = $nodeB; |
714 | while ( $b->parentNode !== $commonAncestor ) { |
715 | $b = $b->parentNode; |
716 | } |
717 | $a = $nodeA; |
718 | while ( $a->parentNode !== $commonAncestor ) { |
719 | $a = $a->parentNode; |
720 | } |
721 | $AFollowsB = false; |
722 | while ( $b ) { |
723 | if ( $a === $b ) { |
724 | $AFollowsB = true; |
725 | break; |
726 | } |
727 | $b = $b->nextSibling; |
728 | } |
729 | } |
730 | |
731 | if ( $AFollowsB ) { |
732 | // Swap variables |
733 | [ $nodeB, $nodeA ] = [ $nodeA, $nodeB ]; |
734 | [ $offsetB, $offsetA ] = [ $offsetA, $offsetB ]; |
735 | } |
736 | |
737 | $ancestor = $nodeB->parentNode; |
738 | |
739 | while ( $ancestor ) { |
740 | if ( $ancestor === $nodeA ) { |
741 | break; |
742 | } |
743 | |
744 | $ancestor = $ancestor->parentNode; |
745 | } |
746 | |
747 | if ( $ancestor ) { |
748 | $child = $nodeB; |
749 | |
750 | while ( $child ) { |
751 | if ( $child->parentNode === $nodeA ) { |
752 | break; |
753 | } |
754 | |
755 | $child = $child->parentNode; |
756 | } |
757 | |
758 | // Phan complains that $child may be null here, but that can't happen, because at this point |
759 | // we know that $nodeA is an ancestor of $nodeB, so the loop above will stop before the root. |
760 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable |
761 | if ( CommentUtils::childIndexOf( $child ) < $offsetA ) { |
762 | return $AFollowsB ? 'before' : 'after'; |
763 | } |
764 | } |
765 | |
766 | return $AFollowsB ? 'after' : 'before'; |
767 | } |
768 | |
769 | public const START_TO_START = 0; |
770 | public const START_TO_END = 1; |
771 | public const END_TO_END = 2; |
772 | public const END_TO_START = 3; |
773 | |
774 | /** |
775 | * Compares the boundary points of this Range with another Range. |
776 | * |
777 | * Ported from https://github.com/TRowbotham/PHPDOM (MIT) |
778 | * |
779 | * @see https://dom.spec.whatwg.org/#dom-range-compareboundarypoints |
780 | * |
781 | * @param int $how One of ImmutableRange::END_TO_END, ImmutableRange::END_TO_START, |
782 | * ImmutableRange::START_TO_END, ImmutableRange::START_TO_START |
783 | * @param ImmutableRange $sourceRange A Range whose boundary points are to be compared. |
784 | * @return int -1, 0, or 1 |
785 | */ |
786 | public function compareBoundaryPoints( int $how, self $sourceRange ): int { |
787 | if ( static::getRootNode( $this->mStartContainer ) !== static::getRootNode( $sourceRange->startContainer ) ) { |
788 | throw new DOMException(); |
789 | } |
790 | |
791 | switch ( $how ) { |
792 | case static::START_TO_START: |
793 | $thisPoint = [ $this->mStartContainer, $this->mStartOffset ]; |
794 | $otherPoint = [ $sourceRange->startContainer, $sourceRange->startOffset ]; |
795 | break; |
796 | |
797 | case static::START_TO_END: |
798 | $thisPoint = [ $this->mEndContainer, $this->mEndOffset ]; |
799 | $otherPoint = [ $sourceRange->startContainer, $sourceRange->startOffset ]; |
800 | break; |
801 | |
802 | case static::END_TO_END: |
803 | $thisPoint = [ $this->mEndContainer, $this->mEndOffset ]; |
804 | $otherPoint = [ $sourceRange->endContainer, $sourceRange->endOffset ]; |
805 | break; |
806 | |
807 | case static::END_TO_START: |
808 | $thisPoint = [ $this->mStartContainer, $this->mStartOffset ]; |
809 | $otherPoint = [ $sourceRange->endContainer, $sourceRange->endOffset ]; |
810 | break; |
811 | |
812 | default: |
813 | throw new DOMException(); |
814 | } |
815 | |
816 | switch ( $this->computePosition( ...$thisPoint, ...$otherPoint ) ) { |
817 | case 'before': |
818 | return -1; |
819 | |
820 | case 'equal': |
821 | return 0; |
822 | |
823 | case 'after': |
824 | return 1; |
825 | |
826 | default: |
827 | throw new DOMException(); |
828 | } |
829 | } |
830 | } |