Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

5.4.1 Backports #21793

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions packages/block-editor/src/components/block-list/use-multi-selection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/**
* WordPress dependencies
*/
import { useEffect, useRef, useCallback } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';

/**
* Internal dependencies
*/
import { getBlockClientId, getBlockDOMNode } from '../../utils/dom';

/**
* Returns for the deepest node at the start or end of a container node. Ignores
* any text nodes that only contain HTML formatting whitespace.
*
* @param {Element} node Container to search.
* @param {string} type 'start' or 'end'.
*/
function getDeepestNode( node, type ) {
const child = type === 'start' ? 'firstChild' : 'lastChild';
const sibling = type === 'start' ? 'nextSibling' : 'previousSibling';

while ( node[ child ] ) {
node = node[ child ];

while (
node.nodeType === node.TEXT_NODE &&
/^[ \t\n]*$/.test( node.data ) &&
node[ sibling ]
) {
node = node[ sibling ];
}
}

return node;
}

function selector( select ) {
const {
isSelectionEnabled,
isMultiSelecting,
getMultiSelectedBlockClientIds,
hasMultiSelection,
getBlockParents,
getSelectedBlockClientId,
} = select( 'core/block-editor' );

return {
isSelectionEnabled: isSelectionEnabled(),
isMultiSelecting: isMultiSelecting(),
multiSelectedBlockClientIds: getMultiSelectedBlockClientIds(),
hasMultiSelection: hasMultiSelection(),
getBlockParents,
selectedBlockClientId: getSelectedBlockClientId(),
};
}

function toggleRichText( container, toggle ) {
Array.from( container.querySelectorAll( '.rich-text' ) ).forEach(
( node ) => {
if ( toggle ) {
node.setAttribute( 'contenteditable', true );
} else {
node.removeAttribute( 'contenteditable' );
}
}
);
}

export default function useMultiSelection( ref ) {
const {
isSelectionEnabled,
isMultiSelecting,
multiSelectedBlockClientIds,
hasMultiSelection,
getBlockParents,
selectedBlockClientId,
} = useSelect( selector, [] );
const {
startMultiSelect,
stopMultiSelect,
multiSelect,
selectBlock,
} = useDispatch( 'core/block-editor' );
const rafId = useRef();
const startClientId = useRef();
const anchorElement = useRef();

/**
* When the component updates, and there is multi selection, we need to
* select the entire block contents.
*/
useEffect( () => {
if ( ! hasMultiSelection || isMultiSelecting ) {
if ( ! selectedBlockClientId || isMultiSelecting ) {
return;
}

const selection = window.getSelection();

if ( selection.rangeCount && ! selection.isCollapsed ) {
const blockNode = getBlockDOMNode( selectedBlockClientId );
const { startContainer, endContainer } = selection.getRangeAt(
0
);

if (
!! blockNode &&
( ! blockNode.contains( startContainer ) ||
! blockNode.contains( endContainer ) )
) {
selection.removeAllRanges();
}
}

return;
}

const { length } = multiSelectedBlockClientIds;

if ( length < 2 ) {
return;
}

// These must be in the right DOM order.
const start = multiSelectedBlockClientIds[ 0 ];
const end = multiSelectedBlockClientIds[ length - 1 ];

let startNode = getBlockDOMNode( start );
let endNode = getBlockDOMNode( end );

const selection = window.getSelection();
const range = document.createRange();

// The most stable way to select the whole block contents is to start
// and end at the deepest points.
startNode = getDeepestNode( startNode, 'start' );
endNode = getDeepestNode( endNode, 'end' );

range.setStartBefore( startNode );
range.setEndAfter( endNode );

selection.removeAllRanges();
selection.addRange( range );
}, [
hasMultiSelection,
isMultiSelecting,
multiSelectedBlockClientIds,
selectBlock,
selectedBlockClientId,
] );

const onSelectionChange = useCallback(
( { isSelectionEnd } ) => {
const selection = window.getSelection();

// If no selection is found, end multi selection and enable all rich
// text areas.
if ( ! selection.rangeCount || selection.isCollapsed ) {
toggleRichText( ref.current, true );
return;
}

const clientId = getBlockClientId( selection.focusNode );
const isSingularSelection = startClientId.current === clientId;

if ( isSingularSelection ) {
selectBlock( clientId );

// If the selection is complete (on mouse up), and no multiple
// blocks have been selected, set focus back to the anchor element
// if the anchor element contains the selection. Additionally, rich
// text elements that were previously disabled can now be enabled
// again.
if ( isSelectionEnd ) {
toggleRichText( ref.current, true );

if ( selection.rangeCount ) {
const {
commonAncestorContainer,
} = selection.getRangeAt( 0 );

if (
anchorElement.current.contains(
commonAncestorContainer
)
) {
anchorElement.current.focus();
}
}
}
} else {
const startPath = [
...getBlockParents( startClientId.current ),
startClientId.current,
];
const endPath = [ ...getBlockParents( clientId ), clientId ];
const depth = Math.min( startPath.length, endPath.length ) - 1;

multiSelect( startPath[ depth ], endPath[ depth ] );
}
},
[ selectBlock, getBlockParents, multiSelect ]
);

/**
* Handles a mouseup event to end the current mouse multi-selection.
*/
const onSelectionEnd = useCallback( () => {
document.removeEventListener( 'selectionchange', onSelectionChange );
// Equivalent to attaching the listener once.
window.removeEventListener( 'mouseup', onSelectionEnd );
// The browser selection won't have updated yet at this point, so wait
// until the next animation frame to get the browser selection.
rafId.current = window.requestAnimationFrame( () => {
onSelectionChange( { isSelectionEnd: true } );
stopMultiSelect();
} );
}, [ onSelectionChange, stopMultiSelect ] );

// Only clean up when unmounting, these are added and cleaned up elsewhere.
useEffect(
() => () => {
document.removeEventListener(
'selectionchange',
onSelectionChange
);
window.removeEventListener( 'mouseup', onSelectionEnd );
window.cancelAnimationFrame( rafId.current );
},
[ onSelectionChange, onSelectionEnd ]
);

/**
* Binds event handlers to the document for tracking a pending multi-select
* in response to a mousedown event occurring in a rendered block.
*/
return useCallback(
( clientId ) => {
if ( ! isSelectionEnabled ) {
return;
}

startClientId.current = clientId;
anchorElement.current = document.activeElement;
startMultiSelect();

// `onSelectionStart` is called after `mousedown` and `mouseleave`
// (from a block). The selection ends when `mouseup` happens anywhere
// in the window.
document.addEventListener( 'selectionchange', onSelectionChange );
window.addEventListener( 'mouseup', onSelectionEnd );

// Removing the contenteditable attributes within the block editor is
// essential for selection to work across editable areas. The edible
// hosts are removed, allowing selection to be extended outside the
// DOM element. `startMultiSelect` sets a flag in the store so the rich
// text components are updated, but the rerender may happen very slowly,
// especially in Safari for the blocks that are asynchonously rendered.
// To ensure the browser instantly removes the selection boundaries, we
// remove the contenteditable attributes manually.
toggleRichText( ref.current, false );
},
[ isSelectionEnabled, startMultiSelect, onSelectionEnd ]
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,45 @@ import scrollIntoView from 'dom-scroll-into-view';
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { withSelect } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { getScrollContainer } from '@wordpress/dom';

/**
* Internal dependencies
*/
import { getBlockDOMNode } from '../../utils/dom';

class MultiSelectScrollIntoView extends Component {
componentDidUpdate() {
// Relies on expectation that `componentDidUpdate` will only be called
// if value of `extentClientId` changes.
this.scrollIntoView();
}
/**
* Scrolls the multi block selection end into view if not in view already. This
* is important to do after selection by keyboard.
*/
export default function MultiSelectScrollIntoView() {
const selector = ( select ) => {
const {
getBlockSelectionEnd,
hasMultiSelection,
isMultiSelecting,
} = select( 'core/block-editor' );

return {
selectionEnd: getBlockSelectionEnd(),
isMultiSelection: hasMultiSelection(),
isMultiSelecting: isMultiSelecting(),
};
};
const { isMultiSelection, selectionEnd, isMultiSelecting } = useSelect(
selector,
[]
);

/**
* Ensures that if a multi-selection exists, the extent of the selection is
* visible within the nearest scrollable container.
*/
scrollIntoView() {
const { extentClientId } = this.props;
if ( ! extentClientId ) {
useEffect( () => {
if ( ! selectionEnd || isMultiSelecting || ! isMultiSelection ) {
return;
}

const extentNode = getBlockDOMNode( extentClientId );
const extentNode = getBlockDOMNode( selectionEnd );

if ( ! extentNode ) {
return;
}
Expand All @@ -48,17 +60,7 @@ class MultiSelectScrollIntoView extends Component {
scrollIntoView( extentNode, scrollContainer, {
onlyScrollIfNeeded: true,
} );
}
}, [ isMultiSelection, selectionEnd, isMultiSelecting ] );

render() {
return null;
}
return null;
}

export default withSelect( ( select ) => {
const { getLastMultiSelectedBlockClientId } = select( 'core/block-editor' );

return {
extentClientId: getLastMultiSelectedBlockClientId(),
};
} )( MultiSelectScrollIntoView );
14 changes: 14 additions & 0 deletions packages/block-library/src/buttons/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.wp-block-buttons .wp-block-button {
display: inline-block;
margin-right: $grid-unit-10;
margin-bottom: $grid-unit-10;
}

.wp-block-buttons.alignright .wp-block-button {
margin-right: none;
margin-left: $grid-unit-10;
}

.wp-block-buttons.aligncenter {
text-align: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Duplicating blocks should duplicate blocks using the block settings menu 1`] = `
"<!-- wp:paragraph -->
<p>Clone me</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>Clone me</p>
<!-- /wp:paragraph -->"
`;

exports[`Duplicating blocks should duplicate blocks using the keyboard shortcut 1`] = `
"<!-- wp:paragraph -->
<p>Clone me</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>Clone me</p>
<!-- /wp:paragraph -->"
`;
Loading