diff --git a/Gruntfile.js b/Gruntfile.js index 4bcf906cc1871..79572099ad24d 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -517,6 +517,8 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-admin/js/common.js' ]: [ './src/js/_enqueues/admin/common.js' ], [ WORKING_DIR + 'wp-admin/js/custom-background.js' ]: [ './src/js/_enqueues/admin/custom-background.js' ], [ WORKING_DIR + 'wp-admin/js/custom-header.js' ]: [ './src/js/_enqueues/admin/custom-header.js' ], + [ WORKING_DIR + 'wp-admin/js/teletype.js' ]: [ './src/js/_enqueues/admin/teletype.js' ], + [ WORKING_DIR + 'wp-admin/js/teletype-loader.js' ]: [ './src/js/_enqueues/admin/teletype-loader.js' ], [ WORKING_DIR + 'wp-admin/js/customize-controls.js' ]: [ './src/js/_enqueues/wp/customize/controls.js' ], [ WORKING_DIR + 'wp-admin/js/customize-nav-menus.js' ]: [ './src/js/_enqueues/wp/customize/nav-menus.js' ], [ WORKING_DIR + 'wp-admin/js/customize-widgets.js' ]: [ './src/js/_enqueues/wp/customize/widgets.js' ], diff --git a/src/js/_enqueues/admin/teletype-loader.js b/src/js/_enqueues/admin/teletype-loader.js new file mode 100644 index 0000000000000..f35be84c719bc --- /dev/null +++ b/src/js/_enqueues/admin/teletype-loader.js @@ -0,0 +1,97 @@ +/** + * Listens to the command palette for the teletype. + * + * @output wp-admin/js/teletype-loader.js + */ + +/** + * Watches Enter in the command palette and loads the teletype on a match. + * + * @param {Object} wp The global WordPress JS object. + * @param {Object} settings Settings printed by wp_enqueue_command_palette_assets(). + */ +( function ( wp, settings ) { + 'use strict'; + + if ( ! wp || ! wp.data || ! settings || ! settings.i18n ) { + return; + } + + /** + * Lowercases text and strips its whitespace and punctuation. + * + * @param {string} value Text to normalize. + * @return {string} Normalized text. + */ + function normalize( value ) { + return value.toLowerCase().replace( /[\s!-\/:-@\[-`{-~\u3000-\u303f\uff01-\uff0f]/g, '' ); + } + + var trigger = normalize( settings.i18n.trigger || '' ); + + /** + * Loads the teletype if needed, then runs it. + */ + function play() { + var script; + + if ( wp.teletype ) { + wp.teletype.run( settings.name, settings.i18n ); + return; + } + + script = document.createElement( 'script' ); + script.src = settings.src; + + script.onload = function () { + if ( wp.teletype ) { + wp.teletype.run( settings.name, settings.i18n ); + } + }; + + document.head.appendChild( script ); + } + + /** + * Handles Enter in an open command palette. + * + * @param {KeyboardEvent} event The keydown event. + */ + function onKeydown( event ) { + var store, + input = event.target, + value; + + if ( 'Enter' !== event.key || ! input || ! input.value ) { + return; + } + + store = wp.data.select( 'core/commands' ); + + if ( ! store || ! store.isOpen() ) { + return; + } + + // Leave Enter to the palette whenever one of its results is active. + if ( + 'combobox' !== input.getAttribute( 'role' ) || + input.getAttribute( 'aria-activedescendant' ) + ) { + return; + } + + value = normalize( input.value ); + + if ( ! value || value !== trigger ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + wp.data.dispatch( 'core/commands' ).close(); + play(); + } + + document.addEventListener( 'keydown', onKeydown, true ); +}( window.wp, window.wpTeletype ) ); diff --git a/src/js/_enqueues/admin/teletype.js b/src/js/_enqueues/admin/teletype.js new file mode 100644 index 0000000000000..4286797df5e4f --- /dev/null +++ b/src/js/_enqueues/admin/teletype.js @@ -0,0 +1,372 @@ +/** + * The teletype. See #65907. + * + * @output wp-admin/js/teletype.js + */ + +/** + * Defines wp.teletype, which plays the scene. + * + * @param {Object} wp The global WordPress JS object. + * @param {Object} settings Settings printed by wp_enqueue_command_palette_assets(). + */ +( function ( wp, settings ) { + 'use strict'; + + var TYPE_SPEED = 100, // Milliseconds per character. + LINE_PAUSE = 2000, // Between lines. + START_DELAY = 3000, // Before the first line. + FADE_TIME = 3000, // Cursor fade-in, once the lights go out. + ACT_PAUSE = 4000, // Between the two acts, and before the exit. + RAIN_SIZE = 16, // Glyph size of the falling rain, in pixels. + RAIN_SPEED = 60; // Milliseconds between rain rows. + + // Half-width katakana and digits. + var GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789'; + + var STYLE = [ + '.wp-teletype { --wp-teletype-transition-duration: ' + FADE_TIME / 1000 + 's }', + settings.styles, + ].join( '' ); + + /** + * Runs the falling glyph rain behind the scene. + * + * @param {HTMLElement} parent Element to render into. + * @return {Function} Stops the rain and removes it. + */ + function rain( parent ) { + var canvas = document.createElement( 'canvas' ), + context = canvas.getContext( '2d' ), + columns = [], + width = 0, + height = 0, + frame = null, + last = 0; + + canvas.className = 'rain'; + canvas.setAttribute( 'aria-hidden', 'true' ); + + /** + * Sizes the canvas to its parent and adds or drops columns to fit. + */ + function resize() { + var ratio = window.devicePixelRatio || 1, + count, + i; + + width = parent.clientWidth; + height = parent.clientHeight; + + canvas.width = width * ratio; + canvas.height = height * ratio; + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + + context.setTransform( ratio, 0, 0, ratio, 0, 0 ); + context.font = RAIN_SIZE + 'px courier, monospace'; + + count = Math.ceil( width / RAIN_SIZE ); + + // New columns start at a random height so they do not arrive as a straight line. + for ( i = columns.length; i < count; i++ ) { + columns.push( Math.random() * height ); + } + + columns.length = count; + } + + /** + * Draws one row of glyphs and advances each column. + */ + function draw() { + var i; + + // Painting over the last frame rather than clearing it leaves the trails. + context.fillStyle = 'rgba(0,0,0,.08)'; + context.fillRect( 0, 0, width, height ); + context.fillStyle = '#0f0'; + + for ( i = 0; i < columns.length; i++ ) { + context.fillText( + GLYPHS.charAt( Math.floor( Math.random() * GLYPHS.length ) ), + i * RAIN_SIZE, + columns[ i ] + ); + + if ( columns[ i ] > height && Math.random() > 0.975 ) { + columns[ i ] = 0; + } else { + columns[ i ] += RAIN_SIZE; + } + } + } + + /** + * Draws a row once every RAIN_SPEED milliseconds. + * + * @param {DOMHighResTimeStamp} now Current frame time. + */ + function tick( now ) { + frame = window.requestAnimationFrame( tick ); + + if ( now - last < RAIN_SPEED ) { + return; + } + + last = now; + draw(); + } + + resize(); + parent.appendChild( canvas ); + window.addEventListener( 'resize', resize ); + frame = window.requestAnimationFrame( tick ); + + return function () { + window.cancelAnimationFrame( frame ); + window.removeEventListener( 'resize', resize ); + canvas.remove(); + }; + } + + /** + * Plays the scene. + * + * @param {string} displayName The current user's display name. + * @param {Object} i18n Translated strings. + * @param {string} i18n.label Accessible name of the dialog. + * @param {string} i18n.exit Text of the Exit button. + * @param {string} i18n.rain Description of the rain for screen readers. + * @param {string[]} i18n.actOne Lines typed on the white screen. + * @param {string[]} i18n.actTwo Lines typed on the black screen; %s is the display name. + */ + function run( displayName, i18n ) { + var aborted = false, + timer = null, + stopRain = null, + previousFocus = document.activeElement, + previousOverflow = document.documentElement.style.overflow, + actOneLines = i18n.actOne.concat( [ '2', '1' ] ); + + var style = document.createElement( 'style' ); + style.textContent = STYLE; + + var overlay = document.createElement( 'div' ); + overlay.className = 'wp-teletype'; + overlay.setAttribute( 'role', 'dialog' ); + overlay.setAttribute( 'aria-modal', 'true' ); + overlay.setAttribute( 'aria-label', i18n.label ); + overlay.setAttribute( 'tabindex', '-1' ); + + // Hidden from screen readers, which get whole lines from the live region. + var line = document.createElement( 'p' ); + line.setAttribute( 'aria-hidden', 'true' ); + + var narration = document.createElement( 'div' ); + narration.className = 'narration'; + narration.setAttribute( 'aria-live', 'polite' ); + narration.setAttribute( 'aria-atomic', 'true' ); + + var exit = document.createElement( 'button' ); + exit.type = 'button'; + exit.className = 'exit'; + exit.textContent = i18n.exit; + + var cursor = document.createElement( 'span' ); + cursor.className = 'cursor'; + cursor.textContent = '▌'; + + line.appendChild( cursor ); + overlay.appendChild( line ); + overlay.appendChild( narration ); + overlay.appendChild( exit ); + + /** + * Hands a whole line to the live region. + * + * @param {string} text Line to announce. + */ + function say( text ) { + narration.textContent = text; + } + + /** + * Calls a function after a delay unless the scene has ended. + * + * @param {number} ms Milliseconds to wait. + * @param {Function} next Function to call. + */ + function wait( ms, next ) { + timer = window.setTimeout( function () { + if ( ! aborted ) { + next(); + } + }, ms ); + } + + /** + * Removes the typed text, leaving the cursor. + */ + function clear() { + while ( line.firstChild !== cursor ) { + line.removeChild( line.firstChild ); + } + } + + /** + * Starts a new line ahead of the cursor. + */ + function newline() { + line.insertBefore( document.createElement( 'br' ), cursor ); + } + + /** + * Types one line, a character at a time, ahead of the cursor. + * + * @param {string} text Text to type. + * @param {Function} done Called once the line is typed. + */ + function type( text, done ) { + var chars = text.split( '' ); + + ( function next() { + if ( aborted ) { + return; + } + + if ( ! chars.length ) { + done(); + return; + } + + line.insertBefore( document.createTextNode( chars.shift() ), cursor ); + wait( TYPE_SPEED, next ); + }() ); + } + + /** + * Types act one on the white screen, each line under the last. + * + * @param {number} index Index of the line to type. + */ + function actOne( index ) { + if ( index >= actOneLines.length ) { + lightsOut(); + return; + } + + say( actOneLines[ index ] ); + type( actOneLines[ index ], function () { + newline(); + wait( LINE_PAUSE, function () { + actOne( index + 1 ); + } ); + } ); + } + + /** + * Switches the screen to black, starts the rain, and fades the cursor in. + */ + function lightsOut() { + overlay.className = 'wp-teletype is-dark'; + clear(); + cursor.className = 'cursor is-visible'; + + // The rain is decoration, so it sits out a reduced-motion preference. + if ( ! window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) { + stopRain = rain( overlay ); + } + + // Describe the rain once for screen readers. + say( i18n.rain ); + + wait( ACT_PAUSE, function () { + actTwo( 0 ); + } ); + } + + /** + * Types act two one line at a time, then ends the scene after the last line. + * + * @param {number} index Index of the line to type. + */ + function actTwo( index ) { + var text = i18n.actTwo[ index ].replace( '%s', function () { + return displayName; + } ); + + say( text ); + type( text, function () { + if ( index + 1 >= i18n.actTwo.length ) { + wait( ACT_PAUSE, abort ); + return; + } + + wait( LINE_PAUSE, function () { + clear(); + actTwo( index + 1 ); + } ); + } ); + } + + /** + * Ends the scene and restores the page, its scrolling, and focus. + */ + function abort() { + if ( aborted ) { + return; + } + + aborted = true; + window.clearTimeout( timer ); + + if ( stopRain ) { + stopRain(); + } + + document.removeEventListener( 'keydown', onKeydown ); + document.documentElement.style.overflow = previousOverflow; + overlay.remove(); + style.remove(); + + if ( previousFocus && previousFocus.focus ) { + previousFocus.focus(); + } + } + + /** + * Exits on Escape and keeps Tab on the Exit button. + * + * @param {KeyboardEvent} event The keydown event. + */ + function onKeydown( event ) { + if ( 'Escape' === event.key ) { + abort(); + return; + } + + // The scene has one control, so the trap has one stop. + if ( 'Tab' === event.key ) { + event.preventDefault(); + exit.focus(); + } + } + + document.addEventListener( 'keydown', onKeydown ); + overlay.addEventListener( 'click', function () { + abort(); + } ); + + document.head.appendChild( style ); + document.body.appendChild( overlay ); + document.documentElement.style.overflow = 'hidden'; + overlay.focus(); + + wait( START_DELAY, function () { + actOne( 0 ); + } ); + } + + wp.teletype = { run: run }; +}( window.wp = window.wp || {}, window.wpTeletype ) ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index fc3ebbd305168..bf960b4728b50 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1221,6 +1221,8 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'password-toggle', "/wp-admin/js/password-toggle$suffix.js", array(), false, 1 ); $scripts->set_translations( 'password-toggle' ); + $scripts->add( 'wp-teletype-loader', "/wp-admin/js/teletype-loader$suffix.js", array( 'wp-data', 'wp-core-commands' ), false, 1 ); + $scripts->add( 'application-passwords', "/wp-admin/js/application-passwords$suffix.js", array( 'jquery', 'wp-util', 'wp-api-request', 'wp-date', 'wp-i18n', 'wp-hooks' ), false, 1 ); $scripts->set_translations( 'application-passwords' ); @@ -3957,6 +3959,150 @@ function wp_enqueue_command_palette_assets() { wp_json_encode( $command_palette_settings, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ) ); + + // Styles for the teletype easter egg, injected by teletype.js when it runs. + $teletype_styles = <<<'CSS' + .wp-teletype { + position:fixed; + inset:0; + z-index:2147483647; + margin:0; + padding:2.5em; + font-family:courier,monospace; + font-size:16px; + line-height:1.6; + background:#fff; + color:#000; + overflow:hidden; + transition:background-color .6s linear,color .6s linear + } + .wp-teletype.is-dark { + background:#000;color:#0f0 + } + /* Focused only to move the reading position in, never by tabbing to it. */ + .wp-teletype:focus { + outline:none + } + .wp-teletype p { + position:relative; + z-index:1; + margin:0; + white-space:pre-wrap + } + .wp-teletype .rain { + position:absolute; + inset:0; + opacity:.5 + } + .wp-teletype .narration { + position:absolute; + width:1px; + height:1px; + margin:-1px; + padding:0; + overflow:hidden; + clip-path:inset(50%); + white-space:nowrap; + border:0 + } + .wp-teletype .exit { + position:absolute; + z-index:2; + top:1.5em; + right:1.5em; + padding:.7em 1.8em; + border:0; + border-radius:999px; + background:#2271b1; + color:#fff; + font-family:inherit; + font-size:14px; + line-height:1; + cursor:pointer + } + .wp-teletype .exit:hover { + background:#135e96 + } + .wp-teletype .exit:focus-visible { + outline:2px solid #fff; + outline-offset:2px + } + .wp-teletype .cursor { + opacity:0; + transition:opacity linear; + transition-duration:var(--wp-teletype-transition-duration) /* Set in teletype.js. */ + } + .wp-teletype .cursor.is-visible { + opacity:1; + animation:wp-teletype-blink 1s step-end infinite + } + @keyframes wp-teletype-blink { + 50% { + opacity:0 + } + } + @media (prefers-reduced-motion:reduce) { + .wp-teletype .cursor.is-visible { + animation:none + } + .wp-teletype, + .wp-teletype .cursor { + transition:none + } + } + CSS; + + // Someone has to keep an eye on the terminal. + wp_enqueue_script( 'wp-teletype-loader' ); + wp_add_inline_script( + 'wp-teletype-loader', + sprintf( + 'window.wpTeletype = %s;', + wp_json_encode( + array( + 'src' => add_query_arg( + 'ver', + wp_get_wp_version(), + admin_url( 'js/teletype' . wp_scripts_get_suffix() . '.js' ) + ), + 'name' => wp_get_current_user()->display_name, + 'styles' => str_replace( array( "\n", "\t" ), '', $teletype_styles ), + /* + * The teletype is an easter egg: typing the trigger phrase into the + * command palette plays a short scene. See #65907 for the details. + */ + 'i18n' => array( + /* translators: Easter egg trigger, typed into the command palette. Case, spaces and punctuation are ignored. Feel free to pick a phrase loved by your local community. See https://core.trac.wordpress.org/ticket/65907 */ + 'trigger' => __( 'Code is poetry' ), + /* translators: Accessible name of the easter egg dialog. See https://core.trac.wordpress.org/ticket/65907 */ + 'label' => __( 'A WordPress easter egg. Press Escape to leave.' ), + /* translators: Button that closes the easter egg. See https://core.trac.wordpress.org/ticket/65907 */ + 'exit' => __( 'Exit' ), + /* translators: Read aloud to screen readers when the easter egg screen goes black and Matrix-style code rain starts. See https://core.trac.wordpress.org/ticket/65907 */ + 'rain' => __( 'The screen goes black. Green code rains down it.' ), + 'actOne' => array( + /* translators: Easter egg, typed out like a computer terminal. See https://core.trac.wordpress.org/ticket/65907 */ + __( 'Self-reference detected.' ), + /* translators: Easter egg, typed out like a computer terminal. See https://core.trac.wordpress.org/ticket/65907 */ + __( 'Initiating infinite loop eschewal protocol.' ), + /* translators: Easter egg, typed out like a computer terminal. The 3 starts a countdown that continues 2, 1. See https://core.trac.wordpress.org/ticket/65907 */ + __( 'Running a routine maintenance cycle. Back in... 3' ), + ), + 'actTwo' => array( + /* translators: %s: User display name. Easter egg quoting The Matrix (1999); use the line from your local release of the film if there is one. See https://core.trac.wordpress.org/ticket/65907 */ + __( 'Wake up, %s...' ), + /* translators: Easter egg quoting The Matrix (1999); use the line from your local release of the film if there is one. See https://core.trac.wordpress.org/ticket/65907 */ + __( 'The Matrix has you...' ), + /* translators: Easter egg quoting The Matrix (1999); use the line from your local release of the film if there is one. See https://core.trac.wordpress.org/ticket/65907 */ + __( 'Follow the white rabbit.' ), + ), + ), + ), + JSON_HEX_TAG | JSON_UNESCAPED_SLASHES + ) + ), + 'before' + ); } /**