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

Block editor: rewrite moving animation for better load performance #57133

Merged
merged 4 commits into from
Dec 16, 2023
Merged
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
23 changes: 0 additions & 23 deletions packages/block-editor/src/components/block-list/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,6 @@ import { PrivateBlockContext } from './private-block-context';

import { unlock } from '../../lock-unlock';

/**
* If the block count exceeds the threshold, we disable the reordering animation
* to avoid laginess.
*/
const BLOCK_ANIMATION_THRESHOLD = 200;

/**
* Merges wrapper props with special handling for classNames and styles.
*
Expand Down Expand Up @@ -516,9 +510,7 @@ function BlockListBlockProvider( props ) {

getBlockIndex,
isTyping,
getGlobalBlockCount,
isBlockMultiSelected,
isAncestorMultiSelected,
isBlockSubtreeDisabled,
isBlockHighlighted,
__unstableIsFullySelected,
Expand Down Expand Up @@ -550,9 +542,6 @@ function BlockListBlockProvider( props ) {
const canRemove = canRemoveBlock( clientId, rootClientId );
const canMove = canMoveBlock( clientId, rootClientId );
const { name: blockName, attributes, isValid } = block;
const isPartOfMultiSelection =
isBlockMultiSelected( clientId ) ||
isAncestorMultiSelected( clientId );
const blockType = getBlockType( blockName );
const match = getActiveBlockVariation( blockName, attributes );
const { outlineMode, supportsLayout } = getSettings();
Expand Down Expand Up @@ -600,12 +589,6 @@ function BlockListBlockProvider( props ) {
index: getBlockIndex( clientId ),
blockApiVersion: blockType?.apiVersion || 1,
blockTitle: match?.title || blockType?.title,
isPartOfSelection: _isSelected || isPartOfMultiSelection,
adjustScrolling:
_isSelected || isFirstMultiSelectedBlock( clientId ),
enableAnimation:
! typing &&
getGlobalBlockCount() <= BLOCK_ANIMATION_THRESHOLD,
isSubtreeDisabled: isBlockSubtreeDisabled( clientId ),
isOutlineEnabled: outlineMode,
hasOverlay: __unstableHasActiveBlockOverlayActive( clientId ),
Expand Down Expand Up @@ -662,9 +645,6 @@ function BlockListBlockProvider( props ) {
index,
blockApiVersion,
blockTitle,
isPartOfSelection,
adjustScrolling,
enableAnimation,
isSubtreeDisabled,
isOutlineEnabled,
hasOverlay,
Expand Down Expand Up @@ -699,9 +679,6 @@ function BlockListBlockProvider( props ) {
blockApiVersion,
blockTitle,
isSelected,
isPartOfSelection,
adjustScrolling,
enableAnimation,
isSubtreeDisabled,
isOutlineEnabled,
hasOverlay,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,6 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) {
blockApiVersion,
blockTitle,
isSelected,
isPartOfSelection,
adjustScrolling,
enableAnimation,
isSubtreeDisabled,
isOutlineEnabled,
hasOverlay,
Expand Down Expand Up @@ -114,12 +111,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) {
useNavModeExit( clientId ),
useIsHovered( { isEnabled: isOutlineEnabled } ),
useIntersectionObserver(),
useMovingAnimation( {
isSelected: isPartOfSelection,
adjustScrolling,
enableAnimation,
triggerAnimationOnChange: index,
} ),
useMovingAnimation( { triggerAnimationOnChange: index, clientId } ),
useDisabled( { isDisabled: ! hasOverlay } ),
] );

Expand Down
210 changes: 107 additions & 103 deletions packages/block-editor/src/components/use-moving-animation/index.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,32 @@
/**
* External dependencies
*/
import { useSpring } from '@react-spring/web';
import { Controller } from '@react-spring/web';

/**
* WordPress dependencies
*/
import {
useState,
useLayoutEffect,
useReducer,
useMemo,
useRef,
} from '@wordpress/element';
import { useReducedMotion } from '@wordpress/compose';
import { useLayoutEffect, useMemo, useRef } from '@wordpress/element';
import { getScrollContainer } from '@wordpress/dom';
import { useSelect } from '@wordpress/data';

/**
* Simple reducer used to increment a counter.
*
* @param {number} state Previous counter value.
* @return {number} New state value.
* Internal dependencies
*/
const counterReducer = ( state ) => state + 1;
import { store as blockEditorStore } from '../../store';

const getAbsolutePosition = ( element ) => {
/**
* If the block count exceeds the threshold, we disable the reordering animation
* to avoid laginess.
*/
const BLOCK_ANIMATION_THRESHOLD = 200;
Copy link
Contributor

Choose a reason for hiding this comment

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

By moving this into useMovingAnimation, the moving animation is now prevented in the list view when the global block count is greater than 200. Was that intentional?

I think for the list view case, where list items are displayed conditionally based on windowing logic, we might not need to guard it behind this global block count threshold. The reason I'm thinking about this is because over in #56625 I'm exploring displacing list view items when dragging within the list view, and useMovingAnimation provides a handy way of smoothly animating a dropped list view item into its final position.

I noticed while editing the blog home template in TT4 that the getGlobalBlockCount() already starts out pretty high (165 in my test site) so it's quite easy to get to over 200 and then lose the animation.


function getAbsolutePosition( element ) {
return {
top: element.offsetTop,
left: element.offsetLeft,
};
};
}

/**
* Hook used to compute the styles required to move a div into a new position.
Expand All @@ -42,114 +39,121 @@ const getAbsolutePosition = ( element ) => {
* - It uses the "resetAnimation" flag to reset the animation
* from the beginning in order to animate to the new destination point.
*
* @param {Object} $1 Options
* @param {boolean} $1.isSelected Whether it's the current block or not.
* @param {boolean} $1.adjustScrolling Adjust the scroll position to the current block.
* @param {boolean} $1.enableAnimation Enable/Disable animation.
* @param {*} $1.triggerAnimationOnChange Variable used to trigger the animation if it changes.
* @param {Object} $1 Options
* @param {*} $1.triggerAnimationOnChange Variable used to trigger the animation if it changes.
* @param {string} $1.clientId
*/
function useMovingAnimation( {
isSelected,
adjustScrolling,
enableAnimation,
triggerAnimationOnChange,
} ) {
function useMovingAnimation( { triggerAnimationOnChange, clientId } ) {
const ref = useRef();
const prefersReducedMotion = useReducedMotion() || ! enableAnimation;
const [ triggeredAnimation, triggerAnimation ] = useReducer(
counterReducer,
0
);
const [ finishedAnimation, endAnimation ] = useReducer( counterReducer, 0 );
const [ transform, setTransform ] = useState( { x: 0, y: 0 } );
const previous = useMemo(
() => ( ref.current ? getAbsolutePosition( ref.current ) : null ),
const {
isTyping,
getGlobalBlockCount,
isBlockSelected,
isFirstMultiSelectedBlock,
isBlockMultiSelected,
isAncestorMultiSelected,
} = useSelect( blockEditorStore );

// Whenever the trigger changes, we need to take a snapshot of the current
// position of the block to use it as a destination point for the animation.
const { previous, prevRect } = useMemo(
() => ( {
previous: ref.current && getAbsolutePosition( ref.current ),
prevRect: ref.current && ref.current.getBoundingClientRect(),
} ),
// eslint-disable-next-line react-hooks/exhaustive-deps
[ triggerAnimationOnChange ]
);

// Calculate the previous position of the block relative to the viewport and
// return a function to maintain that position by scrolling.
const preserveScrollPosition = useMemo( () => {
if ( ! adjustScrolling || ! ref.current ) {
return () => {};
useLayoutEffect( () => {
if ( ! previous || ! ref.current ) {
return;
}

const scrollContainer = getScrollContainer( ref.current );

if ( ! scrollContainer ) {
return () => {};
}

const prevRect = ref.current.getBoundingClientRect();
return () => {
const blockRect = ref.current.getBoundingClientRect();
const diff = blockRect.top - prevRect.top;

if ( diff ) {
scrollContainer.scrollTop += diff;
const isSelected = isBlockSelected( clientId );
const adjustScrolling =
isSelected || isFirstMultiSelectedBlock( clientId );

function preserveScrollPosition() {
if ( adjustScrolling && prevRect ) {
const blockRect = ref.current.getBoundingClientRect();
const diff = blockRect.top - prevRect.top;

if ( diff ) {
scrollContainer.scrollTop += diff;
}
}
};
}, [ triggerAnimationOnChange, adjustScrolling ] );

useLayoutEffect( () => {
if ( triggeredAnimation ) {
endAnimation();
}
}, [ triggeredAnimation ] );
useLayoutEffect( () => {
if ( ! previous ) {
return;
}

if ( prefersReducedMotion ) {
// We disable the animation if the user has a preference for reduced
// motion, if the user is typing (insertion by Enter), or if the block
// count exceeds the threshold (insertion caused all the blocks that
// follow to animate).
// To do: consider enableing the _moving_ animation even for large
// posts, while only disabling the _insertion_ animation?
const disableAnimation =
window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ||
isTyping() ||
getGlobalBlockCount() > BLOCK_ANIMATION_THRESHOLD;

if ( disableAnimation ) {
// If the animation is disabled and the scroll needs to be adjusted,
// just move directly to the final scroll position.
preserveScrollPosition();

return;
}

const isPartOfSelection =
isSelected ||
isBlockMultiSelected( clientId ) ||
isAncestorMultiSelected( clientId );
// Make sure the other blocks move under the selected block(s).
const zIndex = isPartOfSelection ? '1' : '';
ellatrix marked this conversation as resolved.
Show resolved Hide resolved

const controller = new Controller( {
x: 0,
y: 0,
config: { mass: 5, tension: 2000, friction: 200 },
onChange( { value } ) {
if ( ! ref.current ) {
return;
}
let { x, y } = value;
x = Math.round( x );
y = Math.round( y );
const finishedMoving = x === 0 && y === 0;
ref.current.style.transformOrigin = 'center center';
ref.current.style.transform = finishedMoving
? null // Set to `null` to explicitly remove the transform.
: `translate3d(${ x }px,${ y }px,0)`;
ref.current.style.zIndex = zIndex;
Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe we can also set this at before animation start, then restore at the end or on cleanup.

Copy link
Member Author

Choose a reason for hiding this comment

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

(Same for transformOrigin)

Copy link
Member Author

Choose a reason for hiding this comment

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

I left the original code here. Didn't want to alter things too much and I was hoping to see a before start and stop API.

preserveScrollPosition();
},
} );

ref.current.style.transform = undefined;
const destination = getAbsolutePosition( ref.current );

triggerAnimation();
setTransform( {
x: Math.round( previous.left - destination.left ),
y: Math.round( previous.top - destination.top ),
} );
}, [ triggerAnimationOnChange ] );
const x = Math.round( previous.left - destination.left );
const y = Math.round( previous.top - destination.top );

function onChange( { value } ) {
if ( ! ref.current ) {
return;
}
let { x, y } = value;
x = Math.round( x );
y = Math.round( y );
const finishedMoving = x === 0 && y === 0;
ref.current.style.transformOrigin = 'center center';
ref.current.style.transform = finishedMoving
? null // Set to `null` to explicitly remove the transform.
: `translate3d(${ x }px,${ y }px,0)`;
ref.current.style.zIndex = isSelected ? '1' : '';

preserveScrollPosition();
}

useSpring( {
from: {
x: transform.x,
y: transform.y,
},
to: {
x: 0,
y: 0,
},
reset: triggeredAnimation !== finishedAnimation,
config: { mass: 5, tension: 2000, friction: 200 },
immediate: prefersReducedMotion,
onChange,
} );
controller.start( { x: 0, y: 0, from: { x, y } } );

return () => {
controller.stop();
};
}, [
previous,
prevRect,
clientId,
isTyping,
getGlobalBlockCount,
isBlockSelected,
isFirstMultiSelectedBlock,
isBlockMultiSelected,
isAncestorMultiSelected,
] );

return ref;
}
Expand Down
Loading