@wordpress/block-editor
Version:
491 lines (438 loc) • 16.8 kB
JavaScript
/**
* WordPress dependencies
*/
import { useEffect, useRef, useCallback } from '@wordpress/element';
import { useReducedMotion, useResizeObserver } from '@wordpress/compose';
/**
* @typedef {Object} TransitionState
* @property {number} scaleValue Scale of the canvas.
* @property {number} frameSize Size of the frame/offset around the canvas.
* @property {number} containerHeight containerHeight of the iframe.
* @property {number} scrollTop ScrollTop of the iframe.
* @property {number} scrollHeight ScrollHeight of the iframe.
*/
/**
* Calculate the scale of the canvas.
*
* @param {Object} options Object of options
* @param {number} options.frameSize Size of the frame/offset around the canvas
* @param {number} options.containerWidth Actual width of the canvas container
* @param {number} options.maxContainerWidth Maximum width of the container to use for the scale calculation. This locks the canvas to a maximum width when zooming out.
* @param {number} options.scaleContainerWidth Width the of the container wrapping the canvas container
* @return {number} Scale value between 0 and/or equal to 1
*/
function calculateScale( {
frameSize,
containerWidth,
maxContainerWidth,
scaleContainerWidth,
} ) {
return (
( Math.min( containerWidth, maxContainerWidth ) - frameSize * 2 ) /
scaleContainerWidth
);
}
/**
* Compute the next scrollHeight based on the transition states.
*
* @param {TransitionState} transitionFrom Starting point of the transition
* @param {TransitionState} transitionTo Ending state of the transition
* @return {number} Next scrollHeight based on scale and frame value changes.
*/
function computeScrollHeightNext( transitionFrom, transitionTo ) {
const { scaleValue: prevScale, scrollHeight: prevScrollHeight } =
transitionFrom;
const { frameSize, scaleValue } = transitionTo;
return prevScrollHeight * ( scaleValue / prevScale ) + frameSize * 2;
}
/**
* Compute the next scrollTop position after scaling the iframe content.
*
* @param {TransitionState} transitionFrom Starting point of the transition
* @param {TransitionState} transitionTo Ending state of the transition
* @return {number} Next scrollTop position after scaling the iframe content.
*/
function computeScrollTopNext( transitionFrom, transitionTo ) {
const {
containerHeight: prevContainerHeight,
frameSize: prevFrameSize,
scaleValue: prevScale,
scrollTop: prevScrollTop,
} = transitionFrom;
const { containerHeight, frameSize, scaleValue, scrollHeight } =
transitionTo;
// Step 0: Start with the current scrollTop.
let scrollTopNext = prevScrollTop;
// Step 1: Undo the effects of the previous scale and frame around the
// midpoint of the visible area.
scrollTopNext =
( scrollTopNext + prevContainerHeight / 2 - prevFrameSize ) /
prevScale -
prevContainerHeight / 2;
// Step 2: Apply the new scale and frame around the midpoint of the
// visible area.
scrollTopNext =
( scrollTopNext + containerHeight / 2 ) * scaleValue +
frameSize -
containerHeight / 2;
// Step 3: Handle an edge case so that you scroll to the top of the
// iframe if the top of the iframe content is visible in the container.
// The same edge case for the bottom is skipped because changing content
// makes calculating it impossible.
scrollTopNext = prevScrollTop <= prevFrameSize ? 0 : scrollTopNext;
// This is the scrollTop value if you are scrolled to the bottom of the
// iframe. We can't just let the browser handle it because we need to
// animate the scaling.
const maxScrollTop = scrollHeight - containerHeight;
// Step 4: Clamp the scrollTopNext between the minimum and maximum
// possible scrollTop positions. Round the value to avoid subpixel
// truncation by the browser which sometimes causes a 1px error.
return Math.round(
Math.min( Math.max( 0, scrollTopNext ), Math.max( 0, maxScrollTop ) )
);
}
/**
* Generate the keyframes to use for the zoom out animation.
*
* @param {TransitionState} transitionFrom Starting transition state.
* @param {TransitionState} transitionTo Ending transition state.
* @return {Object[]} An array of keyframes to use for the animation.
*/
function getAnimationKeyframes( transitionFrom, transitionTo ) {
const {
scaleValue: prevScale,
frameSize: prevFrameSize,
scrollTop,
} = transitionFrom;
const { scaleValue, frameSize, scrollTop: scrollTopNext } = transitionTo;
return [
{
translate: `0 0`,
scale: prevScale,
paddingTop: `${ prevFrameSize / prevScale }px`,
paddingBottom: `${ prevFrameSize / prevScale }px`,
},
{
translate: `0 ${ scrollTop - scrollTopNext }px`,
scale: scaleValue,
paddingTop: `${ frameSize / scaleValue }px`,
paddingBottom: `${ frameSize / scaleValue }px`,
},
];
}
/**
* @typedef {Object} ScaleCanvasResult
* @property {boolean} isZoomedOut A boolean indicating if the canvas is zoomed out.
* @property {number} scaleContainerWidth The width of the container used to calculate the scale.
* @property {Object} contentResizeListener A resize observer for the content.
* @property {Object} containerResizeListener A resize observer for the container.
*/
/**
* Handles scaling the canvas for the zoom out mode and animating between
* the states.
*
* @param {Object} options Object of options.
* @param {number} options.frameSize Size of the frame around the content.
* @param {Document} options.iframeDocument Document of the iframe.
* @param {number} options.maxContainerWidth Max width of the canvas to use as the starting scale point. Defaults to 750.
* @param {number|string} options.scale Scale of the canvas. Can be an decimal between 0 and 1, 1, or 'auto-scaled'.
* @return {ScaleCanvasResult} Properties of the result.
*/
export function useScaleCanvas( {
frameSize,
iframeDocument,
maxContainerWidth = 750,
scale,
} ) {
const [ contentResizeListener, { height: contentHeight } ] =
useResizeObserver();
const [
containerResizeListener,
{ width: containerWidth, height: containerHeight },
] = useResizeObserver();
const initialContainerWidthRef = useRef( 0 );
const isZoomedOut = scale !== 1;
const prefersReducedMotion = useReducedMotion();
const isAutoScaled = scale === 'auto-scaled';
// Track if the animation should start when the useEffect runs.
const startAnimationRef = useRef( false );
// Track the animation so we know if we have an animation running,
// and can cancel it, reverse it, call a finish event, etc.
const animationRef = useRef( null );
useEffect( () => {
if ( ! isZoomedOut ) {
initialContainerWidthRef.current = containerWidth;
}
}, [ containerWidth, isZoomedOut ] );
const scaleContainerWidth = Math.max(
initialContainerWidthRef.current,
containerWidth
);
const scaleValue = isAutoScaled
? calculateScale( {
frameSize,
containerWidth,
maxContainerWidth,
scaleContainerWidth,
} )
: scale;
/**
* The starting transition state for the zoom out animation.
* @type {import('react').RefObject<TransitionState>}
*/
const transitionFromRef = useRef( {
scaleValue,
frameSize,
containerHeight: 0,
scrollTop: 0,
scrollHeight: 0,
} );
/**
* The ending transition state for the zoom out animation.
* @type {import('react').RefObject<TransitionState>}
*/
const transitionToRef = useRef( {
scaleValue,
frameSize,
containerHeight: 0,
scrollTop: 0,
scrollHeight: 0,
} );
/**
* Start the zoom out animation. This sets the necessary CSS variables
* for animating the canvas and returns the Animation object.
*
* @return {Animation} The animation object for the zoom out animation.
*/
const startZoomOutAnimation = useCallback( () => {
const { scrollTop } = transitionFromRef.current;
const { scrollTop: scrollTopNext } = transitionToRef.current;
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-scroll-top',
`${ scrollTop }px`
);
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-scroll-top-next',
`${ scrollTopNext }px`
);
// If the container has a scrolllbar, force a scrollbar to prevent the content from shifting while animating.
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-overflow-behavior',
transitionFromRef.current.scrollHeight ===
transitionFromRef.current.containerHeight
? 'auto'
: 'scroll'
);
iframeDocument.documentElement.classList.add( 'zoom-out-animation' );
return iframeDocument.documentElement.animate(
getAnimationKeyframes(
transitionFromRef.current,
transitionToRef.current
),
{
easing: 'cubic-bezier(0.46, 0.03, 0.52, 0.96)',
duration: 400,
}
);
}, [ iframeDocument ] );
/**
* Callback when the zoom out animation is finished.
* - Cleans up animations refs.
* - Adds final CSS vars for scale and frame size to preserve the state.
* - Removes the 'zoom-out-animation' class (which has the fixed positioning).
* - Sets the final scroll position after the canvas is no longer in fixed position.
* - Removes CSS vars related to the animation.
* - Sets the transitionFrom to the transitionTo state to be ready for the next animation.
*/
const finishZoomOutAnimation = useCallback( () => {
startAnimationRef.current = false;
animationRef.current = null;
// Add our final scale and frame size now that the animation is done.
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-scale',
transitionToRef.current.scaleValue
);
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-frame-size',
`${ transitionToRef.current.frameSize }px`
);
iframeDocument.documentElement.classList.remove( 'zoom-out-animation' );
// Set the final scroll position that was just animated to.
// Disable reason: Eslint isn't smart enough to know that this is a
// DOM element. https://github.com/facebook/react/issues/31483
// eslint-disable-next-line react-compiler/react-compiler
iframeDocument.documentElement.scrollTop =
transitionToRef.current.scrollTop;
iframeDocument.documentElement.style.removeProperty(
'--wp-block-editor-iframe-zoom-out-scroll-top'
);
iframeDocument.documentElement.style.removeProperty(
'--wp-block-editor-iframe-zoom-out-scroll-top-next'
);
iframeDocument.documentElement.style.removeProperty(
'--wp-block-editor-iframe-zoom-out-overflow-behavior'
);
// Update previous values.
transitionFromRef.current = transitionToRef.current;
}, [ iframeDocument ] );
const previousIsZoomedOut = useRef( false );
/**
* Runs when zoom out mode is toggled, and sets the startAnimation flag
* so the animation will start when the next useEffect runs. We _only_
* want to animate when the zoom out mode is toggled, not when the scale
* changes due to the container resizing.
*/
useEffect( () => {
const trigger =
iframeDocument && previousIsZoomedOut.current !== isZoomedOut;
previousIsZoomedOut.current = isZoomedOut;
if ( ! trigger ) {
return;
}
startAnimationRef.current = true;
if ( ! isZoomedOut ) {
return;
}
iframeDocument.documentElement.classList.add( 'is-zoomed-out' );
return () => {
iframeDocument.documentElement.classList.remove( 'is-zoomed-out' );
};
}, [ iframeDocument, isZoomedOut ] );
/**
* This handles:
* 1. Setting the correct scale and vars of the canvas when zoomed out
* 2. If zoom out mode has been toggled, runs the animation of zooming in/out
*/
useEffect( () => {
if ( ! iframeDocument ) {
return;
}
// We need to update the appropriate scale to exit from. If sidebars have been opened since setting the
// original scale, we will snap to a much smaller scale due to the scale container immediately changing sizes when exiting.
if ( isAutoScaled && transitionFromRef.current.scaleValue !== 1 ) {
// We use containerWidth as the divisor, as scaleContainerWidth will always match the containerWidth when
// exiting.
transitionFromRef.current.scaleValue = calculateScale( {
frameSize: transitionFromRef.current.frameSize,
containerWidth,
maxContainerWidth,
scaleContainerWidth: containerWidth,
} );
}
if ( scaleValue < 1 ) {
// If we are not going to animate the transition, set the scale and frame size directly.
// If we are animating, these values will be set when the animation is finished.
// Example: Opening sidebars that reduce the scale of the canvas, but we don't want to
// animate the transition.
if ( ! startAnimationRef.current ) {
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-scale',
scaleValue
);
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-frame-size',
`${ frameSize }px`
);
}
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-content-height',
`${ contentHeight }px`
);
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-inner-height',
`${ containerHeight }px`
);
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-container-width',
`${ containerWidth }px`
);
iframeDocument.documentElement.style.setProperty(
'--wp-block-editor-iframe-zoom-out-scale-container-width',
`${ scaleContainerWidth }px`
);
}
/**
* Handle the zoom out animation:
*
* - Get the current scrollTop position.
* - Calculate where the same scroll position is after scaling.
* - Apply fixed positioning to the canvas with a transform offset
* to keep the canvas centered.
* - Animate the scale and padding to the new scale and frame size.
* - After the animation is complete, remove the fixed positioning
* and set the scroll position that keeps everything centered.
*/
if ( startAnimationRef.current ) {
// Don't allow a new transition to start again unless it was started by the zoom out mode changing.
startAnimationRef.current = false;
/**
* If we already have an animation running, reverse it.
*/
if ( animationRef.current ) {
animationRef.current.reverse();
// Swap the transition to/from refs so that we set the correct values when
// finishZoomOutAnimation runs.
const tempTransitionFrom = transitionFromRef.current;
const tempTransitionTo = transitionToRef.current;
transitionFromRef.current = tempTransitionTo;
transitionToRef.current = tempTransitionFrom;
} else {
/**
* Start a new zoom animation.
*/
// We can't trust the set value from contentHeight, as it was measured
// before the zoom out mode was changed. After zoom out mode is changed,
// appenders may appear or disappear, so we need to get the height from
// the iframe at this point when we're about to animate the zoom out.
// The iframe scrollTop, scrollHeight, and clientHeight will all be
// the most accurate.
transitionFromRef.current.scrollTop =
iframeDocument.documentElement.scrollTop;
transitionFromRef.current.scrollHeight =
iframeDocument.documentElement.scrollHeight;
// Use containerHeight, as it's the previous container height before the zoom out animation starts.
transitionFromRef.current.containerHeight = containerHeight;
transitionToRef.current = {
scaleValue,
frameSize,
containerHeight:
iframeDocument.documentElement.clientHeight, // use clientHeight to get the actual height of the new container after zoom state changes have rendered, as it will be the most up-to-date.
};
transitionToRef.current.scrollHeight = computeScrollHeightNext(
transitionFromRef.current,
transitionToRef.current
);
transitionToRef.current.scrollTop = computeScrollTopNext(
transitionFromRef.current,
transitionToRef.current
);
animationRef.current = startZoomOutAnimation();
// If the user prefers reduced motion, finish the animation immediately and set the final state.
if ( prefersReducedMotion ) {
finishZoomOutAnimation();
} else {
animationRef.current.onfinish = finishZoomOutAnimation;
}
}
}
}, [
startZoomOutAnimation,
finishZoomOutAnimation,
prefersReducedMotion,
isAutoScaled,
scaleValue,
frameSize,
iframeDocument,
contentHeight,
containerWidth,
containerHeight,
maxContainerWidth,
scaleContainerWidth,
] );
return {
isZoomedOut,
scaleContainerWidth,
contentResizeListener,
containerResizeListener,
};
}