mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/Popups
synced 2025-01-09 04:34:13 +00:00
9aed7b95ee
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
306 lines
9.7 KiB
JavaScript
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;
|