Editor: Add CodeMirror-powered code editor with syntax highlighting, linting, and auto-completion.
* Code editor is integrated into the Theme/Plugin Editor, Additional CSS in Customizer, and Custom HTML widget. Code editor is not yet integrated into the post editor, and it may not be until accessibility concerns are addressed.
* The CodeMirror component in the Custom HTML widget is integrated in a similar way to TinyMCE being integrated into the Text widget, adopting the same approach for integrating dynamic JavaScript-initialized fields.
* Linting is performed for JS, CSS, HTML, and JSON via JSHint, CSSLint, HTMLHint, and JSONLint respectively. Linting is not yet supported for PHP.
* When user lacks `unfiltered_html` the capability, the Custom HTML widget will report any Kses-invalid elements and attributes as errors via a custom Kses rule for HTMLHint.
* When linting errors are detected, the user will be prevented from saving the code until the errors are fixed, reducing instances of broken websites.
* The placeholder value is removed from Custom CSS in favor of a fleshed-out section description which now auto-expands when the CSS field is empty. See #39892.
* The CodeMirror library is included as `wp.CodeMirror` to prevent conflicts with any existing `CodeMirror` global.
* An `wp.codeEditor.initialize()` API in JS is provided to convert a `textarea` into CodeMirror, with a `wp_enqueue_code_editor()` function in PHP to manage enqueueing the assets and settings needed to edit a given type of code.
* A user preference is added to manage whether or not "syntax highlighting" is enabled. The feature is opt-out, being enabled by default.
* Allowed file extensions in the theme and plugin editors have been updated to include formats which CodeMirror has modes for: `conf`, `css`, `diff`, `patch`, `html`, `htm`, `http`, `js`, `json`, `jsx`, `less`, `md`, `php`, `phtml`, `php3`, `php4`, `php5`, `php7`, `phps`, `scss`, `sass`, `sh`, `bash`, `sql`, `svg`, `xml`, `yml`, `yaml`, `txt`.
Props westonruter, georgestephanis, obenland, melchoyce, pixolin, mizejewski, michelleweber, afercia, grahamarmfield, samikeijonen, rianrietveld, iseulde.
See #38707.
Fixes #12423, #39892.
Built from https://develop.svn.wordpress.org/trunk@41376
git-svn-id: http://core.svn.wordpress.org/trunk@41209 1a063a9b-81f0-0310-95a4-ce76da25c4cd
2017-09-13 02:08:47 -04:00
/* global wp */
/* eslint consistent-this: [ "error", "control" ] */
/* eslint no-magic-numbers: ["error", { "ignore": [0,1,-1] }] */
wp . customHtmlWidgets = ( function ( $ ) {
'use strict' ;
var component = {
idBases : [ 'custom_html' ] ,
codeEditorSettings : { } ,
l10n : {
errorNotice : {
singular : '' ,
plural : ''
}
}
} ;
/ * *
* Text widget control .
*
* @ class CustomHtmlWidgetControl
* @ constructor
* @ abstract
* /
component . CustomHtmlWidgetControl = Backbone . View . extend ( {
/ * *
* View events .
*
* @ type { Object }
* /
events : { } ,
/ * *
* Initialize .
*
* @ param { Object } options - Options .
* @ param { jQuery } options . el - Control field container element .
* @ param { jQuery } options . syncContainer - Container element where fields are synced for the server .
* @ returns { void }
* /
initialize : function initialize ( options ) {
var control = this ;
if ( ! options . el ) {
throw new Error ( 'Missing options.el' ) ;
}
if ( ! options . syncContainer ) {
throw new Error ( 'Missing options.syncContainer' ) ;
}
Backbone . View . prototype . initialize . call ( control , options ) ;
control . syncContainer = options . syncContainer ;
control . widgetIdBase = control . syncContainer . parent ( ) . find ( '.id_base' ) . val ( ) ;
control . widgetNumber = control . syncContainer . parent ( ) . find ( '.widget_number' ) . val ( ) ;
control . customizeSettingId = 'widget_' + control . widgetIdBase + '[' + String ( control . widgetNumber ) + ']' ;
control . $el . addClass ( 'custom-html-widget-fields' ) ;
control . $el . html ( wp . template ( 'widget-custom-html-control-fields' ) ( { codeEditorDisabled : component . codeEditorSettings . disabled } ) ) ;
control . errorNoticeContainer = control . $el . find ( '.code-editor-error-container' ) ;
control . currentErrorAnnotations = [ ] ;
control . saveButton = control . syncContainer . add ( control . syncContainer . parent ( ) . find ( '.widget-control-actions' ) ) . find ( '.widget-control-save, #savewidget' ) ;
control . saveButton . addClass ( 'custom-html-widget-save-button' ) ; // To facilitate style targeting.
control . fields = {
title : control . $el . find ( '.title' ) ,
content : control . $el . find ( '.content' )
} ;
// Sync input fields to hidden sync fields which actually get sent to the server.
_ . each ( control . fields , function ( fieldInput , fieldName ) {
fieldInput . on ( 'input change' , function updateSyncField ( ) {
var syncInput = control . syncContainer . find ( '.sync-input.' + fieldName ) ;
if ( syncInput . val ( ) !== fieldInput . val ( ) ) {
syncInput . val ( fieldInput . val ( ) ) ;
syncInput . trigger ( 'change' ) ;
}
} ) ;
// Note that syncInput cannot be re-used because it will be destroyed with each widget-updated event.
fieldInput . val ( control . syncContainer . find ( '.sync-input.' + fieldName ) . val ( ) ) ;
} ) ;
} ,
/ * *
* Update input fields from the sync fields .
*
* This function is called at the widget - updated and widget - synced events .
* A field will only be updated if it is not currently focused , to avoid
* overwriting content that the user is entering .
*
* @ returns { void }
* /
updateFields : function updateFields ( ) {
var control = this , syncInput ;
if ( ! control . fields . title . is ( document . activeElement ) ) {
syncInput = control . syncContainer . find ( '.sync-input.title' ) ;
control . fields . title . val ( syncInput . val ( ) ) ;
}
/ *
* Prevent updating content when the editor is focused or if there are current error annotations ,
* to prevent the editor ' s contents from getting sanitized as soon as a user removes focus from
* the editor . This is particularly important for users who cannot unfiltered _html .
* /
control . contentUpdateBypassed = control . fields . content . is ( document . activeElement ) || control . editor && control . editor . codemirror . state . focused || 0 !== control . currentErrorAnnotations ;
if ( ! control . contentUpdateBypassed ) {
syncInput = control . syncContainer . find ( '.sync-input.content' ) ;
control . fields . content . val ( syncInput . val ( ) ) . trigger ( 'change' ) ;
}
} ,
/ * *
* Show linting error notice .
*
* @ param { Array } errorAnnotations - Error annotations .
* @ returns { void }
* /
updateErrorNotice : function ( errorAnnotations ) {
var control = this , errorNotice , message = '' , customizeSetting ;
if ( 1 === errorAnnotations . length ) {
message = component . l10n . errorNotice . singular . replace ( '%d' , '1' ) ;
} else if ( errorAnnotations . length > 1 ) {
message = component . l10n . errorNotice . plural . replace ( '%d' , String ( errorAnnotations . length ) ) ;
}
if ( control . fields . content [ 0 ] . setCustomValidity ) {
control . fields . content [ 0 ] . setCustomValidity ( message ) ;
}
if ( wp . customize && wp . customize . has ( control . customizeSettingId ) ) {
customizeSetting = wp . customize ( control . customizeSettingId ) ;
customizeSetting . notifications . remove ( 'htmlhint_error' ) ;
if ( 0 !== errorAnnotations . length ) {
customizeSetting . notifications . add ( 'htmlhint_error' , new wp . customize . Notification ( 'htmlhint_error' , {
message : message ,
type : 'error'
} ) ) ;
}
} else if ( 0 !== errorAnnotations . length ) {
errorNotice = $ ( '<div class="inline notice notice-error notice-alt"></div>' ) ;
errorNotice . append ( $ ( '<p></p>' , {
text : message
} ) ) ;
control . errorNoticeContainer . empty ( ) ;
control . errorNoticeContainer . append ( errorNotice ) ;
control . errorNoticeContainer . slideDown ( 'fast' ) ;
wp . a11y . speak ( message ) ;
} else {
control . errorNoticeContainer . slideUp ( 'fast' ) ;
}
} ,
/ * *
* Initialize editor .
*
* @ returns { void }
* /
initializeEditor : function initializeEditor ( ) {
var control = this , settings ;
if ( component . codeEditorSettings . disabled ) {
return ;
}
settings = _ . extend ( { } , component . codeEditorSettings , {
/ * *
* Handle tabbing to the field before the editor .
*
* @ returns { void }
* /
onTabPrevious : function onTabPrevious ( ) {
control . fields . title . focus ( ) ;
} ,
/ * *
* Handle tabbing to the field after the editor .
*
* @ returns { void }
* /
onTabNext : function onTabNext ( ) {
var tabbables = control . syncContainer . add ( control . syncContainer . parent ( ) . find ( '.widget-position, .widget-control-actions' ) ) . find ( ':tabbable' ) ;
tabbables . first ( ) . focus ( ) ;
} ,
/ * *
* Disable save button and store linting errors for use in updateFields .
*
* @ param { Array } errorAnnotations - Error notifications .
* @ returns { void }
* /
onChangeLintingErrors : function onChangeLintingErrors ( errorAnnotations ) {
control . currentErrorAnnotations = errorAnnotations ;
} ,
/ * *
* Update error notice .
*
* @ param { Array } errorAnnotations - Error annotations .
* @ returns { void }
* /
onUpdateErrorNotice : function onUpdateErrorNotice ( errorAnnotations ) {
2017-10-20 12:39:48 -04:00
control . saveButton . toggleClass ( 'validation-blocked disabled' , errorAnnotations . length ) ;
Editor: Add CodeMirror-powered code editor with syntax highlighting, linting, and auto-completion.
* Code editor is integrated into the Theme/Plugin Editor, Additional CSS in Customizer, and Custom HTML widget. Code editor is not yet integrated into the post editor, and it may not be until accessibility concerns are addressed.
* The CodeMirror component in the Custom HTML widget is integrated in a similar way to TinyMCE being integrated into the Text widget, adopting the same approach for integrating dynamic JavaScript-initialized fields.
* Linting is performed for JS, CSS, HTML, and JSON via JSHint, CSSLint, HTMLHint, and JSONLint respectively. Linting is not yet supported for PHP.
* When user lacks `unfiltered_html` the capability, the Custom HTML widget will report any Kses-invalid elements and attributes as errors via a custom Kses rule for HTMLHint.
* When linting errors are detected, the user will be prevented from saving the code until the errors are fixed, reducing instances of broken websites.
* The placeholder value is removed from Custom CSS in favor of a fleshed-out section description which now auto-expands when the CSS field is empty. See #39892.
* The CodeMirror library is included as `wp.CodeMirror` to prevent conflicts with any existing `CodeMirror` global.
* An `wp.codeEditor.initialize()` API in JS is provided to convert a `textarea` into CodeMirror, with a `wp_enqueue_code_editor()` function in PHP to manage enqueueing the assets and settings needed to edit a given type of code.
* A user preference is added to manage whether or not "syntax highlighting" is enabled. The feature is opt-out, being enabled by default.
* Allowed file extensions in the theme and plugin editors have been updated to include formats which CodeMirror has modes for: `conf`, `css`, `diff`, `patch`, `html`, `htm`, `http`, `js`, `json`, `jsx`, `less`, `md`, `php`, `phtml`, `php3`, `php4`, `php5`, `php7`, `phps`, `scss`, `sass`, `sh`, `bash`, `sql`, `svg`, `xml`, `yml`, `yaml`, `txt`.
Props westonruter, georgestephanis, obenland, melchoyce, pixolin, mizejewski, michelleweber, afercia, grahamarmfield, samikeijonen, rianrietveld, iseulde.
See #38707.
Fixes #12423, #39892.
Built from https://develop.svn.wordpress.org/trunk@41376
git-svn-id: http://core.svn.wordpress.org/trunk@41209 1a063a9b-81f0-0310-95a4-ce76da25c4cd
2017-09-13 02:08:47 -04:00
control . updateErrorNotice ( errorAnnotations ) ;
}
} ) ;
control . editor = wp . codeEditor . initialize ( control . fields . content , settings ) ;
2017-09-24 12:00:46 -04:00
// Improve the editor accessibility.
$ ( control . editor . codemirror . display . lineDiv )
. attr ( {
role : 'textbox' ,
'aria-multiline' : 'true' ,
'aria-labelledby' : control . fields . content [ 0 ] . id + '-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.
$ ( '#' + control . fields . content [ 0 ] . id + '-label' ) . on ( 'click' , function ( ) {
control . editor . codemirror . focus ( ) ;
} ) ;
Editor: Add CodeMirror-powered code editor with syntax highlighting, linting, and auto-completion.
* Code editor is integrated into the Theme/Plugin Editor, Additional CSS in Customizer, and Custom HTML widget. Code editor is not yet integrated into the post editor, and it may not be until accessibility concerns are addressed.
* The CodeMirror component in the Custom HTML widget is integrated in a similar way to TinyMCE being integrated into the Text widget, adopting the same approach for integrating dynamic JavaScript-initialized fields.
* Linting is performed for JS, CSS, HTML, and JSON via JSHint, CSSLint, HTMLHint, and JSONLint respectively. Linting is not yet supported for PHP.
* When user lacks `unfiltered_html` the capability, the Custom HTML widget will report any Kses-invalid elements and attributes as errors via a custom Kses rule for HTMLHint.
* When linting errors are detected, the user will be prevented from saving the code until the errors are fixed, reducing instances of broken websites.
* The placeholder value is removed from Custom CSS in favor of a fleshed-out section description which now auto-expands when the CSS field is empty. See #39892.
* The CodeMirror library is included as `wp.CodeMirror` to prevent conflicts with any existing `CodeMirror` global.
* An `wp.codeEditor.initialize()` API in JS is provided to convert a `textarea` into CodeMirror, with a `wp_enqueue_code_editor()` function in PHP to manage enqueueing the assets and settings needed to edit a given type of code.
* A user preference is added to manage whether or not "syntax highlighting" is enabled. The feature is opt-out, being enabled by default.
* Allowed file extensions in the theme and plugin editors have been updated to include formats which CodeMirror has modes for: `conf`, `css`, `diff`, `patch`, `html`, `htm`, `http`, `js`, `json`, `jsx`, `less`, `md`, `php`, `phtml`, `php3`, `php4`, `php5`, `php7`, `phps`, `scss`, `sass`, `sh`, `bash`, `sql`, `svg`, `xml`, `yml`, `yaml`, `txt`.
Props westonruter, georgestephanis, obenland, melchoyce, pixolin, mizejewski, michelleweber, afercia, grahamarmfield, samikeijonen, rianrietveld, iseulde.
See #38707.
Fixes #12423, #39892.
Built from https://develop.svn.wordpress.org/trunk@41376
git-svn-id: http://core.svn.wordpress.org/trunk@41209 1a063a9b-81f0-0310-95a4-ce76da25c4cd
2017-09-13 02:08:47 -04:00
control . fields . content . on ( 'change' , function ( ) {
if ( this . value !== control . editor . codemirror . getValue ( ) ) {
control . editor . codemirror . setValue ( this . value ) ;
}
} ) ;
control . editor . codemirror . on ( 'change' , function ( ) {
var value = control . editor . codemirror . getValue ( ) ;
if ( value !== control . fields . content . val ( ) ) {
control . fields . content . val ( value ) . trigger ( 'change' ) ;
}
} ) ;
// Make sure the editor gets updated if the content was updated on the server (sanitization) but not updated in the editor since it was focused.
control . editor . codemirror . on ( 'blur' , function ( ) {
if ( control . contentUpdateBypassed ) {
control . syncContainer . find ( '.sync-input.content' ) . trigger ( 'change' ) ;
}
} ) ;
// Prevent hitting Esc from collapsing the widget control.
if ( wp . customize ) {
control . editor . codemirror . on ( 'keydown' , function onKeydown ( codemirror , event ) {
var escKeyCode = 27 ;
if ( escKeyCode === event . keyCode ) {
event . stopPropagation ( ) ;
}
} ) ;
}
}
} ) ;
/ * *
* Mapping of widget ID to instances of CustomHtmlWidgetControl subclasses .
*
* @ type { Object . < string , wp . textWidgets . CustomHtmlWidgetControl > }
* /
component . widgetControls = { } ;
/ * *
* Handle widget being added or initialized for the first time at the widget - added event .
*
* @ param { jQuery . Event } event - Event .
* @ param { jQuery } widgetContainer - Widget container element .
* @ returns { void }
* /
component . handleWidgetAdded = function handleWidgetAdded ( event , widgetContainer ) {
var widgetForm , idBase , widgetControl , widgetId , animatedCheckDelay = 50 , renderWhenAnimationDone , fieldContainer , syncContainer ;
widgetForm = widgetContainer . find ( '> .widget-inside > .form, > .widget-inside > form' ) ; // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.
idBase = widgetForm . find ( '> .id_base' ) . val ( ) ;
if ( - 1 === component . idBases . indexOf ( idBase ) ) {
return ;
}
// Prevent initializing already-added widgets.
widgetId = widgetForm . find ( '.widget-id' ) . val ( ) ;
if ( component . widgetControls [ widgetId ] ) {
return ;
}
/ *
* Create a container element for the widget control fields .
* This is inserted into the DOM immediately before the the . widget - content
* element because the contents of this element are essentially "managed"
* by PHP , where each widget update cause the entire element to be emptied
* and replaced with the rendered output of WP _Widget : : form ( ) which is
* sent back in Ajax request made to save / update the widget instance .
* To prevent a " flash of replaced DOM elements and re - initialized JS
* components " , the JS template is rendered outside of the normal form
* container .
* /
fieldContainer = $ ( '<div></div>' ) ;
syncContainer = widgetContainer . find ( '.widget-content:first' ) ;
syncContainer . before ( fieldContainer ) ;
widgetControl = new component . CustomHtmlWidgetControl ( {
el : fieldContainer ,
syncContainer : syncContainer
} ) ;
component . widgetControls [ widgetId ] = widgetControl ;
/ *
* Render the widget once the widget parent ' s container finishes animating ,
* as the widget - added event fires with a slideDown of the container .
* This ensures that the textarea is visible and the editor can be initialized .
* /
renderWhenAnimationDone = function ( ) {
if ( ! ( wp . customize ? widgetContainer . parent ( ) . hasClass ( 'expanded' ) : widgetContainer . hasClass ( 'open' ) ) ) { // Core merge: The wp.customize condition can be eliminated with this change being in core: https://github.com/xwp/wordpress-develop/pull/247/commits/5322387d
setTimeout ( renderWhenAnimationDone , animatedCheckDelay ) ;
} else {
widgetControl . initializeEditor ( ) ;
}
} ;
renderWhenAnimationDone ( ) ;
} ;
/ * *
* Setup widget in accessibility mode .
*
* @ returns { void }
* /
component . setupAccessibleMode = function setupAccessibleMode ( ) {
var widgetForm , idBase , widgetControl , fieldContainer , syncContainer ;
widgetForm = $ ( '.editwidget > form' ) ;
if ( 0 === widgetForm . length ) {
return ;
}
idBase = widgetForm . find ( '> .widget-control-actions > .id_base' ) . val ( ) ;
if ( - 1 === component . idBases . indexOf ( idBase ) ) {
return ;
}
fieldContainer = $ ( '<div></div>' ) ;
syncContainer = widgetForm . find ( '> .widget-inside' ) ;
syncContainer . before ( fieldContainer ) ;
widgetControl = new component . CustomHtmlWidgetControl ( {
el : fieldContainer ,
syncContainer : syncContainer
} ) ;
widgetControl . initializeEditor ( ) ;
} ;
/ * *
* Sync widget instance data sanitized from server back onto widget model .
*
* This gets called via the 'widget-updated' event when saving a widget from
* the widgets admin screen and also via the 'widget-synced' event when making
* a change to a widget in the customizer .
*
* @ param { jQuery . Event } event - Event .
* @ param { jQuery } widgetContainer - Widget container element .
* @ returns { void }
* /
component . handleWidgetUpdated = function handleWidgetUpdated ( event , widgetContainer ) {
var widgetForm , widgetId , widgetControl , idBase ;
widgetForm = widgetContainer . find ( '> .widget-inside > .form, > .widget-inside > form' ) ;
idBase = widgetForm . find ( '> .id_base' ) . val ( ) ;
if ( - 1 === component . idBases . indexOf ( idBase ) ) {
return ;
}
widgetId = widgetForm . find ( '> .widget-id' ) . val ( ) ;
widgetControl = component . widgetControls [ widgetId ] ;
if ( ! widgetControl ) {
return ;
}
widgetControl . updateFields ( ) ;
} ;
/ * *
* Initialize functionality .
*
* This function exists to prevent the JS file from having to boot itself .
* When WordPress enqueues this script , it should have an inline script
* attached which calls wp . textWidgets . init ( ) .
*
* @ param { object } settings - Options for code editor , exported from PHP .
* @ returns { void }
* /
component . init = function init ( settings ) {
var $document = $ ( document ) ;
_ . extend ( component . codeEditorSettings , settings ) ;
$document . on ( 'widget-added' , component . handleWidgetAdded ) ;
$document . on ( 'widget-synced widget-updated' , component . handleWidgetUpdated ) ;
/ *
* Manually trigger widget - added events for media widgets on the admin
* screen once they are expanded . The widget - added event is not triggered
* for each pre - existing widget on the widgets admin screen like it is
* on the customizer . Likewise , the customizer only triggers widget - added
* when the widget is expanded to just - in - time construct the widget form
* when it is actually going to be displayed . So the following implements
* the same for the widgets admin screen , to invoke the widget - added
* handler when a pre - existing media widget is expanded .
* /
$ ( function initializeExistingWidgetContainers ( ) {
var widgetContainers ;
if ( 'widgets' !== window . pagenow ) {
return ;
}
widgetContainers = $ ( '.widgets-holder-wrap:not(#available-widgets)' ) . find ( 'div.widget' ) ;
widgetContainers . one ( 'click.toggle-widget-expanded' , function toggleWidgetExpanded ( ) {
var widgetContainer = $ ( this ) ;
component . handleWidgetAdded ( new jQuery . Event ( 'widget-added' ) , widgetContainer ) ;
} ) ;
// Accessibility mode.
$ ( window ) . on ( 'load' , function ( ) {
component . setupAccessibleMode ( ) ;
} ) ;
} ) ;
} ;
return component ;
} ) ( jQuery ) ;