diff --git a/README.md b/README.md index e8c31b87ddc8d..2e4b74058cd59 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Prototyping since 1440. This is the development and prototyping hub for the editor focus in core. +Gutenberg is the project name. Conversations and discussions take place in #core-editor in Slack. > The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery. — Matt Mullenweg @@ -10,7 +11,6 @@ This is the development and prototyping hub for the editor focus in core. - What are little blocks made of? - Editor Technical Overview -- Mockups: https://cloudup.com/c9pKpaoDpQ4 ## Prototypes @@ -22,6 +22,36 @@ This is the development and prototyping hub for the editor focus in core. - WP Post grammar parser. ----- +## Mockups -Conversations and discussions take place in #core-editor in Slack. +These mockups are all subject to change and feedback. + +See also, clickable prototype. + +**Formatting** + +![Hover](mockups/Formatting, Hover.png) + +![Click](mockups/Formatting, Click.png) + +**New Blocks** + +![New Blocks](mockups/New Blocks.png) +![Insert Between](mockups/Insert Between.png) +![Insert Between, Keyboard Only](mockups/Insert Between, Keyboard.png) +![Insert](mockups/Insert.png) + +**Type Switcher** + +![Switcher](mockups/Type Switcher.png) + +**Other Blocks** + +![Image](mockups/Image.png) +![Quote](mockups/Quote.png) +![Quote 2](mockups/Quote 2.png) +![Headings](mockups/Headings.png) + +**Mobile** + +![Mobile](mockups/Mobile.png) diff --git a/blocks.js b/blocks.js index 8873400246e0d..e7fe7a19f7788 100644 --- a/blocks.js +++ b/blocks.js @@ -40,50 +40,181 @@ var config = { 'default': [] }, blockCategories: [ - { id: 'frequent', label: 'Frequently Used' }, - { id: 'media', label: 'Media' } + { id: 'common', label: 'Common' }, + { id: 'media', label: 'Media' }, + { id: 'embeds', label: 'Embeds' }, + { id: 'other', label: 'Other' }, + { id: 'layout', label: 'Layout' } ], blocks: [ - { - label: 'Paragraph', - icon: '', - categories: [ 'frequent' ] - }, - { - label: 'Heading', - icon: '', - categories: [ 'frequent' ] - }, - { - label: 'Image', - icon: '', - categories: [ 'frequent' ] - }, - { - label: 'Quote', - icon: '', - categories: [ 'frequent' ] - }, - { - label: 'Gallery', - icon: '', - categories: [ 'media' ] - }, - { - label: 'Unordered List', - icon: '', - categories: [ 'frequent' ] - }, - { - label: 'Ordered List', - icon: '', - categories: [ 'frequent' ] - }, - { - label: 'Embed', - icon: '', - categories: [ 'media' ] - } + { + id: 'paragraph', + label: 'Paragraph', + icon: '', + category: 'common' + }, + { + id: 'heading', + label: 'Heading', + icon: '', + category: 'common' + }, + { + id: 'image', + label: 'Image', + icon: '', + category: 'common' + }, + { + id: 'quote', + label: 'Quote', + icon: '', + category: 'common' + }, + { + id: 'gallery', + label: 'Gallery', + icon: '', + category: 'media' + }, + { + id: 'unordered-list', + label: 'Unordered List', + icon: '', + category: 'common' + }, + { + id: 'ordered-list', + label: 'Ordered List', + icon: '', + category: 'common' + }, + { + id: 'embed', + label: 'Embed', + icon: '', + category: 'embeds' + }, + { + id: 'separator', + label: 'Separator', + icon: '', + category: 'common' + }, + { + id: 'map', + label: 'Map', + icon: '', + category: 'embeds' + }, + { + id: 'google-map', + label: 'Google Map', + icon: '', + category: 'other' + }, + { + id: 'openstreet-map', + label: 'OpenStreet Map', + icon: '', + category: 'other' + }, + { + id: 'tweet', + label: 'Tweet', + icon: '', + category: 'other' + }, + { + id: 'video', + label: 'Video', + icon: '', + category: 'media' + }, + { + id: 'youtube', + label: 'YouTube', + icon: '', + category: 'media' + }, + { + id: 'vimeo', + label: 'Vimeo', + icon: '', + category: 'media' + }, + { + id: 'audio', + label: 'Audio', + icon: '', + category: 'media' + }, + { + id: 'form', + label: 'Form', + icon: '', + category: 'other' + }, + { + id: 'survey', + label: 'Survey', + icon: '', + category: 'other' + }, + { + id: 'toc', + label: 'Table of Contents', + icon: '', + category: 'layout' + }, + { + id: 'wordpress-post', + label: 'WordPress Post', + icon: '', + category: 'other' + }, + { + id: 'facebook-post', + label: 'Facebook Post', + icon: '', + category: 'other' + }, + { + id: 'opengraph-link', + label: 'OpenGraph Link', + icon: '', + category: 'other' + }, + { + id: 'playlist', + label: 'Playlist', + icon: '', + category: 'media' + }, + { + id: 'spotify-playlist', + label: 'Spotify Playlist', + icon: '', + category: 'media' + }, + { + id: 'poet', + label: 'Poet', + icon: '', + category: 'layout' + }, + { + id: 'custom-field', + label: 'Custom Field', + icon: '', + category: 'layout' + }, + { + id: 'gist', + label: 'Gist', + icon: '', + category: 'other' + } ] }; @@ -91,8 +222,7 @@ var editor = queryFirst( '.editor' ); var switcher = queryFirst( '.block-switcher' ); var switcherButtons = query( '.block-switcher .type svg' ); var switcherMenu = queryFirst( '.switch-block__menu' ); -var blockControls = queryFirst( '.block-controls' ); -var inlineControls = queryFirst( '.inline-controls' ); +var dockedControls = queryFirst( '.docked-controls' ); var insertBlockButton = queryFirst( '.insert-block__button' ); var insertBlockMenu = queryFirst( '.insert-block__menu' ); var insertBlockMenuSearchInput = queryFirst( '.insert-block__search' ); @@ -105,8 +235,30 @@ var imageAlignNone = queryFirst( '.block-image__no-align' ); var imageAlignLeft = queryFirst( '.block-image__align-left' ); var imageAlignRight = queryFirst( '.block-image__align-right' ); +// Contants +var KEY_ENTER = 13; +var KEY_ARROW_LEFT = 37; +var KEY_ARROW_UP = 38; +var KEY_ARROW_RIGHT = 39; +var KEY_ARROW_DOWN = 40; + +// Editor Variables var selectedBlock = null; + +// Block Menu Variables +var previouslyFocusedBlock = null; var searchBlockFilter = ''; +var blockMenuOpened = false; +var menuSelectedBlock = null; + +// Helper variables +var orderedBlocks = config.blockCategories.reduce( function( memo, category ) { + var categoryBlocks = config.blocks.filter( function( block ) { + return block.category === category.id; + } ); + + return memo.concat( categoryBlocks ); +}, [] ); var supportedBlockTags = Object.keys( config.tagTypes ) .slice( 0, -1 ) // remove 'default' option @@ -122,12 +274,12 @@ insertBlockButton.addEventListener( 'click', openBlockMenu, false ); insertBlockMenu.addEventListener( 'click', function( event ) { event.stopPropagation(); }, false ); -window.addEventListener( 'mouseup', onSelectText, false ); attachBlockHandlers(); attachControlActions(); attachTypeSwitcherActions(); attachBlockMenuSearch(); +attachKeyboardShortcuts(); /** * Core logic @@ -144,6 +296,14 @@ function getBlocks() { supportedBlockTags.map( query ) ); } +function getFocusedBlock() { + var focusedBlocks = getBlocks().filter( function( block ) { + return block.contains( window.getSelection().anchorNode ); + } ); + + return focusedBlocks.length ? focusedBlocks[ 0 ] : null; +} + function selectBlock( event ) { clearBlocks(); event.stopPropagation(); @@ -168,6 +328,7 @@ function showControls( node ) { switcherButtons.forEach( function( element ) { element.style.display = 'none'; } ); + var blockType = getTagType( node.nodeName ); var switcherQuery = '.type-icon-' + blockType; queryFirst( switcherQuery ).style.display = 'block'; @@ -178,52 +339,48 @@ function showControls( node ) { switcher.style.top = ( position.top + 18 + window.scrollY ) + 'px'; // show/hide block-specific block controls - blockControls.className = 'block-controls'; + dockedControls.className = 'docked-controls'; getTypeKinds( blockType ).forEach( function( kind ) { - blockControls.classList.add( 'is-' + kind ); + dockedControls.classList.add( 'is-' + kind ); } ); - blockControls.style.display = 'block'; + dockedControls.style.display = 'block'; // reposition block-specific block controls - blockControls.style.top = ( position.top - 36 + window.scrollY ) + 'px'; - blockControls.style.maxHeight = 'none'; + updateDockedControlsPosition(); } -function hideControls() { - switcher.style.opacity = 0; - switcherMenu.style.display = 'none'; - blockControls.style.display = 'none'; -} +function updateDockedControlsPosition( newClassName ) { + var isImage = selectedBlock.tagName === 'IMG'; + var className = selectedBlock.className; + var position = selectedBlock.getBoundingClientRect(); + var alignedRight = className.match( /align-right/ ); + var alignedLeft = className.match( /align-left/ ); + var fullBleed = className.match( /full-bleed/ ); + + var topPosition = position.top - 36 + window.scrollY; + var leftPosition = null; + + if ( isImage && alignedRight ) { + leftPosition = position.left; + topPosition = newClassName ? topPosition - 15 : topPosition; + } else if ( isImage && alignedLeft && newClassName ) { + topPosition = topPosition - 15; + } else if ( isImage && className === 'is-selected' && dockedControls.style.left ) { + leftPosition = null; + topPosition = topPosition + 15; + } else if ( fullBleed ) { + leftPosition = ( window.innerWidth / 2 ) - ( dockedControls.clientWidth / 2 ); + } -function hideInlineControls() { - inlineControls.style.display = 'none'; + dockedControls.style.maxHeight = 'none'; + dockedControls.style.top = topPosition + 'px'; + dockedControls.style.left = leftPosition ? leftPosition + 'px' : null; } -// Show popup on text selection -function onSelectText( event ) { - event.stopPropagation(); - var txt = ""; - - if ( window.getSelection ) { - txt = window.getSelection(); - } else if ( document.getSelection ) { - txt = document.getSelection(); - } else if ( document.selection ) { - txt = document.selection.createRange().text; - } - - // Show formatting bar - if ( txt != '' ) { - inlineControls.style.display = 'block'; - var range = txt.getRangeAt(0); - var pos = range.getBoundingClientRect(); - var selectCenter = pos.width / 2; - var controlsCenter = inlineControls.offsetWidth / 2; - inlineControls.style.left = ( pos.left + selectCenter - controlsCenter ) + 'px'; - inlineControls.style.top = ( pos.top - 48 + window.scrollY ) + 'px'; - } else { - inlineControls.style.display = 'none'; - } +function hideControls() { + switcher.style.opacity = 0; + switcherMenu.style.display = 'none'; + dockedControls.style.display = 'none'; } function attachControlActions() { @@ -241,9 +398,11 @@ function attachControlActions() { if ( getter ) { node.addEventListener( 'click', function( event ) { event.stopPropagation(); + var previousOffset = selectedBlock.offsetTop; swapNodes( selectedBlock, getter( selectedBlock ) ); attachBlockHandlers(); reselect(); + window.scrollTo( window.scrollX, window.scrollY + selectedBlock.offsetTop - previousOffset ); }, false ); } } ); @@ -276,12 +435,9 @@ function attachTypeSwitcherActions() { } ); Object.keys( typeToTag ).forEach( function( type ) { - var selector = '.switch-block__block .type-icon-' + type; - var button = queryFirst( selector ); - var label = queryFirst( selector + ' + label' ); - + var iconSelector = '.switch-block__block .type-icon-' + type; + var button = queryFirst( iconSelector ).parentNode; button.addEventListener( 'click', switchBlockType, false ); - label.addEventListener( 'click', switchBlockType, false ); function switchBlockType( event ) { if ( ! selectedBlock ) { @@ -300,7 +456,7 @@ function attachTypeSwitcherActions() { } ); } -function fillBlockMenu() { +function renderBlockMenu() { insertBlockMenuContent.innerHTML = ''; config.blockCategories.forEach( function ( category ) { var node = document.createElement( 'div' ); @@ -314,13 +470,13 @@ function fillBlockMenu() { node.appendChild( nodeBlocks ); var categoryBlocks = config.blocks .filter( function( block ) { - return block.categories.indexOf( category.id ) !== -1 + return block.category === category.id && block.label.toLowerCase().indexOf( searchBlockFilter.toLowerCase() ) !== -1; } ); categoryBlocks .forEach( function( block ) { var node = document.createElement( 'div' ); - node.className = 'insert-block__block'; + node.className = 'insert-block__block block-' + block.id + ( menuSelectedBlock === block ? ' is-active' : '' ); node.innerHTML = block.icon + ' ' + block.label; nodeBlocks.appendChild(node); } ); @@ -329,21 +485,128 @@ function fillBlockMenu() { insertBlockMenuContent.appendChild( node ); } } ); - - var placeholder = document.createElement('div'); - placeholder.className = 'insert-block__separator'; - placeholder.textContent = 'These don\'t work yet.'; - insertBlockMenuContent.appendChild( placeholder ); } function attachBlockMenuSearch() { insertBlockMenuSearchInput.addEventListener( 'keyup', filterBlockMenu, false ); insertBlockMenuSearchInput.addEventListener( 'input', filterBlockMenu, false ); - fillBlockMenu(); + insertBlockMenuContent.addEventListener( 'scroll', handleBlockMenuScroll, false ); + selectBlockInMenu(); + renderBlockMenu(); function filterBlockMenu( event ) { searchBlockFilter = event.target.value; - fillBlockMenu(); + selectBlockInMenu(); + renderBlockMenu(); + } + + function handleBlockMenuScroll( event ) { + if ( insertBlockMenuContent.scrollHeight - insertBlockMenuContent.scrollTop <= insertBlockMenuContent.clientHeight ) { + insertBlockMenuContent.className = 'insert-block__content is-bottom'; + } else { + insertBlockMenuContent.className = 'insert-block__content'; + } + } +} + +/** + * Select a block in the block menu + * @param direction direction from the current position (up/down/left/right) + */ +function selectBlockInMenu( direction ) { + var filteredBlocks = orderedBlocks.filter( function( block ) { + return block.label.toLowerCase().indexOf( searchBlockFilter.toLowerCase() ) !== -1; + } ); + var countBlocksByCategories = filteredBlocks.reduce( function( memo, block ) { + if ( ! memo[ block.category ] ) { + memo[ block.category ] = 0; + } + memo[ block.category ]++; + return memo; + }, {} ); + + var selectedBlockIndex = filteredBlocks.indexOf( menuSelectedBlock ); + selectedBlockIndex = selectedBlockIndex === -1 ? 0 : selectedBlockIndex; + var currentBlock = filteredBlocks[ selectedBlockIndex ]; + var previousBlock = filteredBlocks[ selectedBlockIndex - 1 ]; + var nextBlock = filteredBlocks[ selectedBlockIndex + 1 ]; + var offset = 0; + switch ( direction ) { + case KEY_ARROW_UP: + offset = ( + currentBlock + && filteredBlocks[ selectedBlockIndex - 2 ] + && ( + filteredBlocks[ selectedBlockIndex - 2 ].category === currentBlock.category + || countBlocksByCategories[ previousBlock.category ] % 2 === 0 + ) + ) ? -2 : -1; + break; + case KEY_ARROW_DOWN: + offset = ( + currentBlock + && filteredBlocks[ selectedBlockIndex + 2 ] + && ( + currentBlock.category === filteredBlocks[ selectedBlockIndex + 2 ].category + || filteredBlocks[ selectedBlockIndex + 2 ].category === nextBlock.category + || nextBlock.category === currentBlock.category + ) + ) ? 2 : 1; + break; + case KEY_ARROW_RIGHT: + offset = 1; + break; + case KEY_ARROW_LEFT: + offset = -1; + break; + } + + menuSelectedBlock = filteredBlocks[ selectedBlockIndex + offset ] || menuSelectedBlock; + + // Hack to wait for the rerender before scrolling + setTimeout( function() { + var blockElement = queryFirst( '.insert-block__block.block-' + menuSelectedBlock.id ); + if ( + blockElement && ( + blockElement.offsetTop + blockElement.offsetHeight > insertBlockMenuContent.clientHeight + insertBlockMenuContent.scrollTop + || blockElement.offsetTop < insertBlockMenuContent.scrollTop + ) + ) { + insertBlockMenuContent.scrollTop = blockElement.offsetTop - 23; + } + } ); +} + +function attachKeyboardShortcuts() { + document.addEventListener( 'keypress', handleKeyPress, false ); + document.addEventListener( 'keydown', handleKeyDown, false ); + + function handleKeyPress( event ) { + if ( '/' === String.fromCharCode( event.keyCode ) && ! blockMenuOpened ) { + var focusedBlock = getFocusedBlock(); + if ( document.activeElement !== editor || ( focusedBlock && ! focusedBlock.textContent ) ) { + event.preventDefault(); + openBlockMenu(); + } + } + } + + function handleKeyDown( event ) { + if ( ! blockMenuOpened ) return; + switch ( event.keyCode ) { + case KEY_ENTER: + event.preventDefault(); + hideMenu(); + break; + case KEY_ARROW_DOWN: + case KEY_ARROW_UP: + case KEY_ARROW_LEFT: + case KEY_ARROW_RIGHT: + event.preventDefault(); + selectBlockInMenu( event.keyCode ); + renderBlockMenu(); + break; + } } } @@ -392,15 +655,26 @@ function siblingGetter( direction ) { } function openBlockMenu( event ) { - hideInlineControls(); clearBlocks(); - event.stopPropagation(); + event && event.stopPropagation(); insertBlockMenu.style.display = 'block'; + blockMenuOpened = true; + searchBlockFilter = ''; + insertBlockMenuSearchInput.value = ''; + menuSelectedBlock = false; + previouslyFocusedBlock = getFocusedBlock(); insertBlockMenuSearchInput.focus(); + selectBlockInMenu(); + renderBlockMenu(); } function hideMenu() { + if ( ! blockMenuOpened ) return; insertBlockMenu.style.display = 'none'; + blockMenuOpened = false; + if ( previouslyFocusedBlock ) { + setCaret( previouslyFocusedBlock ); + } } function showSwitcherMenu( event ) { @@ -436,6 +710,16 @@ function setElementState( className, event ) { if ( className ) { selectedBlock.classList.add( className ); } + updateBlockControlsPosition( className ); +} + +function setCaret( element ) { + var range = document.createRange(); + range.setStart( element.childNodes[0] ,0 ); + range.collapse( true ); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange( range ); } function l( data ) { diff --git a/dom-parser/blocks.js b/dom-parser/blocks.js new file mode 100644 index 0000000000000..007ad88f04613 --- /dev/null +++ b/dom-parser/blocks.js @@ -0,0 +1,94 @@ +var RE_BLOCK_OPEN, RE_BLOCK_CLOSE, textarea, editor; + +RE_BLOCK_OPEN = /^\s*wp:(\S+)/; +RE_BLOCK_CLOSE = /^\s*\/wp\s*/; + +function findNodeBlocks( rootNode ) { + var stack = [ rootNode ], + comments = [], + blocks = [], + parent, i, node, match, block, child; + + while ( stack.length ) { + parent = stack.pop(); + + for ( i = 0; i < parent.childNodes.length; i++ ) { + node = parent.childNodes[ i ]; + + if ( Node.COMMENT_NODE !== node.nodeType ) { + stack.push( node ); + } else if ( RE_BLOCK_OPEN.test( node.nodeValue ) ) { + comments.push( node ); + } + } + } + + for ( i = comments.length; --i >= 0; ) { + node = comments[ i ]; + match = node.nodeValue.match( RE_BLOCK_OPEN ); + block = { + type: match[ 1 ], + startNode: node, + children: [] + }; + + child = node; + while ( ( child = child.nextSibling ) ) { + if ( Node.COMMENT_NODE !== child.nodeType ) { + block.children.push( child ); + } else if ( RE_BLOCK_CLOSE.test( child.nodeValue ) ) { + block.endNode = child; + break; + } + } + + blocks.unshift( block ); + } + + return blocks; +} + +function renderBlocks( rootNode ) { + var blocks = findNodeBlocks( rootNode ), + i, block, wrapper, hasInlineChild, child; + + for ( i = 0; i < blocks.length; i++ ) { + block = blocks[ i ]; + + if ( ! block.endNode ) { + continue; + } + + wrapper = document.createElement( 'div' ); + wrapper.className = 'block'; + wrapper.setAttribute( 'data-type', block.type ); + + hasInlineChild = block.children.some( function( node ) { + return ( + Node.ELEMENT_NODE === node.nodeType && + 'inline' === window.getComputedStyle( node ).display + ); + } ); + + if ( hasInlineChild ) { + wrapper.style.display = 'inline-block'; + } + + block.endNode.parentNode.insertBefore( wrapper, block.endNode ); + + while ( ( child = block.children.pop() ) ) { + wrapper.insertBefore( child, wrapper.lastChild ); + } + } +} + +textarea = document.getElementById( 'textarea' ); +editor = document.getElementById( 'editor' ); + +function renderEditor() { + editor.innerHTML = textarea.value; + renderBlocks( editor ); +} + +textarea.addEventListener( 'input', renderEditor ); +renderEditor(); diff --git a/dom-parser/index.html b/dom-parser/index.html new file mode 100644 index 0000000000000..7e86e8f3e4e29 --- /dev/null +++ b/dom-parser/index.html @@ -0,0 +1,29 @@ + +