From eadf2dda88e69176c9d577ed7cd197a3da9197a2 Mon Sep 17 00:00:00 2001 From: Jerry Jones Date: Tue, 26 Nov 2024 14:33:09 -0600 Subject: [PATCH] Add useScaleCanvas: Web Animations API for zoom in/out animation (#66917) * Move code into useScaleCanvas that isn't necessary in iframe index * Use Web Animations API for zoom in/out * Add reverse animation if zoom state is toggled quickly * Use transitionTo and transitionFrom refs to manage start and end points of transition --------- Co-authored-by: Alex Lende --- packages/base-styles/_animations.scss | 5 - .../src/components/block-canvas/style.scss | 3 +- .../src/components/iframe/content.scss | 75 ++- .../src/components/iframe/index.js | 326 +----------- .../src/components/iframe/use-scale-canvas.js | 468 ++++++++++++++++++ 5 files changed, 517 insertions(+), 360 deletions(-) create mode 100644 packages/block-editor/src/components/iframe/use-scale-canvas.js diff --git a/packages/base-styles/_animations.scss b/packages/base-styles/_animations.scss index ae5de9a803008c..e5bbf863757356 100644 --- a/packages/base-styles/_animations.scss +++ b/packages/base-styles/_animations.scss @@ -41,8 +41,3 @@ @warn "The `edit-post__fade-in-animation` mixin is deprecated. Use `animation__fade-in` instead."; @include animation__fade-in($speed, $delay); } - -@mixin editor-canvas-resize-animation($additional-transition-rules...) { - transition: all 400ms cubic-bezier(0.46, 0.03, 0.52, 0.96), $additional-transition-rules; - @include reduce-motion("transition"); -} diff --git a/packages/block-editor/src/components/block-canvas/style.scss b/packages/block-editor/src/components/block-canvas/style.scss index 8f6064de0b615c..ea54646e64a59a 100644 --- a/packages/block-editor/src/components/block-canvas/style.scss +++ b/packages/block-editor/src/components/block-canvas/style.scss @@ -4,6 +4,7 @@ iframe[name="editor-canvas"] { height: 100%; display: block; // Handles transitions between device previews - @include editor-canvas-resize-animation; + transition: all 400ms cubic-bezier(0.46, 0.03, 0.52, 0.96); + @include reduce-motion("transition"); background-color: $gray-300; } diff --git a/packages/block-editor/src/components/iframe/content.scss b/packages/block-editor/src/components/iframe/content.scss index 5e390800719949..9b02716671de77 100644 --- a/packages/block-editor/src/components/iframe/content.scss +++ b/packages/block-editor/src/components/iframe/content.scss @@ -4,10 +4,8 @@ .block-editor-iframe__html { transform-origin: top center; - // We don't want to animate the transform of the translateX because it is used - // to "center" the canvas. Leaving it on causes the canvas to slide around in - // odd ways. - @include editor-canvas-resize-animation( transform 0s, scale 0s, padding 0s, translate 0s); + // Prevents a flash of background color change when entering/exiting zoom out + transition: background-color 400ms; &.zoom-out-animation { $scroll-top: var(--wp-block-editor-iframe-zoom-out-scroll-top, 0); @@ -18,53 +16,48 @@ right: 0; top: calc(-1 * #{$scroll-top}); bottom: 0; - translate: 0 calc(#{$scroll-top} - #{$scroll-top-next}); // Force preserving a scrollbar gutter as scrollbar-gutter isn't supported in all browsers yet, // and removing the scrollbar causes the content to shift. overflow-y: scroll; - - // We only want to animate the scaling when entering zoom out. When sidebars - // are toggled, the resizing of the iframe handles scaling the canvas as well, - // and the doubled animations cause very odd animations. - @include editor-canvas-resize-animation( transform 0s, top 0s, bottom 0s, right 0s, left 0s ); } -} -.block-editor-iframe__html.is-zoomed-out { - $scale: var(--wp-block-editor-iframe-zoom-out-scale); - $frame-size: var(--wp-block-editor-iframe-zoom-out-frame-size); - $inner-height: var(--wp-block-editor-iframe-zoom-out-inner-height); - $content-height: var(--wp-block-editor-iframe-zoom-out-content-height); - $scale-container-width: var(--wp-block-editor-iframe-zoom-out-scale-container-width); - $container-width: var(--wp-block-editor-iframe-zoom-out-container-width, 100vw); - // Apply an X translation to center the scaled content within the available space. - transform: translateX(calc((#{$scale-container-width} - #{$container-width}) / 2 / #{$scale})); - scale: #{$scale}; - background-color: $gray-300; + &.is-zoomed-out { + $scale: var(--wp-block-editor-iframe-zoom-out-scale, 1); + $frame-size: var(--wp-block-editor-iframe-zoom-out-frame-size, 0); + $inner-height: var(--wp-block-editor-iframe-zoom-out-inner-height); + $content-height: var(--wp-block-editor-iframe-zoom-out-content-height); + $scale-container-width: var(--wp-block-editor-iframe-zoom-out-scale-container-width); + $container-width: var(--wp-block-editor-iframe-zoom-out-container-width, 100vw); + // Apply an X translation to center the scaled content within the available space. + transform: translateX(calc((#{$scale-container-width} - #{$container-width}) / 2 / #{$scale})); + scale: $scale; + background-color: $gray-300; - // Chrome seems to respect that transform scale shouldn't affect the layout size of the element, - // so we need to adjust the height of the content to match the scale by using negative margins. - $extra-content-height: calc(#{$content-height} * (1 - #{$scale})); - $total-frame-height: calc(2 * #{$frame-size} / #{$scale}); - $total-height: calc(#{$extra-content-height} + #{$total-frame-height} + 2px); - margin-bottom: calc(-1 * #{$total-height}); - // Add the top/bottom frame size. We use scaling to account for the left/right, as - // the padding left/right causes the contents to reflow, which breaks the 1:1 scaling - // of the content. - padding-top: calc(#{$frame-size} / #{$scale}); - padding-bottom: calc(#{$frame-size} / #{$scale}); + // Chrome seems to respect that transform scale shouldn't affect the layout size of the element, + // so we need to adjust the height of the content to match the scale by using negative margins. + $extra-content-height: calc(#{$content-height} * (1 - #{$scale})); + $total-frame-height: calc(2 * #{$frame-size} / #{$scale}); + $total-height: calc(#{$extra-content-height} + #{$total-frame-height} + 2px); + margin-bottom: calc(-1 * #{$total-height}); - body { - min-height: calc((#{$inner-height} - #{$total-frame-height}) / #{$scale}); + // Add the top/bottom frame size. We use scaling to account for the left/right, as + // the padding left/right causes the contents to reflow, which breaks the 1:1 scaling + // of the content. + padding-top: calc(#{$frame-size} / #{$scale}); + padding-bottom: calc(#{$frame-size} / #{$scale}); - > .is-root-container:not(.wp-block-post-content) { - flex: 1; - display: flex; - flex-direction: column; - height: 100%; + body { + min-height: calc((#{$inner-height} - #{$total-frame-height}) / #{$scale}); - > main { + > .is-root-container:not(.wp-block-post-content) { flex: 1; + display: flex; + flex-direction: column; + height: 100%; + + > main { + flex: 1; + } } } } diff --git a/packages/block-editor/src/components/iframe/index.js b/packages/block-editor/src/components/iframe/index.js index f8b7c25084e38d..751e940dd166cc 100644 --- a/packages/block-editor/src/components/iframe/index.js +++ b/packages/block-editor/src/components/iframe/index.js @@ -12,16 +12,9 @@ import { forwardRef, useMemo, useEffect, - useRef, } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { - useResizeObserver, - useMergeRefs, - useRefEffect, - useDisabled, - useReducedMotion, -} from '@wordpress/compose'; +import { useMergeRefs, useRefEffect, useDisabled } from '@wordpress/compose'; import { __experimentalStyleProvider as StyleProvider } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; @@ -31,6 +24,7 @@ import { useSelect } from '@wordpress/data'; import { useBlockSelectionClearer } from '../block-selection-clearer'; import { useWritingFlow } from '../writing-flow'; import { getCompatibilityStyles } from './get-compatibility-styles'; +import { useScaleCanvas } from './use-scale-canvas'; import { store as blockEditorStore } from '../../store'; function bubbleEvent( event, Constructor, frame ) { @@ -124,15 +118,9 @@ function Iframe( { const { styles = '', scripts = '' } = resolvedAssets; /** @type {[Document, import('react').Dispatch]} */ const [ iframeDocument, setIframeDocument ] = useState(); - const initialContainerWidthRef = useRef( 0 ); const [ bodyClasses, setBodyClasses ] = useState( [] ); const clearerRef = useBlockSelectionClearer(); const [ before, writingFlowRef, after ] = useWritingFlow(); - const [ contentResizeListener, { height: contentHeight } ] = - useResizeObserver(); - const [ containerResizeListener, { width: containerWidth } ] = - useResizeObserver(); - const prefersReducedMotion = useReducedMotion(); const setRef = useRefEffect( ( node ) => { node._load = () => { @@ -228,61 +216,16 @@ function Iframe( { }; }, [] ); - const [ iframeWindowInnerHeight, setIframeWindowInnerHeight ] = useState(); - - const iframeResizeRef = useRefEffect( ( node ) => { - const nodeWindow = node.ownerDocument.defaultView; - - setIframeWindowInnerHeight( nodeWindow.innerHeight ); - const onResize = () => { - setIframeWindowInnerHeight( nodeWindow.innerHeight ); - }; - nodeWindow.addEventListener( 'resize', onResize ); - return () => { - nodeWindow.removeEventListener( 'resize', onResize ); - }; - }, [] ); - - const [ windowInnerWidth, setWindowInnerWidth ] = useState(); - - const windowResizeRef = useRefEffect( ( node ) => { - const nodeWindow = node.ownerDocument.defaultView; - - setWindowInnerWidth( nodeWindow.innerWidth ); - const onResize = () => { - setWindowInnerWidth( nodeWindow.innerWidth ); - }; - nodeWindow.addEventListener( 'resize', onResize ); - return () => { - nodeWindow.removeEventListener( 'resize', onResize ); - }; - }, [] ); - - const isZoomedOut = scale !== 1; - - useEffect( () => { - if ( ! isZoomedOut ) { - initialContainerWidthRef.current = containerWidth; - } - }, [ containerWidth, isZoomedOut ] ); - - const scaleContainerWidth = Math.max( - initialContainerWidthRef.current, - containerWidth - ); - - const frameSizeValue = parseInt( frameSize ); - - const maxWidth = 750; - const scaleValue = - scale === 'auto-scaled' - ? ( Math.min( containerWidth, maxWidth ) - frameSizeValue * 2 ) / - scaleContainerWidth - : scale; - - const prevScaleRef = useRef( scaleValue ); - const prevFrameSizeRef = useRef( frameSizeValue ); - const prevClientHeightRef = useRef( /* Initialized in the useEffect. */ ); + const { + contentResizeListener, + containerResizeListener, + isZoomedOut, + scaleContainerWidth, + } = useScaleCanvas( { + scale, + frameSize: parseInt( frameSize ), + iframeDocument, + } ); const disabledRef = useDisabled( { isDisabled: ! readonly } ); const bodyRef = useMergeRefs( [ @@ -291,10 +234,6 @@ function Iframe( { clearerRef, writingFlowRef, disabledRef, - // Avoid resize listeners when not needed, these will trigger - // unnecessary re-renders when animating the iframe width, or when - // expanding preview iframes. - isZoomedOut ? iframeResizeRef : null, ] ); // Correct doctype is required to enable rendering in standards @@ -336,245 +275,6 @@ function Iframe( { useEffect( () => cleanup, [ cleanup ] ); - useEffect( () => { - if ( - ! iframeDocument || - // HACK: Checking if isZoomedOut differs from prevIsZoomedOut here - // instead of the dependency array to appease the linter. - ( scaleValue === 1 ) === ( prevScaleRef.current === 1 ) - ) { - return; - } - - // Unscaled height of the current iframe container. - const clientHeight = iframeDocument.documentElement.clientHeight; - - // Scaled height of the current iframe content. - const scrollHeight = iframeDocument.documentElement.scrollHeight; - - // Previous scale value. - const prevScale = prevScaleRef.current; - - // Unscaled size of the previous padding around the iframe content. - const prevFrameSize = prevFrameSizeRef.current; - - // Unscaled height of the previous iframe container. - const prevClientHeight = prevClientHeightRef.current ?? clientHeight; - - // 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 - // accurate. The client height also does change when the zoom out mode - // is toggled, as the bottom bar about selecting the template is - // added/removed when toggling zoom out mode. - const scrollTop = iframeDocument.documentElement.scrollTop; - - // Step 0: Start with the current scrollTop. - let scrollTopNext = scrollTop; - - // Step 1: Undo the effects of the previous scale and frame around the - // midpoint of the visible area. - scrollTopNext = - ( scrollTopNext + prevClientHeight / 2 - prevFrameSize ) / - prevScale - - prevClientHeight / 2; - - // Step 2: Apply the new scale and frame around the midpoint of the - // visible area. - scrollTopNext = - ( scrollTopNext + clientHeight / 2 ) * scaleValue + - frameSizeValue - - clientHeight / 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 = scrollTop <= 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 * ( scaleValue / prevScale ) + - frameSizeValue * 2 - - clientHeight; - - // 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. - scrollTopNext = Math.round( - Math.min( - Math.max( 0, scrollTopNext ), - Math.max( 0, maxScrollTop ) - ) - ); - - 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` - ); - - iframeDocument.documentElement.classList.add( 'zoom-out-animation' ); - - function onZoomOutTransitionEnd() { - // Remove the position fixed for the animation. - iframeDocument.documentElement.classList.remove( - 'zoom-out-animation' - ); - - // Update previous values. - prevClientHeightRef.current = clientHeight; - prevFrameSizeRef.current = frameSizeValue; - prevScaleRef.current = scaleValue; - - // 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 = scrollTopNext; - } - - let raf; - if ( prefersReducedMotion ) { - // Hack: Wait for the window values to recalculate. - raf = iframeDocument.defaultView.requestAnimationFrame( - onZoomOutTransitionEnd - ); - } else { - iframeDocument.documentElement.addEventListener( - 'transitionend', - onZoomOutTransitionEnd, - { once: true } - ); - } - - return () => { - 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.classList.remove( - 'zoom-out-animation' - ); - if ( prefersReducedMotion ) { - iframeDocument.defaultView.cancelAnimationFrame( raf ); - } else { - iframeDocument.documentElement.removeEventListener( - 'transitionend', - onZoomOutTransitionEnd - ); - } - }; - }, [ iframeDocument, scaleValue, frameSizeValue, prefersReducedMotion ] ); - - // Toggle zoom out CSS Classes only when zoom out mode changes. We could add these into the useEffect - // that controls settings the CSS variables, but then we would need to do more work to ensure we're - // only toggling these when the zoom out mode changes, as that useEffect is also triggered by a large - // number of dependencies. - useEffect( () => { - if ( ! iframeDocument ) { - return; - } - - if ( isZoomedOut ) { - iframeDocument.documentElement.classList.add( 'is-zoomed-out' ); - } else { - // HACK: Since we can't remove this in the cleanup, we need to do it here. - iframeDocument.documentElement.classList.remove( 'is-zoomed-out' ); - } - - return () => { - // HACK: Skipping cleanup because it causes issues with the zoom out - // animation. More refactoring is needed to fix this properly. - // iframeDocument.documentElement.classList.remove( 'is-zoomed-out' ); - }; - }, [ iframeDocument, isZoomedOut ] ); - - // Calculate the scaling and CSS variables for the zoom out canvas - useEffect( () => { - if ( ! iframeDocument ) { - return; - } - - // Note: When we initialize the zoom out when the canvas is smaller (sidebars open), - // initialContainerWidthRef will be smaller than the full page, and reflow will happen - // when the canvas area becomes larger due to sidebars closing. This is a known but - // minor divergence for now. - - // This scaling calculation has to happen within the JS because CSS calc() can - // only divide and multiply by a unitless value. I.e. calc( 100px / 2 ) is valid - // but calc( 100px / 2px ) is not. - iframeDocument.documentElement.style.setProperty( - '--wp-block-editor-iframe-zoom-out-scale', - scaleValue - ); - - // frameSize has to be a px value for the scaling and frame size to be computed correctly. - iframeDocument.documentElement.style.setProperty( - '--wp-block-editor-iframe-zoom-out-frame-size', - typeof frameSize === 'number' ? `${ frameSize }px` : frameSize - ); - 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', - `${ iframeWindowInnerHeight }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` - ); - - return () => { - // HACK: Skipping cleanup because it causes issues with the zoom out - // animation. More refactoring is needed to fix this properly. - // iframeDocument.documentElement.style.removeProperty( - // '--wp-block-editor-iframe-zoom-out-scale' - // ); - // iframeDocument.documentElement.style.removeProperty( - // '--wp-block-editor-iframe-zoom-out-frame-size' - // ); - // iframeDocument.documentElement.style.removeProperty( - // '--wp-block-editor-iframe-zoom-out-content-height' - // ); - // iframeDocument.documentElement.style.removeProperty( - // '--wp-block-editor-iframe-zoom-out-inner-height' - // ); - // iframeDocument.documentElement.style.removeProperty( - // '--wp-block-editor-iframe-zoom-out-container-width' - // ); - // iframeDocument.documentElement.style.removeProperty( - // '--wp-block-editor-iframe-zoom-out-scale-container-width' - // ); - }; - }, [ - scaleValue, - frameSize, - iframeDocument, - iframeWindowInnerHeight, - contentHeight, - containerWidth, - windowInnerWidth, - isZoomedOut, - scaleContainerWidth, - ] ); - // Make sure to not render the before and after focusable div elements in view // mode. They're only needed to capture focus in edit mode. const shouldRenderFocusCaptureElements = tabIndex >= 0 && ! isPreviewMode; @@ -654,7 +354,7 @@ function Iframe( { ); return ( -
+
{ containerResizeListener }
{ + 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} + */ + const transitionFromRef = useRef( { + scaleValue, + frameSize, + clientHeight: 0, + scrollTop: 0, + scrollHeight: 0, + } ); + + /** + * The ending transition state for the zoom out animation. + * @type {import('react').RefObject} + */ + const transitionToRef = useRef( { + scaleValue, + frameSize, + clientHeight: 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` + ); + + 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' + ); + + // Update previous values. + transitionFromRef.current = transitionToRef.current; + }, [ iframeDocument ] ); + + /** + * 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( () => { + if ( ! iframeDocument ) { + return; + } + + if ( isZoomedOut ) { + iframeDocument.documentElement.classList.add( 'is-zoomed-out' ); + } + + startAnimationRef.current = true; + + 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 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` + ); + + const clientHeight = iframeDocument.documentElement.clientHeight; + iframeDocument.documentElement.style.setProperty( + '--wp-block-editor-iframe-zoom-out-inner-height', + `${ clientHeight }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.clientHeight = + transitionFromRef.current.clientHeight ?? clientHeight; + transitionFromRef.current.scrollTop = + iframeDocument.documentElement.scrollTop; + transitionFromRef.current.scrollHeight = + iframeDocument.documentElement.scrollHeight; + + transitionToRef.current = { + scaleValue, + frameSize, + clientHeight, + }; + 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; + } + } + } + + return () => { + iframeDocument.documentElement.style.removeProperty( + '--wp-block-editor-iframe-zoom-out-scale' + ); + iframeDocument.documentElement.style.removeProperty( + '--wp-block-editor-iframe-zoom-out-frame-size' + ); + iframeDocument.documentElement.style.removeProperty( + '--wp-block-editor-iframe-zoom-out-content-height' + ); + iframeDocument.documentElement.style.removeProperty( + '--wp-block-editor-iframe-zoom-out-inner-height' + ); + iframeDocument.documentElement.style.removeProperty( + '--wp-block-editor-iframe-zoom-out-container-width' + ); + iframeDocument.documentElement.style.removeProperty( + '--wp-block-editor-iframe-zoom-out-scale-container-width' + ); + }; + }, [ + startZoomOutAnimation, + finishZoomOutAnimation, + prefersReducedMotion, + isAutoScaled, + scaleValue, + frameSize, + iframeDocument, + contentHeight, + containerWidth, + maxContainerWidth, + scaleContainerWidth, + ] ); + + return { + isZoomedOut, + scaleContainerWidth, + contentResizeListener, + containerResizeListener, + }; +}