mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-12-04 21:08:32 +00:00
ea5639b20b
Change-Id: I5985d1b532988bb3b71ff1aa24eae57ac2e1b9c5
389 lines
13 KiB
JavaScript
389 lines
13 KiB
JavaScript
'use strict';
|
|
/* global $:off */
|
|
|
|
const
|
|
utils = require( './utils.js' );
|
|
|
|
/**
|
|
* Remove extra linebreaks from a wikitext string
|
|
*
|
|
* @param {string} wikitext Wikitext
|
|
* @return {string}
|
|
*/
|
|
function sanitizeWikitextLinebreaks( wikitext ) {
|
|
return utils.htmlTrim( wikitext )
|
|
.replace( /\r/g, '\n' )
|
|
.replace( /\n+/g, '\n' );
|
|
}
|
|
|
|
/**
|
|
* Given a comment and a reply link, add the reply link to its document's DOM tree, at the end of
|
|
* the comment.
|
|
*
|
|
* @param {CommentItem} comment Comment item
|
|
* @param {HTMLElement} linkNode Reply link
|
|
*/
|
|
function addReplyLink( comment, linkNode ) {
|
|
const target = comment.range.endContainer;
|
|
|
|
// 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 === Node.TEXT_NODE ) {
|
|
target.splitText( target.textContent.match( /\s*$/ ).index );
|
|
}
|
|
|
|
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).
|
|
*
|
|
* @param {CommentItem} comment Comment item
|
|
* @param {string} replyIndentation Reply indentation syntax to use, one of:
|
|
* - 'invisible' (use `<dl><dd>` tags to output `:` in wikitext)
|
|
* - 'bullet' (use `<ul><li>` tags to output `*` in wikitext)
|
|
* @return {HTMLElement}
|
|
*/
|
|
function addListItem( comment, replyIndentation ) {
|
|
const 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
|
|
|
|
let curComment = comment;
|
|
while ( curComment.replies.length ) {
|
|
curComment = curComment.replies[ curComment.replies.length - 1 ];
|
|
}
|
|
|
|
// Tag names for lists and items we're going to insert
|
|
let itemType;
|
|
if ( replyIndentation === 'invisible' ) {
|
|
itemType = 'dd';
|
|
} else if ( replyIndentation === 'bullet' ) {
|
|
itemType = 'li';
|
|
} else {
|
|
throw new Error( "Invalid reply indentation syntax '" + replyIndentation + "'" );
|
|
}
|
|
const listType = listTypeMap[ itemType ];
|
|
|
|
const desiredLevel = comment.level + 1;
|
|
let target = curComment.range.endContainer;
|
|
|
|
// 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.
|
|
let parent = utils.closestElement( target, [ 'li', 'dd', 'p' ] ) || target.parentNode;
|
|
while ( target.parentNode !== parent ) {
|
|
target = target.parentNode;
|
|
}
|
|
// parent is a list item or paragraph (hopefully)
|
|
// target is an inline node within it
|
|
|
|
// 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.
|
|
const excludedWrapper = utils.closestElement( target, [ 'section' ] ) ||
|
|
curComment.rootNode;
|
|
const covered = utils.getFullyCoveredSiblings( curComment, excludedWrapper );
|
|
if ( curComment.level === 1 && covered ) {
|
|
target = covered[ covered.length - 1 ];
|
|
parent = target.parentNode;
|
|
}
|
|
|
|
// If the comment is in a transclusion, insert replies after the transclusion. (T313100)
|
|
// This method should never be called in cases where that would be a bad idea.
|
|
let transclusionNode = utils.getTranscludedFromElement( target );
|
|
if ( transclusionNode ) {
|
|
while (
|
|
transclusionNode.nextSibling &&
|
|
transclusionNode.nextSibling.nodeType === Node.ELEMENT_NODE &&
|
|
transclusionNode.nextSibling.getAttribute( 'about' ) === transclusionNode.getAttribute( 'about' )
|
|
) {
|
|
transclusionNode = transclusionNode.nextSibling;
|
|
}
|
|
target = transclusionNode;
|
|
parent = target.parentNode;
|
|
}
|
|
|
|
// If we can't insert a list directly inside this element, insert after it.
|
|
// The covered wrapper check above handles most cases, but we still need this sometimes, such as:
|
|
// * If the comment starts in the middle of a list, then ends with an unindented p/pre, the
|
|
// wrapper check doesn't adjust the parent
|
|
// * If the comment consists of multiple list items (starting with a <dt>, so that the comment is
|
|
// considered to be unindented, that is level === 1), but not all of them, the wrapper check
|
|
// adjusts the parent to be the list, and the rest of the algorithm doesn't handle that well
|
|
if (
|
|
parent.tagName.toLowerCase() === 'p' ||
|
|
parent.tagName.toLowerCase() === 'pre' ||
|
|
parent.tagName.toLowerCase() === 'ul' ||
|
|
parent.tagName.toLowerCase() === 'dl'
|
|
) {
|
|
parent = parent.parentNode;
|
|
target = target.parentNode;
|
|
}
|
|
|
|
// HACK: Skip past our own reply buttons
|
|
if ( target.nextSibling && target.nextSibling.nodeType === Node.ELEMENT_NODE && target.nextSibling.classList.contains( 'ext-discussiontools-init-replylink-buttons' ) ) {
|
|
target = target.nextSibling;
|
|
}
|
|
|
|
// Instead of just using curComment.level, consider indentation of lists within the
|
|
// comment (T252702)
|
|
let curLevel = utils.getIndentLevel( target, curComment.rootNode ) + 1;
|
|
|
|
let item, list;
|
|
if ( desiredLevel === 1 ) {
|
|
// Special handling for top-level comments
|
|
item = target.ownerDocument.createElement( 'div' );
|
|
item.discussionToolsModified = 'new';
|
|
parent.insertBefore( item, target.nextSibling );
|
|
// TODO: We should not insert a <div>, instead we need a function that returns parent and target,
|
|
// so that we can insert nodes in this place in other code
|
|
|
|
} else if ( curLevel < desiredLevel ) {
|
|
// Insert more lists after the target to increase nesting.
|
|
|
|
// Parsoid puts HTML comments (and other "rendering-transparent nodes", e.g. category links)
|
|
// which appear at the end of the line in wikitext outside the paragraph,
|
|
// but we usually shouldn't insert replies between the paragraph and such comments. (T257651)
|
|
// Skip over comments and whitespace, but only update target when skipping past comments.
|
|
let pointer = target;
|
|
while (
|
|
pointer.nextSibling && (
|
|
utils.isRenderingTransparentNode( pointer.nextSibling ) ||
|
|
(
|
|
pointer.nextSibling.nodeType === Node.TEXT_NODE &&
|
|
utils.htmlTrim( pointer.nextSibling.textContent ) === '' &&
|
|
// If at least two lines of whitespace are detected, the following HTML
|
|
// comments are not considered to be part of the reply (T264026, T301214)
|
|
!/\n[^\n]*\n/.test( pointer.nextSibling.textContent )
|
|
)
|
|
)
|
|
) {
|
|
pointer = pointer.nextSibling;
|
|
if ( utils.isRenderingTransparentNode( pointer ) ) {
|
|
target = pointer;
|
|
}
|
|
}
|
|
|
|
// Insert required number of wrappers
|
|
while ( curLevel < desiredLevel ) {
|
|
list = target.ownerDocument.createElement( listType );
|
|
list.discussionToolsModified = 'new';
|
|
item = target.ownerDocument.createElement( itemType );
|
|
item.discussionToolsModified = 'new';
|
|
|
|
parent.insertBefore( list, target.nextSibling );
|
|
list.appendChild( item );
|
|
|
|
target = item;
|
|
parent = list;
|
|
curLevel++;
|
|
}
|
|
} else {
|
|
// Split the ancestor nodes after the target to decrease nesting.
|
|
|
|
let newNode;
|
|
do {
|
|
if ( !target || !parent ) {
|
|
throw new Error( 'Can not decrease nesting any more' );
|
|
}
|
|
|
|
// 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 );
|
|
parent.discussionToolsModified = 'split';
|
|
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 ( listTypeMap[ target.tagName.toLowerCase() ] ) {
|
|
curLevel--;
|
|
}
|
|
} while ( curLevel >= desiredLevel );
|
|
|
|
// parent is now a list, target is a list item
|
|
if ( itemType === target.tagName.toLowerCase() ) {
|
|
item = target.ownerDocument.createElement( itemType );
|
|
item.discussionToolsModified = 'new';
|
|
parent.insertBefore( item, target.nextSibling );
|
|
|
|
} else {
|
|
// This is the wrong type of list, split it one more time
|
|
|
|
// 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 );
|
|
parent.discussionToolsModified = 'split';
|
|
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;
|
|
|
|
// Insert a list of the right type in the middle
|
|
list = target.ownerDocument.createElement( listType );
|
|
list.discussionToolsModified = 'new';
|
|
item = target.ownerDocument.createElement( itemType );
|
|
item.discussionToolsModified = 'new';
|
|
|
|
parent.insertBefore( list, target.nextSibling );
|
|
list.appendChild( item );
|
|
}
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* Undo the effects of #addListItem, also removing or merging any affected parent nodes.
|
|
*
|
|
* @param {HTMLElement} node
|
|
*/
|
|
function removeAddedListItem( node ) {
|
|
while ( node && node.discussionToolsModified ) {
|
|
let nextNode;
|
|
if ( node.discussionToolsModified === 'new' ) {
|
|
nextNode = node.previousSibling || node.parentNode;
|
|
|
|
// Remove this node
|
|
delete node.discussionToolsModified;
|
|
node.parentNode.removeChild( node );
|
|
|
|
} else if ( node.discussionToolsModified === 'split' ) {
|
|
// Children might be split too, if so, descend into them afterwards
|
|
if ( node.lastChild && node.lastChild.discussionToolsModified === 'split' ) {
|
|
node.discussionToolsModified = 'done';
|
|
nextNode = node.lastChild;
|
|
} else {
|
|
delete node.discussionToolsModified;
|
|
nextNode = node.parentNode;
|
|
}
|
|
// Merge the following sibling node back into this one
|
|
while ( node.nextSibling.firstChild ) {
|
|
node.appendChild( node.nextSibling.firstChild );
|
|
}
|
|
node.parentNode.removeChild( node.nextSibling );
|
|
|
|
} else {
|
|
nextNode = node.parentNode;
|
|
}
|
|
|
|
node = nextNode;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unwrap a top level list, converting list item text to paragraphs
|
|
*
|
|
* Assumes that the list has a parent node.
|
|
*
|
|
* @param {Node} list DOM node, will be wrapped if it is a list element (dl/ol/ul)
|
|
* @param {DocumentFragment|null} fragment Containing document fragment if list has no parent
|
|
*/
|
|
function unwrapList( list, fragment ) {
|
|
if ( !(
|
|
list.nodeType === Node.ELEMENT_NODE && (
|
|
list.tagName.toLowerCase() === 'dl' ||
|
|
list.tagName.toLowerCase() === 'ol' ||
|
|
list.tagName.toLowerCase() === 'ul'
|
|
)
|
|
) ) {
|
|
// Not a list, leave alone (e.g. auto-generated ref block)
|
|
return;
|
|
}
|
|
|
|
// If the whole list is a template return it unmodified (T253150)
|
|
if ( utils.getTranscludedFromElement( list ) ) {
|
|
return;
|
|
}
|
|
|
|
const doc = list.ownerDocument;
|
|
const container = fragment || list.parentNode;
|
|
let referenceNode = list;
|
|
while ( list.firstChild ) {
|
|
if ( list.firstChild.nodeType === Node.ELEMENT_NODE ) {
|
|
// Move <dd> contents to <p>
|
|
let 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 ( utils.isBlockElement( list.firstChild.firstChild ) ) {
|
|
if ( p.firstChild ) {
|
|
const insertBefore2 = referenceNode.nextSibling;
|
|
referenceNode = p;
|
|
container.insertBefore( p, insertBefore2 );
|
|
}
|
|
const insertBefore = referenceNode.nextSibling;
|
|
referenceNode = list.firstChild.firstChild;
|
|
container.insertBefore( list.firstChild.firstChild, insertBefore );
|
|
p = doc.createElement( 'p' );
|
|
} else {
|
|
p.appendChild( list.firstChild.firstChild );
|
|
}
|
|
}
|
|
if ( p.firstChild ) {
|
|
const insertBefore = referenceNode.nextSibling;
|
|
referenceNode = p;
|
|
container.insertBefore( p, insertBefore );
|
|
}
|
|
list.removeChild( list.firstChild );
|
|
} else {
|
|
// Text node / comment node, probably empty
|
|
const insertBefore = referenceNode.nextSibling;
|
|
referenceNode = list.firstChild;
|
|
container.insertBefore( list.firstChild, insertBefore );
|
|
}
|
|
}
|
|
container.removeChild( list );
|
|
}
|
|
|
|
/**
|
|
* Add another list item after the given one.
|
|
*
|
|
* @param {HTMLElement} previousItem
|
|
* @return {HTMLElement}
|
|
*/
|
|
function addSiblingListItem( previousItem ) {
|
|
const listItem = previousItem.ownerDocument.createElement( previousItem.tagName );
|
|
previousItem.parentNode.insertBefore( listItem, previousItem.nextSibling );
|
|
return listItem;
|
|
}
|
|
|
|
module.exports = {
|
|
addReplyLink: addReplyLink,
|
|
addListItem: addListItem,
|
|
removeAddedListItem: removeAddedListItem,
|
|
addSiblingListItem: addSiblingListItem,
|
|
unwrapList: unwrapList,
|
|
sanitizeWikitextLinebreaks: sanitizeWikitextLinebreaks
|
|
};
|