/**
* Singleton related to the navigation panel.
*
* @module navPanel
*/
import Button from './Button';
import LiveTimestamp from './LiveTimestamp';
import cd from './cd';
import commentFormRegistry from './commentFormRegistry';
import commentRegistry from './commentRegistry';
import controller from './controller';
import settings from './settings';
import { reorderArray } from './utils-general';
import { formatDate } from './utils-timestamp';
import { removeWikiMarkup } from './utils-wikitext';
import { createSvg, isCmdModifierPressed, isInputFocused, keyCombination } from './utils-window';
import visits from './visits';
export default {
/**
* Navigation panel element.
*
* @type {external:jQuery}
* @memberof module:navPanel
*/
$element: undefined,
/**
* Refresh button.
*
* @type {Button|undefined}
* @memberof module:navPanel
* @private
*/
refreshButton: undefined,
/**
* "Go to the previous new comment" button element.
*
* @type {Button|undefined}
* @memberof module:navPanel
* @private
*/
previousButton: undefined,
/**
* "Go to the next new comment" button element.
*
* @type {Button|undefined}
* @memberof module:navPanel
* @private
*/
nextButton: undefined,
/**
* "Go to the first unseen comment" button element.
*
* @type {Button}
* @memberof module:navPanel
* @private
*/
firstUnseenButton: undefined,
/**
* "Go to the next comment form out of sight" button element.
*
* @name commentFormButton
* @type {Button|undefined}
* @memberof module:navPanel
* @private
*/
commentFormButton: undefined,
/**
* _For internal use._ Mount, unmount or reset the navigation panel based on the context.
*/
setup() {
this.timestampFormat = settings.get('timestampFormat');
this.modifyToc = settings.get('modifyToc');
this.highlightNewInterval = settings.get('highlightNewInterval');
if (cd.page.isActive()) {
// Can be mounted not only on first parse, if using RevisionSlider, for example.
if (!this.isMounted()) {
this.mount();
controller
.on('scroll', this.updateCommentFormButton.bind(this))
.on('keydown', (e) => {
if (isInputFocused()) return;
// R
if (keyCombination(e, 82)) {
this.refreshClick();
}
// W
if (keyCombination(e, 87)) {
this.goToPreviousNewComment();
}
// S
if (keyCombination(e, 83)) {
this.goToNextNewComment();
}
// F
if (keyCombination(e, 70)) {
this.goToFirstUnseenComment();
}
// C
if (keyCombination(e, 67)) {
e.preventDefault();
this.goToNextCommentForm(true);
}
})
.on('addedCommentsUpdate', ({ all, relevant, bySection }) => {
this.updateRefreshButton(all.length, bySection, Boolean(relevant.length));
});
commentFormRegistry
.on('add', this.updateCommentFormButton.bind(this))
.on('remove', this.updateCommentFormButton.bind(this));
LiveTimestamp
.on('updateImproved', this.updateTimestampsInRefreshButtonTooltip.bind(this));
visits
.on('process', this.fill.bind(this));
commentRegistry
.on('registerSeen', this.updateFirstUnseenButton.bind(this));
} else {
this.reset();
}
} else {
if (this.isMounted()) {
this.unmount();
}
}
},
/**
* Render the navigation panel. This is done when the page is first loaded, or created using the
* script.
*
* @private
*/
mount() {
this.$element = $('<div>')
.attr('id', 'cd-navPanel')
.appendTo(document.body);
this.refreshButton = new Button({
tagName: 'div',
classes: ['cd-navPanel-button'],
id: 'cd-navPanel-refreshButton',
action: (e) => {
this.refreshClick(isCmdModifierPressed(e));
},
});
this.updateRefreshButton(0);
this.previousButton = new Button({
tagName: 'div',
classes: ['cd-navPanel-button', 'cd-icon'],
id: 'cd-navPanel-previousButton',
tooltip: `${cd.s('navpanel-previous')} ${cd.mws('parentheses', 'W')}`,
action: () => {
this.goToPreviousNewComment();
},
}).hide();
$(this.previousButton.element).append(
createSvg(16, 16, 20, 20).html(
`<path d="M1 13.75l1.5 1.5 7.5-7.5 7.5 7.5 1.5-1.5-9-9-9 9z" />`
)
);
this.nextButton = new Button({
tagName: 'div',
classes: ['cd-navPanel-button', 'cd-icon'],
id: 'cd-navPanel-nextButton',
tooltip: `${cd.s('navpanel-next')} ${cd.mws('parentheses', 'S')}`,
action: () => {
this.goToNextNewComment();
},
}).hide();
$(this.nextButton.element).append(
createSvg(16, 16, 20, 20).html(
`<path d="M19 6.25l-1.5-1.5-7.5 7.5-7.5-7.5L1 6.25l9 9 9-9z" />`
)
);
this.firstUnseenButton = new Button({
tagName: 'div',
classes: ['cd-navPanel-button'],
id: 'cd-navPanel-firstUnseenButton',
tooltip: `${cd.s('navpanel-firstunseen')} ${cd.mws('parentheses', 'F')}`,
action: () => {
this.goToFirstUnseenComment();
},
}).hide();
this.commentFormButton = new Button({
tagName: 'div',
classes: ['cd-navPanel-button', 'cd-icon'],
id: 'cd-navPanel-commentFormButton',
tooltip: `${cd.s('navpanel-commentform')} ${cd.mws('parentheses', 'C')}`,
action: () => {
this.goToNextCommentForm();
},
}).hide();
$(this.commentFormButton.element).append(
createSvg(16, 16, 20, 20).html(
cd.g.contentDirection === 'ltr' ?
`<path d="M18 0H2a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V2a2 2 0 00-2-2zM5 9.06a1.39 1.39 0 111.37-1.39A1.39 1.39 0 015 9.06zm5.16 0a1.39 1.39 0 111.39-1.39 1.39 1.39 0 01-1.42 1.39zm5.16 0a1.39 1.39 0 111.39-1.39 1.39 1.39 0 01-1.42 1.39z" />` :
`<path d="M0 2v12c0 1.1.9 2 2 2h14l4 4V2c0-1.1-.9-2-2-2H2C.9 0 0 .9 0 2zm13.6 5.7c0-.8.6-1.4 1.4-1.4.8 0 1.4.6 1.4 1.4s-.6 1.4-1.4 1.4c-.8-.1-1.4-.7-1.4-1.4zM9.9 9.1s-.1 0 0 0c-.8 0-1.4-.6-1.4-1.4 0-.8.6-1.4 1.4-1.4.8 0 1.4.6 1.4 1.4s-.7 1.4-1.4 1.4zm-5.2 0c-.8 0-1.4-.6-1.4-1.4 0-.8.6-1.4 1.4-1.4.8 0 1.4.6 1.4 1.4 0 .7-.7 1.4-1.4 1.4z" />`
)
);
this.$element.append(
this.refreshButton.element,
this.previousButton.element,
this.nextButton.element,
this.firstUnseenButton.element,
this.commentFormButton.element,
);
},
/**
* Remove the navigation panel.
*
* @private
*/
unmount() {
this.$element.remove();
this.$element = null;
},
/**
* Check if the navigation panel is mounted. Is equivalent to checking the existence of
* {@link module:navPanel.$element}, and for most practical purposes, does the same as the
* {@link module:pageRegistry.Page#isActive} check.
*
* @returns {boolean}
*/
isMounted() {
return Boolean(this.$element);
},
/**
* Reset the navigation panel to the initial state. This is done after page refreshes. (Comment
* forms are expected to be restored already.)
*
* @private
*/
reset() {
this.updateRefreshButton(0);
this.previousButton.hide();
this.nextButton.hide();
this.firstUnseenButton.hide();
this.commentFormButton.hide();
clearTimeout(this.utirbtTimeout);
},
/**
* Count the new and unseen comments on the page and update the navigation panel to reflect that.
*
* @private
*/
fill() {
if (commentRegistry.getAll().some((comment) => comment.isNew)) {
this.updateRefreshButtonTooltip(0);
this.previousButton.show();
this.nextButton.show();
this.updateFirstUnseenButton();
}
},
/**
* Perform routines at the refresh button click.
*
* @param {boolean} markAsRead Whether to mark all comments as read.
* @private
*/
refreshClick(markAsRead) {
// There was reload confirmation here, but after session restore was introduced, the
// confirmation seems to be no longer needed.
controller.reload({
commentIds: controller.getRelevantAddedCommentIds(),
markAsRead,
});
},
/**
* Generic function for {@link module:navPanel.goToPreviousNewComment} and
* {@link module:navPanel.goToNextNewComment}.
*
* @param {string} direction
* @private
*/
goToNewCommentInDirection(direction) {
if (controller.isAutoScrolling()) return;
const commentInViewport = commentRegistry.findInViewport(direction);
if (!commentInViewport) return;
const reorderedComments = reorderArray(
commentRegistry.getAll(),
commentInViewport.index,
direction === 'backward'
);
const candidates = reorderedComments
.filter((comment) => comment.isNew && !comment.isInViewport());
const comment = candidates.find((comment) => comment.isInViewport() === false) || candidates[0];
if (comment) {
comment.scrollTo({
flash: null,
callback: () => {
// The default controller.handleScroll() callback is executed in $#cdScrollTo, but
// that happens after a 300ms timeout, so we have a chance to have our callback executed
// first.
comment.registerSeen(direction, true);
},
});
}
},
/**
* Scroll to the previous new comment.
*/
goToPreviousNewComment() {
this.goToNewCommentInDirection('backward');
},
/**
* Scroll to the next new comment.
*/
goToNextNewComment() {
this.goToNewCommentInDirection('forward');
},
/**
* Scroll to the first unseen comment.
*/
goToFirstUnseenComment() {
if (controller.isAutoScrolling()) return;
const candidates = commentRegistry.query((comment) => comment.isSeen === false);
const comment = candidates.find((comment) => comment.isInViewport() === false) || candidates[0];
comment?.scrollTo({
flash: null,
callback: () => {
// The default controller.handleScroll() callback is executed in $#cdScrollTo, but
// that happens after a 300ms timeout, so we have a chance to have our callback executed
// first.
comment.registerSeen('forward', true);
},
});
},
/**
* Go to the next comment form out of sight, or just the next comment form, if `inSight` is set to
* `true`.
*
* @param {boolean} [inSight=false]
*/
goToNextCommentForm(inSight) {
commentFormRegistry
.query((commentForm) => inSight || !commentForm.$element.cdIsInViewport(true))
.map((commentForm) => {
let top = commentForm.$element[0].getBoundingClientRect().top;
if (top < 0) {
top += $(document).height() * 2;
}
return { commentForm, top };
})
.sort((data1, data2) => data1.top - data2.top)
.map((data) => data.commentForm)[0]
?.goTo();
},
/**
* Update the refresh button to show the number of comments added to the page since it was loaded.
*
* @param {number} commentCount
* @param {Map} [commentsBySection]
* @param {boolean} [areThereRelevant = false]
* @private
*/
updateRefreshButton(commentCount, commentsBySection, areThereRelevant = false) {
$(this.refreshButton.element)
.empty()
.append(
commentCount ?
$('<span>')
// Can't set the attribute to the button as its tooltip may have another direction.
.attr('dir', 'ltr')
.text(`+${commentCount}`) :
createSvg(20, 20).html(
`<path d="M15.65 4.35A8 8 0 1017.4 13h-2.22a6 6 0 11-1-7.22L11 9h7V2z" />`
)
)
.toggleClass('cd-navPanel-addedCommentCount', Boolean(commentCount))
.toggleClass('cd-icon', !commentCount)
.toggleClass('cd-navPanel-refreshButton-relevant', areThereRelevant);
this.updateRefreshButtonTooltip(commentCount, commentsBySection);
},
/**
* Update the tooltip of the refresh button, displaying statistics of comments not yet displayed
* if there are such.
*
* @param {number} commentCount
* @param {Map} [commentsBySection]
* @private
*/
updateRefreshButtonTooltip(commentCount, commentsBySection) {
// If the method was not called after a timeout and the timeout exists, clear it.
clearTimeout(this.utirbtTimeout);
this.cachedCommentCount = commentCount;
this.cachedCommentsBySection = commentsBySection;
let tooltipText = null;
const areThereNew = commentRegistry.getAll().some((comment) => comment.isNew);
if (commentCount) {
tooltipText = (
cd.s('navpanel-newcomments-count', commentCount) +
' ' +
cd.s('navpanel-newcomments-refresh') +
' ' +
cd.mws('parentheses', 'R')
);
if (areThereNew && this.highlightNewInterval) {
tooltipText += '\n' + cd.s('navpanel-markasread', cd.g.cmdModifier);
}
const bullet = removeWikiMarkup(cd.s('bullet'));
const rtlMarkOrNot = cd.g.contentDirection === 'rtl' ? '\u200f' : '';
commentsBySection.forEach((comments, section) => {
const headline = section?.headline;
tooltipText += headline ? `\n\n${headline}` : '\n';
comments.forEach((comment) => {
tooltipText += `\n`;
const names = comment.parent?.author && comment.level > 1 ?
cd.s(
'navpanel-newcomments-names',
comment.author.getName(),
comment.parent.author.getName()
) :
comment.author.getName();
const date = comment.date ?
formatDate(comment.date) :
cd.s('navpanel-newcomments-unknowndate');
tooltipText += bullet + ' ' + names + rtlMarkOrNot + cd.mws('comma-separator') + date;
});
});
// When timestamps are relative, we need to update the tooltip manually every minute. When
// `improved` timestamps are used, timestamps are updated in LiveTimestamp.updateImproved().
if (this.timestampFormat === 'relative') {
this.utirbtTimeout = setTimeout(
this.updateTimestampsInRefreshButtonTooltip.bind(this),
cd.g.msInMin
);
}
} else {
tooltipText = cd.s('navpanel-refresh') + ' ' + cd.mws('parentheses', 'R');
if (areThereNew && this.highlightNewInterval) {
tooltipText += '\n' + cd.s('navpanel-markasread', cd.g.cmdModifier);
}
}
this.refreshButton.setTooltip(tooltipText);
},
/**
* Update the tooltip of the {@link module:navPanel.refreshButton refresh button}. This is called
* to update timestamps in the text.
*
* @private
*/
updateTimestampsInRefreshButtonTooltip() {
this.updateRefreshButtonTooltip(this.cachedCommentCount, this.cachedCommentsBySection);
},
/**
* Update the state of the
* {@link module:navPanel.firstUnseenButton "Go to the first unseen comment"} button.
*
* @private
*/
updateFirstUnseenButton() {
if (!this.isMounted()) return;
const unseenCommentCount = commentRegistry.query((c) => c.isSeen === false).length;
this.firstUnseenButton
.toggle(Boolean(unseenCommentCount))
.setLabel(unseenCommentCount);
},
/**
* Update the {@link module:navPanel.commentFormButton "Go to the next comment form out of sight"}
* button visibility.
*
* @private
*/
updateCommentFormButton() {
if (!this.isMounted() || controller.isAutoScrolling()) return;
this.commentFormButton.toggle(
commentFormRegistry.getAll().some((cf) => !cf.$element.cdIsInViewport(true))
);
},
};