diff --git a/packages/block-editor/src/components/iframe/content.scss b/packages/block-editor/src/components/iframe/content.scss index 596c177eab2f32..5e390800719949 100644 --- a/packages/block-editor/src/components/iframe/content.scss +++ b/packages/block-editor/src/components/iframe/content.scss @@ -7,13 +7,26 @@ // 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); + @include editor-canvas-resize-animation( transform 0s, scale 0s, padding 0s, translate 0s); &.zoom-out-animation { - // we only want to animate the scaling when entering zoom out. When sidebars + $scroll-top: var(--wp-block-editor-iframe-zoom-out-scroll-top, 0); + $scroll-top-next: var(--wp-block-editor-iframe-zoom-out-scroll-top-next, 0); + + position: fixed; + left: 0; + 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); + @include editor-canvas-resize-animation( transform 0s, top 0s, bottom 0s, right 0s, left 0s ); } } diff --git a/packages/block-editor/src/components/iframe/index.js b/packages/block-editor/src/components/iframe/index.js index 76d2e09dfb7a30..f8b7c25084e38d 100644 --- a/packages/block-editor/src/components/iframe/index.js +++ b/packages/block-editor/src/components/iframe/index.js @@ -20,6 +20,7 @@ import { useMergeRefs, useRefEffect, useDisabled, + useReducedMotion, } from '@wordpress/compose'; import { __experimentalStyleProvider as StyleProvider } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; @@ -121,6 +122,7 @@ function Iframe( { }; }, [] ); const { styles = '', scripts = '' } = resolvedAssets; + /** @type {[Document, import('react').Dispatch]} */ const [ iframeDocument, setIframeDocument ] = useState(); const initialContainerWidthRef = useRef( 0 ); const [ bodyClasses, setBodyClasses ] = useState( [] ); @@ -130,6 +132,7 @@ function Iframe( { useResizeObserver(); const [ containerResizeListener, { width: containerWidth } ] = useResizeObserver(); + const prefersReducedMotion = useReducedMotion(); const setRef = useRefEffect( ( node ) => { node._load = () => { @@ -268,6 +271,19 @@ function Iframe( { 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 disabledRef = useDisabled( { isDisabled: ! readonly } ); const bodyRef = useMergeRefs( [ useBubbleEvents( iframeDocument ), @@ -320,47 +336,176 @@ function Iframe( { useEffect( () => cleanup, [ cleanup ] ); - const zoomOutAnimationClassnameRef = useRef( null ); - - // 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 || ! isZoomedOut ) { + 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; } - const handleZoomOutAnimationClassname = () => { - clearTimeout( zoomOutAnimationClassnameRef.current ); + // 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.classList.add( + 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' ); - zoomOutAnimationClassnameRef.current = setTimeout( () => { - 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 ); - }, 400 ); // 400ms should match the animation speed used in components/iframe/content.scss + } }; + }, [ iframeDocument, scaleValue, frameSizeValue, prefersReducedMotion ] ); - handleZoomOutAnimationClassname(); - iframeDocument.documentElement.classList.add( 'is-zoomed-out' ); + // 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; + } - return () => { - handleZoomOutAnimationClassname(); + 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 || ! isZoomedOut ) { + if ( ! iframeDocument ) { return; } - const maxWidth = 750; // 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 @@ -371,11 +516,7 @@ function Iframe( { // but calc( 100px / 2px ) is not. iframeDocument.documentElement.style.setProperty( '--wp-block-editor-iframe-zoom-out-scale', - scale === 'auto-scaled' - ? ( Math.min( containerWidth, maxWidth ) - - parseInt( frameSize ) * 2 ) / - scaleContainerWidth - : scale + scaleValue ); // frameSize has to be a px value for the scaling and frame size to be computed correctly. @@ -401,27 +542,29 @@ function Iframe( { ); 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' - ); + // 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' + // ); }; }, [ - scale, + scaleValue, frameSize, iframeDocument, iframeWindowInnerHeight, diff --git a/test/e2e/specs/site-editor/zoom-out.spec.js b/test/e2e/specs/site-editor/zoom-out.spec.js index 464bd4a4a4efad..e698a94b7cf0dc 100644 --- a/test/e2e/specs/site-editor/zoom-out.spec.js +++ b/test/e2e/specs/site-editor/zoom-out.spec.js @@ -3,6 +3,63 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const EDITOR_ZOOM_OUT_CONTENT = ` + +
+

First Section Start

+ + + +

First Section Center

+ + + +

First Section End

+
+ + + +
+

Second Section Start

+ + + +

Second Section Center

+ + + +

Second Section End

+
+ + + +
+

Third Section Start

+ + + +

Third Section Center

+ + + +

Third Section End

+
+ + + +
+

Fourth Section Start

+ + + +

Fourth Section Center

+ + + +

Fourth Section End

+
+`; + test.describe( 'Zoom Out', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'twentytwentyfour' ); @@ -47,4 +104,115 @@ test.describe( 'Zoom Out', () => { expect( htmlRect.y + paddingTop ).toBeGreaterThan( iframeRect.y ); expect( htmlRect.x ).toBeGreaterThan( iframeRect.x ); } ); + + test( 'Toggling zoom state should keep content centered', async ( { + page, + editor, + } ) => { + // Add some patterns into the page. + await editor.setContent( EDITOR_ZOOM_OUT_CONTENT ); + // Find the scroll container element + await page.evaluate( () => { + const { activeElement } = + document.activeElement?.contentDocument ?? document; + window.scrollContainer = + window.wp.dom.getScrollContainer( activeElement ); + return window.scrollContainer; + } ); + + // Test: Test from top of page (scrollTop 0) + // Enter Zoom Out + await page.getByRole( 'button', { name: 'Zoom Out' } ).click(); + + const scrollTopZoomed = await page.evaluate( () => { + return window.scrollContainer.scrollTop; + } ); + + expect( scrollTopZoomed ).toBe( 0 ); + + // Exit Zoom Out + await page.getByRole( 'button', { name: 'Zoom Out' } ).click(); + + const scrollTopNoZoom = await page.evaluate( () => { + return window.scrollContainer.scrollTop; + } ); + + expect( scrollTopNoZoom ).toBe( 0 ); + + // Test: Should center the scroll position when zooming out/in + const firstSectionEnd = editor.canvas.locator( + 'text=First Section End' + ); + const secondSectionStart = editor.canvas.locator( + 'text=Second Section Start' + ); + const secondSectionCenter = editor.canvas.locator( + 'text=Second Section Center' + ); + const secondSectionEnd = editor.canvas.locator( + 'text=Second Section End' + ); + const thirdSectionStart = editor.canvas.locator( + 'text=Third Section Start' + ); + const thirdSectionCenter = editor.canvas.locator( + 'text=Third Section Center' + ); + const thirdSectionEnd = editor.canvas.locator( + 'text=Third Section End' + ); + const fourthSectionStart = editor.canvas.locator( + 'text=Fourth Section Start' + ); + + // Test for second section + // Playwright scrolls it to the center of the viewport, so this is what we scroll to. + await secondSectionCenter.scrollIntoViewIfNeeded(); + + // Because the text is spread with a group height of 100vh, they should both be visible. + await expect( firstSectionEnd ).not.toBeInViewport(); + await expect( secondSectionStart ).toBeInViewport(); + await expect( secondSectionEnd ).toBeInViewport(); + await expect( thirdSectionStart ).not.toBeInViewport(); + + // After zooming, if we zoomed out with the correct central point, they should both still be visible when toggling zoom out state + // Enter Zoom Out + await page.getByRole( 'button', { name: 'Zoom Out' } ).click(); + await expect( firstSectionEnd ).toBeInViewport(); + await expect( secondSectionStart ).toBeInViewport(); + await expect( secondSectionEnd ).toBeInViewport(); + await expect( thirdSectionStart ).toBeInViewport(); + + // Exit Zoom Out + await page.getByRole( 'button', { name: 'Zoom Out' } ).click(); + await expect( firstSectionEnd ).not.toBeInViewport(); + await expect( secondSectionStart ).toBeInViewport(); + await expect( secondSectionEnd ).toBeInViewport(); + await expect( thirdSectionStart ).not.toBeInViewport(); + + // Test for third section + // Playwright scrolls it to the center of the viewport, so this is what we scroll to. + await thirdSectionCenter.scrollIntoViewIfNeeded(); + + // Because the text is spread with a group height of 100vh, they should both be visible. + await expect( secondSectionEnd ).not.toBeInViewport(); + await expect( thirdSectionStart ).toBeInViewport(); + await expect( thirdSectionEnd ).toBeInViewport(); + await expect( fourthSectionStart ).not.toBeInViewport(); + + // After zooming, if we zoomed out with the correct central point, they should both still be visible when toggling zoom out state + // Enter Zoom Out + await page.getByRole( 'button', { name: 'Zoom Out' } ).click(); + await expect( secondSectionEnd ).toBeInViewport(); + await expect( thirdSectionStart ).toBeInViewport(); + await expect( thirdSectionEnd ).toBeInViewport(); + await expect( fourthSectionStart ).toBeInViewport(); + + // Exit Zoom Out + await page.getByRole( 'button', { name: 'Zoom Out' } ).click(); + await expect( secondSectionEnd ).not.toBeInViewport(); + await expect( thirdSectionStart ).toBeInViewport(); + await expect( thirdSectionEnd ).toBeInViewport(); + await expect( fourthSectionStart ).not.toBeInViewport(); + } ); } );