( function ( mw, $ ) {
var isSafari = navigator.userAgent.match( /Safari/ ) !== null,
SIZES = {
portraitImage: {
h: 250, // Exact height
w: 203 // Max width
},
landscapeImage: {
h: 200, // Max height
w: 300 // Exact Width
},
landscapePopupWidth: 450,
portraitPopupWidth: 300,
pokeySize: 8 // Height of the pokey.
},
$window = $( window );
mw.popups.renderer = {};
/**
* Extracted from `mw.popups.createSVGMasks`.
*/
function createPokeyMasks() {
$( '
' )
.attr( 'id', 'mwe-popups-svg' )
.html(
''
)
.appendTo( document.body );
}
/**
* Initializes the renderer.
*/
mw.popups.renderer.init = function () {
createPokeyMasks();
};
/**
* The model of how a view is rendered, which is constructed from a response
* from the gateway.
*
* TODO: Rename `isTall` to `isPortrait`.
*
* @typedef {Object} ext.popups.Preview
* @property {jQuery} el
* @property {Boolean} hasThumbnail
* @property {Object} thumbnail
* @property {Boolean} isTall Sugar around
* `preview.hasThumbnail && thumbnail.isTall`
*/
/**
* Renders a preview given data from the {@link gateway ext.popups.Gateway}.
* The preview is rendered and added to the DOM but will remain hidden until
* the `show` method is called.
*
* Previews are rendered at:
*
* # The position of the mouse when the user dwells on the link with their
* mouse.
* # The centermost point of the link when the user dwells on the link with
* their keboard or other assistive device.
*
* Since the content of the preview doesn't change but its position might, we
* distinguish between "rendering" - generating HTML from a MediaWiki API
* response - and "showing/hiding" - positioning the layout and changing its
* orientation, if necessary.
*
* @param {ext.popups.PreviewModel} model
* @return {ext.popups.Preview}
*/
mw.popups.renderer.render = function ( model ) {
var preview = model.extract === undefined ? createEmptyPreview( model ) : createPreview( model );
return {
/**
* Shows the preview given an event representing the user's interaction
* with the active link, e.g. an instance of
* [MouseEvent](https://developer.mozilla.org/en/docs/Web/API/MouseEvent).
*
* See `show` for more detail.
*
* @param {Event} event
* @param {Object} boundActions The
* [bound action creators](http://redux.js.org/docs/api/bindActionCreators.html)
* that were (likely) created in [boot.js](./boot.js).
* @return {jQuery.Promise}
*/
show: function ( event, boundActions ) {
return show( preview, event, boundActions );
},
/**
* Hides the preview.
*
* See `hide` for more detail.
*
* @return {jQuery.Promise}
*/
hide: function () {
return hide( preview );
}
};
};
/**
* Creates an instance of the DTO backing a preview.
*
* @param {ext.popups.PreviewModel} model
* @return {ext.popups.Preview}
*/
function createPreview( model ) {
var templateData,
thumbnail = createThumbnail( model.thumbnail ),
hasThumbnail = thumbnail !== null,
// FIXME: This should probably be moved into the gateway as we'll soon be
// fetching HTML from the API. See
// https://phabricator.wikimedia.org/T141651 for more detail.
extract = renderExtract( model.extract, model.title ),
$el;
templateData = $.extend( {}, model, {
lastModifiedMsg: mw.msg( 'popups-last-edited', moment( model.lastModified ).fromNow() ),
hasThumbnail: hasThumbnail
} );
$el = mw.template.get( 'ext.popups', 'preview.mustache' )
.render( templateData );
if ( hasThumbnail ) {
$el.find( '.mwe-popups-discreet' ).append( thumbnail.el );
}
if ( extract.length ) {
$el.find( '.mwe-popups-extract' ).append( extract );
}
return {
el: $el,
hasThumbnail: hasThumbnail,
thumbnail: thumbnail,
isTall: hasThumbnail && thumbnail.isTall
};
}
/**
* Creates an instance of the DTO backing a preview. In this case the DTO
* represents a generic preview, which covers the following scenarios:
*
* * The page doesn't exist, i.e. the user hovered over a redlink or a
* redirect to a page that doesn't exist.
* * The page doesn't have a viable extract.
*
* @param {ext.popups.PreviewModel} model
* @return {ext.popups.Preview}
*/
function createEmptyPreview( model ) {
var templateData,
$el;
templateData = $.extend( {}, model, {
extractMsg: mw.msg( 'popups-preview-no-preview' ),
readMsg: mw.msg( 'popups-preview-footer-read' )
} );
$el = mw.template.get( 'ext.popups', 'preview-empty.mustache' )
.render( templateData );
return {
el: $el,
hasThumbnail: false,
isTall: false
};
}
/**
* Converts the extract into a list of elements, which correspond to fragments
* of the extract. Fragements that match the title verbatim are wrapped in a
* `` element.
*
* Using the bolded elements of the extract of the page directly is covered by
* [T141651](https://phabricator.wikimedia.org/T141651).
*
* Extracted from `mw.popups.renderer.article.getProcessedElements`.
*
* @param {String} extract
* @param {String} title
* @return {Array}
*/
function renderExtract( extract, title ) {
var regExp, escapedTitle,
elements = [],
boldIdentifier = '',
snip = '';
title = title.replace( /\s+/g, ' ' ).trim(); // Remove extra white spaces
escapedTitle = mw.RegExp.escape( title ); // Escape RegExp elements
regExp = new RegExp( '(^|\\s)(' + escapedTitle + ')(|$)', 'i' );
// Remove text in parentheses along with the parentheses
extract = extract.replace( /\s+/, ' ' ); // Remove extra white spaces
// Make title bold in the extract text
// As the extract is html escaped there can be no such string in it
// Also, the title is escaped of RegExp elements thus can't have "*"
extract = extract.replace( regExp, '$1' + snip + boldIdentifier + '$2' + snip + '$3' );
extract = extract.split( snip );
$.each( extract, function ( index, part ) {
if ( part.indexOf( boldIdentifier ) === 0 ) {
elements.push( $( '' ).text( part.substring( boldIdentifier.length ) ) );
} else {
elements.push( document.createTextNode( part ) );
}
} );
return elements;
}
/**
* Shows the preview.
*
* Extracted from `mw.popups.render.openPopup`.
*
* TODO: From the perspective of the client, there's no need to distinguish
* between renderering and showing a preview. Merge #render and Preview#show.
*
* @param {ext.popups.Preview} preview
* @param {Event} event
* @param {ext.popups.PreviewBehavior} behavior
* @return {jQuery.Promise} A promise that resolves when the promise has faded
* in
*/
function show( preview, event, behavior ) {
var layout = createLayout( preview, event );
preview.el.appendTo( document.body );
// Hack to "refresh" the SVG so that it's displayed.
//
// Elements get added to the DOM and not to the screen because of different
// namespaces of HTML and SVG.
//
// See http://stackoverflow.com/a/13654655/366138 for more detail.
//
// TODO: Find out how early on in the render that this could be done, e.g.
// createThumbnail?
preview.el.html( preview.el.html() );
layoutPreview( preview, layout );
preview.el.hover( behavior.previewDwell, behavior.previewAbandon );
preview.el.find( '.mwe-popups-settings-icon' )
.attr( 'href', behavior.settingsUrl )
.click( behavior.showSettings );
preview.el.show();
return mw.popups.wait( 200 )
.then( behavior.previewShow );
}
/**
* Extracted from `mw.popups.render.closePopup`.
*
* @param {ext.popups.Preview} preview
* @return {jQuery.Promise} A promise that resolves when the preview has faded
* out
*/
function hide( preview ) {
var fadeInClass,
fadeOutClass;
// FIXME: This method clearly needs access to the layout of the preview.
fadeInClass = ( preview.el.hasClass( 'mwe-popups-fade-in-up' ) ) ?
'mwe-popups-fade-in-up' :
'mwe-popups-fade-in-down';
fadeOutClass = ( fadeInClass === 'mwe-popups-fade-in-up' ) ?
'mwe-popups-fade-out-down' :
'mwe-popups-fade-out-up';
preview.el
.removeClass( fadeInClass )
.addClass( fadeOutClass );
return mw.popups.wait( 150 ).then( function () {
preview.el.remove();
} );
}
/**
* @typedef {Object} ext.popups.Thumbnail
* @property {Element} el
* @property {Boolean} isTall Whether or not the thumbnail is portrait
*/
/**
* Creates a thumbnail from the representation of a thumbnail returned by the
* PageImages MediaWiki API query module.
*
* If there's no thumbnail, the thumbnail is too small, or the thumbnail's URL
* contains characters that could be used to perform an
* [XSS attack via CSS](https://www.owasp.org/index.php/Testing_for_CSS_Injection_(OTG-CLIENT-005)),
* then `null` is returned.
*
* Extracted from `mw.popups.renderer.article.createThumbnail`.
*
* @param {Object} rawThumbnail
* @return {ext.popups.Thumbnail|null}
*/
function createThumbnail( rawThumbnail ) {
var tall, thumbWidth, thumbHeight,
x, y, width, height, clipPath,
devicePixelRatio = $.bracketedDevicePixelRatio();
if ( !rawThumbnail ) {
return null;
}
tall = rawThumbnail.width < rawThumbnail.height;
thumbWidth = rawThumbnail.width / devicePixelRatio;
thumbHeight = rawThumbnail.height / devicePixelRatio;
if (
// Image too small for landscape display
( !tall && thumbWidth < SIZES.landscapeImage.w ) ||
// Image too small for portrait display
( tall && thumbHeight < SIZES.portraitImage.h ) ||
// These characters in URL that could inject CSS and thus JS
(
rawThumbnail.source.indexOf( '\\' ) > -1 ||
rawThumbnail.source.indexOf( '\'' ) > -1 ||
rawThumbnail.source.indexOf( '\"' ) > -1
)
) {
return null;
}
if ( tall ) {
x = ( thumbWidth > SIZES.portraitImage.w ) ?
( ( thumbWidth - SIZES.portraitImage.w ) / -2 ) :
( SIZES.portraitImage.w - thumbWidth );
y = ( thumbHeight > SIZES.portraitImage.h ) ?
( ( thumbHeight - SIZES.portraitImage.h ) / -2 ) : 0;
width = SIZES.portraitImage.w;
height = SIZES.portraitImage.h;
} else {
x = 0;
y = ( thumbHeight > SIZES.landscapeImage.h ) ?
( ( thumbHeight - SIZES.landscapeImage.h ) / -2 ) : 0;
width = SIZES.landscapeImage.w + 3;
height = ( thumbHeight > SIZES.landscapeImage.h ) ?
SIZES.landscapeImage.h : thumbHeight;
clipPath = 'mwe-popups-mask';
}
return {
el: createThumbnailElement(
tall ? 'mwe-popups-is-tall' : 'mwe-popups-is-not-tall',
rawThumbnail.source,
x,
y,
thumbWidth,
thumbHeight,
width,
height,
clipPath
),
isTall: tall,
width: thumbWidth,
height: thumbHeight
};
}
/**
* Creates the SVG image element that represents the thumbnail.
*
* This function is distinct from `createThumbnail` as it abstracts away some
* browser issues that are uncovered when manipulating elements across
* namespaces.
*
* @param {String} className
* @param {String} url
* @param {Number} x
* @param {Number} y
* @param {Number} thumbnailWidth
* @param {Number} thumbnailHeight
* @param {Number} width
* @param {Number} height
* @param {String} clipPath
* @return {jQuery}
*/
function createThumbnailElement( className, url, x, y, thumbnailWidth, thumbnailHeight, width, height, clipPath ) {
var $thumbnailSVGImage, $thumbnail,
ns = 'http://www.w3.org/2000/svg',
// Use createElementNS to create the svg:image tag as jQuery uses
// createElement instead. Some browsers mistakenly map the image tag to
// img tag.
svgElement = document.createElementNS( 'http://www.w3.org/2000/svg', 'image' );
$thumbnailSVGImage = $( svgElement );
$thumbnailSVGImage
.addClass( className )
.attr( {
x: x,
y: y,
width: thumbnailWidth,
height: thumbnailHeight,
'clip-path': 'url(#' + clipPath + ')'
} );
// Certain browsers, e.g. IE9, will not correctly set attributes from
// foreign namespaces using Element#setAttribute (see T134979). Apart from
// Safari, all supported browsers can set them using Element#setAttributeNS
// (see T134979).
if ( isSafari ) {
svgElement.setAttribute( 'xlink:href', url );
} else {
svgElement.setAttributeNS( ns, 'xlink:href', url );
}
$thumbnail = $( '