Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Media editor: restore the original image via its edit root
Adds a Restore original image action to the media editor's More
options menu, shown when the attachment exposes the edit_root field
recorded by its lineage (#81803).

- Restore loads the root attachment and its editable details, and
  discards pending edits to the derivative.
- Saving an untouched restore repoints the block without writing an
  attachment; transforming first runs /edit against the root.
- Metadata fields are disabled while a restore is pending; detail
  saves retarget the root.
- Image and Cover restore the previous attachment with snackbar Undo.

Squashed end state of the restore-original branch, ported onto the
session cropper engine after the snapshot-history refactors landed on
trunk.
  • Loading branch information
ramonjd committed Oct 6, 2026
commit 973f8d3329a86d613ad74645f6bf40f3b1a767ee
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/block-library/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Icon: Match an icon's keywords as well as its name and label when searching the icon library ([#82367](https://github.com/WordPress/gutenberg/pull/82367)).
- Button: Only open the link popover from the toolbar button or keyboard shortcut instead of whenever a linked button is selected. The toolbar button now shows a pressed state when a link is set ([#30166](https://github.com/WordPress/gutenberg/pull/30166)).
- Use the `inertValue` helper to set `inert` attributes, for better React 18/19 compatibility ([#83871](https://github.com/WordPress/gutenberg/pull/83871)).
- Image, Cover: Restore the previous attachment with snackbar Undo, including attachment-derived Image attributes while preserving Cover settings ([#81805](https://github.com/WordPress/gutenberg/pull/81805)).

### Bug Fixes

Expand Down
3 changes: 2 additions & 1 deletion packages/block-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@
"@wordpress/integration-tests": "file:../../test/integration",
"@wordpress/stylelint-tools": "file:../../tools/stylelint",
"deep-freeze": "^0.0.1",
"vitest": "^5.0.3"
"vitest": "^5.0.3",
"vitest-browser-react": "^2.3.0"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
Expand Down
11 changes: 11 additions & 0 deletions packages/block-library/src/cover/edit/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -564,11 +564,18 @@ function CoverEdit( {
return;
}

const previousImage = { id, url: propsRef.current.attributes.url };
let isUndone = false;
openMediaEditorModal( {
id,
onClose: () => {
editMediaButtonRef.current?.focus();
},
onUndo: () => {
isUndone = true;
setIsSwappingMedia( false );
setAttributes( previousImage );
},
onUpdate: async ( { id: newId, url: newUrl } ) => {
if ( typeof newId !== 'number' ) {
return;
Expand All @@ -590,6 +597,10 @@ function CoverEdit( {
if ( newUrl ) {
const averageBackgroundColor =
await getMediaColor( newUrl );
// Snackbar Undo can run while the image color is loading.
if ( isUndone ) {
return;
}

// Read latest values after await to avoid stale closures.
const {
Expand Down
181 changes: 179 additions & 2 deletions packages/block-library/src/cover/test/edit.browser.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { userEvent } from 'vitest/browser';
import {
cleanup as cleanupBrowser,
render as renderBrowser,
} from 'vitest-browser-react';
import { screen, act, within, waitFor } from '@testing-library/react';
import { createBlock } from '@wordpress/blocks';
import {
createBlock,
getBlockTypes,
unregisterBlockType,
} from '@wordpress/blocks';
import { createElement, useState } from '@wordpress/element';
import { dispatch, select } from '@wordpress/data';
import {
store as richTextStore,
unregisterFormatType,
} from '@wordpress/rich-text';
import {
BlockInspector,
privateApis as blockEditorPrivateApis,
store as blockEditorStore,
} from '@wordpress/block-editor';
import {
initializeEditor,
selectBlock,
Expand All @@ -10,6 +29,16 @@ import { registerCoreBlocks } from '@wordpress/block-library';
// Keep the matrix popover above the block toolbar, as in the editor.
import '../../../../block-editor/src/components/block-popover/style.scss';
import '../../../../components/src/popover/style.scss';
import { unlock } from '../../lock-unlock';
import { getMediaColor } from '../edit/color-utils';

vi.mock( import( '../edit/color-utils' ), { spy: true } );

const {
ExperimentalBlockCanvas: BlockCanvas,
ExperimentalBlockEditorProvider: BlockEditorProvider,
openMediaEditorModalKey,
} = unlock( blockEditorPrivateApis );
const defaultSettings = {
__experimentalFeatures: {
color: {
Expand Down Expand Up @@ -210,6 +239,154 @@ describe( 'Cover block', () => {
} );
} );

describe( 'media editor Undo', () => {
const originalUrl =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="black"/></svg>'
);
const updatedImage = { id: 11, url: `${ originalUrl }#edited` };

beforeEach( () => {
vi.mocked( getMediaColor ).mockResolvedValue( '#ffffff' );
} );

afterEach( async () => {
await cleanupBrowser();
getBlockTypes().forEach( ( { name } ) =>
unregisterBlockType( name )
);
select( richTextStore )
.getFormatTypes()
.forEach( ( { name } ) => unregisterFormatType( name ) );
} );

async function openMediaEditor( initialAttributes = {} ) {
const openModal = vi.fn();
const block = createBlock( 'core/cover', {
id: 10,
url: originalUrl,
backgroundType: 'image',
sizeSlug: 'medium',
overlayColor: 'black',
isDark: true,
isUserOverlayColor: false,
...initialAttributes,
} );
function TestEditor() {
const [ blocks, setBlocks ] = useState( [ block ] );
return createElement(
BlockEditorProvider,
{
value: blocks,
onInput: setBlocks,
onChange: setBlocks,
useSubRegistry: false,
settings: {
...defaultSettings,
[ openMediaEditorModalKey ]: openModal,
},
},
createElement( BlockInspector ),
createElement( BlockCanvas, {
height: '100%',
shouldIframe: false,
} )
);
}
await renderBrowser( createElement( TestEditor ) );
await userEvent.click( screen.getByLabelText( 'Block: Cover' ) );
await userEvent.click(
screen.getByRole( 'button', { name: 'Edit image' } )
);
const { clientId } = select( blockEditorStore ).getBlocks()[ 0 ];
return {
...openModal.mock.calls[ 0 ][ 0 ],
getAttributes: () =>
select( blockEditorStore ).getBlockAttributes( clientId ),
editAttributes: ( attributes ) =>
dispatch( blockEditorStore ).updateBlockAttributes(
clientId,
attributes
),
};
}

test( 'restores only the previous image without reverting Cover settings', async () => {
const { onUpdate, onUndo, getAttributes, editAttributes } =
await openMediaEditor();
await act( () => onUpdate( updatedImage ) );
expect( getAttributes() ).toMatchObject( {
id: 11,
sizeSlug: 'full',
overlayColor: 'white',
isDark: false,
} );
await act( () =>
editAttributes( { dimRatio: 75, sizeSlug: 'large' } )
);
const beforeUndo = getAttributes();

act( () => onUndo() );

expect( getAttributes() ).toEqual( {
...beforeUndo,
id: 10,
url: originalUrl,
} );
} );

test( 'preserves an overlay chosen after saving when undoing the media update', async () => {
const { onUpdate, onUndo, getAttributes, editAttributes } =
await openMediaEditor();
await act( () => onUpdate( updatedImage ) );
await act( () =>
editAttributes( {
overlayColor: undefined,
customOverlayColor: '#000000',
isUserOverlayColor: true,
dimRatio: 90,
isDark: true,
} )
);

const beforeUndo = getAttributes();
act( () => onUndo() );

expect( getAttributes() ).toEqual( {
...beforeUndo,
id: 10,
url: originalUrl,
} );
} );

test( 'does not reapply a media update when Undo runs before its color loads', async () => {
const { onUpdate, onUndo, getAttributes } = await openMediaEditor();
const previous = getAttributes();
let resolveColor;
vi.mocked( getMediaColor ).mockReturnValueOnce(
new Promise( ( resolve ) => {
resolveColor = resolve;
} )
);
let updatePromise;
act( () => {
updatePromise = onUpdate( updatedImage );
} );

act( () => onUndo() );
await act( async () => {
resolveColor( '#ffffff' );
await updatePromise;
} );

expect( getAttributes() ).toEqual( previous );
expect(
screen.getByRole( 'button', { name: 'Edit image' } )
).toBeEnabled();
} );
} );

describe( 'Inspector controls', () => {
describe( 'Media settings', () => {
test( 'does not display media settings panel if url is not set', async () => {
Expand Down
Loading