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

Lighter block DOM: put sibling inserter in popover #19456

Merged
merged 12 commits into from
Jan 9, 2020
7 changes: 0 additions & 7 deletions packages/block-editor/src/components/block-list/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import BlockCrashBoundary from './block-crash-boundary';
import BlockHtml from './block-html';
import BlockBreadcrumb from './breadcrumb';
import BlockContextualToolbar from './block-contextual-toolbar';
import BlockInsertionPoint from './insertion-point';
import Inserter from '../inserter';
import { isInsideRootBlock } from '../../utils/dom';
import useMovingAnimation from './moving-animation';
Expand Down Expand Up @@ -418,12 +417,6 @@ function BlockListBlock( {
animationStyle
}
>
{ shouldShowInsertionPoint && (
<BlockInsertionPoint
clientId={ clientId }
rootClientId={ rootClientId }
/>
) }
{ shouldRenderDropzone && <BlockDropZone
clientId={ clientId }
rootClientId={ rootClientId }
Expand Down
131 changes: 88 additions & 43 deletions packages/block-editor/src/components/block-list/insertion-point.js
Original file line number Diff line number Diff line change
@@ -1,74 +1,119 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { Popover } from '@wordpress/components';

/**
* Internal dependencies
*/
import Inserter from '../inserter';

export default function BlockInsertionPoint( { rootClientId, clientId } ) {
const [ isInserterFocused, setInserterFocused ] = useState( false );
function Indicator( { clientId } ) {
const showInsertionPoint = useSelect( ( select ) => {
const {
getBlockIndex,
getBlockInsertionPoint,
isBlockInsertionPointVisible,
getBlockRootClientId,
} = select( 'core/block-editor' );
const rootClientId = getBlockRootClientId( clientId );
const blockIndex = getBlockIndex( clientId, rootClientId );
const insertionPoint = getBlockInsertionPoint();
return (
isBlockInsertionPointVisible() &&
insertionPoint.index === blockIndex &&
insertionPoint.rootClientId === rootClientId
);
}, [ clientId, rootClientId ] );
}, [ clientId ] );

function onFocus( event ) {
// Stop propagation of the focus event to avoid selecting the current
// block while inserting a new block, as it is not relevant to sibling
// insertion and conflicts with contextual toolbar placement.
event.stopPropagation();
setInserterFocused( true );
if ( ! showInsertionPoint ) {
return null;
}

function onBlur() {
setInserterFocused( false );
return <div className="block-editor-block-list__insertion-point-indicator" />;
}

export default function InsertionPoint( {
className,
isMultiSelecting,
selectedBlockClientId,
children,
} ) {
const [ isInserterShown, setIsInserterShown ] = useState( false );
const [ isInserterForced, setIsInserterForced ] = useState( false );
const [ inserterElement, setInserterElement ] = useState( null );
const [ inserterClientId, setInserterClientId ] = useState( null );

function onMouseMove( event ) {
if ( event.target.className !== className ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this logic meant to be doing? It's quite unclear to me in reading this. A few inline comments or named utility functions could help clarify here.

FWIW, it raises some curiosity how reliable className is here, whether that might hurt maintainability or interoperability (I know of at least a few Chrome extensions which apply classes to the live DOM, etc). Could be something where event.target.classList.contains could prove more durable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, if we need to take into account outside changes to the class name, then checking with contains is better. Even better would be to keep a list of all the reference check if it's included.

if ( isInserterShown ) {
setIsInserterShown( false );
}
return;
}

const rect = event.target.getBoundingClientRect();
const offset = event.clientY - rect.top;
const element = Array.from( event.target.children ).find( ( blockEl ) => {
return blockEl.offsetTop > offset;
} );

if ( ! element ) {
return;
}

const clientId = element.id.slice( 'block-'.length );

if ( ! clientId || clientId === selectedBlockClientId ) {
return;
}

const elementRect = element.getBoundingClientRect();

if ( event.clientX > elementRect.right || event.clientX < elementRect.left ) {
if ( isInserterShown ) {
setIsInserterShown( false );
}
return;
}

setIsInserterShown( true );
setInserterElement( element );
setInserterClientId( clientId );
Comment on lines +82 to +84
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it might not be too much an issue since setState won't cause a render unless the state actually changes, I note (by placing a logpoint) that this gets called upwards of ~15 times every time a cursor moves within a sibling inserter. Is it something where (even if we don't strictly need to), we could abort the logic when determining that nothing needs to change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make much difference, you think? I thought the only thing we'd avoid is an extra function call.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make much difference, you think? I thought the only thing we'd avoid is an extra function call.

It's hard to say. I'm not too sure what all React does internally to decide how to handle setState. Depending if we could abort any earlier, it could be an opportunity to avoid reflow from one of the getBoundingClientRect? I'm not sure of the implementation here to know whether that's even possible; I'd guess by the fact there are already some return; that it's probably not?

}

return (
<div className="block-editor-block-list__insertion-point">
{ showInsertionPoint && (
<div className="block-editor-block-list__insertion-point-indicator" />
) }
<div
onFocus={ onFocus }
onBlur={ onBlur }
// While ideally it would be enough to capture the
// bubbling focus event from the Inserter, due to the
// characteristics of click focusing of `button`s in
// Firefox and Safari, it is not reliable.
//
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
tabIndex={ -1 }
className={
classnames( 'block-editor-block-list__insertion-point-inserter', {
'is-visible': isInserterFocused,
} )
}
>
<Inserter
rootClientId={ rootClientId }
clientId={ clientId }
/>
return <>
{ ! isMultiSelecting && ( isInserterShown || isInserterForced ) && <Popover
noArrow
animate={ false }
anchorRef={ inserterElement }
position="top right left"
focusOnMount={ false }
className="block-editor-block-list__block-popover"
__unstableSlotName="block-toolbar"
>
<div className="block-editor-block-list__insertion-point" style={ { width: inserterElement.offsetWidth } }>
<Indicator clientId={ inserterClientId } />
<div
onFocus={ () => setIsInserterForced( true ) }
onBlur={ () => setIsInserterForced( false ) }
// While ideally it would be enough to capture the
// bubbling focus event from the Inserter, due to the
// characteristics of click focusing of `button`s in
// Firefox and Safari, it is not reliable.
//
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
tabIndex={ -1 }
className="block-editor-block-list__insertion-point-inserter"
>
<Inserter clientId={ inserterClientId } />
</div>
</div>
</Popover> }
<div onMouseMove={ ! isInserterForced && ! isMultiSelecting ? onMouseMove : undefined }>
{ children }
</div>
);
</>;
}
26 changes: 18 additions & 8 deletions packages/block-editor/src/components/block-list/root-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useSelect, useDispatch } from '@wordpress/data';
*/
import useMultiSelection from './use-multi-selection';
import { getBlockClientId } from '../../utils/dom';
import InsertionPoint from './insertion-point';

/** @typedef {import('@wordpress/element').WPSyntheticEvent} WPSyntheticEvent */

Expand All @@ -18,11 +19,13 @@ function selector( select ) {
const {
getSelectedBlockClientId,
hasMultiSelection,
isMultiSelecting,
} = select( 'core/block-editor' );

return {
selectedBlockClientId: getSelectedBlockClientId(),
hasMultiSelection: hasMultiSelection(),
isMultiSelecting: isMultiSelecting(),
};
}

Expand All @@ -46,6 +49,7 @@ export default function RootContainer( { children, className } ) {
const {
selectedBlockClientId,
hasMultiSelection,
isMultiSelecting,
} = useSelect( selector, [] );
const { selectBlock } = useDispatch( 'core/block-editor' );
const onSelectionStart = useMultiSelection( ref );
Expand All @@ -70,15 +74,21 @@ export default function RootContainer( { children, className } ) {
}

return (
<div
ref={ ref }
<InsertionPoint
className={ className }
onFocus={ onFocus }
onDragStart={ onDragStart }
isMultiSelecting={ isMultiSelecting }
selectedBlockClientId={ selectedBlockClientId }
>
<Context.Provider value={ onSelectionStart }>
{ children }
</Context.Provider>
</div>
<div
ref={ ref }
className={ className }
onFocus={ onFocus }
onDragStart={ onDragStart }
>
<Context.Provider value={ onSelectionStart }>
{ children }
</Context.Provider>
</div>
</InsertionPoint>
);
}
32 changes: 1 addition & 31 deletions packages/block-editor/src/components/block-list/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
.block-editor-block-list__layout {
padding-left: $block-padding;
padding-right: $block-padding;
position: relative;

// Beyond the mobile breakpoint, compensate for side UI.
@include break-small() {
Expand Down Expand Up @@ -402,12 +403,7 @@
display: flex;
}

position: absolute;
bottom: auto;
left: 0;
right: 0;
justify-content: center;
height: $block-padding + 8px;

// Show a clickable plus.
.block-editor-inserter__toggle {
Expand All @@ -422,32 +418,6 @@
box-shadow: none;
}
}

// Hide both the button until hovered.
opacity: 0;
transition: opacity 0.1s linear;
@include reduce-motion("transition");

&:hover,
&.is-visible {
opacity: 1;
}
}

// Don't show the sibling inserter before the selected block.
.edit-post-layout:not(.has-fixed-toolbar) {
// The child selector is necessary for this to work properly in nested contexts.
.is-selected > .block-editor-block-list__insertion-point > .block-editor-block-list__insertion-point-inserter,
.is-focused > .block-editor-block-list__insertion-point > .block-editor-block-list__insertion-point-inserter {
opacity: 0;
pointer-events: none;

&:hover,
&.is-visible {
opacity: 1;
pointer-events: auto;
}
}
}

// This is the edge-to-edge hover area that contains the plus.
Expand Down
43 changes: 42 additions & 1 deletion packages/block-editor/src/components/block-toolbar/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';
import { Toolbar } from '@wordpress/components';
import { useState } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -13,18 +20,21 @@ import BlockSettingsMenu from '../block-settings-menu';
import BlockSwitcher from '../block-switcher';
import MultiBlocksSwitcher from '../block-switcher/multi-blocks-switcher';
import BlockMover from '../block-mover';
import Inserter from '../inserter';

export default function BlockToolbar( { moverDirection, hasMovers = true } ) {
const { blockClientIds, isValid, mode } = useSelect( ( select ) => {
const { blockClientIds, isValid, mode, rootClientId } = useSelect( ( select ) => {
const {
getBlockMode,
getSelectedBlockClientIds,
isBlockValid,
getBlockRootClientId,
} = select( 'core/block-editor' );
const selectedBlockClientIds = getSelectedBlockClientIds();

return {
blockClientIds: selectedBlockClientIds,
rootClientId: getBlockRootClientId( selectedBlockClientIds[ 0 ] ),
isValid: selectedBlockClientIds.length === 1 ?
isBlockValid( selectedBlockClientIds[ 0 ] ) :
null,
Expand All @@ -33,11 +43,40 @@ export default function BlockToolbar( { moverDirection, hasMovers = true } ) {
null,
};
}, [] );
const [ isInserterShown, setIsInserterShown ] = useState( false );

if ( blockClientIds.length === 0 ) {
return null;
}

function onFocus() {
setIsInserterShown( true );
}

function onBlur() {
setIsInserterShown( false );
}

const inserter = (
<Toolbar
onFocus={ onFocus }
onBlur={ onBlur }
// While ideally it would be enough to capture the
// bubbling focus event from the Inserter, due to the
// characteristics of click focusing of `button`s in
// Firefox and Safari, it is not reliable.
//
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
tabIndex={ -1 }
className={ classnames(
'block-editor-block-toolbar__inserter',
{ 'is-visible': isInserterShown }
) }
>
<Inserter clientId={ blockClientIds[ 0 ] } rootClientId={ rootClientId } />
</Toolbar>
);

if ( blockClientIds.length > 1 ) {
return (
<div className="block-editor-block-toolbar">
Expand All @@ -47,6 +86,7 @@ export default function BlockToolbar( { moverDirection, hasMovers = true } ) {
/> ) }
<MultiBlocksSwitcher />
<BlockSettingsMenu clientIds={ blockClientIds } />
{ inserter }
</div>
);
}
Expand All @@ -65,6 +105,7 @@ export default function BlockToolbar( { moverDirection, hasMovers = true } ) {
</>
) }
<BlockSettingsMenu clientIds={ blockClientIds } />
{ inserter }
</div>
);
}
Loading