Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions .changeset/bright-otters-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@emdash-cms/admin": minor
---

Adds in-context Media Library asset editing to admin image pickers, image fields, and rich text
images and galleries. Editors can update asset metadata and focal points, create and select cropped
copies, or replace original image data while staying in the content editor. Gallery images also
support keyboard reordering.
23 changes: 20 additions & 3 deletions docs/src/content/docs/guides/media-library.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,21 @@ references remain unchanged.
For fields configured as image or file types, select the field action to open the same media
picker. The field's MIME type rules limit the sources and files you can choose.

### Edit a selected image asset

Local image fields, rich text images, and gallery images provide three actions:

- **Replace** changes the image used in the current field, block, or gallery position.
- **Edit asset** opens Media Details for the selected Media Library item. You can update its alt
text, caption, focal point, or crop while staying in the content editor.
- **Remove** clears the current content reference. The Media Library item remains available.

**Create cropped copy** selects the new copy for the current usage. Rich text and gallery images
keep their per-use alt text, caption, layout, and position. **Replace original** keeps the same media
reference and changes the image anywhere that asset is used.

External-provider images and file fields provide **Replace** and **Remove**, but not **Edit asset**.

## Replacing an image

Use **Replace image** to update the file behind an existing local media item. Authors can replace
Expand Down Expand Up @@ -270,14 +285,16 @@ the replacement. Replacing the file clears its focal point.
A focal point keeps the important part of a local image visible when a card, gallery, or other
layout crops it to fill a fixed shape.

1. Open **Media**, then select an image from the local library.
1. Open **Media** and select an image from the local library, or select **Edit asset** for a local
image in the content editor.
2. Select **Edit image**, then **Focal point**.
3. Click or drag the marker onto the important part of the image. You can also use the Arrow keys.
4. Check the square, landscape, and portrait previews, then select **Save**.

Select **Reset** to remove a custom focal point. The saved point is copied when you select
the image for a content field or gallery. Content that already uses the image keeps its stored point
until you select the image again.
the image for a content field or gallery. Other content already using the image keeps its stored point
until you select the image again. When you edit an asset from a content field or gallery, that current
usage refreshes with the saved focal point.

## Cropping an image

Expand Down
93 changes: 93 additions & 0 deletions e2e/tests/media-library.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,99 @@ test.describe("Media Library", () => {
).toBe(true);
});

test("edits a selected content image and uses a cropped copy without leaving the editor", async ({
admin,
page,
serverInfo,
}) => {
test.setTimeout(90_000);
const marker = Date.now();
const filename = `editor-asset-${marker}.png`;
const duplicateFilename = `editor-asset-${marker}-square.png`;
await admin.goToMedia();
await admin.waitForLoading();
await uploadCropTestImage(page, filename);
const original = await findMediaByFilename(serverInfo, filename);

await admin.goto("/content/posts/new");
await admin.waitForShell();
await admin.waitForLoading();
const editorUrl = page.url();
await page.getByRole("button", { name: "Select image" }).click();
const picker = page.getByRole("dialog", { name: "Select Featured Image" });
const workspaceElement = await picker.elementHandle();
expect(workspaceElement).not.toBeNull();
const workspaceWidth = await picker.evaluate((dialog) => dialog.clientWidth);
await picker.getByRole("searchbox", { name: "Search media" }).fill(filename);
await picker.getByRole("button", { name: filename, exact: true }).click();
await picker.getByRole("button", { name: "Edit asset" }).click();
const pickerDetails = page.getByRole("dialog", { name: "Media details" });
await expect(pickerDetails).toBeVisible();
expect(await pickerDetails.evaluate((dialog) => dialog.clientWidth)).toBe(workspaceWidth);
expect(
await pickerDetails.evaluate(
(dialog, originalDialog) => dialog === originalDialog,
workspaceElement,
),
).toBe(true);
await expect(page.getByRole("dialog")).toHaveCount(1);
await expect(pickerDetails.getByRole("searchbox", { name: "Search media" })).toHaveCount(0);
await pickerDetails.getByRole("button", { name: "Back" }).click();
await expect(picker).toBeVisible();
await expect(picker.getByRole("button", { name: "Edit asset" })).toBeFocused();
expect(
await picker.evaluate(
(dialog, originalDialog) => dialog === originalDialog,
workspaceElement,
),
).toBe(true);
await expect(picker.getByRole("button", { name: filename, exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await picker.getByRole("button", { name: "Select", exact: true }).click();
const featuredImageField = page.locator("#field-featured_image");
await expect(featuredImageField.getByText(filename, { exact: true })).toBeVisible();

await featuredImageField.getByRole("button", { name: "Edit asset" }).click();
const details = page.getByRole("dialog", { name: "Media details" });
await expect(details).toBeVisible();
await expect(picker).not.toBeVisible();
await expect(details.getByRole("tab", { name: "Used in" })).toHaveCount(0);
await expect(details.getByRole("button", { name: "Delete" })).toHaveCount(0);
expect(page.url()).toBe(editorUrl);

await details.getByRole("tab", { name: "Edit image" }).click();
const aspectRatio = details.getByRole("combobox", { name: "Aspect ratio" });
await aspectRatio.click();
await page.getByRole("option", { name: "Square (1:1)" }).click();
const duplicateResponse = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname.endsWith("/confirm") &&
response.status() === 200,
);
await details.getByRole("button", { name: "Create cropped copy" }).click();
await duplicateResponse;

await expect(details).not.toBeVisible();
await expect(featuredImageField.getByText(duplicateFilename, { exact: true })).toBeVisible();
expect(page.url()).toBe(editorUrl);
const duplicate = await findMediaByFilename(serverInfo, duplicateFilename);
expect(duplicate.id).not.toBe(original.id);

await page.setViewportSize({ width: 320, height: 800 });
await expect(featuredImageField.getByRole("button", { name: "Replace" })).toBeVisible();
await expect(featuredImageField.getByRole("button", { name: "Edit asset" })).toBeVisible();
await expect(featuredImageField.getByRole("button", { name: "Remove image" })).toBeVisible();
expect(
await featuredImageField.evaluate((element) => element.scrollWidth <= element.clientWidth),
).toBe(true);
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
});

test.describe("List View", () => {
test("shows file details in list view", async ({ admin, page }) => {
// Upload a file first so there's something to show
Expand Down
15 changes: 8 additions & 7 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1858,19 +1858,19 @@ function FileFieldRenderer({
</p>
)}
</div>
<div className="flex gap-1">
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
{t`Change`}
{t`Replace`}
</Button>
<Button
type="button"
shape="square"
variant="destructive"
className="h-8 w-8"
size="sm"
variant="secondary-destructive"
icon={<X aria-hidden="true" />}
onClick={handleRemove}
aria-label={t`Remove ${label}`}
>
<X className="h-4 w-4" />
{t`Remove`}
</Button>
</div>
</div>
Expand All @@ -1896,7 +1896,8 @@ function FileFieldRenderer({
fieldId={fieldId}
hideUrlInput
mediaKind="file"
title={t`Select ${label}`}
title={normalized ? t`Replace ${label}` : t`Select ${label}`}
confirmLabel={normalized ? t`Replace` : undefined}
/>
{required && !normalized && (
<p className="-mt-1 text-sm text-kumo-danger">{t`This field is required`}</p>
Expand Down
Loading
Loading