Skip to content

Commit 981d2c1

Browse files
committed
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.
1 parent 5893eba commit 981d2c1

18 files changed

Lines changed: 883 additions & 178 deletions

File tree

‎package-lock.json‎

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/block-library/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- 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)).
1414
- 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)).
1515
- Use the `inertValue` helper to set `inert` attributes, for better React 18/19 compatibility ([#83871](https://github.com/WordPress/gutenberg/pull/83871)).
16+
- 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)).
1617

1718
### Bug Fixes
1819

‎packages/block-library/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@
148148
"@wordpress/integration-tests": "file:../../test/integration",
149149
"@wordpress/stylelint-tools": "file:../../tools/stylelint",
150150
"deep-freeze": "^0.0.1",
151-
"vitest": "^5.0.0"
151+
"vitest": "^5.0.0",
152+
"vitest-browser-react": "^2.3.0"
152153
},
153154
"peerDependencies": {
154155
"@types/react": "^18 || ^19",

‎packages/block-library/src/cover/edit/index.jsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,11 +564,18 @@ function CoverEdit( {
564564
return;
565565
}
566566

567+
const previousImage = { id, url: propsRef.current.attributes.url };
568+
let isUndone = false;
567569
openMediaEditorModal( {
568570
id,
569571
onClose: () => {
570572
editMediaButtonRef.current?.focus();
571573
},
574+
onUndo: () => {
575+
isUndone = true;
576+
setIsSwappingMedia( false );
577+
setAttributes( previousImage );
578+
},
572579
onUpdate: async ( { id: newId, url: newUrl } ) => {
573580
if ( typeof newId !== 'number' ) {
574581
return;
@@ -590,6 +597,10 @@ function CoverEdit( {
590597
if ( newUrl ) {
591598
const averageBackgroundColor =
592599
await getMediaColor( newUrl );
600+
// Snackbar Undo can run while the image color is loading.
601+
if ( isUndone ) {
602+
return;
603+
}
593604

594605
// Read latest values after await to avoid stale closures.
595606
const {

‎packages/block-library/src/cover/test/edit.browser.test.js‎

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1-
import { beforeEach, describe, expect, test } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
22
import { userEvent } from 'vitest/browser';
3+
import {
4+
cleanup as cleanupBrowser,
5+
render as renderBrowser,
6+
} from 'vitest-browser-react';
37
import { screen, act, within, waitFor } from '@testing-library/react';
4-
import { createBlock } from '@wordpress/blocks';
8+
import {
9+
createBlock,
10+
getBlockTypes,
11+
unregisterBlockType,
12+
} from '@wordpress/blocks';
13+
import { createElement, useState } from '@wordpress/element';
14+
import { dispatch, select } from '@wordpress/data';
15+
import {
16+
store as richTextStore,
17+
unregisterFormatType,
18+
} from '@wordpress/rich-text';
19+
import {
20+
BlockInspector,
21+
privateApis as blockEditorPrivateApis,
22+
store as blockEditorStore,
23+
} from '@wordpress/block-editor';
524
import {
625
initializeEditor,
726
selectBlock,
@@ -12,6 +31,16 @@ import { registerCoreBlocks } from '@wordpress/block-library';
1231
import '../../../../block-editor/src/components/block-popover/style.scss';
1332
// eslint-disable-next-line @wordpress/no-non-module-stylesheet-imports -- Browser fixtures need the styles WordPress normally enqueues.
1433
import '../../../../components/src/popover/style.scss';
34+
import { unlock } from '../../lock-unlock';
35+
import { getMediaColor } from '../edit/color-utils';
36+
37+
vi.mock( import( '../edit/color-utils' ), { spy: true } );
38+
39+
const {
40+
ExperimentalBlockCanvas: BlockCanvas,
41+
ExperimentalBlockEditorProvider: BlockEditorProvider,
42+
openMediaEditorModalKey,
43+
} = unlock( blockEditorPrivateApis );
1544
const defaultSettings = {
1645
__experimentalFeatures: {
1746
color: {
@@ -212,6 +241,154 @@ describe( 'Cover block', () => {
212241
} );
213242
} );
214243

244+
describe( 'media editor Undo', () => {
245+
const originalUrl =
246+
'data:image/svg+xml,' +
247+
encodeURIComponent(
248+
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="black"/></svg>'
249+
);
250+
const updatedImage = { id: 11, url: `${ originalUrl }#edited` };
251+
252+
beforeEach( () => {
253+
vi.mocked( getMediaColor ).mockResolvedValue( '#ffffff' );
254+
} );
255+
256+
afterEach( async () => {
257+
await cleanupBrowser();
258+
getBlockTypes().forEach( ( { name } ) =>
259+
unregisterBlockType( name )
260+
);
261+
select( richTextStore )
262+
.getFormatTypes()
263+
.forEach( ( { name } ) => unregisterFormatType( name ) );
264+
} );
265+
266+
async function openMediaEditor( initialAttributes = {} ) {
267+
const openModal = vi.fn();
268+
const block = createBlock( 'core/cover', {
269+
id: 10,
270+
url: originalUrl,
271+
backgroundType: 'image',
272+
sizeSlug: 'medium',
273+
overlayColor: 'black',
274+
isDark: true,
275+
isUserOverlayColor: false,
276+
...initialAttributes,
277+
} );
278+
function TestEditor() {
279+
const [ blocks, setBlocks ] = useState( [ block ] );
280+
return createElement(
281+
BlockEditorProvider,
282+
{
283+
value: blocks,
284+
onInput: setBlocks,
285+
onChange: setBlocks,
286+
useSubRegistry: false,
287+
settings: {
288+
...defaultSettings,
289+
[ openMediaEditorModalKey ]: openModal,
290+
},
291+
},
292+
createElement( BlockInspector ),
293+
createElement( BlockCanvas, {
294+
height: '100%',
295+
shouldIframe: false,
296+
} )
297+
);
298+
}
299+
await renderBrowser( createElement( TestEditor ) );
300+
await userEvent.click( screen.getByLabelText( 'Block: Cover' ) );
301+
await userEvent.click(
302+
screen.getByRole( 'button', { name: 'Edit image' } )
303+
);
304+
const { clientId } = select( blockEditorStore ).getBlocks()[ 0 ];
305+
return {
306+
...openModal.mock.calls[ 0 ][ 0 ],
307+
getAttributes: () =>
308+
select( blockEditorStore ).getBlockAttributes( clientId ),
309+
editAttributes: ( attributes ) =>
310+
dispatch( blockEditorStore ).updateBlockAttributes(
311+
clientId,
312+
attributes
313+
),
314+
};
315+
}
316+
317+
test( 'restores only the previous image without reverting Cover settings', async () => {
318+
const { onUpdate, onUndo, getAttributes, editAttributes } =
319+
await openMediaEditor();
320+
await act( () => onUpdate( updatedImage ) );
321+
expect( getAttributes() ).toMatchObject( {
322+
id: 11,
323+
sizeSlug: 'full',
324+
overlayColor: 'white',
325+
isDark: false,
326+
} );
327+
await act( () =>
328+
editAttributes( { dimRatio: 75, sizeSlug: 'large' } )
329+
);
330+
const beforeUndo = getAttributes();
331+
332+
act( () => onUndo() );
333+
334+
expect( getAttributes() ).toEqual( {
335+
...beforeUndo,
336+
id: 10,
337+
url: originalUrl,
338+
} );
339+
} );
340+
341+
test( 'preserves an overlay chosen after saving when undoing the media update', async () => {
342+
const { onUpdate, onUndo, getAttributes, editAttributes } =
343+
await openMediaEditor();
344+
await act( () => onUpdate( updatedImage ) );
345+
await act( () =>
346+
editAttributes( {
347+
overlayColor: undefined,
348+
customOverlayColor: '#000000',
349+
isUserOverlayColor: true,
350+
dimRatio: 90,
351+
isDark: true,
352+
} )
353+
);
354+
355+
const beforeUndo = getAttributes();
356+
act( () => onUndo() );
357+
358+
expect( getAttributes() ).toEqual( {
359+
...beforeUndo,
360+
id: 10,
361+
url: originalUrl,
362+
} );
363+
} );
364+
365+
test( 'does not reapply a media update when Undo runs before its color loads', async () => {
366+
const { onUpdate, onUndo, getAttributes } = await openMediaEditor();
367+
const previous = getAttributes();
368+
let resolveColor;
369+
vi.mocked( getMediaColor ).mockReturnValueOnce(
370+
new Promise( ( resolve ) => {
371+
resolveColor = resolve;
372+
} )
373+
);
374+
let updatePromise;
375+
act( () => {
376+
updatePromise = onUpdate( updatedImage );
377+
} );
378+
379+
act( () => onUndo() );
380+
await act( async () => {
381+
resolveColor( '#ffffff' );
382+
await updatePromise;
383+
} );
384+
385+
expect( getAttributes() ).toEqual( previous );
386+
expect(
387+
screen.getByRole( 'button', { name: 'Edit image' } )
388+
).toBeEnabled();
389+
} );
390+
} );
391+
215392
describe( 'Inspector controls', () => {
216393
describe( 'Media settings', () => {
217394
test( 'does not display media settings panel if url is not set', async () => {

0 commit comments

Comments
 (0)