diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 1d230d45fc3a23..6c2fc3bbac7770 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -420,6 +420,12 @@ _Returns_ - `Array`: converted rules. +# **Typewriter** + +Ensures that the text selection keeps the same vertical distance from the +viewport during keyboard events within this component. The vertical distance +can vary. It is the last clicked or scrolled to position. + # **URLInput** _Related_ diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index 623840c3492ee3..e6fa2d7b8cb605 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -263,6 +263,7 @@ export default compose( [ getMultiSelectedBlockClientIds, hasMultiSelection, getGlobalBlockCount, + isTyping, } = select( 'core/block-editor' ); const { rootClientId } = ownProps; @@ -276,7 +277,10 @@ export default compose( [ selectedBlockClientId: getSelectedBlockClientId(), multiSelectedBlockClientIds: getMultiSelectedBlockClientIds(), hasMultiSelection: hasMultiSelection(), - enableAnimation: getGlobalBlockCount() <= BLOCK_ANIMATION_THRESHOLD, + enableAnimation: ( + ! isTyping() && + getGlobalBlockCount() <= BLOCK_ANIMATION_THRESHOLD + ), }; } ), withDispatch( ( dispatch ) => { diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 9ad37a8929b433..729fe3fd222295 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -59,6 +59,7 @@ export { default as NavigableToolbar } from './navigable-toolbar'; export { default as ObserveTyping } from './observe-typing'; export { default as PreserveScrollInReorder } from './preserve-scroll-in-reorder'; export { default as SkipToSelectedBlock } from './skip-to-selected-block'; +export { default as Typewriter } from './typewriter'; export { default as Warning } from './warning'; export { default as WritingFlow } from './writing-flow'; diff --git a/packages/block-editor/src/components/typewriter/index.js b/packages/block-editor/src/components/typewriter/index.js new file mode 100644 index 00000000000000..cb17e38e549200 --- /dev/null +++ b/packages/block-editor/src/components/typewriter/index.js @@ -0,0 +1,254 @@ +/** + * WordPress dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import { computeCaretRect, getScrollContainer } from '@wordpress/dom'; +import { withSelect } from '@wordpress/data'; +import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; + +const isIE = window.navigator.userAgent.indexOf( 'Trident' ) !== -1; +const arrowKeyCodes = new Set( [ UP, DOWN, LEFT, RIGHT ] ); +const initialTriggerPercentage = 0.75; + +class Typewriter extends Component { + constructor() { + super( ...arguments ); + + this.ref = createRef(); + this.onKeyDown = this.onKeyDown.bind( this ); + this.addSelectionChangeListener = this.addSelectionChangeListener.bind( this ); + this.computeCaretRectOnSelectionChange = this.computeCaretRectOnSelectionChange.bind( this ); + this.maintainCaretPosition = this.maintainCaretPosition.bind( this ); + this.computeCaretRect = this.computeCaretRect.bind( this ); + this.onScrollResize = this.onScrollResize.bind( this ); + this.isSelectionEligibleForScroll = this.isSelectionEligibleForScroll.bind( this ); + } + + componentDidMount() { + // When the user scrolls or resizes, the scroll position should be + // reset. + window.addEventListener( 'scroll', this.onScrollResize, true ); + window.addEventListener( 'resize', this.onScrollResize, true ); + } + + componentWillUnmount() { + window.removeEventListener( 'scroll', this.onScrollResize, true ); + window.removeEventListener( 'resize', this.onScrollResize, true ); + document.removeEventListener( 'selectionchange', this.computeCaretRectOnSelectionChange ); + + if ( this.onScrollResize.rafId ) { + window.cancelAnimationFrame( this.onScrollResize.rafId ); + } + + if ( this.onKeyDown.rafId ) { + window.cancelAnimationFrame( this.onKeyDown.rafId ); + } + } + + /** + * Resets the scroll position to be maintained. + */ + computeCaretRect() { + if ( this.isSelectionEligibleForScroll() ) { + this.caretRect = computeCaretRect(); + } + } + + /** + * Resets the scroll position to be maintained during a `selectionchange` + * event. Also removes the listener, so it acts as a one-time listener. + */ + computeCaretRectOnSelectionChange() { + document.removeEventListener( 'selectionchange', this.computeCaretRectOnSelectionChange ); + this.computeCaretRect(); + } + + onScrollResize() { + if ( this.onScrollResize.rafId ) { + return; + } + + this.onScrollResize.rafId = window.requestAnimationFrame( () => { + this.computeCaretRect(); + delete this.onScrollResize.rafId; + } ); + } + + /** + * Checks if the current situation is elegible for scroll: + * - There should be one and only one block selected. + * - The component must contain the selection. + * - The active element must be contenteditable. + */ + isSelectionEligibleForScroll() { + return ( + this.props.selectedBlockClientId && + this.ref.current.contains( document.activeElement ) && + document.activeElement.isContentEditable + ); + } + + isLastEditableNode() { + const editableNodes = this.ref.current.querySelectorAll( + '[contenteditable="true"]' + ); + const lastEditableNode = editableNodes[ editableNodes.length - 1 ]; + return lastEditableNode === document.activeElement; + } + + /** + * Maintains the scroll position after a selection change caused by a + * keyboard event. + * + * @param {SyntheticEvent} event Synthetic keyboard event. + */ + maintainCaretPosition( { keyCode } ) { + if ( ! this.isSelectionEligibleForScroll() ) { + return; + } + + const currentCaretRect = computeCaretRect(); + + if ( ! currentCaretRect ) { + return; + } + + // If for some reason there is no position set to be scrolled to, let + // this be the position to be scrolled to in the future. + if ( ! this.caretRect ) { + this.caretRect = currentCaretRect; + return; + } + + // Even though enabling the typewriter effect for arrow keys results in + // a pleasant experience, it may not be the case for everyone, so, for + // now, let's disable it. + if ( arrowKeyCodes.has( keyCode ) ) { + // Reset the caret position to maintain. + this.caretRect = currentCaretRect; + return; + } + + const diff = currentCaretRect.y - this.caretRect.y; + + if ( diff === 0 ) { + return; + } + + const scrollContainer = getScrollContainer( this.ref.current ); + + // The page must be scrollable. + if ( ! scrollContainer ) { + return; + } + + const windowScroll = scrollContainer === document.body; + const scrollY = windowScroll ? + window.scrollY : + scrollContainer.scrollTop; + const scrollContainerY = windowScroll ? + 0 : + scrollContainer.getBoundingClientRect().y; + const relativeScrollPosition = windowScroll ? + this.caretRect.y / window.innerHeight : + ( this.caretRect.y - scrollContainerY ) / + ( window.innerHeight - scrollContainerY ); + + // If the scroll position is at the start, the active editable element + // is the last one, and the caret is positioned within the initial + // trigger percentage of the page, do not scroll the page. + // The typewriter effect should not kick in until an empty page has been + // filled with the initial trigger percentage or the user scrolls + // intentionally down. + if ( + scrollY === 0 && + relativeScrollPosition < initialTriggerPercentage && + this.isLastEditableNode() + ) { + // Reset the caret position to maintain. + this.caretRect = currentCaretRect; + return; + } + + const scrollContainerHeight = windowScroll ? + window.innerHeight : + scrollContainer.clientHeight; + + // Abort if the target scroll position would scroll the caret out of + // view. + if ( + // The caret is under the lower fold. + this.caretRect.y + this.caretRect.height > + scrollContainerY + scrollContainerHeight || + // The caret is above the upper fold. + this.caretRect.y < scrollContainerY + ) { + // Reset the caret position to maintain. + this.caretRect = currentCaretRect; + return; + } + + if ( windowScroll ) { + window.scrollBy( 0, diff ); + } else { + scrollContainer.scrollTop += diff; + } + } + + /** + * Adds a `selectionchange` listener to reset the scroll position to be + * maintained. + */ + addSelectionChangeListener() { + document.addEventListener( 'selectionchange', this.computeCaretRectOnSelectionChange ); + } + + onKeyDown( event ) { + event.persist(); + + // Ensure the any remaining request is cancelled. + if ( this.onKeyDown.rafId ) { + window.cancelAnimationFrame( this.onKeyDown.rafId ); + } + + // Use an animation frame for a smooth result. + this.onKeyDown.rafId = window.requestAnimationFrame( () => { + this.maintainCaretPosition( event ); + delete this.onKeyDown.rafId; + } ); + } + + render() { + // There are some issues with Internet Explorer, which are probably not + // worth spending time on. Let's disable it. + if ( isIE ) { + return this.props.children; + } + + // Disable reason: Wrapper itself is non-interactive, but must capture + // bubbling events from children to determine focus transition intents. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
+ { this.props.children } +
+ ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +/** + * Ensures that the text selection keeps the same vertical distance from the + * viewport during keyboard events within this component. The vertical distance + * can vary. It is the last clicked or scrolled to position. + */ +export default withSelect( ( select ) => { + const { getSelectedBlockClientId } = select( 'core/block-editor' ); + return { selectedBlockClientId: getSelectedBlockClientId() }; +} )( Typewriter ); diff --git a/packages/block-editor/src/components/writing-flow/index.js b/packages/block-editor/src/components/writing-flow/index.js index cbc531ef754a85..54e1a7ee77fe08 100644 --- a/packages/block-editor/src/components/writing-flow/index.js +++ b/packages/block-editor/src/components/writing-flow/index.js @@ -404,7 +404,7 @@ class WritingFlow extends Component { aria-hidden tabIndex={ -1 } onClick={ this.focusLastTextField } - className="wp-block editor-writing-flow__click-redirect block-editor-writing-flow__click-redirect" + className="editor-writing-flow__click-redirect block-editor-writing-flow__click-redirect" /> ); diff --git a/packages/block-editor/src/components/writing-flow/style.scss b/packages/block-editor/src/components/writing-flow/style.scss index e1ff5e860ad149..422b378f2e8e2e 100644 --- a/packages/block-editor/src/components/writing-flow/style.scss +++ b/packages/block-editor/src/components/writing-flow/style.scss @@ -1,10 +1,8 @@ .block-editor-writing-flow { - height: 100%; display: flex; flex-direction: column; } .block-editor-writing-flow__click-redirect { - flex-basis: 100%; cursor: text; } diff --git a/packages/e2e-tests/specs/typewriter.test.js b/packages/e2e-tests/specs/typewriter.test.js new file mode 100644 index 00000000000000..a41d9b0ba4b71b --- /dev/null +++ b/packages/e2e-tests/specs/typewriter.test.js @@ -0,0 +1,168 @@ +/** + * WordPress dependencies + */ +import { createNewPost } from '@wordpress/e2e-test-utils'; + +describe( 'TypeWriter', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + const getCaretPosition = async () => + await page.evaluate( () => wp.dom.computeCaretRect().y ); + + // Allow the scroll position to be 1px off. + const BUFFER = 1; + + const getDiff = async ( caretPosition ) => + Math.abs( await getCaretPosition() - caretPosition ); + + it( 'should maintain caret position', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + + const initialPosition = await getCaretPosition(); + + // The page shouldn't be scrolled when it's being filled. + await page.keyboard.press( 'Enter' ); + + expect( await getCaretPosition() ).toBeGreaterThan( initialPosition ); + + // Create blocks until the the typewriter effect kicks in. + while ( await page.evaluate( () => + wp.dom.getScrollContainer( document.activeElement ).scrollTop === 0 + ) ) { + await page.keyboard.press( 'Enter' ); + } + + const newPosition = await getCaretPosition(); + + // Now the scroll position should be maintained. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + + // Type until the text wraps. + while ( await page.evaluate( () => + document.activeElement.clientHeight <= + parseInt( getComputedStyle( document.activeElement ).lineHeight, 10 ) + ) ) { + await page.keyboard.type( 'a' ); + } + + expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + + // Pressing backspace will reposition the caret to the previous line. + // Scroll position should be adjusted again. + await page.keyboard.press( 'Backspace' ); + + expect( await getDiff( newPosition ) ).toBeLessThanOrEqual( BUFFER ); + + // Should reset scroll position to maintain. + await page.keyboard.press( 'ArrowUp' ); + + const positionAfterArrowUp = await getCaretPosition(); + + expect( positionAfterArrowUp ).toBeLessThan( newPosition ); + + // Should be scrolled to new position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( positionAfterArrowUp ) ).toBeLessThanOrEqual( BUFFER ); + } ); + + it( 'should maintain caret position after scroll', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + + await page.evaluate( () => + wp.dom.getScrollContainer( document.activeElement ).scrollTop = 1 + ); + + const initialPosition = await getCaretPosition(); + + // Should maintain scroll position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( initialPosition ) ).toBeLessThanOrEqual( BUFFER ); + } ); + + it( 'should maintain caret position after leaving last editable', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + // Create second block. + await page.keyboard.press( 'Enter' ); + // Move to first block. + await page.keyboard.press( 'ArrowUp' ); + + const initialPosition = await getCaretPosition(); + + // Should maintain scroll position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( initialPosition ) ).toBeLessThanOrEqual( BUFFER ); + } ); + + it( 'should scroll caret into view from the top', async () => { + // Create first block. + await page.keyboard.press( 'Enter' ); + + let count = 0; + + // Create blocks until the the typewriter effect kicks in. + while ( await page.evaluate( () => + wp.dom.getScrollContainer( document.activeElement ).scrollTop === 0 + ) ) { + await page.keyboard.press( 'Enter' ); + count++; + } + + // Scroll the active element to the very bottom of the scroll container, + // then scroll 20px down, so the caret is partially hidden. + await page.evaluate( () => { + document.activeElement.scrollIntoView( false ); + wp.dom.getScrollContainer( document.activeElement ).scrollTop -= 20; + } ); + + const bottomPostition = await getCaretPosition(); + + // Should scroll the caret back into view (preserve browser behaviour). + await page.keyboard.type( 'a' ); + + const newBottomPosition = await getCaretPosition(); + + expect( newBottomPosition ).toBeLessThan( bottomPostition ); + + // Should maintain new caret position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( newBottomPosition ) ).toBeLessThanOrEqual( BUFFER ); + + await page.keyboard.press( 'Backspace' ); + + while ( count-- ) { + await page.keyboard.press( 'ArrowUp' ); + } + + // Scroll the active element to the very top of the scroll container, + // then scroll 10px down, so the caret is partially hidden. + await page.evaluate( () => { + document.activeElement.scrollIntoView(); + wp.dom.getScrollContainer( document.activeElement ).scrollTop += 20; + } ); + + const topPostition = await getCaretPosition(); + + // Should scroll the caret back into view (preserve browser behaviour). + await page.keyboard.type( 'a' ); + + const newTopPosition = await getCaretPosition(); + + expect( newTopPosition ).toBeGreaterThan( topPostition ); + + // Should maintain new caret position. + await page.keyboard.press( 'Enter' ); + + expect( await getDiff( newTopPosition ) ).toBeLessThanOrEqual( BUFFER ); + } ); +} ); diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index ee2b55bbed5d24..4ae2c2a9a71e9b 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -62,6 +62,7 @@ function Layout( { const className = classnames( 'edit-post-layout', { 'is-sidebar-opened': sidebarIsOpened, 'has-fixed-toolbar': hasFixedToolbar, + 'has-metaboxes': hasActiveMetaboxes, } ); const publishLandmarkProps = { diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index 8287f5c27396d5..37d3749f0f4983 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -7,6 +7,7 @@ import { } from '@wordpress/editor'; import { WritingFlow, + Typewriter, ObserveTyping, BlockList, CopyHandler, @@ -27,14 +28,16 @@ function VisualEditor() { - - - - - - - - + + + + + + + + + + <__experimentalBlockSettingsMenuFirstItem> { ( { onClose } ) => } diff --git a/packages/edit-post/src/components/visual-editor/style.scss b/packages/edit-post/src/components/visual-editor/style.scss index c02a1c7dbcacc8..6be41449eae792 100644 --- a/packages/edit-post/src/components/visual-editor/style.scss +++ b/packages/edit-post/src/components/visual-editor/style.scss @@ -1,6 +1,6 @@ .edit-post-visual-editor { position: relative; - padding: 50px 0; + padding-top: 50px; & .components-button { font-family: $default-font; @@ -8,11 +8,14 @@ } .edit-post-visual-editor .block-editor-writing-flow__click-redirect { - // Collapse to minimum height of 50px, to fully occupy editor bottom pad. - height: 50px; + // Allow the page to be scrolled with the last block in the middle. + height: 50vh; width: 100%; - // Offset for: Visual editor padding, block (default appender) margin. - margin: #{ -1 * $block-spacing } auto -50px; +} + +// Hide the extra space when there are metaboxes. +.has-metaboxes .edit-post-visual-editor .block-editor-writing-flow__click-redirect { + height: 0; } // The base width of blocks