diff --git a/.github/workflows/end2end-test.yml b/.github/workflows/end2end-test.yml index 694a48f0266367..5fa1a82d5ad25b 100644 --- a/.github/workflows/end2end-test.yml +++ b/.github/workflows/end2end-test.yml @@ -36,10 +36,6 @@ jobs: run: | npm run wp-env start - - name: Running the tests - run: | - npx wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" - - name: Archive debug artifacts (screenshots, HTML snapshots) uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 if: always() diff --git a/docs/manifest.json b/docs/manifest.json index 84c7da42aa8b20..638c5d2b16c83d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1679,6 +1679,12 @@ "markdown_source": "../packages/interactivity/README.md", "parent": "packages" }, + { + "title": "API Reference", + "slug": "packages-interactivity-api-reference", + "markdown_source": "../packages/interactivity/docs/api-reference.md", + "parent": "packages-interactivity" + }, { "title": "@wordpress/interface", "slug": "packages-interface", diff --git a/docs/reference-guides/block-api/block-registration.md b/docs/reference-guides/block-api/block-registration.md index 7af276d242509a..57123b7b47093d 100644 --- a/docs/reference-guides/block-api/block-registration.md +++ b/docs/reference-guides/block-api/block-registration.md @@ -304,7 +304,7 @@ The key is the name of the block (`string`) to hook into, and the value is the p ```js { blockHooks: { - 'core/verse': 'before' + 'core/verse': 'before', 'core/spacer': 'after', 'core/column': 'firstChild', 'core/group': 'lastChild', diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index b97cb649bb0bc8..f5d40ae8a2110c 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -22,6 +22,9 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-color-randomizer', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableColorRandomizer = true', 'before' ); } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-grid-interactivity', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGridInteractivity = true', 'before' ); + } if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' ); } diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 6608fbb138c58f..f66e0932192637 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -90,6 +90,7 @@ function gutenberg_initialize_experiments_settings() { 'id' => 'gutenberg-color-randomizer', ) ); + add_settings_field( 'gutenberg-form-blocks', __( 'Form and input blocks ', 'gutenberg' ), @@ -101,6 +102,19 @@ function gutenberg_initialize_experiments_settings() { 'id' => 'gutenberg-form-blocks', ) ); + + add_settings_field( + 'gutenberg-grid-interactivity', + __( 'Grid interactivty ', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Test enhancements to the Grid block that let you move and resize items in the editor canvas.', 'gutenberg' ), + 'id' => 'gutenberg-grid-interactivity', + ) + ); + add_settings_field( 'gutenberg-no-tinymce', __( 'Disable TinyMCE and Classic block', 'gutenberg' ), diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index ff21d1d8df8f35..762508f921a00f 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -108,6 +108,9 @@ $z-layers: ( // Above the block list and the header. ".block-editor-block-popover": 31, + // Below the block toolbar. + ".block-editor-grid-visualizer": 30, + // Show snackbars above everything (similar to popovers) ".components-snackbar-list": 100000, diff --git a/packages/block-editor/src/components/global-styles/shadow-panel-components.js b/packages/block-editor/src/components/global-styles/shadow-panel-components.js index 8c9ba795bc17ba..0dc2367e7eda2f 100644 --- a/packages/block-editor/src/components/global-styles/shadow-panel-components.js +++ b/packages/block-editor/src/components/global-styles/shadow-panel-components.js @@ -5,19 +5,25 @@ import { __ } from '@wordpress/i18n'; import { __experimentalVStack as VStack, __experimentalHeading as Heading, - __experimentalGrid as Grid, __experimentalHStack as HStack, __experimentalDropdownContentWrapper as DropdownContentWrapper, Button, FlexItem, Dropdown, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { shadow as shadowIcon, Icon, check } from '@wordpress/icons'; + /** * External dependencies */ import classNames from 'classnames'; +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + export function ShadowPopoverContainer( { shadow, onShadowChange, settings } ) { const defaultShadows = settings?.shadow?.presets?.default || []; const themeShadows = settings?.shadow?.presets?.theme || []; @@ -43,8 +49,16 @@ export function ShadowPopoverContainer( { shadow, onShadowChange, settings } ) { } export function ShadowPresets( { presets, activeShadow, onSelect } ) { + const { CompositeV2: Composite, useCompositeStoreV2: useCompositeStore } = + unlock( componentsPrivateApis ); + const compositeStore = useCompositeStore(); return ! presets ? null : ( - + { presets.map( ( { name, slug, shadow } ) => ( ) ) } - + ); } export function ShadowIndicator( { label, isActive, onSelect, shadow } ) { + const { CompositeItemV2: CompositeItem } = unlock( componentsPrivateApis ); return ( -
- -
+ + { isActive && } + + } + /> ); } diff --git a/packages/block-editor/src/components/global-styles/style.scss b/packages/block-editor/src/components/global-styles/style.scss index d2ba88f9f31e00..b99759511413a6 100644 --- a/packages/block-editor/src/components/global-styles/style.scss +++ b/packages/block-editor/src/components/global-styles/style.scss @@ -21,14 +21,6 @@ } } -// wrapper to clip the shadow beyond 6px -.block-editor-global-styles__shadow-indicator-wrapper { - padding: $grid-unit-15 * 0.5; - display: flex; - align-items: center; - justify-content: center; -} - // These styles are similar to the color palette. .block-editor-global-styles__shadow-indicator { color: $gray-800; @@ -36,9 +28,24 @@ border-radius: $radius-block-ui; cursor: pointer; padding: 0; + margin-right: $grid-unit-15; + margin-bottom: $grid-unit-15; + + height: $button-size-small + 2 * $border-width; + width: $button-size-small + 2 * $border-width; + box-sizing: border-box; + + transform: scale(1); + transition: transform 0.1s ease; + will-change: transform; - height: $button-size-small; - width: $button-size-small; + &:focus { + border: #{ $border-width * 2 } solid $gray-700; + } + + &:hover { + transform: scale(1.2); + } } .block-editor-global-styles-advanced-panel__custom-css-input textarea { diff --git a/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js b/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js new file mode 100644 index 00000000000000..54683e48beeea4 --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/grid-item-resizer.js @@ -0,0 +1,100 @@ +/** + * WordPress dependencies + */ +import { ResizableBox } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs'; +import BlockPopoverCover from '../block-popover/cover'; +import { getComputedCSS } from './utils'; + +export function GridItemResizer( { clientId, onChange } ) { + const blockElement = useBlockElement( clientId ); + if ( ! blockElement ) { + return null; + } + return ( + + { + const gridElement = blockElement.parentElement; + const columnGap = parseFloat( + getComputedCSS( gridElement, 'column-gap' ) + ); + const rowGap = parseFloat( + getComputedCSS( gridElement, 'row-gap' ) + ); + const gridColumnLines = getGridLines( + getComputedCSS( gridElement, 'grid-template-columns' ), + columnGap + ); + const gridRowLines = getGridLines( + getComputedCSS( gridElement, 'grid-template-rows' ), + rowGap + ); + const columnStart = getClosestLine( + gridColumnLines, + blockElement.offsetLeft + ); + const rowStart = getClosestLine( + gridRowLines, + blockElement.offsetTop + ); + const columnEnd = getClosestLine( + gridColumnLines, + blockElement.offsetLeft + boxElement.offsetWidth + ); + const rowEnd = getClosestLine( + gridRowLines, + blockElement.offsetTop + boxElement.offsetHeight + ); + onChange( { + columnSpan: Math.max( columnEnd - columnStart, 1 ), + rowSpan: Math.max( rowEnd - rowStart, 1 ), + } ); + } } + /> + + ); +} + +function getGridLines( template, gap ) { + const lines = [ 0 ]; + for ( const size of template.split( ' ' ) ) { + const line = parseFloat( size ); + lines.push( lines[ lines.length - 1 ] + line + gap ); + } + return lines; +} + +function getClosestLine( lines, position ) { + return lines.reduce( + ( closest, line, index ) => + Math.abs( line - position ) < + Math.abs( lines[ closest ] - position ) + ? index + : closest, + 0 + ); +} diff --git a/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js b/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js new file mode 100644 index 00000000000000..2ca65eb6722e4c --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/grid-visualizer.js @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs'; +import BlockPopoverCover from '../block-popover/cover'; +import { getComputedCSS } from './utils'; + +export function GridVisualizer( { clientId } ) { + const blockElement = useBlockElement( clientId ); + if ( ! blockElement ) { + return null; + } + return ( + + + + ); +} + +function GridVisualizerGrid( { blockElement } ) { + const [ gridInfo, setGridInfo ] = useState( () => + getGridInfo( blockElement ) + ); + useEffect( () => { + const observers = []; + for ( const element of [ blockElement, ...blockElement.children ] ) { + const observer = new window.ResizeObserver( () => { + setGridInfo( getGridInfo( blockElement ) ); + } ); + observer.observe( element ); + observers.push( observer ); + } + return () => { + for ( const observer of observers ) { + observer.disconnect(); + } + }; + }, [ blockElement ] ); + return ( +
+ { Array.from( { length: gridInfo.numItems }, ( _, i ) => ( +
+ ) ) } +
+ ); +} + +function getGridInfo( blockElement ) { + const gridTemplateColumns = getComputedCSS( + blockElement, + 'grid-template-columns' + ); + const gridTemplateRows = getComputedCSS( + blockElement, + 'grid-template-rows' + ); + const numColumns = gridTemplateColumns.split( ' ' ).length; + const numRows = gridTemplateRows.split( ' ' ).length; + const numItems = numColumns * numRows; + return { + numItems, + style: { + gridTemplateColumns, + gridTemplateRows, + gap: getComputedCSS( blockElement, 'gap' ), + padding: getComputedCSS( blockElement, 'padding' ), + }, + }; +} diff --git a/packages/block-editor/src/components/grid-visualizer/index.js b/packages/block-editor/src/components/grid-visualizer/index.js new file mode 100644 index 00000000000000..add845d7022030 --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/index.js @@ -0,0 +1,2 @@ +export { GridVisualizer } from './grid-visualizer'; +export { GridItemResizer } from './grid-item-resizer'; diff --git a/packages/block-editor/src/components/grid-visualizer/style.scss b/packages/block-editor/src/components/grid-visualizer/style.scss new file mode 100644 index 00000000000000..45140e59c7af94 --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/style.scss @@ -0,0 +1,33 @@ +// TODO: Specificity hacks to get rid of all these darn !importants. + +.block-editor-grid-visualizer { + z-index: z-index(".block-editor-grid-visualizer") !important; +} + +.block-editor-grid-visualizer .components-popover__content * { + pointer-events: none !important; +} + +.block-editor-grid-visualizer__grid { + display: grid; +} + +.block-editor-grid-visualizer__item { + border: $border-width dashed $gray-300; +} + +.block-editor-grid-item-resizer { + z-index: z-index(".block-editor-grid-visualizer") !important; +} + +.block-editor-grid-item-resizer .components-popover__content * { + pointer-events: none !important; +} + +.block-editor-grid-item-resizer__box { + border: $border-width solid var(--wp-admin-theme-color); + + .components-resizable-box__handle { + pointer-events: all !important; + } +} diff --git a/packages/block-editor/src/components/grid-visualizer/utils.js b/packages/block-editor/src/components/grid-visualizer/utils.js new file mode 100644 index 00000000000000..a100e596a4e243 --- /dev/null +++ b/packages/block-editor/src/components/grid-visualizer/utils.js @@ -0,0 +1,5 @@ +export function getComputedCSS( element, property ) { + return element.ownerDocument.defaultView + .getComputedStyle( element ) + .getPropertyValue( property ); +} diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index ac4b45af3609ca..debf5a074a07a9 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -49,6 +49,7 @@ export { default as MediaUploadProgress } from './media-upload-progress'; export { MEDIA_UPLOAD_STATE_UPLOADING, MEDIA_UPLOAD_STATE_SUCCEEDED, + MEDIA_UPLOAD_STATE_PAUSED, MEDIA_UPLOAD_STATE_FAILED, MEDIA_UPLOAD_STATE_RESET, } from './media-upload-progress/constants'; diff --git a/packages/block-editor/src/components/inserter/mobile-tab-navigation.js b/packages/block-editor/src/components/inserter/mobile-tab-navigation.js index b3c4edd28ac938..0efc739fac6571 100644 --- a/packages/block-editor/src/components/inserter/mobile-tab-navigation.js +++ b/packages/block-editor/src/components/inserter/mobile-tab-navigation.js @@ -31,7 +31,7 @@ function ScreenHeader( { title } ) { { minWidth: 24, padding: 0 } } icon={ isRTL() ? chevronRight : chevronLeft } - isSmall + size="small" aria-label={ __( 'Navigate to the previous view' ) } /> diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 01753ceda4728e..5f94206c78752f 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -19,7 +19,7 @@ import { removeFormat, } from '@wordpress/rich-text'; import { Popover } from '@wordpress/components'; -import { getBlockType } from '@wordpress/blocks'; +import { getBlockType, store as blocksStore } from '@wordpress/blocks'; /** * Internal dependencies @@ -151,9 +151,7 @@ export function RichTextWrapper( let disableBoundBlocks = false; if ( blockBindings && blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS ) { const blockTypeAttributes = getBlockType( blockName ).attributes; - const { getBlockBindingsSource } = unlock( - select( blockEditorStore ) - ); + const { getBlockBindingsSource } = unlock( select( blocksStore ) ); for ( const [ attribute, args ] of Object.entries( blockBindings ) ) { diff --git a/packages/block-editor/src/components/url-popover/style.scss b/packages/block-editor/src/components/url-popover/style.scss index df4e10fe13d534..324d82d4183aab 100644 --- a/packages/block-editor/src/components/url-popover/style.scss +++ b/packages/block-editor/src/components/url-popover/style.scss @@ -58,6 +58,7 @@ text-overflow: ellipsis; white-space: nowrap; margin-right: $grid-unit-10; + min-width: 150px; // Avoids the popover from growing too wide when the URL is long. // See https://github.com/WordPress/gutenberg/issues/58599 max-width: $modal-min-width; diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index e6227ea2b03e2e..36efe3dcf409b5 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -42,6 +42,7 @@ createBlockEditFilter( contentLockUI, blockHooks, blockRenaming, + childLayout, ].filter( Boolean ) ); createBlockListBlockFilter( [ diff --git a/packages/block-editor/src/hooks/layout-child.js b/packages/block-editor/src/hooks/layout-child.js index a9a6ff4db0f4ab..d8333e8e0e830f 100644 --- a/packages/block-editor/src/hooks/layout-child.js +++ b/packages/block-editor/src/hooks/layout-child.js @@ -10,6 +10,7 @@ import { useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '../store'; import { useStyleOverride } from './utils'; import { useLayout } from '../components/block-list/layout'; +import { GridVisualizer, GridItemResizer } from '../components/grid-visualizer'; function useBlockPropsChildLayoutStyles( { style } ) { const shouldRenderChildLayoutStyles = useSelect( ( select ) => { @@ -96,8 +97,45 @@ function useBlockPropsChildLayoutStyles( { style } ) { return { className: `wp-container-content-${ id }` }; } +function ChildLayoutControlsPure( { clientId, style, setAttributes } ) { + const parentLayout = useLayout() || {}; + const rootClientId = useSelect( + ( select ) => { + return select( blockEditorStore ).getBlockRootClientId( clientId ); + }, + [ clientId ] + ); + if ( parentLayout.type !== 'grid' ) { + return null; + } + if ( ! window.__experimentalEnableGridInteractivity ) { + return null; + } + return ( + <> + + { + setAttributes( { + style: { + ...style, + layout: { + ...style?.layout, + columnSpan, + rowSpan, + }, + }, + } ); + } } + /> + + ); +} + export default { useBlockProps: useBlockPropsChildLayoutStyles, + edit: ChildLayoutControlsPure, attributeKeys: [ 'style' ], hasSupport() { return true; diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js index a83d07398d54a9..76a5557850a603 100644 --- a/packages/block-editor/src/hooks/layout.js +++ b/packages/block-editor/src/hooks/layout.js @@ -135,7 +135,12 @@ export function useLayoutStyles( blockAttributes = {}, blockName, selector ) { return css; } -function LayoutPanelPure( { layout, setAttributes, name: blockName } ) { +function LayoutPanelPure( { + layout, + setAttributes, + name: blockName, + clientId, +} ) { const settings = useBlockSettings( blockName ); // Block settings come from theme.json under settings.[blockName]. const { layout: layoutSettings } = settings; @@ -266,6 +271,8 @@ function LayoutPanelPure( { layout, setAttributes, name: blockName } ) { layout={ usedLayout } onChange={ onChangeLayout } layoutBlockSupport={ blockSupportAndThemeSettings } + name={ blockName } + clientId={ clientId } /> ) } { constrainedType && displayControlsForLegacyLayouts && ( @@ -273,6 +280,8 @@ function LayoutPanelPure( { layout, setAttributes, name: blockName } ) { layout={ usedLayout } onChange={ onChangeLayout } layoutBlockSupport={ blockSupportAndThemeSettings } + name={ blockName } + clientId={ clientId } /> ) } @@ -282,6 +291,8 @@ function LayoutPanelPure( { layout, setAttributes, name: blockName } ) { layout={ usedLayout } onChange={ onChangeLayout } layoutBlockSupport={ layoutBlockSupport } + name={ blockName } + clientId={ clientId } /> ) } diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js index 899faf0a8cbd5d..0e5b6614f07cbf 100644 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { getBlockType } from '@wordpress/blocks'; +import { getBlockType, store as blocksStore } from '@wordpress/blocks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { addFilter } from '@wordpress/hooks'; @@ -33,18 +33,16 @@ const createEditFunctionWithBindingsAttribute = () => createHigherOrderComponent( ( BlockEdit ) => ( props ) => { const { clientId, name: blockName } = useBlockEditContext(); - const { getBlockBindingsSource } = unlock( - useSelect( blockEditorStore ) - ); + const blockBindingsSources = unlock( + useSelect( blocksStore ) + ).getAllBlockBindingsSources(); const { getBlockAttributes } = useSelect( blockEditorStore ); const updatedAttributes = getBlockAttributes( clientId ); if ( updatedAttributes?.metadata?.bindings ) { Object.entries( updatedAttributes.metadata.bindings ).forEach( ( [ attributeName, settings ] ) => { - const source = getBlockBindingsSource( - settings.source - ); + const source = blockBindingsSources[ settings.source ]; if ( source && source.useSource ) { // Second argument (`updateMetaValue`) will be used to update the value in the future. diff --git a/packages/block-editor/src/layouts/grid.js b/packages/block-editor/src/layouts/grid.js index a27d07b3854a23..0dc72694bd5688 100644 --- a/packages/block-editor/src/layouts/grid.js +++ b/packages/block-editor/src/layouts/grid.js @@ -23,6 +23,7 @@ import { appendSelectors, getBlockGapCSS } from './utils'; import { getGapCSSValue } from '../hooks/gap'; import { shouldSkipSerialization } from '../hooks/utils'; import { LAYOUT_DEFINITIONS } from './definitions'; +import { GridVisualizer } from '../components/grid-visualizer'; const RANGE_CONTROL_MAX_VALUES = { px: 600, @@ -67,6 +68,7 @@ export default { inspectorControls: function GridLayoutInspectorControls( { layout = {}, onChange, + clientId, } ) { return ( <> @@ -85,10 +87,13 @@ export default { onChange={ onChange } /> ) } + { window.__experimentalEnableGridInteractivity && ( + + ) } ); }, - toolBarControls: function DefaultLayoutToolbarControls() { + toolBarControls: function GridLayoutToolbarControls() { return null; }, getLayoutStyle: function getLayoutStyle( { diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index 606cd01228204f..d402d45657704c 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -355,16 +355,6 @@ export function stopEditingAsBlocks( clientId ) { }; } -export function registerBlockBindingsSource( source ) { - return { - type: 'REGISTER_BLOCK_BINDINGS_SOURCE', - sourceName: source.name, - sourceLabel: source.label, - useSource: source.useSource, - lockAttributesEditing: source.lockAttributesEditing, - }; -} - /** * Returns an action object used in signalling that the user has begun to drag. * diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index c885e43ba75208..e4885cbbd9e1e1 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -341,14 +341,6 @@ export function getLastFocus( state ) { return state.lastFocus; } -export function getAllBlockBindingsSources( state ) { - return state.blockBindingsSources; -} - -export function getBlockBindingsSource( state, sourceName ) { - return state.blockBindingsSources[ sourceName ]; -} - /** * Returns true if the user is dragging anything, or false otherwise. It is possible for a * user to be dragging data from outside of the editor, so this selector is separate from diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 33678f64905d69..751a19a1c2a8c2 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -2044,20 +2044,6 @@ export function lastFocus( state = false, action ) { return state; } -function blockBindingsSources( state = {}, action ) { - if ( action.type === 'REGISTER_BLOCK_BINDINGS_SOURCE' ) { - return { - ...state, - [ action.sourceName ]: { - label: action.sourceLabel, - useSource: action.useSource, - lockAttributesEditing: action.lockAttributesEditing ?? true, - }, - }; - } - return state; -} - const combinedReducers = combineReducers( { blocks, isDragging, @@ -2089,7 +2075,6 @@ const combinedReducers = combineReducers( { blockRemovalRules, openedBlockSettingsMenu, registeredInserterMediaCategories, - blockBindingsSources, } ); function withAutomaticChangeReset( reducer ) { diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index 01931adace3f13..d7aa3ebcc12d08 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -27,6 +27,7 @@ @import "./components/duotone-control/style.scss"; @import "./components/font-appearance-control/style.scss"; @import "./components/global-styles/style.scss"; +@import "./components/grid-visualizer/style.scss"; @import "./components/height-control/style.scss"; @import "./components/image-size-control/style.scss"; @import "./components/inserter-list-item/style.scss"; diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index ff90cdd1bf64c0..1d124baf1a7e30 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -45,6 +45,7 @@ import { createBlock, cloneBlock, getDefaultBlockName, + store as blocksStore, } from '@wordpress/blocks'; import { useMergeRefs, useRefEffect } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -123,7 +124,7 @@ function WidthPanel( { selectedWidth, setAttributes } ) { } return ( - + { [ 25, 50, 75, 100 ].map( ( widthValue ) => { return ( @@ -239,7 +240,7 @@ function ButtonEdit( props ) { } const blockBindingsSource = unlock( - select( blockEditorStore ) + select( blocksStore ) ).getBlockBindingsSource( metadata?.bindings?.url?.source ); return { diff --git a/packages/block-library/src/columns/edit.js b/packages/block-library/src/columns/edit.js index 0a756b34c6ef6f..d70b14ddcd2d73 100644 --- a/packages/block-library/src/columns/edit.js +++ b/packages/block-library/src/columns/edit.js @@ -139,18 +139,19 @@ function ColumnsEditContainer( { attributes, setAttributes, clientId } ) { // If adding a new column, assign width to the new column equal to // as if it were `1 / columns` of the total available space. const newColumnWidth = toWidthPrecision( 100 / newColumns ); + const newlyAddedColumns = newColumns - previousColumns; // Redistribute in consideration of pending block insertion as // constraining the available working width. const widths = getRedistributedColumnWidths( innerBlocks, - 100 - newColumnWidth + 100 - newColumnWidth * newlyAddedColumns ); innerBlocks = [ ...getMappedColumnWidths( innerBlocks, widths ), ...Array.from( { - length: newColumns - previousColumns, + length: newlyAddedColumns, } ).map( () => { return createBlock( 'core/column', { width: `${ newColumnWidth }%`, diff --git a/packages/block-library/src/comment-author-avatar/edit.js b/packages/block-library/src/comment-author-avatar/edit.js index a8c831db620c8b..3b25bf6916c8b8 100644 --- a/packages/block-library/src/comment-author-avatar/edit.js +++ b/packages/block-library/src/comment-author-avatar/edit.js @@ -48,7 +48,7 @@ export default function Edit( { const inspectorControls = ( - + { 'hidden' !== type && ( - + { 'checkbox' !== type && ( ( - + { + uploadCallBack = callback; +} ); +sendMediaUpload.mockImplementation( ( payload ) => { + uploadCallBack( payload ); +} ); + +setupCoreBlocks( [ 'core/media-text' ] ); + +describe( 'Media & Text block edit', () => { + it( 'should display an error message for failed video uploads', async () => { + requestMediaPicker.mockImplementation( + ( source, filter, multiple, callback ) => { + callback( { + id: 1, + url: 'file://video.mp4', + type: 'video', + } ); + } + ); + await initializeEditor(); + await addBlock( screen, 'Media & Text' ); + fireEvent.press( screen.getByText( 'Add image or video' ) ); + fireEvent.press( screen.getByText( 'Choose from device' ) ); + + sendMediaUpload( { + mediaId: 1, + state: MEDIA_UPLOAD_STATE_PAUSED, + progress: 0, + } ); + + expect( + screen.getByText( 'Failed to insert media.\nTap for more info.' ) + ).toBeVisible(); + } ); +} ); diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index e45591661abfe4..3a3a654aee6126 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -565,7 +565,7 @@ private static function get_nav_element_directives( $is_interactive ) { ) ); $nav_element_directives = ' - data-wp-interactive="core/navigation"' + data-wp-interactive="core/navigation" ' . $nav_element_context; return $nav_element_directives; diff --git a/packages/block-library/src/post-featured-image/edit.js b/packages/block-library/src/post-featured-image/edit.js index 26f3439964f90e..a3b3b1ec6398a2 100644 --- a/packages/block-library/src/post-featured-image/edit.js +++ b/packages/block-library/src/post-featured-image/edit.js @@ -24,6 +24,7 @@ import { useBlockProps, store as blockEditorStore, __experimentalUseBorderProps as useBorderProps, + useBlockEditingMode, } from '@wordpress/block-editor'; import { useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; @@ -143,6 +144,7 @@ export default function PostFeaturedImageEdit( { style: { width, height, aspectRatio }, } ); const borderProps = useBorderProps( attributes ); + const blockEditingMode = useBlockEditingMode(); const placeholder = ( content ) => { return ( @@ -174,8 +176,13 @@ export default function PostFeaturedImageEdit( { createErrorNotice( message, { type: 'snackbar' } ); }; - const controls = ( + const controls = blockEditingMode === 'default' && ( <> + ); + let image; /** @@ -251,11 +259,6 @@ export default function PostFeaturedImageEdit( { ) : ( placeholder() ) } -
); @@ -360,11 +363,6 @@ export default function PostFeaturedImageEdit( { ) : ( image ) } - ); diff --git a/packages/block-library/src/post-title/edit.js b/packages/block-library/src/post-title/edit.js index 28d9b8cdcfd8ff..1cb89bc3e5e67b 100644 --- a/packages/block-library/src/post-title/edit.js +++ b/packages/block-library/src/post-title/edit.js @@ -115,53 +115,59 @@ export default function PostTitleEdit( { return ( <> { blockEditingMode === 'default' && ( - - - setAttributes( { level: newLevel } ) - } - /> - { - setAttributes( { textAlign: nextAlign } ); - } } - /> - - ) } - - - setAttributes( { isLink: ! isLink } ) } - checked={ isLink } - /> - { isLink && ( - <> + <> + + + setAttributes( { level: newLevel } ) + } + /> + { + setAttributes( { textAlign: nextAlign } ); + } } + /> + + + - setAttributes( { - linkTarget: value ? '_blank' : '_self', - } ) + label={ __( 'Make title a link' ) } + onChange={ () => + setAttributes( { isLink: ! isLink } ) } - checked={ linkTarget === '_blank' } + checked={ isLink } /> - - setAttributes( { rel: newRel } ) - } - /> - - ) } - - + { isLink && ( + <> + + setAttributes( { + linkTarget: value + ? '_blank' + : '_self', + } ) + } + checked={ linkTarget === '_blank' } + /> + + setAttributes( { rel: newRel } ) + } + /> + + ) } + + + + ) } { titleElement } ); diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js index 0123bdfd565698..2f60b1f650ef87 100644 --- a/packages/block-library/src/search/edit.js +++ b/packages/block-library/src/search/edit.js @@ -400,7 +400,7 @@ export default function SearchEdit( { - + + ); +} + export default function TemplatePartEdit( { attributes, setAttributes, clientId, } ) { + const { createSuccessNotice } = useDispatch( noticesStore ); const currentTheme = useSelect( ( select ) => select( coreStore ).getCurrentTheme()?.stylesheet, [] @@ -117,12 +139,28 @@ export default function TemplatePartEdit( { [ templatePartId, attributes.area, clientId ] ); + const { templateParts } = useAlternativeTemplateParts( + area, + templatePartId + ); + const blockPatterns = useAlternativeBlockPatterns( area, clientId ); + const hasReplacements = !! templateParts.length || !! blockPatterns.length; const areaObject = useTemplatePartArea( area ); const blockProps = useBlockProps(); const isPlaceholder = ! slug; const isEntityAvailable = ! isPlaceholder && ! isMissing && isResolved; const TagName = tagName || areaObject.tagName; + const canReplace = + isEntityAvailable && + hasReplacements && + ( area === 'header' || area === 'footer' ); + + const createFromBlocks = useCreateTemplatePartFromBlocks( + area, + setAttributes + ); + // We don't want to render a missing state if we have any inner blocks. // A new template part is automatically created if we have any inner blocks but no entity. if ( @@ -154,6 +192,28 @@ export default function TemplatePartEdit( { ); } + const partsAsPatterns = templateParts.map( ( templatePart ) => + mapTemplatePartToBlockPattern( templatePart ) + ); + + const onTemplatePartSelect = ( templatePart ) => { + setAttributes( { + slug: templatePart.slug, + theme: templatePart.theme, + area: undefined, + } ); + createSuccessNotice( + sprintf( + /* translators: %s: template part title. */ + __( 'Template Part "%s" replaceed.' ), + templatePart.title?.rendered || templatePart.slug + ), + { + type: 'snackbar', + } + ); + }; + return ( <> @@ -207,6 +267,33 @@ export default function TemplatePartEdit( { ); } } + + { canReplace && + ( partsAsPatterns.length > 0 || + blockPatterns.length > 0 ) && ( + + + { + onTemplatePartSelect( + pattern.templatePart + ); + } } + /> + { + createFromBlocks( + blocks, + pattern.title + ); + } } + /> + + + ) } + { isEntityAvailable && ( { - const partsAsPatterns = templateParts.map( ( templatePart ) => ( { - name: createTemplatePartId( templatePart.theme, templatePart.slug ), - title: templatePart.title.rendered, - blocks: parse( templatePart.content.raw ), - templatePart, - } ) ); + const partsAsPatterns = templateParts.map( ( templatePart ) => + mapTemplatePartToBlockPattern( templatePart ) + ); return searchPatterns( partsAsPatterns, searchValue ); }, [ templateParts, searchValue ] ); diff --git a/packages/block-library/src/template-part/edit/utils/map-template-part-to-block-pattern.js b/packages/block-library/src/template-part/edit/utils/map-template-part-to-block-pattern.js new file mode 100644 index 00000000000000..5a053350d750f8 --- /dev/null +++ b/packages/block-library/src/template-part/edit/utils/map-template-part-to-block-pattern.js @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import { parse } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { createTemplatePartId } from './create-template-part-id'; + +/** + * This maps the properties of a template part to those of a block pattern. + * @param {Object} templatePart + * @return {Object} The template part in the shape of block pattern. + */ +export function mapTemplatePartToBlockPattern( templatePart ) { + return { + name: createTemplatePartId( templatePart.theme, templatePart.slug ), + title: templatePart.title.rendered, + blocks: parse( templatePart.content.raw ), + templatePart, + }; +} diff --git a/packages/blocks/src/store/private-actions.js b/packages/blocks/src/store/private-actions.js index bc06e231b17222..d609f70b91b55d 100644 --- a/packages/blocks/src/store/private-actions.js +++ b/packages/blocks/src/store/private-actions.js @@ -40,3 +40,18 @@ export function addUnprocessedBlockType( name, blockType ) { dispatch.addBlockTypes( processedBlockType ); }; } + +/** + * Register new block bindings source. + * + * @param {string} source Name of the source to register. + */ +export function registerBlockBindingsSource( source ) { + return { + type: 'REGISTER_BLOCK_BINDINGS_SOURCE', + sourceName: source.name, + sourceLabel: source.label, + useSource: source.useSource, + lockAttributesEditing: source.lockAttributesEditing, + }; +} diff --git a/packages/blocks/src/store/private-selectors.js b/packages/blocks/src/store/private-selectors.js index f2acf9c1051846..e6eac8be421466 100644 --- a/packages/blocks/src/store/private-selectors.js +++ b/packages/blocks/src/store/private-selectors.js @@ -186,3 +186,26 @@ export function getBootstrappedBlockType( state, name ) { export function getUnprocessedBlockTypes( state ) { return state.unprocessedBlockTypes; } + +/** + * Returns all the block bindings sources registered. + * + * @param {Object} state Data state. + * + * @return {Object} All the registered sources and their properties. + */ +export function getAllBlockBindingsSources( state ) { + return state.blockBindingsSources; +} + +/** + * Returns a specific block bindings source. + * + * @param {Object} state Data state. + * @param {string} sourceName Name of the source to get. + * + * @return {Object} The specific block binding source and its properties. + */ +export function getBlockBindingsSource( state, sourceName ) { + return state.blockBindingsSources[ sourceName ]; +} diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index 91f061871f9e27..f92fb376b530a7 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -383,6 +383,20 @@ export function collections( state = {}, action ) { return state; } +export function blockBindingsSources( state = {}, action ) { + if ( action.type === 'REGISTER_BLOCK_BINDINGS_SOURCE' ) { + return { + ...state, + [ action.sourceName ]: { + label: action.sourceLabel, + useSource: action.useSource, + lockAttributesEditing: action.lockAttributesEditing ?? true, + }, + }; + } + return state; +} + export default combineReducers( { bootstrappedBlockTypes, unprocessedBlockTypes, @@ -395,4 +409,5 @@ export default combineReducers( { groupingBlockName, categories, collections, + blockBindingsSources, } ); diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index b37a6775079e8b..d4dfaac153f010 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fix + +- `Tooltip`: Explicitly set system font to avoid CSS bleed ([#59307](https://github.com/WordPress/gutenberg/pull/59307)). + ## 27.0.0 (2024-02-21) ### Breaking Changes diff --git a/packages/components/src/mobile/image/index.native.js b/packages/components/src/mobile/image/index.native.js index 0b9588eb3ac8ac..aec8f94d1b5088 100644 --- a/packages/components/src/mobile/image/index.native.js +++ b/packages/components/src/mobile/image/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { Animated, Image as RNImage, Text, View } from 'react-native'; +import { Image as RNImage, Text, View } from 'react-native'; import FastImage from 'react-native-fast-image'; /** @@ -11,7 +11,7 @@ import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/components'; import { image, offline } from '@wordpress/icons'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; -import { useEffect, useState, useRef, Platform } from '@wordpress/element'; +import { useEffect, useState, Platform } from '@wordpress/element'; /** * Internal dependencies @@ -218,19 +218,8 @@ const ImageComponent = ( { focalPoint && styles.focalPointContainer, ]; - const opacityValue = useRef( new Animated.Value( 1 ) ).current; - - useEffect( () => { - Animated.timing( opacityValue, { - toValue: isUploadInProgress ? 0.3 : 1, - duration: 100, - useNativeDriver: true, - } ).start(); - }, [ isUploadInProgress, opacityValue ] ); - const imageStyles = [ { - opacity: opacityValue, height: containerSize?.height, }, ! resizeMode && { @@ -319,7 +308,7 @@ const ImageComponent = ( { { Platform.isAndroid && ( <> { networkImageLoaded && networkURL && ( - ) } { ! networkImageLoaded && ! networkURL && ( - - - async ( { dispatch } ) => { + async ( { dispatch, select } ) => { const fallback = await apiFetch( { - path: addQueryArgs( '/wp-block-editor/v1/navigation-fallback' ), + path: addQueryArgs( '/wp-block-editor/v1/navigation-fallback', { + _embed: true, + } ), } ); + const record = fallback?._embedded?.self; + dispatch.receiveNavigationFallbackId( fallback?.id ); + + if ( record ) { + // If the fallback is already in the store, don't invalidate navigation queries. + // Otherwise, invalidate the cache for the scenario where there were no Navigation + // posts in the state and the fallback created one. + const existingFallbackEntityRecord = select.getEntityRecord( + 'postType', + 'wp_navigation', + fallback.id + ); + const invalidateNavigationQueries = ! existingFallbackEntityRecord; + dispatch.receiveEntityRecords( + 'postType', + 'wp_navigation', + record, + undefined, + invalidateNavigationQueries + ); + + // Resolve to avoid further network requests. + dispatch.finishResolution( 'getEntityRecord', [ + 'postType', + 'wp_navigation', + fallback.id, + ] ); + } }; export const getDefaultTemplateId = diff --git a/packages/dataviews/src/dataviews.js b/packages/dataviews/src/dataviews.js index c7ae6b6e27aad8..49143d0bb0753e 100644 --- a/packages/dataviews/src/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -79,17 +79,30 @@ export default function DataViews( {
- { search && ( - + { search && ( + + ) } + - ) } + { [ LAYOUT_TABLE, LAYOUT_GRID ].includes( view.type ) && ( - - - + { filterComponents } + + ); } ); export default Filters; diff --git a/packages/dataviews/src/pagination.js b/packages/dataviews/src/pagination.js index f69d7c7a12e193..4e44b5e9ca4b2e 100644 --- a/packages/dataviews/src/pagination.js +++ b/packages/dataviews/src/pagination.js @@ -27,7 +27,12 @@ const Pagination = memo( function Pagination( { justify="end" className="dataviews-pagination" > - + { createInterpolateElement( sprintf( // translators: %s: Total number of pages. diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index cd293b46d4a473..e865ff8a25fed0 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -13,8 +13,6 @@ .dataviews-filters__view-actions { padding: $grid-unit-15 $grid-unit-40 0; .components-search-control { - flex-grow: 1; - .components-base-control__field { max-width: 240px; } @@ -22,11 +20,7 @@ } .dataviews-filters__container { - padding: 0 $grid-unit-40; -} - -.dataviews-filters__view-actions.components-h-stack { - align-items: center; + padding-right: $grid-unit-40; } .dataviews-filters-button { @@ -44,6 +38,13 @@ color: $gray-700; } +.dataviews-pagination__page-selection { + font-size: 11px; + text-transform: uppercase; + font-weight: 500; + color: $gray-900; +} + .dataviews-filters-options { margin: $grid-unit-40 0 $grid-unit-20; } @@ -202,6 +203,12 @@ .dataviews-view-table__actions-column { width: 1%; } + + &:has(tr.is-selected) { + .components-checkbox-control__input { + opacity: 1; + } + } } .dataviews-view-list__primary-field, @@ -648,7 +655,8 @@ } &:hover, - &:focus-visible { + &:focus-visible, + &[aria-expanded="true"] { background: $gray-200; color: $gray-900; } @@ -657,8 +665,9 @@ color: var(--wp-admin-theme-color); background: rgba(var(--wp-admin-theme-color--rgb), 0.04); - &:hover { - background: rgba(var(--wp-admin-theme-color--rgb), 0.08); + &:hover, + &[aria-expanded="true"] { + background: rgba(var(--wp-admin-theme-color--rgb), 0.12); } } diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php index 25d2bbc692efdf..6b20b2dba8376e 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -170,3 +170,29 @@
+ +
+ +
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js index 2267868713c41b..fd061100fca641 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -88,3 +88,21 @@ const { actions } = store( 'directive-context-navigate', { }, }, } ); + +store( 'directive-context-watch', { + actions: { + increment: () => { + const ctx = getContext(); + ctx.counter = ctx.counter + 1; + }, + }, + callbacks: { + countChanges: () => { + const ctx = getContext(); + // Subscribe to changes in counter. + // eslint-disable-next-line no-unused-expressions + ctx.counter; + ctx.changes = ctx.changes + 1; + }, + }, +}); diff --git a/packages/e2e-tests/specs/editor/various/autosave.test.js b/packages/e2e-tests/specs/editor/various/autosave.test.js deleted file mode 100644 index 528efc2d463167..00000000000000 --- a/packages/e2e-tests/specs/editor/various/autosave.test.js +++ /dev/null @@ -1,346 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - createNewPost, - getEditedPostContent, - pressKeyWithModifier, - publishPost, - saveDraft, - toggleOfflineMode, - canvas, -} from '@wordpress/e2e-test-utils'; - -// Constant to override editor preference -const AUTOSAVE_INTERVAL_SECONDS = 5; - -const AUTOSAVE_NOTICE_REMOTE = - 'There is an autosave of this post that is more recent than the version below.'; -const AUTOSAVE_NOTICE_LOCAL = - 'The backup of this post in your browser is different from the version below.'; - -// Save and wait for "Saved" to confirm save complete. Preserves focus in the -// editing area. -async function saveDraftWithKeyboard() { - await page.waitForSelector( '.editor-post-save-draft' ); - await Promise.all( [ - page.waitForSelector( '.editor-post-saved-state.is-saved' ), - pressKeyWithModifier( 'primary', 'S' ), - ] ); -} - -async function sleep( durationInSeconds ) { - // Rule `no-restricted-syntax` recommends `waitForSelector` against - // `waitFor`, which isn't apt for the use case, when provided an integer, - // of waiting for a given amount of time. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( durationInSeconds * 1000 ); -} - -async function clearSessionStorage() { - await page.evaluate( () => window.sessionStorage.clear() ); -} - -async function readSessionStorageAutosave( postId ) { - return page.evaluate( - ( key ) => window.sessionStorage.getItem( key ), - `wp-autosave-block-editor-post-${ postId ? postId : 'auto-draft' }` - ); -} - -async function getCurrentPostId() { - return page.evaluate( () => - window.wp.data.select( 'core/editor' ).getCurrentPostId() - ); -} - -async function setLocalAutosaveInterval( value ) { - return page.evaluate( ( _value ) => { - window.wp.data.dispatch( 'core/editor' ).updateEditorSettings( { - localAutosaveInterval: _value, - } ); - }, value ); -} - -function wrapParagraph( text ) { - return ` -

${ text }

-`; -} - -describe( 'autosave', () => { - beforeEach( async () => { - await clearSessionStorage(); - await createNewPost(); - await setLocalAutosaveInterval( AUTOSAVE_INTERVAL_SECONDS ); - } ); - - it( 'should save to sessionStorage', async () => { - // Wait for the original timeout to kick in, it will schedule - // another run using the updated interval length of AUTOSAVE_INTERVAL_SECONDS. - await sleep( 15 ); - - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraftWithKeyboard(); - await sleep( 1 ); - await page.keyboard.type( ' after save' ); - - // Wait long enough for local autosave to kick in. - await sleep( AUTOSAVE_INTERVAL_SECONDS + 1 ); - - const id = await getCurrentPostId(); - const autosave = await readSessionStorageAutosave( id ); - const { content } = JSON.parse( autosave ); - expect( content ).toBe( wrapParagraph( 'before save after save' ) ); - } ); - - it( 'should recover from sessionStorage', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraftWithKeyboard(); - await page.keyboard.type( ' after save' ); - - // Trigger local autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave( { local: true } ) - ); - // Reload without saving on the server. - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - - const notice = await page.$eval( - '.components-notice__content', - ( element ) => element.innerText - ); - expect( notice ).toContain( AUTOSAVE_NOTICE_LOCAL ); - - expect( await getEditedPostContent() ).toEqual( - wrapParagraph( 'before save' ) - ); - await page.click( '.components-notice__action' ); - expect( await getEditedPostContent() ).toEqual( - wrapParagraph( 'before save after save' ) - ); - } ); - - it( "shouldn't contaminate other posts", async () => { - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraft(); - - // Fake local autosave. - await page.evaluate( - ( postId ) => - window.sessionStorage.setItem( - `wp-autosave-block-editor-post-${ postId }`, - JSON.stringify( { - post_title: 'A', - content: 'B', - excerpt: 'C', - } ) - ), - await getCurrentPostId() - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 1 ); - - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - const notice = await page.$eval( - '.components-notice__content', - ( element ) => element.innerText - ); - expect( notice ).toContain( - 'The backup of this post in your browser is different from the version below.' - ); - - await createNewPost(); - expect( await page.$( '.components-notice__content' ) ).toBe( null ); - } ); - - it( 'should clear local autosave after successful remote autosave', async () => { - // Edit, save draft, edit again. - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraftWithKeyboard(); - await page.keyboard.type( ' after save' ); - - // Trigger local autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave( { local: true } ) - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBeGreaterThanOrEqual( 1 ); - - // Trigger remote autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave() - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 0 ); - } ); - - it( "shouldn't clear local autosave if remote autosave fails", async () => { - // Edit, save draft, edit again. - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraftWithKeyboard(); - await page.keyboard.type( ' after save' ); - - // Trigger local autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave( { local: true } ) - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 1 ); - - // Bring network down and attempt to autosave remotely. - toggleOfflineMode( true ); - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave() - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 1 ); - } ); - - it( 'should clear local autosave after successful save', async () => { - // Edit, save draft, edit again. - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraftWithKeyboard(); - await page.keyboard.type( ' after save' ); - - // Trigger local autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave( { local: true } ) - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 1 ); - - await saveDraftWithKeyboard(); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 0 ); - } ); - - it( "shouldn't clear local autosave if save fails", async () => { - // Edit, save draft, edit again. - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraftWithKeyboard(); - await page.keyboard.type( ' after save' ); - - // Trigger local autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave( { local: true } ) - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBeGreaterThanOrEqual( 1 ); - - // Bring network down and attempt to save. - toggleOfflineMode( true ); - saveDraftWithKeyboard(); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBeGreaterThanOrEqual( 1 ); - } ); - - it( "shouldn't conflict with server-side autosave", async () => { - await clickBlockAppender(); - await page.keyboard.type( 'before publish' ); - await publishPost(); - - await canvas().click( '[data-type="core/paragraph"]' ); - await page.keyboard.type( ' after publish' ); - - // Trigger remote autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave() - ); - - // Force conflicting local autosave. - await page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave( { local: true } ) - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBeGreaterThanOrEqual( 1 ); - - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - - // FIXME: Occasionally, upon reload, there is no server-provided - // autosave value available, despite our having previously explicitly - // autosaved. The reasons for this are still unknown. Since this is - // unrelated to *local* autosave, until we can understand them, we'll - // drop this test's expectations if we don't have an autosave object - // available. - const stillHasRemoteAutosave = await page.evaluate( - () => - window.wp.data.select( 'core/editor' ).getEditorSettings() - .autosave - ); - if ( ! stillHasRemoteAutosave ) { - return; - } - - // Only one autosave notice should be displayed. - const notices = await page.$$( '.components-notice' ); - expect( notices.length ).toBe( 1 ); - const notice = await page.$eval( - '.components-notice__content', - ( element ) => element.innerText - ); - expect( notice ).toContain( AUTOSAVE_NOTICE_REMOTE ); - } ); - - it( 'should clear sessionStorage upon user logout', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'before save' ); - await saveDraft(); - - // Fake local autosave. - await page.evaluate( - ( postId ) => - window.sessionStorage.setItem( - `wp-autosave-block-editor-post-${ postId }`, - JSON.stringify( { - post_title: 'A', - content: 'B', - excerpt: 'C', - } ) - ), - await getCurrentPostId() - ); - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 1 ); - - await Promise.all( [ - page.waitForSelector( '#wp-admin-bar-logout', { visible: true } ), - page.hover( '#wp-admin-bar-my-account' ), - ] ); - await Promise.all( [ - page.waitForNavigation(), - page.click( '#wp-admin-bar-logout' ), - ] ); - - expect( - await page.evaluate( () => window.sessionStorage.length ) - ).toBe( 0 ); - } ); - - afterEach( async () => { - toggleOfflineMode( false ); - await clearSessionStorage(); - } ); -} ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js index 6236ea8fe3f246..74daf15640ffaf 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js @@ -19,10 +19,11 @@ import { FlexItem, Flex, Button, + DropdownMenu, } from '@wordpress/components'; import { debounce } from '@wordpress/compose'; import { sprintf, __, _x } from '@wordpress/i18n'; -import { search, closeSmall } from '@wordpress/icons'; +import { search, closeSmall, moreVertical } from '@wordpress/icons'; /** * Internal dependencies @@ -41,15 +42,15 @@ const DEFAULT_CATEGORY = { slug: 'all', name: _x( 'All', 'font categories' ), }; + +const LOCAL_STORAGE_ITEM = 'wp-font-library-google-fonts-permission'; +const MIN_WINDOW_HEIGHT = 500; + function FontCollection( { slug } ) { const requiresPermission = slug === 'google-fonts'; const getGoogleFontsPermissionFromStorage = () => { - return ( - window.localStorage.getItem( - 'wp-font-library-google-fonts-permission' - ) === 'true' - ); + return window.localStorage.getItem( LOCAL_STORAGE_ITEM ) === 'true'; }; const [ selectedFont, setSelectedFont ] = useState( null ); @@ -76,6 +77,11 @@ function FontCollection( { slug } ) { return () => window.removeEventListener( 'storage', handleStorage ); }, [ slug, requiresPermission ] ); + const revokeAccess = () => { + window.localStorage.setItem( LOCAL_STORAGE_ITEM, 'false' ); + window.dispatchEvent( new Event( 'storage' ) ); + }; + useEffect( () => { const fetchFontCollection = async () => { try { @@ -118,7 +124,8 @@ function FontCollection( { slug } ) { // NOTE: The height of the font library modal unavailable to use for rendering font family items is roughly 417px // The height of each font family item is 61px. - const pageSize = Math.floor( ( window.innerHeight - 417 ) / 61 ); + const windowHeight = Math.max( window.innerHeight, MIN_WINDOW_HEIGHT ); + const pageSize = Math.floor( ( windowHeight - 417 ) / 61 ); const totalPages = Math.ceil( fonts.length / pageSize ); const itemsStart = ( page - 1 ) * pageSize; const itemsLimit = page * pageSize; @@ -223,11 +230,33 @@ function FontCollection( { slug } ) { ); } + const ActionsComponent = () => { + if ( slug !== 'google-fonts' || renderConfirmDialog || selectedFont ) { + return null; + } + return ( + + ); + }; + return ( } description={ ! selectedFont ? selectedCollection.description diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js b/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js index c959a5373190ec..87a569b5500ee0 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js @@ -11,6 +11,7 @@ import { Button, Notice, FlexBlock, + FlexItem, } from '@wordpress/components'; import { chevronLeft } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; @@ -27,6 +28,7 @@ function TabPanelLayout( { handleBack, children, footer, + actions, } ) { const { setNotice } = useContext( FontLibraryContext ); @@ -35,7 +37,11 @@ function TabPanelLayout( { - + { !! handleBack && (