Media editor: restore original image in modal - #77781
Conversation
Adds a "Restore original image" action to the Media Editor modal's Crop
sidebar tab. When the currently-edited attachment was created via
/wp/v2/media/{id}/edit, clicking Restore loads the chain root into the
cropper as a dirty preview. Saving without further crop swaps the block
to the root attachment with no /edit call. Saving with a fresh crop
posts /edit to the root id, creating a new sibling child off the root.
A new rest_prepare_attachment filter adds media_details.root_image to
attachment responses, walking parent_image to root server-side so the
client never needs to fetch the chain.
PHPUnit covers chain / no-chain / cycle / self-reference cases.
| onChange={ onFreeformChange } | ||
| /> | ||
| </Stack> | ||
| <Stack direction="column" gap="md"> |
There was a problem hiding this comment.
All this to get the restore button to sit at the bottom of the panel.
|
Size Change: +862 B (+0.01%) Total Size: 7.82 MB 📦 View Changed
ℹ️ View Unchanged
|
Replaces the read-time chain walk with a single meta lookup. A new hook on `wp_edited_image_metadata` writes `root_attachment_id` whenever core's `/edit` creates a child attachment — the new child inherits the parent's `root_attachment_id` if present, otherwise the parent itself is the root. The REST filter just reads that meta key. No walking, no cycle/cap guards, no backfill needed (this code is tied to the unreleased media editor cropper). Renames the surfaced field from `media_details.root_image` to `media_details.original_attachment` to pair naturally with core's existing `media_details.original_image` (a filename) without colliding.
The persisted meta key and its helper function used "root" terminology
that didn't match the public response field (`original_attachment`) or
the user-facing concept ("Restore original"). Renames the meta key
from `root_attachment_id` to `original_attachment_id` and the
maintenance hook from `gutenberg_record_root_attachment_id` to
`gutenberg_record_original_attachment_id` so all three layers — meta
key, hook function, response field — share one vocabulary.
No behavior change. No backfill (the cropper hasn't shipped, so no
attachments carry the old key in the wild).
There was a problem hiding this comment.
Pull request overview
Adds a “Restore original image” workflow to the Media Editor modal so users can revert an edited (child) attachment back to its original/root attachment and then either save without creating a new edit, or crop again to create a new sibling edit.
Changes:
- Expose an
original_attachmentpayload on attachment REST responses (and persistoriginal_attachment_idduring/editcreation). - Add Restore UI/notice + save-branch logic to swap to the original attachment (no
/edit) or post/editagainst the original id (new sibling). - Add supporting styles, a canvas
srcoverride, and update core-data attachment typings; include PHPUnit coverage for the server filter/hook.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| phpunit/experimental/media/original-attachment-test.php | Adds PHPUnit coverage for the REST response field and /edit metadata hook behavior. |
| packages/media-editor/src/style.scss | Includes crop panel styles in the media-editor bundle. |
| packages/media-editor/src/components/media-editor-modal/style.scss | Adjusts sidebar/tab panel layout to allow a sticky footer in the Crop tab. |
| packages/media-editor/src/components/media-editor-modal/index.tsx | Implements restore state, wires Restore UI props, and adds save-path branching for restore-only vs restore+crop. |
| packages/media-editor/src/components/media-editor-crop-panel/style.scss | New styles for a full-height crop panel with sticky footer and notice spacing. |
| packages/media-editor/src/components/media-editor-crop-panel/index.tsx | Adds Restore button + inline Notice messaging driven by modal state. |
| packages/media-editor/src/components/media-editor-canvas/index.tsx | Adds optional src override so the cropper can load a restored URL without mutating the entity record. |
| packages/core-data/src/entity-types/attachment.ts | Extends attachment media_details typing with original_attachment. |
| lib/load.php | Loads the new experimental media original-attachment feature file. |
| lib/experimental/media/original-attachment.php | Implements wp_edited_image_metadata persistence and rest_prepare_attachment response augmentation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $meta = wp_get_attachment_metadata( $post->ID ); | ||
| if ( empty( $meta['original_attachment_id'] ) ) { | ||
| return $response; | ||
| } | ||
|
|
||
| $original_id = (int) $meta['original_attachment_id']; | ||
| if ( $original_id === (int) $post->ID || $original_id <= 0 ) { | ||
| return $response; | ||
| } | ||
|
|
||
| $data['media_details']['original_attachment'] = array( | ||
| 'attachment_id' => $original_id, | ||
| 'source_url' => wp_get_attachment_url( $original_id ), | ||
| ); |
There was a problem hiding this comment.
wp_get_attachment_metadata( $post->ID ) can return false, which makes empty( $meta['original_attachment_id'] ) emit a PHP warning. Also, wp_get_attachment_url( $original_id ) can return false (e.g. if the original attachment no longer exists), but the client code assumes source_url is a string. Consider: (1) early-return unless $meta is an array, (2) verify the original attachment exists and is readable, and (3) only add media_details.original_attachment when the resolved URL is a non-empty string.
Address PR review: - Guard against `wp_get_attachment_metadata` returning false in both the edit-time hook and the REST filter. - Skip emitting `media_details.original_attachment` when the original attachment's URL no longer resolves (deleted file / attachment). - In the Crop panel, only render the Restore button when both `canRestoreOriginal` and `onRestoreOriginal` are present, so a misuse can't produce a click-no-op control.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * @return array Filtered metadata. | ||
| */ | ||
| function gutenberg_record_original_attachment_id( $new_image_meta, $new_attachment_id, $attachment_id ) { | ||
| $parent_meta = wp_get_attachment_metadata( $attachment_id ); |
There was a problem hiding this comment.
So this new key is serialized into wp_postmeta and never gets cleaned up unless the attachment is deleted. Every attachment edited via /edit from will carry this key permanently.
That means we can’t easily unship this. If we change our mind about the the approach, the data we wrote stays in databases that pulled the plugin during this window.
The alternative (original implementation) was to keep going up the parent metadata chain to find that last parent.
There was a problem hiding this comment.
Oh, interesting idea! Apologies if I'm missing something obvious, but what's the main downside of walking up the parent hierarchy client-side to find the original? How many requests are we talking to walk our way up to a parent?
I.e. is it possible to implement this without any backend changes, and if so, what's the main downside? Is it a slightly longer process to open the modal, or is it in the "the modal takes forever and it's extremely painful" category?
There was a problem hiding this comment.
what's the main downside of walking up the parent hierarchy client-side to find the original? How many requests are we talking to walk our way up to a parent?
good questions, thanks
a possibly confected downside - if an image has undergone 1000 edits, then that's 1000 calls to wp_get_attachment_metadata at read time. a single lookup is far cheaper.
is it possible to implement this without any backend changes
Unfortunately not unless we want to walk through a world of pain. We could do the walking using getEntityRecord but that's n uncached HTTP requests.
As for the backend changes, I also looked at how wp_get_original_image_url() and wp_restore_image() work
They deal with the original "by file", not by original id or attachment.
Since we're working in the editor, and the image block relies on a media entity (with id and url), we need an attachment entity in order to swap the block to point at it without first creating a new attachment from it. That way we make edits non-destructive: the original attachment is preserved as a separate post so we can reference it independently.
There was a problem hiding this comment.
One thing I'll add is that this feature is a "nice to have" right now (in my opinion). A workaround might be to provide access to the media library and allow users to "swap" the image while cropping, assuming they can find the original that way 😅
There was a problem hiding this comment.
Oh it's an interesting problem for sure. The main thing on my mind is considering what we'd like the ideal end state to be. One way of looking at it is that there shouldn't ever be more than n === 1 edit if we can store a crop / edit history somehow alongside the cropped image. We'd implement it something like:
- When the media modal opens, if we're currently on an image that has been cropped, load up the linked parent media item as the "real" version of it, and inject into state the saved list of edits and crops
- Any new crop that's performed has its parent set to the original media item
That way we'd avoid the "crop of a crop" problem. I don't think we'll get there in a single PR, but I wondered if the above is our end goal, then it might inform what we need from PHP / API changes.
But the above assumes a different kind of problem: settling on the cropping schema and values so that any crop data stored alongside an edit of an image can't go stale.
And it also assumes that an ideal end state is that every crop is treated as a sibling with a common single parent, instead of a hierarchy of crops. And I can imagine there are arguments for either approach.
In any case, I don't want to be blocking with these comments, just thoughts on how and why we're doing things!
There was a problem hiding this comment.
When the media modal opens, if we're currently on an image that has been cropped, load up the linked parent media item as the "real" version of it, and inject into state the saved list of edits and crops
I like that! It would get tricky once folks introduce things like color filtering or other canvas ops maybe
There was a problem hiding this comment.
It would get tricky once folks introduce things like color filtering or other canvas ops maybe
Oh, indeed. Ideally any filters, etc, could also be applied as non-destructive edits, but again that sort of proposes that we settle on an API for what an edit should be. But in principle I love the idea of something like Photoshop adjustment layers where you can go back in and tweak to your heart's content after the fact.
| return $response; | ||
| } | ||
|
|
||
| $data['media_details']['original_attachment'] = array( |
There was a problem hiding this comment.
If any of this goes to core, we'll have to think about:
- Schema test (the new field appears in OPTIONS).
- REST integration test (single + collection responses).
- Backwards-compat test (an attachment without original_attachment_id in meta still serializes cleanly).
- Permission test (does the field disclose anything? appropriate for the context?).
- The delete_attachment cleanup hook for metadata.
Limits disclosure of attachment lineage to authenticated editors. Anonymous and view-context reads no longer see `media_details.original_attachment`. The Media Editor modal already fetches via core-data's postType resolver, which defaults to `?context=edit`, so the client-side button is unaffected. Adds a view-context test alongside the existing edit-context tests.
Hooks `delete_attachment` to remove `original_attachment_id` from any descendants of the deleted attachment. Without this, descendants carried a dangling pointer that could resurrect if the deleted id was later reused. The REST filter already declined to emit `media_details.original_attachment` when the URL didn't resolve, so the user-visible behavior is unchanged. This commit just keeps the stored meta accurate. Two new PHPUnit tests cover the happy path and the unrelated-descendants case so cleanup doesn't over-reach into other lineages.
| 'meta_query' => array( | ||
| array( | ||
| 'key' => '_wp_attachment_metadata', | ||
| 'value' => sprintf( 'i:%d;', $attachment_id ), |
There was a problem hiding this comment.
One small note: the LIKE-on-serialized approach scales linearly with the attachment library size. For a site with 100k attachments, deleting an attachment now triggers a full scan of _wp_attachment_metadata rows. In practice still fast (it’s an indexed text LIKE), but if this graduates to core, the proper solution would be promoting original_attachment_id to its own postmeta key so it can be queried directly without LIKE.
Migrates the lineage pointer from a key inside the serialized \`_wp_attachment_metadata\` blob to its own postmeta key, \`_wp_attachment_original_id\`. - Reads use \`get_post_meta\` — no \`is_array\` guard, no serialization to navigate. - Writes use \`update_post_meta\` instead of mutating the metadata array we receive on \`wp_edited_image_metadata\`. - Cleanup on \`delete_attachment\` is now a single indexed query via \`delete_metadata\` against (meta_key, meta_value), instead of a serialized-blob LIKE scan. The leading underscore marks the key as internal so it doesn't get exposed by default postmeta REST surfacing. The public REST field (\`media_details.original_attachment\`) is unchanged. No backfill needed: the cropper hasn't shipped, so no attachments carry the old key.
|
Flaky tests detected in 28aca6c. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/25150089554
|
|
Closed in favor of #81805 |
Summary
Part of:
Adds a Restore original image action to the Media Editor modal's Crop sidebar tab. Re-introduces the equivalent of WP core classic editor's "Restore original image" for the new Gutenberg modal, adapted to the
/edit-creates-child-attachment model.Kapture.2026-04-29.at.16.07.40.mp4
When the currently-edited attachment was itself created via
/wp/v2/media/{id}/edit, the button loads the lineage's original attachment into the cropper as a dirty preview./editcall. No new attachment created./editis posted to the original's id, creating a new sibling child off the original.Approach
A new
wp_edited_image_metadatahook (lib/experimental/media/original-attachment.php) recordsoriginal_attachment_idon each edited attachment at write time — the new child inherits its parent'soriginal_attachment_idif present, otherwise the parent itself is the original. A REST filter onrest_prepare_attachmentreads that meta key and addsmedia_details.original_attachment = { attachment_id, source_url }to the response. Single meta lookup per response, no walking, no client-side fetches.The button is hidden entirely when the attachment has no
original_attachmentfield. After clicking it, the cropper's existingsetImageresets the dirty snapshot, so subsequent crop edits produce modifiers built against the original's natural dimensions.This is tied to the (unreleased) media editor cropper, so no backfill is needed for attachments edited before this lands.
Test plan
Notes