* @output wp-admin/js/theme-plugin-editor.js
/* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 1] }] */
wp.themePluginEditor = (function( $ ) {
var component, TreeLinks,
__ = wp.i18n.__, _n = wp.i18n._n, sprintf = wp.i18n.sprintf;
* @param {jQuery} form - Form element.
* @param {Object} settings - Settings.
* @param {Object|boolean} settings.codeEditor - Code editor settings (or `false` if syntax highlighting is disabled).
component.init = function init( form, settings ) {
$.extend( component, settings );
component.noticeTemplate = wp.template( 'wp-file-editor-notice' );
component.noticesContainer = component.form.find( '.editor-notices' );
component.submitButton = component.form.find( ':input[name=submit]' );
component.spinner = component.form.find( '.submit .spinner' );
component.form.on( 'submit', component.submit );
component.textarea = component.form.find( '#newcontent' );
component.textarea.on( 'change', component.onChange );
component.warning = $( '.file-editor-warning' );
component.docsLookUpButton = component.form.find( '#docs-lookup' );
component.docsLookUpList = component.form.find( '#docs-list' );
if ( component.warning.length > 0 ) {
if ( false !== component.codeEditor ) {
* Defer adding notices until after DOM ready as workaround for WP Admin injecting
* its own managed dismiss buttons and also to prevent the editor from showing a notice
* when the file had linting errors to begin with.
component.initCodeEditor();
$( component.initFileBrowser );
$( window ).on( 'beforeunload', function() {
return __( 'The changes you made will be lost if you navigate away from this page.' );
component.docsLookUpList.on( 'change', function() {
var option = $( this ).val();
component.docsLookUpButton.prop( 'disabled', true );
component.docsLookUpButton.prop( 'disabled', false );
* Set up and display the warning modal.
component.showWarning = function() {
// Get the text within the modal.
var rawMessage = component.warning.find( '.file-editor-warning-message' ).text();
// Hide all the #wpwrap content from assistive technologies.
$( '#wpwrap' ).attr( 'aria-hidden', 'true' );
// Detach the warning modal from its position and append it to the body.
.addClass( 'modal-open' )
.append( component.warning.detach() );
// Reveal the modal and set focus on the go back button.
.find( '.file-editor-warning-go-back' ).trigger( 'focus' );
// Get the links and buttons within the modal.
component.warningTabbables = component.warning.find( 'a, button' );
// Attach event handlers.
component.warningTabbables.on( 'keydown', component.constrainTabbing );
component.warning.on( 'click', '.file-editor-warning-dismiss', component.dismissWarning );
// Make screen readers announce the warning message after a short delay (necessary for some screen readers).
wp.a11y.speak( wp.sanitize.stripTags( rawMessage.replace( /\s+/g, ' ' ) ), 'assertive' );
* Constrain tabbing within the warning modal.
* @param {Object} event jQuery event object.
component.constrainTabbing = function( event ) {
var firstTabbable, lastTabbable;
if ( 9 !== event.which ) {
firstTabbable = component.warningTabbables.first()[0];
lastTabbable = component.warningTabbables.last()[0];
if ( lastTabbable === event.target && ! event.shiftKey ) {
} else if ( firstTabbable === event.target && event.shiftKey ) {
* Dismiss the warning modal.
component.dismissWarning = function() {
wp.ajax.post( 'dismiss-wp-pointer', {
pointer: component.themeOrPlugin + '_editor_notice'
component.warning.remove();
$( '#wpwrap' ).removeAttr( 'aria-hidden' );
$( 'body' ).removeClass( 'modal-open' );
* Callback for when a change happens.
component.onChange = function() {
component.removeNotice( 'file_saved' );
* @param {jQuery.Event} event - Event.
component.submit = function( event ) {
event.preventDefault(); // Prevent form submission in favor of Ajax below.
$.each( component.form.serializeArray(), function() {
data[ this.name ] = this.value;
// Use value from codemirror if present.
if ( component.instance ) {
data.newcontent = component.instance.codemirror.getValue();
if ( component.isSaving ) {
// Scroll ot the line that has the error.
if ( component.lintErrors.length ) {
component.instance.codemirror.setCursor( component.lintErrors[0].from.line );
component.isSaving = true;
component.textarea.prop( 'readonly', true );
if ( component.instance ) {
component.instance.codemirror.setOption( 'readOnly', true );
component.spinner.addClass( 'is-active' );
request = wp.ajax.post( 'edit-theme-plugin-file', data );
// Remove previous save notice before saving.
if ( component.lastSaveNoticeCode ) {
component.removeNotice( component.lastSaveNoticeCode );
request.done( function( response ) {
component.lastSaveNoticeCode = 'file_saved';
code: component.lastSaveNoticeCode,
message: response.message,
request.fail( function( response ) {
message: __( 'Something went wrong. Your change may not have been saved. Please try again. There is also a chance that you may need to manually fix and upload the file over FTP.' )
component.lastSaveNoticeCode = notice.code;
component.addNotice( notice );
request.always( function() {
component.spinner.removeClass( 'is-active' );
component.isSaving = false;
component.textarea.prop( 'readonly', false );
if ( component.instance ) {
component.instance.codemirror.setOption( 'readOnly', false );
* @param {Object} notice - Notice.
* @param {string} notice.code - Code.
* @param {string} notice.type - Type.
* @param {string} notice.message - Message.
* @param {boolean} [notice.dismissible=false] - Dismissible.
* @param {Function} [notice.onDismiss] - Callback for when a user dismisses the notice.
* @return {jQuery} Notice element.
component.addNotice = function( notice ) {
throw new Error( 'Missing code.' );
// Only let one notice of a given type be displayed at a time.
component.removeNotice( notice.code );
noticeElement = $( component.noticeTemplate( notice ) );
noticeElement.find( '.notice-dismiss' ).on( 'click', function() {
component.removeNotice( notice.code );
if ( notice.onDismiss ) {
notice.onDismiss( notice );
wp.a11y.speak( notice.message );
component.noticesContainer.append( noticeElement );
noticeElement.slideDown( 'fast' );
component.noticeElements[ notice.code ] = noticeElement;
* @param {string} code - Notice code.
* @return {boolean} Whether a notice was removed.
component.removeNotice = function( code ) {
if ( component.noticeElements[ code ] ) {
component.noticeElements[ code ].slideUp( 'fast', function() {
delete component.noticeElements[ code ];
* Initialize code editor.
component.initCodeEditor = function initCodeEditor() {
var codeEditorSettings, editor;
codeEditorSettings = $.extend( {}, component.codeEditor );
* Handle tabbing to the field before the editor.
codeEditorSettings.onTabPrevious = function() {
$( '#templateside' ).find( ':tabbable' ).last().focus();
* Handle tabbing to the field after the editor.
codeEditorSettings.onTabNext = function() {
$( '#template' ).find( ':tabbable:not(.CodeMirror-code)' ).first().focus();
* Handle change to the linting errors.
* @param {Array} errors - List of linting errors.
codeEditorSettings.onChangeLintingErrors = function( errors ) {
component.lintErrors = errors;
// Only disable the button in onUpdateErrorNotice when there are errors so users can still feel they can click the button.
if ( 0 === errors.length ) {
component.submitButton.toggleClass( 'disabled', false );
* @param {Array} errorAnnotations - Error annotations.
codeEditorSettings.onUpdateErrorNotice = function onUpdateErrorNotice( errorAnnotations ) {
component.submitButton.toggleClass( 'disabled', errorAnnotations.length > 0 );
if ( 0 !== errorAnnotations.length ) {
noticeElement = component.addNotice({
/* translators: %s: Error count. */
'There is %s error which must be fixed before you can update this file.',
'There are %s errors which must be fixed before you can update this file.',
String( errorAnnotations.length )
noticeElement.find( 'input[type=checkbox]' ).on( 'click', function() {
codeEditorSettings.onChangeLintingErrors( [] );
component.removeNotice( 'lint_errors' );
component.removeNotice( 'lint_errors' );
editor = wp.codeEditor.initialize( $( '#newcontent' ), codeEditorSettings );
editor.codemirror.on( 'change', component.onChange );
// Improve the editor accessibility.
$( editor.codemirror.display.lineDiv )
'aria-multiline': 'true',
'aria-labelledby': 'theme-plugin-editor-label',
'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4'
// Focus the editor when clicking on its label.
$( '#theme-plugin-editor-label' ).on( 'click', function() {
editor.codemirror.focus();
component.instance = editor;
* Initialization of the file browser's folder states.
component.initFileBrowser = function initFileBrowser() {
var $templateside = $( '#templateside' );
$templateside.find( '[role="group"]' ).parent().attr( 'aria-expanded', false );
// Expand ancestors to the current file.
$templateside.find( '.notice' ).parents( '[aria-expanded]' ).attr( 'aria-expanded', true );
// Find Tree elements and enhance them.
$templateside.find( '[role="tree"]' ).each( function() {
var treeLinks = new TreeLinks( this );
// Scroll the current file into view.
$templateside.find( '.current-file:first' ).each( function() {
if ( this.scrollIntoViewIfNeeded ) {
this.scrollIntoViewIfNeeded();
this.scrollIntoView( false );
/* jshint ignore:start */
* Creates a new TreeitemLink.
* @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/examples/treeview/treeview-2/treeview-2b.html|W3C Treeview Example}
var TreeitemLink = (function () {
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
* Desc: Treeitem widget that implements ARIA Authoring Practices
* for a tree being used as a file viewer
* Author: Jon Gunderson, Ku Ja Eun and Nicholas Hoyt
* Treeitem object for representing the state and user interactions for a
* An element with the role=tree attribute
var TreeitemLink = function (node, treeObj, group) {
// Check whether node is a DOM element.
if (typeof node !== 'object') {
this.groupTreeitem = group;
this.label = node.textContent.trim();
this.stopDefaultClick = false;