Move reference previews to Cite extension

The ext.cite.referencePreviews module will transparently replace the
ext.popups.referencePreviews module after this patch.  Configuration
stays in Popups for now, we can migrate it in later work.

CSS classes may be renamed in the future but this will be handled
separately since it could be a breaking change for on-wiki
customizations.

A lot of fancy footwork happens in this patch to emulate a soft
dependency on Popups.  This mechanism doesn't exist explicitly in
either ResourceLoader or QUnit, so lots of workarounds are used, to
conditionally load the module and to dynamically skip dependent tests.

renderer.test.js is fully skipped for now, but can be wired up in
later work.

Bug: T355194
Change-Id: I0dc47abb59a40d4e41e7dda0eb7b415a2e1ae508
This commit is contained in:
Jon Robson 2024-01-12 14:30:44 -08:00 committed by WMDE-Fisch
parent 66d73af276
commit dcb513eb0e
24 changed files with 1304 additions and 14 deletions

View file

@ -1,8 +1,6 @@
{
"root": true,
"extends": [
"wikimedia/client-es6",
"wikimedia/jquery",
"wikimedia/mediawiki"
"wikimedia/server"
]
}

View file

@ -0,0 +1,80 @@
import * as helpers from '../../utils/functions.helper.js';
const title = getTestString( 'CiteTest-title' );
const encodedTitle = encodeURIComponent( title );
function getTestString( prefix = '' ) {
return prefix + Math.random().toString();
}
function skipTest( message ) {
cy.log( message );
// Dips into secret internals—stealing code from the skip plugin.
const mochaContext = cy.state( 'runnable' ).ctx;
return mochaContext.skip();
}
describe( 'Cite popups integration', () => {
before( () => {
cy.visit( '/index.php' );
const wikiText = 'Lorem ipsum dolor.<ref>small reference</ref>' +
'Reference with lots of text.<ref>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</ref>' +
'Lorem ipsum dolor.<ref>reference inception{{#tag:ref|body}}</ref>';
// Rely on the retry behavior of Cypress assertions to use this as a "wait"
// until the specified conditions are met.
cy.window().should( 'have.property', 'mw' ).and( 'have.property', 'loader' ).and( 'have.property', 'using' );
// Create a new page containing a reference
cy.window().then( async ( win ) => {
await win.mw.loader.using( 'mediawiki.api' );
const response = await new win.mw.Api().create( title, {}, wikiText );
expect( response.result ).to.equal( 'Success' );
await win.mw.loader.using( 'ext.popups.main', () => {}, () => skipTest( 'Popups not available' ) );
} );
} );
beforeEach( () => {
cy.visit( `/index.php?title=${ encodedTitle }` );
cy.window()
.should( 'have.property', 'mw' ).and( 'have.property', 'loader' ).and( 'have.property', 'getState' );
cy.window().should( ( win ) => win.mw.loader.getState( 'ext.cite.referencePreviews' ) === 'ready' );
} );
it( 'simple popup on hover and hide on leave', () => {
helpers.abandonReference( 'cite_ref-1' );
helpers.dwellReference( 'cite_ref-1' );
cy.get( '.mwe-popups-type-reference', { timeout: 1000 } )
.should( 'be.visible' );
helpers.assertPreviewIsScrollable( false );
cy.get( '.mwe-popups-fade-out' ).should( 'not.exist' );
helpers.abandonReference( 'cite_ref-1' );
cy.get( '.mwe-popups-type-reference' )
.should( 'not.exist' );
} );
it( 'includes scrollbar and fadeout on long previews', () => {
helpers.abandonReference( 'cite_ref-2' );
helpers.dwellReference( 'cite_ref-2' );
cy.get( '.mwe-popups-type-reference', { timeout: 1000 } )
.should( 'be.visible' );
helpers.assertPreviewIsScrollable( true );
cy.get( '.mwe-popups-fade-out' ).should( 'be.visible' );
} );
it( 'hovering nested reference', () => {
helpers.abandonReference( 'cite_ref-3' );
helpers.dwellReference( 'cite_ref-3' );
cy.get( '.mwe-popups-type-reference', { timeout: 1000 } )
.should( 'be.visible' );
helpers.dwellReference( 'cite_ref-4' );
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait( 1000 );
cy.get( '.mwe-popups-type-reference' )
.should( 'include.text', 'reference inception' );
} );
} );

View file

@ -115,3 +115,21 @@ export function verifyBacklinkHrefContent( refName, rowNumber, index ) {
.eq( index )
.should( 'have.attr', 'href', expectedHref );
}
export function abandonReference( id ) {
cy.get( `:not(.reference-text) > #${ id } a` )
.trigger( 'mouseout' );
// Wait for the 300ms default ABANDON_END_DELAY.
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait( 500 );
}
export function dwellReference( id ) {
cy.get( `:not(.reference-text) > #${ id } a` )
.trigger( 'mouseover' );
}
export function assertPreviewIsScrollable( isScrollable ) {
cy.get( '.mwe-popups-extract .mwe-popups-scroll' )
.should( ( $el ) => isScrollable === ( $el.prop( 'scrollHeight' ) > $el.prop( 'offsetHeight' ) ) );
}

View file

@ -31,7 +31,8 @@
"ParserCloned": "parser",
"ParserFirstCallInit": "parser",
"EditPage::showEditForm:initial": "main",
"ResourceLoaderGetConfigVars": "main"
"ResourceLoaderGetConfigVars": "main",
"ResourceLoaderRegisterModules": "main"
},
"HookHandlers": {
"main": {
@ -226,21 +227,29 @@
"remoteExtPath": "Cite/modules"
},
"QUnitTestModule": {
"localBasePath": "modules/ve-cite/tests",
"remoteExtPath": "Cite/modules/ve-cite/tests",
"localBasePath": "",
"remoteExtPath": "Cite",
"scripts": [
"ve.dm.citeExample.js",
"ve.dm.Converter.test.js",
"ve.dm.InternalList.test.js",
"ve.dm.MWReferenceModel.test.js",
"ve.dm.Transaction.test.js",
"ve.ui.DiffElement.test.js",
"ve.ui.MWWikitextStringTransferHandler.test.js"
"modules/ve-cite/tests/ve.dm.citeExample.js",
"modules/ve-cite/tests/ve.dm.Converter.test.js",
"modules/ve-cite/tests/ve.dm.InternalList.test.js",
"modules/ve-cite/tests/ve.dm.MWReferenceModel.test.js",
"modules/ve-cite/tests/ve.dm.Transaction.test.js",
"modules/ve-cite/tests/ve.ui.DiffElement.test.js",
"modules/ve-cite/tests/ve.ui.MWWikitextStringTransferHandler.test.js",
"tests/qunit/ext.cite.referencePreviews/createReferenceGateway.test.js",
"tests/qunit/ext.cite.referencePreviews/isReferencePreviewsEnabled.test.js",
"tests/qunit/ext.cite.referencePreviews/renderer.test.js",
"tests/qunit/ext.cite.referencePreviews/setUserConfigFlags.test.js"
],
"dependencies": [
"ext.cite.visualEditor",
"test.VisualEditor"
]
],
"optionalDependencies": {
"Popups": "ext.cite.referencePreviews"
},
"factory": "Cite\\ResourceLoader\\OptionalLoader::addOptionalDependencies"
},
"attributes": {
"CodeMirror": {
@ -249,6 +258,11 @@
"references": "text/mediawiki"
}
},
"Popups": {
"PluginModules": [
"ext.cite.referencePreviews"
]
},
"VisualEditor": {
"PluginModules": [
"ext.cite.visualEditor"

View file

@ -18,8 +18,16 @@
},
"wmf": {
"linkMap": {
"ext.popups.Preview": "https://doc.wikimedia.org/Popups/master/js/js/Popups/module-preview.html",
"ext.popups.PreviewModel": "https://doc.wikimedia.org/Popups/master/js/js/Popups/module-preview_model.html",
"Gateway": "https://doc.wikimedia.org/Popups/master/js/js/Popups/module-gateway.html",
"HTMLAnchorElement": "https://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement",
"HTMLElement": "https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement",
"jQuery": "https://api.jquery.com/Types/#jQuery",
"jQuery.Event": "https://api.jquery.com/category/events/event-object/",
"mw.Map": "https://doc.wikimedia.org/mediawiki-core/master/js/mw.Map.html",
"mw.Title": "https://doc.wikimedia.org/mediawiki-core/master/js/mw.Title.html",
"mw.user": "https://doc.wikimedia.org/mediawiki-core/master/js/mw.user.html",
"OO.EventEmitter": "https://doc.wikimedia.org/oojs/master/OO.EventEmitter.html",
"OO.ui.ComboBoxInputWidget": "https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ComboBoxInputWidget.html",
"OO.ui.OptionWidget": "https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.OptionWidget.html",

11
modules/.eslintrc.json Normal file
View file

@ -0,0 +1,11 @@
{
"root": true,
"extends": [
"wikimedia/client-es6",
"wikimedia/jquery",
"wikimedia/mediawiki"
],
"rules": {
"max-len": "off"
}
}

View file

@ -0,0 +1,5 @@
{
"env": {
"commonjs": true
}
}

View file

@ -0,0 +1 @@
Code in this folder and subfolders is maintained by WMDE.

View file

@ -0,0 +1,4 @@
module.exports = {
TYPE_REFERENCE: 'reference',
FETCH_DELAY_REFERENCE_TYPE: 150
};

View file

@ -0,0 +1,94 @@
/**
* @module gateway/reference
*/
const { TYPE_REFERENCE } = require( './constants.js' );
/**
* @return {Gateway}
*/
module.exports = function createReferenceGateway() {
/**
* @param {string} id
* @return {HTMLElement}
*/
function scrapeReferenceText( id ) {
const idSelector = `#${ CSS.escape( id ) }`;
/**
* Same alternative selectors with and without mw- as in the RESTbased endpoint.
*
* @see https://phabricator.wikimedia.org/diffusion/GMOA/browse/master/lib/transformations/references/structureReferenceListContent.js$138
*/
return document.querySelector( `${ idSelector } .mw-reference-text, ${ idSelector } .reference-text` );
}
/**
* Attempts to find a single reference type identifier, limited to a list of known types.
* - When a `class="…"` attribute mentions multiple known types, the last one is used, following
* CSS semantics.
* - When there are multiple <cite> tags, the first with a known type is used.
*
* @param {HTMLElement} referenceText
* @return {string|null}
*/
function scrapeReferenceType( referenceText ) {
const KNOWN_TYPES = [ 'book', 'journal', 'news', 'note', 'web' ];
let type = null;
const citeTags = referenceText.querySelectorAll( 'cite[class]' );
Array.prototype.forEach.call( citeTags, ( element ) => {
// don't need to keep scanning if one is found.
if ( type ) {
return;
}
const classNames = element.className.split( /\s+/ );
for ( let i = classNames.length; i--; ) {
if ( KNOWN_TYPES.indexOf( classNames[ i ] ) !== -1 ) {
type = classNames[ i ];
return false;
}
}
} );
return type;
}
/**
* @param {mw.Title} title
* @param {HTMLAnchorElement} el
* @return {Promise<ext.popups.PreviewModel>}
*/
function fetchPreviewForTitle( title, el ) {
// Need to encode the fragment again as mw.Title returns it as decoded text
const id = title.getFragment().replace( / /g, '_' ),
referenceNode = scrapeReferenceText( id );
if ( !referenceNode ||
// Skip references that don't contain anything but whitespace, e.g. a single &nbsp;
( !referenceNode.textContent.trim() && !referenceNode.children.length )
) {
return Promise.reject(
// Required to set `showNullPreview` to false and not open an error popup
{ textStatus: 'abort', textContext: 'Footnote not found or empty', xhr: { readyState: 0 } }
);
}
const model = {
url: `#${ id }`,
extract: referenceNode.innerHTML,
type: TYPE_REFERENCE,
referenceType: scrapeReferenceType( referenceNode ),
// Note: Even the top-most HTMLHtmlElement is guaranteed to have a parent.
sourceElementId: el.parentNode.id
};
// Make promise abortable.
const promise = Promise.resolve( model );
promise.abort = () => {};
return promise;
}
return {
fetchPreviewForTitle
};
};

View file

@ -0,0 +1,191 @@
/**
* @module referencePreview
*/
const { isTrackingEnabled, LOGGING_SCHEMA } = require( './referencePreviewsInstrumentation.js' );
const TEMPLATE = document.createElement( 'template' );
TEMPLATE.innerHTML = `
<div class="mwe-popups mwe-popups mwe-popups-type-reference" aria-hidden>
<div class="mwe-popups-container">
<div class="mwe-popups-extract">
<div class="mwe-popups-scroll">
<strong class="mwe-popups-title">
<span class="popups-icon"></span>
<span class="mwe-popups-title-placeholder"></span>
</strong>
<bdi><div class="mw-parser-output"></div></bdi>
</div>
<div class="mwe-popups-fade"></div>
</div>
<footer>
<div class="mwe-popups-settings"></div>
</footer>
</div>
</div>`;
/**
* @param {HTMLElement} node
* @param {HTMLElement|string} htmlOrOtherNode
*/
const replaceWith = ( node, htmlOrOtherNode ) => {
if ( typeof htmlOrOtherNode === 'string' ) {
node.insertAdjacentHTML( 'afterend', htmlOrOtherNode );
} else {
node.parentNode.appendChild( htmlOrOtherNode );
}
node.remove();
};
/**
* @param {ext.popups.PreviewModel} model
* @return {jQuery}
*/
function renderReferencePreview(
model
) {
const type = model.referenceType || 'generic';
// The following messages are used here:
// * popups-refpreview-book
// * popups-refpreview-journal
// * popups-refpreview-news
// * popups-refpreview-note
// * popups-refpreview-web
let titleMsg = mw.message( `popups-refpreview-${ type }` );
if ( !titleMsg.exists() ) {
titleMsg = mw.message( 'popups-refpreview-reference' );
}
const el = TEMPLATE.content.cloneNode( true ).children[ 0 ];
replaceWith(
el.querySelector( '.mwe-popups-title-placeholder' ),
mw.html.escape( titleMsg.text() )
);
// The following classes are used here:
// * popups-icon--reference-generic
// * popups-icon--reference-book
// * popups-icon--reference-journal
// * popups-icon--reference-news
// * popups-icon--reference-note
// * popups-icon--reference-web
el.querySelector( '.mwe-popups-title .popups-icon' )
.classList.add( `popups-icon--reference-${ type }` );
el.querySelector( '.mw-parser-output' )
.innerHTML = model.extract;
// Make sure to not destroy existing targets, if any
Array.prototype.forEach.call(
el.querySelectorAll( '.mwe-popups-extract a[href][class~="external"]:not([target])' ),
( a ) => {
a.target = '_blank';
// Don't let the external site access and possibly manipulate window.opener.location
a.rel = `${ a.rel ? `${ a.rel } ` : '' }noopener`;
}
);
// We assume elements that benefit from being collapsible are to large for the popup
Array.prototype.forEach.call( el.querySelectorAll( '.mw-collapsible' ), ( node ) => {
const otherNode = document.createElement( 'div' );
otherNode.classList.add( 'mwe-collapsible-placeholder' );
const icon = document.createElement( 'span' );
icon.classList.add( 'popups-icon', 'popups-icon--infoFilled' );
const label = document.createElement( 'span' );
label.classList.add( 'mwe-collapsible-placeholder-label' );
label.textContent = mw.msg( 'popups-refpreview-collapsible-placeholder' );
otherNode.appendChild( icon );
otherNode.appendChild( label );
replaceWith( node, otherNode );
} );
// Undo remaining effects from the jquery.tablesorter.js plugin
const undoHeaderSort = ( headerSort ) => {
headerSort.classList.remove( 'headerSort' );
headerSort.removeAttribute( 'tabindex' );
headerSort.removeAttribute( 'title' );
};
Array.prototype.forEach.call( el.querySelectorAll( 'table.sortable' ), ( node ) => {
node.classList.remove( 'sortable', 'jquery-tablesorter' );
Array.prototype.forEach.call( node.querySelectorAll( '.headerSort' ), undoHeaderSort );
} );
// TODO: Do not remove this but move it up into the templateHTML constant!
const settingsButton = document.createElement( 'a' );
settingsButton.classList.add( 'cdx-button', 'cdx-button--fake-button', 'cdx-button--fake-button--enabled', 'cdx-button--weight-quiet', 'cdx-button--icon-only', 'mwe-popups-settings-button' );
const settingsIcon = document.createElement( 'span' );
settingsIcon.classList.add( 'popups-icon', 'popups-icon--size-small', 'popups-icon--settings' );
const settingsButtonLabel = document.createElement( 'span' );
settingsButtonLabel.textContent = mw.msg( 'popups-settings-icon-gear-title' );
settingsButton.append( settingsIcon );
settingsButton.append( settingsButtonLabel );
el.querySelector( '.mwe-popups-settings' ).appendChild( settingsButton );
if ( isTrackingEnabled() ) {
el.querySelector( '.mw-parser-output' ).addEventListener( 'click', ( ev ) => {
if ( !ev.target.matches( 'a' ) ) {
return;
}
mw.track( LOGGING_SCHEMA, {
action: 'clickedReferencePreviewsContentLink'
} );
} );
}
el.querySelector( '.mwe-popups-scroll' ).addEventListener( 'scroll', function ( e ) {
const element = e.target,
// We are dealing with floating point numbers here when the page is zoomed!
scrolledToBottom = element.scrollTop >= element.scrollHeight - element.clientHeight - 1;
if ( isTrackingEnabled() ) {
if ( !element.isOpenRecorded ) {
mw.track( LOGGING_SCHEMA, {
action: 'poppedOpen',
scrollbarsPresent: element.scrollHeight > element.clientHeight
} );
element.isOpenRecorded = true;
}
if (
element.scrollTop > 0 &&
!element.isScrollRecorded
) {
mw.track( LOGGING_SCHEMA, {
action: 'scrolled'
} );
element.isScrollRecorded = true;
}
}
if ( !scrolledToBottom && element.isScrolling ) {
return;
}
const extract = element.parentNode,
hasHorizontalScroll = element.scrollWidth > element.clientWidth,
scrollbarHeight = element.offsetHeight - element.clientHeight,
hasVerticalScroll = element.scrollHeight > element.clientHeight,
scrollbarWidth = element.offsetWidth - element.clientWidth;
const fade = extract.querySelector( '.mwe-popups-fade' );
fade.style.bottom = hasHorizontalScroll ? `${ scrollbarHeight }px` : 0;
fade.style.right = hasVerticalScroll ? `${ scrollbarWidth }px` : 0;
element.isScrolling = !scrolledToBottom;
extract.classList.toggle( 'mwe-popups-fade-out', element.isScrolling );
extract.setAttribute( 'lang', mw.config.get( 'wgPageContentLanguage' ) );
} );
return el;
}
/**
* @param {ext.popups.PreviewModel} model
* @return {ext.popups.Preview}
*/
function createReferencePreview( model ) {
return {
el: renderReferencePreview( model ),
hasThumbnail: false,
isTall: false
};
}
module.exports = createReferencePreview;

View file

@ -0,0 +1,46 @@
const isReferencePreviewsEnabled = require( './isReferencePreviewsEnabled.js' );
const { initReferencePreviewsInstrumentation, LOGGING_SCHEMA } = require( './referencePreviewsInstrumentation.js' );
const createReferenceGateway = require( './createReferenceGateway.js' );
const renderFn = require( './createReferencePreview.js' );
const { TYPE_REFERENCE, FETCH_DELAY_REFERENCE_TYPE } = require( './constants.js' );
const setUserConfigFlags = require( './setUserConfigFlags.js' );
setUserConfigFlags( mw.config );
const referencePreviewsState = isReferencePreviewsEnabled(
mw.user,
mw.popups.isEnabled,
mw.config
);
const gateway = createReferenceGateway();
// For tracking baseline stats in the Cite extension https://phabricator.wikimedia.org/T353798
// FIXME: This might be obsolete when the code moves to the Cite extension and the tracking there
// can check that state differently.
mw.config.set( 'wgPopupsReferencePreviewsVisible', !!referencePreviewsState );
mw.trackSubscribe( 'Popups.SettingChange', ( data ) => {
if ( data.previewType === TYPE_REFERENCE ) {
mw.track( LOGGING_SCHEMA, data );
}
} );
module.exports = referencePreviewsState !== null ? {
type: TYPE_REFERENCE,
selector: '#mw-content-text .reference a[ href*="#" ]',
delay: FETCH_DELAY_REFERENCE_TYPE,
gateway,
renderFn,
init: () => {
initReferencePreviewsInstrumentation();
}
} : null;
// Expose private methods for QUnit tests
if ( typeof QUnit !== 'undefined' ) {
module.exports = { private: {
createReferenceGateway: require( './createReferenceGateway.js' ),
createReferencePreview: require( './createReferencePreview.js' ),
isReferencePreviewsEnabled: require( './isReferencePreviewsEnabled.js' ),
setUserConfigFlags: require( './setUserConfigFlags.js' )
} };
}

View file

@ -0,0 +1,41 @@
const { TYPE_REFERENCE } = require( './constants.js' );
/**
* @module isReferencePreviewsEnabled
*/
/**
* Given the global state of the application, creates a function that gets
* whether or not the user should have Reference Previews enabled.
*
* @param {mw.user} user The `mw.user` singleton instance
* @param {Function} isPreviewTypeEnabled check whether preview has been disabled or enabled.
* @param {mw.Map} config
*
* @return {boolean|null} Null when there is no way the popup type can be enabled at run-time.
*/
function isReferencePreviewsEnabled( user, isPreviewTypeEnabled, config ) {
// TODO: This and the final `mw.user.options` check are currently redundant. Only this here
// should be removed when the wgPopupsReferencePreviews feature flag is not needed any more.
if ( !config.get( 'wgPopupsReferencePreviews' ) ) {
return null;
}
// T265872: Unavailable when in conflict with (one of the) reference tooltips gadgets.
if ( config.get( 'wgPopupsConflictsWithRefTooltipsGadget' ) ||
config.get( 'wgPopupsConflictsWithNavPopupGadget' ) ||
// T243822: Temporarily disabled in the mobile skin
config.get( 'skin' ) === 'minerva'
) {
return null;
}
if ( user.isAnon() ) {
return isPreviewTypeEnabled( TYPE_REFERENCE );
}
// Registered users never can enable popup types at run-time.
return user.options.get( 'popups-reference-previews' ) === '1' ? true : null;
}
module.exports = isReferencePreviewsEnabled;

View file

@ -0,0 +1,89 @@
@import 'mediawiki.skin.variables.less';
// Should be in sync with Popups/src/ui/variables.less
@popupPadding: 16px;
@lineHeight: 20px;
/* stylelint-disable selector-class-pattern */
.popups-icon--reference-generic {
.cdx-mixin-css-icon( @cdx-icon-reference );
}
.popups-icon--reference-book {
.cdx-mixin-css-icon( @cdx-icon-book );
}
.popups-icon--reference-journal {
.cdx-mixin-css-icon( @cdx-icon-journal );
}
.popups-icon--reference-news {
.cdx-mixin-css-icon( @cdx-icon-newspaper );
}
.popups-icon--reference-web {
.cdx-mixin-css-icon( @cdx-icon-browser );
}
.popups-icon--preview-disambiguation {
.cdx-mixin-css-icon( @cdx-icon-articles );
}
// Don't do any event bubbling on childs like <a><span>[</span>2]</a>
// see https://phabricator.wikimedia.org/T214693
/* stylelint-disable-next-line selector-max-id */
#mw-content-text .reference a[ href*='#' ] * {
pointer-events: none;
}
.mwe-popups.mwe-popups-type-reference .mwe-popups-container {
.mwe-popups-title .popups-icon--reference-note {
// There is currently no "reference-note" icon specified in extension.json
display: none;
}
.mwe-popups-extract {
margin-right: 0;
max-height: inherit;
.mwe-popups-scroll {
// This is how the @previewFooterHeight in popup.less is calculated
@marginBottom: @popupPadding + 34px;
// Same as @previewPointerHeight in popup.less
@pointerHeight: 8px;
max-height: 401px - @popupPadding - @marginBottom + @pointerHeight;
overflow: auto;
padding-right: @popupPadding;
}
.mw-parser-output {
overflow-wrap: break-word;
}
// Remove the default fade-out effect set by popup.less
&::after {
display: none;
}
.mwe-popups-fade {
position: absolute;
width: 100%;
height: @lineHeight;
background-color: transparent;
background-image: linear-gradient( rgba( 255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) );
opacity: 0;
pointer-events: none; // Allows clicking "through" the element
transition: opacity 250ms ease;
}
&.mwe-popups-fade-out .mwe-popups-fade {
opacity: 1;
}
}
.mwe-collapsible-placeholder {
font-weight: bold;
margin: 1em 0;
position: relative;
}
}

View file

@ -0,0 +1,27 @@
let isTracking = false;
const LOGGING_SCHEMA = 'event.ReferencePreviewsPopups';
/**
* Run once the preview is initialized.
*/
function initReferencePreviewsInstrumentation() {
if ( mw.config.get( 'wgPopupsReferencePreviews' ) &&
navigator.sendBeacon &&
mw.config.get( 'wgIsArticle' ) &&
!isTracking
) {
isTracking = true;
mw.track( LOGGING_SCHEMA, { action: 'pageview' } );
}
}
function isTrackingEnabled() {
return isTracking;
}
module.exports = {
LOGGING_SCHEMA,
initReferencePreviewsInstrumentation,
isTrackingEnabled
};

View file

@ -0,0 +1,29 @@
/**
* @module setUserConfigFlags
*/
/**
* Same as in includes/PopupsContext.php
*/
const REF_TOOLTIPS_ENABLED = 2,
REFERENCE_PREVIEWS_ENABLED = 4;
/**
* Decodes the bitmask that represents preferences to the related config options.
*
* @param {mw.Map} config
*/
module.exports = function setUserConfigFlags( config ) {
const popupsFlags = parseInt( config.get( 'wgPopupsFlags' ), 10 );
/* eslint-disable no-bitwise */
config.set(
'wgPopupsConflictsWithRefTooltipsGadget',
!!( popupsFlags & REF_TOOLTIPS_ENABLED )
);
config.set(
'wgPopupsReferencePreviews',
!!( popupsFlags & REFERENCE_PREVIEWS_ENABLED )
);
/* eslint-enable no-bitwise */
};

View file

@ -14,6 +14,8 @@ use MediaWiki\EditPage\EditPage;
use MediaWiki\Hook\EditPage__showEditForm_initialHook;
use MediaWiki\Output\OutputPage;
use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsLookup;
@ -25,6 +27,7 @@ use MediaWiki\User\Options\UserOptionsLookup;
class CiteHooks implements
ContentHandlerDefaultModelForHook,
ResourceLoaderGetConfigVarsHook,
ResourceLoaderRegisterModulesHook,
APIQuerySiteInfoGeneralInfoHook,
EditPage__showEditForm_initialHook
{
@ -67,6 +70,36 @@ class CiteHooks implements
$vars['wgCiteBookReferencing'] = $config->get( 'CiteBookReferencing' );
}
/**
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderRegisterModules
*/
public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
if ( ExtensionRegistry::getInstance()->isLoaded( 'Popups' ) ) {
$dir = dirname( __DIR__, 2 ) . '/modules/';
$resourceLoader->register( [
'ext.cite.referencePreviews' => [
'localBasePath' => $dir . '/ext.cite.referencePreviews',
'remoteExtPath' => 'Cite/modules/ext.cite.referencePreviews',
'dependencies' => [
'ext.popups.main',
],
'styles' => [
'referencePreview.less',
],
'packageFiles' => [
'index.js',
'constants.js',
'createReferenceGateway.js',
'createReferencePreview.js',
'isReferencePreviewsEnabled.js',
'referencePreviewsInstrumentation.js',
'setUserConfigFlags.js'
]
]
] );
}
}
/**
* Hook: APIQuerySiteInfoGeneralInfo
*

View file

@ -0,0 +1,24 @@
<?php
namespace Cite\ResourceLoader;
use ExtensionRegistry;
use MediaWiki\ResourceLoader\FileModule;
use MediaWiki\ResourceLoader\Module;
/**
* @license MIT
*/
class OptionalLoader {
public static function addOptionalDependencies( array $info ): Module {
// Copied from DiscussionTools
$extensionRegistry = ExtensionRegistry::getInstance();
foreach ( $info['optionalDependencies'] as $ext => $deps ) {
if ( $extensionRegistry->isLoaded( $ext ) ) {
$info['dependencies'] = array_merge( $info['dependencies'], (array)$deps );
}
}
$class = $info['class'] ?? FileModule::class;
return new $class( $info );
}
}

View file

@ -0,0 +1,16 @@
{
"root": true,
"extends": [
"../../modules/.eslintrc.json",
"wikimedia/qunit"
],
"parserOptions": {
"sourceType": "module"
},
"env": {
"commonjs": true
},
"rules": {
"no-jquery/no-class-state": "off"
}
}

View file

@ -0,0 +1 @@
Code in this folder and subfolders is maintained by WMDE.

View file

@ -0,0 +1,179 @@
function createStubTitle( fragment = null ) {
return {
getFragment() {
return fragment;
}
};
}
( mw.loader.getModuleNames().indexOf( 'ext.popups.main' ) !== -1 ?
QUnit.module :
QUnit.module.skip )( 'ext.cite.referencePreviews#createReferenceGateway', {
beforeEach() {
// FIXME: Is this needed?
// global.CSS = {
// escape: ( str ) => $.escapeSelector( str )
// };
mw.msg = ( key ) => `<${ key }>`;
mw.message = ( key ) => {
return { exists: () => !key.endsWith( 'generic' ), text: () => `<${ key }>` };
};
this.$sourceElement = $( '<a>' ).appendTo(
$( '<sup>' ).attr( 'id', 'cite_ref-1' ).appendTo( document.body )
);
this.$references = $( '<ul>' ).append(
$( '<li>' ).attr( 'id', 'cite_note-1' ).append(
$( '<span>' ).addClass( 'mw-reference-text' ).text( 'Footnote 1' )
),
$( '<li>' ).attr( 'id', 'cite_note-2' ).append(
$( '<span>' ).addClass( 'reference-text' ).append(
$( '<cite>' ).addClass( 'journal web unknown' ).text( 'Footnote 2' )
)
),
$( '<li>' ).attr( 'id', 'cite_note-3' ).append(
$( '<span>' ).addClass( 'reference-text' ).append(
$( '<cite>' ).addClass( 'news' ).text( 'Footnote 3' ),
$( '<cite>' ).addClass( 'news citation' ),
$( '<cite>' ).addClass( 'citation' )
)
),
$( '<li>' ).attr( 'id', 'cite_note-4' ).append(
$( '<span>' ).addClass( 'reference-text' ).append(
$( '<cite>' ).addClass( 'news' ).text( 'Footnote 4' ),
$( '<cite>' ).addClass( 'web' )
)
),
$( '<li>' ).attr( 'id', 'cite_note-5' ).append(
$( '<span>' ).addClass( 'mw-reference-text' ).html( '&nbsp;' )
)
).appendTo( document.body );
},
afterEach() {
mw.msg = null;
mw.message = null;
this.$sourceElement.parent().remove();
this.$references.remove();
}
} );
QUnit.test( 'Reference preview gateway returns the correct data', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'cite note-1' );
return gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] ).then( ( result ) => {
assert.propEqual(
result,
{
url: '#cite_note-1',
extract: 'Footnote 1',
type: 'reference',
referenceType: null,
sourceElementId: 'cite_ref-1'
}
);
} );
} );
QUnit.test( 'Reference preview gateway accepts alternative text node class name', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'cite note-2' );
return gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] ).then( ( result ) => {
assert.propEqual(
result,
{
url: '#cite_note-2',
extract: '<cite class="journal web unknown">Footnote 2</cite>',
type: 'reference',
referenceType: 'web',
sourceElementId: 'cite_ref-1'
}
);
} );
} );
QUnit.test( 'Reference preview gateway accepts duplicated types', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'cite note-3' );
return gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] ).then( ( result ) => {
assert.propEqual(
result,
{
url: '#cite_note-3',
extract: '<cite class="news">Footnote 3</cite><cite class="news citation"></cite><cite class="citation"></cite>',
type: 'reference',
referenceType: 'news',
sourceElementId: 'cite_ref-1'
}
);
} );
} );
QUnit.test( 'Reference preview gateway ignores conflicting types', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'cite note-4' );
return gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] ).then( ( result ) => {
assert.propEqual(
result,
{
url: '#cite_note-4',
extract: '<cite class="news">Footnote 4</cite><cite class="web"></cite>',
type: 'reference',
referenceType: 'news',
sourceElementId: 'cite_ref-1'
}
);
} );
} );
QUnit.test( 'Reference preview gateway returns source element id', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'cite note-1' );
return gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] ).then( ( result ) => {
assert.propEqual(
result,
{
url: '#cite_note-1',
extract: 'Footnote 1',
type: 'reference',
referenceType: null,
sourceElementId: 'cite_ref-1'
}
);
} );
} );
QUnit.test( 'Reference preview gateway rejects non-existing references', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'undefined' );
return gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] ).then( () => {
assert.true( false, 'It should not resolve' );
} ).catch( ( result ) => {
assert.propEqual( result, { textStatus: 'abort', textContext: 'Footnote not found or empty', xhr: { readyState: 0 } } );
} );
} );
QUnit.test( 'Reference preview gateway rejects all-whitespace references', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'cite note-5' );
return gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] ).then( () => {
assert.true( false, 'It should not resolve' );
} ).catch( ( result ) => {
assert.propEqual( result, { textStatus: 'abort', textContext: 'Footnote not found or empty', xhr: { readyState: 0 } } );
} );
} );
QUnit.test( 'Reference preview gateway is abortable', function ( assert ) {
const gateway = require( 'ext.cite.referencePreviews' ).private.createReferenceGateway(),
title = createStubTitle( 'cite note-1' ),
promise = gateway.fetchPreviewForTitle( title, this.$sourceElement[ 0 ] );
assert.strictEqual( typeof promise.abort, 'function' );
} );

View file

@ -0,0 +1,221 @@
function createStubUserSettings( expectEnabled ) {
return {
isPreviewTypeEnabled() {
return expectEnabled !== false;
}
};
}
function createStubUser( isAnon, options ) {
return {
isNamed() {
return !isAnon;
},
isAnon() {
return isAnon;
},
options
};
}
const options = { get: () => '1' };
( mw.loader.getModuleNames().indexOf( 'ext.popups.main' ) !== -1 ?
QUnit.module :
QUnit.module.skip )( 'ext.cite.referencePreviews#isReferencePreviewsEnabled' );
QUnit.test( 'all relevant combinations of flags', ( assert ) => {
[
{
testCase: 'enabled for an anonymous user',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: false,
isAnon: true,
enabledByAnon: true,
enabledByRegistered: false,
expected: true
},
{
testCase: 'turned off via the feature flag (anonymous user)',
wgPopupsReferencePreviews: false,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: false,
isAnon: true,
enabledByAnon: true,
enabledByRegistered: true,
expected: null
},
{
testCase: 'not available because of a conflicting gadget (anonymous user)',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: true,
isMobile: false,
isAnon: true,
enabledByAnon: true,
enabledByRegistered: true,
expected: null
},
{
testCase: 'not available in the mobile skin (anonymous user)',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: true,
isAnon: true,
enabledByAnon: true,
enabledByRegistered: true,
expected: null
},
{
testCase: 'manually disabled by the anonymous user',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: false,
isAnon: true,
enabledByAnon: false,
enabledByRegistered: true,
expected: false
},
{
testCase: 'enabled for a registered user',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: false,
isAnon: false,
enabledByAnon: false,
enabledByRegistered: true,
expected: true
},
{
testCase: 'turned off via the feature flag (registered user)',
wgPopupsReferencePreviews: false,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: false,
isAnon: false,
enabledByAnon: true,
enabledByRegistered: true,
expected: null
},
{
testCase: 'not available because of a conflicting gadget (registered user)',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: true,
isMobile: false,
isAnon: false,
enabledByAnon: true,
enabledByRegistered: true,
expected: null
},
{
testCase: 'not available in the mobile skin (registered user)',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: true,
isAnon: false,
enabledByAnon: true,
enabledByRegistered: true,
expected: null
},
{
// TODO: This combination will make much more sense when the server-side
// wgPopupsReferencePreviews flag doesn't include the user's setting any more
testCase: 'manually disabled by the registered user',
wgPopupsReferencePreviews: true,
wgPopupsConflictsWithRefTooltipsGadget: false,
isMobile: false,
isAnon: false,
enabledByAnon: true,
enabledByRegistered: false,
expected: null
}
].forEach( ( data ) => {
const user = {
isNamed: () => !data.isAnon && !data.isIPMasked,
isAnon: () => data.isAnon,
options: {
get: () => {}
}
},
isPreviewTypeEnabled = () => {
if ( !data.isAnon ) {
assert.true( false, 'not expected to be called' );
}
return data.enabledByAnon;
},
config = {
get: ( key ) => key === 'skin' && data.isMobile ? 'minerva' : data[ key ]
};
if ( data.isAnon ) {
user.options.get = () => assert.true( false, 'not expected to be called 2' );
} else {
user.options.get = () => data.enabledByRegistered ? '1' : '0';
}
assert.strictEqual(
require( 'ext.cite.referencePreviews' ).private.isReferencePreviewsEnabled( user, isPreviewTypeEnabled, config ),
data.expected,
data.testCase
);
} );
} );
QUnit.test( 'it should display reference previews when conditions are fulfilled', ( assert ) => {
const user = createStubUser( false, options ),
userSettings = createStubUserSettings( false ),
config = new Map();
config.set( 'wgPopupsReferencePreviews', true );
config.set( 'wgPopupsConflictsWithRefTooltipsGadget', false );
assert.true(
require( 'ext.cite.referencePreviews' ).private.isReferencePreviewsEnabled( user, userSettings, config ),
'If the user is logged in and the user is in the on group, then it\'s enabled.'
);
} );
QUnit.test( 'it should handle the conflict with the Reference Tooltips Gadget', ( assert ) => {
const user = createStubUser( false ),
userSettings = createStubUserSettings( false ),
config = new Map();
config.set( 'wgPopupsReferencePreviews', true );
config.set( 'wgPopupsConflictsWithRefTooltipsGadget', true );
assert.strictEqual(
require( 'ext.cite.referencePreviews' ).private.isReferencePreviewsEnabled( user, userSettings, config ),
null,
'Reference Previews is disabled.'
);
} );
QUnit.test( 'it should not be enabled when the global is disabling it', ( assert ) => {
const user = createStubUser( false ),
userSettings = createStubUserSettings( false ),
config = new Map();
config.set( 'wgPopupsReferencePreviews', false );
config.set( 'wgPopupsConflictsWithRefTooltipsGadget', false );
assert.strictEqual(
require( 'ext.cite.referencePreviews' ).private.isReferencePreviewsEnabled( user, userSettings, config ),
null,
'Reference Previews is disabled.'
);
} );
QUnit.test( 'it should not be enabled when minerva skin used', ( assert ) => {
const user = createStubUser( false ),
userSettings = createStubUserSettings( false ),
config = new Map();
config.set( 'wgPopupsReferencePreviews', true );
config.set( 'wgPopupsConflictsWithRefTooltipsGadget', false );
config.set( 'skin', 'minerva' );
assert.strictEqual(
require( 'ext.cite.referencePreviews' ).private.isReferencePreviewsEnabled( user, userSettings, config ),
null,
'Reference Previews is disabled.'
);
} );

View file

@ -0,0 +1,109 @@
let createReferencePreview;
const previewTypes = { TYPE_REFERENCE: 'reference' };
// TODO: Fix this test. Currently failing on `document.getElementById`
QUnit.module.skip( 'ext.cite.referencePreviews#renderer', {
before() {
createReferencePreview = require( 'ext.cite.referencePreviews' ).private.createReferencePreview;
},
beforeEach() {
mw.msg = ( key ) => `<${ key }>`;
mw.message = ( key ) => {
return { exists: () => !key.endsWith( 'generic' ), text: () => `<${ key }>` };
};
mw.html = {
escape: ( str ) => str && str.replace( /'/g, '&apos;' ).replace( /</g, '&lt;' )
};
mw.track = () => {};
global.navigator = {
sendBeacon() {}
};
// Some tests below stub this function. Keep a copy so it can be restored.
this.getElementById = document.getElementById;
},
afterEach() {
// Restore getElementsById to its original state.
document.getElementById = this.getElementById;
mw.msg = null;
mw.message = null;
mw.html = null;
}
} );
QUnit.test( 'createReferencePreview(model)', ( assert ) => {
const model = {
url: '#custom_id',
extract: 'Custom <i>extract</i> with an <a href="/wiki/Internal">internal</a> and an <a href="//wikipedia.de" class="external">external</a> link',
type: previewTypes.TYPE_REFERENCE,
referenceType: 'web'
},
preview = createReferencePreview( model );
assert.strictEqual( preview.hasThumbnail, false );
assert.strictEqual( preview.isTall, false );
assert.strictEqual(
$( preview.el ).find( '.mwe-popups-title' ).text().trim(),
'<popups-refpreview-web>'
);
assert.strictEqual(
$( preview.el ).find( '.mw-parser-output' ).text().trim(),
'Custom extract with an internal and an external link'
);
assert.strictEqual(
$( preview.el ).find( 'a[target="_blank"]' ).length,
1,
'only external links open in new tabs'
);
} );
QUnit.test( 'createReferencePreview default title', ( assert ) => {
const model = {
url: '',
extract: '',
type: previewTypes.TYPE_REFERENCE
},
preview = createReferencePreview( model );
assert.strictEqual(
$( preview.el ).find( '.mwe-popups-title' ).text().trim(),
'<popups-refpreview-reference>'
);
} );
QUnit.test( 'createReferencePreview updates fade-out effect on scroll', ( assert ) => {
const model = {
url: '',
extract: '',
type: previewTypes.TYPE_REFERENCE
},
preview = createReferencePreview( model ),
$extract = $( preview.el ).find( '.mwe-popups-extract' );
$extract.children()[ 0 ].dispatchEvent( new Event( 'scroll' ) );
assert.false( $extract.children()[ 0 ].isScrolling );
assert.false( $extract.hasClass( 'mwe-popups-fade-out' ) );
} );
QUnit.test( 'createReferencePreview collapsible/sortable handling', ( assert ) => {
const model = {
url: '',
extract: '<table class="mw-collapsible"></table>' +
'<table class="sortable"><th class="headerSort" tabindex="1" title="Click here"></th></table>',
type: previewTypes.TYPE_REFERENCE
},
preview = createReferencePreview( model );
assert.strictEqual( $( preview.el ).find( '.mw-collapsible, .sortable, .headerSort' ).length, 0 );
assert.strictEqual( $( preview.el ).find( 'th' ).attr( 'tabindex' ), undefined );
assert.strictEqual( $( preview.el ).find( 'th' ).attr( 'title' ), undefined );
assert.strictEqual(
$( preview.el ).find( '.mwe-collapsible-placeholder' ).text(),
'<popups-refpreview-collapsible-placeholder>'
);
} );

View file

@ -0,0 +1,51 @@
( mw.loader.getModuleNames().indexOf( 'ext.popups.main' ) !== -1 ?
QUnit.module :
QUnit.module.skip )( 'ext.cite.referencePreviews#setUserConfigFlags' );
QUnit.test( 'reference preview config settings are successfully set from bitmask', ( assert ) => {
const config = new Map();
config.set( 'wgPopupsFlags', '7' );
require( 'ext.cite.referencePreviews' ).private.setUserConfigFlags( config );
assert.deepEqual(
[
config.get( 'wgPopupsConflictsWithRefTooltipsGadget' ),
config.get( 'wgPopupsReferencePreviews' )
],
[ true, true ]
);
config.set( 'wgPopupsFlags', '2' );
require( 'ext.cite.referencePreviews' ).private.setUserConfigFlags( config );
assert.deepEqual(
[
config.get( 'wgPopupsConflictsWithRefTooltipsGadget' ),
config.get( 'wgPopupsReferencePreviews' )
],
[ true, false ]
);
config.set( 'wgPopupsFlags', '5' );
require( 'ext.cite.referencePreviews' ).private.setUserConfigFlags( config );
assert.deepEqual(
[
config.get( 'wgPopupsConflictsWithRefTooltipsGadget' ),
config.get( 'wgPopupsReferencePreviews' )
],
[ false, true ]
);
config.set( 'wgPopupsFlags', '0' );
require( 'ext.cite.referencePreviews' ).private.setUserConfigFlags( config );
assert.deepEqual(
[
config.get( 'wgPopupsConflictsWithRefTooltipsGadget' ),
config.get( 'wgPopupsReferencePreviews' )
],
[ false, false ]
);
} );