diff --git a/blocks/editable/index.js b/blocks/editable/index.js index af8657d27e8d07..ab5230a1cd1d43 100644 --- a/blocks/editable/index.js +++ b/blocks/editable/index.js @@ -21,6 +21,7 @@ import { BACKSPACE, DELETE, ENTER } from 'utils/keycodes'; import './style.scss'; import FormatToolbar from './format-toolbar'; import TinyMCE from './tinymce'; +import patterns from './patterns'; function createTinyMCEElement( type, props, ...children ) { if ( props[ 'data-mce-bogus' ] === 'all' ) { @@ -81,6 +82,8 @@ export default class Editable extends Component { editor.on( 'selectionChange', this.onSelectionChange ); editor.on( 'PastePostProcess', this.onPastePostProcess ); + patterns.apply( this, [ editor ] ); + if ( this.props.onSetup ) { this.props.onSetup( editor ); } diff --git a/blocks/editable/patterns.js b/blocks/editable/patterns.js new file mode 100644 index 00000000000000..d84dceeab67446 --- /dev/null +++ b/blocks/editable/patterns.js @@ -0,0 +1,241 @@ +/** + * External dependencies + */ +import tinymce from 'tinymce'; +import { find, get, escapeRegExp, partition, drop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { ESCAPE, ENTER, SPACE, BACKSPACE } from 'utils/keycodes'; + +/** + * Internal dependencies + */ +import { getBlockTypes } from '../api/registration'; + +/** + * Browser dependencies + */ +const { setTimeout } = window; + +export default function( editor ) { + const getContent = this.getContent.bind( this ); + const { onReplace } = this.props; + + const VK = tinymce.util.VK; + const settings = editor.settings.wptextpattern || {}; + + const patterns = getBlockTypes().reduce( ( acc, blockType ) => { + const transformsFrom = get( blockType, 'transforms.from', [] ); + const transforms = transformsFrom.filter( ( { type } ) => type === 'pattern' ); + return [ ...acc, ...transforms ]; + }, [] ); + + const [ enterPatterns, spacePatterns ] = partition( + patterns, + ( { regExp } ) => regExp.source.endsWith( '$' ), + ); + + const inlinePatterns = settings.inline || [ + { delimiter: '`', format: 'code' }, + ]; + + let canUndo; + + editor.on( 'selectionchange', function() { + canUndo = null; + } ); + + editor.on( 'keydown', function( event ) { + const { keyCode } = event; + + if ( ( canUndo && keyCode === ESCAPE ) || ( canUndo === 'space' && keyCode === BACKSPACE ) ) { + editor.undoManager.undo(); + event.preventDefault(); + event.stopImmediatePropagation(); + } + + if ( VK.metaKeyPressed( event ) ) { + return; + } + + if ( keyCode === ENTER ) { + enter(); + // Wait for the browser to insert the character. + } else if ( keyCode === SPACE ) { + setTimeout( space ); + } else if ( keyCode > 47 && ! ( keyCode >= 91 && keyCode <= 93 ) ) { + setTimeout( inline ); + } + }, true ); + + function inline() { + const range = editor.selection.getRng(); + const node = range.startContainer; + const carretOffset = range.startOffset; + + // We need a non empty text node with an offset greater than zero. + if ( ! node || node.nodeType !== 3 || ! node.data.length || ! carretOffset ) { + return; + } + + const textBeforeCaret = node.data.slice( 0, carretOffset ); + const charBeforeCaret = node.data.charAt( carretOffset - 1 ); + + const { start, pattern } = inlinePatterns.reduce( ( acc, item ) => { + if ( acc.result ) { + return acc; + } + + if ( charBeforeCaret !== item.delimiter.slice( -1 ) ) { + return acc; + } + + const escapedDelimiter = escapeRegExp( item.delimiter ); + const regExp = new RegExp( '(.*)' + escapedDelimiter + '.+' + escapedDelimiter + '$' ); + const match = textBeforeCaret.match( regExp ); + + if ( ! match ) { + return acc; + } + + const startOffset = match[ 1 ].length; + const endOffset = carretOffset - item.delimiter.length; + const before = textBeforeCaret.charAt( startOffset - 1 ); + const after = textBeforeCaret.charAt( startOffset + item.delimiter.length ); + const delimiterFirstChar = item.delimiter.charAt( 0 ); + + // test*test* => format applied + // test *test* => applied + // test* test* => not applied + if ( startOffset && /\S/.test( before ) ) { + if ( /\s/.test( after ) || before === delimiterFirstChar ) { + return acc; + } + } + + const contentRegEx = new RegExp( '^[\\s' + escapeRegExp( delimiterFirstChar ) + ']+$' ); + const content = textBeforeCaret.slice( startOffset, endOffset ); + + // Do not replace when only whitespace and delimiter characters. + if ( contentRegEx.test( content ) ) { + return acc; + } + + return { + start: startOffset, + pattern: item, + }; + }, {} ); + + if ( ! pattern ) { + return; + } + + const { delimiter, format } = pattern; + const formats = editor.formatter.get( format ); + + if ( ! formats || ! formats[ 0 ].inline ) { + return; + } + + editor.undoManager.add(); + editor.undoManager.transact( () => { + node.insertData( carretOffset, '\uFEFF' ); + + const newNode = node.splitText( start ); + const zero = newNode.splitText( carretOffset - start ); + + newNode.deleteData( 0, delimiter.length ); + newNode.deleteData( newNode.data.length - delimiter.length, delimiter.length ); + + editor.formatter.apply( format, {}, newNode ); + editor.selection.setCursorLocation( zero, 1 ); + + // We need to wait for native events to be triggered. + setTimeout( () => { + canUndo = 'space'; + + editor.once( 'selectionchange', () => { + if ( zero ) { + const zeroOffset = zero.data.indexOf( '\uFEFF' ); + + if ( zeroOffset !== -1 ) { + zero.deleteData( zeroOffset, zeroOffset + 1 ); + } + } + } ); + } ); + } ); + } + + function space() { + if ( ! onReplace ) { + return; + } + + // Merge text nodes. + editor.getBody().normalize(); + + const content = getContent(); + + if ( ! content.length ) { + return; + } + + const firstText = content[ 0 ]; + + const { result, pattern } = spacePatterns.reduce( ( acc, item ) => { + return acc.result ? acc : { + result: item.regExp.exec( firstText ), + pattern: item, + }; + }, {} ); + + if ( ! result ) { + return; + } + + const range = editor.selection.getRng(); + const matchLength = result[ 0 ].length; + const remainingText = firstText.slice( matchLength ); + + // The caret position must be at the end of the match. + if ( range.startOffset !== matchLength ) { + return; + } + + const block = pattern.transform( { + content: [ remainingText, ...drop( content ) ], + match: result, + } ); + + onReplace( [ block ] ); + } + + function enter() { + if ( ! onReplace ) { + return; + } + + // Merge text nodes. + editor.getBody().normalize(); + + const content = getContent(); + + if ( ! content.length ) { + return; + } + + const pattern = find( enterPatterns, ( { regExp } ) => regExp.test( content[ 0 ] ) ); + + if ( ! pattern ) { + return; + } + + const block = pattern.transform( { content } ); + + editor.once( 'keyup', () => onReplace( [ block ] ) ); + } +} diff --git a/blocks/library/code/index.js b/blocks/library/code/index.js index 4d34e6e1b6619a..d64d8d54af01f7 100644 --- a/blocks/library/code/index.js +++ b/blocks/library/code/index.js @@ -12,7 +12,7 @@ import { __ } from 'i18n'; * Internal dependencies */ import './style.scss'; -import { registerBlockType, query } from '../../api'; +import { registerBlockType, query, createBlock } from '../../api'; const { prop } = query; @@ -27,6 +27,16 @@ registerBlockType( 'core/code', { content: prop( 'code', 'textContent' ), }, + transforms: { + from: [ + { + type: 'pattern', + regExp: /^```$/, + transform: () => createBlock( 'core/code' ), + }, + ], + }, + edit( { attributes, setAttributes, className } ) { return ( { + const level = match[ 1 ].length; + + return createBlock( 'core/heading', { + nodeName: `H${ level }`, + content, + } ); + }, + }, ], to: [ { diff --git a/blocks/library/list/index.js b/blocks/library/list/index.js index 73adbef9e8c0b4..74de82c2426e25 100644 --- a/blocks/library/list/index.js +++ b/blocks/library/list/index.js @@ -110,6 +110,26 @@ registerBlockType( 'core/list', { values: children( 'ol,ul' ), }, }, + { + type: 'pattern', + regExp: /^[*-]\s/, + transform: ( { content } ) => { + return createBlock( 'core/list', { + nodeName: 'ul', + values: fromBrDelimitedContent( content ), + } ); + }, + }, + { + type: 'pattern', + regExp: /^1[.)]\s/, + transform: ( { content } ) => { + return createBlock( 'core/list', { + nodeName: 'ol', + values: fromBrDelimitedContent( content ), + } ); + }, + }, ], to: [ { diff --git a/blocks/library/quote/index.js b/blocks/library/quote/index.js index 151126ab2d2d72..03721855d65492 100644 --- a/blocks/library/quote/index.js +++ b/blocks/library/quote/index.js @@ -54,6 +54,15 @@ registerBlockType( 'core/quote', { } ); }, }, + { + type: 'pattern', + regExp: /^>\s/, + transform: ( { content } ) => { + return createBlock( 'core/quote', { + value: content, + } ); + }, + }, ], to: [ { diff --git a/blocks/library/separator/index.js b/blocks/library/separator/index.js index a4bc70249bfadb..51c4fb524c7030 100644 --- a/blocks/library/separator/index.js +++ b/blocks/library/separator/index.js @@ -7,7 +7,7 @@ import { __ } from 'i18n'; * Internal dependencies */ import './block.scss'; -import { registerBlockType } from '../../api'; +import { registerBlockType, createBlock } from '../../api'; registerBlockType( 'core/separator', { title: __( 'Separator' ), @@ -16,6 +16,16 @@ registerBlockType( 'core/separator', { category: 'layout', + transforms: { + from: [ + { + type: 'pattern', + regExp: /^-{3,}$/, + transform: () => createBlock( 'core/separator' ), + }, + ], + }, + edit( { className } ) { return
; }, diff --git a/blocks/library/text/index.js b/blocks/library/text/index.js index ba1832d5e25403..14b91331d218fd 100644 --- a/blocks/library/text/index.js +++ b/blocks/library/text/index.js @@ -53,7 +53,7 @@ registerBlockType( 'core/text', { }; }, - edit( { attributes, setAttributes, insertBlocksAfter, focus, setFocus, mergeBlocks } ) { + edit( { attributes, setAttributes, insertBlocksAfter, focus, setFocus, mergeBlocks, onReplace } ) { const { align, content, dropCap, placeholder } = attributes; const toggleDropCap = () => setAttributes( { dropCap: ! dropCap } ); return [ @@ -99,6 +99,7 @@ registerBlockType( 'core/text', { ] ); } } onMerge={ mergeBlocks } + onReplace={ onReplace } style={ { textAlign: align } } className={ dropCap && 'has-drop-cap' } placeholder={ placeholder || __( 'New Paragraph' ) } diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index cf2030cf2f2641..673a60173d1b1e 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -32,6 +32,7 @@ import { clearSelectedBlock, startTyping, stopTyping, + replaceBlocks, } from '../../actions'; import { getPreviousBlock, @@ -335,7 +336,7 @@ class VisualEditorBlock extends Component { 'is-showing-mobile-controls': showMobileControls, } ); - const { onMouseLeave, onFocus, onInsertBlocksAfter } = this.props; + const { onMouseLeave, onFocus, onInsertBlocksAfter, onReplace } = this.props; // Determine whether the block has props to apply to the wrapper. let wrapperProps; @@ -408,6 +409,7 @@ class VisualEditorBlock extends Component { attributes={ block.attributes } setAttributes={ this.setAttributes } insertBlocksAfter={ onInsertBlocksAfter } + onReplace={ onReplace } setFocus={ partial( onFocus, block.uid ) } mergeBlocks={ this.mergeBlocks } className={ className } @@ -497,5 +499,9 @@ export default connect( onMerge( ...args ) { dispatch( mergeBlocks( ...args ) ); }, + + onReplace( blocks ) { + dispatch( replaceBlocks( [ ownProps.uid ], blocks ) ); + }, } ) )( VisualEditorBlock ); diff --git a/utils/keycodes.js b/utils/keycodes.js index f7f3b6cfe2db86..fdafef005fd969 100644 --- a/utils/keycodes.js +++ b/utils/keycodes.js @@ -2,6 +2,7 @@ export const BACKSPACE = 8; export const TAB = 9; export const ENTER = 13; export const ESCAPE = 27; +export const SPACE = 32; export const LEFT = 37; export const UP = 38; export const RIGHT = 39;