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..c0cd4f170e 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,14 +285,16 @@ the replacement. Replacing the file clears its focal point. A focal point keeps the important part of a local image visible when a card, gallery, or other layout crops it to fill a fixed shape. -1. Open **Media**, then select an image from the local library. +1. Open **Media** and select an image from the local library, or select **Edit asset** for a local + image in the content editor. 2. Select **Edit image**, then **Focal point**. 3. Click or drag the marker onto the important part of the image. You can also use the Arrow keys. 4. Check the square, landscape, and portrait previews, then select **Save**. Select **Reset** to remove a custom focal point. The saved point is copied when you select -the image for a content field or gallery. Content that already uses the image keeps its stored point -until you select the image again. +the image for a content field or gallery. Other content already using the image keeps its stored point +until you select the image again. When you edit an asset from a content field or gallery, that current +usage refreshes with the saved focal point. ## Cropping an image diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index 2e587d0b30..89c1d5c3c8 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -399,6 +399,156 @@ test.describe("Media Library", () => { ).toBe(true); }); + test("edits a selected content image and uses a cropped copy without leaving the editor", async ({ + admin, + page, + serverInfo, + }) => { + test.setTimeout(90_000); + const marker = Date.now(); + const filename = `editor-asset-${marker}.png`; + const duplicateFilename = `editor-asset-${marker}-square.png`; + await admin.goToMedia(); + await admin.waitForLoading(); + await uploadCropTestImage(page, filename); + const original = await findMediaByFilename(serverInfo, filename); + + await admin.goto("/content/posts/new"); + await admin.waitForShell(); + await admin.waitForLoading(); + const editorUrl = page.url(); + await page.getByRole("button", { name: "Select image" }).click(); + const picker = page.getByRole("dialog", { name: "Select Featured Image" }); + const workspaceElement = await picker.elementHandle(); + expect(workspaceElement).not.toBeNull(); + const workspaceWidth = await picker.evaluate((dialog) => dialog.clientWidth); + await picker.getByRole("searchbox", { name: "Search media" }).fill(filename); + await picker.getByRole("button", { name: filename, exact: true }).click(); + await picker.getByRole("button", { name: "Edit asset" }).click(); + const pickerDetails = page.getByRole("dialog", { name: "Media details" }); + await expect(pickerDetails).toBeVisible(); + expect(await pickerDetails.evaluate((dialog) => dialog.clientWidth)).toBe(workspaceWidth); + expect( + await pickerDetails.evaluate( + (dialog, originalDialog) => dialog === originalDialog, + workspaceElement, + ), + ).toBe(true); + await expect(page.getByRole("dialog")).toHaveCount(1); + await expect(pickerDetails.getByRole("searchbox", { name: "Search media" })).toHaveCount(0); + await pickerDetails.getByRole("button", { name: "Back" }).click(); + await expect(picker).toBeVisible(); + await expect(picker.getByRole("button", { name: "Edit asset" })).toBeFocused(); + expect( + await picker.evaluate( + (dialog, originalDialog) => dialog === originalDialog, + workspaceElement, + ), + ).toBe(true); + await expect(picker.getByRole("button", { name: filename, exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await picker.getByRole("button", { name: "Select", exact: true }).click(); + const featuredImageField = page.locator("#field-featured_image"); + await expect(featuredImageField.getByText(filename, { exact: true })).toBeVisible(); + + await featuredImageField.getByRole("button", { name: "Edit asset" }).click(); + const details = page.getByRole("dialog", { name: "Media details" }); + await expect(details).toBeVisible(); + await expect(picker).not.toBeVisible(); + await expect(details.getByRole("tab", { name: "Used in" })).toHaveCount(0); + await expect(details.getByRole("button", { name: "Delete" })).toHaveCount(0); + expect(page.url()).toBe(editorUrl); + + await details.getByRole("tab", { name: "Edit image" }).click(); + const aspectRatio = details.getByRole("combobox", { name: "Aspect ratio" }); + await aspectRatio.click(); + await page.getByRole("option", { name: "Square (1:1)" }).click(); + const duplicateResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname.endsWith("/confirm") && + response.status() === 200, + ); + await details.getByRole("button", { name: "Create cropped copy" }).click(); + await duplicateResponse; + + await expect(details).not.toBeVisible(); + await expect(featuredImageField.getByText(duplicateFilename, { exact: true })).toBeVisible(); + expect(page.url()).toBe(editorUrl); + const duplicate = await findMediaByFilename(serverInfo, duplicateFilename); + expect(duplicate.id).not.toBe(original.id); + }); + + test("keeps featured image actions compact and reachable on mobile", async ({ admin, page }) => { + test.setTimeout(60_000); + await admin.goto("/content/posts/new"); + await admin.waitForShell(); + await admin.waitForLoading(); + await page.setViewportSize({ width: 320, height: 800 }); + await page.getByRole("button", { name: "Select image" }).click(); + const picker = page.getByRole("dialog", { name: "Select Featured Image" }); + await picker.getByRole("button", { name: "test-image.png", exact: true }).click(); + await picker.getByRole("button", { name: "Select", exact: true }).click(); + + const featuredImageField = page.locator("#field-featured_image"); + await expect(featuredImageField.getByText("test-image.png", { exact: true })).toBeVisible(); + const featuredPreview = featuredImageField.locator(".emdash-featured-image-preview"); + const featuredCard = featuredPreview.locator(".."); + expect( + await featuredPreview.evaluate((element) => element.getBoundingClientRect().height), + ).toBeLessThan(80); + expect( + await featuredCard.evaluate((element) => element.getBoundingClientRect().height), + ).toBeLessThan(120); + const imageActions = featuredImageField.getByRole("button", { name: "Image actions" }); + await expect(imageActions).toBeVisible(); + await expect(featuredImageField.getByRole("button", { name: "Replace" })).toHaveCount(0); + await expect(featuredImageField.getByRole("button", { name: "Edit asset" })).toHaveCount(0); + await expect(featuredImageField.getByRole("button", { name: "Remove image" })).toHaveCount(0); + + await imageActions.click(); + await expect(imageActions).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByRole("menuitem", { name: "Replace" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Edit asset" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Remove" })).toBeVisible(); + await page.setViewportSize({ width: 640, height: 800 }); + await expect(page.getByRole("menu", { name: "Image actions" })).not.toBeVisible(); + await expect(featuredImageField.getByRole("button", { name: "Replace" })).toBeVisible(); + await page.setViewportSize({ width: 320, height: 800 }); + await expect(imageActions).toHaveAttribute("aria-expanded", "false"); + await imageActions.click(); + await page.getByRole("menuitem", { name: "Replace" }).click(); + const replacePicker = page.getByRole("dialog", { name: "Replace Featured Image" }); + await expect(replacePicker).toBeVisible(); + await replacePicker.getByRole("button", { name: "Close" }).click(); + + await imageActions.click(); + await page.getByRole("menuitem", { name: "Edit asset" }).click(); + const details = page.getByRole("dialog", { name: "Media details" }); + await expect(details).toBeVisible(); + await details.getByRole("button", { name: "Close" }).click(); + await expect(details).not.toBeVisible(); + await expect(imageActions).toBeFocused(); + await featuredPreview.locator("img").dispatchEvent("error"); + await expect(featuredPreview.getByText("Image not found")).toHaveCount(1); + expect( + await featuredCard.evaluate((element) => element.getBoundingClientRect().height), + ).toBeLessThan(120); + await expect(imageActions).toBeVisible(); + + await imageActions.click(); + await page.getByRole("menuitem", { name: "Remove" }).click(); + await expect(featuredImageField.getByRole("button", { name: "Select 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/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 36e4eb2271..274af8c6e9 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1858,19 +1858,19 @@ function FileFieldRenderer({

)} -
+
@@ -1896,7 +1896,8 @@ function FileFieldRenderer({ fieldId={fieldId} hideUrlInput mediaKind="file" - title={t`Select ${label}`} + title={normalized ? t`Replace ${label}` : t`Select ${label}`} + confirmLabel={normalized ? t`Replace` : undefined} /> {required && !normalized && (

{t`This field is required`}

diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx index 912d9e02a8..2292891b39 100644 --- a/packages/admin/src/components/ImageFieldRenderer.tsx +++ b/packages/admin/src/components/ImageFieldRenderer.tsx @@ -8,9 +8,17 @@ * sub-fields) can reuse the same picker without a circular import. */ -import { Button, Label, LayerCard, Text } from "@cloudflare/kumo"; +import { Button, DropdownMenu, Label, LayerCard, Text, Tooltip } 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, + DotsThree, + Moon, + PencilSimple, + X, +} from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; @@ -22,6 +30,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 +77,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; @@ -97,8 +128,13 @@ export function ImageFieldRenderer({ const { t } = useLingui(); const [pickerOpen, setPickerOpen] = React.useState(false); const [pickerTarget, setPickerTarget] = React.useState<"image" | "darkVariant">("image"); + const [mobileActionsOpen, setMobileActionsOpen] = React.useState(false); const [imageBroken, setImageBroken] = React.useState(false); const [darkImageBroken, setDarkImageBroken] = React.useState(false); + const mobileImageActionsRef = React.useRef(null); + 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 +144,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 +180,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(() => { @@ -147,36 +204,23 @@ export function ImageFieldRenderer({ setDarkImageBroken(false); }, [darkDisplayUrl]); + React.useEffect(() => { + if (variant !== "featured") return; + const desktop = window.matchMedia("(min-width: 640px)"); + const closeMobileActions = (event: MediaQueryListEvent) => { + if (event.matches) setMobileActionsOpen(false); + }; + desktop.addEventListener("change", closeMobileActions); + return () => desktop.removeEventListener("change", closeMobileActions); + }, [variant]); + const openPicker = (target: "image" | "darkVariant") => { setPickerTarget(target); setPickerOpen(true); }; 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 +258,113 @@ 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 = ( + + ); + const mobileFeaturedActions = ( + + + ); const darkVariantSlot = darkVariant && objectValue && displayUrl ? ( @@ -243,23 +394,41 @@ export function ImageFieldRenderer({ {darkFilename} -
+
+ {canEditDarkAsset && ( + + )} @@ -281,12 +451,15 @@ export function ImageFieldRenderer({ ) : null; const featuredCard = displayUrl ? ( - -
+ +
{imageBroken ? ( -
+
@@ -300,39 +473,22 @@ export function ImageFieldRenderer({ /> )}
-
-
- - {selectedFilename} - - {metadata && ( - - {metadata} +
+
+
+ + {selectedFilename} - )} -
-
- - + {metadata && ( + + {metadata} + + )} +
+ {primaryActions}
+
{mobileFeaturedActions}
) : null; @@ -353,34 +509,15 @@ export function ImageFieldRenderer({ featuredCard ) : displayUrl ? ( imageBroken ? ( -
+
{t`Image not found`}
-
- - -
+ {primaryActions}
) : ( -
+
setImageBroken(true)} /> -
- - -
+ {primaryActions}
) ) : ( @@ -434,10 +552,29 @@ export function ImageFieldRenderer({ fieldId={fieldId} title={ pickerTarget === "darkVariant" - ? t`Select dark mode variant for ${label}` - : t`Select ${label}` + ? darkDisplayUrl + ? t`Replace dark mode variant for ${label}` + : t`Select dark mode variant for ${label}` + : displayUrl + ? t`Replace ${label}` + : t`Select ${label}` + } + confirmLabel={ + pickerTarget === "darkVariant" + ? darkDisplayUrl + ? t`Replace` + : undefined + : displayUrl + ? t`Replace` + : undefined } /> + {assetEditor.dialog} + {assetEditor.error && ( +

+ {assetEditor.error} +

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

{t`This field is required`}

)} diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx index 7055eda37b..3095a97523 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,39 +139,110 @@ interface MediaLocationOption { export interface MediaDetailPanelProps { open: boolean; item: MediaItem; + embedded?: boolean; + context?: "library" | "content"; providerName?: string; canDelete?: boolean; canMoveLocation?: boolean; 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; - onCroppedCopyCreated?: () => void; + onCroppedCopyCreated?: (item: LocalMediaItem) => void; + onUnavailable?: (id: string) => void; 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, + context = "library", providerName, canDelete: canDeleteProp, canMoveLocation: canMoveLocationProp, canReplaceOriginal: canReplaceOriginalProp, canCropOriginal = false, canDuplicateCrop = false, + requestExitRef, restoreFocusTargetRef, onClose, + onExit, onClosed, onUpdated, onItemRefreshed, onCroppedCopyCreated, + onUnavailable, onDeleted, }: MediaDetailPanelProps) { const { t } = useLingui(); @@ -184,6 +256,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); @@ -199,10 +272,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( @@ -252,6 +325,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, ); @@ -269,6 +343,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); @@ -301,6 +376,7 @@ export function MediaDetailPanel({ setReplacementImage(null); setReplaceSelectionError(""); setReplaceStatus(""); + discardActionRef.current = "close"; setPendingUsageEntry(null); }, [item.id, localItem?.folderId, open]); @@ -383,14 +459,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 = @@ -665,7 +753,7 @@ export function MediaDetailPanel({ onSuccess: ({ action, item: croppedItem }) => { void queryClient.invalidateQueries({ queryKey: ["media"] }); if (action === "duplicate") { - onCroppedCopyCreated?.(); + onCroppedCopyCreated?.(croppedItem); onUpdated?.(); setCropAspectMode("original"); setCropSelection(undefined); @@ -686,9 +774,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; @@ -706,6 +795,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 = @@ -731,6 +825,7 @@ export function MediaDetailPanel({ const requestClose = React.useCallback(() => { if (isBusy) return; if (isConfirmOpen) return; + discardActionRef.current = "close"; setPendingUsageEntry(null); if (hasChanges) { setShowDiscardConfirm(true); @@ -738,6 +833,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; @@ -931,7 +1044,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", @@ -953,6 +1067,7 @@ export function MediaDetailPanel({ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); + discardActionRef.current = "close"; setPendingUsageEntry(entry); setShowDiscardConfirm(true); }; @@ -976,24 +1091,17 @@ export function MediaDetailPanel({ return ( <> - { - if (!nextOpen && !isConfirmOpen) requestClose(); - }} - onOpenChangeComplete={(nextOpen) => { - if (nextOpen) return; - finishClose(); - }} + isConfirmOpen={isConfirmOpen} + onRequestClose={requestClose} + onClosed={finishClose} > - +
@@ -1006,7 +1114,7 @@ export function MediaDetailPanel({ variant="ghost" shape="square" aria-label={t`Close`} - onClick={requestClose} + onClick={embedded ? requestExit : requestClose} disabled={isBusy} >
-
+ + { if (mimeTypeFilters !== undefined) { @@ -229,11 +249,18 @@ export function MediaPickerModal({ const [providerDimensions, setProviderDimensions] = React.useState< Record >({}); + 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 workspaceResizeAnimationRef = React.useRef(null); + const workspaceResizeTimerRef = 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 restoreEditFocusRef = React.useRef(false); const invalidateUrlProbe = React.useCallback(() => { urlProbeIdRef.current += 1; setIsProbing(false); @@ -260,6 +287,39 @@ export function MediaPickerModal({ ); const uploadQueue = useMediaUploadQueue({ upload: uploadFile }); + React.useEffect(() => { + if (open) return; + workspaceResizeAnimationRef.current?.cancel(); + workspaceResizeAnimationRef.current = null; + if (workspaceResizeTimerRef.current !== null) { + window.clearTimeout(workspaceResizeTimerRef.current); + workspaceResizeTimerRef.current = null; + } + const dialog = editAssetButtonRef.current?.closest('[role="dialog"]'); + if (dialog) { + dialog.style.height = ""; + dialog.style.maxHeight = ""; + } + restoreEditFocusRef.current = false; + setAssetOpen(false); + }, [open]); + + React.useEffect( + () => () => { + workspaceResizeAnimationRef.current?.cancel(); + if (workspaceResizeTimerRef.current !== null) { + window.clearTimeout(workspaceResizeTimerRef.current); + } + }, + [], + ); + + 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"); @@ -637,6 +697,160 @@ 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 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 transitionWorkspaceLayout = (nextAssetOpen: boolean) => { + const dialog = editAssetButtonRef.current?.closest('[role="dialog"]') ?? null; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!dialog || reduceMotion || typeof dialog.animate !== "function") { + setAssetOpen(nextAssetOpen); + return; + } + + const startHeight = dialog.getBoundingClientRect().height; + workspaceResizeAnimationRef.current?.cancel(); + workspaceResizeAnimationRef.current = null; + if (workspaceResizeTimerRef.current !== null) { + window.clearTimeout(workspaceResizeTimerRef.current); + workspaceResizeTimerRef.current = null; + } + dialog.style.height = `${startHeight}px`; + dialog.style.maxHeight = "none"; + flushSync(() => setAssetOpen(nextAssetOpen)); + dialog.style.maxHeight = "none"; + dialog.style.height = ""; + const naturalEndHeight = dialog.getBoundingClientRect().height; + const rootFontSize = + Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const detailMaxHeight = Math.min(window.innerHeight * 0.88, rootFontSize * 43.5); + const endHeight = nextAssetOpen + ? Math.min(naturalEndHeight, detailMaxHeight) + : naturalEndHeight; + const finalMaxHeight = nextAssetOpen ? "min(88dvh, 43.5rem)" : ""; + if (Math.abs(endHeight - startHeight) < 1) { + dialog.style.maxHeight = finalMaxHeight; + return; + } + + dialog.style.height = `${startHeight}px`; + const animation = dialog.animate( + [{ height: `${startHeight}px` }, { height: `${endHeight}px` }], + { + duration: WORKSPACE_RESIZE_DURATION_MS, + easing: WORKSPACE_RESIZE_EASING, + fill: "forwards", + }, + ); + workspaceResizeAnimationRef.current = animation; + const finish = () => { + if (workspaceResizeAnimationRef.current !== animation) return; + workspaceResizeAnimationRef.current = null; + if (workspaceResizeTimerRef.current !== null) { + window.clearTimeout(workspaceResizeTimerRef.current); + workspaceResizeTimerRef.current = null; + } + dialog.style.height = ""; + dialog.style.maxHeight = finalMaxHeight; + animation.cancel(); + }; + animation.addEventListener("finish", finish, { once: true }); + workspaceResizeTimerRef.current = window.setTimeout(finish, WORKSPACE_RESIZE_DURATION_MS + 100); + }; + const openAssetEditor = () => { + if (!editableSelection || uploadQueue.hasUnfinished) return; + setAssetItem(editableSelection); + transitionWorkspaceLayout(true); + }; + const closeAssetEditor = () => { + restoreEditFocusRef.current = true; + transitionWorkspaceLayout(false); + }; const confirmText = confirmLabel ?? (multiple @@ -738,40 +952,69 @@ export function MediaPickerModal({ ) : null; - return ( + const browseDialog = ( { - if (!nextOpen) handleClose(); + if (nextOpen) return; + if (assetOpen && mediaDetailPanelRef.current) { + mediaDetailPanelRef.current(); + return; + } + handleClose(); }} > -
-
- {title} - - {description} - -
- ( -
+ ) : null} + {!assetOpen ? ( +
+
+ {title} + + {description} + +
+ ( +
+ ) : null}
); + + return browseDialog; } export default MediaPickerModal; diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index ad189873ea..169fd7a554 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 ) => void; onReplace: (item: MediaItem) => void; + onAssetChange: (item: MediaItem) => void; + onAssetEditorActiveChange: (active: boolean) => void; + onRemove: () => void; } -function GalleryImageSettings({ image, onChange, onReplace }: GalleryImageSettingsProps) { +function GalleryImageSettings({ + image, + onChange, + onReplace, + onAssetChange, + onAssetEditorActiveChange, + onRemove, +}: GalleryImageSettingsProps) { const { t } = useLingui(); const [showReplacePicker, setShowReplacePicker] = React.useState(false); + const assetEditor = useMediaAssetEditor(onAssetChange); + React.useEffect(() => { + onAssetEditorActiveChange(assetEditor.isActive); + return () => onAssetEditorActiveChange(false); + }, [assetEditor.isActive, onAssetEditorActiveChange]); + const canEditAsset = + Boolean(image.asset._ref) && canonicalMediaProviderId(image.asset.provider) === "local"; const hasOriginalSize = typeof image.width === "number" && typeof image.height === "number"; return (
-
- {image.alt -
+
+ +
+
+ + {canEditAsset && ( -
+ )} +
{hasOriginalSize && (
@@ -399,8 +501,14 @@ function GalleryImageSettings({ image, onChange, onReplace }: GalleryImageSettin }} mimeTypeFilters={["image/"]} title={t`Replace image`} - confirmLabel={t`Replace image`} + confirmLabel={t`Replace`} /> + {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/src/components/editor/ImageDetailPanel.tsx b/packages/admin/src/components/editor/ImageDetailPanel.tsx index 95e5e44493..a927b8cbf2 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 { @@ -47,8 +49,13 @@ export interface ImageAttributes { alignment?: "left" | "center" | "right" | "wide" | "full"; } +export interface ImagePanelAttributes extends ImageAttributes { + /** Transient identity for the image node that opened the sidebar. */ + nodeKey?: object; +} + export interface ImageDetailPanelProps { - attributes: ImageAttributes; + attributes: ImagePanelAttributes; onUpdate: (attrs: Partial) => void; onReplace: (attrs: ImageAttributes) => void; onDelete: () => void; @@ -76,6 +83,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( @@ -88,10 +127,22 @@ export function ImageDetailPanel({ const [alignment, setAlignment] = React.useState( attributes.alignment, ); + const nodeKey = attributes.nodeKey; + + React.useEffect(() => { + setAlt(attributes.alt ?? ""); + setCaption(attributes.caption ?? ""); + setTitle(attributes.title ?? ""); + setAsset(attributes); + setDisplayWidth(attributes.displayWidth ?? attributes.width); + setDisplayHeight(attributes.displayHeight ?? attributes.height); + setLockAspectRatio(true); + setAlignment(attributes.alignment); + // eslint-disable-next-line react-hooks/exhaustive-deps -- the node token identifies a new attribute snapshot + }, [nodeKey]); // 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 +161,8 @@ export function ImageDetailPanel({ }; const handleResetDimensions = () => { - setDisplayWidth(attributes.width); - setDisplayHeight(attributes.height); + setDisplayWidth(asset.width); + setDisplayHeight(asset.height); }; const handleMediaSelect = (item: MediaItem) => { @@ -134,8 +185,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 +195,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 +229,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 +267,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 +283,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...`} @@ -214,8 +307,9 @@ export function ImageDetailPanel({ onSelect={handleMediaSelect} mimeTypeFilter="image/" title={t`Replace image`} - confirmLabel={t`Replace image`} + confirmLabel={t`Replace`} /> + {assetEditor.dialog} ); @@ -236,42 +330,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 +111,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 +147,316 @@ describe("ImageDetailPanel replacement", () => { />, ); - await screen.getByRole("button", { name: "Replace Image" }).click(); + await screen.getByRole("button", { name: "Replace" }).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: "Replace" })).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: "Replace" })).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), + }), + ); + }); + + it("resyncs the complete form when the sidebar switches image nodes", async () => { + const firstNode = {}; + const secondNode = {}; + const thirdNode = {}; + const onSecondUpdate = vi.fn(); + const onThirdUpdate = vi.fn(); + const panel = (attributes: ImagePanelAttributes, onUpdate = vi.fn()) => ( + + ); + const screen = await render( + panel({ + nodeKey: firstNode, + src: "/_emdash/api/media/file/first.jpg", + mediaId: "first-image", + provider: "local", + width: 1200, + height: 800, + alt: "First alt", + caption: "First caption", + title: "First title", + displayWidth: 600, + displayHeight: 400, + alignment: "wide", + }), + ); + + await screen.rerender( + panel( + { + nodeKey: secondNode, + src: "/_emdash/api/media/file/second.jpg", + mediaId: "second-image", + provider: "local", + width: 900, + height: 600, + alt: "Second alt", + caption: "Second caption", + title: "Second title", + displayWidth: 450, + displayHeight: 300, + alignment: "center", + }, + onSecondUpdate, + ), + ); + + await expect + .element(screen.getByRole("img", { name: "Second alt" })) + .toHaveAttribute("src", "/_emdash/api/media/file/second.jpg"); + await expect.element(screen.getByLabelText("Alt Text")).toHaveValue("Second alt"); + await expect.element(screen.getByLabelText("Caption")).toHaveValue("Second caption"); + await expect.element(screen.getByLabelText("Title (Tooltip)")).toHaveValue("Second title"); + await expect.element(screen.getByLabelText("Width")).toHaveValue(450); + await expect.element(screen.getByLabelText("Height")).toHaveValue(300); + + await screen.rerender( + panel( + { + nodeKey: thirdNode, + src: "/_emdash/api/media/file/second.jpg", + mediaId: "second-image", + provider: "local", + width: 900, + height: 600, + alt: "Third alt", + caption: "Third caption", + title: "Third title", + displayWidth: 300, + displayHeight: 200, + alignment: "full", + }, + onThirdUpdate, + ), + ); + + await expect.element(screen.getByLabelText("Alt Text")).toHaveValue("Third alt"); + await screen.getByLabelText("Alt Text").fill("Updated third alt"); + await screen.getByRole("button", { name: "Save" }).click(); + + expect(onSecondUpdate).not.toHaveBeenCalled(); + expect(onThirdUpdate).toHaveBeenCalledWith({ + alt: "Updated third alt", + caption: "Third caption", + title: "Third title", + displayWidth: 300, + displayHeight: 200, + alignment: "full", + }); + }); }); diff --git a/packages/admin/tests/components/ImageFieldRenderer.test.tsx b/packages/admin/tests/components/ImageFieldRenderer.test.tsx index 9e9f381712..b1149a274a 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: "Replace" }); + 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"); @@ -114,6 +190,7 @@ describe("ImageFieldRenderer", () => { const image = screen.container.querySelector("img"); expect(image).toHaveAttribute("src", "/_emdash/api/media/file/featured-image.jpg"); expect(image?.style.objectPosition).toBe("25% 75%"); + expect(getComputedStyle(image!.parentElement!).aspectRatio).toBe("16 / 9"); }); it("refreshes a featured local preview from the current media item", async () => { @@ -291,6 +368,29 @@ describe("ImageFieldRenderer", () => { expect(onChange.mock.calls[0]?.[0].previewUrl).toBeUndefined(); }); + it("does not offer asset editing in mobile actions for an external featured image", async () => { + const screen = await render( + , + ); + + await screen.getByRole("button", { name: "Image actions" }).click(); + + await expect.element(screen.getByRole("menuitem", { name: "Replace" })).toBeVisible(); + expect(screen.getByRole("menuitem", { name: "Edit asset" }).query()).toBeNull(); + await expect.element(screen.getByRole("menuitem", { name: "Remove" })).toBeVisible(); + }); + it("removes the featured image immediately", async () => { const onChange = vi.fn(); const screen = await render( @@ -323,6 +423,7 @@ 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: "Edit asset" })).toBeVisible(); await expect.element(screen.getByRole("button", { name: "Remove image" })).toBeVisible(); }); @@ -472,7 +573,7 @@ describe("ImageFieldRenderer", () => { />, ); - await screen.getByRole("button", { name: "Change", exact: true }).click(); + await screen.getByRole("button", { name: "Replace", exact: true }).click(); await screen.getByRole("button", { name: "Choose replacement" }).click(); expect(onChange).toHaveBeenCalledWith( @@ -488,4 +589,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(); + }); }); diff --git a/packages/admin/tests/components/MediaDetailPanel.test.tsx b/packages/admin/tests/components/MediaDetailPanel.test.tsx index e5f6a25986..7b312c661e 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,26 @@ function renderPanel(props: Partial> = {}) { + const defaultProps: React.ComponentProps = { + open: true, + item: makeImageItem(), + embedded: true, + 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(); @@ -950,13 +971,34 @@ 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.getByRole("textbox", { name: "Location" })) + .toHaveValue("Product photos"); + await expect.element(screen.getByRole("textbox", { name: "Location" })).toBeDisabled(); + }); + it("confirms and replaces the original while keeping the dialog open", async () => { const refreshed = makeLocalItem({ url: INTERNAL_TEST_IMAGE_URL, @@ -1061,6 +1103,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({ @@ -1625,13 +1694,9 @@ describe("MediaDetailPanel", () => { it("shows a read-only Location when the user cannot move the item", async () => { const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: false }); - await expect.element(screen.getByText("Location")).toBeInTheDocument(); - const currentLocation = screen.getByText("Product photos"); - await expect.element(currentLocation).toBeInTheDocument(); - expect(currentLocation.element()).toHaveAttribute("dir", "auto"); - expect( - currentLocation.element().parentElement?.querySelector('[data-testid="media-location-icon"]'), - ).not.toBeNull(); + const currentLocation = screen.getByRole("textbox", { name: "Location" }); + await expect.element(currentLocation).toHaveValue("Product photos"); + await expect.element(currentLocation).toBeDisabled(); expect(screen.getByRole("combobox", { name: "Location" }).query()).toBeNull(); expect(fetchMediaFolders).not.toHaveBeenCalled(); }); @@ -1731,7 +1796,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(); @@ -1746,6 +1816,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 () => { @@ -1874,6 +1945,42 @@ describe("MediaDetailPanel", () => { expect(onClose).toHaveBeenCalled(); }); + 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" }); + + 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(); + 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 e16b634833..4059b8ff35 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,120 @@ 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, + embedded, + onClose, + onExit, + onClosed, + onItemRefreshed, + onCroppedCopyCreated, + onUnavailable, + }: { + 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, +})); + function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -85,6 +206,7 @@ async function openUrlSource(screen: Awaited>) { describe("MediaPickerModal", () => { beforeEach(() => { vi.clearAllMocks(); + pickerTestState.currentUser = { id: "user-1", role: 40 }; }); afterEach(() => vi.unstubAllGlobals()); @@ -162,6 +284,150 @@ 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 workspace = screen.getByRole("dialog", { name: "Select image" }).element(); + 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("region", { name: "Asset details" }); + await expect.element(details).toBeVisible(); + expect(details.element()).toHaveAttribute("data-context", "content"); + expect(details.element()).toHaveAttribute("data-embedded", "true"); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(document.querySelector('[role="dialog"]')).toBe(workspace); + expect(screen.getByRole("button", { name: "Select" }).query()).toBeNull(); + + screen.getByRole("button", { name: "Back" }).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("closes the complete workspace from asset details without returning to browse", 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 expect.element(screen.getByRole("button", { name: "Close workspace" })).toBeVisible(); + + screen.getByRole("button", { name: "Close workspace" }).element().click(); + + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(screen.getByRole("button", { name: "photo.jpg" }).query()).toBeNull(); + }); + + 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: "Back" }).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(); + }); + + 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", () => { diff --git a/packages/admin/tests/gallery-detail-panel.test.tsx b/packages/admin/tests/gallery-detail-panel.test.tsx index 05e81b8de1..1d450bb7f0 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: "Replace" })).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", + }); + }); });