diff --git a/blocks/library/html/editor.scss b/blocks/library/html/editor.scss index 40409e2c819c4..6a76fd7d37657 100644 --- a/blocks/library/html/editor.scss +++ b/blocks/library/html/editor.scss @@ -1,9 +1,25 @@ -.wp-block-html.blocks-plain-text { - font-family: $editor-html-font; - font-size: $text-editor-font-size; - color: $dark-gray-800; - padding: .8em 1.6em; - overflow-x: auto !important; - border: 1px solid $light-gray-500; - border-radius: 4px; +.gutenberg .wp-block-html { + iframe { + display: block; + + // Disable pointer events so that we can click on the block to select it + pointer-events: none; + } + + .CodeMirror { + border-radius: 4px; + border: 1px solid $light-gray-500; + font-family: $editor-html-font; + font-size: $text-editor-font-size; + height: auto; + } + + .CodeMirror-gutters { + background: $white; + border-right: none; + } + + .CodeMirror-lines { + padding: 8px 8px 8px 0; + } } diff --git a/blocks/library/html/index.js b/blocks/library/html/index.js index 9ed3e582b7294..8ed57744305e3 100644 --- a/blocks/library/html/index.js +++ b/blocks/library/html/index.js @@ -3,14 +3,13 @@ */ import { RawHTML } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { withState } from '@wordpress/components'; +import { withState, SandBox, CodeEditor } from '@wordpress/components'; /** * Internal dependencies */ import './editor.scss'; import BlockControls from '../../block-controls'; -import PlainText from '../../plain-text'; export const name = 'core/html'; @@ -49,35 +48,38 @@ export const settings = { edit: withState( { preview: false, - } )( ( { attributes, setAttributes, setState, isSelected, preview } ) => [ - isSelected && ( - -
- - -
-
- ), - preview ? -
: - setAttributes( { content } ) } - aria-label={ __( 'HTML' ) } - />, - ] ), + } )( ( { attributes, setAttributes, setState, isSelected, toggleSelection, preview } ) => ( + <div className="wp-block-html"> + { isSelected && ( + <BlockControls> + <div className="components-toolbar"> + <button + className={ `components-tab-button ${ ! preview ? 'is-active' : '' }` } + onClick={ () => setState( { preview: false } ) } + > + <span>HTML</span> + </button> + <button + className={ `components-tab-button ${ preview ? 'is-active' : '' }` } + onClick={ () => setState( { preview: true } ) } + > + <span>{ __( 'Preview' ) }</span> + </button> + </div> + </BlockControls> + ) } + { preview ? ( + <SandBox html={ attributes.content } /> + ) : ( + <CodeEditor + value={ attributes.content } + focus={ isSelected } + onFocus={ toggleSelection } + onChange={ content => setAttributes( { content } ) } + /> + ) } + </div> + ) ), save( { attributes } ) { return <RawHTML>{ attributes.content }</RawHTML>; diff --git a/blocks/library/html/test/__snapshots__/index.js.snap b/blocks/library/html/test/__snapshots__/index.js.snap index ab2e253b0e572..465266d93baee 100644 --- a/blocks/library/html/test/__snapshots__/index.js.snap +++ b/blocks/library/html/test/__snapshots__/index.js.snap @@ -1,9 +1,22 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`core/html block edit matches snapshot 1`] = ` -<textarea - aria-label="HTML" - class="blocks-plain-text wp-block-html" - rows="1" -/> +<div + class="wp-block-html" +> + <div + class="components-placeholder" + > + <div + class="components-placeholder__label" + /> + <div + class="components-placeholder__fieldset" + > + <span + class="spinner is-active" + /> + </div> + </div> +</div> `; diff --git a/components/code-editor/README.md b/components/code-editor/README.md new file mode 100644 index 0000000000000..c750c6474b6cd --- /dev/null +++ b/components/code-editor/README.md @@ -0,0 +1,56 @@ +CodeEditor +======= + +CodeEditor is a React component that provides the user with a code editor +that has syntax highlighting and linting. + +The components acts as a drop-in replacement for a <textarea>, and uses the +CodeMirror library that is provided as part of WordPress Core. + +## Usage + +```jsx +import { CodeEditor } from '@wordpress/components'; + +function editCode() { + return ( + <CodeEditor + value={ '<p>This is some <b>HTML</b> code that will have syntax highlighting!</p>' } + onChange={ value => console.log( value ) } + /> + ); +} +``` + +## Props + +The component accepts the following props: + +### value + +The source code to load into the code editor. + +- Type: `string` +- Required: Yes + +### focus + +Whether or not the code editor should be focused. + +- Type: `boolean` +- Required: No + +### onFocus + +The function called when the editor is focused. + +- Type: `Function` +- Required: No + +### onChange + +The function called when the user has modified the source code via the +editor. It is passed the new value as an argument. + +- Type: `Function` +- Required: No diff --git a/components/code-editor/editor.js b/components/code-editor/editor.js new file mode 100644 index 0000000000000..23ebe66a4aba8 --- /dev/null +++ b/components/code-editor/editor.js @@ -0,0 +1,105 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { keycodes } from '@wordpress/utils'; + +/** + * Module constants + */ +const { UP, DOWN } = keycodes; + +class CodeEditor extends Component { + constructor() { + super( ...arguments ); + + this.onFocus = this.onFocus.bind( this ); + this.onBlur = this.onBlur.bind( this ); + this.onCursorActivity = this.onCursorActivity.bind( this ); + this.onKeyHandled = this.onKeyHandled.bind( this ); + } + + componentDidMount() { + const instance = wp.codeEditor.initialize( this.textarea, window._wpGutenbergCodeEditorSettings ); + this.editor = instance.codemirror; + + this.editor.on( 'focus', this.onFocus ); + this.editor.on( 'blur', this.onBlur ); + this.editor.on( 'cursorActivity', this.onCursorActivity ); + this.editor.on( 'keyHandled', this.onKeyHandled ); + + this.updateFocus(); + } + + componentDidUpdate( prevProps ) { + if ( this.props.value !== prevProps.value && this.editor.getValue() !== this.props.value ) { + this.editor.setValue( this.props.value ); + } + + if ( this.props.focus !== prevProps.focus ) { + this.updateFocus(); + } + } + + componentWillUnmount() { + this.editor.on( 'focus', this.onFocus ); + this.editor.off( 'blur', this.onBlur ); + this.editor.off( 'cursorActivity', this.onCursorActivity ); + this.editor.off( 'keyHandled', this.onKeyHandled ); + + this.editor.toTextArea(); + this.editor = null; + } + + onFocus() { + if ( this.props.onFocus ) { + this.props.onFocus(); + } + } + + onBlur( editor ) { + if ( this.props.onChange ) { + this.props.onChange( editor.getValue() ); + } + } + + onCursorActivity( editor ) { + this.lastCursor = editor.getCursor(); + } + + onKeyHandled( editor, name, event ) { + /* + * Pressing UP/DOWN should only move focus to another block if the cursor is + * at the start or end of the editor. + * + * We do this by stopping UP/DOWN from propagating if: + * - We know what the cursor was before this event; AND + * - This event caused the cursor to move + */ + if ( event.keyCode === UP || event.keyCode === DOWN ) { + const areCursorsEqual = ( a, b ) => a.line === b.line && a.ch === b.ch; + if ( this.lastCursor && ! areCursorsEqual( editor.getCursor(), this.lastCursor ) ) { + event.stopImmediatePropagation(); + } + } + } + + updateFocus() { + if ( this.props.focus && ! this.editor.hasFocus() ) { + // Need to wait for the next frame to be painted before we can focus the editor + window.requestAnimationFrame( () => { + this.editor.focus(); + } ); + } + + if ( ! this.props.focus && this.editor.hasFocus() ) { + document.activeElement.blur(); + } + } + + render() { + return <textarea ref={ ref => ( this.textarea = ref ) } value={ this.props.value } />; + } +} + +export default CodeEditor; diff --git a/components/code-editor/index.js b/components/code-editor/index.js new file mode 100644 index 0000000000000..8e9bebe5f5fac --- /dev/null +++ b/components/code-editor/index.js @@ -0,0 +1,95 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import CodeEditor from './editor'; +import Placeholder from '../placeholder'; +import Spinner from '../spinner'; + +function loadScript() { + return new Promise( ( resolve, reject ) => { + const handles = [ 'wp-codemirror', 'code-editor', 'htmlhint', 'csslint', 'jshint' ]; + + // Don't load htmlhint-kses unless we need it + if ( window._wpGutenbergCodeEditorSettings.htmlhint.kses ) { + handles.push( 'htmlhint-kses' ); + } + + const script = document.createElement( 'script' ); + script.src = `/wp-admin/load-scripts.php?load=${ handles.join( ',' ) }`; + script.onload = resolve; + script.onerror = reject; + + document.head.appendChild( script ); + } ); +} + +function loadStyle() { + return new Promise( ( resolve, reject ) => { + const handles = [ 'wp-codemirror', 'code-editor' ]; + + const style = document.createElement( 'link' ); + style.rel = 'stylesheet'; + style.href = `/wp-admin/load-styles.php?load=${ handles.join( ',' ) }`; + style.onload = resolve; + style.onerror = reject; + + document.head.appendChild( style ); + } ); +} + +let hasAlreadyLoadedAssets = false; + +function loadAssets() { + if ( hasAlreadyLoadedAssets ) { + return Promise.resolve(); + } + + return Promise.all( [ loadScript(), loadStyle() ] ).then( () => { + hasAlreadyLoadedAssets = true; + } ); +} + +class LazyCodeEditor extends Component { + constructor() { + super( ...arguments ); + + this.state = { + status: 'pending', + }; + } + + componentDidMount() { + loadAssets().then( + () => { + this.setState( { status: 'success' } ); + }, + () => { + this.setState( { status: 'error' } ); + } + ); + } + + render() { + if ( this.state.status === 'pending' ) { + return ( + <Placeholder> + <Spinner /> + </Placeholder> + ); + } + + if ( this.state.status === 'error' ) { + return <Placeholder>{ __( 'An unknown error occurred.' ) }</Placeholder>; + } + + return <CodeEditor { ...this.props } />; + } +} + +export default LazyCodeEditor; diff --git a/components/code-editor/test/__snapshots__/editor.js.snap b/components/code-editor/test/__snapshots__/editor.js.snap new file mode 100644 index 0000000000000..145b69b94d7d0 --- /dev/null +++ b/components/code-editor/test/__snapshots__/editor.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CodeEditor should render without an error 1`] = ` +<textarea + value="<b>wowee</b>" +/> +`; diff --git a/components/code-editor/test/editor.js b/components/code-editor/test/editor.js new file mode 100644 index 0000000000000..8579eb42b2f4f --- /dev/null +++ b/components/code-editor/test/editor.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { set, noop } from 'lodash'; + +/** + * Internal dependencies + */ +import CodeEditor from '../editor'; + +describe( 'CodeEditor', () => { + it( 'should render without an error', () => { + set( global, 'wp.codeEditor.initialize', () => ( { + codemirror: { + on: noop, + hasFocus: () => false, + }, + } ) ); + + const wrapper = shallow( <CodeEditor value={ '<b>wowee</b>' } /> ); + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/components/index.js b/components/index.js index d56bdf3e1d53f..1f1ed1f3c32e9 100644 --- a/components/index.js +++ b/components/index.js @@ -5,6 +5,7 @@ export { default as BaseControl } from './base-control'; export { default as Button } from './button'; export { default as CheckboxControl } from './checkbox-control'; export { default as ClipboardButton } from './clipboard-button'; +export { default as CodeEditor } from './code-editor'; export { default as Dashicon } from './dashicon'; export { DateTimePicker, DatePicker, TimePicker } from './date-time'; export { default as DropZone } from './drop-zone'; diff --git a/edit-post/assets/stylesheets/_z-index.scss b/edit-post/assets/stylesheets/_z-index.scss index 6b152bb03983e..55caded5d1981 100644 --- a/edit-post/assets/stylesheets/_z-index.scss +++ b/edit-post/assets/stylesheets/_z-index.scss @@ -16,12 +16,12 @@ $z-layers: ( '.editor-inserter__tab.is-active': 1, '.components-panel__header': 1, '.edit-post-meta-boxes-area.is-loading:before': 1, - '.edit-post-meta-boxes-area .spinner': 2, + '.edit-post-meta-boxes-area .spinner': 5, '.editor-block-contextual-toolbar': 21, - '.editor-block-switcher__menu': 2, - '.components-popover__close': 2, - '.editor-block-list__insertion-point': 2, - '.blocks-format-toolbar__link-modal': 3, + '.editor-block-switcher__menu': 5, + '.components-popover__close': 5, + '.editor-block-list__insertion-point': 5, + '.blocks-format-toolbar__link-modal': 6, '.editor-block-mover': 1, '.blocks-gallery-item__inline-menu': 20, '.editor-block-settings-menu__popover': 20, // Below the header diff --git a/lib/client-assets.php b/lib/client-assets.php index 87760b81fe193..31d83b1b2bbe8 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -739,6 +739,32 @@ function gutenberg_enqueue_registered_block_scripts_and_styles() { add_action( 'enqueue_block_assets', 'gutenberg_enqueue_registered_block_scripts_and_styles' ); add_action( 'enqueue_block_editor_assets', 'gutenberg_enqueue_registered_block_scripts_and_styles' ); +/** + * The code editor settings that were last captured by + * gutenberg_capture_code_editor_settings(). + * + * @var array|false + */ +$gutenberg_captured_code_editor_settings = false; + +/** + * When passed to the wp_code_editor_settings filter, this function captures + * the code editor settings given to it and then prevents + * wp_enqueue_code_editor() from enqueuing any assets. + * + * This is a workaround until e.g. code_editor_settings() is added to Core. + * + * @since 2.1.0 + * + * @param array $settings Code editor settings. + * @return false + */ +function gutenberg_capture_code_editor_settings( $settings ) { + global $gutenberg_captured_code_editor_settings; + $gutenberg_captured_code_editor_settings = $settings; + return false; +} + /** * Scripts & Styles. * @@ -835,6 +861,16 @@ function gutenberg_editor_scripts_and_styles( $hook ) { ), $meta_box_url ); wp_localize_script( 'wp-editor', '_wpMetaBoxUrl', $meta_box_url ); + // Populate default code editor settings by short-circuiting wp_enqueue_code_editor. + global $gutenberg_captured_code_editor_settings; + add_filter( 'wp_code_editor_settings', 'gutenberg_capture_code_editor_settings' ); + wp_enqueue_code_editor( array( 'type' => 'text/html' ) ); + remove_filter( 'wp_code_editor_settings', 'gutenberg_capture_code_editor_settings' ); + wp_add_inline_script( 'wp-editor', sprintf( + 'window._wpGutenbergCodeEditorSettings = %s;', + wp_json_encode( $gutenberg_captured_code_editor_settings ) + ) ); + // Initialize the editor. $gutenberg_theme_support = get_theme_support( 'gutenberg' ); $align_wide = get_theme_support( 'align-wide' ); @@ -903,7 +939,6 @@ function gutenberg_editor_scripts_and_styles( $hook ) { /** * Styles */ - wp_enqueue_style( 'wp-edit-post' ); /**