diff --git a/.changeset/pt-gallery-editing.md b/.changeset/pt-gallery-editing.md new file mode 100644 index 000000000..ead78582b --- /dev/null +++ b/.changeset/pt-gallery-editing.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Makes the Portable Text gallery block editable in the admin editor. Galleries imported from WordPress now load and stay editable instead of being invisible and lost on save, and you can insert new galleries from the toolbar or with /gallery: pick several images at once reorder them by drag and drop, set the column count, and click any image in the gallery to edit its alt text and caption or replace it. Multiple galleries per document are supported, and gallery blocks render on the public site as before. diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 8f72e5351..3c3e8230d 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -29,6 +29,8 @@ import { useDebouncedValue } from "../lib/hooks.js"; import { slugify } from "../lib/utils"; import type { CurrentUserInfo } from "./ContentEditor.js"; import { DocumentOutline } from "./editor/DocumentOutline"; +import { GalleryDetailPanel } from "./editor/GalleryDetailPanel"; +import type { GalleryAttributes } from "./editor/GalleryNode"; import { ImageDetailPanel } from "./editor/ImageDetailPanel"; import type { ImageAttributes } from "./editor/ImageDetailPanel"; import type { BlockSidebarPanel } from "./PortableTextEditor"; @@ -378,6 +380,16 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ inline /> + ) : blockSidebarPanel.type === "gallery" ? ( +
+ blockSidebarPanel.onUpdate(attrs)} + onDelete={onBlockSidebarDelete} + onClose={onBlockSidebarClose} + inline + /> +
) : null; } diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 49f52b59e..66f4b8bbf 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -66,6 +66,10 @@ export interface MediaPickerModalProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (item: MediaItem) => void; + /** Allow selecting several items at once; confirms through `onSelectMany`. */ + multiple?: boolean; + /** Called instead of `onSelect` when `multiple` is set. */ + onSelectMany?: (items: MediaItem[]) => void; /** Filter by mime type prefix, e.g. "image/" */ mimeTypeFilter?: string; title?: string; @@ -122,6 +126,8 @@ export function MediaPickerModal({ open, onOpenChange, onSelect, + multiple = false, + onSelectMany, mimeTypeFilter = "image/", mimeTypeFilters, fieldId, @@ -149,6 +155,8 @@ export function MediaPickerModal({ const EmptyStateIcon = isFileKind ? Paperclip : Image; const queryClient = useQueryClient(); const [selectedItem, setSelectedItem] = React.useState(null); + // Multi-select mode keeps items in click order — it becomes the gallery order. + const [selectedItems, setSelectedItems] = React.useState([]); const [activeProvider, setActiveProvider] = React.useState("local"); const [searchQuery, setSearchQuery] = React.useState(""); // Debounced for the local library's server-side filename search. @@ -173,6 +181,7 @@ export function MediaPickerModal({ React.useEffect(() => { if (open) { setSelectedItem(null); + setSelectedItems([]); setActiveProvider("local"); setSearchQuery(""); setImageUrl(""); @@ -257,7 +266,11 @@ export function MediaPickerModal({ mutationFn: (file: File) => uploadMedia(file, { fieldId }), onSuccess: (item) => { void queryClient.invalidateQueries({ queryKey: ["media"] }); - setSelectedItem({ providerId: "local", item }); + if (multiple) { + setSelectedItems((prev) => [...prev, { providerId: "local", item }]); + } else { + setSelectedItem({ providerId: "local", item }); + } setUploadError(null); }, onError: (err: Error) => { @@ -271,7 +284,11 @@ export function MediaPickerModal({ uploadToProvider(providerId, file), onSuccess: (item, { providerId }) => { void queryClient.invalidateQueries({ queryKey: ["provider-media", providerId] }); - setSelectedItem({ providerId, item }); + if (multiple) { + setSelectedItems((prev) => [...prev, { providerId, item }]); + } else { + setSelectedItem({ providerId, item }); + } setUploadError(null); }, onError: (err: Error) => { @@ -356,25 +373,51 @@ export function MediaPickerModal({ } }; + // When providerId is "local", item is always MediaItem; otherwise MediaProviderItem + const toMediaItem = (selected: SelectedMedia): MediaItem => { + if (selected.providerId === "local") { + return selected.item as MediaItem; + } + const providerItem = selected.item as MediaProviderItem; + const dims = providerDimensions[providerItem.id]; + const itemWithDims = dims + ? { + ...providerItem, + width: providerItem.width ?? dims.width, + height: providerItem.height ?? dims.height, + } + : providerItem; + return providerItemToMediaItem(selected.providerId, itemWithDims); + }; + + const isItemSelected = (providerId: string, id: string) => + multiple + ? selectedItems.some((s) => s.providerId === providerId && s.item.id === id) + : selectedItem?.providerId === providerId && selectedItem.item.id === id; + + const handleItemClick = (providerId: string, item: MediaItem | MediaProviderItem) => { + if (multiple) { + setSelectedItems((prev) => + prev.some((s) => s.providerId === providerId && s.item.id === item.id) + ? prev.filter((s) => !(s.providerId === providerId && s.item.id === item.id)) + : [...prev, { providerId, item }], + ); + } else { + setSelectedItem({ providerId, item }); + } + }; + const handleConfirm = () => { + if (multiple) { + if (selectedItems.length === 0) return; + onSelectMany?.(selectedItems.map(toMediaItem)); + onOpenChange(false); + setSelectedItems([]); + setImageUrl(""); + return; + } if (selectedItem) { - if (selectedItem.providerId === "local") { - // When providerId is "local", item is always MediaItem - onSelect(selectedItem.item as MediaItem); - } else { - // When providerId is not "local", item is always MediaProviderItem - const providerItem = selectedItem.item as MediaProviderItem; - const dims = providerDimensions[providerItem.id]; - const itemWithDims = dims - ? { - ...providerItem, - width: providerItem.width ?? dims.width, - height: providerItem.height ?? dims.height, - } - : providerItem; - const mediaItem = providerItemToMediaItem(selectedItem.providerId, itemWithDims); - onSelect(mediaItem); - } + onSelect(toMediaItem(selectedItem)); onOpenChange(false); setSelectedItem(null); setImageUrl(""); @@ -384,6 +427,7 @@ export function MediaPickerModal({ const handleClose = () => { onOpenChange(false); setSelectedItem(null); + setSelectedItems([]); setImageUrl(""); setUrlError(null); }; @@ -431,7 +475,11 @@ export function MediaPickerModal({ createdAt: new Date().toISOString(), }; - onSelect(externalItem); + if (multiple) { + onSelectMany?.([externalItem]); + } else { + onSelect(externalItem); + } onOpenChange(false); setImageUrl(""); } catch { @@ -544,6 +592,7 @@ export function MediaPickerModal({ onClick={() => { setActiveProvider(tab.id); setSelectedItem(null); + setSelectedItems([]); setSearchQuery(""); }} className={cn( @@ -665,11 +714,14 @@ export function MediaPickerModal({ setSelectedItem({ providerId: "local", item })} + selected={isItemSelected("local", item.id)} + onClick={() => handleItemClick("local", item)} onDoubleClick={() => { + // Multi-select: double-click is just a toggle, no instant insert + if (multiple) { + handleItemClick("local", item); + return; + } onSelect(item); onOpenChange(false); }} @@ -680,12 +732,13 @@ export function MediaPickerModal({ setSelectedItem({ providerId: activeProvider, item })} + selected={isItemSelected(activeProvider, item.id)} + onClick={() => handleItemClick(activeProvider, item)} onDoubleClick={() => { + if (multiple) { + handleItemClick(activeProvider, item); + return; + } // Merge loaded dimensions for double-click select const dims = providerDimensions[item.id]; const itemWithDims = dims @@ -728,21 +781,33 @@ export function MediaPickerModal({ {/* Footer */}
- {selectedItem && ( - - {t`Selected:`} {selectedItem.item.filename} - {selectedItem.providerId !== "local" && ( - - {t`(from ${providers?.find((p) => p.id === selectedItem.providerId)?.name})`} + {multiple + ? selectedItems.length > 0 && ( + + {plural(selectedItems.length, { + one: "# item selected", + other: "# items selected", + })} + + ) + : selectedItem && ( + + {t`Selected:`} {selectedItem.item.filename} + {selectedItem.providerId !== "local" && ( + + {t`(from ${providers?.find((p) => p.id === selectedItem.providerId)?.name})`} + + )} )} - - )}
-
diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 28b13a3f3..d5442b4b6 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -48,6 +48,7 @@ import { Quotes, Link as LinkIcon, Image as ImageIcon, + Images, ArrowUUpLeft, ArrowUUpRight, TextAlignLeft, @@ -94,6 +95,8 @@ import { CaretNext } from "./ArrowIcons.js"; import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField"; import { CodeBlockExtension } from "./editor/CodeBlockNode"; import { DragHandleWrapper } from "./editor/DragHandleWrapper"; +import { mediaItemToGalleryImage } from "./editor/GalleryDetailPanel"; +import { GalleryExtension, type GalleryImage } from "./editor/GalleryNode"; import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; import { ImageExtension } from "./editor/ImageNode"; import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension"; @@ -175,6 +178,41 @@ function generateKey(): string { return Math.random().toString(36).substring(2, 11); } +/** + * Normalize an untrusted gallery `images` value into well-formed entries. + * Mirrors `sanitizeGalleryImages` in core's content/converters (duplicated + * like the converters themselves — see note above). + */ +function sanitizeGalleryImages(value: unknown, withKeys = false): GalleryImage[] { + if (!Array.isArray(value)) return []; + const images: GalleryImage[] = []; + for (const entry of value as unknown[]) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; + const record = entry as Record; + const asset = record.asset; + if (typeof asset !== "object" || asset === null) continue; + const assetRecord = asset as Record; + const image: GalleryImage = { + _type: "image", + _key: attrStr(record._key) ?? (withKeys ? generateKey() : ""), + asset: { + _type: "reference", + _ref: typeof assetRecord._ref === "string" ? assetRecord._ref : "", + ...(attrStr(assetRecord.url) ? { url: attrStr(assetRecord.url) } : {}), + ...(attrStr(assetRecord.provider) ? { provider: attrStr(assetRecord.provider) } : {}), + }, + }; + if (attrStr(record.alt)) image.alt = attrStr(record.alt); + if (attrStr(record.caption)) image.caption = attrStr(record.caption); + if (typeof record.width === "number") image.width = record.width; + if (typeof record.height === "number") image.height = record.height; + if (attrStr(record.blurhash)) image.blurhash = attrStr(record.blurhash); + if (attrStr(record.dominantColor)) image.dominantColor = attrStr(record.dominantColor); + images.push(image); + } + return images; +} + // Helpers for safely extracting typed values from ProseMirror attrs (Record) const attrStr = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined); const attrNum = (v: unknown): number | undefined => (typeof v === "number" && v ? v : undefined); @@ -344,6 +382,16 @@ function convertPMNode(node: { style: "lineBreak", }; + case "gallery": { + const columns = node.attrs?.columns; + return { + _type: "gallery", + _key: generateKey(), + images: sanitizeGalleryImages(node.attrs?.images, true), + ...(typeof columns === "number" ? { columns } : {}), + }; + } + case "table": { const tableKey = generateKey(); const tableContent = (node.content || []) as Array<{ @@ -712,6 +760,31 @@ function convertPTBlock(block: PortableTextBlock): unknown { case "break": return { type: "horizontalRule" }; + case "gallery": { + const galleryBlock = block as { _type: "gallery"; _key: string; [key: string]: unknown }; + // A gallery without an images array is malformed — keep the visible + // placeholder rather than silently rendering an empty grid. + if (!Array.isArray(galleryBlock.images)) { + return { + type: "paragraph", + content: [ + { + type: "text", + text: `[Unknown block type: ${block._type}]`, + marks: [{ type: "code" }], + }, + ], + }; + } + return { + type: "gallery", + attrs: { + images: sanitizeGalleryImages(galleryBlock.images), + columns: typeof galleryBlock.columns === "number" ? galleryBlock.columns : undefined, + }, + }; + } + case "htmlBlock": { const htmlBlock = block as { _type: "htmlBlock"; _key: string; html?: string }; return { @@ -2130,6 +2203,9 @@ export function PortableTextEditor({ // Media picker state (for image insertion) const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); + // Multi-select media picker state (for gallery insertion) + const [galleryPickerOpen, setGalleryPickerOpen] = React.useState(false); + // Plugin block insertion/editing state const [pluginBlockModal, setPluginBlockModal] = React.useState(null); const [pluginBlockInitialValues, setPluginBlockInitialValues] = React.useState< @@ -2198,6 +2274,20 @@ export function PortableTextEditor({ }, }); + // Add gallery command + cmds.push({ + id: "gallery", + title: msg`Gallery`, + description: msg`Insert an image gallery`, + icon: Images, + aliases: ["gal", "photos", "grid"], + category: msg`Media`, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).run(); + setGalleryPickerOpen(true); + }, + }); + // Add section command cmds.push({ id: "section", @@ -2291,6 +2381,7 @@ export function PortableTextEditor({ }), CodeBlockExtension, HtmlBlockExtension, + GalleryExtension, ImageExtension, MarkdownLinkExtension, PluginBlockExtension, @@ -2413,17 +2504,24 @@ export function PortableTextEditor({ React.useEffect(() => { if (!editor) return; - const storage = (editor.storage as unknown as Record>).image; - if (!storage) return; - storage.onOpenBlockSidebar = (panel: BlockSidebarPanel) => { - onBlockSidebarOpenRef.current?.(panel); - }; - storage.onCloseBlockSidebar = () => { - onBlockSidebarCloseRef.current?.(); - }; + const editorStorage = editor.storage as unknown as Record>; + // Both node types share the same sidebar plumbing + const storages = [editorStorage.image, editorStorage.gallery].filter( + (storage): storage is Record => storage !== undefined, + ); + for (const storage of storages) { + storage.onOpenBlockSidebar = (panel: BlockSidebarPanel) => { + onBlockSidebarOpenRef.current?.(panel); + }; + storage.onCloseBlockSidebar = () => { + onBlockSidebarCloseRef.current?.(); + }; + } return () => { - storage.onOpenBlockSidebar = null; - storage.onCloseBlockSidebar = null; + for (const storage of storages) { + storage.onOpenBlockSidebar = null; + storage.onCloseBlockSidebar = null; + } }; }, [editor]); @@ -2453,6 +2551,21 @@ export function PortableTextEditor({ [editor], ); + // Handle gallery insertion from the multi-select media picker + const handleGallerySelect = React.useCallback( + (items: MediaItem[]) => { + if (editor && items.length > 0) { + editor + .chain() + .focus() + .setGallery({ images: items.map(mediaItemToGalleryImage), columns: 3 }) + .run(); + } + setGalleryPickerOpen(false); + }, + [editor], + ); + // Handle plugin block insertion or update const handlePluginBlockInsert = React.useCallback( (values: Record) => { @@ -2580,6 +2693,17 @@ export function PortableTextEditor({ title={t`Select Image`} /> + {/* Multi-select media picker for gallery insertion */} + {}} + onSelectMany={handleGallerySelect} + mimeTypeFilter="image/" + title={t`Select Gallery Images`} + /> + {/* Plugin block insertion/editing modal */} (null); @@ -2956,6 +3081,19 @@ function EditorToolbar({ [editor], ); + const handleGallerySelect = React.useCallback( + (items: MediaItem[]) => { + setGalleryPickerOpen(false); + if (items.length === 0) return; + editor + .chain() + .focus() + .setGallery({ images: items.map(mediaItemToGalleryImage), columns: 3 }) + .run(); + }, + [editor], + ); + // Keyboard navigation for toolbar (WAI-ARIA toolbar pattern) const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { const toolbar = toolbarRef.current; @@ -3205,6 +3343,9 @@ function EditorToolbar({ setMediaPickerOpen(true)} title={t`Insert Image`}> + setGalleryPickerOpen(true)} title={t`Insert Gallery`}> + editor @@ -3266,6 +3407,17 @@ function EditorToolbar({ mimeTypeFilter="image/" title={t`Select Image`} /> + + {/* Multi-select media picker for gallery insertion */} + {}} + onSelectMany={handleGallerySelect} + mimeTypeFilter="image/" + title={t`Select Gallery Images`} + /> ); } diff --git a/packages/admin/src/components/SectionEditor.tsx b/packages/admin/src/components/SectionEditor.tsx index c53880fd2..f474b1de2 100644 --- a/packages/admin/src/components/SectionEditor.tsx +++ b/packages/admin/src/components/SectionEditor.tsx @@ -13,6 +13,8 @@ import * as React from "react"; import { fetchSection, updateSection, type Section, type UpdateSectionInput } from "../lib/api"; import { slugify } from "../lib/utils"; import { ArrowPrev } from "./ArrowIcons.js"; +import { GalleryDetailPanel } from "./editor/GalleryDetailPanel"; +import type { GalleryAttributes } from "./editor/GalleryNode"; import { ImageDetailPanel, type ImageAttributes } from "./editor/ImageDetailPanel"; import { EditorHeader } from "./EditorHeader"; import { PortableTextEditor, type BlockSidebarPanel } from "./PortableTextEditor"; @@ -228,6 +230,17 @@ function SectionEditorForm({ section, isSaving, onSave }: SectionEditorFormProps onClose={handleBlockSidebarClose} inline /> + ) : blockSidebarPanel?.type === "gallery" ? ( + blockSidebarPanel.onUpdate(attrs)} + onDelete={() => { + blockSidebarPanel.onDelete(); + setBlockSidebarPanel(null); + }} + onClose={handleBlockSidebarClose} + inline + /> ) : ( <> {/* Metadata */} diff --git a/packages/admin/src/components/Widgets.tsx b/packages/admin/src/components/Widgets.tsx index 8024b7419..a2265e24d 100644 --- a/packages/admin/src/components/Widgets.tsx +++ b/packages/admin/src/components/Widgets.tsx @@ -58,6 +58,8 @@ import { getPluginBlocks } from "../lib/pluginBlocks"; import { CaretNext } from "./ArrowIcons.js"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { DialogError, getMutationError } from "./DialogError.js"; +import { GalleryDetailPanel } from "./editor/GalleryDetailPanel"; +import type { GalleryAttributes } from "./editor/GalleryNode"; import { ImageDetailPanel, type ImageAttributes } from "./editor/ImageDetailPanel"; import { PortableTextEditor, @@ -313,172 +315,185 @@ export function Widgets() { } return ( - -
-
-
-

{t`Widgets`}

-

{t`Manage content widgets in your widget areas`}

-
- { - setIsCreateAreaOpen(open); - if (!open) setCreateAreaError(null); - }} - > - ( - - )} - /> - -
- - {t`Create Widget Area`} - - ( - - )} - /> -
-
- - - - -
- - + )} + /> + +
+ + {t`Create Widget Area`} + + ( + + )} + />
- -
- -
- -
- {/* Available Widgets (draggable palette) */} -
-
-

{t`Available Widgets`}

-

{t`Drag widgets into an area to add them`}

-
- {BUILTIN_WIDGETS.map((item) => ( - + - ))} - {components.map((comp) => ( - + - ))} -
-
+ +
+ + +
+ +
+
- {/* Widget Areas (droppable + sortable) */} -
- {areas.length === 0 ? ( -
-

{t`No widget areas yet. Create one to get started.`}

+
+ {/* Available Widgets (draggable palette) */} +
+
+

{t`Available Widgets`}

+

{t`Drag widgets into an area to add them`}

+
+ {BUILTIN_WIDGETS.map((item) => ( + + ))} + {components.map((comp) => ( + + ))} +
- ) : ( - areas.map((area) => ( - - )) - )} +
+ + {/* Widget Areas (droppable + sortable) */} +
+ {areas.length === 0 ? ( +
+

{t`No widget areas yet. Create one to get started.`}

+
+ ) : ( + areas.map((area) => ( + + )) + )} +
-
- {/* Drag overlay — no drop animation for palette items (source stays in place). + {/* Drag overlay — no drop animation for palette items (source stays in place). Use ref because state is cleared in handleDragEnd before animation runs. */} - - {activePaletteLabel ? ( -
-
{activePaletteLabel}
-
- ) : activeWidget ? ( -
-
- - {activeWidget.title || t`Untitled Widget`} - ({activeWidget.type}) + + {activePaletteLabel ? ( +
+
{activePaletteLabel}
-
- ) : null} - + ) : activeWidget ? ( +
+
+ + {activeWidget.title || t`Untitled Widget`} + ({activeWidget.type}) +
+
+ ) : null} + - {/* A single block-sidebar panel for the whole page — ensures only one is ever + {/* A single block-sidebar panel for the whole page — ensures only one is ever open at a time, preventing stacked fixed overlays and duplicated window listeners. */} - {blockSidebarPanel?.type === "image" && ( - blockSidebarPanel.onUpdate(attrs)} + onReplace={(attrs) => + blockSidebarPanel.onReplace(attrs as unknown as Record) + } + onDelete={() => { + blockSidebarPanel.onDelete(); + setBlockSidebarPanel(null); + }} + onClose={handleBlockSidebarClose} + /> + )} + + {blockSidebarPanel?.type === "gallery" && ( + blockSidebarPanel.onUpdate(attrs)} - onReplace={(attrs) => - blockSidebarPanel.onReplace(attrs as unknown as Record) - } onDelete={() => { blockSidebarPanel.onDelete(); setBlockSidebarPanel(null); @@ -486,7 +501,7 @@ export function Widgets() { onClose={handleBlockSidebarClose} /> )} - + ); } diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx new file mode 100644 index 000000000..43dcb55da --- /dev/null +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -0,0 +1,400 @@ +/** + * Gallery Detail Panel for Editor + * + * Sidebar panel for editing a gallery block: add images (multi-select media + * picker), remove, drag-and-drop reorder, per-image alt/caption, and column + * count. Changes apply immediately via onUpdate (reordering is inherently + * live, so the whole panel follows suit instead of a save-button form). + */ + +import { Button, Input, Label, Select } from "@cloudflare/kumo"; +import { DndContext, 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 { CSS } from "@dnd-kit/utilities"; +import { useLingui } from "@lingui/react/macro"; +import { X, Plus, Trash, ImageSquare } from "@phosphor-icons/react"; +import * as React from "react"; + +import type { MediaItem } from "../../lib/api"; +import { metaString } from "../../lib/media-utils"; +import { cn } from "../../lib/utils"; +import { MediaPickerModal } from "../MediaPickerModal"; +import { galleryImageUrl, type GalleryAttributes, type GalleryImage } from "./GalleryNode"; + +export interface GalleryDetailPanelProps { + attributes: GalleryAttributes; + onUpdate: (attrs: Partial) => void; + onDelete: () => void; + onClose: () => void; + /** When true, renders inline within the sidebar column instead of as a fixed overlay */ + inline?: boolean; +} + +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 { + return { + _type: "image", + _key: generateKey(), + 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, + // 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"), + }; +} + +export function GalleryDetailPanel({ + attributes, + onUpdate, + onDelete, + onClose, + inline = false, +}: GalleryDetailPanelProps) { + const { t } = useLingui(); + // 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 [showMediaPicker, setShowMediaPicker] = 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. + const selectedImageKey = (attributes as GalleryAttributes & { selectedImageKey?: string }) + .selectedImageKey; + const nodeKey = (attributes as GalleryAttributes & { nodeKey?: string }).nodeKey; + const [selectedKey, setSelectedKey] = React.useState(selectedImageKey ?? null); + + // The panel component instance is reused (not remounted) while the + // sidebar stays open, so clicking a different image in the canvas grid + // must update the selection even though `selectedKey` state already exists. + React.useEffect(() => { + if (selectedImageKey != null) { + setSelectedKey(selectedImageKey); + } + }, [selectedImageKey]); + + // `attributes` is a snapshot taken when the sidebar opened; it does not + // refresh after onUpdate. Local state is the live source of truth while + // the panel is open so sequential edits (caption, then reorder) compose + // instead of the later edit clobbering the earlier one. + const [gallery, setGallery] = React.useState({ + images: attributes.images ?? [], + columns: attributes.columns, + }); + + // Resync local state only when `nodeKey` changes — i.e. the sidebar switched + // to a DIFFERENT gallery node (or opened for the first time). `attributes` + // is a snapshot taken when the sidebar opened; a parent re-render can give + // it a new object identity without the underlying node changing (e.g. a + // parent re-wrapping attrs), and resyncing on identity alone would clobber + // in-progress local edits with that stale snapshot. + React.useEffect(() => { + setGallery({ images: attributes.images ?? [], columns: attributes.columns }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on nodeKey by design; see comment above + }, [nodeKey]); + const images = gallery.images; + const columns = gallery.columns ?? 3; + const selectedImage = selectedKey + ? (images.find((image) => image._key === selectedKey) ?? null) + : null; + + const apply = (patch: Partial) => { + setGallery((prev) => ({ ...prev, ...patch })); + onUpdate(patch); + }; + + const handleAdd = (items: MediaItem[]) => { + apply({ images: [...images, ...items.map(mediaItemToGalleryImage)] }); + }; + + const handleRemove = (key: string) => { + apply({ images: images.filter((image) => image._key !== key) }); + setSelectedKey((prev) => (prev === key ? null : prev)); + }; + + const handleImageChange = (key: string, patch: Partial) => { + apply({ + images: images.map((image) => (image._key === key ? { ...image, ...patch } : image)), + }); + }; + + const handleReplace = (key: string, item: MediaItem) => { + // Keep the slot (key, caption) — swap the asset and its intrinsic data + 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, + blurhash: item.blurhash ?? metaString(item.meta, "blurhash"), + dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"), + } + : image, + ), + }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = images.findIndex((image) => image._key === active.id); + const newIndex = images.findIndex((image) => image._key === over.id); + if (oldIndex === -1 || newIndex === -1) return; + apply({ images: arrayMove(images, oldIndex, newIndex) }); + }; + + const body = ( +
+
+

{t`Gallery`}

+ +
+ + onChange({ alt: e.target.value })} + placeholder={t`Describe the image...`} + /> + onChange({ caption: e.target.value || undefined })} + placeholder={t`Optional caption`} + /> + + { + onReplace(item); + setShowReplacePicker(false); + }} + mimeTypeFilters={["image/"]} + title={t`Replace image`} + /> +
+ ); +} diff --git a/packages/admin/src/components/editor/GalleryNode.tsx b/packages/admin/src/components/editor/GalleryNode.tsx new file mode 100644 index 000000000..102ccb967 --- /dev/null +++ b/packages/admin/src/components/editor/GalleryNode.tsx @@ -0,0 +1,284 @@ +/** + * Gallery Node for TipTap + * + * Node view for the Portable Text `gallery` block (grid of images with + * optional per-image captions and a column count). Provides a WYSIWYG grid + * preview, selection state, and a settings button that opens the gallery + * detail panel in the content sidebar (same wiring as ImageNode). + */ + +import { Button } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { Images, Trash, SlidersHorizontal } from "@phosphor-icons/react"; +import type { NodeViewProps } from "@tiptap/react"; +import { Node } from "@tiptap/react"; +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +/** One image inside a gallery block — mirrors the Portable Text shape. */ +export interface GalleryImage { + _type: "image"; + _key: string; + asset: { + _type?: "reference"; + _ref: string; + url?: string; + /** Provider ID for external media (e.g., "cloudflare-images") */ + provider?: string; + }; + alt?: string; + caption?: string; + width?: number; + height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; +} + +export interface GalleryAttributes { + images: GalleryImage[]; + columns?: number; +} + +/** Panel descriptor passed to the block sidebar (see BlockSidebarPanel). */ +export interface GallerySidebarPanel { + type: "gallery"; + /** + * `selectedImageKey` is transient UI state (which image's settings card + * is open) and `nodeKey` identifies which gallery node this panel + * instance belongs to (from `getPos()`) — both must never be written + * into node attrs via `updateAttributes`. + */ + attrs: GalleryAttributes & { selectedImageKey?: string; nodeKey?: string }; + onUpdate: (attrs: Partial) => void; + onReplace: (attrs: GalleryAttributes) => void; + onDelete: () => void; + onClose: () => void; +} + +declare module "@tiptap/react" { + interface Commands { + gallery: { + setGallery: (options: GalleryAttributes) => ReturnType; + }; + } +} + +/** Resolve the admin preview URL for a gallery image. */ +export function galleryImageUrl(image: GalleryImage): string { + if (image.asset.url) return image.asset.url; + if (image.asset._ref) return `/_emdash/api/media/file/${encodeURIComponent(image.asset._ref)}`; + return ""; +} + +function GalleryNodeView({ + node, + updateAttributes, + selected, + deleteNode, + editor, + getPos, +}: NodeViewProps) { + const { t } = useLingui(); + const sidebarOpenRef = React.useRef(false); + + const images = (node.attrs.images ?? []) as GalleryImage[]; + const columns = typeof node.attrs.columns === "number" ? node.attrs.columns : 3; + + const getAttrs = (): GalleryAttributes => ({ + images: (node.attrs.images ?? []) as GalleryImage[], + columns: typeof node.attrs.columns === "number" ? node.attrs.columns : undefined, + }); + + const openSidebar = (selectedImageKey?: string) => { + const storage = (editor.storage as unknown as Record>).gallery; + const onOpen = storage?.onOpenBlockSidebar as ((panel: GallerySidebarPanel) => void) | null; + if (onOpen) { + sidebarOpenRef.current = true; + onOpen({ + type: "gallery", + // `nodeKey` is transient UI state identifying which gallery node this + // panel instance is for — used by GalleryDetailPanel to key its resync + // effect on node identity rather than attrs object identity. Never + // written to node attrs via `updateAttributes`. + attrs: { ...getAttrs(), selectedImageKey, nodeKey: String(getPos?.() ?? "") }, + onUpdate: (attrs) => updateAttributes(attrs), + onReplace: (attrs) => updateAttributes(attrs), + onDelete: () => deleteNode(), + onClose: () => { + sidebarOpenRef.current = false; + }, + }); + } + }; + + const closeSidebar = () => { + if (!sidebarOpenRef.current) return; + const storage = (editor.storage as unknown as Record>).gallery; + const onClose = storage?.onCloseBlockSidebar as (() => void) | null; + if (onClose) { + onClose(); + sidebarOpenRef.current = false; + } + }; + + const toggleSidebar = () => { + if (sidebarOpenRef.current) { + closeSidebar(); + } else { + openSidebar(); + } + }; + + // Close sidebar when this node is deselected + React.useEffect(() => { + if (!selected) { + closeSidebar(); + } + }, [selected]); + + return ( + + {images.length === 0 ? ( + + ) : ( +
+ {images.map((image, index) => ( +
+ + {image.caption && ( +
+ {image.caption} +
+ )} +
+ ))} +
+ )} + + {/* Selection overlay with actions */} + {selected && ( +
+ + +
+ )} +
+ ); +} + +export const GalleryExtension = Node.create({ + name: "gallery", + + group: "block", + + atom: true, + + draggable: true, + + addStorage() { + return { + /** Callback set by PortableTextEditor to open gallery settings in the content sidebar */ + onOpenBlockSidebar: null as ((panel: GallerySidebarPanel) => void) | null, + /** Callback set by PortableTextEditor to close the sidebar */ + onCloseBlockSidebar: null as (() => void) | null, + }; + }, + + addAttributes() { + return { + images: { + default: [], + }, + columns: { + default: null, + }, + }; + }, + + parseHTML() { + return [{ tag: 'div[data-type="gallery"]' }]; + }, + + renderHTML() { + return ["div", { "data-type": "gallery" }]; + }, + + addNodeView() { + return ReactNodeViewRenderer(GalleryNodeView); + }, + + addCommands() { + return { + setGallery: + (options: GalleryAttributes) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ commands }: any) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, + }; + }, +}); diff --git a/packages/admin/tests/gallery-detail-panel.test.tsx b/packages/admin/tests/gallery-detail-panel.test.tsx new file mode 100644 index 000000000..05e81b8de --- /dev/null +++ b/packages/admin/tests/gallery-detail-panel.test.tsx @@ -0,0 +1,227 @@ +/** + * GalleryDetailPanel tests: thumbnail selection, caption editing, removal, + * and the `nodeKey`-keyed resync effect (code-review finding #4 — the panel + * must not clobber in-progress local edits when a parent re-renders with a + * new `attributes` object identity for the SAME gallery node, but must reset + * when the sidebar switches to a DIFFERENT gallery node). + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + GalleryDetailPanel, + type GalleryDetailPanelProps, +} from "../src/components/editor/GalleryDetailPanel"; +import type { GalleryAttributes, GalleryImage } from "../src/components/editor/GalleryNode"; +import { render } from "./utils/render.tsx"; + +vi.mock("../src/lib/api", async () => { + const actual = await vi.importActual("../src/lib/api"); + return { + ...actual, + fetchMediaList: vi.fn().mockResolvedValue({ items: [] }), + fetchMediaProviders: vi.fn().mockResolvedValue([]), + fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }), + uploadMedia: vi.fn().mockResolvedValue({}), + uploadToProvider: vi.fn().mockResolvedValue({}), + updateMedia: vi.fn().mockResolvedValue({}), + }; +}); + +function QueryWrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {children}; +} + +function makeImage(overrides: Partial = {}): GalleryImage { + return { + _type: "image", + _key: "img1", + asset: { _type: "reference", _ref: "m1", url: "/media/m1.jpg" }, + alt: "Image One", + ...overrides, + }; +} + +function threeImages(): GalleryImage[] { + return [ + makeImage({ + _key: "img1", + asset: { _type: "reference", _ref: "m1", url: "/media/m1.jpg" }, + alt: "Image One", + }), + makeImage({ + _key: "img2", + asset: { _type: "reference", _ref: "m2", url: "/media/m2.jpg" }, + alt: "Image Two", + }), + makeImage({ + _key: "img3", + asset: { _type: "reference", _ref: "m3", url: "/media/m3.jpg" }, + alt: "Image Three", + }), + ]; +} + +type PanelAttrs = GalleryAttributes & { selectedImageKey?: string; nodeKey?: string }; + +function renderPanel(props: Partial & { attributes: PanelAttrs }) { + const defaultProps: GalleryDetailPanelProps = { + attributes: props.attributes, + onUpdate: vi.fn(), + onDelete: vi.fn(), + onClose: vi.fn(), + inline: true, + }; + return render( + + + , + ); +} + +describe("GalleryDetailPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clicking a thumbnail shows that image's settings card with matching alt value", async () => { + const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" }; + const screen = await renderPanel({ attributes }); + + // No settings card until a thumbnail is selected. + expect(screen.getByLabelText("Alt text").query()).toBeNull(); + + const thumb = screen.getByRole("img", { name: "Image Two" }); + await expect.element(thumb).toBeInTheDocument(); + thumb.element().closest("button")!.click(); + + const altInput = screen.getByLabelText("Alt text"); + await expect.element(altInput).toBeInTheDocument(); + await expect.element(altInput).toHaveValue("Image Two"); + }); + + it("editing caption calls onUpdate with the patched images array", async () => { + const onUpdate = vi.fn(); + const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" }; + const screen = await renderPanel({ attributes, onUpdate }); + + const thumb = screen.getByRole("img", { name: "Image One" }); + await expect.element(thumb).toBeInTheDocument(); + thumb.element().closest("button")!.click(); + + const captionInput = screen.getByLabelText("Caption"); + await expect.element(captionInput).toBeInTheDocument(); + const inputEl = captionInput.element() as HTMLInputElement; + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + nativeInputValueSetter.call(inputEl, "New caption"); + inputEl.dispatchEvent(new Event("input", { bubbles: true })); + inputEl.dispatchEvent(new Event("change", { bubbles: true })); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalled(); + }); + const lastCall = onUpdate.mock.calls.at(-1)![0] as Partial; + const patchedImages = lastCall.images!; + expect(patchedImages.find((img) => img._key === "img1")?.caption).toBe("New caption"); + // Other images are untouched. + expect(patchedImages.find((img) => img._key === "img2")?.caption).toBeUndefined(); + }); + + it("remove button removes only that image", async () => { + const onUpdate = vi.fn(); + const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" }; + const screen = await renderPanel({ attributes, onUpdate }); + + const removeBtn = screen.getByRole("button", { name: "Remove image 2" }); + await expect.element(removeBtn).toBeInTheDocument(); + removeBtn.element().click(); + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalled(); + }); + const lastCall = onUpdate.mock.calls.at(-1)![0] as Partial; + const remaining = lastCall.images!; + expect(remaining).toHaveLength(2); + expect(remaining.map((img) => img._key)).toEqual(["img1", "img3"]); + }); + + it("does not clobber local edits when re-rendered with the same nodeKey but a new attrs identity", async () => { + const onUpdate = vi.fn(); + const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" }; + const screen = await renderPanel({ attributes, onUpdate }); + + // Local edit: remove the second image. + const removeBtn = screen.getByRole("button", { name: "Remove image 2" }); + await expect.element(removeBtn).toBeInTheDocument(); + removeBtn.element().click(); + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1)); + + // Re-render with a brand-new `attributes` object (new identity) but the + // SAME nodeKey and the ORIGINAL (stale, unedited) images — simulating a + // parent re-wrapping attrs without the sidebar having switched nodes. + const staleAttrs: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" }; + await screen.rerender( + + + , + ); + + // The removed image must still be gone — local state was not clobbered. + expect(screen.getByRole("img", { name: "Image Two" }).query()).toBeNull(); + await expect.element(screen.getByRole("img", { name: "Image One" })).toBeInTheDocument(); + await expect.element(screen.getByRole("img", { name: "Image Three" })).toBeInTheDocument(); + }); + + it("resets local state when re-rendered with a different nodeKey", async () => { + const onUpdate = vi.fn(); + const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" }; + const screen = await renderPanel({ attributes, onUpdate }); + + // Local edit: remove the second image. + const removeBtn = screen.getByRole("button", { name: "Remove image 2" }); + await expect.element(removeBtn).toBeInTheDocument(); + removeBtn.element().click(); + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1)); + expect(screen.getByRole("img", { name: "Image Two" }).query()).toBeNull(); + + // Re-render for a DIFFERENT gallery node (different nodeKey, fresh images). + const otherNodeImages: GalleryImage[] = [ + makeImage({ + _key: "other1", + asset: { _type: "reference", _ref: "o1", url: "/media/o1.jpg" }, + alt: "Other Image", + }), + ]; + const otherAttrs: PanelAttrs = { images: otherNodeImages, columns: 2, nodeKey: "20" }; + await screen.rerender( + + + , + ); + + // The panel now reflects the new node's images — the previous node's + // edited state is gone, replaced with the new node's snapshot. + await expect.element(screen.getByRole("img", { name: "Other Image" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Image One" }).query()).toBeNull(); + expect(screen.getByRole("img", { name: "Image Three" }).query()).toBeNull(); + }); +}); diff --git a/packages/admin/tests/media-picker-multiselect.test.tsx b/packages/admin/tests/media-picker-multiselect.test.tsx new file mode 100644 index 000000000..ce7da5abf --- /dev/null +++ b/packages/admin/tests/media-picker-multiselect.test.tsx @@ -0,0 +1,214 @@ +/** + * Multi-select behavior of MediaPickerModal (`multiple` + `onSelectMany`), + * used by the gallery block's "Add Images" flow. The existing + * `tests/components/MediaPickerModal.test.tsx` suite covers single-select — + * this file guards the multi-select accumulation/toggle/order semantics + * added for galleries (#1436), plus a regression check that single-select + * mode still works. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { MediaPickerModal } from "../src/components/MediaPickerModal"; +import { render } from "./utils/render.tsx"; + +vi.mock("../src/lib/api", async () => { + const actual = await vi.importActual("../src/lib/api"); + return { + ...actual, + fetchMediaList: vi.fn().mockResolvedValue({ + items: [ + { + id: "m1", + filename: "photo.jpg", + mimeType: "image/jpeg", + url: "/media/photo.jpg", + size: 1024, + width: 800, + height: 600, + createdAt: "2024-01-01", + }, + { + id: "m2", + filename: "landscape.png", + mimeType: "image/png", + url: "/media/landscape.png", + size: 2048, + width: 1200, + height: 800, + createdAt: "2024-01-02", + }, + { + id: "m3", + filename: "portrait.png", + mimeType: "image/png", + url: "/media/portrait.png", + size: 3072, + width: 600, + height: 900, + createdAt: "2024-01-03", + }, + ], + }), + fetchMediaProviders: vi.fn().mockResolvedValue([]), + fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }), + uploadMedia: vi.fn().mockResolvedValue({ id: "m4", filename: "new.jpg" }), + uploadToProvider: vi.fn().mockResolvedValue({}), + updateMedia: vi.fn().mockResolvedValue({}), + }; +}); + +function QueryWrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {children}; +} + +function renderModal(props: Partial> = {}) { + const defaultProps: React.ComponentProps = { + open: true, + onOpenChange: vi.fn(), + onSelect: vi.fn(), + ...props, + }; + return render( + + + , + ); +} + +function optionButton(screen: Awaited>, name: string) { + const option = screen.getByRole("option", { name }); + return option.element().querySelector("button")!; +} + +function footerInsertButton(): HTMLButtonElement { + const allInsertBtns = [...document.querySelectorAll("button")]; + const insertBtns = allInsertBtns.filter((b) => b.textContent?.trim() === "Insert"); + return insertBtns.at(-1) as HTMLButtonElement; +} + +describe("MediaPickerModal — multi-select", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("accumulates clicked items in click order", async () => { + const onSelectMany = vi.fn(); + const screen = await renderModal({ multiple: true, onSelectMany }); + + await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument(); + + // Click landscape, then photo, then portrait — order should be preserved + // as the gallery insertion order, not the grid's display order. + optionButton(screen, "landscape.png").click(); + await expect + .element(screen.getByRole("option", { name: "landscape.png" })) + .toHaveAttribute("aria-selected", "true"); + + optionButton(screen, "photo.jpg").click(); + await expect + .element(screen.getByRole("option", { name: "photo.jpg" })) + .toHaveAttribute("aria-selected", "true"); + + optionButton(screen, "portrait.png").click(); + await expect + .element(screen.getByRole("option", { name: "portrait.png" })) + .toHaveAttribute("aria-selected", "true"); + + await vi.waitFor(() => { + expect(footerInsertButton().disabled).toBe(false); + }); + footerInsertButton().click(); + + expect(onSelectMany).toHaveBeenCalledTimes(1); + const selected = onSelectMany.mock.calls[0]![0] as Array<{ filename: string }>; + expect(selected.map((item) => item.filename)).toEqual([ + "landscape.png", + "photo.jpg", + "portrait.png", + ]); + }); + + it("clicking a selected item deselects it", async () => { + const onSelectMany = vi.fn(); + const screen = await renderModal({ multiple: true, onSelectMany }); + + await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument(); + + optionButton(screen, "photo.jpg").click(); + await expect + .element(screen.getByRole("option", { name: "photo.jpg" })) + .toHaveAttribute("aria-selected", "true"); + + optionButton(screen, "landscape.png").click(); + await expect + .element(screen.getByRole("option", { name: "landscape.png" })) + .toHaveAttribute("aria-selected", "true"); + + // Deselect the first click + optionButton(screen, "photo.jpg").click(); + await expect + .element(screen.getByRole("option", { name: "photo.jpg" })) + .toHaveAttribute("aria-selected", "false"); + + await vi.waitFor(() => { + expect(footerInsertButton().disabled).toBe(false); + }); + footerInsertButton().click(); + + expect(onSelectMany).toHaveBeenCalledTimes(1); + const selected = onSelectMany.mock.calls[0]![0] as Array<{ filename: string }>; + expect(selected.map((item) => item.filename)).toEqual(["landscape.png"]); + }); + + it("Insert button is disabled at zero selections", async () => { + await renderModal({ multiple: true }); + + await vi.waitFor(() => { + expect(footerInsertButton().disabled).toBe(true); + }); + }); + + it("Insert button becomes enabled after one selection and disabled again after deselecting it", async () => { + const screen = await renderModal({ multiple: true }); + await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument(); + + optionButton(screen, "photo.jpg").click(); + await vi.waitFor(() => { + expect(footerInsertButton().disabled).toBe(false); + }); + + optionButton(screen, "photo.jpg").click(); + await vi.waitFor(() => { + expect(footerInsertButton().disabled).toBe(true); + }); + }); + + it("regression: single-select mode still calls onSelect with one item", async () => { + const onSelect = vi.fn(); + const onSelectMany = vi.fn(); + const screen = await renderModal({ onSelect, onSelectMany }); + + await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument(); + + optionButton(screen, "photo.jpg").click(); + await expect + .element(screen.getByRole("option", { name: "photo.jpg" })) + .toHaveAttribute("aria-selected", "true"); + + await vi.waitFor(() => { + expect(footerInsertButton().disabled).toBe(false); + }); + footerInsertButton().click(); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "m1", filename: "photo.jpg" }), + ); + expect(onSelectMany).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/admin/tests/pt-gallery-converters.test.ts b/packages/admin/tests/pt-gallery-converters.test.ts new file mode 100644 index 000000000..f6bd27678 --- /dev/null +++ b/packages/admin/tests/pt-gallery-converters.test.ts @@ -0,0 +1,85 @@ +/** + * Guards against the core (`packages/core/src/content/converters`) and admin + * (`PortableTextEditor.tsx`, duplicated for editor-only reasons) gallery + * converter pair drifting apart. Round-trips a WordPress-shaped gallery + * block — the exact shape emitted by `@emdash-cms/gutenberg-to-portable-text` + * after the import media pass — through the admin's PT → PM → PT converters. + */ +import { describe, it, expect } from "vitest"; + +import { + _portableTextToProsemirror, + _prosemirrorToPortableText, +} from "../src/components/PortableTextEditor"; + +describe("Gallery conversion (admin converters): PortableText ↔ ProseMirror", () => { + it("round-trips a WordPress-shaped gallery block without loss", () => { + const imported = [ + { + _type: "gallery", + _key: "wpgal1", + images: [ + { + _type: "image", + _key: "wpimg1", + asset: { + _type: "reference", + _ref: "01ABC", + url: "/_emdash/api/media/file/01ABC.jpg", + provider: "cloudflare-images", + }, + alt: "Beach", + caption: "Summer 2019", + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#336699", + }, + { + _type: "image", + _key: "wpimg2", + asset: { _type: "reference", _ref: "42", url: "https://old-site.com/photo.jpg" }, + }, + ], + columns: 3, + }, + ]; + + const pm = _portableTextToProsemirror(imported); + const galleryNode = pm.content[0] as { type: string; attrs?: Record }; + expect(galleryNode.type).toBe("gallery"); + expect(galleryNode.attrs?.images).toHaveLength(2); + + const pt = _prosemirrorToPortableText(pm); + const restored = pt[0] as { + _type: string; + images: Array>; + columns?: number; + }; + + expect(restored._type).toBe("gallery"); + expect(restored.columns).toBe(3); + expect(restored.images).toHaveLength(2); + + const [first, second] = restored.images; + expect(first).toMatchObject({ + _type: "image", + asset: { + _type: "reference", + _ref: "01ABC", + url: "/_emdash/api/media/file/01ABC.jpg", + provider: "cloudflare-images", + }, + alt: "Beach", + caption: "Summer 2019", + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#336699", + }); + expect(second).toMatchObject({ + _type: "image", + asset: { _type: "reference", _ref: "42", url: "https://old-site.com/photo.jpg" }, + }); + // Local (non-external) provider must never round-trip as a literal + // "local" string on asset.provider — it's omitted, matching the image + // block and image-field paths. + expect(second?.asset).not.toHaveProperty("provider"); + }); +}); diff --git a/packages/core/src/components/Gallery.astro b/packages/core/src/components/Gallery.astro index c020ffc04..184179cbf 100644 --- a/packages/core/src/components/Gallery.astro +++ b/packages/core/src/components/Gallery.astro @@ -2,11 +2,17 @@ /** * Portable Text Gallery block component * - * Renders image galleries from WordPress imports. - * Uses Astro's Image component for optimization when dimensions are available. + * Renders image galleries from WordPress imports and EmDash media. + * Uses the provider's getEmbed/getSrc functions for external media (mirrors + * Image.astro), falling back to Astro's Image component when dimensions are + * available for locally-stored media. */ import { Image as AstroImage } from "astro:assets"; +import type { ImageEmbed } from "../media/types.js"; +import { getMediaProvider } from "../media/provider-loader.js"; import { buildRenderMediaUrl } from "../media/url.js"; +import { toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS } from "../media/responsive.js"; +import { getPublicOrigin } from "../api/public-url.js"; export interface Props { node: { @@ -18,50 +24,159 @@ export interface Props { asset: { _ref: string; url?: string; + /** Provider ID for external media (e.g., "cloudflare-images") */ + provider?: string; }; alt?: string; caption?: string; width?: number; height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; }>; columns?: number; }; + /** Render the LQIP placeholder as an inline background. Disable for strict CSP. */ + placeholder?: boolean; } -const { node } = Astro.props; +/** + * Generate srcset using provider's getSrc function + */ +function generateSrcset( + getSrc: NonNullable, + maxWidth: number, + aspectRatio?: number, +): string { + return RESPONSIVE_BREAKPOINTS.filter((w) => w <= maxWidth * 2) + .map((w) => { + const h = aspectRatio ? Math.round(w / aspectRatio) : undefined; + return `${getSrc({ width: w, height: h })} ${w}w`; + }) + .join(", "); +} + +const { node, placeholder = true } = Astro.props; const images = node?.images ?? []; const columns = node?.columns ?? 3; if (!images.length) { return null; } + +// Resolve provider embeds up front (async), keeping the template's `.map()` +// synchronous over the resolved data — mirrors Image.astro's per-image logic. +const resolvedImages = await Promise.all( + images.map(async (image) => { + const { asset, alt = "", width, height } = image; + const aspectRatio = width && height ? width / height : undefined; + + let src = ""; + let srcset: string | undefined; + let sizes: string | undefined; + let astroImageSrc = ""; + const providerId = asset.provider; + + if (providerId && providerId !== "local") { + const provider = await getMediaProvider(providerId); + if (provider) { + try { + const mediaValue = { + provider: providerId, + id: asset._ref, + width, + height, + alt, + }; + const result = provider.getEmbed(mediaValue, { width, height }); + const embed = result instanceof Promise ? await result : result; + if (embed.type === "image") { + src = embed.src; + + if (embed.getSrc) { + const maxWidth = width || 1200; + srcset = generateSrcset(embed.getSrc, maxWidth, aspectRatio); + sizes = width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw"; + } + } + } catch (error) { + console.warn(`Failed to get embed for image ${asset._ref}:`, error); + } + } + } + + // Fallback for local provider. `asset.url` carries the storage key with + // extension when present; `asset._ref` is a bare ULID that only the + // internal `/file/{id}` route can resolve. `buildRenderMediaUrl` picks + // the right shape. + if (!src) { + src = buildRenderMediaUrl(Astro.locals.emdash?.getPublicMediaUrl, { + url: asset.url, + id: asset._ref, + }); + if (width && height) { + astroImageSrc = toAbsoluteMediaUrl( + src, + getPublicOrigin(Astro.url, Astro.locals.emdash?.config), + ); + } + } + + // Prefer blurhash over dominant color for the LQIP placeholder, like + // Image.astro. + let placeholderStyle = ""; + if (placeholder && image.blurhash) { + const { blurhashToImageCssString } = await import("@unpic/placeholder"); + placeholderStyle = blurhashToImageCssString(image.blurhash); + } else if (placeholder && image.dominantColor) { + placeholderStyle = `background-color: ${image.dominantColor};`; + } + + return { + key: image._key, + alt, + caption: image.caption, + width, + height, + src, + srcset, + sizes, + astroImageSrc, + placeholderStyle, + }; + }), +); --- diff --git a/packages/core/src/content/converters/gallery.ts b/packages/core/src/content/converters/gallery.ts new file mode 100644 index 000000000..d25500fb6 --- /dev/null +++ b/packages/core/src/content/converters/gallery.ts @@ -0,0 +1,61 @@ +/** + * Shared sanitization for gallery block images, used by both converters so + * the editor round-trip and the stored shape stay in lockstep. + */ + +import type { PortableTextGalleryImage } from "./types.js"; + +/** + * Normalize an untrusted `images` value into well-formed gallery images. + * Non-object entries and entries without an asset object are dropped. + * Missing `_key`s are filled via `generateKey` when provided (PM → PT); + * left empty otherwise (PT → PM keeps whatever the block carried). + */ +export function sanitizeGalleryImages( + value: unknown, + generateKey?: () => string, +): PortableTextGalleryImage[] { + if (!Array.isArray(value)) return []; + + const images: PortableTextGalleryImage[] = []; + for (const entry of value as unknown[]) { + if (!isRecord(entry)) continue; + const record = entry; + const asset = record.asset; + if (!isRecord(asset)) continue; + const assetRecord = asset; + + const image: PortableTextGalleryImage = { + _type: "image", + _key: + typeof record._key === "string" && record._key + ? record._key + : generateKey + ? generateKey() + : "", + asset: { + _type: "reference", + _ref: typeof assetRecord._ref === "string" ? assetRecord._ref : "", + ...(typeof assetRecord.url === "string" && assetRecord.url ? { url: assetRecord.url } : {}), + ...(typeof assetRecord.provider === "string" && assetRecord.provider + ? { provider: assetRecord.provider } + : {}), + }, + }; + if (typeof record.alt === "string" && record.alt) image.alt = record.alt; + if (typeof record.caption === "string" && record.caption) image.caption = record.caption; + if (typeof record.width === "number") image.width = record.width; + if (typeof record.height === "number") image.height = record.height; + if (typeof record.blurhash === "string" && record.blurhash) image.blurhash = record.blurhash; + if (typeof record.dominantColor === "string" && record.dominantColor) + image.dominantColor = record.dominantColor; + + images.push(image); + } + + return images; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/content/converters/portable-text-to-prosemirror.ts b/packages/core/src/content/converters/portable-text-to-prosemirror.ts index 1d61906ce..7f85899c5 100644 --- a/packages/core/src/content/converters/portable-text-to-prosemirror.ts +++ b/packages/core/src/content/converters/portable-text-to-prosemirror.ts @@ -4,6 +4,7 @@ * Converts Portable Text to TipTap's ProseMirror JSON format for editing. */ +import { sanitizeGalleryImages } from "./gallery.js"; import type { ProseMirrorDocument, ProseMirrorNode, @@ -13,6 +14,7 @@ import type { PortableTextSpan, PortableTextMarkDef, PortableTextImageBlock, + PortableTextGalleryBlock, PortableTextCodeBlock, } from "./types.js"; @@ -128,6 +130,14 @@ function isImageBlock(block: PortableTextBlock): block is PortableTextImageBlock ); } +/** + * Type guard for gallery blocks. Requires an `images` array — a gallery + * without one is malformed and falls through to the unknown-block path. + */ +function isGalleryBlock(block: PortableTextBlock): block is PortableTextGalleryBlock { + return block._type === "gallery" && "images" in block && Array.isArray(block.images); +} + /** * Type guard for code blocks */ @@ -149,6 +159,15 @@ function convertBlock(block: PortableTextBlock): ProseMirrorNode | null { // Malformed image block (no asset wrapper) — extract url from top level return convertMalformedImage(block); } + if (isGalleryBlock(block)) { + return { + type: "gallery", + attrs: { + images: sanitizeGalleryImages(block.images), + columns: typeof block.columns === "number" ? block.columns : undefined, + }, + }; + } if (isCodeBlock(block)) { return convertCodeBlock(block); } diff --git a/packages/core/src/content/converters/prosemirror-to-portable-text.ts b/packages/core/src/content/converters/prosemirror-to-portable-text.ts index e7522bf06..49bf82513 100644 --- a/packages/core/src/content/converters/prosemirror-to-portable-text.ts +++ b/packages/core/src/content/converters/prosemirror-to-portable-text.ts @@ -4,6 +4,7 @@ * Converts TipTap's ProseMirror JSON format to Portable Text for storage. */ +import { sanitizeGalleryImages } from "./gallery.js"; import type { ProseMirrorDocument, ProseMirrorNode, @@ -13,6 +14,7 @@ import type { PortableTextSpan, PortableTextMarkDef, PortableTextImageBlock, + PortableTextGalleryBlock, PortableTextCodeBlock, PortableTextHtmlBlock, } from "./types.js"; @@ -77,6 +79,9 @@ function convertNode(node: ProseMirrorNode): PortableTextBlock | PortableTextBlo case "image": return convertImage(node); + case "gallery": + return convertGallery(node); + case "horizontalRule": return { _type: "break", @@ -327,6 +332,19 @@ function convertImage(node: ProseMirrorNode): PortableTextImageBlock { }; } +/** + * Convert gallery node to Portable Text + */ +function convertGallery(node: ProseMirrorNode): PortableTextGalleryBlock { + const columns = node.attrs?.columns; + return { + _type: "gallery", + _key: generateKey(), + images: sanitizeGalleryImages(node.attrs?.images, generateKey), + ...(typeof columns === "number" ? { columns } : {}), + }; +} + /** * Convert inline content (text nodes with marks) to Portable Text spans */ diff --git a/packages/core/src/content/converters/types.ts b/packages/core/src/content/converters/types.ts index fd25ed432..d1952151e 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -70,6 +70,41 @@ export interface PortableTextImageBlock { displayHeight?: number; } +/** + * A single image inside a gallery block. Mirrors the shape produced by + * gutenberg-to-portable-text and consumed by Gallery.astro. + */ +export interface PortableTextGalleryImage { + _type: "image"; + _key: string; + asset: { + /** Present on WordPress-imported galleries; always emitted on round-trip */ + _type?: "reference"; + _ref: string; + url?: string; + /** Provider ID for external media (e.g., "cloudflare-images") */ + provider?: string; + }; + alt?: string; + caption?: string; + width?: number; + height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; +} + +/** + * Gallery block (grid of images with optional per-image captions) + */ +export interface PortableTextGalleryBlock { + _type: "gallery"; + _key: string; + images: PortableTextGalleryImage[]; + columns?: number; +} + /** * Code block */ @@ -105,6 +140,7 @@ export interface PortableTextUnknownBlock { export type PortableTextBlock = | PortableTextTextBlock | PortableTextImageBlock + | PortableTextGalleryBlock | PortableTextCodeBlock | PortableTextHtmlBlock | PortableTextUnknownBlock; diff --git a/packages/core/tests/unit/converters/gallery-round-trip.test.ts b/packages/core/tests/unit/converters/gallery-round-trip.test.ts new file mode 100644 index 000000000..99cb01d5a --- /dev/null +++ b/packages/core/tests/unit/converters/gallery-round-trip.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect } from "vitest"; + +import { portableTextToProsemirror } from "../../../src/content/converters/portable-text-to-prosemirror.js"; +import { prosemirrorToPortableText } from "../../../src/content/converters/prosemirror-to-portable-text.js"; +import type { PortableTextGalleryBlock } from "../../../src/content/converters/types.js"; + +const gallery: PortableTextGalleryBlock = { + _type: "gallery", + _key: "gal001", + images: [ + { + _type: "image", + _key: "img001", + asset: { _ref: "media-a" }, + alt: "First", + caption: "A local image", + width: 800, + height: 600, + }, + { + _type: "image", + _key: "img002", + asset: { _ref: "", url: "https://example.com/photo.jpg" }, + alt: "External", + }, + ], + columns: 4, +}; + +describe("gallery block round-trip (core converters)", () => { + it("converts a gallery block to a gallery ProseMirror node", () => { + const pm = portableTextToProsemirror([gallery]); + const node = pm.content[0]; + + expect(node.type).toBe("gallery"); + expect(node.attrs?.columns).toBe(4); + expect(node.attrs?.images).toHaveLength(2); + }); + + it("preserves images, captions, dimensions, and columns through PT → PM → PT", () => { + const pm = portableTextToProsemirror([gallery]); + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextGalleryBlock; + + expect(restored._type).toBe("gallery"); + expect(restored._key).toBeDefined(); + expect(restored.columns).toBe(4); + expect(restored.images).toHaveLength(2); + + const [first, second] = restored.images; + expect(first).toMatchObject({ + _type: "image", + asset: { _ref: "media-a" }, + alt: "First", + caption: "A local image", + width: 800, + height: 600, + }); + expect(first._key).toBeDefined(); + expect(second).toMatchObject({ + _type: "image", + asset: { _ref: "", url: "https://example.com/photo.jpg" }, + alt: "External", + }); + }); + + it("omits columns when not set and survives an empty images list", () => { + const minimal: PortableTextGalleryBlock = { + _type: "gallery", + _key: "gal002", + images: [], + }; + + const pm = portableTextToProsemirror([minimal]); + expect(pm.content[0].type).toBe("gallery"); + + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextGalleryBlock; + expect(restored._type).toBe("gallery"); + expect(restored.images).toEqual([]); + expect(restored.columns).toBeUndefined(); + }); + + it("drops non-object entries in images instead of crashing", () => { + const dirty = { + _type: "gallery", + _key: "gal003", + images: [null, "junk", { _type: "image", _key: "ok1", asset: { _ref: "media-b" } }], + }; + + const pm = portableTextToProsemirror([dirty as never]); + expect(pm.content[0].type).toBe("gallery"); + expect(pm.content[0].attrs?.images).toHaveLength(1); + + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextGalleryBlock; + expect(restored.images).toHaveLength(1); + expect(restored.images[0].asset._ref).toBe("media-b"); + }); + + it("still falls back to the unknown-block placeholder for a gallery without an images array", () => { + const malformed = { _type: "gallery", _key: "gal004" }; + const pm = portableTextToProsemirror([malformed as never]); + expect(pm.content[0].type).toBe("paragraph"); + }); + + it("round-trips a WordPress-imported gallery without loss", () => { + // Exact shape emitted by @emdash-cms/gutenberg-to-portable-text `gallery` + // transformer after the import media pass (asset._type "reference", + // rewritten _ref/url, per-image caption, no width/height, columns attr). + const imported = { + _type: "gallery", + _key: "wpgal1", + images: [ + { + _type: "image", + _key: "wpimg1", + asset: { + _type: "reference", + _ref: "/_emdash/api/media/file/01ABC.jpg", + url: "/_emdash/api/media/file/01ABC.jpg", + }, + alt: "Beach", + caption: "Summer 2019", + }, + { + _type: "image", + _key: "wpimg2", + asset: { _type: "reference", _ref: "42", url: "https://old-site.com/photo.jpg" }, + alt: undefined, + caption: undefined, + }, + ], + columns: 3, + }; + + const pm = portableTextToProsemirror([imported as never]); + expect(pm.content[0].type).toBe("gallery"); + + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextGalleryBlock; + + expect(restored._type).toBe("gallery"); + expect(restored.columns).toBe(3); + expect(restored.images).toHaveLength(2); + expect(restored.images[0]).toMatchObject({ + _type: "image", + asset: { + _type: "reference", + _ref: "/_emdash/api/media/file/01ABC.jpg", + url: "/_emdash/api/media/file/01ABC.jpg", + }, + alt: "Beach", + caption: "Summer 2019", + }); + expect(restored.images[1].asset).toEqual({ + _type: "reference", + _ref: "42", + url: "https://old-site.com/photo.jpg", + }); + }); + + it("preserves provider, blurhash, and dominantColor through PT → PM → PT", () => { + const withMeta: PortableTextGalleryBlock = { + _type: "gallery", + _key: "gal005", + images: [ + { + _type: "image", + _key: "img005", + asset: { _type: "reference", _ref: "media-c", provider: "cloudflare-images" }, + alt: "Provider image", + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#a3b1c2", + }, + ], + }; + + const pm = portableTextToProsemirror([withMeta]); + const node = pm.content[0]; + expect(node.attrs?.images).toHaveLength(1); + + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextGalleryBlock; + expect(restored.images[0]).toMatchObject({ + asset: { _ref: "media-c", provider: "cloudflare-images" }, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#a3b1c2", + }); + }); + + it("preserves galleries among other block types", () => { + const blocks = [ + { + _type: "block" as const, + _key: "txt001", + style: "normal" as const, + children: [{ _type: "span" as const, _key: "s1", text: "Before" }], + }, + gallery, + { + _type: "block" as const, + _key: "txt002", + style: "normal" as const, + children: [{ _type: "span" as const, _key: "s2", text: "After" }], + }, + ]; + + const pm = portableTextToProsemirror(blocks); + expect(pm.content.map((n) => n.type)).toEqual(["paragraph", "gallery", "paragraph"]); + + const pt = prosemirrorToPortableText(pm); + expect(pt.map((b) => b._type)).toEqual(["block", "gallery", "block"]); + }); +});