// See [[mw:Reference Tooltips]]
// Source https://en.wikipedia.org/wiki/MediaWiki:Gadget-ReferenceTooltips.js
( function () {
var ILH_LINK_SELECTOR = '.ilh-all:not(.ilh-blue) > .ilh-page > a';
var messages = {
'en': {
'ilh-settings': 'Internal Link Helper settings',
'ilh-enable-footer': 'Enable Internal Link Helper',
'ilh-settings-title': 'Internal Link Helper',
'ilh-save': 'Save',
'ilh-cancel': 'Cancel',
'ilh-enable': 'Enable',
'ilh-disable': 'Disable',
'ilh-activationMethod': 'Tooltip appears when',
'ilh-hovering': 'hovering',
'ilh-clicking': 'clicking',
'ilh-delay': 'Delay before the tooltip appears (in milliseconds)',
'ilh-disabledNote': 'You can re-enable Internal Link Helper using a link in the footer of the page.',
'ilh-done': 'Done',
'ilh-enabled': 'Internal Link Helper is enabled',
'ilh-target-missing': 'The page $1 does not exist, but there is a corresponding page in $2.',
'ilh-visit-foreign': 'View this page in $1'
},
'yue': {
'ilh-settings': '內部連結助手',
'ilh-enable-footer': '開著內部連結助手',
'ilh-settings-title': '內部連結助手',
'ilh-save': '儲存',
'ilh-cancel': '取消',
'ilh-enable': '開著',
'ilh-disable': '閂咗佢',
'ilh-activationMethod': '幾時顯示內部連結助手',
'ilh-hovering': '指標擺喺連結上面嘅時候顯示',
'ilh-clicking': '撳落去先顯示',
'ilh-delay': '延遲幾耐先顯示(以毫秒計)',
'ilh-disabledNote': '你可以喺呢版最底處開返著內部連結助手。',
'ilh-done': '搞掂',
'ilh-enabled': '內部連結助手開著咗',
'ilh-target-missing': '呢度搵唔到「$1」,但係$2維基百科度有對應嘅版。',
'ilh-visit-foreign': '去$1維基百科'
}
};
var USER_LANGUAGE = mw.config.get('wgUserLanguage').split('-')[0];
mw.messages.set( messages[ USER_LANGUAGE in messages ? USER_LANGUAGE : 'en' ] );
// "Global" variables
var SECONDS_IN_A_DAY = 60 * 60 * 24,
CLASSES = {
FADE_IN_DOWN: 'ilh-fade-in-down',
FADE_IN_UP: 'ilh-fade-in-up',
FADE_OUT_DOWN: 'ilh-fade-out-down',
FADE_OUT_UP: 'ilh-fade-out-up'
},
IS_TOUCHSCREEN = 'ontouchstart' in document.documentElement,
CLIENT_NAME = $.client.profile().name,
settingsString, settings, enabled, delay, activatedByClick, cursorWaitCss,
windowManager,
$body = $( document.body ),
$window = $( window );
function ilh( $content ) {
// Popups gadget
if ( window.pg ) {
return;
}
var teSelector,
settingsDialogOpening = false;
function setSettingsCookie() {
mw.cookie.set(
'ILHsettings',
Number( enabled ) + '|' + delay + '|' + Number( activatedByClick ),
{ path: '/', expires: 90 * SECONDS_IN_A_DAY, prefix: '' }
);
}
function enableILH() {
enabled = true;
setSettingsCookie();
$( '.ilh-enableItem' ).remove();
ilh( $content );
mw.notify( mw.msg( 'ilh-enabled' ) );
}
function disableILH() {
$content.find( teSelector ).off( '.ilh' );
$body.off( '.ilh' );
$window.off( '.ilh' );
}
function addEnableLink() {
// #footer-places – Vector
// #f-list – Timeless, Monobook, Modern
// parent of #footer li – Cologne Blue
var $footer = $( '#footer-places, #f-list' );
if ( !$footer.length ) {
$footer = $( '#footer li' ).parent();
}
$footer.append(
$( '<li>' )
.addClass( 'ilh-enableItem' )
.append(
$( '<a>' )
.text( mw.msg( 'ilh-enable-footer' ) )
.attr( 'href', 'javascript:' )
.on( 'click', function ( e ) {
e.preventDefault();
enableILH();
} )
)
);
}
function TooltippedElement( $element ) {
var tooltip,
events,
te = this;
function onStartEvent( e ) {
if ( activatedByClick && e.type !== 'contextmenu' ) {
e.preventDefault();
}
te.showRef.apply( te, [ $( this ), e.pageX, e.pageY ] );
}
function onEndEvent() {
te.hideRef();
}
if ( !$element ) {
return;
}
// TooltippedElement.$element and TooltippedElement.$originalElement will be different when
// the first is changed after its cloned version is hovered in a tooltip
this.$element = $element;
this.$originalElement = $element;
if ( activatedByClick ) {
events = {
'click.ilh': onStartEvent
};
} else {
events = {
'mouseenter.ilh': onStartEvent,
'mouseleave.ilh': onEndEvent
};
}
this.$element.on( events );
this.hideRef = function ( immediately ) {
clearTimeout( te.showTimer );
if ( this.tooltip && this.tooltip.isPresent ) {
if ( activatedByClick || immediately ) {
this.tooltip.hide();
} else {
this.hideTimer = setTimeout( function () {
te.tooltip.hide();
}, 200 );
}
} else if ( this.$ref && this.$ref.hasClass( 'ilh-target' ) ) {
this.$ref.removeClass( 'ilh-target' );
if ( activatedByClick ) {
$body.off( 'click.ilh touchstart.ilh', this.onBodyClick );
}
}
};
this.showRef = function ( $element, ePageX, ePageY ) {
// Popups gadget
if ( window.pg ) {
disableILH();
return;
}
if ( this.tooltip && !this.tooltip.$content.length ) {
return;
}
var tooltipInitiallyPresent = this.tooltip && this.tooltip.isPresent;
function reallyShow() {
var viewportTop, refOffsetTop;
if ( !tooltipInitiallyPresent ) {
viewportTop = $window.scrollTop();
}
if ( !te.tooltip ) {
te.tooltip = new Tooltip( te );
if ( !te.tooltip.$content.length ) {
return;
}
}
// If this tooltip is called from inside another tooltip. We can't define it
// in the constructor since a ref can be cloned but have the same Tooltip object;
// so, Tooltip.parent is a floating value.
te.tooltip.parent = te.$element.closest( '.ilh-tooltip' ).data( 'tooltip' );
if ( te.tooltip.parent && te.tooltip.parent.disappearing ) {
return;
}
te.tooltip.show();
if ( tooltipInitiallyPresent ) {
if ( te.tooltip.$element.hasClass( 'ilh-tooltip-above' ) ) {
te.tooltip.$element.addClass( CLASSES.FADE_IN_DOWN );
} else {
te.tooltip.$element.addClass( CLASSES.FADE_IN_UP );
}
return;
}
te.tooltip.calculatePosition( ePageX, ePageY );
$window.on( 'resize.ilh', te.onWindowResize );
}
// We redefine this.$element here because e.target can be a reference link inside
// a reference tooltip, not a link that was initially assigned to this.$element
this.$element = $element;
if ( activatedByClick ) {
if ( tooltipInitiallyPresent ||
( this.$ref && this.$ref.hasClass( 'ilh-target' ) )
) {
return;
} else {
setTimeout( function () {
$body.on( 'click.ilh touchstart.ilh', te.onBodyClick );
}, 0 );
}
}
if ( activatedByClick || tooltipInitiallyPresent ) {
reallyShow();
} else {
this.showTimer = setTimeout( reallyShow, delay );
}
};
this.onBodyClick = function ( e ) {
if ( !te.tooltip && !(te.$ref && te.$ref.hasClass( 'ilh-target' )) ) {
return;
}
var $current = $( e.target );
function contextMatchesParameter( parameter ) {
return this === parameter;
}
// The last condition is used to determine cases when a clicked tooltip is the current
// element's tooltip or one of its descendants
while ( $current.length &&
( !$current.hasClass( 'ilh-tooltip' ) ||
!$current.data( 'tooltip' ) ||
!$current.data( 'tooltip' ).upToTopParent(
contextMatchesParameter, [ te.tooltip ],
true
)
)
) {
$current = $current.parent();
}
if ( !$current.length ) {
te.hideRef();
}
};
this.onWindowResize = function () {
te.tooltip.calculatePosition();
};
}
function Tooltip( te ) {
function openSettingsDialog() {
var settingsDialog, settingsWindow;
if ( cursorWaitCss ) {
cursorWaitCss.disabled = true;
}
function SettingsDialog() {
SettingsDialog.parent.call( this );
}
OO.inheritClass( SettingsDialog, OO.ui.ProcessDialog );
SettingsDialog.static.name = 'settingsDialog';
SettingsDialog.static.title = mw.msg( 'ilh-settings-title' );
SettingsDialog.static.actions = [
{
modes: 'basic',
action: 'save',
label: mw.msg( 'ilh-save' ),
flags: [ 'primary', 'progressive' ]
},
{
modes: 'basic',
label: mw.msg( 'ilh-cancel' ),
flags: 'safe'
},
{
modes: 'disabled',
action: 'deactivated',
label: mw.msg( 'ilh-done' ),
flags: [ 'primary', 'progressive' ]
}
];
SettingsDialog.prototype.initialize = function () {
var dialog = this;
SettingsDialog.parent.prototype.initialize.apply( this, arguments );
this.enableOption = new OO.ui.RadioOptionWidget( {
label: mw.msg( 'ilh-enable' )
} );
this.disableOption = new OO.ui.RadioOptionWidget( {
label: mw.msg( 'ilh-disable' )
} );
this.enableSelect = new OO.ui.RadioSelectWidget( {
items: [ this.enableOption, this.disableOption ],
classes: [ 'ilh-enableSelect' ]
} );
this.enableSelect.selectItem( this.enableOption );
this.enableSelect.on( 'choose', function ( item ) {
if ( item === dialog.disableOption ) {
dialog.activationMethodSelect.setDisabled( true );
dialog.delayInput.setDisabled( true );
} else {
dialog.activationMethodSelect.setDisabled( false );
dialog.delayInput.setDisabled( dialog.clickOption.isSelected() );
}
} );
this.hoverOption = new OO.ui.RadioOptionWidget( {
label: mw.msg( 'ilh-hovering' )
} );
this.clickOption = new OO.ui.RadioOptionWidget( {
label: mw.msg( 'ilh-clicking' )
} );
this.activationMethodSelect = new OO.ui.RadioSelectWidget( {
items: [ this.hoverOption, this.clickOption ]
} );
this.activationMethodSelect.selectItem( activatedByClick ?
this.clickOption :
this.hoverOption
);
this.activationMethodSelect.on( 'choose', function ( item ) {
if ( item === dialog.clickOption ) {
dialog.delayInput.setDisabled( true );
} else {
dialog.delayInput.setDisabled( dialog.clickOption.isSelected() );
}
} );
this.activationMethodField = new OO.ui.FieldLayout( this.activationMethodSelect, {
label: mw.msg( 'ilh-activationMethod' ),
align: 'top'
} );
this.delayInput = new OO.ui.NumberInputWidget( {
input: { value: delay },
step: 50,
min: 0,
max: 5000,
disabled: activatedByClick,
classes: [ 'ilh-numberInput' ]
} );
this.delayField = new OO.ui.FieldLayout( this.delayInput, {
label: mw.msg( 'ilh-delay' ),
align: 'top'
} );
this.fieldset = new OO.ui.FieldsetLayout();
this.fieldset.addItems( [
this.activationMethodField,
this.delayField,
] );
this.panelSettings = new OO.ui.PanelLayout( {
padded: true,
expanded: false
} );
this.panelSettings.$element.append(
this.enableSelect.$element,
$( '<hr>' ).addClass( 'ilh-settingsFormSeparator' ),
this.fieldset.$element
);
this.panelDisabled = new OO.ui.PanelLayout( {
padded: true,
expanded: false
} );
this.panelDisabled.$element.append(
$( '<table>' )
.addClass( 'ilh-disabledHelp' )
.append(
$( '<tr>' ).append(
$( '<td>' ).append(
$( '<img>' ).attr( 'src', 'https://en.wikipedia.org/w/load.php?modules=ext.popups.images&image=footer&format=rasterized&lang=ru&skin=vector&version=0uotisb' )
),
$( '<td>' )
.addClass( 'ilh-disabledNote' )
.text( mw.msg( 'ilh-disabledNote' ) )
)
)
);
this.stackLayout = new OO.ui.StackLayout( {
items: [ this.panelSettings, this.panelDisabled ]
} );
this.$body.append( this.stackLayout.$element );
};
SettingsDialog.prototype.getSetupProcess = function ( data ) {
return SettingsDialog.parent.prototype.getSetupProcess.call( this, data )
.next( function () {
this.stackLayout.setItem( this.panelSettings );
this.actions.setMode( 'basic' );
}, this );
};
SettingsDialog.prototype.getActionProcess = function ( action ) {
var dialog = this;
if ( action === 'save' ) {
return new OO.ui.Process( function () {
var newDelay = Number( dialog.delayInput.getValue() );
enabled = dialog.enableOption.isSelected();
if ( newDelay >= 0 && newDelay <= 5000 ) {
delay = newDelay;
}
activatedByClick = dialog.clickOption.isSelected();
setSettingsCookie();
if ( enabled ) {
dialog.close();
disableILH();
ilh( $content );
} else {
dialog.actions.setMode( 'disabled' );
dialog.stackLayout.setItem( dialog.panelDisabled );
disableILH();
addEnableLink();
}
} );
} else if ( action === 'deactivated' ) {
dialog.close();
}
return SettingsDialog.parent.prototype.getActionProcess.call( this, action );
};
SettingsDialog.prototype.getBodyHeight = function () {
return this.stackLayout.getCurrentItem().$element.outerHeight( true );
};
tooltip.upToTopParent( function adjustRightAndHide() {
if ( this.isPresent ) {
if ( this.$element[ 0 ].style.right ) {
this.$element.css(
'right',
'+=' + ( window.innerWidth - $window.width() )
);
}
this.te.hideRef( true );
}
} );
if ( !windowManager ) {
windowManager = new OO.ui.WindowManager();
$body.append( windowManager.$element );
}
settingsDialog = new SettingsDialog();
windowManager.addWindows( [ settingsDialog ] );
settingsWindow = windowManager.openWindow( settingsDialog );
settingsWindow.opened.then( function () {
settingsDialogOpening = false;
} );
settingsWindow.closed.then( function () {
windowManager.clearWindows();
} );
}
var tooltip = this;
// This variable can change: one tooltip can be called from a harvard-style reference link
// that is put into different tooltips
this.te = te;
var ilhWrapper = this.te.$originalElement.closest( '.ilh-all' );
var origTitle = ilhWrapper.data( 'orig-title' );
var langName = ilhWrapper.data( 'lang-name' );
var foreignLink = this.te.$originalElement.parent().next().find( 'a.extiw' ).attr( 'href' );
this.id = 'ilh-'.concat(origTitle);
this.$content = $( "<div /> ").text( mw.msg( 'ilh-target-missing', origTitle, langName ) );
if ( !this.$content.length ) {
return;
}
this.insideWindow = Boolean( this.te.$element.closest( '.oo-ui-window' ).length );
this.$element = $( '<div>' )
.addClass( 'ilh-tooltip' )
.attr( 'id', this.id )
.attr( 'role', 'tooltip' )
.data( 'tooltip', this );
if ( this.insideWindow ) {
this.$element.addClass( 'ilh-tooltip-insideWindow' );
}
// We need the $content interlayer here in order for the settings icon to have correct
// margins
this.$content = this.$content
.wrapAll( '<div>' )
.parent()
.addClass( 'ilh-tooltipContent' )
.addClass( 'mw-parser-output' )
.appendTo( this.$element );
$( '<div>' ).addClass( 'ilh-icon' ).prependTo( this.$element );
$( '<a />' )
.attr( 'href', foreignLink )
.addClass( 'ilh-tooltip-link' )
.text( mw.msg( 'ilh-visit-foreign', langName ))
.appendTo( this.$element );
if ( !activatedByClick ) {
this.$element
.mouseenter( function () {
if ( !tooltip.disappearing ) {
tooltip.upToTopParent( function () {
this.show();
} );
}
} )
.mouseleave( function ( e ) {
// https://stackoverflow.com/q/47649442 workaround. Relying on relatedTarget
// alone has pitfalls: when alt-tabbing, relatedTarget is empty too
if ( CLIENT_NAME !== 'chrome' ||
( !e.originalEvent ||
e.originalEvent.relatedTarget !== null ||
!tooltip.clickedTime ||
$.now() - tooltip.clickedTime > 50
)
) {
tooltip.upToTopParent( function () {
this.te.hideRef();
} );
}
} )
.click( function () {
tooltip.clickedTime = $.now();
} );
}
if ( !this.insideWindow ) {
$( '<div>' )
.addClass( 'ilh-settingsLink' )
.attr( 'title', mw.msg( 'ilh-settings' ) )
.click( function () {
if ( settingsDialogOpening ) {
return;
}
settingsDialogOpening = true;
if ( mw.loader.getState( 'oojs-ui' ) !== 'ready' ) {
if ( cursorWaitCss ) {
cursorWaitCss.disabled = false;
} else {
cursorWaitCss = mw.util.addCSS( 'body { cursor: wait; }' );
}
}
mw.loader.using( [ 'oojs', 'oojs-ui' ], openSettingsDialog );
} )
.appendTo( this.$element );
}
// Tooltip tail element is inside tooltip content element in order for the tooltip
// not to disappear when the mouse is above the tail
this.$tail = $( '<div>' )
.addClass( 'ilh-tooltipTail' )
.prependTo( this.$element );
this.disappearing = false;
this.show = function () {
this.disappearing = false;
clearTimeout( this.te.hideTimer );
clearTimeout( this.te.removeTimer );
this.$element
.removeClass( CLASSES.FADE_OUT_DOWN )
.removeClass( CLASSES.FADE_OUT_UP );
if ( !this.isPresent ) {
$body.append( this.$element );
}
this.isPresent = true;
};
this.hide = function () {
var tooltip = this;
tooltip.disappearing = true;
if ( tooltip.$element.hasClass( 'ilh-tooltip-above' ) ) {
tooltip.$element
.removeClass( CLASSES.FADE_IN_DOWN )
.addClass( CLASSES.FADE_OUT_UP );
} else {
tooltip.$element
.removeClass( CLASSES.FADE_IN_UP )
.addClass( CLASSES.FADE_OUT_DOWN );
}
tooltip.te.removeTimer = setTimeout( function () {
if ( tooltip.isPresent ) {
tooltip.$element.detach();
tooltip.$tail.css( 'left', '' );
if ( activatedByClick ) {
$body.off( 'click.ilh touchstart.ilh', tooltip.te.onBodyClick );
}
$window.off( 'resize.ilh', tooltip.te.onWindowResize );
tooltip.isPresent = false;
}
}, 200 );
};
this.calculatePosition = function ( ePageX, ePageY ) {
var teElement, teOffsets, teOffset, tooltipTailOffsetX, tooltipTailLeft,
offsetYCorrection = 0;
this.$tail.css( 'left', '' );
teElement = this.te.$element.get( 0 );
if ( ePageX !== undefined ) {
tooltipTailOffsetX = ePageX;
teOffsets = teElement.getClientRects &&
teElement.getClientRects() ||
teElement.getBoundingClientRect();
if ( teOffsets.length > 1 ) {
for (var i = teOffsets.length - 1; i >= 0; i--) {
if ( ePageY >= Math.round( $window.scrollTop() + teOffsets[i].top ) &&
ePageY <= Math.round(
$window.scrollTop() + teOffsets[i].top + teOffsets[i].height
)
) {
teOffset = teOffsets[i];
}
}
}
}
if ( !teOffset ) {
teOffset = teElement.getClientRects &&
teElement.getClientRects()[0] ||
teElement.getBoundingClientRect();
}
teOffset = {
top: $window.scrollTop() + teOffset.top,
left: $window.scrollLeft() + teOffset.left,
width: teOffset.width,
height: teOffset.height
};
if ( !tooltipTailOffsetX ) {
tooltipTailOffsetX = ( teOffset.left * 2 + teOffset.width ) / 2;
}
this.$element.css( {
top: teOffset.top - this.$element.outerHeight() - 7 + offsetYCorrection,
left: tooltipTailOffsetX - 20,
right: ''
} );
// Is it squished against the right side of the page?
if ( this.$element.offset().left + this.$element.outerWidth() > $window.width() - 1 ) {
this.$element.css( {
left: '',
right: 0
} );
tooltipTailLeft = tooltipTailOffsetX - this.$element.offset().left - 5;
}
// Is a part of it above the top of the screen?
if ( teOffset.top < this.$element.outerHeight() + $window.scrollTop() + 6 ) {
this.$element
.removeClass( 'ilh-tooltip-above' )
.addClass( 'ilh-tooltip-below' )
.addClass( CLASSES.FADE_IN_UP )
.css( {
top: teOffset.top + teOffset.height + 9 + offsetYCorrection
} );
if ( tooltipTailLeft ) {
this.$tail.css( 'left', ( tooltipTailLeft + 12 ) + 'px' );
}
} else {
this.$element
.removeClass( 'ilh-tooltip-below' )
.addClass( 'ilh-tooltip-above' )
.addClass( CLASSES.FADE_IN_DOWN )
// A fix for cases when a tooltip shown once is then wrongly positioned when it
// is shown again after a window resize. We just repeat what is above.
.css( {
top: teOffset.top - this.$element.outerHeight() - 7 + offsetYCorrection
} );
if ( tooltipTailLeft ) {
// 12 is the tail element width/height
this.$tail.css( 'left', tooltipTailLeft + 'px' );
}
}
};
// Run some function for all the tooltips up to the top one in a tree. Its context will be
// the tooltip, while its parameters may be passed to Tooltip.upToTopParent as an array
// in the second parameter. If the third parameter passed to ToolTip.upToTopParent is true,
// the execution stops when the function in question returns true for the first time,
// and ToolTip.upToTopParent returns true as well.
this.upToTopParent = function ( func, parameters, stopAtTrue ) {
var returnValue,
currentTooltip = this;
do {
returnValue = func.apply( currentTooltip, parameters );
if ( stopAtTrue && returnValue ) {
break;
}
} while ( currentTooltip = currentTooltip.parent );
if ( stopAtTrue ) {
return returnValue;
}
};
}
if ( !enabled ) {
addEnableLink();
return;
}
teSelector = ILH_LINK_SELECTOR;
$content.find( teSelector ).each( function () {
new TooltippedElement( $( this ) );
} );
}
settingsString = mw.cookie.get( 'ILHsettings', '' );
if ( settingsString ) {
settings = settingsString.split( '|' );
enabled = Boolean( Number( settings[ 0 ] ) );
delay = Number( settings[ 1 ] );
activatedByClick = Boolean( Number( settings[ 2 ] ) );
} else {
enabled = true;
delay = 200;
// Since the mobile browser check is error-prone, adding IS_MOBILE condition here would probably
// leave cases where a user interacting with the browser using touches doesn't know how to call
// a tooltip in order to switch to activation by click. Some touch-supporting laptop users
// interacting by touch (though probably not the most popular use case) would not be happy too.
activatedByClick = IS_TOUCHSCREEN;
}
mw.hook( 'wikipage.content' ).add( ilh );
}() );