[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-admin/js/ -> code-editor.js (source)

   1  /**
   2   * @output wp-admin/js/code-editor.js
   3   */
   4  
   5  /* global console */
   6  
   7  if ( 'undefined' === typeof window.wp ) {
   8      /**
   9       * @namespace wp
  10       */
  11      window.wp = {};
  12  }
  13  if ( 'undefined' === typeof window.wp.codeEditor ) {
  14      /**
  15       * @namespace wp.codeEditor
  16       */
  17      window.wp.codeEditor = {};
  18  }
  19  
  20  /**
  21   * @typedef {Object} CodeMirrorState
  22   * @property {boolean} [completionActive] - Whether completion is active.
  23   * @property {boolean} [focused] - Whether the editor is focused.
  24   */
  25  
  26  /**
  27   * @typedef {import('codemirror').EditorFromTextArea & {
  28   *   options: import('codemirror').EditorConfiguration,
  29   *   performLint?: () => void,
  30   *   showHint?: (options: import('codemirror').ShowHintOptions) => void,
  31   *   state: CodeMirrorState
  32   * }} CodeMirrorEditor
  33   */
  34  
  35  /**
  36   * @typedef {Object} LintAnnotation
  37   * @property {string} message - Message.
  38   * @property {'error'|'warning'} severity - Severity.
  39   * @property {import('codemirror').Position} from - From position.
  40   * @property {import('codemirror').Position} to - To position.
  41   */
  42  
  43  /**
  44   * @typedef {Object} CodeMirrorTokenState
  45   * @property {Object} [htmlState] - HTML state.
  46   * @property {string} [htmlState.tagName] - Tag name.
  47   * @property {CodeMirrorTokenState} [curState] - Current state.
  48   */
  49  
  50  /**
  51   * @typedef {import('codemirror').EditorConfiguration & {
  52   *   lint?: boolean | CombinedLintOptions,
  53   *   autoCloseBrackets?: boolean,
  54   *   matchBrackets?: boolean,
  55   *   continueComments?: boolean,
  56   *   styleActiveLine?: boolean
  57   * }} CodeMirrorSettings
  58   */
  59  
  60  /**
  61   * @typedef {Object} CSSLintRules
  62   * @property {boolean} [errors] - Errors.
  63   * @property {boolean} [box-model] - Box model rules.
  64   * @property {boolean} [display-property-grouping] - Display property grouping rules.
  65   * @property {boolean} [duplicate-properties] - Duplicate properties rules.
  66   * @property {boolean} [known-properties] - Known properties rules.
  67   * @property {boolean} [outline-none] - Outline none rules.
  68   */
  69  
  70  /**
  71   * @typedef {Object} JSHintRules
  72   * @property {number} [esversion] - ECMAScript version.
  73   * @property {boolean} [module] - Whether to use modules.
  74   * @property {boolean} [boss] - Whether to allow assignments in control expressions.
  75   * @property {boolean} [curly] - Whether to require curly braces.
  76   * @property {boolean} [eqeqeq] - Whether to require === and !==.
  77   * @property {boolean} [eqnull] - Whether to allow == null.
  78   * @property {boolean} [expr] - Whether to allow expressions.
  79   * @property {boolean} [immed] - Whether to require immediate function invocation.
  80   * @property {boolean} [noarg] - Whether to prohibit arguments.caller/callee.
  81   * @property {boolean} [nonbsp] - Whether to prohibit non-breaking spaces.
  82   * @property {string} [quotmark] - Quote mark preference.
  83   * @property {boolean} [undef] - Whether to prohibit undefined variables.
  84   * @property {boolean} [unused] - Whether to prohibit unused variables.
  85   * @property {boolean} [browser] - Whether to enable browser globals.
  86   * @property {Record<string, boolean>} [globals] - Global variables.
  87   */
  88  
  89  /**
  90   * @typedef {Object} HTMLHintRules
  91   * @property {boolean} [tagname-lowercase] - Tag name lowercase rules.
  92   * @property {boolean} [attr-lowercase] - Attribute lowercase rules.
  93   * @property {boolean} [attr-value-double-quotes] - Attribute value double quotes rules.
  94   * @property {boolean} [doctype-first] - Doctype first rules.
  95   * @property {boolean} [tag-pair] - Tag pair rules.
  96   * @property {boolean} [spec-char-escape] - Spec char escape rules.
  97   * @property {boolean} [id-unique] - ID unique rules.
  98   * @property {boolean} [src-not-empty] - Src not empty rules.
  99   * @property {boolean} [attr-no-duplication] - Attribute no duplication rules.
 100   * @property {boolean} [alt-require] - Alt require rules.
 101   * @property {string} [space-tab-mixed-disabled] - Space tab mixed disabled rules.
 102   * @property {boolean} [attr-unsafe-chars] - Attribute unsafe chars rules.
 103   * @property {JSHintRules} [jshint] - JSHint rules.
 104   * @property {CSSLintRules} [csslint] - CSSLint rules.
 105   */
 106  
 107  /**
 108   * Settings for the code editor.
 109   *
 110   * @typedef {Object} CodeEditorSettings
 111   *
 112   * @property {CodeMirrorSettings} [codemirror] - CodeMirror settings.
 113   * @property {CSSLintRules} [csslint] - CSSLint rules.
 114   * @property {JSHintRules} [jshint] - JSHint rules.
 115   * @property {HTMLHintRules} [htmlhint] - HTMLHint rules.
 116   *
 117   * @property {(codemirror: CodeMirrorEditor, event: KeyboardEvent|JQuery.KeyDownEvent) => void} [onTabNext] - Callback to handle tabbing to the next tabbable element.
 118   * @property {(codemirror: CodeMirrorEditor, event: KeyboardEvent|JQuery.KeyDownEvent) => void} [onTabPrevious] - Callback to handle tabbing to the previous tabbable element.
 119   * @property {(errorAnnotations: LintAnnotation[], annotations: LintAnnotation[], annotationsSorted: LintAnnotation[], cm: CodeMirrorEditor) => void} [onChangeLintingErrors] - Callback for when the linting errors have changed.
 120   * @property {(errorAnnotations: LintAnnotation[], editor: CodeMirrorEditor) => void} [onUpdateErrorNotice] - Callback for when error notice should be displayed.
 121   */
 122  
 123  /**
 124   * @typedef {import('codemirror/addon/lint/lint').LintStateOptions<Record<string, unknown>> & JSHintRules & CSSLintRules & { rules?: HTMLHintRules }} CombinedLintOptions
 125   */
 126  
 127  /**
 128   * @typedef {Object} CodeEditorInstance
 129   * @property {CodeEditorSettings} settings - The code editor settings.
 130   * @property {CodeMirrorEditor} codemirror - The CodeMirror instance.
 131   * @property {() => void} updateErrorNotice - Force update the error notice.
 132   */
 133  
 134  /**
 135   * @typedef {Object} WpCodeEditor
 136   * @property {CodeEditorSettings} defaultSettings - Default settings.
 137   * @property {(textarea: string|JQuery|Element, settings?: CodeEditorSettings) => CodeEditorInstance} initialize - Initialize.
 138   */
 139  
 140  /**
 141   * Handles the Code Editor (CodeMirror) functionality.
 142   *
 143   * @param {JQueryStatic} $  The jQuery object.
 144   * @param {wp}           wp The WordPress global object.
 145   */
 146  ( function( $, wp ) {
 147      'use strict';
 148  
 149      /**
 150       * Default settings for code editor.
 151       *
 152       * @since 4.9.0
 153       * @type {CodeEditorSettings}
 154       */
 155      wp.codeEditor.defaultSettings = {
 156          codemirror: {},
 157          csslint: {},
 158          htmlhint: {},
 159          jshint: {},
 160          onTabNext: function() {},
 161          onTabPrevious: function() {},
 162          onChangeLintingErrors: function() {},
 163          onUpdateErrorNotice: function() {},
 164      };
 165  
 166      /**
 167       * Configures linting.
 168       *
 169       * @param {CodeEditorSettings} settings - Code editor settings.
 170       *
 171       * @return {LintingController} Linting controller.
 172       */
 173  	function configureLinting( settings ) {
 174          /** @type {LintAnnotation[]} */
 175          let currentErrorAnnotations = [];
 176  
 177          /** @type {LintAnnotation[]} */
 178          let previouslyShownErrorAnnotations = [];
 179  
 180          /**
 181           * Call the onUpdateErrorNotice if there are new errors to show.
 182           *
 183           * @param {import('codemirror').Editor} editor - Editor.
 184           * @return {void}
 185           */
 186  		function updateErrorNotice( editor ) {
 187              if ( settings.onUpdateErrorNotice && ! _.isEqual( currentErrorAnnotations, previouslyShownErrorAnnotations ) ) {
 188                  settings.onUpdateErrorNotice( currentErrorAnnotations, /** @type {CodeMirrorEditor} */ ( editor ) );
 189                  previouslyShownErrorAnnotations = currentErrorAnnotations;
 190              }
 191          }
 192  
 193          /**
 194           * Gets the lint options.
 195           *
 196           * @return {CombinedLintOptions|false} Lint options.
 197           */
 198  		function getLintOptions() {
 199              /** @type {CombinedLintOptions | boolean} */
 200              let options = settings.codemirror?.lint ?? false;
 201  
 202              if ( ! options ) {
 203                  return false;
 204              }
 205  
 206              if ( true === options ) {
 207                  options = {};
 208              } else if ( _.isObject( options ) ) {
 209                  options = $.extend( {}, options );
 210              }
 211              const linterOptions = /** @type {CombinedLintOptions} */ ( options );
 212  
 213              // Configure JSHint.
 214              if ( 'javascript' === settings.codemirror?.mode && settings.jshint ) {
 215                  $.extend( linterOptions, settings.jshint );
 216              }
 217  
 218              // Configure CSSLint.
 219              if ( 'css' === settings.codemirror?.mode && settings.csslint ) {
 220                  $.extend( linterOptions, settings.csslint );
 221              }
 222  
 223              // Configure HTMLHint.
 224              if ( 'htmlmixed' === settings.codemirror?.mode && settings.htmlhint ) {
 225                  linterOptions.rules = $.extend( {}, settings.htmlhint );
 226  
 227                  if ( settings.jshint && linterOptions.rules ) {
 228                      linterOptions.rules.jshint = settings.jshint;
 229                  }
 230                  if ( settings.csslint && linterOptions.rules ) {
 231                      linterOptions.rules.csslint = settings.csslint;
 232                  }
 233              }
 234  
 235              // Wrap the onUpdateLinting CodeMirror event to route to onChangeLintingErrors and onUpdateErrorNotice.
 236              linterOptions.onUpdateLinting = (function( onUpdateLintingOverridden ) {
 237                  /**
 238                   * Wraps the onUpdateLinting event to filter errors, detect state changes, and manage error notice visibility.
 239                   *
 240                   * @param {LintAnnotation[]} annotations - Annotations.
 241                   * @param {LintAnnotation[]} annotationsSorted - Sorted annotations.
 242                   * @param {CodeMirrorEditor} cm - Editor.
 243                   */
 244                  return function( annotations, annotationsSorted, cm ) {
 245                      const errorAnnotations = annotations.filter( function( annotation ) {
 246                          return 'error' === annotation.severity;
 247                      } );
 248  
 249                      if ( onUpdateLintingOverridden ) {
 250                          onUpdateLintingOverridden( annotations, annotationsSorted, cm );
 251                      }
 252  
 253                      // Skip if there are no changes to the errors.
 254                      if ( _.isEqual( errorAnnotations, currentErrorAnnotations ) ) {
 255                          return;
 256                      }
 257  
 258                      currentErrorAnnotations = errorAnnotations;
 259  
 260                      if ( settings.onChangeLintingErrors ) {
 261                          settings.onChangeLintingErrors( errorAnnotations, annotations, annotationsSorted, cm );
 262                      }
 263  
 264                      /*
 265                       * Update notifications when the editor is not focused to prevent error message
 266                       * from overwhelming the user during input, unless there are now no errors or there
 267                       * were previously errors shown. In these cases, update immediately so they can know
 268                       * that they fixed the errors.
 269                       */
 270                      if ( ! cm.state.focused || 0 === currentErrorAnnotations.length || previouslyShownErrorAnnotations.length > 0 ) {
 271                          updateErrorNotice( cm );
 272                      }
 273                  };
 274              })( linterOptions.onUpdateLinting );
 275  
 276              return linterOptions;
 277          }
 278  
 279          return {
 280              getLintOptions,
 281              /**
 282               * Initializes the CodeMirror editor.
 283               *
 284               * @param {CodeMirrorEditor} editor - Editor instance.
 285               * @return {void}
 286               */
 287              init: function( editor ) {
 288                  // Keep lint options populated.
 289                  editor.on( 'optionChange', function( _cm, option ) {
 290                      const gutterName = 'CodeMirror-lint-markers';
 291                      if ( 'lint' !== ( /** @type {string} */ ( option ) ) ) {
 292                          return;
 293                      }
 294                      const gutters = ( /** @type {string[]} */ ( editor.getOption( 'gutters' ) ) ) || [];
 295                      const options = editor.getOption( 'lint' );
 296                      if ( true === options ) {
 297                          if ( ! _.contains( gutters, gutterName ) ) {
 298                              editor.setOption( 'gutters', [ gutterName ].concat( gutters ) );
 299                          }
 300                          editor.setOption( 'lint', getLintOptions() ); // Expand to include linting options.
 301                      } else if ( ! options ) {
 302                          editor.setOption( 'gutters', _.without( gutters, gutterName ) );
 303                      }
 304  
 305                      // Force update on error notice to show or hide.
 306                      if ( editor.getOption( 'lint' ) && editor.performLint ) {
 307                          editor.performLint();
 308                      } else {
 309                          currentErrorAnnotations = [];
 310                          updateErrorNotice( editor );
 311                      }
 312                  } );
 313  
 314                  // Update error notice when leaving the editor.
 315                  editor.on( 'blur', updateErrorNotice );
 316  
 317                  // Work around hint selection with mouse causing focus to leave editor.
 318                  editor.on( 'startCompletion', function() {
 319                      editor.off( 'blur', updateErrorNotice );
 320                  } );
 321                  editor.on( 'endCompletion', function() {
 322                      const editorRefocusWait = 500;
 323                      editor.on( 'blur', updateErrorNotice );
 324  
 325                      // Wait for editor to possibly get re-focused after selection.
 326                      _.delay( function() {
 327                          if ( ! editor.state.focused ) {
 328                              updateErrorNotice( editor );
 329                          }
 330                      }, editorRefocusWait );
 331                  } );
 332  
 333                  /*
 334                   * Make sure setting validities are set if the user tries to click Publish
 335                   * while an autocomplete dropdown is still open. The Customizer will block
 336                   * saving when a setting has an error notifications on it. This is only
 337                   * necessary for mouse interactions because keyboards will have already
 338                   * blurred the field and cause onUpdateErrorNotice to have already been
 339                   * called.
 340                   */
 341                  $( document.body ).on( 'mousedown', function( /** @type {JQuery.MouseDownEvent} */ event ) {
 342                      if (
 343                          editor.state.focused &&
 344                          ! editor.getWrapperElement().contains( event.target ) &&
 345                          ! event.target.classList.contains( 'CodeMirror-hint' )
 346                      ) {
 347                          updateErrorNotice( editor );
 348                      }
 349                  } );
 350              },
 351              /**
 352               * @param {CodeMirrorEditor} editor - Editor instance.
 353               * @return {void}
 354               */
 355              updateErrorNotice,
 356          };
 357      }
 358  
 359      /**
 360       * Configure tabbing.
 361       *
 362       * @param {CodeMirrorEditor} codemirror - Editor.
 363       * @param {CodeEditorSettings} settings - Code editor settings.
 364       *
 365       * @return {void}
 366       */
 367  	function configureTabbing( codemirror, settings ) {
 368          const $textarea = $( codemirror.getTextArea() );
 369  
 370          codemirror.on( 'blur', function() {
 371              $textarea.data( 'next-tab-blurs', false );
 372          });
 373          codemirror.on( 'keydown', function onKeydown( _editor, event ) {
 374              // Take note of the ESC keypress so that the next TAB can focus outside the editor.
 375              if ( 'Escape' === event.key ) {
 376                  $textarea.data( 'next-tab-blurs', true );
 377                  return;
 378              }
 379  
 380              // Short-circuit if tab key is not being pressed or the tab key press should move focus.
 381              if ( 'Tab' !== event.key || ! $textarea.data( 'next-tab-blurs' ) ) {
 382                  return;
 383              }
 384  
 385              // Focus on previous or next focusable item.
 386              if ( event.shiftKey && settings.onTabPrevious ) {
 387                  settings.onTabPrevious( codemirror, event );
 388              } else if ( ! event.shiftKey && settings.onTabNext ) {
 389                  settings.onTabNext( codemirror, event );
 390              }
 391  
 392              // Reset tab state.
 393              $textarea.data( 'next-tab-blurs', false );
 394  
 395              // Prevent tab character from being added.
 396              event.preventDefault();
 397          });
 398      }
 399  
 400      /**
 401       * @typedef {Object} LintingController
 402       * @property {() => CombinedLintOptions|false} getLintOptions - Get lint options.
 403       * @property {(editor: CodeMirrorEditor) => void} init - Initialize.
 404       * @property {(editor: import('codemirror').Editor) => void} updateErrorNotice - Update error notice.
 405       */
 406  
 407      /**
 408       * Initialize Code Editor (CodeMirror) for an existing textarea.
 409       *
 410       * @since 4.9.0
 411       *
 412       * @param {string|JQuery<HTMLElement>|HTMLElement} textarea - The HTML id, jQuery object, or DOM Element for the textarea that is used for the editor.
 413       * @param {CodeEditorSettings}    [settings] - Settings to override defaults.
 414       *
 415       * @return {CodeEditorInstance} Instance.
 416       */
 417      wp.codeEditor.initialize = function initialize( textarea, settings ) {
 418          if ( document.readyState === 'loading' ) {
 419              console.warn( 'wp.codeEditor.initialize() ran too early. Invoke this function in a `DOMContentLoaded` event listener.' );
 420          }
 421  
 422          let $textarea;
 423          if ( 'string' === typeof textarea ) {
 424              $textarea = $( '#' + textarea );
 425          } else {
 426              $textarea = $( textarea );
 427          }
 428  
 429          /** @type {CodeEditorSettings} */
 430          const instanceSettings = $.extend( true, {}, wp.codeEditor.defaultSettings, settings );
 431  
 432          const lintingController = configureLinting( instanceSettings );
 433          if ( instanceSettings.codemirror ) {
 434              instanceSettings.codemirror.lint = lintingController.getLintOptions();
 435          }
 436  
 437          const codemirror = /** @type {CodeMirrorEditor} */ ( wp.CodeMirror.fromTextArea( $textarea[0], instanceSettings.codemirror ) );
 438  
 439          lintingController.init( codemirror );
 440  
 441          /** @type {CodeEditorInstance} */
 442          const instance = {
 443              settings: instanceSettings,
 444              codemirror,
 445              updateErrorNotice: function() {
 446                  lintingController.updateErrorNotice( codemirror );
 447              },
 448          };
 449  
 450          if ( codemirror.showHint ) {
 451              codemirror.on( 'inputRead', function( _editor, change ) {
 452                  // Only trigger autocompletion for typed input or IME composition.
 453                  if ( ! change.origin || ( '+input' !== change.origin && ! change.origin.startsWith( '*compose' ) ) ) {
 454                      return;
 455                  }
 456  
 457                  // Only trigger autocompletion for single-character inputs.
 458                  // The text property is an array of strings, one for each line.
 459                  // We check that there is only one line and that line has only one character.
 460                  if ( 1 !== change.text.length || 1 !== change.text[0].length ) {
 461                      return;
 462                  }
 463  
 464                  const char = change.text[0];
 465                  const isAlphaKey = /^[a-zA-Z]$/.test( char );
 466                  if ( codemirror.state.completionActive && isAlphaKey ) {
 467                      return;
 468                  }
 469  
 470                  // Prevent autocompletion in string literals or comments.
 471                  const token = /** @type {import('codemirror').Token & { state: CodeMirrorTokenState }} */ ( codemirror.getTokenAt( codemirror.getCursor() ) );
 472                  if ( 'string' === token.type || 'comment' === token.type ) {
 473                      return;
 474                  }
 475  
 476                  const innerMode = wp.CodeMirror.innerMode( codemirror.getMode(), token.state ).mode.name;
 477                  const doc = codemirror.getDoc();
 478                  const lineBeforeCursor = doc.getLine( doc.getCursor().line ).slice( 0, doc.getCursor().ch );
 479                  let shouldAutocomplete = false;
 480                  if ( 'html' === innerMode || 'xml' === innerMode ) {
 481                      shouldAutocomplete = (
 482                          '<' === char ||
 483                          ( '/' === char && 'tag' === token.type ) ||
 484                          ( isAlphaKey && 'tag' === token.type ) ||
 485                          ( isAlphaKey && 'attribute' === token.type ) ||
 486                          ( '=' === char && !! (
 487                              token.state.htmlState?.tagName ||
 488                              token.state.curState?.htmlState?.tagName
 489                          ) )
 490                      );
 491                  } else if ( 'css' === innerMode ) {
 492                      shouldAutocomplete =
 493                          isAlphaKey ||
 494                          ':' === char ||
 495                          ( ' ' === char && /:\s+$/.test( lineBeforeCursor ) );
 496                  } else if ( 'javascript' === innerMode ) {
 497                      shouldAutocomplete = isAlphaKey || '.' === char;
 498                  } else if ( 'clike' === innerMode && 'php' === codemirror.options.mode ) {
 499                      shouldAutocomplete = isAlphaKey && ( 'keyword' === token.type || 'variable' === token.type );
 500                  }
 501                  if ( shouldAutocomplete ) {
 502                      codemirror.showHint( { completeSingle: false } );
 503                  }
 504              } );
 505          }
 506  
 507          // Facilitate tabbing out of the editor.
 508          configureTabbing( codemirror, instanceSettings );
 509  
 510          return instance;
 511      };
 512  
 513  })( jQuery, window.wp );


Generated : Thu Sep 10 08:20:30 2026 Cross-referenced by PHPXref