From c689d9ff27be17c6a47d07517c87f7bec6b95a23 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Sat, 20 May 2023 05:25:20 +1000 Subject: [PATCH] Update frame resizing (#49910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * frame resizer centered * Use a lerp function to modify the height of the frame. It should gradually reduce from original aspect ratio until reaching a 9 / 19.5 view. * Make the frame full screen when the user resizes it to the left. Reset the initial aspect ratio if the frame is resized slightly, and trigger full screen if the frame is resized far enough over the sidebar. * Ensure the frame grows only to the left when going above its size. * Disable user selection while resizing the frame. * Make it easier to grab the handle. * Switch to setTimeout and set a fixed resizeRatio. * Modify oversized calculation to reduce resizing bug. * Avoid timer * Temp working * Clean up CSS * More cleanup * Refactor lerpy parts * More cleanup * Rename `isFull` to `isFullWidth` * Improve maintainability * More cleanup * Match component classnames * Invert control for flex changes * Calculate fluid resize ratio * Prevent React re-render loop warning * Always show handle when resizing * Maintain resizing cursor when resizing * Improve code comments * Exclude `ListPage` from ResizableFrame * Use CSS var for accent color * Handle spinner gracefully * Lift loading state so resizing can be disabled * Change max width for less jankiness * Remove outdated padding animation * Clean up magic numbers * Update saveSiteEditorEntities() locators * Update StyleBook.open() locators * Quickfix: Wait until load spinner is gone * Revert to class-based save detection The `.getByRole()` way resolves a bit too early. --------- Co-authored-by: Matías Ventura Co-authored-by: Lena Morita Co-authored-by: Marco Ciampini Co-authored-by: Bart Kalisz --- .../src/admin/visit-site-editor.ts | 5 + .../src/editor/site-editor.ts | 28 +- .../edit-site/src/components/editor/index.js | 56 ++-- .../src/components/editor/style.scss | 10 + .../edit-site/src/components/layout/hooks.js | 46 ++++ .../edit-site/src/components/layout/index.js | 147 +++------- .../src/components/layout/style.scss | 10 +- .../src/components/resizable-frame/index.js | 253 ++++++++++++++++++ .../src/components/resizable-frame/style.scss | 69 +++++ packages/edit-site/src/style.scss | 1 + test/e2e/specs/site-editor/style-book.spec.js | 7 +- 11 files changed, 461 insertions(+), 171 deletions(-) create mode 100644 packages/edit-site/src/components/layout/hooks.js create mode 100644 packages/edit-site/src/components/resizable-frame/index.js create mode 100644 packages/edit-site/src/components/resizable-frame/style.scss diff --git a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts index a4c3adc747a1e7..bd25796d25eccf 100644 --- a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts +++ b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts @@ -54,4 +54,9 @@ export async function visitSiteEditor( .locator( 'body > *' ) .first() .waitFor(); + + // TODO: Ideally the content underneath the spinner should be marked inert until it's ready. + await this.page + .locator( '.edit-site-canvas-spinner' ) + .waitFor( { state: 'hidden' } ); } diff --git a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts index d3fb58f9aab401..432e8c15b120a7 100644 --- a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts +++ b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts @@ -9,15 +9,25 @@ import type { Editor } from './index'; * @param this */ export async function saveSiteEditorEntities( this: Editor ) { - await this.page.click( - 'role=region[name="Editor top bar"i] >> role=button[name="Save"i]' - ); + const editorTopBar = this.page.getByRole( 'region', { + name: 'Editor top bar', + } ); + const savePanel = this.page.getByRole( 'region', { name: 'Save panel' } ); + + // First Save button in the top bar. + await editorTopBar + .getByRole( 'button', { name: 'Save', exact: true } ) + .click(); + // Second Save button in the entities panel. - await this.page.click( - 'role=region[name="Save panel"i] >> role=button[name="Save"i]' - ); + await savePanel + .getByRole( 'button', { name: 'Save', exact: true } ) + .click(); + // A role selector cannot be used here because it needs to check that the `is-busy` class is not present. - await this.page.waitForSelector( '[aria-label="Saved"].is-busy', { - state: 'hidden', - } ); + await this.page + .locator( '[aria-label="Editor top bar"] [aria-label="Saved"].is-busy' ) + .waitFor( { + state: 'hidden', + } ); } diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index 777f6dd53afdf6..44b31c6945f57f 100644 --- a/packages/edit-site/src/components/editor/index.js +++ b/packages/edit-site/src/components/editor/index.js @@ -1,10 +1,15 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ -import { useEffect, useMemo, useState } from '@wordpress/element'; +import { useMemo } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { Notice } from '@wordpress/components'; -import { EntityProvider, store as coreStore } from '@wordpress/core-data'; +import { EntityProvider } from '@wordpress/core-data'; import { store as preferencesStore } from '@wordpress/preferences'; import { BlockContextProvider, @@ -50,42 +55,7 @@ const interfaceLabels = { footer: __( 'Editor footer' ), }; -function useIsSiteEditorLoading() { - const { isLoaded: hasLoadedPost } = useEditedEntityRecord(); - const [ loaded, setLoaded ] = useState( false ); - const inLoadingPause = useSelect( - ( select ) => { - const hasResolvingSelectors = - select( coreStore ).hasResolvingSelectors(); - return ! loaded && ! hasResolvingSelectors; - }, - [ loaded ] - ); - - useEffect( () => { - if ( inLoadingPause ) { - /* - * We're using an arbitrary 1s timeout here to catch brief moments - * without any resolving selectors that would result in displaying - * brief flickers of loading state and loaded state. - * - * It's worth experimenting with different values, since this also - * adds 1s of artificial delay after loading has finished. - */ - const timeout = setTimeout( () => { - setLoaded( true ); - }, 1000 ); - - return () => { - clearTimeout( timeout ); - }; - } - }, [ inLoadingPause ] ); - - return ! loaded || ! hasLoadedPost; -} - -export default function Editor() { +export default function Editor( { isLoading } ) { const { record: editedPost, getTitle, @@ -188,8 +158,6 @@ export default function Editor() { // action in from double-announcing. useTitle( hasLoadedPost && title ); - const isLoading = useIsSiteEditorLoading(); - return ( <> { isLoading ? : null } @@ -205,7 +173,13 @@ export default function Editor() { { isEditMode && } { + const hasResolvingSelectors = + select( coreStore ).hasResolvingSelectors(); + return ! loaded && ! hasResolvingSelectors; + }, + [ loaded ] + ); + + useEffect( () => { + if ( inLoadingPause ) { + /* + * We're using an arbitrary 1s timeout here to catch brief moments + * without any resolving selectors that would result in displaying + * brief flickers of loading state and loaded state. + * + * It's worth experimenting with different values, since this also + * adds 1s of artificial delay after loading has finished. + */ + const timeout = setTimeout( () => { + setLoaded( true ); + }, 1000 ); + + return () => { + clearTimeout( timeout ); + }; + } + }, [ inLoadingPause ] ); + + return ! loaded || ! hasLoadedPost; +} diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js index 54b3f6ab22a1da..83527bae6d7e3b 100644 --- a/packages/edit-site/src/components/layout/index.js +++ b/packages/edit-site/src/components/layout/index.js @@ -11,7 +11,6 @@ import { __unstableMotion as motion, __unstableAnimatePresence as AnimatePresence, __unstableUseNavigateRegions as useNavigateRegions, - ResizableBox, } from '@wordpress/components'; import { useReducedMotion, @@ -42,30 +41,20 @@ import getIsListPage from '../../utils/get-is-list-page'; import Header from '../header-edit-mode'; import useInitEditedEntityFromURL from '../sync-state-with-url/use-init-edited-entity-from-url'; import SiteHub from '../site-hub'; -import ResizeHandle from '../block-editor/resize-handle'; +import ResizableFrame from '../resizable-frame'; import useSyncCanvasModeWithURL from '../sync-state-with-url/use-sync-canvas-mode-with-url'; import { unlock } from '../../private-apis'; import SavePanel from '../save-panel'; import KeyboardShortcutsRegister from '../keyboard-shortcuts/register'; import KeyboardShortcutsGlobal from '../keyboard-shortcuts/global'; import { useEditModeCommands } from '../../hooks/commands/use-edit-mode-commands'; +import { useIsSiteEditorLoading } from './hooks'; const { useCommands } = unlock( coreCommandsPrivateApis ); const { useCommandContext } = unlock( commandsPrivateApis ); const { useLocation } = unlock( routerPrivateApis ); const ANIMATION_DURATION = 0.5; -const emptyResizeHandleStyles = { - position: undefined, - userSelect: undefined, - cursor: undefined, - width: undefined, - height: undefined, - top: undefined, - right: undefined, - bottom: undefined, - left: undefined, -}; export default function Layout() { // This ensures the edited entity id and type are initialized properly. @@ -96,36 +85,26 @@ export default function Layout() { select( preferencesStore ).get( 'fixedToolbar' ), }; }, [] ); + const isEditing = canvasMode === 'edit'; const navigateRegionsProps = useNavigateRegions( { previous: previousShortcut, next: nextShortcut, } ); const disableMotion = useReducedMotion(); const isMobileViewport = useViewportMatch( 'medium', '<' ); - const canvasPadding = isMobileViewport ? 0 : 24; const showSidebar = ( isMobileViewport && ! isListPage ) || ( ! isMobileViewport && ( canvasMode === 'view' || ! isEditorPage ) ); const showCanvas = - ( isMobileViewport && isEditorPage && canvasMode === 'edit' ) || + ( isMobileViewport && isEditorPage && isEditing ) || ! isMobileViewport || ! isEditorPage; - const showFrame = - ( ! isEditorPage && ! isMobileViewport ) || - ( ! isMobileViewport && isEditorPage && canvasMode === 'view' ); const isFullCanvas = - ( isMobileViewport && isListPage ) || - ( isEditorPage && canvasMode === 'edit' ); + ( isMobileViewport && isListPage ) || ( isEditorPage && isEditing ); const [ canvasResizer, canvasSize ] = useResizeObserver(); - const [ fullResizer, fullSize ] = useResizeObserver(); - const [ forcedWidth, setForcedWidth ] = useState( null ); - const [ isResizing, setIsResizing ] = useState( false ); - const isResizingEnabled = ! isMobileViewport && canvasMode === 'view'; - const defaultSidebarWidth = isMobileViewport ? '100vw' : 360; - let canvasWidth = isResizing ? '100%' : fullSize.width; - if ( showFrame && ! isResizing ) { - canvasWidth = canvasSize.width - canvasPadding; - } + const [ fullResizer ] = useResizeObserver(); + const [ isResizing ] = useState( false ); + const isEditorLoading = useIsSiteEditorLoading(); // Sets the right context for the command center const commandContext = @@ -155,7 +134,7 @@ export default function Layout() { navigateRegionsProps.className, { 'is-full-canvas': isFullCanvas, - 'is-edit-mode': canvasMode === 'edit', + 'is-edit-mode': isEditing, 'has-fixed-toolbar': hasFixedToolbar, } ) } @@ -163,7 +142,7 @@ export default function Layout() { - { isEditorPage && canvasMode === 'edit' && ( + { isEditorPage && isEditing && ( -
+ { isEditing &&
} ) } @@ -193,8 +172,7 @@ export default function Layout() {
{ showSidebar && ( - { - setForcedWidth( elt.clientWidth ); - setIsResizing( false ); - } } - onResizeStart={ () => { - setIsResizing( true ); - } } - onResize={ ( event, direction, elt ) => { - // This is a performance optimization - // We set the width imperatively to avoid re-rendering - // the whole component while resizing. - hubRef.current.style.width = - elt.clientWidth - 48 + 'px'; - } } - handleComponent={ { - right: ( - { - setForcedWidth( - ( forcedWidth ?? - defaultSidebarWidth ) + - delta - ); - } } - /> - ), - } } - handleClasses={ undefined } - handleStyles={ { - right: emptyResizeHandleStyles, - } } - minWidth={ isResizingEnabled ? 320 : undefined } - maxWidth={ - isResizingEnabled && fullSize - ? fullSize.width - 360 - : undefined - } > - + ) } @@ -282,10 +208,6 @@ export default function Layout() { 'is-resizing': isResizing, } ) } - style={ { - paddingTop: showFrame ? canvasPadding : 0, - paddingBottom: showFrame ? canvasPadding : 0, - } } > { canvasResizer } { !! canvasSize.width && ( @@ -317,31 +239,22 @@ export default function Layout() { ease: 'easeOut', } } > - - - { isEditorPage && } - { isListPage && } - - + + { isEditorPage && ( + + + + ) } + { isListPage && } + ) }
diff --git a/packages/edit-site/src/components/layout/style.scss b/packages/edit-site/src/components/layout/style.scss index 89a6fa3c9ccf18..ecb15aac8fe1e0 100644 --- a/packages/edit-site/src/components/layout/style.scss +++ b/packages/edit-site/src/components/layout/style.scss @@ -105,7 +105,13 @@ left: 0; bottom: 0; width: 100%; - overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + + &:has(.edit-site-layout__resizable-frame-oversized) { + justify-content: flex-end; + } & > div { color: $gray-900; @@ -243,5 +249,5 @@ z-index: 3; } } - } + diff --git a/packages/edit-site/src/components/resizable-frame/index.js b/packages/edit-site/src/components/resizable-frame/index.js new file mode 100644 index 00000000000000..f5dff65f3749b5 --- /dev/null +++ b/packages/edit-site/src/components/resizable-frame/index.js @@ -0,0 +1,253 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useState, useRef, useEffect } from '@wordpress/element'; +import { + ResizableBox, + __unstableMotion as motion, +} from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { unlock } from '../../private-apis'; +import { store as editSiteStore } from '../../store'; + +// Removes the inline styles in the drag handles. +const HANDLE_STYLES_OVERRIDE = { + position: undefined, + userSelect: undefined, + cursor: undefined, + width: undefined, + height: undefined, + top: undefined, + right: undefined, + bottom: undefined, + left: undefined, +}; + +// The minimum width of the frame (in px) while resizing. +const FRAME_MIN_WIDTH = 340; +// The reference width of the frame (in px) used to calculate the aspect ratio. +const FRAME_REFERENCE_WIDTH = 1300; +// 9 : 19.5 is the target aspect ratio enforced (when possible) while resizing. +const FRAME_TARGET_ASPECT_RATIO = 9 / 19.5; +// The minimum distance (in px) between the frame resize handle and the +// viewport's edge. If the frame is resized to be closer to the viewport's edge +// than this distance, then "canvas mode" will be enabled. +const SNAP_TO_EDIT_CANVAS_MODE_THRESHOLD = 200; + +function calculateNewHeight( width, initialAspectRatio ) { + const lerp = ( a, b, amount ) => { + return a + ( b - a ) * amount; + }; + + // Calculate the intermediate aspect ratio based on the current width. + const lerpFactor = + 1 - + Math.max( + 0, + Math.min( + 1, + ( width - FRAME_MIN_WIDTH ) / + ( FRAME_REFERENCE_WIDTH - FRAME_MIN_WIDTH ) + ) + ); + + // Calculate the height based on the intermediate aspect ratio + // ensuring the frame arrives at the target aspect ratio. + const intermediateAspectRatio = lerp( + initialAspectRatio, + FRAME_TARGET_ASPECT_RATIO, + lerpFactor + ); + + return width / intermediateAspectRatio; +} + +function ResizableFrame( { + isFullWidth, + isReady, + children, + oversizedClassName, +} ) { + const [ frameSize, setFrameSize ] = useState( { + width: '100%', + height: '100%', + } ); + // The width of the resizable frame when a new resize gesture starts. + const [ startingWidth, setStartingWidth ] = useState(); + const [ isResizing, setIsResizing ] = useState( false ); + const [ isHovering, setIsHovering ] = useState( false ); + const [ isOversized, setIsOversized ] = useState( false ); + const [ resizeRatio, setResizeRatio ] = useState( 1 ); + const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); + const initialAspectRatioRef = useRef( null ); + // The width of the resizable frame on initial render. + const initialComputedWidthRef = useRef( null ); + const FRAME_TRANSITION = { type: 'tween', duration: isResizing ? 0 : 0.5 }; + const frameRef = useRef( null ); + + // Remember frame dimensions on initial render. + useEffect( () => { + const { offsetWidth, offsetHeight } = frameRef.current.resizable; + initialComputedWidthRef.current = offsetWidth; + initialAspectRatioRef.current = offsetWidth / offsetHeight; + }, [] ); + + const handleResizeStart = ( _event, _direction, ref ) => { + // Remember the starting width so we don't have to get `ref.offsetWidth` on + // every resize event thereafter, which will cause layout thrashing. + setStartingWidth( ref.offsetWidth ); + setIsResizing( true ); + }; + + // Calculate the frame size based on the window width as its resized. + const handleResize = ( _event, _direction, _ref, delta ) => { + const normalizedDelta = delta.width / resizeRatio; + const deltaAbs = Math.abs( normalizedDelta ); + const maxDoubledDelta = + delta.width < 0 // is shrinking + ? deltaAbs + : ( initialComputedWidthRef.current - startingWidth ) / 2; + const deltaToDouble = Math.min( deltaAbs, maxDoubledDelta ); + const doubleSegment = deltaAbs === 0 ? 0 : deltaToDouble / deltaAbs; + const singleSegment = 1 - doubleSegment; + + setResizeRatio( singleSegment + doubleSegment * 2 ); + + const updatedWidth = startingWidth + delta.width; + + setIsOversized( updatedWidth > initialComputedWidthRef.current ); + + // Width will be controlled by the library (via `resizeRatio`), + // so we only need to update the height. + setFrameSize( { + height: isOversized + ? '100%' + : calculateNewHeight( + updatedWidth, + initialAspectRatioRef.current + ), + } ); + }; + + const handleResizeStop = ( _event, _direction, ref ) => { + setIsResizing( false ); + + if ( ! isOversized ) { + return; + } + + setIsOversized( false ); + + const remainingWidth = + ref.ownerDocument.documentElement.offsetWidth - ref.offsetWidth; + + if ( remainingWidth > SNAP_TO_EDIT_CANVAS_MODE_THRESHOLD ) { + // Reset the initial aspect ratio if the frame is resized slightly + // above the sidebar but not far enough to trigger full screen. + setFrameSize( { width: '100%', height: '100%' } ); + } else { + // Trigger full screen if the frame is resized far enough to the left. + setCanvasMode( 'edit' ); + } + }; + + const frameAnimationVariants = { + default: { + flexGrow: 0, + height: frameSize.height, + }, + fullWidth: { + flexGrow: 1, + height: frameSize.height, + }, + }; + + return ( + { + if ( definition === 'fullWidth' ) + setFrameSize( { width: '100%', height: '100%' } ); + } } + transition={ FRAME_TRANSITION } + size={ frameSize } + enable={ { + top: false, + right: false, + bottom: false, + // Resizing will be disabled until the editor content is loaded. + left: isReady, + topRight: false, + bottomRight: false, + bottomLeft: false, + topLeft: false, + } } + resizeRatio={ resizeRatio } + handleClasses={ undefined } + handleStyles={ { + left: HANDLE_STYLES_OVERRIDE, + right: HANDLE_STYLES_OVERRIDE, + } } + minWidth={ FRAME_MIN_WIDTH } + maxWidth={ isFullWidth ? '100%' : '150%' } + maxHeight={ '100%' } + onMouseOver={ () => setIsHovering( true ) } + onMouseOut={ () => setIsHovering( false ) } + handleComponent={ { + left: + isHovering || isResizing ? ( + + ) : null, + } } + onResizeStart={ handleResizeStart } + onResize={ handleResize } + onResizeStop={ handleResizeStop } + className={ classnames( 'edit-site-resizable-frame__inner', { + 'is-resizing': isResizing, + [ oversizedClassName ]: isOversized, + } ) } + > + + { children } + + + ); +} + +export default ResizableFrame; diff --git a/packages/edit-site/src/components/resizable-frame/style.scss b/packages/edit-site/src/components/resizable-frame/style.scss new file mode 100644 index 00000000000000..2bd478b9bf9916 --- /dev/null +++ b/packages/edit-site/src/components/resizable-frame/style.scss @@ -0,0 +1,69 @@ +.edit-site-resizable-frame__inner { + position: relative; + + &.is-resizing { + @at-root { + body:has(&) { + cursor: col-resize; + user-select: none; + -webkit-user-select: none; + } + } + + &::before { + // This covers the whole content which ensures mouse up triggers + // even if the content is "inert". + position: absolute; + z-index: 1; + inset: 0; + content: ""; + } + } +} + +.edit-site-resizable-frame__inner-content { + position: absolute; + z-index: 0; + inset: 0; +} + +.edit-site-resizable-frame__handle { + position: absolute; + width: 5px; + height: 50px; + background-color: rgba(255, 255, 255, 0.3); + z-index: 100; + border-radius: 5px; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: flex-end; + top: 50%; + &::before { + position: absolute; + left: 100%; + height: 100%; + width: $grid-unit-30; + content: ""; + } + + &::after { + position: absolute; + right: 100%; + height: 100%; + width: $grid-unit-30; + content: ""; + } + + &:hover { + background-color: var(--wp-admin-theme-color); + } + + .edit-site-resizable-frame__handle-label { + border-radius: 2px; + background: var(--wp-admin-theme-color); + padding: 4px 8px; + color: #fff; + margin-right: $grid-unit-10; + } +} diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 7a0233ebee5247..3be15cd02d2599 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -34,6 +34,7 @@ @import "./components/site-icon/style.scss"; @import "./components/style-book/style.scss"; @import "./components/editor-canvas-container/style.scss"; +@import "./components/resizable-frame/style.scss"; @import "./hooks/push-changes-to-global-styles/style.scss"; html #wpadminbar { diff --git a/test/e2e/specs/site-editor/style-book.spec.js b/test/e2e/specs/site-editor/style-book.spec.js index 6231175584554b..b7002c6dea8f0e 100644 --- a/test/e2e/specs/site-editor/style-book.spec.js +++ b/test/e2e/specs/site-editor/style-book.spec.js @@ -173,7 +173,10 @@ class StyleBook { async open() { await this.disableWelcomeGuide(); - await this.page.click( 'role=button[name="Styles"i]' ); - await this.page.click( 'role=button[name="Style Book"i]' ); + await this.page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Styles' } ) + .click(); + await this.page.getByRole( 'button', { name: 'Style Book' } ).click(); } }