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 a4c3adc747a1e..bd25796d25ecc 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 d3fb58f9aab40..432e8c15b120a 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 777f6dd53afdf..44b31c6945f57 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 0ca3c9b642220..ae19a9f0b6ce3 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 89a6fa3c9ccf1..ecb15aac8fe1e 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 0000000000000..f5dff65f3749b --- /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 0000000000000..2bd478b9bf991 --- /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 679d13a08277a..7c4ba4334059c 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -35,6 +35,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 5cdf1c2a0e59e..2c856f6f5fb87 100644 --- a/test/e2e/specs/site-editor/style-book.spec.js +++ b/test/e2e/specs/site-editor/style-book.spec.js @@ -176,7 +176,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(); } }