From 93323fd1a3cecd0771ead22b16a9661c53be6212 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Fri, 27 Sep 2024 14:18:41 +0800 Subject: [PATCH 01/14] Fix unable to remove empty blocks on merge (#65262) Co-authored-by: kevin940726 Co-authored-by: ntsekouras Co-authored-by: talldan Co-authored-by: andrewserong Co-authored-by: youknowriad Co-authored-by: getdave Co-authored-by: Mamaduka Co-authored-by: ramonjd Co-authored-by: kspilarski Co-authored-by: ndiego Co-authored-by: richtabor * Fix unable to remove empty blocks on merge * Update to the stabilized API * Rename the utils --- .../src/components/block-list/block.js | 58 ++++++++--- packages/blocks/src/api/index.js | 2 + packages/blocks/src/api/utils.js | 68 ++++++++++--- .../editor/various/splitting-merging.spec.js | 97 +++++++++++++++++++ 4 files changed, 200 insertions(+), 25 deletions(-) diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 90c39649319dc8..2cecd941dfa3bb 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -25,6 +25,7 @@ import { getBlockDefaultClassName, hasBlockSupport, store as blocksStore, + privateApis as blocksPrivateApis, } from '@wordpress/blocks'; import { withFilters } from '@wordpress/components'; import { withDispatch, useDispatch, useSelect } from '@wordpress/data'; @@ -46,6 +47,8 @@ import { PrivateBlockContext } from './private-block-context'; import { unlock } from '../../lock-unlock'; +const { isUnmodifiedBlockContent } = unlock( blocksPrivateApis ); + /** * Merges wrapper props with special handling for classNames and styles. * @@ -350,12 +353,48 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => { removeBlock( _clientId ); } else { registry.batch( () => { + const firstBlock = getBlock( firstClientId ); + const isFirstBlockContentUnmodified = + isUnmodifiedBlockContent( firstBlock ); + const defaultBlockName = getDefaultBlockName(); + const replacement = switchToBlockType( + firstBlock, + defaultBlockName + ); + const canTransformToDefaultBlock = + !! replacement?.length && + replacement.every( ( block ) => + canInsertBlockType( block.name, _clientId ) + ); + if ( + isFirstBlockContentUnmodified && + canTransformToDefaultBlock + ) { + // Step 1: If the block is empty and can be transformed to the default block type. + replaceBlocks( + firstClientId, + replacement, + changeSelection + ); + } else if ( + isFirstBlockContentUnmodified && + firstBlock.name === defaultBlockName + ) { + // Step 2: If the block is empty and is already the default block type. + removeBlock( firstClientId ); + const nextBlockClientId = + getNextBlockClientId( clientId ); + if ( nextBlockClientId ) { + selectBlock( nextBlockClientId ); + } + } else if ( canInsertBlockType( - getBlockName( firstClientId ), + firstBlock.name, targetRootClientId ) ) { + // Step 3: If the block can be moved up. moveBlocksToPosition( [ firstClientId ], _clientId, @@ -363,21 +402,17 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => { getBlockIndex( _clientId ) ); } else { - const replacement = switchToBlockType( - getBlock( firstClientId ), - getDefaultBlockName() - ); - - if ( - replacement && - replacement.length && + const canLiftAndTransformToDefaultBlock = + !! replacement?.length && replacement.every( ( block ) => canInsertBlockType( block.name, targetRootClientId ) - ) - ) { + ); + + if ( canLiftAndTransformToDefaultBlock ) { + // Step 4: If the block can be transformed to the default block type and moved up. insertBlocks( replacement, getBlockIndex( _clientId ), @@ -386,6 +421,7 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => { ); removeBlock( firstClientId, false ); } else { + // Step 5: Continue the default behavior. switchToDefaultOrRemove(); } } diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index e7ab69af71103a..e23f347fe4fee8 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -8,6 +8,7 @@ import { getBlockBindingsSource, getBlockBindingsSources, } from './registration'; +import { isUnmodifiedBlockContent } from './utils'; // The blocktype is the most important concept within the block API. It defines // all aspects of the block configuration and its interfaces, including `edit` @@ -183,4 +184,5 @@ lock( privateApis, { unregisterBlockBindingsSource, getBlockBindingsSource, getBlockBindingsSources, + isUnmodifiedBlockContent, } ); diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index 20f0f6a85ed091..7bace4ff84c29b 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -30,6 +30,30 @@ extend( [ namesPlugin, a11yPlugin ] ); */ const ICON_COLORS = [ '#191e23', '#f8f9f9' ]; +/** + * Determines whether the block's attribute is equal to the default attribute + * which means the attribute is unmodified. + * @param {Object} attributeDefinition The attribute's definition of the block type. + * @param {*} value The attribute's value. + * @return {boolean} Whether the attribute is unmodified. + */ +function isUnmodifiedAttribute( attributeDefinition, value ) { + // Every attribute that has a default must match the default. + if ( attributeDefinition.hasOwnProperty( 'default' ) ) { + return value === attributeDefinition.default; + } + + // The rich text type is a bit different from the rest because it + // has an implicit default value of an empty RichTextData instance, + // so check the length of the value. + if ( attributeDefinition.type === 'rich-text' ) { + return ! value?.length; + } + + // Every attribute that doesn't have a default should be undefined. + return value === undefined; +} + /** * Determines whether the block's attributes are equal to the default attributes * which means the block is unmodified. @@ -43,20 +67,7 @@ export function isUnmodifiedBlock( block ) { ( [ key, definition ] ) => { const value = block.attributes[ key ]; - // Every attribute that has a default must match the default. - if ( definition.hasOwnProperty( 'default' ) ) { - return value === definition.default; - } - - // The rich text type is a bit different from the rest because it - // has an implicit default value of an empty RichTextData instance, - // so check the length of the value. - if ( definition.type === 'rich-text' ) { - return ! value?.length; - } - - // Every attribute that doesn't have a default should be undefined. - return value === undefined; + return isUnmodifiedAttribute( definition, value ); } ); } @@ -73,6 +84,35 @@ export function isUnmodifiedDefaultBlock( block ) { return block.name === getDefaultBlockName() && isUnmodifiedBlock( block ); } +/** + * Determines whether the block content is unmodified. A block content is + * considered unmodified if all the attributes that have a role of 'content' + * are equal to the default attributes (or undefined). + * If the block does not have any attributes with a role of 'content', it + * will be considered unmodified if all the attributes are equal to the default + * attributes (or undefined). + * + * @param {WPBlock} block Block Object + * @return {boolean} Whether the block content is unmodified. + */ +export function isUnmodifiedBlockContent( block ) { + const contentAttributes = getBlockAttributesNamesByRole( + block.name, + 'content' + ); + + if ( contentAttributes.length === 0 ) { + return isUnmodifiedBlock( block ); + } + + return contentAttributes.every( ( key ) => { + const definition = getBlockType( block.name )?.attributes[ key ]; + const value = block.attributes[ key ]; + + return isUnmodifiedAttribute( definition, value ); + } ); +} + /** * Function that checks if the parameter is a valid icon. * diff --git a/test/e2e/specs/editor/various/splitting-merging.spec.js b/test/e2e/specs/editor/various/splitting-merging.spec.js index 29e7e5d64522c9..eba9f1d3163fd5 100644 --- a/test/e2e/specs/editor/various/splitting-merging.spec.js +++ b/test/e2e/specs/editor/various/splitting-merging.spec.js @@ -373,6 +373,103 @@ test.describe( 'splitting and merging blocks (@firefox, @webkit)', () => { ); } ); + // Fix for https://github.com/WordPress/gutenberg/issues/65174. + test( 'should handle unwrapping and merging blocks with empty contents', async ( { + editor, + page, + } ) => { + const emptyAlignedParagraph = { + name: 'core/paragraph', + attributes: { content: '', align: 'center', dropCap: false }, + innerBlocks: [], + }; + const emptyAlignedHeading = { + name: 'core/heading', + attributes: { content: '', textAlign: 'center', level: 2 }, + innerBlocks: [], + }; + const headingWithContent = { + name: 'core/heading', + attributes: { content: 'heading', level: 2 }, + innerBlocks: [], + }; + const placeholderBlock = { name: 'core/separator' }; + await editor.insertBlock( { + name: 'core/group', + innerBlocks: [ + emptyAlignedParagraph, + emptyAlignedHeading, + headingWithContent, + placeholderBlock, + ], + } ); + await editor.canvas + .getByRole( 'document', { name: 'Empty block' } ) + .focus(); + + await page.keyboard.press( 'Backspace' ); + await expect + .poll( editor.getBlocks, 'Remove the default empty block' ) + .toEqual( [ + { + name: 'core/group', + attributes: { tagName: 'div' }, + innerBlocks: [ + emptyAlignedHeading, + headingWithContent, + expect.objectContaining( placeholderBlock ), + ], + }, + ] ); + + // Move the caret to the beginning of the empty heading block. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Backspace' ); + await expect + .poll( + editor.getBlocks, + 'Convert the non-default empty block to a default block' + ) + .toEqual( [ + { + name: 'core/group', + attributes: { tagName: 'div' }, + innerBlocks: [ + emptyAlignedParagraph, + headingWithContent, + expect.objectContaining( placeholderBlock ), + ], + }, + ] ); + await page.keyboard.press( 'Backspace' ); + await expect.poll( editor.getBlocks ).toEqual( [ + { + name: 'core/group', + attributes: { tagName: 'div' }, + innerBlocks: [ + headingWithContent, + expect.objectContaining( placeholderBlock ), + ], + }, + ] ); + + // Move the caret to the beginning of the "heading" heading block. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Backspace' ); + await expect + .poll( editor.getBlocks, 'Lift the non-empty non-default block' ) + .toEqual( [ + headingWithContent, + { + name: 'core/group', + attributes: { tagName: 'div' }, + innerBlocks: [ + expect.objectContaining( placeholderBlock ), + ], + }, + ] ); + } ); + test.describe( 'test restore selection when merge produces more than one block', () => { const snap1 = [ { From ebb58c858addf8268d1c171b9ac85f4b55e0553b Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Fri, 27 Sep 2024 10:24:01 +0200 Subject: [PATCH 02/14] Hooks: add support for async filters and actions (#64204) * Hooks: add support for async filters and actions * Unit tests for doing/didAction/Filter --- packages/hooks/CHANGELOG.md | 4 + packages/hooks/README.md | 2 + packages/hooks/src/createCurrentHook.js | 7 +- packages/hooks/src/createDoingHook.js | 10 +- packages/hooks/src/createHooks.js | 10 +- packages/hooks/src/createRunHook.js | 57 +++++---- packages/hooks/src/index.js | 6 +- packages/hooks/src/test/index.test.js | 150 ++++++++++++++++++++++++ 8 files changed, 211 insertions(+), 35 deletions(-) diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md index 0e162b64513d26..060e061b5c2843 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- added new `doActionAsync` and `applyFiltersAsync` functions to run hooks in async mode ([#64204](https://github.com/WordPress/gutenberg/pull/64204)). + ## 4.8.0 (2024-09-19) ## 4.7.0 (2024-09-05) diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 3e9897c79952cd..f80d2e63af37ba 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -41,7 +41,9 @@ One notable difference between the JS and PHP hooks API is that in the JS versio - `removeAllActions( 'hookName' )` - `removeAllFilters( 'hookName' )` - `doAction( 'hookName', arg1, arg2, moreArgs, finalArg )` +- `doActionAsync( 'hookName', arg1, arg2, moreArgs, finalArg )` - `applyFilters( 'hookName', content, arg1, arg2, moreArgs, finalArg )` +- `applyFiltersAsync( 'hookName', content, arg1, arg2, moreArgs, finalArg )` - `doingAction( 'hookName' )` - `doingFilter( 'hookName' )` - `didAction( 'hookName' )` diff --git a/packages/hooks/src/createCurrentHook.js b/packages/hooks/src/createCurrentHook.js index 634901fe55f63a..3ada0322496004 100644 --- a/packages/hooks/src/createCurrentHook.js +++ b/packages/hooks/src/createCurrentHook.js @@ -11,11 +11,8 @@ function createCurrentHook( hooks, storeKey ) { return function currentHook() { const hooksStore = hooks[ storeKey ]; - - return ( - hooksStore.__current[ hooksStore.__current.length - 1 ]?.name ?? - null - ); + const currentArray = Array.from( hooksStore.__current ); + return currentArray.at( -1 )?.name ?? null; }; } diff --git a/packages/hooks/src/createDoingHook.js b/packages/hooks/src/createDoingHook.js index 652ab06b4ba728..9fccf38171f332 100644 --- a/packages/hooks/src/createDoingHook.js +++ b/packages/hooks/src/createDoingHook.js @@ -24,13 +24,13 @@ function createDoingHook( hooks, storeKey ) { // If the hookName was not passed, check for any current hook. if ( 'undefined' === typeof hookName ) { - return 'undefined' !== typeof hooksStore.__current[ 0 ]; + return hooksStore.__current.size > 0; } - // Return the __current hook. - return hooksStore.__current[ 0 ] - ? hookName === hooksStore.__current[ 0 ].name - : false; + // Find if the `hookName` hook is in `__current`. + return Array.from( hooksStore.__current ).some( + ( hook ) => hook.name === hookName + ); }; } diff --git a/packages/hooks/src/createHooks.js b/packages/hooks/src/createHooks.js index 361383a3a97fc9..1f9b1a8206b020 100644 --- a/packages/hooks/src/createHooks.js +++ b/packages/hooks/src/createHooks.js @@ -20,11 +20,11 @@ export class _Hooks { constructor() { /** @type {import('.').Store} actions */ this.actions = Object.create( null ); - this.actions.__current = []; + this.actions.__current = new Set(); /** @type {import('.').Store} filters */ this.filters = Object.create( null ); - this.filters.__current = []; + this.filters.__current = new Set(); this.addAction = createAddHook( this, 'actions' ); this.addFilter = createAddHook( this, 'filters' ); @@ -34,8 +34,10 @@ export class _Hooks { this.hasFilter = createHasHook( this, 'filters' ); this.removeAllActions = createRemoveHook( this, 'actions', true ); this.removeAllFilters = createRemoveHook( this, 'filters', true ); - this.doAction = createRunHook( this, 'actions' ); - this.applyFilters = createRunHook( this, 'filters', true ); + this.doAction = createRunHook( this, 'actions', false, false ); + this.doActionAsync = createRunHook( this, 'actions', false, true ); + this.applyFilters = createRunHook( this, 'filters', true, false ); + this.applyFiltersAsync = createRunHook( this, 'filters', true, true ); this.currentAction = createCurrentHook( this, 'actions' ); this.currentFilter = createCurrentHook( this, 'filters' ); this.doingAction = createDoingHook( this, 'actions' ); diff --git a/packages/hooks/src/createRunHook.js b/packages/hooks/src/createRunHook.js index c2bf6fd187ce08..f2a56dbdc0d717 100644 --- a/packages/hooks/src/createRunHook.js +++ b/packages/hooks/src/createRunHook.js @@ -3,15 +3,15 @@ * registered to a hook of the specified type, optionally returning the final * value of the call chain. * - * @param {import('.').Hooks} hooks Hooks instance. + * @param {import('.').Hooks} hooks Hooks instance. * @param {import('.').StoreKey} storeKey - * @param {boolean} [returnFirstArg=false] Whether each hook callback is expected to - * return its first argument. + * @param {boolean} returnFirstArg Whether each hook callback is expected to return its first argument. + * @param {boolean} async Whether the hook callback should be run asynchronously * * @return {(hookName:string, ...args: unknown[]) => undefined|unknown} Function that runs hook callbacks. */ -function createRunHook( hooks, storeKey, returnFirstArg = false ) { - return function runHooks( hookName, ...args ) { +function createRunHook( hooks, storeKey, returnFirstArg, async ) { + return function runHook( hookName, ...args ) { const hooksStore = hooks[ storeKey ]; if ( ! hooksStore[ hookName ] ) { @@ -42,26 +42,43 @@ function createRunHook( hooks, storeKey, returnFirstArg = false ) { currentIndex: 0, }; - hooksStore.__current.push( hookInfo ); - - while ( hookInfo.currentIndex < handlers.length ) { - const handler = handlers[ hookInfo.currentIndex ]; - - const result = handler.callback.apply( null, args ); - if ( returnFirstArg ) { - args[ 0 ] = result; + async function asyncRunner() { + try { + hooksStore.__current.add( hookInfo ); + let result = returnFirstArg ? args[ 0 ] : undefined; + while ( hookInfo.currentIndex < handlers.length ) { + const handler = handlers[ hookInfo.currentIndex ]; + result = await handler.callback.apply( null, args ); + if ( returnFirstArg ) { + args[ 0 ] = result; + } + hookInfo.currentIndex++; + } + return returnFirstArg ? result : undefined; + } finally { + hooksStore.__current.delete( hookInfo ); } - - hookInfo.currentIndex++; } - hooksStore.__current.pop(); - - if ( returnFirstArg ) { - return args[ 0 ]; + function syncRunner() { + try { + hooksStore.__current.add( hookInfo ); + let result = returnFirstArg ? args[ 0 ] : undefined; + while ( hookInfo.currentIndex < handlers.length ) { + const handler = handlers[ hookInfo.currentIndex ]; + result = handler.callback.apply( null, args ); + if ( returnFirstArg ) { + args[ 0 ] = result; + } + hookInfo.currentIndex++; + } + return returnFirstArg ? result : undefined; + } finally { + hooksStore.__current.delete( hookInfo ); + } } - return undefined; + return ( async ? asyncRunner : syncRunner )(); }; } diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js index 653a9537145d91..1d13397e406c6b 100644 --- a/packages/hooks/src/index.js +++ b/packages/hooks/src/index.js @@ -25,7 +25,7 @@ import createHooks from './createHooks'; */ /** - * @typedef {Record & {__current: Current[]}} Store + * @typedef {Record & {__current: Set}} Store */ /** @@ -48,7 +48,9 @@ const { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, @@ -70,7 +72,9 @@ export { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, diff --git a/packages/hooks/src/test/index.test.js b/packages/hooks/src/test/index.test.js index 9b7eb3b8e0e223..5fdaf5fc7207a1 100644 --- a/packages/hooks/src/test/index.test.js +++ b/packages/hooks/src/test/index.test.js @@ -12,7 +12,9 @@ import { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, @@ -943,3 +945,151 @@ test( 'checking hasFilter with named callbacks and removeAllActions', () => { expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( false ); expect( hasFilter( 'test.filter', 'my_second_callback' ) ).toBe( false ); } ); + +describe( 'async filter', () => { + test( 'runs all registered handlers', async () => { + addFilter( 'test.async.filter', 'callback_plus1', ( value ) => { + return new Promise( ( r ) => + setTimeout( () => r( value + 1 ), 10 ) + ); + } ); + addFilter( 'test.async.filter', 'callback_times2', ( value ) => { + return new Promise( ( r ) => + setTimeout( () => r( value * 2 ), 10 ) + ); + } ); + + expect( await applyFiltersAsync( 'test.async.filter', 2 ) ).toBe( 6 ); + } ); + + test( 'aborts when handler throws an error', async () => { + const sqrt = jest.fn( async ( value ) => { + if ( value < 0 ) { + throw new Error( 'cannot pass negative value to sqrt' ); + } + return Math.sqrt( value ); + } ); + + const plus1 = jest.fn( async ( value ) => { + return value + 1; + } ); + + addFilter( 'test.async.filter', 'callback_sqrt', sqrt ); + addFilter( 'test.async.filter', 'callback_plus1', plus1 ); + + await expect( + applyFiltersAsync( 'test.async.filter', -1 ) + ).rejects.toThrow( 'cannot pass negative value to sqrt' ); + expect( sqrt ).toHaveBeenCalledTimes( 1 ); + expect( plus1 ).not.toHaveBeenCalled(); + } ); + + test( 'is correctly tracked by doingFilter and didFilter', async () => { + addFilter( 'test.async.filter', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter' ) ).toBe( true ); + return value; + } ); + + expect( doingFilter( 'test.async.filter' ) ).toBe( false ); + expect( didFilter( 'test.async.filter' ) ).toBe( 0 ); + await applyFiltersAsync( 'test.async.filter', 0 ); + expect( doingFilter( 'test.async.filter' ) ).toBe( false ); + expect( didFilter( 'test.async.filter' ) ).toBe( 1 ); + } ); + + test( 'is correctly tracked when multiple filters run at once', async () => { + addFilter( 'test.async.filter1', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter1' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + return value; + } ); + addFilter( 'test.async.filter2', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter2' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + return value; + } ); + + await Promise.all( [ + applyFiltersAsync( 'test.async.filter1', 0 ), + applyFiltersAsync( 'test.async.filter2', 0 ), + ] ); + } ); +} ); + +describe( 'async action', () => { + test( 'runs all registered handlers sequentially', async () => { + const outputs = []; + const action1 = async () => { + outputs.push( 1 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 2 ); + }; + + const action2 = async () => { + outputs.push( 3 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 4 ); + }; + + addAction( 'test.async.action', 'action1', action1 ); + addAction( 'test.async.action', 'action2', action2 ); + + await doActionAsync( 'test.async.action' ); + expect( outputs ).toEqual( [ 1, 2, 3, 4 ] ); + } ); + + test( 'aborts when handler throws an error', async () => { + const outputs = []; + const action1 = async () => { + throw new Error( 'aborting' ); + }; + + const action2 = async () => { + outputs.push( 3 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 4 ); + }; + + addAction( 'test.async.action', 'action1', action1 ); + addAction( 'test.async.action', 'action2', action2 ); + + await expect( doActionAsync( 'test.async.action' ) ).rejects.toThrow( + 'aborting' + ); + expect( outputs ).toEqual( [] ); + } ); + + test( 'is correctly tracked by doingAction and didAction', async () => { + addAction( 'test.async.action', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action' ) ).toBe( true ); + } ); + + expect( doingAction( 'test.async.action' ) ).toBe( false ); + expect( didAction( 'test.async.action' ) ).toBe( 0 ); + await doActionAsync( 'test.async.action', 0 ); + expect( doingAction( 'test.async.action' ) ).toBe( false ); + expect( didAction( 'test.async.action' ) ).toBe( 1 ); + } ); + + test( 'is correctly tracked when multiple actions run at once', async () => { + addAction( 'test.async.action1', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action1' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + } ); + addAction( 'test.async.action2', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action2' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + } ); + + await Promise.all( [ + doActionAsync( 'test.async.action1', 0 ), + doActionAsync( 'test.async.action2', 0 ), + ] ); + } ); +} ); From ae1c989558079c705be7e5ecbd36fc596c9c23ae Mon Sep 17 00:00:00 2001 From: dhruvang21 <105810308+dhruvang21@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:10:24 +0530 Subject: [PATCH 03/14] Added: DropZone when sitelogo is present (#65596) * Add: DropZone when sitelogo is present * Add canUserEdit condition in dropzone Co-authored-by: dhruvang21 Co-authored-by: mikachan Co-authored-by: richtabor --------- Co-authored-by: Sarah Norris <1645628+mikachan@users.noreply.github.com> --- packages/block-library/src/site-logo/edit.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/block-library/src/site-logo/edit.js b/packages/block-library/src/site-logo/edit.js index dc95d5906d7345..36c217c1bf0c79 100644 --- a/packages/block-library/src/site-logo/edit.js +++ b/packages/block-library/src/site-logo/edit.js @@ -564,6 +564,7 @@ export default function LogoEdit( { iconId={ siteIconId } canUserEdit={ canUserEdit } /> + { canUserEdit && } ); } From 2659751cb8316b036de37ce3988829158c354a92 Mon Sep 17 00:00:00 2001 From: djcowan Date: Fri, 27 Sep 2024 22:01:48 +1000 Subject: [PATCH 04/14] RichText: Update JSDoc block for to-html-string (#65688) Co-authored-by: djcowan Co-authored-by: Mamaduka --- packages/rich-text/README.md | 2 +- packages/rich-text/src/to-html-string.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md index 3c3abc422fa5f9..033a4f2c747abe 100644 --- a/packages/rich-text/README.md +++ b/packages/rich-text/README.md @@ -430,7 +430,7 @@ Create an HTML string from a Rich Text value. _Parameters_ -- _$1_ `Object`: Named argements. +- _$1_ `Object`: Named arguments. - _$1.value_ `RichTextValue`: Rich text value. - _$1.preserveWhiteSpace_ `[boolean]`: Preserves newlines if true. diff --git a/packages/rich-text/src/to-html-string.js b/packages/rich-text/src/to-html-string.js index 35089003f0b3fb..f770dfdefc128a 100644 --- a/packages/rich-text/src/to-html-string.js +++ b/packages/rich-text/src/to-html-string.js @@ -19,7 +19,7 @@ import { toTree } from './to-tree'; /** * Create an HTML string from a Rich Text value. * - * @param {Object} $1 Named argements. + * @param {Object} $1 Named arguments. * @param {RichTextValue} $1.value Rich text value. * @param {boolean} [$1.preserveWhiteSpace] Preserves newlines if true. * From 3155ab7fff75f4204faaa5bb621811f492905dc6 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Fri, 27 Sep 2024 13:40:25 +0100 Subject: [PATCH 05/14] Add prompt to zoom out separator (#65392) * Add prompt to separator * updated the text to be animated too and to have contextual messageing - drop or insert * Fix font size * Scale font size based on zoom scale * Force font weight reset * Remove dynamic text * adds a color to the test which somehow gets the same color as the babacground after rebase on trunk * bogus change for our pipeline * revert bogus change for our pipeline to restart --------- Co-authored-by: getdave Co-authored-by: draganescu Co-authored-by: jasmussen --- .../src/components/block-list/content.scss | 12 ++++++++++++ .../components/block-list/zoom-out-separator.js | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/block-list/content.scss b/packages/block-editor/src/components/block-list/content.scss index 3e3865e689beac..c5fda109d8b67d 100644 --- a/packages/block-editor/src/components/block-list/content.scss +++ b/packages/block-editor/src/components/block-list/content.scss @@ -411,6 +411,18 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b margin-left: -1px; margin-right: -1px; transition: background-color 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: $default-font-size; + font-family: $default-font; + color: $black; + font-weight: normal; + + .is-zoomed-out & { + // Scale the font size based on the zoom level. + font-size: calc(#{$default-font-size} * ( 2 - var(--wp-block-editor-iframe-zoom-out-scale) )); + } &.is-dragged-over { background: $gray-400; diff --git a/packages/block-editor/src/components/block-list/zoom-out-separator.js b/packages/block-editor/src/components/block-list/zoom-out-separator.js index 984e29546c213b..9e0d087c2267cd 100644 --- a/packages/block-editor/src/components/block-list/zoom-out-separator.js +++ b/packages/block-editor/src/components/block-list/zoom-out-separator.js @@ -13,6 +13,7 @@ import { import { useReducedMotion } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -119,7 +120,19 @@ export function ZoomOutSeparator( { data-is-insertion-point="true" onDragOver={ () => setIsDraggedOver( true ) } onDragLeave={ () => setIsDraggedOver( false ) } - > + > + + { __( 'Drop pattern.' ) } + + ) } ); From cc0b3d3ae350e9f87527371ef9a761f27409d329 Mon Sep 17 00:00:00 2001 From: Aki Hamano <54422211+t-hamano@users.noreply.github.com> Date: Fri, 27 Sep 2024 22:04:34 +0900 Subject: [PATCH 06/14] Editor Canvas: Tweal close button (#65694) Co-authored-by: t-hamano Co-authored-by: jasmussen Co-authored-by: ramonjd --- .../edit-site/src/components/editor-canvas-container/index.js | 2 +- .../edit-site/src/components/editor-canvas-container/style.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/edit-site/src/components/editor-canvas-container/index.js b/packages/edit-site/src/components/editor-canvas-container/index.js index c55d6b188e1a25..ac1083e69abd7e 100644 --- a/packages/edit-site/src/components/editor-canvas-container/index.js +++ b/packages/edit-site/src/components/editor-canvas-container/index.js @@ -125,7 +125,7 @@ function EditorCanvasContainer( { > { shouldShowCloseButton && (