mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-12-11 00:02:43 +00:00
dda9227947
Change-Id: I4474bd0205e7a1ed8e60147e52675e3e0b93ccd9
429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
const
|
|
featuresEnabled = mw.config.get( 'wgDiscussionToolsFeaturesEnabled' ) || {},
|
|
CommentItem = require( './CommentItem.js' ),
|
|
HeadingItem = require( './HeadingItem.js' ),
|
|
utils = require( './utils.js' );
|
|
let lastHighlightedPublishedComment = null;
|
|
|
|
/**
|
|
* Draw a semi-transparent rectangle on the page to highlight the given thread item.
|
|
*
|
|
* @class
|
|
* @param {ThreadItem|ThreadItem[]} items Thread item(s) to highlight
|
|
*/
|
|
function Highlight( items ) {
|
|
const highlightNodes = [];
|
|
const ranges = [];
|
|
|
|
this.topmostElement = null;
|
|
|
|
items = Array.isArray( items ) ? items : [ items ];
|
|
|
|
this.rootNode = items[ 0 ] ? items[ 0 ].rootNode : null;
|
|
|
|
items.forEach( ( item ) => {
|
|
const $highlight = $( '<div>' ).addClass( 'ext-discussiontools-init-highlight' );
|
|
|
|
// We insert the highlight in the DOM near the thead item, so that it remains positioned correctly
|
|
// when it shifts (e.g. collapsing the table of contents), and disappears when it is hidden (e.g.
|
|
// opening visual editor).
|
|
const range = item.getRange();
|
|
// Support: Firefox
|
|
// The highlight node must be inserted after the start marker node (data-mw-comment-start), not
|
|
// before, otherwise Node#getBoundingClientRect() returns wrong results.
|
|
range.insertNode( $highlight[ 0 ] );
|
|
|
|
// If the item is a top-level comment wrapped in a frame, highlight outside that frame
|
|
if ( item.level === 1 ) {
|
|
const coveredSiblings = utils.getFullyCoveredSiblings( item, item.rootNode );
|
|
if ( coveredSiblings ) {
|
|
range.setStartBefore( coveredSiblings[ 0 ] );
|
|
range.setEndAfter( coveredSiblings[ coveredSiblings.length - 1 ] );
|
|
}
|
|
}
|
|
|
|
// If the item is a heading, highlight the full extent of it (not only the text)
|
|
if ( item instanceof HeadingItem && !item.placeholderHeading ) {
|
|
range.selectNode(
|
|
$highlight.closest( '.mw-heading' )[ 0 ] ||
|
|
$highlight.closest( 'h1, h2, h3, h4, h5, h6' )[ 0 ]
|
|
);
|
|
}
|
|
|
|
ranges.push( range );
|
|
highlightNodes.push( $highlight[ 0 ] );
|
|
} );
|
|
|
|
this.ranges = ranges;
|
|
this.$element = $( highlightNodes );
|
|
|
|
// Events
|
|
this.updateDebounced = OO.ui.debounce( this.update.bind( this ), 500 );
|
|
window.addEventListener( 'resize', this.updateDebounced );
|
|
|
|
if ( OO.ui.isMobile() ) {
|
|
// In MobileFrontend, ensure the section we are highlighting within is expanded. This is
|
|
// often the case as we add the hash fragment to our notification URLs, but not when we highlight
|
|
// comments across multiple threads.
|
|
// HACK: Ideally MF would expose Toggler as a public API, but for now just find the correct DOM
|
|
// node and trigger a fake click.
|
|
this.$element.parents( '.collapsible-block' ).prev( '.collapsible-heading:not( .open-block )' ).trigger( 'click' );
|
|
}
|
|
|
|
this.update();
|
|
}
|
|
|
|
OO.initClass( Highlight );
|
|
|
|
/**
|
|
* Update position of highlights, e.g. after window resize
|
|
*/
|
|
Highlight.prototype.update = function () {
|
|
this.$element.css( {
|
|
'margin-top': '',
|
|
'margin-left': '',
|
|
'margin-right': '',
|
|
width: '',
|
|
height: ''
|
|
} );
|
|
const rootRect = this.rootNode.getBoundingClientRect();
|
|
this.topmostElement = null;
|
|
let topmostTop = Infinity;
|
|
this.ranges.forEach( ( range, i ) => {
|
|
const $element = this.$element.eq( i );
|
|
const baseRect = $element[ 0 ].getBoundingClientRect();
|
|
const rect = RangeFix.getBoundingClientRect( range );
|
|
// rect may be null if the range is in a detached or hidden node
|
|
if ( rect ) {
|
|
// Draw the highlight over the full width of the page, except for very short comments
|
|
// (less than 1/3 of the available width).
|
|
//
|
|
// This lets the far edges of almost all comments align nicely (T309444#7968858),
|
|
// while still accurately highlighting comments in narrow floating boxes, such as
|
|
// image captions or {{archive top}}.
|
|
//
|
|
// It seems difficult to distinguish the floating boxes from comments that are just
|
|
// very short or very deeply indented, and this seems to work well enough in practice.
|
|
const useFullWidth = rect.width > rootRect.width / 3;
|
|
|
|
let headingTopAdj = 0;
|
|
if (
|
|
featuresEnabled.visualenhancements &&
|
|
$element.closest( '.ext-discussiontools-init-section' ).length
|
|
) {
|
|
// Shift the highlight a little to avoid drawing over the separator border at the top,
|
|
// and to cover the gap to the first comment at the bottom.
|
|
headingTopAdj = 10;
|
|
}
|
|
|
|
const top = rect.top - baseRect.top;
|
|
let width = rect.width;
|
|
const height = rect.height;
|
|
let left, right;
|
|
if ( $element.css( 'direction' ) === 'ltr' ) {
|
|
left = rect.left - baseRect.left;
|
|
if ( useFullWidth ) {
|
|
width = rootRect.width - ( rect.left - rootRect.left );
|
|
}
|
|
} else {
|
|
right = ( baseRect.left + baseRect.width ) - ( rect.left + rect.width );
|
|
if ( useFullWidth ) {
|
|
width = rootRect.width - ( ( rootRect.left + rootRect.width ) - ( rect.left + rect.width ) );
|
|
}
|
|
}
|
|
const padding = 5;
|
|
$element.css( {
|
|
'margin-top': top - padding + headingTopAdj,
|
|
'margin-left': left !== undefined ? left - padding : '',
|
|
'margin-right': right !== undefined ? right - padding : '',
|
|
width: width + ( padding * 2 ),
|
|
height: height + ( padding * 2 )
|
|
} );
|
|
|
|
if ( rect.top < topmostTop ) {
|
|
this.topmostElement = $element[ 0 ];
|
|
topmostTop = rect.top;
|
|
}
|
|
}
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Scroll the topmost highlight into view
|
|
*/
|
|
Highlight.prototype.scrollIntoView = function () {
|
|
if ( this.topmostElement ) {
|
|
this.topmostElement.scrollIntoView();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Destroy the highlight
|
|
*/
|
|
Highlight.prototype.destroy = function () {
|
|
this.$element.remove();
|
|
window.removeEventListener( 'resize', this.updateDebounced );
|
|
};
|
|
|
|
let highlightedTarget = null;
|
|
/**
|
|
* Highlight the thread item(s) on the page associated with the URL hash or query string
|
|
*
|
|
* @param {ThreadItemSet} threadItemSet
|
|
* @param {boolean} [noScroll] Don't scroll to the topmost highlight, e.g. on popstate
|
|
* @return {Object} Object containing 'requested' IDs, 'requestedSince' ID, and successfully 'highligted' IDs
|
|
*/
|
|
function highlightTargetComment( threadItemSet, noScroll ) {
|
|
if ( highlightedTarget ) {
|
|
highlightedTarget.destroy();
|
|
highlightedTarget = null;
|
|
}
|
|
|
|
const targetElement = mw.util.getTargetFromFragment();
|
|
|
|
if ( targetElement && targetElement.hasAttribute( 'data-mw-comment-start' ) ) {
|
|
const threadItemId = targetElement.getAttribute( 'id' );
|
|
const threadItem = threadItemSet.findCommentById( targetElement.getAttribute( 'id' ) );
|
|
if ( threadItem ) {
|
|
highlightedTarget = new Highlight( threadItem );
|
|
highlightedTarget.$element.addClass( 'ext-discussiontools-init-targetcomment' );
|
|
highlightedTarget.$element.addClass( 'ext-discussiontools-init-highlight-fadein' );
|
|
}
|
|
return {
|
|
requested: [ threadItemId ],
|
|
highlighted: [ threadItemId ]
|
|
};
|
|
}
|
|
|
|
// Single thread item not highlighted yet
|
|
if ( location.hash.match( /^#(c|h)-/ ) ) {
|
|
return {
|
|
requested: [ location.hash.slice( 1 ) ],
|
|
highlighted: []
|
|
};
|
|
}
|
|
|
|
const url = new URL( location.href );
|
|
return highlightNewComments(
|
|
threadItemSet,
|
|
noScroll,
|
|
url.searchParams.get( 'dtnewcomments' ) && url.searchParams.get( 'dtnewcomments' ).split( '|' ),
|
|
{
|
|
newCommentsSinceId: url.searchParams.get( 'dtnewcommentssince' ),
|
|
inThread: url.searchParams.get( 'dtinthread' ),
|
|
sinceThread: url.searchParams.get( 'dtsincethread' )
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Highlight a just-published comment/topic
|
|
*
|
|
* These highlights show for a short period of time then tear themselves down.
|
|
*
|
|
* @param {ThreadItemSet} threadItemSet Thread item set
|
|
* @param {string} threadItemId Thread item ID (NEW_TOPIC_COMMENT_ID for the a new topic)
|
|
*/
|
|
function highlightPublishedComment( threadItemSet, threadItemId ) {
|
|
const highlightComments = [];
|
|
|
|
if ( threadItemId === utils.NEW_TOPIC_COMMENT_ID ) {
|
|
// Highlight the last comment on the page
|
|
const lastComment = threadItemSet.threadItems[ threadItemSet.threadItems.length - 1 ];
|
|
lastHighlightedPublishedComment = lastComment;
|
|
highlightComments.push( lastComment );
|
|
|
|
// If it's the only comment under its heading, highlight the heading too.
|
|
// (It might not be if the new discussion topic was posted without a title: T272666.)
|
|
if (
|
|
lastComment.parent &&
|
|
lastComment.parent.type === 'heading' &&
|
|
lastComment.parent.replies.length === 1
|
|
) {
|
|
highlightComments.push( lastComment.parent );
|
|
lastHighlightedPublishedComment = lastComment.parent;
|
|
// Change URL to point to this section, like the old section=new wikitext editor does.
|
|
// This also expands collapsed sections on mobile (T301840).
|
|
const sectionTitle = lastHighlightedPublishedComment.getLinkableTitle();
|
|
const urlFragment = mw.util.escapeIdForLink( sectionTitle );
|
|
// Navigate to fragment without scrolling
|
|
location.hash = '#' + urlFragment + '-DoesNotExist-DiscussionToolsHack';
|
|
history.replaceState( null, '', '#' + urlFragment );
|
|
}
|
|
} else {
|
|
// Find the comment we replied to, then highlight the last reply
|
|
const repliedToComment = threadItemSet.threadItemsById[ threadItemId ];
|
|
highlightComments.push( repliedToComment.replies[ repliedToComment.replies.length - 1 ] );
|
|
lastHighlightedPublishedComment = highlightComments[ 0 ];
|
|
}
|
|
|
|
// We may have changed the location hash on mobile, so wait for that to cause
|
|
// the section to expand before drawing the highlight.
|
|
setTimeout( () => {
|
|
const highlight = new Highlight( highlightComments );
|
|
highlight.$element.addClass( 'ext-discussiontools-init-publishedcomment' );
|
|
|
|
// Show a highlight with the same timing as the post-edit message (mediawiki.action.view.postEdit):
|
|
// show for 3000ms, fade out for 250ms (animation duration is defined in CSS).
|
|
OO.ui.Element.static.scrollIntoView(
|
|
highlight.topmostElement,
|
|
{
|
|
padding: {
|
|
// Add padding to avoid overlapping the post-edit notification (above on desktop, below on mobile)
|
|
top: OO.ui.isMobile() ? 10 : 60,
|
|
bottom: OO.ui.isMobile() ? 85 : 10
|
|
},
|
|
// Specify scrollContainer for compatibility with MobileFrontend.
|
|
// Apparently it makes `<dd>` elements scrollable and OOUI tried to scroll them instead of body.
|
|
scrollContainer: OO.ui.Element.static.getRootScrollableElement( highlight.topmostElement )
|
|
}
|
|
).then( () => {
|
|
highlight.$element.addClass( 'ext-discussiontools-init-highlight-fadein' );
|
|
setTimeout( () => {
|
|
highlight.$element.addClass( 'ext-discussiontools-init-highlight-fadeout' );
|
|
setTimeout( () => {
|
|
// Remove the node when no longer needed, because it's using CSS 'mix-blend-mode', which
|
|
// affects the text rendering of the whole page, disabling subpixel antialiasing on Windows
|
|
highlight.destroy();
|
|
}, 250 );
|
|
}, 3000 );
|
|
} );
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Highlight the new comments on the page associated with the query string
|
|
*
|
|
* @param {ThreadItemSet} threadItemSet
|
|
* @param {boolean} [noScroll] Don't scroll to the topmost highlighted comment, e.g. on popstate
|
|
* @param {string[]} [newCommentIds] A list of comment IDs to highlight
|
|
* @param {Object} [options] Extra options
|
|
* @param {string} [options.newCommentsSinceId] Highlight all comments after the comment with this ID
|
|
* @param {boolean} [options.inThread] When using newCommentsSinceId, only highlight comments in the same thread
|
|
* @param {boolean} [options.sinceThread] When using newCommentsSinceId, only highlight comments in threads
|
|
* created since that comment was posted.
|
|
* @return {Object} Object containing 'requested' comment IDs, 'requestedSince' ID, and successfully 'highligted' IDs
|
|
*/
|
|
function highlightNewComments( threadItemSet, noScroll, newCommentIds, options ) {
|
|
if ( highlightedTarget ) {
|
|
highlightedTarget.destroy();
|
|
highlightedTarget = null;
|
|
}
|
|
|
|
newCommentIds = newCommentIds || [];
|
|
options = options || {};
|
|
|
|
if ( options.newCommentsSinceId ) {
|
|
const newCommentsSince = threadItemSet.findCommentById( options.newCommentsSinceId );
|
|
if ( newCommentsSince && newCommentsSince instanceof CommentItem ) {
|
|
const sinceTimestamp = newCommentsSince.timestamp;
|
|
let threadItems;
|
|
if ( options.inThread ) {
|
|
const heading = newCommentsSince.getSubscribableHeading() || newCommentsSince.getHeading();
|
|
threadItems = heading.getThreadItemsBelow();
|
|
} else {
|
|
threadItems = threadItemSet.getCommentItems();
|
|
}
|
|
threadItems.forEach( ( threadItem ) => {
|
|
if (
|
|
threadItem instanceof CommentItem &&
|
|
threadItem.timestamp >= sinceTimestamp
|
|
) {
|
|
if ( options.sinceThread ) {
|
|
// Check that we are in a thread that is newer than `sinceTimestamp`.
|
|
// Thread age is determined by looking at getOldestReply.
|
|
const itemHeading = threadItem.getSubscribableHeading() || threadItem.getHeading();
|
|
const oldestReply = itemHeading.getOldestReply();
|
|
if ( !( oldestReply && oldestReply.timestamp >= sinceTimestamp ) ) {
|
|
return;
|
|
}
|
|
}
|
|
newCommentIds.push( threadItem.id );
|
|
}
|
|
} );
|
|
}
|
|
}
|
|
|
|
let comments;
|
|
if ( newCommentIds.length ) {
|
|
comments = newCommentIds
|
|
.map( ( id ) => threadItemSet.findCommentById( id ) )
|
|
.filter( ( cmt ) => !!cmt );
|
|
if ( comments.length ) {
|
|
highlightedTarget = new Highlight( comments );
|
|
highlightedTarget.$element.addClass( 'ext-discussiontools-init-targetcomment' );
|
|
highlightedTarget.$element.addClass( 'ext-discussiontools-init-highlight-fadein' );
|
|
|
|
if ( !noScroll ) {
|
|
highlightedTarget.scrollIntoView();
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
requested: newCommentIds,
|
|
requestedSince: options.newCommentsSinceId,
|
|
highlighted: comments || []
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear the highlighting of the comment in the URL hash
|
|
*
|
|
* @param {ThreadItemSet} threadItemSet
|
|
*/
|
|
function clearHighlightTargetComment( threadItemSet ) {
|
|
const url = new URL( location.href );
|
|
|
|
const targetElement = mw.util.getTargetFromFragment();
|
|
|
|
if ( targetElement && targetElement.hasAttribute( 'data-mw-comment-start' ) ) {
|
|
// Clear the hash from the URL, triggering the 'hashchange' event and updating the :target
|
|
// selector (so that our code to clear our highlight works), but without scrolling anywhere.
|
|
// This is tricky because:
|
|
// * Using history.pushState() does not trigger 'hashchange' or update the :target selector.
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/History/pushState#description
|
|
// https://github.com/whatwg/html/issues/639
|
|
// * Using location.hash does, but it also scrolls to the target, which is the top of the
|
|
// page for the empty hash.
|
|
// Instead, we first use location.hash to navigate to a *different* hash (whose target
|
|
// doesn't exist on the page, hopefully), and then use history.pushState() to clear it.
|
|
location.hash += '-DoesNotExist-DiscussionToolsHack';
|
|
url.hash = '';
|
|
history.replaceState( null, '', url );
|
|
} else if (
|
|
url.searchParams.has( 'dtnewcomments' ) ||
|
|
url.searchParams.has( 'dtnewcommentssince' )
|
|
) {
|
|
url.searchParams.delete( 'dtnewcomments' );
|
|
url.searchParams.delete( 'dtnewcommentssince' );
|
|
url.searchParams.delete( 'dtinthread' );
|
|
url.searchParams.delete( 'dtsincethread' );
|
|
history.pushState( null, '', url );
|
|
highlightTargetComment( threadItemSet );
|
|
} else if ( highlightedTarget ) {
|
|
// Highlights were applied without changing the URL, e.g. when showing
|
|
// new comments while drafting. Just clear the highlights.
|
|
highlightedTarget.destroy();
|
|
highlightedTarget = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the last highlighted just-published comment, if any
|
|
*
|
|
* Used to show an auto-subscription popup to first-time users
|
|
*
|
|
* @return {ThreadItem|null}
|
|
*/
|
|
function getLastHighlightedPublishedComment() {
|
|
return lastHighlightedPublishedComment;
|
|
}
|
|
|
|
module.exports = {
|
|
highlightTargetComment: highlightTargetComment,
|
|
highlightPublishedComment: highlightPublishedComment,
|
|
highlightNewComments: highlightNewComments,
|
|
clearHighlightTargetComment: clearHighlightTargetComment,
|
|
getLastHighlightedPublishedComment: getLastHighlightedPublishedComment
|
|
};
|