diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 066db8eadb63d..bbb46e80d0337 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -4,20 +4,12 @@ // Note: This is loaded as a script module, so there is no need for an IIFE to prevent pollution of the global scope. -/** - * Emoji Settings as exported in PHP via _print_emoji_detection_script(). - * @typedef WPEmojiSettings - * @type {Object} - * @property {?object} source - * @property {?string} source.concatemoji - * @property {?string} source.twemoji - * @property {?string} source.wpemoji - */ +// Note: The WPEmojiSettings and EmojiSupports types are declared in typings/wp-emoji, since wp-emoji.js reads them back. const selector = 'script#wp-emoji-settings'; const script = document.querySelector( selector ); if ( ! ( script instanceof HTMLScriptElement ) ) { - throw new Error( `Element missing: ${ selector }`); + throw new Error( `Element missing: ${ selector }` ); } const settings = /** @type {WPEmojiSettings} */ ( JSON.parse( script.text ) ); @@ -25,14 +17,17 @@ const settings = /** @type {WPEmojiSettings} */ ( JSON.parse( script.text ) ); window._wpemojiSettings = settings; /** - * Support tests. + * Results of the emoji support tests. + * * @typedef SupportTests * @type {Object} - * @property {?boolean} flag - * @property {?boolean} emoji + * @property {boolean} flag Whether the browser renders flag emoji. + * @property {boolean} emoji Whether the browser renders emoji. */ const sessionStorageKey = 'wpEmojiSettingsSupports'; + +/** @type {Array} */ const tests = [ 'flag', 'emoji' ]; /** @@ -49,16 +44,18 @@ function supportsWorkerOffloading() { typeof Worker !== 'undefined' && typeof OffscreenCanvas !== 'undefined' && typeof URL !== 'undefined' && - URL.createObjectURL && + typeof URL.createObjectURL === 'function' && typeof Blob !== 'undefined' ); } /** + * Support tests as they are stored in session storage. + * * @typedef SessionSupportTests * @type {Object} - * @property {number} timestamp - * @property {SupportTests} supportTests + * @property {number} timestamp When the tests were run, in milliseconds since the epoch. + * @property {SupportTests} supportTests What the tests found. */ /** @@ -72,10 +69,13 @@ function supportsWorkerOffloading() { */ function getSessionSupportTests() { try { + const itemJson = sessionStorage.getItem( sessionStorageKey ); + if ( null === itemJson ) { + return null; + } + /** @type {SessionSupportTests} */ - const item = JSON.parse( - sessionStorage.getItem( sessionStorageKey ) - ); + const item = JSON.parse( itemJson ); if ( typeof item === 'object' && typeof item.timestamp === 'number' && @@ -95,7 +95,7 @@ function getSessionSupportTests() { * * @private * - * @param {SupportTests} supportTests Support tests. + * @param {SupportTests} supportTests What the tests found. */ function setSessionSupportTests( supportTests ) { try { @@ -112,6 +112,51 @@ function setSessionSupportTests( supportTests ) { } catch ( e ) {} } +/** + * A 2D context for the support tests. + * + * Which of the two it is depends on the kind of canvas it came from, and the tests use only what + * both provide. + * + * @typedef {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} EmojiTestContext + */ + +/** + * Checks if two sets of Emoji characters render the same visually. + * + * @callback EmojiSetsRenderIdentically + * + * @param {EmojiTestContext} context 2D Context. + * @param {string} set1 Set of Emoji to test. + * @param {string} set2 Set of Emoji to test. + * + * @return {boolean} True if the two sets render the same. + */ + +/** + * Checks if the center point of a single emoji is empty. + * + * @callback EmojiRendersEmptyCenterPoint + * + * @param {EmojiTestContext} context 2D Context. + * @param {string} emoji Emoji to test. + * + * @return {boolean} True if the center point is empty. + */ + +/** + * Determines if the browser properly renders Emoji that Twemoji can supplement. + * + * @callback BrowserSupportsEmoji + * + * @param {EmojiTestContext} context 2D Context. + * @param {keyof SupportTests} type Which support test to run. + * @param {EmojiSetsRenderIdentically} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {EmojiRendersEmptyCenterPoint} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * + * @return {boolean} True if the browser can render emoji, false if it cannot. + */ + /** * Checks if two sets of Emoji characters render the same visually. * @@ -127,9 +172,9 @@ function setSessionSupportTests( supportTests ) { * * @private * - * @param {CanvasRenderingContext2D} context 2D Context. - * @param {string} set1 Set of Emoji to test. - * @param {string} set2 Set of Emoji to test. + * @param {EmojiTestContext} context 2D Context. + * @param {string} set1 Set of Emoji to test. + * @param {string} set2 Set of Emoji to test. * * @return {boolean} True if the two sets render the same. */ @@ -177,8 +222,8 @@ function emojiSetsRenderIdentically( context, set1, set2 ) { * * @private * - * @param {CanvasRenderingContext2D} context 2D Context. - * @param {string} emoji Emoji to test. + * @param {EmojiTestContext} context 2D Context. + * @param {string} emoji Emoji to test. * * @return {boolean} True if the center point is empty. */ @@ -188,7 +233,7 @@ function emojiRendersEmptyCenterPoint( context, emoji ) { context.fillText( emoji, 0, 0 ); // Test if the center point (16, 16) is empty (0,0,0,0). - const centerPoint = context.getImageData(16, 16, 1, 1); + const centerPoint = context.getImageData( 16, 16, 1, 1 ); for ( let i = 0; i < centerPoint.data.length; i++ ) { if ( centerPoint.data[ i ] !== 0 ) { // Stop checking the moment it's known not to be empty. @@ -209,14 +254,15 @@ function emojiRendersEmptyCenterPoint( context, emoji ) { * * @private * - * @param {CanvasRenderingContext2D} context 2D Context. - * @param {string} type Whether to test for support of "flag" or "emoji". - * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. - * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * @param {EmojiTestContext} context 2D Context. + * @param {keyof SupportTests} type Which support test to run. + * @param {EmojiSetsRenderIdentically} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {EmojiRendersEmptyCenterPoint} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. * * @return {boolean} True if the browser can render emoji, false if it cannot. */ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ) { + /** @type {boolean} */ let isIdentical; switch ( type ) { @@ -256,7 +302,7 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR /* * Test for English flag compatibility. England is a country in the United Kingdom, it - * does not have a two letter locale code but rather a five letter sub-division code. + * does not have a two letter locale code but rather a five letter subdivision code. * * To test for support, we try to render it, and compare the rendering to how it would look if * the browser doesn't render it correctly (black flag emoji + [G] + [B] + [E] + [N] + [G]). @@ -288,8 +334,6 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR const notSupported = emojiRendersEmptyCenterPoint( context, '\uD83E\uDEC8' ); return ! notSupported; } - - return false; } /** @@ -302,25 +346,30 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR * * @private * - * @param {string[]} tests Tests. - * @param {Function} browserSupportsEmoji Reference to browserSupportsEmoji function, needed due to minification. - * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. - * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * @param {Array} tests Which support tests to run. + * @param {BrowserSupportsEmoji} browserSupportsEmoji Reference to browserSupportsEmoji function, needed due to minification. + * @param {EmojiSetsRenderIdentically} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {EmojiRendersEmptyCenterPoint} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. * * @return {SupportTests} Support tests. */ function testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ) { - let canvas; + /** @type {?EmojiTestContext} */ + let context; + if ( typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope ) { - canvas = new OffscreenCanvas( 300, 150 ); // Dimensions are default for HTMLCanvasElement. + // Dimensions are default for HTMLCanvasElement. + context = new OffscreenCanvas( 300, 150 ).getContext( '2d', { willReadFrequently: true } ); } else { - canvas = document.createElement( 'canvas' ); + context = document.createElement( 'canvas' ).getContext( '2d', { willReadFrequently: true } ); } - const context = canvas.getContext( '2d', { willReadFrequently: true } ); + if ( ! context ) { + throw new Error( 'Unable to obtain a 2D context for the emoji support tests.' ); + } /* * Chrome on OS X added native emoji rendering in M41. Unfortunately, @@ -330,7 +379,7 @@ function testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentica context.textBaseline = 'top'; context.font = '600 32px Arial'; - const supports = {}; + const supports = /** @type {SupportTests} */ ( {} ); tests.forEach( ( test ) => { supports[ test ] = browserSupportsEmoji( context, test, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ); } ); @@ -361,10 +410,11 @@ settings.supports = { }; // Obtain the emoji support from the browser, asynchronously when possible. -new Promise( ( resolve ) => { - let supportTests = getSessionSupportTests(); - if ( supportTests ) { - resolve( supportTests ); +/** @type {Promise} */ +const supportTestsPromise = new Promise( ( resolve ) => { + const sessionSupportTests = getSessionSupportTests(); + if ( sessionSupportTests ) { + resolve( sessionSupportTests ); return; } @@ -386,20 +436,21 @@ new Promise( ( resolve ) => { type: 'text/javascript' } ); const worker = new Worker( URL.createObjectURL( blob ), { name: 'wpTestEmojiSupports' } ); - worker.onmessage = ( event ) => { - supportTests = event.data; - setSessionSupportTests( supportTests ); + worker.onmessage = ( /** @type {MessageEvent} */ event ) => { + setSessionSupportTests( event.data ); worker.terminate(); - resolve( supportTests ); + resolve( event.data ); }; return; } catch ( e ) {} } - supportTests = testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ); - setSessionSupportTests( supportTests ); - resolve( supportTests ); -} ) + const testedSupportTests = testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ); + setSessionSupportTests( testedSupportTests ); + resolve( testedSupportTests ); +} ); + +supportTestsPromise // Once the browser emoji support has been obtained from the session, finalize the settings. .then( ( supportTests ) => { /* @@ -407,15 +458,17 @@ new Promise( ( resolve ) => { * support settings accordingly. */ for ( const test in supportTests ) { - settings.supports[ test ] = supportTests[ test ]; + const key = /** @type {keyof SupportTests} */ ( test ); + const supported = supportTests[ key ]; + + settings.supports[ key ] = supported; settings.supports.everything = - settings.supports.everything && settings.supports[ test ]; + settings.supports.everything && supported; - if ( 'flag' !== test ) { + if ( 'flag' !== key ) { settings.supports.everythingExceptFlag = - settings.supports.everythingExceptFlag && - settings.supports[ test ]; + settings.supports.everythingExceptFlag && supported; } } diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 274868f52d859..a1f0f4face234 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -1,58 +1,52 @@ +/** + * @output wp-includes/js/wp-emoji.js + */ + +/** + * Additional options accepted by wp.emoji.parse(). + * + * @typedef WPEmojiParseArgs + * @type {Object} + * @property {string} [className] Class name to give each generated image. + * @property {Record} [imgAttr] Attributes to set on each generated image, in + * place of the default ones. + */ + /** * wp-emoji.js is used to replace emoji with images in browsers when the browser * doesn't support emoji natively. * - * @param {Window} window The global window object. - * @param {Object} settings The settings object. - * @output wp-includes/js/wp-emoji.js + * @param {Window} window The global window object. + * @param {WPEmojiSettings} settings The settings object. */ - -( function( window, settings ) { +( function ( window, settings ) { /** * Replaces emoji with images when browsers don't support emoji. * * @since 4.2.0 * @access private * - * @class + * @see Twemoji + * @link https://github.com/jdecked/twemoji * - * @see Twitter Emoji library - * @link https://github.com/twitter/twemoji - * - * @return {Object} The wpEmoji parse and test functions. + * @return {{ + * parse: ( object: HTMLElement|string, args?: WPEmojiParseArgs ) => HTMLElement|string, + * test: ( text: ?string ) => boolean + * }} The wpEmoji parse and test functions. */ function wpEmoji() { - var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver, - // Compression and maintain local scope. - document = window.document, + const document = window.document; // Private. - twemoji, timer, - loaded = false, - count = 0, - ie11 = window.navigator.userAgent.indexOf( 'Trident/7.0' ) > 0; + /** @type {Twemoji|undefined} */ + let twemoji; - /** - * Detect if the browser supports SVG. - * - * @since 4.6.0 - * @private - * - * @see Modernizr - * @link https://github.com/Modernizr/Modernizr/blob/master/feature-detects/svg/asimg.js - * - * @return {boolean} True if the browser supports svg, false if not. - */ - function browserSupportsSvgAsImage() { - if ( !! document.implementation.hasFeature ) { - return document.implementation.hasFeature( 'http://www.w3.org/TR/SVG11/feature#Image', '1.1' ); - } + /** @type {number|undefined} */ + let timer; - // document.implementation.hasFeature is deprecated. It can be presumed - // if future browsers remove it, the browser will support SVGs as images. - return true; - } + let loaded = false; + let count = 0; /** * Runs when the document load event is fired, so we can do our first parse of @@ -89,74 +83,42 @@ // Initialize the mutation observer, which checks all added nodes for // replaceable emoji characters. - if ( MutationObserver ) { - new MutationObserver( function( mutationRecords ) { - var i = mutationRecords.length, - addedNodes, removedNodes, ii, node; - - while ( i-- ) { - addedNodes = mutationRecords[ i ].addedNodes; - removedNodes = mutationRecords[ i ].removedNodes; - ii = addedNodes.length; - - /* - * Checks if an image has been replaced by a text element - * with the same text as the alternate description of the replaced image. - * (presumably because the image could not be loaded). - * If it is, do absolutely nothing. - * - * Node type 3 is a TEXT_NODE. - * - * @link https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType - */ - if ( - ii === 1 && removedNodes.length === 1 && - addedNodes[0].nodeType === 3 && - removedNodes[0].nodeName === 'IMG' && - addedNodes[0].data === removedNodes[0].alt && - 'load-failed' === removedNodes[0].getAttribute( 'data-error' ) - ) { - return; - } + new MutationObserver( ( mutationRecords ) => { + for ( const { addedNodes, removedNodes } of mutationRecords ) { + const addedNode = addedNodes[ 0 ]; + const removedNode = removedNodes[ 0 ]; + + /* + * Checks if an image has been replaced by a text element + * with the same text as the alternate description of the replaced image. + * (presumably because the image could not be loaded). + * If it is, leave this record alone, so that the text is not turned + * straight back into the image which just failed to load. + */ + if ( + addedNodes.length === 1 && removedNodes.length === 1 && + addedNode instanceof Text && + removedNode instanceof HTMLImageElement && + addedNode.data === removedNode.alt && + 'load-failed' === removedNode.dataset.error + ) { + continue; + } - // Loop through all the added nodes. - while ( ii-- ) { - node = addedNodes[ ii ]; - - // Node type 3 is a TEXT_NODE. - if ( node.nodeType === 3 ) { - if ( ! node.parentNode ) { - continue; - } - - if ( ie11 ) { - /* - * IE 11's implementation of MutationObserver is buggy. - * It unnecessarily splits text nodes when it encounters a HTML - * template interpolation symbol ( "{{", for example ). So, we - * join the text nodes back together as a work-around. - * - * Node type 3 is a TEXT_NODE. - */ - while( node.nextSibling && 3 === node.nextSibling.nodeType ) { - node.nodeValue = node.nodeValue + node.nextSibling.nodeValue; - node.parentNode.removeChild( node.nextSibling ); - } - } - - node = node.parentNode; - } - - if ( test( node.textContent ) ) { - parse( node ); - } + // Loop through all the added nodes. + for ( const addedNode of addedNodes ) { + // Emoji in a text node are replaced by parsing the element which contains it. + const node = addedNode instanceof Text ? addedNode.parentElement : addedNode; + + if ( node instanceof HTMLElement && test( node.textContent ) ) { + parse( node ); } } - } ).observe( document.body, { - childList: true, - subtree: true - } ); - } + } + } ).observe( document.body, { + childList: true, + subtree: true + } ); parse( document.body ); } @@ -168,18 +130,19 @@ * * @memberOf wp.emoji * - * @param {string} text The string to test. + * @param {?string} text The string to test. * * @return {boolean} Whether the string contains emoji characters. */ function test( text ) { // Single char. U+20E3 to detect keycaps. U+00A9 "copyright sign" and U+00AE "registered sign" not included. - var single = /[\u203C\u2049\u20E3\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2300\u231A\u231B\u2328\u2388\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638\u2639\u263A\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267B\u267F\u2692\u2693\u2694\u2696\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753\u2754\u2755\u2757\u2763\u2764\u2795\u2796\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05\u2B06\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]/, + const single = /[\u203C\u2049\u20E3\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2300\u231A\u231B\u2328\u2388\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638\u2639\u263A\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267B\u267F\u2692\u2693\u2694\u2696\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753\u2754\u2755\u2757\u2763\u2764\u2795\u2796\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05\u2B06\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]/; + // Surrogate pair range. Only tests for the second half. - pair = /[\uDC00-\uDFFF]/; + const pair = /[\uDC00-\uDFFF]/; if ( text ) { - return pair.test( text ) || single.test( text ); + return pair.test( text ) || single.test( text ); } return false; @@ -197,14 +160,12 @@ * @memberOf wp.emoji * * @param {HTMLElement|string} object The element or string to parse. - * @param {Object} args Additional options for Twemoji. + * @param {WPEmojiParseArgs} [args] Additional options for Twemoji. * * @return {HTMLElement|string} A string where all emoji are now image tags of * emoji. Or the element that was passed as the first argument. */ function parse( object, args ) { - var params; - /* * If the browser has full support, twemoji is not loaded or our * object is not what was expected, we do not parse anything. @@ -217,11 +178,16 @@ // Compose the params for the twitter emoji library. args = args || {}; - params = { - base: browserSupportsSvgAsImage() ? settings.svgUrl : settings.baseUrl, - ext: browserSupportsSvgAsImage() ? settings.svgExt : settings.ext, + + // The caller may replace the attributes given to every generated image. + const attributes = typeof args.imgAttr === 'object' ? args.imgAttr : { role: 'img' }; + + /** @type {TwemojiParseOptions} */ + const params = { + base: settings.svgUrl, + ext: settings.svgExt, className: args.className || 'emoji', - callback: function( icon, options ) { + callback: ( icon, options ) => { // Ignore some standard characters that TinyMCE recommends in its character map. switch ( icon ) { case 'a9': @@ -244,47 +210,30 @@ return ''.concat( options.base, icon, options.ext ); }, - attributes: function() { - return { - role: 'img' - }; - }, - onerror: function() { - if ( twemoji.parentNode ) { - this.setAttribute( 'data-error', 'load-failed' ); - twemoji.parentNode.replaceChild( document.createTextNode( twemoji.alt ), twemoji ); + attributes: () => attributes, + onerror: function () { + /* + * Put the emoji character back in place of the image which failed to load. The + * attribute is what tells the MutationObserver above that this replacement is + * the one it must not turn straight back into an image. + */ + if ( this.parentNode ) { + this.dataset.error = 'load-failed'; + this.parentNode.replaceChild( document.createTextNode( this.alt ), this ); } }, - doNotParse: function( node ) { - if ( - node && - node.className && - typeof node.className === 'string' && - node.className.indexOf( 'wp-exclude-emoji' ) !== -1 - ) { - // Do not parse this node. Emojis will not be replaced in this node and all sub-nodes. - return true; - } - - return false; + doNotParse: ( element ) => { + // Emoji will not be replaced in this element, nor in any of its descendants. + return element.classList.contains( 'wp-exclude-emoji' ); } }; - if ( typeof args.imgAttr === 'object' ) { - params.attributes = function() { - return args.imgAttr; - }; - } - return twemoji.parse( object, params ); } load(); - return { - parse: parse, - test: test - }; + return { parse, test }; } window.wp = window.wp || {}; @@ -292,6 +241,6 @@ /** * @namespace wp.emoji */ - window.wp.emoji = new wpEmoji(); + window.wp.emoji = wpEmoji(); } )( window, window._wpemojiSettings ); diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 82524daa01227..37f4a90952aac 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -97,6 +97,37 @@ + + @@ -157,6 +188,7 @@ + diff --git a/tests/qunit/wp-includes/js/wp-emoji.js b/tests/qunit/wp-includes/js/wp-emoji.js new file mode 100644 index 0000000000000..90ba9f056ed52 --- /dev/null +++ b/tests/qunit/wp-includes/js/wp-emoji.js @@ -0,0 +1,456 @@ +/* global wp, twemoji */ + +/* + * The elements built here are deliberately left out of the document. wp-emoji observes the body for + * added nodes, so attaching them would set that observer going in the middle of a test and call the + * Twemoji stand-in again behind the assertions. + */ + +const EMOJI = '😀'; // Grinning face. + +/** + * Builds a detached element containing the given text. + * + * @param {string} text Text to place inside the element. + * @param {string} [className] Class attribute for the element. + * + * @return {HTMLElement} The element. + */ +function emojiFixtureElement( text, className ) { + const element = document.createElement( 'div' ); + + if ( className ) { + element.setAttribute( 'class', className ); + } + + element.appendChild( document.createTextNode( text ) ); + + return element; +} + +/** + * Parses a fixture element and returns the params wp-emoji handed to Twemoji. + * + * @param {Object} [args] Additional options for wp.emoji.parse(). + * + * @return {Object} The params Twemoji was called with. + */ +function paramsFromParse( args ) { + wp.emoji.parse( emojiFixtureElement( EMOJI ), args ); + + return twemoji.lastParams; +} + +QUnit.module( 'wp.emoji.test' ); + +QUnit.test( 'recognizes a surrogate pair emoji', function ( assert ) { + assert.strictEqual( wp.emoji.test( EMOJI ), true, 'A grinning face is an emoji.' ); +} ); + +QUnit.test( 'recognizes an emoji within surrounding text', function ( assert ) { + assert.strictEqual( wp.emoji.test( 'before ' + EMOJI + ' after' ), true, 'An emoji is found among other text.' ); +} ); + +QUnit.test( 'recognizes a single code point emoji', function ( assert ) { + assert.strictEqual( wp.emoji.test( '❤' ), true, 'A heavy black heart is an emoji.' ); +} ); + +QUnit.test( 'does not recognize plain text', function ( assert ) { + assert.strictEqual( wp.emoji.test( 'Hello world' ), false, 'Plain text contains no emoji.' ); +} ); + +QUnit.test( 'does not recognize a copyright sign', function ( assert ) { + assert.strictEqual( wp.emoji.test( '©' ), false, 'The copyright sign is excluded from the test.' ); +} ); + +QUnit.test( 'returns false for values which are not text', function ( assert ) { + assert.strictEqual( wp.emoji.test( '' ), false, 'An empty string contains no emoji.' ); + assert.strictEqual( wp.emoji.test( null ), false, 'Null contains no emoji.' ); + assert.strictEqual( wp.emoji.test( undefined ), false, 'Undefined contains no emoji.' ); +} ); + +QUnit.module( 'wp.emoji.parse', { + beforeEach: function () { + twemoji.calls = 0; + twemoji.lastObject = null; + twemoji.lastParams = null; + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'does nothing when the browser supports every emoji', function ( assert ) { + window._wpemojiSettings.supports.everything = true; + + const element = emojiFixtureElement( EMOJI ); + + assert.strictEqual( wp.emoji.parse( element ), element, 'The element is returned as it was.' ); + assert.strictEqual( twemoji.calls, 0, 'Twemoji is not called.' ); +} ); + +QUnit.test( 'does nothing when given an element with no child nodes', function ( assert ) { + const element = document.createElement( 'div' ); + + assert.strictEqual( wp.emoji.parse( element ), element, 'The element is returned as it was.' ); + assert.strictEqual( twemoji.calls, 0, 'Twemoji is not called.' ); +} ); + +QUnit.test( 'does nothing when given nothing to parse', function ( assert ) { + assert.strictEqual( wp.emoji.parse( null ), null, 'Null is returned as it was.' ); + assert.strictEqual( twemoji.calls, 0, 'Twemoji is not called.' ); +} ); + +QUnit.test( 'passes a string on to Twemoji', function ( assert ) { + wp.emoji.parse( EMOJI ); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, EMOJI, 'Twemoji is given the string.' ); +} ); + +QUnit.test( 'uses the SVG images', function ( assert ) { + const params = paramsFromParse(); + + assert.strictEqual( params.base, window._wpemojiSettings.svgUrl, 'The SVG URL is used as the base.' ); + assert.strictEqual( params.ext, window._wpemojiSettings.svgExt, 'The SVG extension is used.' ); +} ); + +QUnit.test( 'gives each image the emoji class by default', function ( assert ) { + assert.strictEqual( paramsFromParse().className, 'emoji', 'The class name defaults to emoji.' ); +} ); + +QUnit.test( 'allows the class name to be replaced', function ( assert ) { + assert.strictEqual( paramsFromParse( { className: 'custom' } ).className, 'custom', 'The given class name is used.' ); +} ); + +QUnit.test( 'marks each image as an image for assistive technology', function ( assert ) { + assert.deepEqual( paramsFromParse().attributes(), { role: 'img' }, 'The role attribute is set.' ); +} ); + +QUnit.test( 'allows the attributes to be replaced', function ( assert ) { + const imgAttr = { 'aria-hidden': 'true' }; + + assert.deepEqual( paramsFromParse( { imgAttr: imgAttr } ).attributes(), imgAttr, 'The given attributes are used.' ); +} ); + +QUnit.test( 'ignores attributes which are not an object', function ( assert ) { + assert.deepEqual( paramsFromParse( { imgAttr: 'not an object' } ).attributes(), { role: 'img' }, 'The default attributes are used.' ); +} ); + +QUnit.module( 'wp.emoji.parse callback', { + beforeEach: function () { + twemoji.calls = 0; + twemoji.lastParams = null; + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'builds the image source from the base and extension', function ( assert ) { + const params = paramsFromParse(); + + assert.strictEqual( + params.callback( '1f600', params ), + window._wpemojiSettings.svgUrl + '1f600' + window._wpemojiSettings.svgExt, + 'The source is the base, the icon and the extension.' + ); +} ); + +QUnit.test( 'leaves the characters TinyMCE offers in its character map alone', function ( assert ) { + const params = paramsFromParse(); + + [ 'a9', 'ae', '2122', '2194', '2660', '2663', '2665', '2666' ].forEach( function ( icon ) { + assert.strictEqual( params.callback( icon, params ), false, icon + ' is left as it is.' ); + } ); +} ); + +QUnit.test( 'replaces only flags when everything but flags is supported', function ( assert ) { + window._wpemojiSettings.supports.everythingExceptFlag = true; + + const params = paramsFromParse(); + + assert.strictEqual( params.callback( '1f600', params ), false, 'A grinning face is left as it is.' ); + assert.strictEqual( + params.callback( '1f1e8-1f1f6', params ), + window._wpemojiSettings.svgUrl + '1f1e8-1f1f6' + window._wpemojiSettings.svgExt, + 'A country flag is replaced.' + ); + assert.strictEqual( + params.callback( '1f3f3-fe0f-200d-1f308', params ), + window._wpemojiSettings.svgUrl + '1f3f3-fe0f-200d-1f308' + window._wpemojiSettings.svgExt, + 'The rainbow flag is replaced.' + ); +} ); + +QUnit.module( 'wp.emoji.parse doNotParse', { + beforeEach: function () { + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'excludes an element carrying the exclusion class', function ( assert ) { + const doNotParse = paramsFromParse().doNotParse; + + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'wp-exclude-emoji' ) ), true, 'The class on its own excludes.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'one wp-exclude-emoji two' ) ), true, 'The class among others excludes.' ); +} ); + +QUnit.test( 'does not treat a longer class name as the exclusion class', function ( assert ) { + const doNotParse = paramsFromParse().doNotParse; + + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'wp-exclude-emoji-wrapper' ) ), false, 'A class which begins with it does not exclude.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'my-wp-exclude-emoji' ) ), false, 'A class which ends with it does not exclude.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'wp-exclude-emojis' ) ), false, 'A plural of it does not exclude.' ); +} ); + +QUnit.test( 'does not exclude an unrelated element', function ( assert ) { + const doNotParse = paramsFromParse().doNotParse; + + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'unrelated' ) ), false, 'Another class does not exclude.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '' ) ), false, 'No class at all does not exclude.' ); +} ); + +QUnit.module( 'wp.emoji.parse onerror', { + beforeEach: function () { + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'puts the emoji character back when the image cannot be loaded', function ( assert ) { + const parent = document.createElement( 'p' ); + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + parent.appendChild( image ); + + paramsFromParse().onerror.call( image ); + + assert.strictEqual( parent.textContent, EMOJI, 'The parent holds the emoji character.' ); + assert.strictEqual( parent.contains( image ), false, 'The image is gone.' ); +} ); + +QUnit.test( 'marks the image it removed, so that it is not put back', function ( assert ) { + const parent = document.createElement( 'p' ); + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + parent.appendChild( image ); + + paramsFromParse().onerror.call( image ); + + assert.strictEqual( image.dataset.error, 'load-failed', 'The image carries the marker the observer looks for.' ); +} ); + +QUnit.test( 'does nothing to an image which is not in the document', function ( assert ) { + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + + paramsFromParse().onerror.call( image ); + + assert.strictEqual( image.dataset.error, undefined, 'The image is left unmarked.' ); +} ); + +/* + * Unlike everything above, these do attach their elements, because what is under test is the + * observer wp-emoji sets on the body. + */ +QUnit.module( 'wp-emoji mutation observer', { + beforeEach: function () { + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +/** + * Waits for pending mutation records to be delivered to the observer. + * + * Records reach an observer in a microtask, so settling on one puts this behind the observer's own + * callback. A timer cannot be used to wait: every test here is wrapped by sinon-test, which swaps + * the timers for a fake clock that nothing in this file advances. + * + * @return {Promise} A promise which settles once the observer has run. + */ +function afterMutations() { + return Promise.resolve(); +} + +/** + * Adds an element to the fixture, and returns once the observer has seen it. + * + * @param {HTMLElement} element Element to add. + * + * @return {Promise} A promise which settles once the observer has run. + */ +function attachToFixture( element ) { + document.getElementById( 'qunit-fixture' ).appendChild( element ); + + return afterMutations(); +} + +/** + * Adds an element of the given kind, holding an emoji, to the fixture. + * + * @param {string} namespace Namespace URI for the element. + * @param {string} name Local name for the element. + * + * @return {Promise} A promise which settles once the observer has run. + */ +function attachNamespacedElement( namespace, name ) { + const element = document.createElementNS( namespace, name ); + + element.appendChild( document.createTextNode( EMOJI ) ); + + return attachToFixture( element ); +} + +/** + * Builds a paragraph holding an emoji image, as Twemoji would have left it. + * + * @param {string} [error] Value for the data-error attribute. + * + * @return {{ paragraph: HTMLParagraphElement, image: HTMLImageElement }} The paragraph and the image within it. + */ +function emojiImageParagraph( error ) { + const paragraph = document.createElement( 'p' ); + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + + if ( error ) { + image.dataset.error = error; + } + + paragraph.appendChild( image ); + + return { paragraph, image }; +} + +QUnit.test( 'parses an element added to the document', async function ( assert ) { + const element = emojiFixtureElement( EMOJI ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + await attachToFixture( element ); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, element, 'Twemoji is given the added element.' ); +} ); + +QUnit.test( 'parses the containing element of an added text node', async function ( assert ) { + const paragraph = document.createElement( 'p' ); + + await attachToFixture( paragraph ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + paragraph.appendChild( document.createTextNode( EMOJI ) ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, paragraph, 'Twemoji is given the containing element.' ); +} ); + +QUnit.test( 'leaves alone an image replaced by its own alternative text', async function ( assert ) { + const nodes = emojiImageParagraph( 'load-failed' ); + + await attachToFixture( nodes.paragraph ); + + twemoji.calls = 0; + + nodes.paragraph.replaceChild( document.createTextNode( EMOJI ), nodes.image ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 0, 'The text which replaced the image is not parsed back into one.' ); +} ); + +QUnit.test( 'leaves an SVG element alone', async function ( assert ) { + twemoji.calls = 0; + + await attachNamespacedElement( 'http://www.w3.org/2000/svg', 'svg' ); + + assert.strictEqual( twemoji.calls, 0, 'An SVG element is not given to Twemoji, which would put an image inside it.' ); +} ); + +QUnit.test( 'leaves a MathML element alone', async function ( assert ) { + twemoji.calls = 0; + + await attachNamespacedElement( 'http://www.w3.org/1998/Math/MathML', 'math' ); + + assert.strictEqual( twemoji.calls, 0, 'A MathML element is not given to Twemoji.' ); +} ); + +QUnit.test( 'leaves alone a text node added within an SVG element', async function ( assert ) { + const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ); + + await attachToFixture( svg ); + + twemoji.calls = 0; + + svg.appendChild( document.createTextNode( EMOJI ) ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 0, 'The SVG element containing the text is not parsed either.' ); +} ); + +QUnit.test( 'parses other additions delivered alongside a fallback', async function ( assert ) { + const nodes = emojiImageParagraph( 'load-failed' ); + + await attachToFixture( nodes.paragraph ); + + const other = emojiFixtureElement( EMOJI ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + /* + * Both changes are made before waiting, so that the observer is given the two records in one + * call. The fallback comes first: recognizing it must not stop the rest of the records being + * looked at, which is the difference between skipping that record and abandoning the callback. + */ + nodes.paragraph.replaceChild( document.createTextNode( EMOJI ), nodes.image ); + document.getElementById( 'qunit-fixture' ).appendChild( other ); + + await afterMutations(); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, other, 'The element added alongside the fallback is still parsed.' ); +} ); + +QUnit.test( 'parses the same replacement when the image was not marked', async function ( assert ) { + const nodes = emojiImageParagraph(); + + await attachToFixture( nodes.paragraph ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + nodes.paragraph.replaceChild( document.createTextNode( EMOJI ), nodes.image ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, nodes.paragraph, 'Without the marker, the element containing the replacement is parsed.' ); +} ); diff --git a/tsconfig.json b/tsconfig.json index c583f18b34ddd..df150871d9311 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "erasableSyntaxOnly": true, "noUnusedLocals": true, "noUnusedParameters": true, + "allowUnreachableCode": false, "skipLibCheck": true, "isolatedModules": true, "allowSyntheticDefaultImports": true, @@ -21,6 +22,8 @@ "types": [ "node", "wp-globals", + "browser-globals", + "wp-emoji", "codemirror/addon/lint/lint", "codemirror/addon/hint/show-hint" ] @@ -29,7 +32,9 @@ "files": [ "src/js/_enqueues/lib/codemirror/htmlhint-kses.js", "src/js/_enqueues/lib/codemirror/javascript-lint.js", + "src/js/_enqueues/lib/emoji-loader.js", "src/js/_enqueues/wp/code-editor.js", + "src/js/_enqueues/wp/emoji.js", "src/js/_enqueues/wp/wp-tooltip.js", "tools/gutenberg/copy.js", "tools/gutenberg/download.js", diff --git a/typings/browser-globals/index.d.ts b/typings/browser-globals/index.d.ts new file mode 100644 index 0000000000000..d79c4e8499f8b --- /dev/null +++ b/typings/browser-globals/index.d.ts @@ -0,0 +1,14 @@ +/** + * Globals which the TypeScript `dom` lib does not declare. + * + * Each is optional, because none of them can be assumed to exist. Code must guard every one of them + * with a `typeof` check, or reach it as a property of `window`, before use. + */ + +/** + * Present only when the script is running inside a Worker. + * + * This is declared by the `webworker` lib, which cannot be added alongside the `dom` lib because the + * two conflict. Code which may run in either context therefore has to declare it. + */ +declare var WorkerGlobalScope: Function | undefined; diff --git a/typings/wp-emoji/index.d.ts b/typings/wp-emoji/index.d.ts new file mode 100644 index 0000000000000..3b59f65361b27 --- /dev/null +++ b/typings/wp-emoji/index.d.ts @@ -0,0 +1,138 @@ +/** + * Types for the emoji settings and for the vendored Twemoji library. + * + * These live here rather than beside either script because two files share them: the emoji loader + * (js/_enqueues/lib/emoji-loader.js) reads the settings and records which emoji the browser + * supports, and wp-emoji (js/_enqueues/wp/emoji.js) then reads both back. + */ + +/** + * Emoji script source URLs. + * + * Either `concatemoji` alone, or `wpemoji` together with `twemoji`, depending on whether the + * concatenated script is in use. + */ +interface WPEmojiSettingsSource { + concatemoji?: string; + twemoji?: string; + wpemoji?: string; +} + +/** + * Which emoji the browser supports. + * + * The individual test results are absent until the support tests have completed. + */ +interface EmojiSupports { + /** Whether the browser passed every test. */ + everything: boolean; + /** Whether the browser passed every test but the flag test. */ + everythingExceptFlag: boolean; + /** Whether the browser renders flag emoji. */ + flag?: boolean; + /** Whether the browser renders emoji. */ + emoji?: boolean; +} + +/** + * Emoji settings as exported in PHP via `_print_emoji_detection_script()`. + */ +interface WPEmojiSettings { + /** + * Base URL for the PNG emoji images. + * + * No longer read by any script, since wp-emoji always uses the SVG images. Still exported, both + * for anything reading the settings itself and because the emoji_url filter behind it also + * feeds wp_staticize_emoji(). + */ + baseUrl: string; + /** File extension for the PNG emoji images. See the note on baseUrl. */ + ext: string; + /** Base URL for the SVG emoji images. */ + svgUrl: string; + /** File extension for the SVG emoji images. */ + svgExt: string; + /** Emoji script source URLs. */ + source?: WPEmojiSettingsSource; + /** + * Which emoji the browser supports. + * + * Not exported from PHP; populated by the emoji loader. + */ + supports: EmojiSupports; +} + +/** + * The emoji settings, as published by the emoji loader for other scripts to read. + */ +declare var _wpemojiSettings: WPEmojiSettings; + +/** + * Options accepted by `twemoji.parse()`. + * + * Taken from the vendored copy in js/_enqueues/vendor/twemoji.js rather than from the library's own + * documentation, since that copy is patched: `doNotParse` is WordPress's own addition and is not + * part of the library upstream. + */ +interface TwemojiParseOptions { + /** Base URL to prepend to each image source. */ + base?: string; + /** File extension to append to each image source. */ + ext?: string; + /** Asset size, squared into a path segment: 72 becomes 72x72. */ + size?: string | number; + /** Path segment to use in place of the size, when the assets are not in a square named folder. */ + folder?: string; + /** Class name to give each generated image. */ + className?: string; + /** Returns the source for an icon, or false to leave the character as it is. */ + callback?: ( icon: string, options: TwemojiResolvedParseOptions ) => string | false; + /** + * Returns the attributes to set on each generated image. + * + * Null is allowed, and is what Twemoji itself falls back to: the result is only ever read with + * `for...in`, which does nothing when given it. + */ + attributes?: ( rawText: string, iconId: string ) => Record< string, string > | null; + /** Runs on the generated image when it fails to load, with the image as `this`. */ + onerror?: ( this: HTMLImageElement ) => void; + /** + * Returns true to leave an element, and everything under it, unparsed. + * + * Twemoji only calls this for element nodes, never for anything within an SVG, and never for + * script, style and the like, so callers do not have to test for any of those. + */ + doNotParse?: ( element: Element ) => boolean; +} + +/** + * The options as Twemoji passes them on to `callback`, once it has filled in its own defaults for + * whatever the caller left out. + */ +interface TwemojiResolvedParseOptions extends TwemojiParseOptions { + base: string; + ext: string; + size: string | number; + className: string; +} + +/** + * The vendored Twemoji library. + * + * Absent until js/_enqueues/vendor/twemoji.js has loaded, so callers must guard with a `typeof` + * check. + * + * Only what WordPress itself uses is declared. The library also exposes `replace()`, `test()`, + * `convert`, and the defaults behind the options above, and `parse()` additionally accepts a + * callback in place of the options object. None of that is described here. + */ +interface Twemoji { + /** Replaces the emoji in an element, in place. */ + parse( node: HTMLElement, options?: TwemojiParseOptions ): HTMLElement; + /** Replaces the emoji in a string and returns the result. */ + parse( text: string, options?: TwemojiParseOptions ): string; + /** Replaces the emoji in whichever of the two was given. */ + parse( nodeOrText: HTMLElement | string, options?: TwemojiParseOptions ): HTMLElement | string; +} + +declare var twemoji: Twemoji | undefined;