2020-05-11 15:54:06 +00:00
|
|
|
<?php
|
|
|
|
|
2020-05-14 22:44:49 +00:00
|
|
|
namespace MediaWiki\Extension\DiscussionTools;
|
|
|
|
|
2020-05-15 21:35:50 +00:00
|
|
|
use DOMDocument;
|
2020-05-14 22:44:49 +00:00
|
|
|
use DOMElement;
|
|
|
|
use DOMNode;
|
|
|
|
|
|
|
|
class CommentModifier {
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
private function __construct() {
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add an attribute to a list item to remove pre-whitespace in Parsoid
|
|
|
|
*
|
|
|
|
* @param DOMElement $listItem List item element
|
|
|
|
*/
|
|
|
|
private static function whitespaceParsoidHack( DOMElement $listItem ) : void {
|
|
|
|
// HACK: Setting data-parsoid removes the whitespace after the list item,
|
|
|
|
// which makes nested lists work.
|
|
|
|
// This is undocumented behaviour and probably very fragile.
|
|
|
|
$listItem->setAttribute( 'data-parsoid', '{}' );
|
|
|
|
}
|
|
|
|
|
|
|
|
private static $blockElementTypes = [
|
|
|
|
'div', 'p',
|
|
|
|
// Tables
|
|
|
|
'table', 'tbody', 'thead', 'tfoot', 'caption', 'th', 'tr', 'td',
|
|
|
|
// Lists
|
|
|
|
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
|
|
// HTML5 heading content
|
|
|
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
|
|
|
|
// HTML5 sectioning content
|
|
|
|
'article', 'aside', 'body', 'nav', 'section', 'footer', 'header', 'figure',
|
|
|
|
'figcaption', 'fieldset', 'details', 'blockquote',
|
|
|
|
// Other
|
|
|
|
'hr', 'button', 'canvas', 'center', 'col', 'colgroup', 'embed',
|
|
|
|
'map', 'object', 'pre', 'progress', 'video'
|
|
|
|
];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param DOMNode $node Node
|
|
|
|
* @return bool Node is a block element
|
|
|
|
*/
|
|
|
|
private static function isBlockElement( DOMNode $node ) : bool {
|
2020-06-04 18:48:58 +00:00
|
|
|
return $node instanceof DOMElement &&
|
2020-05-11 15:54:06 +00:00
|
|
|
in_array( strtolower( $node->tagName ), self::$blockElementTypes );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given a comment and a reply link, add the reply link to its document's DOM tree, at the end of
|
|
|
|
* the comment.
|
|
|
|
*
|
2020-05-22 16:26:05 +00:00
|
|
|
* @param CommentItem $comment Comment data returned by parser#groupThreads
|
2020-05-11 15:54:06 +00:00
|
|
|
* @param DOMElement $linkNode Reply link
|
|
|
|
*/
|
2020-05-22 16:26:05 +00:00
|
|
|
public static function addReplyLink( CommentItem $comment, DOMElement $linkNode ) : void {
|
|
|
|
$target = $comment->getRange()->endContainer;
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
// Skip to the end of the "paragraph". This only looks at tag names and can be fooled by CSS, but
|
|
|
|
// avoiding that would be more difficult and slower.
|
|
|
|
while ( $target->nextSibling && !self::isBlockElement( $target->nextSibling ) ) {
|
|
|
|
$target = $target->nextSibling;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Insert the link before trailing whitespace.
|
|
|
|
// In the MediaWiki parser output, <ul>/<dl> nodes are preceded by a newline. Normally it isn't
|
|
|
|
// visible on the page. But if we insert an inline element (the reply link) after it, it becomes
|
|
|
|
// meaningful and gets rendered, which results in additional spacing before some reply links.
|
|
|
|
// Split the text node, so that we can insert the link before the trailing whitespace.
|
|
|
|
if ( $target->nodeType === XML_TEXT_NODE ) {
|
|
|
|
preg_match( '/\s*$/', $target->nodeValue, $matches, PREG_OFFSET_CAPTURE );
|
|
|
|
$byteOffset = $matches[0][1];
|
|
|
|
$charOffset = mb_strlen(
|
|
|
|
substr( $target->nodeValue, 0, $byteOffset )
|
|
|
|
);
|
|
|
|
$target->splitText( $charOffset );
|
|
|
|
}
|
|
|
|
|
|
|
|
$target->parentNode->insertBefore( $linkNode, $target->nextSibling );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given a comment, add a list item to its document's DOM tree, inside of which a reply to said
|
|
|
|
* comment can be added.
|
|
|
|
*
|
|
|
|
* The DOM tree is suitably rearranged to ensure correct indentation level of the reply (wrapper
|
|
|
|
* nodes are added, and other nodes may be moved around).
|
|
|
|
*
|
2020-05-22 16:26:05 +00:00
|
|
|
* @param CommentItem $comment Comment data returned by parser#groupThreads
|
2020-05-11 15:54:06 +00:00
|
|
|
* @return DOMElement
|
|
|
|
*/
|
2020-05-22 16:26:05 +00:00
|
|
|
public static function addListItem( CommentItem $comment ) : DOMElement {
|
2020-05-11 15:54:06 +00:00
|
|
|
$listTypeMap = [
|
|
|
|
'li' => 'ul',
|
|
|
|
'dd' => 'dl'
|
|
|
|
];
|
|
|
|
|
|
|
|
// 1. Start at given comment
|
|
|
|
// 2. Skip past all comments with level greater than the given
|
|
|
|
// (or in other words, all replies, and replies to replies, and so on)
|
|
|
|
// 3. Add comment with level of the given comment plus 1
|
|
|
|
|
|
|
|
$curComment = $comment;
|
2020-05-22 16:26:05 +00:00
|
|
|
while ( count( $curComment->getReplies() ) ) {
|
|
|
|
$replies = $curComment->getReplies();
|
|
|
|
$curComment = end( $replies );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
|
2020-05-22 16:26:05 +00:00
|
|
|
$desiredLevel = $comment->getLevel() + 1;
|
|
|
|
$curLevel = $curComment->getLevel();
|
|
|
|
$target = $curComment->getRange()->endContainer;
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
// Skip to the end of the "paragraph". This only looks at tag names and can be fooled by CSS, but
|
|
|
|
// avoiding that would be more difficult and slower.
|
|
|
|
while ( $target->nextSibling && !self::isBlockElement( $target->nextSibling ) ) {
|
|
|
|
$target = $target->nextSibling;
|
|
|
|
}
|
|
|
|
|
|
|
|
// target is a text node or an inline element at the end of a "paragraph"
|
|
|
|
// (not necessarily paragraph node).
|
|
|
|
// First, we need to find a block-level parent that we can mess with.
|
|
|
|
// If we can't find a surrounding list item or paragraph (e.g. maybe we're inside a table cell
|
|
|
|
// or something), take the parent node and hope for the best.
|
2020-05-14 22:44:49 +00:00
|
|
|
$parent = CommentUtils::closestElement( $target, [ 'li', 'dd', 'p' ] ) ??
|
2020-05-11 15:54:06 +00:00
|
|
|
$target->parentNode;
|
|
|
|
while ( $target->parentNode !== $parent ) {
|
|
|
|
$target = $target->parentNode;
|
|
|
|
}
|
|
|
|
// parent is a list item or paragraph (hopefully)
|
|
|
|
// target is an inline node within it
|
|
|
|
|
2020-06-10 20:20:05 +00:00
|
|
|
$item = null;
|
2020-05-11 15:54:06 +00:00
|
|
|
if ( $curLevel < $desiredLevel ) {
|
|
|
|
// Insert more lists after the target to increase nesting.
|
|
|
|
|
2020-04-28 19:20:23 +00:00
|
|
|
// If the comment is fully covered by some wrapper element, insert replies outside that wrapper.
|
|
|
|
// This will often just be a paragraph node (<p>), but it can be a <div> or <table> that serves
|
|
|
|
// as some kind of a fancy frame, which are often used for barnstars and announcements.
|
2020-05-27 17:31:31 +00:00
|
|
|
if ( $curLevel === 1 && ( $wrapper = CommentUtils::getFullyCoveredWrapper( $curComment ) ) ) {
|
2020-04-28 19:20:23 +00:00
|
|
|
$target = $wrapper;
|
|
|
|
$parent = $target->parentNode;
|
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
// If we can't insert a list directly inside this element, insert after it.
|
2020-04-28 19:20:23 +00:00
|
|
|
// The wrapper check above handles most cases, but this special case is still needed for comments
|
|
|
|
// consisting of multiple paragraphs with no fancy frames.
|
2020-05-11 15:54:06 +00:00
|
|
|
// TODO Improve this check
|
|
|
|
if ( strtolower( $parent->tagName ) === 'p' || strtolower( $parent->tagName ) === 'pre' ) {
|
|
|
|
$parent = $parent->parentNode;
|
|
|
|
$target = $target->parentNode;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Decide on tag names for lists and items
|
|
|
|
$itemType = strtolower( $parent->tagName );
|
|
|
|
$itemType = isset( $listTypeMap[ $itemType ] ) ? $itemType : 'dd';
|
|
|
|
$listType = $listTypeMap[ $itemType ];
|
|
|
|
|
|
|
|
// Insert required number of wrappers
|
|
|
|
while ( $curLevel < $desiredLevel ) {
|
|
|
|
$list = $target->ownerDocument->createElement( $listType );
|
2020-05-15 00:23:50 +00:00
|
|
|
// Setting modified would only be needed for removeAddedListItem,
|
|
|
|
// which isn't needed on the server
|
|
|
|
// $list->setAttribute( 'dt-modified', 'new' );
|
2020-05-11 15:54:06 +00:00
|
|
|
$item = $target->ownerDocument->createElement( $itemType );
|
2020-05-15 00:23:50 +00:00
|
|
|
// $item->setAttribute( 'dt-modified', 'new' );
|
2020-05-11 15:54:06 +00:00
|
|
|
self::whitespaceParsoidHack( $item );
|
|
|
|
|
|
|
|
$parent->insertBefore( $list, $target->nextSibling );
|
|
|
|
$list->appendChild( $item );
|
|
|
|
|
|
|
|
$target = $item;
|
|
|
|
$parent = $list;
|
|
|
|
$curLevel++;
|
|
|
|
}
|
2020-06-04 18:48:58 +00:00
|
|
|
} else {
|
2020-05-11 15:54:06 +00:00
|
|
|
// Split the ancestor nodes after the target to decrease nesting.
|
|
|
|
|
|
|
|
do {
|
|
|
|
// If target is the last child of its parent, no need to split it
|
|
|
|
if ( $target->nextSibling ) {
|
|
|
|
// Create new identical node after the parent
|
|
|
|
$newNode = $parent->cloneNode( false );
|
2020-05-15 00:23:50 +00:00
|
|
|
// $parent->setAttribute( 'dt-modified', 'split' );
|
2020-05-11 15:54:06 +00:00
|
|
|
$parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
|
|
|
|
|
|
|
|
// Move nodes following target to the new node
|
|
|
|
while ( $target->nextSibling ) {
|
|
|
|
$newNode->appendChild( $target->nextSibling );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$target = $parent;
|
|
|
|
$parent = $parent->parentNode;
|
|
|
|
|
|
|
|
// Decrease nesting level if we escaped outside of a list
|
|
|
|
if ( isset( $listTypeMap[ strtolower( $target->tagName ) ] ) ) {
|
|
|
|
$curLevel--;
|
|
|
|
}
|
|
|
|
} while ( $curLevel >= $desiredLevel );
|
|
|
|
|
|
|
|
// parent is now a list, target is a list item
|
|
|
|
$item = $target->ownerDocument->createElement( $target->tagName );
|
2020-05-15 00:23:50 +00:00
|
|
|
// $item->setAttribute( 'dt-modified', 'new' );
|
2020-05-11 15:54:06 +00:00
|
|
|
self::whitespaceParsoidHack( $item );
|
|
|
|
$parent->insertBefore( $item, $target->nextSibling );
|
|
|
|
}
|
|
|
|
|
2020-06-10 20:20:05 +00:00
|
|
|
if ( $item === null ) {
|
|
|
|
throw new \LogicException( __METHOD__ . ' no item found' );
|
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
return $item;
|
|
|
|
}
|
|
|
|
|
2020-05-15 00:23:50 +00:00
|
|
|
// removeAddedListItem is only needed in the client
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Unwrap a top level list, converting list item text to paragraphs
|
|
|
|
*
|
2020-05-26 20:47:46 +00:00
|
|
|
* Assumes that the list has a parent node.
|
2020-05-11 15:54:06 +00:00
|
|
|
*
|
2020-06-24 18:19:06 +00:00
|
|
|
* @param DOMnode $list DOM node, will be wrapepd if it is a list element (dl/ol/ul)
|
2020-05-11 15:54:06 +00:00
|
|
|
*/
|
2020-06-24 18:19:06 +00:00
|
|
|
public static function unwrapList( DOMnode $list ) : void {
|
2020-05-11 15:54:06 +00:00
|
|
|
$doc = $list->ownerDocument;
|
|
|
|
$container = $list->parentNode;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $list;
|
|
|
|
|
2020-06-24 18:19:06 +00:00
|
|
|
if ( !(
|
|
|
|
$list instanceof DOMElement && (
|
|
|
|
strtolower( $list->tagName ) === 'dl' ||
|
|
|
|
strtolower( $list->tagName ) === 'ol' ||
|
|
|
|
strtolower( $list->tagName ) === 'ul'
|
|
|
|
)
|
|
|
|
) ) {
|
|
|
|
// Not a list, leave alone (e.g. auto-generated ref block)
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-05-26 20:47:46 +00:00
|
|
|
// If the whole list is a template return it unmodified (T253150)
|
|
|
|
if ( CommentUtils::getTranscludedFromElement( $list ) ) {
|
|
|
|
return;
|
|
|
|
}
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
while ( $list->firstChild ) {
|
|
|
|
if ( $list->firstChild->nodeType === XML_ELEMENT_NODE ) {
|
|
|
|
// Move <dd> contents to <p>
|
|
|
|
$p = $doc->createElement( 'p' );
|
|
|
|
while ( $list->firstChild->firstChild ) {
|
|
|
|
// If contents is a block element, place outside the paragraph
|
|
|
|
// and start a new paragraph after
|
|
|
|
if ( self::isBlockElement( $list->firstChild->firstChild ) ) {
|
|
|
|
if ( $p->firstChild ) {
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $p;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $p, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $list->firstChild->firstChild;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $list->firstChild->firstChild, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
$p = $doc->createElement( 'p' );
|
|
|
|
} else {
|
|
|
|
$p->appendChild( $list->firstChild->firstChild );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if ( $p->firstChild ) {
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $p;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $p, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
$list->removeChild( $list->firstChild );
|
|
|
|
} else {
|
|
|
|
// Text node / comment node, probably empty
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $list->firstChild;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $list->firstChild, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
}
|
2020-05-26 20:47:46 +00:00
|
|
|
$container->removeChild( $list );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add another list item after the given one.
|
|
|
|
*
|
|
|
|
* @param DOMElement $previousItem
|
|
|
|
* @return DOMElement
|
|
|
|
*/
|
2020-05-14 23:09:20 +00:00
|
|
|
public static function addSiblingListItem( DOMElement $previousItem ) : DOMElement {
|
2020-06-10 16:22:27 +00:00
|
|
|
$listItem = $previousItem->ownerDocument->createElement( $previousItem->tagName );
|
2020-05-11 15:54:06 +00:00
|
|
|
self::whitespaceParsoidHack( $listItem );
|
|
|
|
$previousItem->parentNode->insertBefore( $listItem, $previousItem->nextSibling );
|
|
|
|
return $listItem;
|
|
|
|
}
|
|
|
|
|
2020-06-26 22:00:34 +00:00
|
|
|
/**
|
|
|
|
* Add a reply to a specific comment
|
|
|
|
*
|
|
|
|
* @param CommentItem $comment Comment being replied to
|
|
|
|
* @param DOMElement $container Container of comment DOM nodes
|
|
|
|
*/
|
|
|
|
public static function addReply( CommentItem $comment, DOMElement $container ) {
|
|
|
|
$newParsoidItem = null;
|
|
|
|
// Transfer comment DOM to Parsoid DOM
|
|
|
|
// Wrap every root node of the document in a new list item (dd/li).
|
|
|
|
// In wikitext mode every root node is a paragraph.
|
|
|
|
// In visual mode the editor takes care of preventing problematic nodes
|
|
|
|
// like <table> or <h2> from ever occurring in the comment.
|
|
|
|
while ( $container->childNodes->length ) {
|
|
|
|
if ( !$newParsoidItem ) {
|
|
|
|
$newParsoidItem = self::addListItem( $comment );
|
|
|
|
} else {
|
|
|
|
$newParsoidItem = self::addSiblingListItem( $newParsoidItem );
|
|
|
|
}
|
|
|
|
$newParsoidItem->appendChild( $container->firstChild );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
/**
|
|
|
|
* Create an element that will convert to the provided wikitext
|
2020-05-15 21:35:50 +00:00
|
|
|
*
|
|
|
|
* @param DOMDocument $doc Document
|
2020-05-11 15:54:06 +00:00
|
|
|
* @param string $wt Wikitext
|
|
|
|
* @return DOMElement Element
|
|
|
|
*/
|
2020-05-15 21:35:50 +00:00
|
|
|
public static function createWikitextNode( DOMDocument $doc, string $wt ) : DOMElement {
|
|
|
|
$span = $doc->createElement( 'span' );
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
$span->setAttribute( 'typeof', 'mw:Transclusion' );
|
|
|
|
$span->setAttribute( 'data-mw', json_encode( [ 'parts' => [ $wt ] ] ) );
|
|
|
|
|
|
|
|
return $span;
|
|
|
|
}
|
|
|
|
}
|