mediawiki-extensions-Popups/src/index.js
Jon Robson 9aed7b95ee jsdoc: Restrict Popups documentation to public facing APIs
Most of page previews is private, and developers should rely
on text editors to explore the codebase.

For public documentation we only surface what is a stable API
for page previews.

Change-Id: I8536de1de3038de43e5108f7d877df2ba3a2175b
2024-06-28 12:04:48 -07:00

306 lines
9.7 KiB
JavaScript

import * as Redux from 'redux';
import * as ReduxThunk from 'redux-thunk';
import createPagePreviewGateway from './gateway/page';
import createUserSettings from './userSettings';
import createPreviewBehavior from './previewBehavior';
import createSettingsDialogRenderer from './ui/settingsDialogRenderer';
import registerChangeListener from './changeListener';
import createIsPagePreviewsEnabled from './isPagePreviewsEnabled';
import { fromElement as titleFromElement } from './title';
import { init as rendererInit, registerPreviewUI, createPagePreview,
createDisambiguationPreview
} from './ui/renderer';
import createExperiments from './experiments';
import { isEnabled as isStatsvEnabled } from './instrumentation/statsv';
import changeListeners from './changeListeners';
import * as actions from './actions';
import reducers from './reducers';
import createMediaWikiPopupsObject from './integrations/mwpopups';
import { previewTypes, getPreviewType,
registerModel, findNearestEligibleTarget } from './preview/model';
import setUserConfigFlags from './setUserConfigFlags';
import { registerGatewayForPreviewType, getGatewayForPreviewType } from './gateway';
import { FETCH_START_DELAY, FETCH_COMPLETE_TARGET_DELAY } from './constants';
/**
* @module popups
* @private
*/
const EXCLUDED_LINK_SELECTORS = [
'.extiw',
// ignore links that point to the same article
'.mw-selflink',
'.image',
'.new',
'.internal',
'.external',
'.mw-cite-backlink a',
'.oo-ui-buttonElement-button',
'.ve-ce-surface a', // T259889
'.ext-discussiontools-init-timestamplink',
'.cancelLink a',
// T198652: lists to hash fragments are ignored.
// Note links that include the path will still trigger a hover,
// e.g. <a href="Foo#foo"> will trigger a preview but <a href="#foo"> will not.
// This is intentional behaviour that will not be handled by page previews, to avoid
// introducing complex behaviour. If a link must include the path it should make use of
// the .mw-selflink-fragment class.
'.mw-selflink-fragment',
'[href^="#"]'
];
/**
* @typedef {Function} EventTracker
*
* An analytics event tracker, i.e. `mw.track`.
*
* @param {string} topic
* @param {Object} data
*/
/**
* Gets the appropriate analytics event tracker for logging metrics to StatsD
* via [the "StatsD timers and counters" analytics event protocol][0].
*
* If logging metrics to StatsD is enabled for the duration of the user's
* session, then the appriopriate function is `mw.track`; otherwise it's
* `() => {}`.
*
* [0]: https://github.com/wikimedia/mediawiki-extensions-WikimediaEvents/blob/29c864a0/modules/ext.wikimediaEvents.statsd.js
*
* @param {Object} user
* @param {Object} config
* @param {Experiments} experiments
* @return {EventTracker}
*/
function getStatsvTracker( user, config, experiments ) {
return isStatsvEnabled( user, config, experiments ) ? mw.track : () => {};
}
/**
* Gets the appropriate analytics event tracker for logging virtual pageviews.
*
* @param {Object} config
* @return {EventTracker}
*/
function getPageviewTracker( config ) {
return config.get( 'wgPopupsVirtualPageViews' ) ? mw.track : () => {
// NOP
};
}
/**
* Subscribes the registered change listeners to the
* [store](http://redux.js.org/docs/api/Store.html#store).
*
* @param {Redux.Store} store
* @param {Object} registerActions
* @param {UserSettings} userSettings
* @param {Function} settingsDialog
* @param {PreviewBehavior} previewBehavior
* @param {EventTracker} statsvTracker
* @param {EventTracker} pageviewTracker
*/
function registerChangeListeners(
store, registerActions, userSettings, settingsDialog, previewBehavior,
statsvTracker, pageviewTracker
) {
registerChangeListener( store, changeListeners.footerLink( registerActions ) );
registerChangeListener( store, changeListeners.linkTitle() );
registerChangeListener( store, changeListeners.render( previewBehavior ) );
registerChangeListener(
store, changeListeners.statsv( registerActions, statsvTracker ) );
registerChangeListener(
store, changeListeners.syncUserSettings( userSettings ) );
registerChangeListener(
store, changeListeners.settings( registerActions, settingsDialog ) );
registerChangeListener( store,
changeListeners.pageviews( registerActions, pageviewTracker )
);
}
/**
* Creates an event handler that only executes if the current target
* is eligible for page previews and a title can be associated with the element.
*
* @param {Function} handler
* @return {Function}
*/
function handleDOMEventIfEligible( handler ) {
return function ( event ) {
let target = event && event.target;
if ( !target ) {
return;
}
// if the element is a text node, as events can be triggered on text nodes
// it won't have a closest method, so we get its parent element (T340081)
if ( target.nodeType === 3 ) {
target = target.parentNode;
}
// If the event bubbles up all the way,
// document does not have closest method, so exit early (T336650).
if ( target === document ) {
return;
}
// If the closest method is not defined, let's return early and
// understand this better by logging an error. (T340081)
if ( target && !target.closest ) {
const err = new Error( `T340081: Unexpected DOM element ${ target.tagName } with nodeType ${ target.nodeType }` );
mw.errorLogger.logError( err, 'error.web-team' );
return;
}
target = findNearestEligibleTarget( target );
if ( target === null ) {
return;
}
const mwTitle = titleFromElement( target, mw.config );
if ( mwTitle ) {
handler( target, mwTitle, event );
}
};
}
/*
* Initialize the application by:
* 1. Initializing side-effects and "services"
* 2. Creating the state store
* 3. Binding the actions to such store
* 4. Registering change listeners
* 5. Triggering the boot action to bootstrap the system
* 6. When the page content is ready:
* - Initializing the renderer
* - Binding hover and click events to the eligible links to trigger actions
*/
( function init() {
setUserConfigFlags( mw.config );
let compose = Redux.compose;
const
// So-called "services".
generateToken = mw.user.generateRandomSessionId,
pagePreviewGateway = createPagePreviewGateway( mw.config ),
userSettings = createUserSettings( mw.storage ),
settingsDialog = createSettingsDialogRenderer(),
experiments = createExperiments( mw.experiments ),
statsvTracker = getStatsvTracker( mw.user, mw.config, experiments ),
pageviewTracker = getPageviewTracker( mw.config ),
pagePreviewState = createIsPagePreviewsEnabled( mw.user, userSettings, mw.config );
// If debug mode is enabled, then enable Redux DevTools.
if ( mw.config.get( 'debug' ) ||
/* global process */
process.env.NODE_ENV !== 'production' ) {
// eslint-disable-next-line no-underscore-dangle
compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
}
const store = Redux.createStore(
Redux.combineReducers( reducers ),
compose( Redux.applyMiddleware(
ReduxThunk.default
) )
);
const boundActions = Redux.bindActionCreators( actions, store.dispatch );
const previewBehavior = createPreviewBehavior( mw.user, boundActions );
registerChangeListeners(
store, boundActions, userSettings, settingsDialog,
previewBehavior, statsvTracker, pageviewTracker
);
boundActions.boot(
{},
mw.user,
userSettings,
mw.config,
window.location.href
);
/*
* Register external interface exposing popups internals so that other
* extensions can query it (T171287)
*/
mw.popups = createMediaWikiPopupsObject(
store, registerModel, registerPreviewUI, registerGatewayForPreviewType,
boundActions.registerSetting, userSettings
);
// Migrate any old preferences to new system.
// FIXME: This can be removed in 4 weeks time.
userSettings.migrateOldPreferences();
if ( pagePreviewState !== null ) {
const excludedLinksSelector = EXCLUDED_LINK_SELECTORS.join( ', ' );
// Register default preview type
mw.popups.register( {
type: previewTypes.TYPE_PAGE,
selector: `#mw-content-text a[href][title]:not(${ excludedLinksSelector })`,
delay: FETCH_COMPLETE_TARGET_DELAY - FETCH_START_DELAY,
gateway: pagePreviewGateway,
renderFn: createPagePreview,
subTypes: [
{
type: previewTypes.TYPE_DISAMBIGUATION,
renderFn: createDisambiguationPreview,
doNotRequireSummary: true
}
]
} );
}
rendererInit();
/*
* Binding hover and click events to the eligible links to trigger actions
*/
function setupEventListeners() {
const onHover = handleDOMEventIfEligible( function ( target, mwTitle, event ) {
const type = getPreviewType( target );
const gateway = getGatewayForPreviewType( type );
if ( !gateway ) {
return;
}
const scrollTop = window.scrollY;
const bbox = target.getBoundingClientRect();
const offset = {
top: scrollTop + bbox.y,
left: window.scrollX + bbox.x
};
const measures = {
pageX: event.pageX,
pageY: event.pageY,
clientY: event.clientY,
width: target.offsetWidth,
height: target.offsetHeight,
offset,
clientRects: target.getClientRects(),
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scrollTop
};
boundActions.linkDwell( mwTitle, target, measures, gateway, generateToken, type );
} );
const onHoverOut = handleDOMEventIfEligible( function () {
boundActions.abandon();
} );
const onClick = handleDOMEventIfEligible( function ( target ) {
if ( previewTypes.TYPE_PAGE === getPreviewType( target ) ) {
boundActions.linkClick( target );
}
} );
document.addEventListener( 'mouseover', onHover );
document.addEventListener( 'keyup', onHover );
document.addEventListener( 'mouseout', onHoverOut );
document.addEventListener( 'blur', onHoverOut );
document.addEventListener( 'click', onClick );
}
setupEventListeners();
}() );
window.Redux = Redux;
window.ReduxThunk = ReduxThunk;