From 8987d7a9d028f5e28e636c11b63688115abdf52f Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:30:42 +0100 Subject: [PATCH 01/20] Add in-context media asset editing flow --- .../admin/src/components/MediaDetailPanel.tsx | 12 +- .../admin/src/components/MediaPickerModal.tsx | 202 +++++++++++++++++- packages/admin/src/lib/api/media.ts | 10 +- .../components/MediaDetailPanel.test.tsx | 20 +- .../components/MediaPickerModal.test.tsx | 195 +++++++++++++++++ 5 files changed, 425 insertions(+), 14 deletions(-) diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx index 7055eda37b..948adc4df0 100644 --- a/packages/admin/src/components/MediaDetailPanel.tsx +++ b/packages/admin/src/components/MediaDetailPanel.tsx @@ -138,6 +138,7 @@ interface MediaLocationOption { export interface MediaDetailPanelProps { open: boolean; item: MediaItem; + context?: "library" | "content"; providerName?: string; canDelete?: boolean; canMoveLocation?: boolean; @@ -149,7 +150,7 @@ export interface MediaDetailPanelProps { onClosed?: () => void; onUpdated?: () => void; onItemRefreshed?: (item: LocalMediaItem) => void; - onCroppedCopyCreated?: () => void; + onCroppedCopyCreated?: (item: LocalMediaItem) => void; onDeleted?: () => void; } @@ -159,6 +160,7 @@ export interface MediaDetailPanelProps { export function MediaDetailPanel({ open, item, + context = "library", providerName, canDelete: canDeleteProp, canMoveLocation: canMoveLocationProp, @@ -199,10 +201,10 @@ export function MediaDetailPanel({ // Present when the item streams rather than resolving to a playable file. const playback = metaPlayback(item.meta); const canEditMetadata = !isProviderAsset && isImage; - const hasUsage = !isProviderAsset; - const canDelete = !isProviderAsset || Boolean(canDeleteProp); + const hasUsage = context === "library" && !isProviderAsset; + const canDelete = context === "library" && (!isProviderAsset || Boolean(canDeleteProp)); const localItem = isLocalMediaItem(item) ? item : null; - const canMoveLocation = Boolean(localItem && canMoveLocationProp); + const canMoveLocation = context === "library" && Boolean(localItem && canMoveLocationProp); const canReplaceOriginal = canReplaceOriginalProp ?? canCropOriginal; const cropMime = normalizeCropMime(item.mimeType); const canShowCrop = Boolean( @@ -665,7 +667,7 @@ export function MediaDetailPanel({ onSuccess: ({ action, item: croppedItem }) => { void queryClient.invalidateQueries({ queryKey: ["media"] }); if (action === "duplicate") { - onCroppedCopyCreated?.(); + onCroppedCopyCreated?.(croppedItem); onUpdated?.(); setCropAspectMode("original"); setCropSelection(undefined); diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 0567a635c5..4737c4db25 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -20,6 +20,7 @@ import { List, LinkSimple, Paperclip, + PencilSimple, SquaresFour, Upload, X, @@ -45,8 +46,10 @@ import { uploadToProvider, updateMedia, type MediaItem, + type LocalMediaItem, type MediaProviderItem, } from "../lib/api.js"; +import { useCurrentUser } from "../lib/api/current-user.js"; import { useDebouncedValue } from "../lib/hooks.js"; import { canonicalMediaProviderId, providerItemToMediaItem } from "../lib/media-utils.js"; import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js"; @@ -58,10 +61,23 @@ import { mimeForMediaTypeFilter, } from "./media/MediaBrowserItems.js"; import { useMediaUploadQueue } from "./media/useMediaUploadQueue.js"; +import { MediaDetailPanel } from "./MediaDetailPanel.js"; import { TableToolbar, TableToolbarSearch } from "./TableToolbar.js"; const URL_SOURCE = "__url"; const PICKER_PAGE_SIZE = 12; +const ROLE_CONTRIBUTOR = 20; +const ROLE_AUTHOR = 30; +const ROLE_EDITOR = 40; +const EMPTY_DETAIL_ITEM: MediaItem = { + id: "", + filename: "", + mimeType: "application/octet-stream", + url: "", + size: 0, + createdAt: "", + provider: "picker-placeholder", +}; interface SelectedMedia { key: string; @@ -198,6 +214,7 @@ export function MediaPickerModal({ }: MediaPickerModalProps) { const { t } = useLingui(); const queryClient = useQueryClient(); + const currentUser = useCurrentUser().data; const isFileKind = mediaKind === "file"; const filters = React.useMemo(() => { if (mimeTypeFilters !== undefined) { @@ -229,11 +246,21 @@ export function MediaPickerModal({ const [providerDimensions, setProviderDimensions] = React.useState< Record >({}); + const [browseOpen, setBrowseOpen] = React.useState(open); + const [assetOpen, setAssetOpen] = React.useState(false); + const [assetItem, setAssetItem] = React.useState(EMPTY_DETAIL_ITEM); const fileInputRef = React.useRef(null); + const editAssetButtonRef = React.useRef(null); const updatedDimensionsRef = React.useRef(new Set()); const urlProbeIdRef = React.useRef(0); const uploadTargetsRef = React.useRef(new Map()); const selectionOrderEditedRef = React.useRef(false); + const parentOpenRef = React.useRef(open); + const flowGenerationRef = React.useRef(0); + const transitionRef = React.useRef< + { kind: "open-asset" | "return-browse"; generation: number } | undefined + >(undefined); + const restoreEditFocusRef = React.useRef(false); const invalidateUrlProbe = React.useCallback(() => { urlProbeIdRef.current += 1; setIsProbing(false); @@ -260,6 +287,24 @@ export function MediaPickerModal({ ); const uploadQueue = useMediaUploadQueue({ upload: uploadFile }); + React.useEffect(() => { + const wasOpen = parentOpenRef.current; + parentOpenRef.current = open; + if (!open) { + flowGenerationRef.current += 1; + transitionRef.current = undefined; + restoreEditFocusRef.current = false; + setBrowseOpen(false); + setAssetOpen(false); + return; + } + if (wasOpen) return; + flowGenerationRef.current += 1; + transitionRef.current = undefined; + setAssetOpen(false); + setBrowseOpen(true); + }, [open]); + React.useEffect(() => { if (!open) return; setActiveSource("local"); @@ -637,6 +682,110 @@ export function MediaPickerModal({ setSelectedItems((current) => current.filter((item) => item.key !== selected.key)); setLiveMessage(t`Removed ${selected.item.filename} from selection.`); }; + const editableSelection = React.useMemo(() => { + if (selectedItems.length !== 1 || !currentUser) return null; + const selected = selectedItems[0]!; + if (selected.providerId !== "local" || !selected.item.mimeType.startsWith("image/")) { + return null; + } + const item = selected.item as LocalMediaItem; + const canEdit = + currentUser.role >= ROLE_EDITOR || + (currentUser.role >= ROLE_AUTHOR && item.authorId === currentUser.id); + return canEdit ? item : null; + }, [currentUser, selectedItems]); + const replaceSelectedLocalItem = React.useCallback((item: LocalMediaItem) => { + setSelectedItems((current) => + current.map((selected) => + selected.providerId === "local" && selected.item.id === item.id + ? { ...selected, item } + : selected, + ), + ); + setPinnedItems((current) => + current.map((selected) => + selected.providerId === "local" && selected.item.id === item.id + ? { ...selected, item } + : selected, + ), + ); + }, []); + const cacheLocalItem = React.useCallback( + (item: LocalMediaItem) => { + queryClient.setQueryData(["media", item.id], item); + queryClient.setQueriesData<{ items: LocalMediaItem[] }>({ queryKey: ["media"] }, (current) => + current && Array.isArray(current.items) + ? { + ...current, + items: current.items.map((entry) => (entry.id === item.id ? item : entry)), + } + : current, + ); + }, + [queryClient], + ); + const handleAssetRefreshed = React.useCallback( + (item: LocalMediaItem) => { + setAssetItem(item); + replaceSelectedLocalItem(item); + cacheLocalItem(item); + }, + [cacheLocalItem, replaceSelectedLocalItem], + ); + const handleCroppedCopyCreated = React.useCallback( + (item: LocalMediaItem) => { + const selected: SelectedMedia = { + key: selectionKey("local", item), + providerId: "local", + item, + }; + setAssetItem(item); + setPinnedItems((current) => appendUniqueSelections(current, [selected])); + setSelectedItems((current) => { + if (!multiple) return [selected]; + const sourceId = assetItem.id; + const sourceIndex = current.findIndex( + (entry) => entry.providerId === "local" && entry.item.id === sourceId, + ); + if (sourceIndex === -1) return appendUniqueSelections(current, [selected]); + const next = [...current]; + next[sourceIndex] = selected; + return next; + }); + cacheLocalItem(item); + void queryClient.invalidateQueries({ queryKey: ["media"] }); + }, + [assetItem.id, cacheLocalItem, multiple, queryClient], + ); + const openAssetEditor = () => { + if (!editableSelection || uploadQueue.hasUnfinished) return; + setAssetItem(editableSelection); + transitionRef.current = { + kind: "open-asset", + generation: flowGenerationRef.current, + }; + setBrowseOpen(false); + }; + const closeAssetEditor = () => { + transitionRef.current = { + kind: "return-browse", + generation: flowGenerationRef.current, + }; + setAssetOpen(false); + }; + const handleAssetClosed = () => { + const transition = transitionRef.current; + if ( + !parentOpenRef.current || + transition?.kind !== "return-browse" || + transition.generation !== flowGenerationRef.current + ) { + return; + } + transitionRef.current = undefined; + restoreEditFocusRef.current = true; + setBrowseOpen(true); + }; const confirmText = confirmLabel ?? (multiple @@ -738,11 +887,29 @@ export function MediaPickerModal({ ) : null; - return ( + const browseDialog = ( { - if (!nextOpen) handleClose(); + if (!nextOpen && transitionRef.current?.kind !== "open-asset") handleClose(); + }} + onOpenChangeComplete={(nextOpen) => { + if (nextOpen) { + if (!restoreEditFocusRef.current) return; + restoreEditFocusRef.current = false; + editAssetButtonRef.current?.focus({ preventScroll: true }); + return; + } + const transition = transitionRef.current; + if ( + !parentOpenRef.current || + transition?.kind !== "open-asset" || + transition.generation !== flowGenerationRef.current + ) { + return; + } + transitionRef.current = undefined; + setAssetOpen(true); }} >
+ {editableSelection && ( + + )} @@ -1351,6 +1529,24 @@ export function MediaPickerModal({
); + + return ( + <> + {browseDialog} + = ROLE_CONTRIBUTOR} + restoreFocusTargetRef={editAssetButtonRef} + onClose={closeAssetEditor} + onClosed={handleAssetClosed} + onItemRefreshed={handleAssetRefreshed} + onCroppedCopyCreated={handleCroppedCopyCreated} + /> + + ); } export default MediaPickerModal; diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index fbd4cc7716..f16ea89f82 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -357,14 +357,14 @@ async function confirmUpload( mediaId: string, metadata?: { width?: number; height?: number; size?: number }, options?: MediaUploadOptions, -): Promise { +): Promise { const response = await apiFetch(`${API_BASE}/media/${mediaId}/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(metadata || {}), signal: options?.signal, }); - const data = await parseApiResponse<{ item: MediaItem }>( + const data = await parseApiResponse<{ item: LocalMediaItem }>( response, i18n._(msg`Failed to confirm upload`), ); @@ -438,7 +438,7 @@ export async function getImageDimensions( /** * Upload media file via direct upload (legacy/local storage) */ -async function uploadMediaDirect(file: File, opts?: UploadMediaOptions): Promise { +async function uploadMediaDirect(file: File, opts?: UploadMediaOptions): Promise { // Get image dimensions before upload const dimensions = await getImageDimensions(file, opts); @@ -458,7 +458,7 @@ async function uploadMediaDirect(file: File, opts?: UploadMediaOptions): Promise body: formData, signal: opts?.signal, }); - const data = await parseApiResponse<{ item: MediaItem }>( + const data = await parseApiResponse<{ item: LocalMediaItem }>( response, i18n._(msg`Failed to upload media`), ); @@ -471,7 +471,7 @@ async function uploadMediaDirect(file: File, opts?: UploadMediaOptions): Promise * Tries signed URL upload first (for S3/R2 storage), falls back to direct upload * (for local storage) if signed URLs are not supported. */ -export async function uploadMedia(file: File, opts?: UploadMediaOptions): Promise { +export async function uploadMedia(file: File, opts?: UploadMediaOptions): Promise { opts?.signal?.throwIfAborted(); // Try to get a signed upload URL const uploadInfo = await getUploadUrl(file, opts); diff --git a/packages/admin/tests/components/MediaDetailPanel.test.tsx b/packages/admin/tests/components/MediaDetailPanel.test.tsx index e5f6a25986..1ca8e1c0df 100644 --- a/packages/admin/tests/components/MediaDetailPanel.test.tsx +++ b/packages/admin/tests/components/MediaDetailPanel.test.tsx @@ -950,13 +950,31 @@ describe("MediaDetailPanel", () => { expect.objectContaining({ name: "photo-80x80.jpg", type: "image/jpeg" }), { deduplicate: false, ensureUniqueFilename: true, folderId: "folder-1" }, ); - expect(onCroppedCopyCreated).toHaveBeenCalledTimes(1); + expect(onCroppedCopyCreated).toHaveBeenCalledWith(duplicate); }); expect(onClose).toHaveBeenCalledTimes(1); expect(replaceMediaImage).not.toHaveBeenCalled(); expect(onItemRefreshed).not.toHaveBeenCalled(); }); + it("keeps asset-management controls out of content workflows", async () => { + const screen = await renderPanel({ + item: makeLocalItem(), + context: "content", + canDelete: true, + canMoveLocation: true, + canCropOriginal: true, + canDuplicateCrop: true, + }); + + await expect.element(screen.getByRole("tab", { name: "Details" })).toBeVisible(); + await expect.element(screen.getByRole("tab", { name: "Edit image" })).toBeVisible(); + expect(screen.getByRole("tab", { name: "Used in" }).query()).toBeNull(); + expect(screen.getByRole("button", { name: "Delete" }).query()).toBeNull(); + expect(screen.getByRole("combobox", { name: "Location" }).query()).toBeNull(); + await expect.element(screen.getByText("Product photos")).toBeVisible(); + }); + it("confirms and replaces the original while keeping the dialog open", async () => { const refreshed = makeLocalItem({ url: INTERNAL_TEST_IMAGE_URL, diff --git a/packages/admin/tests/components/MediaPickerModal.test.tsx b/packages/admin/tests/components/MediaPickerModal.test.tsx index e16b634833..75f14bca94 100644 --- a/packages/admin/tests/components/MediaPickerModal.test.tsx +++ b/packages/admin/tests/components/MediaPickerModal.test.tsx @@ -13,6 +13,9 @@ import { render } from "../utils/render.tsx"; // matches the hidden file `` and trips // playwright's strict-mode "resolved to N elements" guard. const UPLOAD_BUTTON_REGEX = /^Upload files$/; +const pickerTestState = vi.hoisted(() => ({ + currentUser: { id: "user-1", role: 40 }, +})); vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); @@ -30,6 +33,10 @@ vi.mock("../../src/lib/api", async () => { height: 600, focalX: 0.2, focalY: 0.8, + storageKey: "photo.jpg", + status: "ready", + authorId: "user-1", + folderId: null, createdAt: "2024-01-01", }, { @@ -57,6 +64,90 @@ vi.mock("../../src/lib/api", async () => { }; }); +vi.mock("../../src/lib/api/current-user.js", () => ({ + useCurrentUser: () => ({ data: pickerTestState.currentUser }), +})); + +vi.mock("../../src/components/MediaDetailPanel", () => ({ + MediaDetailPanel: ({ + open, + item, + context, + onClose, + onClosed, + onItemRefreshed, + onCroppedCopyCreated, + }: { + open: boolean; + item: { filename: string }; + context?: string; + onClose: () => void; + onClosed?: () => void; + onItemRefreshed?: (item: unknown) => void; + onCroppedCopyCreated?: (item: unknown) => void; + }) => + open ? ( +
+ {item.filename} + + + +
+ ) : null, +})); + function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -85,6 +176,7 @@ async function openUrlSource(screen: Awaited>) { describe("MediaPickerModal", () => { beforeEach(() => { vi.clearAllMocks(); + pickerTestState.currentUser = { id: "user-1", role: 40 }; }); afterEach(() => vi.unstubAllGlobals()); @@ -162,6 +254,109 @@ describe("MediaPickerModal", () => { expect.objectContaining({ id: "m1", filename: "photo.jpg" }), ); }); + + it("edits a selected local image and returns to the preserved picker state", async () => { + const screen = await renderModal(); + const item = screen.getByRole("button", { name: "photo.jpg" }); + await expect.element(item).toBeVisible(); + item.element().click(); + + const editAsset = screen.getByRole("button", { name: "Edit asset" }); + await expect.element(editAsset).toBeEnabled(); + editAsset.element().click(); + + const details = screen.getByRole("dialog", { name: "Asset details" }); + await expect.element(details).toBeVisible(); + expect(details.element()).toHaveAttribute("data-context", "content"); + expect(screen.getByRole("dialog", { name: "Select image" }).query()).toBeNull(); + + screen.getByRole("button", { name: "Close asset details" }).element().click(); + + const returnedItem = screen.getByRole("button", { name: "photo.jpg" }); + await expect.element(returnedItem).toBeVisible(); + await expect.element(returnedItem).toHaveAttribute("aria-pressed", "true"); + await expect.element(screen.getByRole("button", { name: "Edit asset" })).toHaveFocus(); + }); + + it("does not reopen either dialog when the parent closes during the handoff", async () => { + const onOpenChange = vi.fn(); + const screen = await renderModal({ onOpenChange }); + const item = screen.getByRole("button", { name: "photo.jpg" }); + await expect.element(item).toBeVisible(); + item.element().click(); + const editAsset = screen.getByRole("button", { name: "Edit asset" }); + await expect.element(editAsset).toBeEnabled(); + editAsset.element().click(); + + await screen.rerender( + + + , + ); + + await new Promise((resolve) => window.setTimeout(resolve, 250)); + expect(screen.getByRole("dialog", { name: "Asset details" }).query()).toBeNull(); + expect(screen.getByRole("dialog", { name: "Select image" }).query()).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("returns from asset editing with a cropped copy selected", async () => { + const onSelect = vi.fn(); + const screen = await renderModal({ onSelect }); + const item = screen.getByRole("button", { name: "photo.jpg" }); + await expect.element(item).toBeVisible(); + item.element().click(); + const editAsset = screen.getByRole("button", { name: "Edit asset" }); + await expect.element(editAsset).toBeEnabled(); + editAsset.element().click(); + await expect + .element(screen.getByRole("button", { name: "Create cropped copy" })) + .toBeVisible(); + + screen.getByRole("button", { name: "Create cropped copy" }).element().click(); + + const copy = screen.getByRole("button", { name: "photo-cropped.jpg" }); + await expect.element(copy).toBeVisible(); + await expect.element(copy).toHaveAttribute("aria-pressed", "true"); + screen.getByRole("button", { name: "Select" }).element().click(); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "media-copy", filename: "photo-cropped.jpg" }), + ); + }); + + it("confirms the refreshed local item after editing its asset metadata", async () => { + const onSelect = vi.fn(); + const screen = await renderModal({ onSelect }); + const item = screen.getByRole("button", { name: "photo.jpg" }); + await expect.element(item).toBeVisible(); + item.element().click(); + const editAsset = screen.getByRole("button", { name: "Edit asset" }); + await expect.element(editAsset).toBeEnabled(); + editAsset.element().click(); + + await expect + .element(screen.getByRole("button", { name: "Save updated asset" })) + .toBeVisible(); + screen.getByRole("button", { name: "Save updated asset" }).element().click(); + screen.getByRole("button", { name: "Close asset details" }).element().click(); + + await expect.element(screen.getByRole("button", { name: "Select" })).toBeEnabled(); + screen.getByRole("button", { name: "Select" }).element().click(); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "m1", width: 320, height: 240, focalX: 0.5, focalY: 0.4 }), + ); + }); + + it("does not offer asset editing to an author who does not own the image", async () => { + pickerTestState.currentUser = { id: "other-user", role: 30 }; + const screen = await renderModal(); + const item = screen.getByRole("button", { name: "photo.jpg" }); + await expect.element(item).toBeVisible(); + item.element().click(); + + await expect.element(item).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Edit asset" }).query()).toBeNull(); + }); }); describe("URL input", () => { From 6799807571ed4618ca9390b9ec43bed531c3306a Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:04:19 +0100 Subject: [PATCH 02/20] Add editor asset actions and refresh snapshots --- .../admin/src/components/ContentEditor.tsx | 12 +- .../src/components/ImageFieldRenderer.tsx | 230 ++++++++------ .../components/editor/ImageDetailPanel.tsx | 192 +++++++---- .../components/media/useMediaAssetEditor.tsx | 111 +++++++ .../tests/components/ContentEditor.test.tsx | 20 +- .../components/ImageDetailPanel.test.tsx | 300 +++++++++++++++++- .../components/ImageFieldRenderer.test.tsx | 206 +++++++++++- 7 files changed, 894 insertions(+), 177 deletions(-) create mode 100644 packages/admin/src/components/media/useMediaAssetEditor.tsx diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 36e4eb2271..014628938c 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1858,19 +1858,19 @@ function FileFieldRenderer({

)} -
+
diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx index 912d9e02a8..1beaed4dec 100644 --- a/packages/admin/src/components/ImageFieldRenderer.tsx +++ b/packages/admin/src/components/ImageFieldRenderer.tsx @@ -10,7 +10,14 @@ import { Button, Label, LayerCard, Text } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; -import { Image as ImageIcon, ImageBroken, ImageSquare, Moon, X } from "@phosphor-icons/react"; +import { + Image as ImageIcon, + ImageBroken, + ImageSquare, + Moon, + PencilSimple, + X, +} from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; @@ -22,6 +29,7 @@ import { metaString, } from "../lib/media-utils.js"; import { FieldHelpLabel } from "./FieldHelpLabel.js"; +import { useMediaAssetEditor } from "./media/useMediaAssetEditor.js"; import { MediaPickerModal } from "./MediaPickerModal"; /** @@ -68,6 +76,28 @@ function mediaDisplayUrl(value: ImageFieldValue | string | undefined): string | return undefined; } +function mediaItemToImageFieldValue(item: MediaItem): ImageFieldValue { + const provider = canonicalMediaProviderId(item.provider); + const isLocalProvider = provider === "local"; + const isDirectUrl = provider === "external"; + return { + id: item.id, + provider, + src: isDirectUrl ? item.url : undefined, + previewUrl: !isLocalProvider && !isDirectUrl ? item.url : undefined, + alt: item.alt || "", + width: item.width, + height: item.height, + focalX: item.focalX ?? undefined, + focalY: item.focalY ?? undefined, + filename: item.filename, + mimeType: item.mimeType, + blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), + dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), + meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta, + }; +} + export interface ImageFieldRendererProps { id?: string; label: string; @@ -99,6 +129,9 @@ export function ImageFieldRenderer({ const [pickerTarget, setPickerTarget] = React.useState<"image" | "darkVariant">("image"); const [imageBroken, setImageBroken] = React.useState(false); const [darkImageBroken, setDarkImageBroken] = React.useState(false); + const [editedContentHashes, setEditedContentHashes] = React.useState< + Record + >({}); // A legacy string URL needs object form to carry a dark variant. The runtime // resolves the URL in `src` on save, so the provider linkage survives. const objectValue: ImageFieldValue | undefined = @@ -108,6 +141,19 @@ export function ImageFieldRenderer({ ? { id: "", src: value } : undefined; const darkValue = objectValue?.darkVariant; + const handleAssetItemChanged = React.useCallback( + (item: MediaItem) => { + const selected = mediaItemToImageFieldValue(item); + setEditedContentHashes((current) => ({ ...current, [item.id]: item.contentHash })); + if (pickerTarget === "darkVariant") { + if (objectValue) onChange({ ...objectValue, darkVariant: selected }); + return; + } + onChange(darkValue ? { ...selected, darkVariant: darkValue } : selected); + }, + [darkValue, objectValue, onChange, pickerTarget], + ); + const assetEditor = useMediaAssetEditor(handleAssetItemChanged); const currentMediaId = variant === "featured" && objectValue?.id && @@ -131,12 +177,20 @@ export function ImageFieldRenderer({ enabled: currentDarkMediaId !== null, }); const storedDisplayUrl = mediaDisplayUrl(value); + const primaryContentHash = + objectValue?.id && Object.hasOwn(editedContentHashes, objectValue.id) + ? editedContentHashes[objectValue.id] + : currentMedia?.contentHash; const displayUrl = storedDisplayUrl - ? getMediaPreviewUrl(storedDisplayUrl, currentMedia?.contentHash) + ? getMediaPreviewUrl(storedDisplayUrl, primaryContentHash) : undefined; const storedDarkDisplayUrl = mediaDisplayUrl(darkValue); + const darkContentHash = + darkValue?.id && Object.hasOwn(editedContentHashes, darkValue.id) + ? editedContentHashes[darkValue.id] + : currentDarkMedia?.contentHash; const darkDisplayUrl = storedDarkDisplayUrl - ? getMediaPreviewUrl(storedDarkDisplayUrl, currentDarkMedia?.contentHash) + ? getMediaPreviewUrl(storedDarkDisplayUrl, darkContentHash) : undefined; React.useEffect(() => { @@ -153,30 +207,7 @@ export function ImageFieldRenderer({ }; const handleSelect = (item: MediaItem) => { - const provider = canonicalMediaProviderId(item.provider); - const isLocalProvider = provider === "local"; - const isDirectUrl = provider === "external"; - - const selected: ImageFieldValue = { - id: item.id, - provider, - // Local media derives its URL from storageKey. Direct URLs persist src, - // while external providers cache a preview URL for the admin. - src: isDirectUrl ? item.url : undefined, - previewUrl: !isLocalProvider && !isDirectUrl ? item.url : undefined, - alt: item.alt || "", - width: item.width, - height: item.height, - focalX: item.focalX ?? undefined, - focalY: item.focalY ?? undefined, - filename: item.filename, - mimeType: item.mimeType, - // Cache LQIP alongside dimensions so embeds render a placeholder without a - // runtime lookup. Fall back to `meta` for providers that stash it there. - blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), - dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), - meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta, - }; + const selected = mediaItemToImageFieldValue(item); if (pickerTarget === "darkVariant") { if (objectValue) onChange({ ...objectValue, darkVariant: selected }); @@ -214,6 +245,52 @@ export function ImageFieldRenderer({ typeof value === "object" && value ? getMediaObjectPosition(value) : undefined; const darkObjectPosition = darkValue ? getMediaObjectPosition(darkValue) : undefined; const darkFilename = darkValue?.filename || t`Selected image`; + const canEditPrimaryAsset = Boolean( + objectValue?.id && canonicalMediaProviderId(objectValue.provider) === "local", + ); + const canEditDarkAsset = Boolean( + darkValue?.id && canonicalMediaProviderId(darkValue.provider) === "local", + ); + const primaryActions = ( +
+ + {canEditPrimaryAsset && ( + + )} + +
+ ); const darkVariantSlot = darkVariant && objectValue && displayUrl ? ( @@ -243,23 +320,41 @@ export function ImageFieldRenderer({ {darkFilename} -
+
+ {canEditDarkAsset && ( + + )} @@ -311,27 +407,7 @@ export function ImageFieldRenderer({ )}
-
- - -
+ {primaryActions}
) : null; @@ -353,34 +429,15 @@ export function ImageFieldRenderer({ featuredCard ) : displayUrl ? ( imageBroken ? ( -
+
{t`Image not found`}
-
- - -
+ {primaryActions}
) : ( -
+
setImageBroken(true)} /> -
- - -
+ {primaryActions}
) ) : ( @@ -438,6 +476,12 @@ export function ImageFieldRenderer({ : t`Select ${label}` } /> + {assetEditor.dialog} + {assetEditor.error && ( +

+ {assetEditor.error} +

+ )} {required && !displayUrl && (

{t`This field is required`}

)} diff --git a/packages/admin/src/components/editor/ImageDetailPanel.tsx b/packages/admin/src/components/editor/ImageDetailPanel.tsx index 95e5e44493..8ca3905660 100644 --- a/packages/admin/src/components/editor/ImageDetailPanel.tsx +++ b/packages/admin/src/components/editor/ImageDetailPanel.tsx @@ -13,6 +13,7 @@ import { Ruler, SlidersHorizontal, ImageSquare, + PencilSimple, LinkSimple, LinkBreak, } from "@phosphor-icons/react"; @@ -20,8 +21,9 @@ import * as React from "react"; import type { MediaItem } from "../../lib/api"; import { useStableCallback } from "../../lib/hooks"; -import { canonicalMediaProviderId } from "../../lib/media-utils.js"; +import { canonicalMediaProviderId, metaString } from "../../lib/media-utils.js"; import { ConfirmDialog } from "../ConfirmDialog"; +import { useMediaAssetEditor } from "../media/useMediaAssetEditor.js"; import { MediaPickerModal } from "../MediaPickerModal"; export interface ImageAttributes { @@ -76,6 +78,38 @@ export function ImageDetailPanel({ const [caption, setCaption] = React.useState(attributes.caption ?? ""); const [title, setTitle] = React.useState(attributes.title ?? ""); const [showMediaPicker, setShowMediaPicker] = React.useState(false); + const [asset, setAsset] = React.useState(attributes); + const handleAssetItemChanged = React.useCallback( + (item: MediaItem) => { + setDisplayWidth((current) => + attributes.displayWidth === undefined && current === asset.width ? item.width : current, + ); + setDisplayHeight((current) => + attributes.displayHeight === undefined && current === asset.height ? item.height : current, + ); + setAsset((current) => ({ + ...current, + src: item.url, + mediaId: item.id, + provider: "local", + width: item.width, + height: item.height, + blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), + dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), + })); + onUpdate({ + src: item.url, + mediaId: item.id, + provider: "local", + width: item.width, + height: item.height, + blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), + dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), + }); + }, + [asset.height, asset.width, attributes.displayHeight, attributes.displayWidth, onUpdate], + ); + const assetEditor = useMediaAssetEditor(handleAssetItemChanged); // Dimension state - default to display dimensions, fall back to original const [displayWidth, setDisplayWidth] = React.useState( @@ -90,8 +124,7 @@ export function ImageDetailPanel({ ); // Calculate aspect ratio from original dimensions - const aspectRatio = - attributes.width && attributes.height ? attributes.width / attributes.height : undefined; + const aspectRatio = asset.width && asset.height ? asset.width / asset.height : undefined; const handleWidthChange = (value: string) => { const newWidth = value ? parseInt(value, 10) : undefined; @@ -110,8 +143,8 @@ export function ImageDetailPanel({ }; const handleResetDimensions = () => { - setDisplayWidth(attributes.width); - setDisplayHeight(attributes.height); + setDisplayWidth(asset.width); + setDisplayHeight(asset.height); }; const handleMediaSelect = (item: MediaItem) => { @@ -134,8 +167,8 @@ export function ImageDetailPanel({ // Track if form has unsaved changes const hasChanges = React.useMemo(() => { - const originalDisplayWidth = attributes.displayWidth ?? attributes.width; - const originalDisplayHeight = attributes.displayHeight ?? attributes.height; + const originalDisplayWidth = attributes.displayWidth ?? asset.width; + const originalDisplayHeight = attributes.displayHeight ?? asset.height; return ( alt !== (attributes.alt ?? "") || caption !== (attributes.caption ?? "") || @@ -144,7 +177,17 @@ export function ImageDetailPanel({ displayHeight !== originalDisplayHeight || alignment !== attributes.alignment ); - }, [attributes, alt, caption, title, displayWidth, displayHeight, alignment]); + }, [ + asset.height, + asset.width, + attributes, + alt, + caption, + title, + displayWidth, + displayHeight, + alignment, + ]); const handleSave = () => { onUpdate({ @@ -168,6 +211,33 @@ export function ImageDetailPanel({ ]; const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + const canEditAsset = Boolean( + asset.mediaId && canonicalMediaProviderId(asset.provider) === "local", + ); + const imageActions = ( +
+ + {canEditAsset && ( + + )} +
+ ); const handleDelete = () => { setShowDeleteConfirm(true); @@ -179,10 +249,15 @@ export function ImageDetailPanel({ // Handle keyboard shortcuts React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + const saveShortcut = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s"; + if (showDeleteConfirm || showMediaPicker || assetEditor.isActive) { + if (saveShortcut) e.preventDefault(); + return; + } if (e.key === "Escape") { stableOnClose(); } - if ((e.metaKey || e.ctrlKey) && e.key === "s") { + if (saveShortcut) { e.preventDefault(); stableHandleSave(); } @@ -190,14 +265,14 @@ export function ImageDetailPanel({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [stableOnClose, stableHandleSave]); + }, [assetEditor.isActive, showDeleteConfirm, showMediaPicker, stableOnClose, stableHandleSave]); const dialogs = ( <> setShowDeleteConfirm(false)} - title={t`Remove Image?`} + title={t`Remove image?`} description={t`Remove this image from the document?`} confirmLabel={t`Remove`} pendingLabel={t`Removing...`} @@ -213,9 +288,10 @@ export function ImageDetailPanel({ onOpenChange={setShowMediaPicker} onSelect={handleMediaSelect} mimeTypeFilter="image/" - title={t`Replace image`} - confirmLabel={t`Replace image`} + title={t`Choose another image`} + confirmLabel={t`Choose another`} /> + {assetEditor.dialog} ); @@ -236,42 +312,38 @@ export function ImageDetailPanel({ {/* Preview */}
-
+
{attributes.alt -
- -
+ {imageActions} + {assetEditor.error && ( +

+ {assetEditor.error} +

+ )} {/* Original dimensions */} - {(attributes.width || attributes.height) && ( + {(asset.width || asset.height) && (
{t`Original:`} - {attributes.width} × {attributes.height} + {asset.width} × {asset.height}
)}
{/* Display Size — shown for any image; migrated images may lack original dims */} - {attributes.src && ( + {asset.src && (
- {attributes.width && attributes.height && ( + {asset.width && asset.height && ( -
+ {imageActions} + {assetEditor.error && ( +

+ {assetEditor.error} +

+ )}
{/* Image Info - original dimensions */} - {(attributes.width || attributes.height) && ( + {(asset.width || asset.height) && (
{t`Original:`} - {attributes.width} × {attributes.height} + {asset.width} × {asset.height}
)} {/* Display Size — shown for any image; migrated images may lack original dims */} - {attributes.src && ( + {asset.src && (
- {attributes.width && attributes.height && ( + {asset.width && asset.height && (
+ ) : null, +})); + const replacements: Record = { "Choose local image": { id: "local-image", @@ -40,6 +108,22 @@ vi.mock("../../src/components/MediaPickerModal.js", () => ({ })); describe("ImageDetailPanel replacement", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchMediaItem).mockResolvedValue({ + id: "old-image", + filename: "old.jpg", + mimeType: "image/jpeg", + url: "/_emdash/api/media/file/old.jpg", + storageKey: "old.jpg", + size: 100, + status: "ready", + authorId: "editor-1", + folderId: null, + createdAt: "2026-08-16T00:00:00.000Z", + }); + }); + it.each([ { action: "Choose local image", expectedProvider: "local" }, { action: "Choose provider image", expectedProvider: "cloudflare-images" }, @@ -60,9 +144,219 @@ describe("ImageDetailPanel replacement", () => { />, ); - await screen.getByRole("button", { name: "Replace Image" }).click(); + await screen.getByRole("button", { name: "Choose another" }).click(); await screen.getByRole("button", { name: action }).click(); expect(onReplace).toHaveBeenCalledWith(expect.objectContaining({ provider: expectedProvider })); }); + + it("preserves per-use image settings when a cropped copy replaces the asset", async () => { + const onUpdate = vi.fn(); + const screen = await render( + , + ); + + await expect.element(screen.getByRole("button", { name: "Choose another" })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Edit asset" })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Remove" })).toBeVisible(); + expect(fetchMediaItem).not.toHaveBeenCalled(); + await screen.getByRole("button", { name: "Edit asset" }).click(); + await vi.waitFor(() => + expect(fetchMediaItem).toHaveBeenCalledWith("old-image", { + signal: expect.any(AbortSignal), + }), + ); + await screen.getByRole("button", { name: "Use cropped asset" }).click(); + + expect(onUpdate).toHaveBeenCalledWith({ + src: "/_emdash/api/media/file/cropped.jpg", + mediaId: "cropped-image", + provider: "local", + width: 640, + height: 480, + blurhash: "new-hash", + dominantColor: "#123456", + }); + }); + + it("keeps the usage unchanged when the current asset no longer exists", async () => { + vi.mocked(fetchMediaItem).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Missing"), + ); + const onUpdate = vi.fn(); + const screen = await render( + , + ); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("This media item no longer exists."); + expect(onUpdate).not.toHaveBeenCalled(); + }); + + it("builds a local preview URL when the item response omits one", async () => { + vi.mocked(fetchMediaItem).mockResolvedValueOnce({ + id: "old-image", + filename: "old.jpg", + mimeType: "image/jpeg", + storageKey: "folder/old image.jpg", + size: 100, + status: "ready", + authorId: "editor-1", + folderId: null, + createdAt: "2026-08-16T00:00:00.000Z", + } as LocalMediaItem); + const screen = await render( + , + ); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + + await expect + .element(screen.getByRole("button", { name: "Use cropped asset" })) + .toHaveAttribute("data-item-url", "/_emdash/api/media/file/folder%2Fold%20image.jpg"); + }); + + it("does not close or save the usage behind an open asset dialog", async () => { + const onUpdate = vi.fn(); + const onClose = vi.fn(); + const screen = await render( + , + ); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + await expect.element(screen.getByRole("button", { name: "Use cropped asset" })).toBeVisible(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + const saveEvent = new KeyboardEvent("keydown", { key: "s", ctrlKey: true, cancelable: true }); + window.dispatchEvent(saveEvent); + + expect(onClose).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + expect(saveEvent.defaultPrevented).toBe(true); + }); + + it("blocks a second media action while the current asset is loading", async () => { + let resolveItem!: (item: LocalMediaItem) => void; + vi.mocked(fetchMediaItem).mockImplementationOnce( + () => new Promise((resolve) => (resolveItem = resolve)), + ); + const screen = await render( + , + ); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + + await expect.element(screen.getByRole("button", { name: "Choose another" })).toBeDisabled(); + await expect.element(screen.getByRole("button", { name: "Remove" })).toBeDisabled(); + resolveItem({ + id: "old-image", + filename: "old.jpg", + mimeType: "image/jpeg", + url: "/_emdash/api/media/file/old.jpg", + storageKey: "old.jpg", + size: 100, + status: "ready", + authorId: "editor-1", + folderId: null, + createdAt: "2026-08-16T00:00:00.000Z", + }); + }); + + it("keeps implicit display dimensions aligned with the cropped asset", async () => { + const screen = await render( + , + ); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + await screen.getByRole("button", { name: "Use cropped asset" }).click(); + + await expect.element(screen.getByLabelText("Width")).toHaveValue(640); + await expect.element(screen.getByLabelText("Height")).toHaveValue(480); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + await vi.waitFor(() => + expect(fetchMediaItem).toHaveBeenLastCalledWith("cropped-image", { + signal: expect.any(AbortSignal), + }), + ); + }); }); diff --git a/packages/admin/tests/components/ImageFieldRenderer.test.tsx b/packages/admin/tests/components/ImageFieldRenderer.test.tsx index 9e9f381712..a363465d40 100644 --- a/packages/admin/tests/components/ImageFieldRenderer.test.tsx +++ b/packages/admin/tests/components/ImageFieldRenderer.test.tsx @@ -58,6 +58,81 @@ vi.mock("../../src/components/MediaPickerModal", () => ({ ) : null, })); +vi.mock("../../src/lib/api/current-user.js", () => ({ + useCurrentUser: () => ({ data: { id: "editor-1", role: 40 } }), +})); + +vi.mock("../../src/components/MediaDetailPanel.js", () => ({ + MediaDetailPanel: ({ + open, + onClose, + onClosed, + onItemRefreshed, + onCroppedCopyCreated, + }: { + open: boolean; + onClose: () => void; + onClosed?: () => void; + onItemRefreshed?: (item: LocalMediaItem) => void; + onCroppedCopyCreated?: (item: LocalMediaItem) => void; + }) => + open ? ( + <> + + + + ) : null, +})); + const selectedImage: ImageFieldValue = { id: "featured-image", provider: "local", @@ -104,9 +179,10 @@ describe("ImageFieldRenderer", () => { const metadata = screen.getByText("1200 × 800 · image/jpeg"); await expect.element(metadata).toBeVisible(); expect(metadata.element()).toHaveAttribute("dir", "ltr"); - const replaceButton = screen.getByRole("button", { name: "Replace" }); - await expect.element(replaceButton).toBeVisible(); - expect(replaceButton.element().querySelector("svg")).not.toBeNull(); + const chooseAnotherButton = screen.getByRole("button", { name: "Choose another" }); + await expect.element(chooseAnotherButton).toBeVisible(); + expect(chooseAnotherButton.element().querySelector("svg")).not.toBeNull(); + await expect.element(screen.getByRole("button", { name: "Edit asset" })).toBeVisible(); const removeButton = screen.getByRole("button", { name: "Remove image" }); await expect.element(removeButton).toBeVisible(); expect(removeButton.element()).toHaveTextContent("Remove"); @@ -251,7 +327,7 @@ describe("ImageFieldRenderer", () => { />, ); - await screen.getByRole("button", { name: "Replace" }).click(); + await screen.getByRole("button", { name: "Choose another" }).click(); await screen.getByRole("button", { name: "Choose replacement" }).click(); expect(onChange).toHaveBeenCalledWith( @@ -278,7 +354,7 @@ describe("ImageFieldRenderer", () => { />, ); - await screen.getByRole("button", { name: "Replace" }).click(); + await screen.getByRole("button", { name: "Choose another" }).click(); await screen.getByRole("button", { name: "Choose external URL" }).click(); expect(onChange).toHaveBeenCalledWith( @@ -322,7 +398,8 @@ describe("ImageFieldRenderer", () => { await expect.element(screen.getByText("Image not found")).toBeVisible(); await expect.element(screen.getByText("notes-on-simplicity.jpg")).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Replace" })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Choose another" })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Edit asset" })).toBeVisible(); await expect.element(screen.getByRole("button", { name: "Remove image" })).toBeVisible(); }); @@ -472,7 +549,7 @@ describe("ImageFieldRenderer", () => { />, ); - await screen.getByRole("button", { name: "Change", exact: true }).click(); + await screen.getByRole("button", { name: "Choose another", exact: true }).click(); await screen.getByRole("button", { name: "Choose replacement" }).click(); expect(onChange).toHaveBeenCalledWith( @@ -488,4 +565,119 @@ describe("ImageFieldRenderer", () => { expect(screen.getByText("notes-on-simplicity.jpg").query()).toBeNull(); expect(screen.getByText("1200 × 800 · image/jpeg").query()).toBeNull(); }); + + it("uses a cropped copy for the field without dropping its dark mode variant", async () => { + const darkVariant: ImageFieldValue = { id: "dark-image", provider: "local" }; + const onChange = vi.fn(); + const screen = await render( + , + ); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + await screen.getByRole("button", { name: "Use cropped asset" }).click(); + + expect(onChange).toHaveBeenCalledWith({ + id: "cropped-image", + provider: "local", + src: undefined, + previewUrl: undefined, + alt: "Cropped asset alt", + width: 900, + height: 600, + focalX: 0.4, + focalY: 0.6, + filename: "cropped.webp", + mimeType: "image/webp", + blurhash: "cropped-hash", + dominantColor: "#aabbcc", + meta: { storageKey: "cropped.webp" }, + darkVariant, + }); + }); + + it("refreshes a custom field preview from the edited item without persisting its content hash", async () => { + let persistedValue: ImageFieldValue | null | undefined; + function Harness() { + const [value, setValue] = React.useState(selectedImage); + return ( + { + persistedValue = next; + setValue(next); + }} + /> + ); + } + const screen = await render(); + const sources: string[] = []; + const observer = new MutationObserver(() => { + for (const image of screen.container.querySelectorAll("img")) sources.push(image.src); + }); + observer.observe(screen.container, { + attributes: true, + attributeFilter: ["src"], + childList: true, + subtree: true, + }); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + await screen.getByRole("button", { name: "Use cropped asset" }).click(); + + await vi.waitFor(() => { + expect( + sources.some((source) => + source.endsWith( + "/_emdash/api/media/file/cropped.webp?_emdash_media=sha256%3Acropped-copy", + ), + ), + ).toBe(true); + }); + observer.disconnect(); + expect(persistedValue).not.toHaveProperty("contentHash"); + }); + + it("refreshes both previews when light and dark variants share one replaced asset", async () => { + function Harness() { + const [value, setValue] = React.useState({ + ...selectedImage, + darkVariant: { ...selectedImage }, + }); + return ( + next && setValue(next)} + darkVariant + /> + ); + } + const screen = await render(); + const refreshedSource = + "/_emdash/api/media/file/featured-image.jpg?_emdash_media=sha256%3Areplaced-original"; + const sources: string[] = []; + const observer = new MutationObserver(() => { + for (const image of screen.container.querySelectorAll("img")) sources.push(image.src); + }); + observer.observe(screen.container, { + attributes: true, + attributeFilter: ["src"], + childList: true, + subtree: true, + }); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + await screen.getByRole("button", { name: "Refresh original asset" }).click(); + + await vi.waitFor(() => { + expect(sources.filter((source) => source.endsWith(refreshedSource))).toHaveLength(2); + }); + observer.disconnect(); + }); }); From 807a155453b76fefc79000d732e6423e9e8cb8e7 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:43:30 +0100 Subject: [PATCH 03/20] Add in-context gallery asset editing --- .changeset/bright-otters-edit.md | 8 + .../src/content/docs/guides/media-library.mdx | 18 +- e2e/tests/media-library.spec.ts | 75 ++++++ .../components/editor/GalleryDetailPanel.tsx | 216 +++++++++++++----- .../src/components/editor/GalleryNode.tsx | 42 +++- .../admin/tests/gallery-detail-panel.test.tsx | 216 ++++++++++++++++++ 6 files changed, 514 insertions(+), 61 deletions(-) create mode 100644 .changeset/bright-otters-edit.md diff --git a/.changeset/bright-otters-edit.md b/.changeset/bright-otters-edit.md new file mode 100644 index 0000000000..6ab8ab29d8 --- /dev/null +++ b/.changeset/bright-otters-edit.md @@ -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. diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index 49da78601e..30e94e2b52 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -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 @@ -270,7 +285,8 @@ 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**. diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index 2e587d0b30..560bb84508 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -399,6 +399,81 @@ 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").filter({ hasText: "Select Featured Image" }); + 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(); + await expect(picker).not.toBeVisible(); + await pickerDetails.getByRole("button", { name: "Close" }).first().click(); + await expect(picker).toBeVisible(); + 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: "Choose another" })).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 diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index ad189873ea..de8aa7fd5c 100644 --- a/packages/admin/src/components/editor/GalleryDetailPanel.tsx +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -8,19 +8,33 @@ */ import { Button, Input, Label, Select } from "@cloudflare/kumo"; -import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; import type { DragEndEvent } from "@dnd-kit/core"; -import { SortableContext, rectSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useLingui } from "@lingui/react/macro"; -import { X, Plus, Trash, ImageSquare } from "@phosphor-icons/react"; +import { X, Plus, Trash, ImageSquare, PencilSimple } from "@phosphor-icons/react"; import * as React from "react"; import type { MediaItem } from "../../lib/api"; -import { getMediaObjectPosition, metaString } from "../../lib/media-utils"; +import { canonicalMediaProviderId, metaString } from "../../lib/media-utils"; import { cn } from "../../lib/utils"; +import { useMediaAssetEditor } from "../media/useMediaAssetEditor.js"; import { MediaPickerModal } from "../MediaPickerModal"; -import { galleryImageUrl, type GalleryAttributes, type GalleryImage } from "./GalleryNode"; +import { GalleryPreviewImage, type GalleryAttributes, type GalleryImage } from "./GalleryNode"; export interface GalleryDetailPanelProps { attributes: GalleryAttributes; @@ -35,30 +49,33 @@ function generateKey(): string { return Math.random().toString(36).substring(2, 11); } -/** Map a picked MediaItem to the gallery's Portable Text image shape. */ -export function mediaItemToGalleryImage(item: MediaItem): GalleryImage { +function mediaItemAssetFields(item: MediaItem) { return { - _type: "image", - _key: generateKey(), asset: { - _type: "reference", + _type: "reference" as const, _ref: item.id, url: item.url, provider: item.provider && item.provider !== "local" ? item.provider : undefined, }, - alt: item.alt || "", width: item.width, height: item.height, focalX: item.focalX ?? undefined, focalY: item.focalY ?? undefined, - // Cache LQIP alongside dimensions so the gallery renders a placeholder - // without a runtime lookup. Fall back to `meta` for providers that - // stash it there — mirrors ImageFieldRenderer's handleSelect. blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), }; } +/** Map a picked MediaItem to the gallery's Portable Text image shape. */ +export function mediaItemToGalleryImage(item: MediaItem): GalleryImage { + return { + _type: "image", + _key: generateKey(), + alt: item.alt || "", + ...mediaItemAssetFields(item), + }; +} + export function GalleryDetailPanel({ attributes, onUpdate, @@ -70,8 +87,12 @@ export function GalleryDetailPanel({ // A distance-based activation constraint lets a plain pointerdown+pointerup // (a click) pass through to the thumbnail button's onClick instead of the // sensor immediately claiming the pointer and starting drag tracking. - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); const [showMediaPicker, setShowMediaPicker] = React.useState(false); + const [assetEditorActive, setAssetEditorActive] = React.useState(false); // `selectedImageKey` and `nodeKey` are transient UI state passed in via // `attributes` when the gallery node view opens the sidebar (e.g. clicking // an image in the canvas grid) — neither is ever persisted to node attrs. @@ -113,6 +134,53 @@ export function GalleryDetailPanel({ const selectedImage = selectedKey ? (images.find((image) => image._key === selectedKey) ?? null) : null; + const dragAccessibility = React.useMemo( + () => ({ + announcements: { + onDragStart: ({ active }: { active: { id: string | number } }) => { + const index = images.findIndex((image) => image._key === active.id); + const label = images[index]?.alt || t`Image ${index + 1}`; + return t`Picked up ${label}.`; + }, + onDragOver: ({ + active, + over, + }: { + active: { id: string | number }; + over: { id: string | number } | null; + }) => { + const oldIndex = images.findIndex((image) => image._key === active.id); + const newIndex = images.findIndex((image) => image._key === over?.id); + if (newIndex < 0) return ""; + const label = images[oldIndex]?.alt || t`Image ${oldIndex + 1}`; + return t`Moving ${label} to position ${newIndex + 1} of ${images.length}.`; + }, + onDragEnd: ({ + active, + over, + }: { + active: { id: string | number }; + over: { id: string | number } | null; + }) => { + const oldIndex = images.findIndex((image) => image._key === active.id); + const newIndex = images.findIndex((image) => image._key === over?.id); + const label = images[oldIndex]?.alt || t`Image ${oldIndex + 1}`; + return newIndex < 0 + ? t`Moving ${label} was cancelled.` + : t`${label} moved to position ${newIndex + 1} of ${images.length}.`; + }, + onDragCancel: ({ active }: { active: { id: string | number } }) => { + const index = images.findIndex((image) => image._key === active.id); + const label = images[index]?.alt || t`Image ${index + 1}`; + return t`Moving ${label} was cancelled.`; + }, + }, + screenReaderInstructions: { + draggable: t`Press Space to pick up an image. Use the Arrow keys to move it, then press Space to drop it.`, + }, + }), + [images, t], + ); const apply = (patch: Partial) => { setGallery((prev) => ({ ...prev, ...patch })); @@ -139,26 +207,18 @@ export function GalleryDetailPanel({ apply({ images: images.map((image) => image._key === key - ? { - ...image, - asset: { - _type: "reference", - _ref: item.id, - url: item.url, - provider: item.provider && item.provider !== "local" ? item.provider : undefined, - }, - alt: item.alt || "", - width: item.width, - height: item.height, - focalX: item.focalX ?? undefined, - focalY: item.focalY ?? undefined, - blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), - dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), - } + ? { ...image, ...mediaItemAssetFields(item), alt: item.alt || "" } : image, ), }); }; + const handleAssetChange = (key: string, item: MediaItem) => { + apply({ + images: images.map((image) => + image._key === key ? { ...image, ...mediaItemAssetFields(item) } : image, + ), + }); + }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; @@ -200,6 +260,7 @@ export function GalleryDetailPanel({ size="sm" icon={} onClick={() => setShowMediaPicker(true)} + disabled={assetEditorActive} > {t`Add Images`} @@ -208,7 +269,12 @@ export function GalleryDetailPanel({ {images.length === 0 ? (

{t`No images in this gallery yet.`}

) : ( - + image._key)} strategy={rectSortingStrategy}>
{images.map((image, index) => ( @@ -234,9 +300,11 @@ export function GalleryDetailPanel({ image={selectedImage} onChange={(patch) => handleImageChange(selectedImage._key, patch)} onReplace={(item) => handleReplace(selectedImage._key, item)} + onAssetChange={(item) => handleAssetChange(selectedImage._key, item)} + onAssetEditorActiveChange={setAssetEditorActive} + onRemove={() => handleRemove(selectedImage._key)} /> )} - @@ -304,19 +372,16 @@ function SortableGalleryThumb({ {...attributes} {...listeners} > - {image.alt + {canEditAsset && ( -
+ )} +
{hasOriginalSize && (
@@ -398,9 +500,15 @@ function GalleryImageSettings({ image, onChange, onReplace }: GalleryImageSettin setShowReplacePicker(false); }} mimeTypeFilters={["image/"]} - title={t`Replace image`} - confirmLabel={t`Replace image`} + title={t`Choose another image`} + confirmLabel={t`Choose another`} /> + {assetEditor.dialog} + {assetEditor.error && ( +

+ {assetEditor.error} +

+ )}
); } diff --git a/packages/admin/src/components/editor/GalleryNode.tsx b/packages/admin/src/components/editor/GalleryNode.tsx index 75ac6c47df..3de8ec7ea5 100644 --- a/packages/admin/src/components/editor/GalleryNode.tsx +++ b/packages/admin/src/components/editor/GalleryNode.tsx @@ -10,12 +10,18 @@ import { Button } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Images, Trash, SlidersHorizontal } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; import type { NodeViewProps } from "@tiptap/react"; import { Node } from "@tiptap/react"; import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; import * as React from "react"; -import { getMediaObjectPosition } from "../../lib/media-utils.js"; +import { fetchMediaItem } from "../../lib/api/media.js"; +import { + canonicalMediaProviderId, + getMediaObjectPosition, + getMediaPreviewUrl, +} from "../../lib/media-utils.js"; import { cn } from "../../lib/utils"; /** One image inside a gallery block — mirrors the Portable Text shape. */ @@ -77,6 +83,33 @@ export function galleryImageUrl(image: GalleryImage): string { return ""; } +export function GalleryPreviewImage({ + image, + className, +}: { + image: GalleryImage; + className: string; +}) { + const mediaId = + canonicalMediaProviderId(image.asset.provider) === "local" && image.asset._ref + ? image.asset._ref + : null; + const { data: currentMedia } = useQuery({ + queryKey: mediaId ? ["media", mediaId] : ["media-preview-disabled", image.asset._ref], + queryFn: ({ signal }) => fetchMediaItem(mediaId!, { signal }), + enabled: false, + }); + return ( + {image.alt + ); +} + function GalleryNodeView({ node, updateAttributes, @@ -180,12 +213,9 @@ function GalleryNodeView({ }} aria-label={t`Edit image ${index + 1}`} > - {image.alt {image.caption && ( diff --git a/packages/admin/tests/gallery-detail-panel.test.tsx b/packages/admin/tests/gallery-detail-panel.test.tsx index 05e81b8de1..f82015e2a0 100644 --- a/packages/admin/tests/gallery-detail-panel.test.tsx +++ b/packages/admin/tests/gallery-detail-panel.test.tsx @@ -23,12 +23,97 @@ vi.mock("../src/lib/api", async () => { fetchMediaList: vi.fn().mockResolvedValue({ items: [] }), fetchMediaProviders: vi.fn().mockResolvedValue([]), fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }), + fetchMediaItem: vi.fn().mockResolvedValue({ + id: "m2", + filename: "m2.jpg", + mimeType: "image/jpeg", + url: "/media/m2.jpg", + storageKey: "m2.jpg", + size: 100, + status: "ready", + authorId: "editor-1", + folderId: null, + createdAt: "2026-01-01T00:00:00.000Z", + }), uploadMedia: vi.fn().mockResolvedValue({}), uploadToProvider: vi.fn().mockResolvedValue({}), updateMedia: vi.fn().mockResolvedValue({}), }; }); +vi.mock("../src/lib/api/current-user.js", () => ({ + useCurrentUser: () => ({ data: { id: "editor-1", role: 40 } }), +})); + +vi.mock("../src/components/MediaDetailPanel.js", () => ({ + MediaDetailPanel: ({ + open, + onClose, + onClosed, + onItemRefreshed, + onCroppedCopyCreated, + }: { + open: boolean; + onClose: () => void; + onClosed?: () => void; + onItemRefreshed?: (item: unknown) => void; + onCroppedCopyCreated?: (item: unknown) => void; + }) => + open ? ( + <> + + + + ) : null, +})); + function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -224,4 +309,135 @@ describe("GalleryDetailPanel", () => { expect(screen.getByRole("img", { name: "Image One" }).query()).toBeNull(); expect(screen.getByRole("img", { name: "Image Three" }).query()).toBeNull(); }); + + it("reorders gallery images with the keyboard and announces the new position", async () => { + const onUpdate = vi.fn(); + const screen = await renderPanel({ + attributes: { images: threeImages(), columns: 3, nodeKey: "10" }, + onUpdate, + }); + const first = screen.getByRole("button", { name: "Image One" }); + first.element().focus(); + + first + .element() + .dispatchEvent( + new KeyboardEvent("keydown", { key: " ", code: "Space", bubbles: true, cancelable: true }), + ); + await expect.element(first).toHaveAttribute("aria-pressed", "true"); + await new Promise((resolve) => window.setTimeout(resolve, 50)); + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + code: "ArrowDown", + bubbles: true, + cancelable: true, + }), + ); + await new Promise((resolve) => window.setTimeout(resolve, 50)); + await expect + .element(screen.getByText("Moving Image One to position 2 of 3.")) + .toBeInTheDocument(); + document.dispatchEvent( + new KeyboardEvent("keydown", { key: " ", code: "Space", bubbles: true, cancelable: true }), + ); + + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalled()); + const images = onUpdate.mock.calls.at(-1)?.[0].images as GalleryImage[]; + expect(images.map((image) => image._key)).toEqual(["img2", "img1", "img3"]); + await expect + .element(screen.getByText("Image One moved to position 2 of 3.")) + .toBeInTheDocument(); + }); + + it("uses a cropped copy without changing the gallery slot metadata or order", async () => { + const onUpdate = vi.fn(); + const images = threeImages(); + images[1] = { ...images[1]!, alt: "Usage alt", caption: "Usage caption" }; + const screen = await renderPanel({ + attributes: { images, columns: 3, nodeKey: "10", selectedImageKey: "img2" }, + onUpdate, + }); + + await expect.element(screen.getByRole("button", { name: "Choose another" })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Edit asset" })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Remove", exact: true })).toBeVisible(); + await screen.getByRole("button", { name: "Edit asset" }).click(); + await screen.getByRole("button", { name: "Use cropped gallery asset" }).click(); + + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalled()); + const patched = onUpdate.mock.calls.at(-1)?.[0].images as GalleryImage[]; + expect(patched.map((image) => image._key)).toEqual(["img1", "img2", "img3"]); + expect(patched[1]).toEqual({ + ...images[1], + asset: { _type: "reference", _ref: "m2-cropped", url: "/media/m2-cropped.jpg" }, + width: 640, + height: 480, + focalX: 0.4, + focalY: 0.6, + blurhash: "cropped-hash", + dominantColor: "#123456", + }); + }); + + it("refreshes every admin preview of a replaced gallery asset without another load request", async () => { + const api = await import("../src/lib/api"); + const images = threeImages(); + images[1] = { + ...images[1]!, + asset: { + ...images[1]!.asset, + url: "/_emdash/api/media/file/m2.jpg", + }, + alt: "Usage alt", + }; + const screen = await renderPanel({ + attributes: { images, columns: 3, nodeKey: "10", selectedImageKey: "img2" }, + }); + expect(api.fetchMediaItem).not.toHaveBeenCalled(); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + await screen.getByRole("button", { name: "Refresh gallery asset" }).click(); + + await vi.waitFor(() => { + expect( + screen.container.querySelectorAll( + 'img[src="/_emdash/api/media/file/m2.jpg?_emdash_media=sha256%3Areplaced-gallery"]', + ), + ).toHaveLength(2); + }); + expect(api.fetchMediaItem).toHaveBeenCalledTimes(1); + }); + + it("does not open the add picker while an asset edit is loading", async () => { + const api = await import("../src/lib/api"); + let resolveItem!: (item: Awaited>) => void; + vi.mocked(api.fetchMediaItem).mockImplementationOnce( + () => new Promise((resolve) => (resolveItem = resolve)), + ); + const screen = await renderPanel({ + attributes: { + images: threeImages(), + columns: 3, + nodeKey: "10", + selectedImageKey: "img2", + }, + }); + + await screen.getByRole("button", { name: "Edit asset" }).click(); + + await expect.element(screen.getByRole("button", { name: "Add Images" })).toBeDisabled(); + resolveItem({ + id: "m2", + filename: "m2.jpg", + mimeType: "image/jpeg", + url: "/media/m2.jpg", + storageKey: "m2.jpg", + size: 100, + status: "ready", + authorId: "editor-1", + folderId: null, + createdAt: "2026-01-01T00:00:00.000Z", + }); + }); }); From 82c4cc9c51c3073c3ac0ac0db117220cacab4d47 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:20:57 +0100 Subject: [PATCH 04/20] Fix missing assets in media pickers --- .../src/content/docs/guides/media-library.mdx | 5 +-- .../admin/src/components/MediaDetailPanel.tsx | 12 ++++++- .../admin/src/components/MediaPickerModal.tsx | 14 ++++++++ .../components/MediaDetailPanel.test.tsx | 35 ++++++++++++++++++- .../components/MediaPickerModal.test.tsx | 32 +++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index 30e94e2b52..c0cd4f170e 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -292,8 +292,9 @@ layout crops it to fill a fixed shape. 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 diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx index 948adc4df0..06a620a46c 100644 --- a/packages/admin/src/components/MediaDetailPanel.tsx +++ b/packages/admin/src/components/MediaDetailPanel.tsx @@ -151,6 +151,7 @@ export interface MediaDetailPanelProps { onUpdated?: () => void; onItemRefreshed?: (item: LocalMediaItem) => void; onCroppedCopyCreated?: (item: LocalMediaItem) => void; + onUnavailable?: (id: string) => void; onDeleted?: () => void; } @@ -173,6 +174,7 @@ export function MediaDetailPanel({ onUpdated, onItemRefreshed, onCroppedCopyCreated, + onUnavailable, onDeleted, }: MediaDetailPanelProps) { const { t } = useLingui(); @@ -186,6 +188,7 @@ export function MediaDetailPanel({ const closeFallbackTimerRef = React.useRef(null); const closeFinishedRef = React.useRef(false); const cropPendingRef = React.useRef(false); + const unavailableReportedRef = React.useRef(null); const cropImageRef = React.useRef(null); const dialogBodyRef = React.useRef(null); const dialogResizeAnimationRef = React.useRef(null); @@ -271,6 +274,7 @@ export function MediaDetailPanel({ replacePendingRef.current = false; replaceSelectionTokenRef.current += 1; cropPendingRef.current = false; + unavailableReportedRef.current = null; cropImageRef.current = null; if (imageModeOverflowFrameRef.current !== null) { window.cancelAnimationFrame(imageModeOverflowFrameRef.current); @@ -688,9 +692,10 @@ export function MediaDetailPanel({ setShowCropConfirm(false); setCropStatus(t`Original image cropped.`); }, - onError: (_error, action) => { + onError: (error, action) => { setCropStatus(""); if (action === "replace") setShowCropConfirm(false); + if (error instanceof ApiResponseError && error.code === "NOT_FOUND") recoverMediaItem(); }, onSettled: () => { cropPendingRef.current = false; @@ -708,6 +713,11 @@ export function MediaDetailPanel({ const mediaUnavailable = recoverMediaMutation.error instanceof ApiResponseError && recoverMediaMutation.error.code === "NOT_FOUND"; + React.useEffect(() => { + if (!open || !mediaUnavailable || unavailableReportedRef.current === item.id) return; + unavailableReportedRef.current = item.id; + onUnavailable?.(item.id); + }, [item.id, mediaUnavailable, onUnavailable, open]); const isBusy = isSaving || isDeleting || isRecovering || isReplacing || isCropping; const cropFooterActive = activeTab === "edit-image" && imageEditMode === "crop" && canShowCrop; const cropActionDisabled = diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 4737c4db25..debae7cda7 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -757,6 +757,19 @@ export function MediaPickerModal({ }, [assetItem.id, cacheLocalItem, multiple, queryClient], ); + const handleAssetUnavailable = React.useCallback( + (id: string) => { + setSelectedItems((current) => + current.filter((selected) => selected.providerId !== "local" || selected.item.id !== id), + ); + setPinnedItems((current) => + current.filter((selected) => selected.providerId !== "local" || selected.item.id !== id), + ); + queryClient.removeQueries({ queryKey: ["media", id], exact: true }); + void queryClient.invalidateQueries({ queryKey: ["media"] }); + }, + [queryClient], + ); const openAssetEditor = () => { if (!editableSelection || uploadQueue.hasUnfinished) return; setAssetItem(editableSelection); @@ -1544,6 +1557,7 @@ export function MediaPickerModal({ onClosed={handleAssetClosed} onItemRefreshed={handleAssetRefreshed} onCroppedCopyCreated={handleCroppedCopyCreated} + onUnavailable={handleAssetUnavailable} /> ); diff --git a/packages/admin/tests/components/MediaDetailPanel.test.tsx b/packages/admin/tests/components/MediaDetailPanel.test.tsx index 1ca8e1c0df..56ced423ed 100644 --- a/packages/admin/tests/components/MediaDetailPanel.test.tsx +++ b/packages/admin/tests/components/MediaDetailPanel.test.tsx @@ -1079,6 +1079,33 @@ describe("MediaDetailPanel", () => { expect(cropSelectionStyle(screen)).toBe(draftStyle); }); + it("reports when the image is deleted while replacing the original", async () => { + vi.mocked(replaceMediaImage).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media item not found"), + ); + vi.mocked(fetchMediaItem).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media item not found"), + ); + const onUnavailable = vi.fn(); + const screen = await renderPanel({ + item: makeLocalItem({ url: TEST_IMAGE_URL }), + canCropOriginal: true, + onUnavailable, + }); + await openCropEditor(screen); + await resizeCrop(screen); + + screen.getByRole("button", { name: "Replace original" }).element().click(); + const confirmation = screen.getByRole("alertdialog", { name: "Replace original image?" }); + await expect.element(confirmation).toBeVisible(); + confirmation.getByRole("button", { name: "Replace original" }).element().click(); + + await vi.waitFor(() => { + expect(fetchMediaItem).toHaveBeenCalledWith("media-1"); + expect(onUnavailable).toHaveBeenCalledWith("media-1"); + }); + }); + it("explains that cropped WebP output is static", async () => { const screen = await renderPanel({ item: makeLocalItem({ @@ -1749,7 +1776,12 @@ describe("MediaDetailPanel", () => { vi.mocked(fetchMediaItem).mockRejectedValueOnce( new ApiResponseError(404, "NOT_FOUND", "Media item not found"), ); - const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + const onUnavailable = vi.fn(); + const screen = await renderPanel({ + item: makeLocalItem(), + canMoveLocation: true, + onUnavailable, + }); screen.getByRole("combobox", { name: "Location" }).element().click(); await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); @@ -1764,6 +1796,7 @@ describe("MediaDetailPanel", () => { .query(), ).toBeNull(); await expect.element(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(onUnavailable).toHaveBeenCalledWith("media-1"); }); it("does not blame the folder when missing-item recovery cannot confirm the state", async () => { diff --git a/packages/admin/tests/components/MediaPickerModal.test.tsx b/packages/admin/tests/components/MediaPickerModal.test.tsx index 75f14bca94..c4eca0498c 100644 --- a/packages/admin/tests/components/MediaPickerModal.test.tsx +++ b/packages/admin/tests/components/MediaPickerModal.test.tsx @@ -77,6 +77,7 @@ vi.mock("../../src/components/MediaDetailPanel", () => ({ onClosed, onItemRefreshed, onCroppedCopyCreated, + onUnavailable, }: { open: boolean; item: { filename: string }; @@ -85,6 +86,7 @@ vi.mock("../../src/components/MediaDetailPanel", () => ({ onClosed?: () => void; onItemRefreshed?: (item: unknown) => void; onCroppedCopyCreated?: (item: unknown) => void; + onUnavailable?: (id: string) => void; }) => open ? (
@@ -144,6 +146,16 @@ vi.mock("../../src/components/MediaDetailPanel", () => ({ > Create cropped copy +
) : null, })); @@ -357,6 +369,26 @@ describe("MediaPickerModal", () => { await expect.element(item).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Edit asset" }).query()).toBeNull(); }); + + it("removes a missing edited asset from the picker selection", async () => { + const screen = await renderModal(); + const item = screen.getByRole("button", { name: "photo.jpg" }); + await expect.element(item).toBeVisible(); + item.element().click(); + const editAsset = screen.getByRole("button", { name: "Edit asset" }); + await expect.element(editAsset).toBeEnabled(); + editAsset.element().click(); + await expect + .element(screen.getByRole("button", { name: "Report asset missing" })) + .toBeVisible(); + + screen.getByRole("button", { name: "Report asset missing" }).element().click(); + + await expect.element(screen.getByRole("button", { name: "Select" })).toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "photo.jpg" })) + .toHaveAttribute("aria-pressed", "false"); + }); }); describe("URL input", () => { From 163f716ab997ed7a01a6b8aa22c55f8ccff17f44 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:30:58 +0100 Subject: [PATCH 05/20] Unify editor media workspace navigation --- e2e/tests/media-library.spec.ts | 22 ++- .../admin/src/components/MediaDetailPanel.tsx | 177 ++++++++++++++---- .../admin/src/components/MediaPickerModal.tsx | 177 ++++++++---------- .../components/MediaDetailPanel.test.tsx | 52 +++++ .../components/MediaPickerModal.test.tsx | 51 ++++- 5 files changed, 337 insertions(+), 142 deletions(-) diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index 560bb84508..79a6dec64e 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -418,15 +418,31 @@ test.describe("Media Library", () => { await admin.waitForLoading(); const editorUrl = page.url(); await page.getByRole("button", { name: "Select image" }).click(); - const picker = page.getByRole("dialog").filter({ hasText: "Select Featured Image" }); + const picker = page.getByRole("dialog", { name: "Select Featured Image" }); + const workspaceElement = await picker.elementHandle(); + expect(workspaceElement).not.toBeNull(); 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(); - await expect(picker).not.toBeVisible(); - await pickerDetails.getByRole("button", { name: "Close" }).first().click(); + 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 to library" }).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", diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx index 06a620a46c..4e5611acc6 100644 --- a/packages/admin/src/components/MediaDetailPanel.tsx +++ b/packages/admin/src/components/MediaDetailPanel.tsx @@ -21,6 +21,7 @@ import { import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { + ArrowLeft, ArrowCounterClockwise, ArrowsClockwise, X, @@ -138,6 +139,8 @@ interface MediaLocationOption { export interface MediaDetailPanelProps { open: boolean; item: MediaItem; + embedded?: boolean; + backLabel?: string; context?: "library" | "content"; providerName?: string; canDelete?: boolean; @@ -145,8 +148,10 @@ export interface MediaDetailPanelProps { canReplaceOriginal?: boolean; canCropOriginal?: boolean; canDuplicateCrop?: boolean; + requestExitRef?: React.MutableRefObject<(() => void) | null>; restoreFocusTargetRef?: React.RefObject; onClose: () => void; + onExit?: () => void; onClosed?: () => void; onUpdated?: () => void; onItemRefreshed?: (item: LocalMediaItem) => void; @@ -155,12 +160,75 @@ export interface MediaDetailPanelProps { onDeleted?: () => void; } +interface MediaDetailRootProps { + embedded: boolean; + open: boolean; + isConfirmOpen: boolean; + onRequestClose: () => void; + onClosed: () => void; + children: React.ReactNode; +} + +function MediaDetailRoot({ + embedded, + open, + isConfirmOpen, + onRequestClose, + onClosed, + children, +}: MediaDetailRootProps) { + if (embedded) return <>{children}; + return ( + { + if (!nextOpen && !isConfirmOpen) onRequestClose(); + }} + onOpenChangeComplete={(nextOpen) => { + if (!nextOpen) onClosed(); + }} + > + {children} + + ); +} + +function MediaDetailSurface({ + embedded, + children, +}: { + embedded: boolean; + children: React.ReactNode; +}) { + if (embedded) { + return ( +
+ {children} +
+ ); + } + return ( + + {children} + + ); +} + /** * Centered dialog for viewing and editing media metadata. */ export function MediaDetailPanel({ open, item, + embedded = false, + backLabel, context = "library", providerName, canDelete: canDeleteProp, @@ -168,8 +236,10 @@ export function MediaDetailPanel({ canReplaceOriginal: canReplaceOriginalProp, canCropOriginal = false, canDuplicateCrop = false, + requestExitRef, restoreFocusTargetRef, onClose, + onExit, onClosed, onUpdated, onItemRefreshed, @@ -257,6 +327,7 @@ export function MediaDetailPanel({ const [replacementImage, setReplacementImage] = React.useState(null); const [replaceSelectionError, setReplaceSelectionError] = React.useState(""); const [replaceStatus, setReplaceStatus] = React.useState(""); + const discardActionRef = React.useRef<"close" | "exit">("close"); const [pendingUsageEntry, setPendingUsageEntry] = React.useState( null, ); @@ -307,6 +378,7 @@ export function MediaDetailPanel({ setReplacementImage(null); setReplaceSelectionError(""); setReplaceStatus(""); + discardActionRef.current = "close"; setPendingUsageEntry(null); }, [item.id, localItem?.folderId, open]); @@ -389,14 +461,26 @@ export function MediaDetailPanel({ } }, [onClosed, restoreFocusTargetRef]); - const closeDialog = React.useCallback(() => { - replaceSelectionTokenRef.current += 1; - onClose(); - if (closeFallbackTimerRef.current !== null) { - window.clearTimeout(closeFallbackTimerRef.current); - } - closeFallbackTimerRef.current = window.setTimeout(finishClose, CLOSE_FALLBACK_MS); - }, [finishClose, onClose]); + const closeWith = React.useCallback( + (callback: () => void) => { + replaceSelectionTokenRef.current += 1; + callback(); + if (embedded) { + finishClose(); + return; + } + if (closeFallbackTimerRef.current !== null) { + window.clearTimeout(closeFallbackTimerRef.current); + } + closeFallbackTimerRef.current = window.setTimeout(finishClose, CLOSE_FALLBACK_MS); + }, + [embedded, finishClose], + ); + const closeDialog = React.useCallback(() => closeWith(onClose), [closeWith, onClose]); + const exitDialog = React.useCallback( + () => closeWith(onExit ?? onClose), + [closeWith, onClose, onExit], + ); const originalFocalPoint = normalizeMediaFocalPoint(item); const focalPointChanged = @@ -743,6 +827,7 @@ export function MediaDetailPanel({ const requestClose = React.useCallback(() => { if (isBusy) return; if (isConfirmOpen) return; + discardActionRef.current = "close"; setPendingUsageEntry(null); if (hasChanges) { setShowDiscardConfirm(true); @@ -750,6 +835,24 @@ export function MediaDetailPanel({ } closeDialog(); }, [closeDialog, hasChanges, isBusy, isConfirmOpen]); + const requestExit = React.useCallback(() => { + if (isBusy) return; + if (isConfirmOpen) return; + discardActionRef.current = "exit"; + setPendingUsageEntry(null); + if (hasChanges) { + setShowDiscardConfirm(true); + return; + } + exitDialog(); + }, [exitDialog, hasChanges, isBusy, isConfirmOpen]); + React.useLayoutEffect(() => { + if (!requestExitRef) return; + requestExitRef.current = requestExit; + return () => { + if (requestExitRef.current === requestExit) requestExitRef.current = null; + }; + }, [requestExit, requestExitRef]); const handleSave = () => { if (!canEdit || !hasChanges || isBusy || mediaUnavailable || savePendingRef.current) return; @@ -943,7 +1046,8 @@ export function MediaDetailPanel({ const usageEntry = pendingUsageEntry; setShowDiscardConfirm(false); setPendingUsageEntry(null); - closeDialog(); + if (usageEntry || discardActionRef.current === "close") closeDialog(); + else exitDialog(); if (usageEntry) { void navigate({ to: "/content/$collection/$id", @@ -965,6 +1069,7 @@ export function MediaDetailPanel({ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); + discardActionRef.current = "close"; setPendingUsageEntry(entry); setShowDiscardConfirm(true); }; @@ -988,37 +1093,45 @@ export function MediaDetailPanel({ return ( <> - { - if (!nextOpen && !isConfirmOpen) requestClose(); - }} - onOpenChangeComplete={(nextOpen) => { - if (nextOpen) return; - finishClose(); - }} + isConfirmOpen={isConfirmOpen} + onRequestClose={requestClose} + onClosed={finishClose} > - +
-
- - {t`Media details`} - -

{item.filename}

+
+ {embedded && backLabel ? ( + + ) : null} +
+ + {t`Media details`} + +

{item.filename}

+
-
-
+ + >({}); - const [browseOpen, setBrowseOpen] = React.useState(open); const [assetOpen, setAssetOpen] = React.useState(false); const [assetItem, setAssetItem] = React.useState(EMPTY_DETAIL_ITEM); const fileInputRef = React.useRef(null); const editAssetButtonRef = React.useRef(null); + const mediaDetailPanelRef = React.useRef<(() => void) | null>(null); const updatedDimensionsRef = React.useRef(new Set()); const urlProbeIdRef = React.useRef(0); const uploadTargetsRef = React.useRef(new Map()); const selectionOrderEditedRef = React.useRef(false); - const parentOpenRef = React.useRef(open); - const flowGenerationRef = React.useRef(0); - const transitionRef = React.useRef< - { kind: "open-asset" | "return-browse"; generation: number } | undefined - >(undefined); const restoreEditFocusRef = React.useRef(false); const invalidateUrlProbe = React.useCallback(() => { urlProbeIdRef.current += 1; @@ -288,23 +283,17 @@ export function MediaPickerModal({ const uploadQueue = useMediaUploadQueue({ upload: uploadFile }); React.useEffect(() => { - const wasOpen = parentOpenRef.current; - parentOpenRef.current = open; - if (!open) { - flowGenerationRef.current += 1; - transitionRef.current = undefined; - restoreEditFocusRef.current = false; - setBrowseOpen(false); - setAssetOpen(false); - return; - } - if (wasOpen) return; - flowGenerationRef.current += 1; - transitionRef.current = undefined; + if (open) return; + restoreEditFocusRef.current = false; setAssetOpen(false); - setBrowseOpen(true); }, [open]); + React.useEffect(() => { + if (!open || assetOpen || !restoreEditFocusRef.current) return; + restoreEditFocusRef.current = false; + editAssetButtonRef.current?.focus({ preventScroll: true }); + }, [assetOpen, open]); + React.useEffect(() => { if (!open) return; setActiveSource("local"); @@ -773,31 +762,11 @@ export function MediaPickerModal({ const openAssetEditor = () => { if (!editableSelection || uploadQueue.hasUnfinished) return; setAssetItem(editableSelection); - transitionRef.current = { - kind: "open-asset", - generation: flowGenerationRef.current, - }; - setBrowseOpen(false); + setAssetOpen(true); }; const closeAssetEditor = () => { - transitionRef.current = { - kind: "return-browse", - generation: flowGenerationRef.current, - }; - setAssetOpen(false); - }; - const handleAssetClosed = () => { - const transition = transitionRef.current; - if ( - !parentOpenRef.current || - transition?.kind !== "return-browse" || - transition.generation !== flowGenerationRef.current - ) { - return; - } - transitionRef.current = undefined; restoreEditFocusRef.current = true; - setBrowseOpen(true); + setAssetOpen(false); }; const confirmText = confirmLabel ?? @@ -902,56 +871,64 @@ export function MediaPickerModal({ const browseDialog = ( { - if (!nextOpen && transitionRef.current?.kind !== "open-asset") handleClose(); - }} - onOpenChangeComplete={(nextOpen) => { - if (nextOpen) { - if (!restoreEditFocusRef.current) return; - restoreEditFocusRef.current = false; - editAssetButtonRef.current?.focus({ preventScroll: true }); + if (nextOpen) return; + if (assetOpen && mediaDetailPanelRef.current) { + mediaDetailPanelRef.current(); return; } - const transition = transitionRef.current; - if ( - !parentOpenRef.current || - transition?.kind !== "open-asset" || - transition.generation !== flowGenerationRef.current - ) { - return; - } - transitionRef.current = undefined; - setAssetOpen(true); + handleClose(); }} > -
-
- {title} - - {description} - -
- ( -
+ ) : null} + {!assetOpen ? ( +
+
+ {title} + + {description} + +
+ ( +
+ ) : null} - +
); - return ( - <> - {browseDialog} - = ROLE_CONTRIBUTOR} - restoreFocusTargetRef={editAssetButtonRef} - onClose={closeAssetEditor} - onClosed={handleAssetClosed} - onItemRefreshed={handleAssetRefreshed} - onCroppedCopyCreated={handleCroppedCopyCreated} - onUnavailable={handleAssetUnavailable} - /> - - ); + return browseDialog; } export default MediaPickerModal; diff --git a/packages/admin/tests/components/MediaDetailPanel.test.tsx b/packages/admin/tests/components/MediaDetailPanel.test.tsx index 56ced423ed..393d00c43a 100644 --- a/packages/admin/tests/components/MediaDetailPanel.test.tsx +++ b/packages/admin/tests/components/MediaDetailPanel.test.tsx @@ -1,3 +1,4 @@ +import { Dialog } from "@cloudflare/kumo"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -216,6 +217,27 @@ function renderPanel(props: Partial> = {}) { + const defaultProps: React.ComponentProps = { + open: true, + item: makeImageItem(), + embedded: true, + backLabel: "Back to library", + onClose: vi.fn(), + onDeleted: vi.fn(), + ...props, + }; + return render( + + + + + + + , + ); +} + async function openFocalEditor(screen: Awaited>) { const editTab = screen.getByRole("tab", { name: "Edit image" }).element(); editTab.focus(); @@ -1925,6 +1947,36 @@ describe("MediaDetailPanel", () => { expect(onClose).toHaveBeenCalled(); }); + it("uses Back to return from embedded details without closing the workspace", async () => { + const onClose = vi.fn(); + const onExit = vi.fn(); + const screen = await renderEmbeddedPanel({ onClose, onExit }); + + screen.getByRole("button", { name: "Back to library" }).element().click(); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onExit).not.toHaveBeenCalled(); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + }); + + it("confirms dirty changes before closing an embedded workspace", async () => { + const onClose = vi.fn(); + const onExit = vi.fn(); + const item = makeImageItem({ alt: "Original" }); + const screen = await renderEmbeddedPanel({ item, onClose, onExit }); + + await screen.getByLabelText("Alt Text").fill("Changed alt"); + screen.getByRole("button", { name: "Close" }).element().click(); + + await expect.element(screen.getByText("Discard changes?")).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + + screen.getByRole("button", { name: "Discard" }).element().click(); + expect(onExit).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + it("close button opens discard confirmation when dirty", async () => { const onClose = vi.fn(); const item = makeImageItem({ alt: "Original" }); diff --git a/packages/admin/tests/components/MediaPickerModal.test.tsx b/packages/admin/tests/components/MediaPickerModal.test.tsx index c4eca0498c..657f10f453 100644 --- a/packages/admin/tests/components/MediaPickerModal.test.tsx +++ b/packages/admin/tests/components/MediaPickerModal.test.tsx @@ -73,7 +73,9 @@ vi.mock("../../src/components/MediaDetailPanel", () => ({ open, item, context, + embedded, onClose, + onExit, onClosed, onItemRefreshed, onCroppedCopyCreated, @@ -82,14 +84,21 @@ vi.mock("../../src/components/MediaDetailPanel", () => ({ open: boolean; item: { filename: string }; context?: string; + embedded?: boolean; onClose: () => void; + onExit?: () => void; onClosed?: () => void; onItemRefreshed?: (item: unknown) => void; onCroppedCopyCreated?: (item: unknown) => void; onUnavailable?: (id: string) => void; }) => open ? ( -
+
{item.filename} + - ) : null} -
- - {t`Media details`} - -

{item.filename}

-
+
+ + {t`Media details`} + +

{item.filename}

- )} - {canReplaceImage && activeTab === "details" ? ( + ) : ( <> - - + {canDelete && !cropFooterActive ? ( + + ) : null} + {canReplaceImage && activeTab === "details" ? ( + <> + + + + ) : null} - ) : null} + )}
- + {!embedded ? ( + + ) : null} {cropFooterActive ? ( <> {canReplaceOriginal ? ( diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 94ed0933c1..4ece63e753 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -883,14 +883,20 @@ export function MediaPickerModal({ > {assetOpen ? ( = ROLE_CONTRIBUTOR} diff --git a/packages/admin/tests/components/MediaDetailPanel.test.tsx b/packages/admin/tests/components/MediaDetailPanel.test.tsx index 393d00c43a..179acad138 100644 --- a/packages/admin/tests/components/MediaDetailPanel.test.tsx +++ b/packages/admin/tests/components/MediaDetailPanel.test.tsx @@ -222,7 +222,6 @@ function renderEmbeddedPanel(props: Partial { expect(onClose).toHaveBeenCalled(); }); - it("uses Back to return from embedded details without closing the workspace", async () => { + it("places a compact Back action in the embedded footer", async () => { const onClose = vi.fn(); const onExit = vi.fn(); const screen = await renderEmbeddedPanel({ onClose, onExit }); + const header = screen.getByTestId("media-detail-dialog-header").element(); + const footer = screen.getByTestId("media-detail-dialog-footer").element(); + const back = screen.getByRole("button", { name: "Back" }); - screen.getByRole("button", { name: "Back to library" }).element().click(); + expect(header).not.toContainElement(back.element()); + expect(footer).toContainElement(back.element()); + expect(screen.getByRole("button", { name: "Cancel" }).query()).toBeNull(); + back.element().click(); expect(onClose).toHaveBeenCalledTimes(1); expect(onExit).not.toHaveBeenCalled(); diff --git a/packages/admin/tests/components/MediaPickerModal.test.tsx b/packages/admin/tests/components/MediaPickerModal.test.tsx index 657f10f453..4059b8ff35 100644 --- a/packages/admin/tests/components/MediaPickerModal.test.tsx +++ b/packages/admin/tests/components/MediaPickerModal.test.tsx @@ -107,7 +107,7 @@ vi.mock("../../src/components/MediaDetailPanel", () => ({ onClosed?.(); }} > - Back to library + Back
{canEditPrimaryAsset && ( {canEditDarkAsset && ( {canEditAsset && ( {canEditAsset && (
-
+
{selectedFilename} From d06f30213c3f9164bc0d399707a7bb680d93f95f Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:00:58 +0100 Subject: [PATCH 15/20] Balance featured image card content --- .../src/components/ImageFieldRenderer.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx index e92aa4486c..39abe3d2ac 100644 --- a/packages/admin/src/components/ImageFieldRenderer.tsx +++ b/packages/admin/src/components/ImageFieldRenderer.tsx @@ -406,18 +406,23 @@ export function ImageFieldRenderer({ /> )}
-
-
- - {selectedFilename} - - {metadata && ( - - {metadata} +
+
+
+ + {selectedFilename} - )} + {metadata && ( + + {metadata} + + )} +
+ {primaryActions}
- {primaryActions}
) : null; From f55ad62f7ee545333af462cfe2acf8b01a7be72c Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:27:42 +0100 Subject: [PATCH 16/20] Fix image panel state synchronization --- .../src/components/ImageFieldRenderer.tsx | 4 +- .../components/editor/ImageDetailPanel.tsx | 20 +++- .../admin/src/components/editor/ImageNode.tsx | 10 +- .../components/ImageDetailPanel.test.tsx | 102 +++++++++++++++++- 4 files changed, 128 insertions(+), 8 deletions(-) diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx index 39abe3d2ac..d070e9301e 100644 --- a/packages/admin/src/components/ImageFieldRenderer.tsx +++ b/packages/admin/src/components/ImageFieldRenderer.tsx @@ -332,7 +332,7 @@ export function ImageFieldRenderer({ type="button" size="sm" variant="secondary" - icon={} + icon={