diff --git a/blocks/url-input/index.js b/blocks/url-input/index.js index 64eb67c684f7a5..debe3a7e2832ac 100644 --- a/blocks/url-input/index.js +++ b/blocks/url-input/index.js @@ -104,6 +104,12 @@ class UrlInput extends Component { onKeyDown( event ) { const { selectedSuggestion, posts } = this.state; + // If the suggestions are not shown, we shouldn't handle the arrow keys + // We shouldn't preventDefault to allow block arrow keys navigation + if ( ! this.state.showSuggestions || ! this.state.posts.length ) { + return; + } + switch ( event.keyCode ) { case UP: { event.stopPropagation(); diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index 0c56468ea333b8..c26f4149a77eef 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -117,7 +117,7 @@ class VisualEditorBlock extends Component { } // Focus node when focus state is programmatically transferred. - if ( this.props.focus && ! prevProps.focus ) { + if ( this.props.focus && ! prevProps.focus && ! this.node.contains( document.activeElement ) ) { this.node.focus(); } @@ -256,7 +256,6 @@ class VisualEditorBlock extends Component { onKeyDown( event ) { const { keyCode, target } = event; - if ( ENTER === keyCode && target === this.node ) { event.preventDefault(); diff --git a/editor/modes/visual-editor/index.js b/editor/modes/visual-editor/index.js index bd149a77bba4a5..4733a578170616 100644 --- a/editor/modes/visual-editor/index.js +++ b/editor/modes/visual-editor/index.js @@ -17,6 +17,7 @@ import { KeyboardShortcuts } from '@wordpress/components'; import './style.scss'; import VisualEditorBlockList from './block-list'; import PostTitle from '../../post-title'; +import WritingFlow from '../../writing-flow'; import { getBlockUids, getMultiSelectedBlockUids } from '../../selectors'; import { clearSelectedBlock, multiSelect, redo, undo, removeBlocks } from '../../actions'; @@ -98,8 +99,10 @@ class VisualEditor extends Component { backspace: this.deleteSelectedBlocks, del: this.deleteSelectedBlocks, } } /> - - + + + + ); /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ diff --git a/editor/utils/dom.js b/editor/utils/dom.js new file mode 100644 index 00000000000000..f6396752e573de --- /dev/null +++ b/editor/utils/dom.js @@ -0,0 +1,88 @@ +/** + * Check whether the selection touches an edge of the container + * + * @param {Element} container DOM Element + * @param {Boolean} start Reverse means check if it touches the start of the container + * @return {Boolean} Is Edge or not + */ +export function isEdge( container, start = false ) { + if ( [ 'INPUT', 'TEXTAREA' ].indexOf( container.tagName ) !== -1 ) { + if ( container.selectionStart !== container.selectionEnd ) { + return false; + } + + if ( start ) { + return container.selectionStart === 0; + } + + return container.value.length === container.selectionStart; + } + + if ( ! container.isContentEditable ) { + return true; + } + + const selection = window.getSelection(); + const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; + const position = start ? 'start' : 'end'; + const order = start ? 'first' : 'last'; + const offset = range[ `${ position }Offset` ]; + + let node = range.startContainer; + + if ( ! range || ! range.collapsed ) { + return false; + } + + if ( start && offset !== 0 ) { + return false; + } + + if ( ! start && offset !== node.textContent.length ) { + return false; + } + + while ( node !== container ) { + const parentNode = node.parentNode; + + if ( parentNode[ `${ order }Child` ] !== node ) { + return false; + } + + node = parentNode; + } + + return true; +} + +/** + * Places the caret at start or end of a given element + * + * @param {Element} container DOM Element + * @param {Boolean} start Position: Start or end of the element + */ +export function placeCaretAtEdge( container, start = false ) { + const isInputOrTextarea = [ 'INPUT', 'TEXTAREA' ].indexOf( container.tagName ) !== -1; + + // Inputs and Textareas + if ( isInputOrTextarea ) { + container.focus(); + if ( start ) { + container.selectionStart = 0; + container.selectionEnd = 0; + } else { + container.selectionStart = container.value.length; + container.selectionEnd = container.value.length; + } + return; + } + + // Content editables + const range = document.createRange(); + range.selectNodeContents( container ); + range.collapse( start ); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange( range ); + container.focus(); +} diff --git a/editor/utils/test/dom.js b/editor/utils/test/dom.js new file mode 100644 index 00000000000000..eadea699a5a8aa --- /dev/null +++ b/editor/utils/test/dom.js @@ -0,0 +1,94 @@ +/** + * Internal dependencies + */ +import { isEdge, placeCaretAtEdge } from '../dom'; + +describe( 'DOM', () => { + let parent; + + beforeEach( () => { + parent = document.createElement( 'div' ); + document.body.appendChild( parent ); + } ); + + afterEach( () => { + parent.remove(); + } ); + + describe( 'isEdge', () => { + it( 'Should return true for empty input', () => { + const input = document.createElement( 'input' ); + parent.appendChild( input ); + input.focus(); + expect( isEdge( input, true ) ).toBe( true ); + expect( isEdge( input, false ) ).toBe( true ); + } ); + + it( 'Should return the right values if we focus the end of the input', () => { + const input = document.createElement( 'input' ); + parent.appendChild( input ); + input.value = 'value'; + input.focus(); + input.selectionStart = 5; + input.selectionEnd = 5; + expect( isEdge( input, true ) ).toBe( false ); + expect( isEdge( input, false ) ).toBe( true ); + } ); + + it( 'Should return the right values if we focus the start of the input', () => { + const input = document.createElement( 'input' ); + parent.appendChild( input ); + input.value = 'value'; + input.focus(); + input.selectionStart = 0; + input.selectionEnd = 0; + expect( isEdge( input, true ) ).toBe( true ); + expect( isEdge( input, false ) ).toBe( false ); + } ); + + it( 'Should return false if we\'re not at the edge', () => { + const input = document.createElement( 'input' ); + parent.appendChild( input ); + input.value = 'value'; + input.focus(); + input.selectionStart = 3; + input.selectionEnd = 3; + expect( isEdge( input, true ) ).toBe( false ); + expect( isEdge( input, false ) ).toBe( false ); + } ); + + it( 'Should return false if the selection is not collapseds', () => { + const input = document.createElement( 'input' ); + parent.appendChild( input ); + input.value = 'value'; + input.focus(); + input.selectionStart = 0; + input.selectionEnd = 5; + expect( isEdge( input, true ) ).toBe( false ); + expect( isEdge( input, false ) ).toBe( false ); + } ); + + it( 'Should always return true for non content editabless', () => { + const div = document.createElement( 'div' ); + parent.appendChild( div ); + expect( isEdge( div, true ) ).toBe( true ); + expect( isEdge( div, false ) ).toBe( true ); + } ); + } ); + + describe( 'placeCaretAtEdge', () => { + it( 'should place caret at the start of the input', () => { + const input = document.createElement( 'input' ); + input.value = 'value'; + placeCaretAtEdge( input, true ); + expect( isEdge( input, true ) ).toBe( true ); + } ); + + it( 'should place caret at the end of the input', () => { + const input = document.createElement( 'input' ); + input.value = 'value'; + placeCaretAtEdge( input, false ); + expect( isEdge( input, false ) ).toBe( true ); + } ); + } ); +} ); diff --git a/editor/writing-flow/index.js b/editor/writing-flow/index.js new file mode 100644 index 00000000000000..e406a280c72097 --- /dev/null +++ b/editor/writing-flow/index.js @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +import { Component } from 'element'; +import { keycodes } from '@wordpress/utils'; + +/** + * Internal dependencies + */ +import { isEdge, placeCaretAtEdge } from '../utils/dom'; + +/** + * Module Constants + */ +const { UP, DOWN, LEFT, RIGHT } = keycodes; + +class WritingFlow extends Component { + constructor() { + super( ...arguments ); + this.zones = []; + this.onKeyDown = this.onKeyDown.bind( this ); + this.onKeyUp = this.onKeyUp.bind( this ); + this.bindContainer = this.bindContainer.bind( this ); + this.state = { + shouldMove: false, + }; + } + + bindContainer( ref ) { + this.container = ref; + } + + getVisibleTabbables() { + const tabbablesSelector = [ + '*[contenteditable="true"]', + '*[tabindex]:not([tabindex="-1"])', + 'textarea', + 'input', + ].join( ', ' ); + const isVisible = ( elem ) => elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0; + return [ ...this.container.querySelectorAll( tabbablesSelector ) ].filter( isVisible ); + } + + moveFocusInContainer( target, direction = 'UP' ) { + const focusableNodes = this.getVisibleTabbables(); + if ( direction === 'UP' ) { + focusableNodes.reverse(); + } + + const targetNode = focusableNodes + .slice( focusableNodes.indexOf( target ) ) + .reduce( ( result, node ) => { + return result || ( node.contains( target ) ? null : node ); + }, null ); + + if ( targetNode ) { + placeCaretAtEdge( targetNode, direction === 'DOWN' ); + } + } + + onKeyDown( event ) { + const { keyCode, target } = event; + const moveUp = ( keyCode === UP || keyCode === LEFT ); + const moveDown = ( keyCode === DOWN || keyCode === RIGHT ); + + if ( ( moveUp || moveDown ) && isEdge( target, moveUp ) ) { + event.preventDefault(); + this.setState( { shouldMove: true } ); + } + } + + onKeyUp( event ) { + const { keyCode, target } = event; + const moveUp = ( keyCode === UP || keyCode === LEFT ); + if ( this.state.shouldMove ) { + event.preventDefault(); + this.moveFocusInContainer( target, moveUp ? 'UP' : 'DOWN' ); + this.setState( { shouldMove: false } ); + } + } + + render() { + const { children } = this.props; + + return ( +
+ { children } +
+ ); + } +} + +export default WritingFlow;