From 56fef926b45034e4f4b57344b53db8ce425063b6 Mon Sep 17 00:00:00 2001 From: fermin Date: Sat, 11 Jul 2026 20:27:33 -0500 Subject: [PATCH 1/7] feat: implement gallery block with image management, drag-and-drop reordering, and side-panel configuration in the editor --- demos/simple/emdash-env.d.ts | 1 + .../src/components/ContentSettingsPanel.tsx | 12 + .../admin/src/components/MediaPickerModal.tsx | 141 +++++++--- .../src/components/PortableTextEditor.tsx | 168 +++++++++++- .../admin/src/components/SectionEditor.tsx | 13 + packages/admin/src/components/Widgets.tsx | 13 + .../components/editor/GalleryDetailPanel.tsx | 243 ++++++++++++++++++ .../src/components/editor/GalleryNode.tsx | 243 ++++++++++++++++++ .../core/src/content/converters/gallery.ts | 56 ++++ .../portable-text-to-prosemirror.ts | 19 ++ .../prosemirror-to-portable-text.ts | 18 ++ packages/core/src/content/converters/types.ts | 28 ++ .../converters/gallery-round-trip.test.ts | 134 ++++++++++ 13 files changed, 1041 insertions(+), 48 deletions(-) create mode 100644 packages/admin/src/components/editor/GalleryDetailPanel.tsx create mode 100644 packages/admin/src/components/editor/GalleryNode.tsx create mode 100644 packages/core/src/content/converters/gallery.ts create mode 100644 packages/core/tests/unit/converters/gallery-round-trip.test.ts diff --git a/demos/simple/emdash-env.d.ts b/demos/simple/emdash-env.d.ts index 4c380e8e87..5acc9365d4 100644 --- a/demos/simple/emdash-env.d.ts +++ b/demos/simple/emdash-env.d.ts @@ -26,6 +26,7 @@ export interface Post { featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; content?: PortableTextBlock[]; excerpt?: string; + gallery?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; createdAt: Date; updatedAt: Date; publishedAt: Date | null; diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 8f72e5351f..3c3e8230d3 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 49f52b59e6..66f4b8bbf4 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 28b13a3f3f..e098f35bed 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, @@ -95,6 +96,8 @@ import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField"; import { CodeBlockExtension } from "./editor/CodeBlockNode"; import { DragHandleWrapper } from "./editor/DragHandleWrapper"; import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; +import { mediaItemToGalleryImage } from "./editor/GalleryDetailPanel"; +import { GalleryExtension, type GalleryImage } from "./editor/GalleryNode"; import { ImageExtension } from "./editor/ImageNode"; import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension"; import { @@ -175,6 +178,37 @@ 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: { + _ref: typeof assetRecord._ref === "string" ? assetRecord._ref : "", + ...(attrStr(assetRecord.url) ? { url: attrStr(assetRecord.url) } : {}), + }, + }; + 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; + 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 +378,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 +756,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 +2199,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 +2270,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 +2377,7 @@ export function PortableTextEditor({ }), CodeBlockExtension, HtmlBlockExtension, + GalleryExtension, ImageExtension, MarkdownLinkExtension, PluginBlockExtension, @@ -2413,17 +2500,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 +2547,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 +2689,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 +3077,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 +3339,9 @@ function EditorToolbar({ setMediaPickerOpen(true)} title={t`Insert Image`}> + setGalleryPickerOpen(true)} title={t`Insert Gallery`}> + editor @@ -3266,6 +3403,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 c53880fd2e..f474b1de24 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 8024b7419a..b6cb30b751 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, @@ -486,6 +488,17 @@ export function Widgets() { onClose={handleBlockSidebarClose} /> )} + {blockSidebarPanel?.type === "gallery" && ( + blockSidebarPanel.onUpdate(attrs)} + onDelete={() => { + blockSidebarPanel.onDelete(); + setBlockSidebarPanel(null); + }} + 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 0000000000..3f50416de1 --- /dev/null +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -0,0 +1,243 @@ +/** + * 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, closestCenter } from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useLingui } from "@lingui/react/macro"; +import { X, Plus, Trash, DotsSixVertical } from "@phosphor-icons/react"; +import * as React from "react"; + +import type { MediaItem } from "../../lib/api"; +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: { _ref: item.id, url: item.url }, + alt: item.alt || "", + width: item.width, + height: item.height, + }; +} + +export function GalleryDetailPanel({ + attributes, + onUpdate, + onDelete, + onClose, + inline = false, +}: GalleryDetailPanelProps) { + const { t } = useLingui(); + const [showMediaPicker, setShowMediaPicker] = React.useState(false); + + const images = attributes.images ?? []; + const columns = attributes.columns ?? 3; + + const handleAdd = (items: MediaItem[]) => { + onUpdate({ images: [...images, ...items.map(mediaItemToGalleryImage)] }); + }; + + const handleRemove = (key: string) => { + onUpdate({ images: images.filter((image) => image._key !== key) }); + }; + + const handleImageChange = (key: string, patch: Partial) => { + onUpdate({ + images: images.map((image) => (image._key === key ? { ...image, ...patch } : 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; + onUpdate({ 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`} + /> +
+ ); +} diff --git a/packages/admin/src/components/editor/GalleryNode.tsx b/packages/admin/src/components/editor/GalleryNode.tsx new file mode 100644 index 0000000000..e201f85d03 --- /dev/null +++ b/packages/admin/src/components/editor/GalleryNode.tsx @@ -0,0 +1,243 @@ +/** + * 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: { _ref: string; url?: string }; + alt?: string; + caption?: string; + width?: number; + height?: number; +} + +export interface GalleryAttributes { + images: GalleryImage[]; + columns?: number; +} + +/** Panel descriptor passed to the block sidebar (see BlockSidebarPanel). */ +export interface GallerySidebarPanel { + type: "gallery"; + attrs: GalleryAttributes; + 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 }: 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 = () => { + 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", + attrs: getAttrs(), + 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) => ( +
+ {image.alt + {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/core/src/content/converters/gallery.ts b/packages/core/src/content/converters/gallery.ts new file mode 100644 index 0000000000..12da3348a2 --- /dev/null +++ b/packages/core/src/content/converters/gallery.ts @@ -0,0 +1,56 @@ +/** + * 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: { + _ref: typeof assetRecord._ref === "string" ? assetRecord._ref : "", + ...(typeof assetRecord.url === "string" && assetRecord.url + ? { url: assetRecord.url } + : {}), + }, + }; + 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; + + 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 1d61906ced..7f85899c5c 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 e7522bf06e..49bf82513c 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 fd25ed4328..8596dc95f7 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -70,6 +70,33 @@ 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: { + _ref: string; + url?: string; + }; + alt?: string; + caption?: string; + width?: number; + height?: number; +} + +/** + * 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 +132,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 0000000000..c4d0b01036 --- /dev/null +++ b/packages/core/tests/unit/converters/gallery-round-trip.test.ts @@ -0,0 +1,134 @@ +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("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"]); + }); +}); From 7be204b1995af39fc4a3e36638d2b524f07fdcfa Mon Sep 17 00:00:00 2001 From: fermin Date: Sat, 11 Jul 2026 20:46:19 -0500 Subject: [PATCH 2/7] feat: add image replacement functionality and improve state consistency in GalleryDetailPanel --- demos/simple/emdash-env.d.ts | 1 - .../components/editor/GalleryDetailPanel.tsx | 71 ++++++++++++++++--- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/demos/simple/emdash-env.d.ts b/demos/simple/emdash-env.d.ts index 5acc9365d4..4c380e8e87 100644 --- a/demos/simple/emdash-env.d.ts +++ b/demos/simple/emdash-env.d.ts @@ -26,7 +26,6 @@ export interface Post { featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; content?: PortableTextBlock[]; excerpt?: string; - gallery?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; createdAt: Date; updatedAt: Date; publishedAt: Date | null; diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index 3f50416de1..d4d77ec445 100644 --- a/packages/admin/src/components/editor/GalleryDetailPanel.tsx +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -18,7 +18,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useLingui } from "@lingui/react/macro"; -import { X, Plus, Trash, DotsSixVertical } from "@phosphor-icons/react"; +import { X, Plus, Trash, DotsSixVertical, ImageSquare } from "@phosphor-icons/react"; import * as React from "react"; import type { MediaItem } from "../../lib/api"; @@ -61,30 +61,60 @@ export function GalleryDetailPanel({ const { t } = useLingui(); const [showMediaPicker, setShowMediaPicker] = React.useState(false); - const images = attributes.images ?? []; - const columns = attributes.columns ?? 3; + // `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, + }); + const images = gallery.images; + const columns = gallery.columns ?? 3; + + const apply = (patch: Partial) => { + setGallery((prev) => ({ ...prev, ...patch })); + onUpdate(patch); + }; const handleAdd = (items: MediaItem[]) => { - onUpdate({ images: [...images, ...items.map(mediaItemToGalleryImage)] }); + apply({ images: [...images, ...items.map(mediaItemToGalleryImage)] }); }; const handleRemove = (key: string) => { - onUpdate({ images: images.filter((image) => image._key !== key) }); + apply({ images: images.filter((image) => image._key !== key) }); }; const handleImageChange = (key: string, patch: Partial) => { - onUpdate({ + 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: { _ref: item.id, url: item.url }, + alt: item.alt || "", + width: item.width, + height: item.height, + } + : 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; - onUpdate({ images: arrayMove(images, oldIndex, newIndex) }); + apply({ images: arrayMove(images, oldIndex, newIndex) }); }; const body = ( @@ -106,7 +136,7 @@ export function GalleryDetailPanel({ Date: Sat, 11 Jul 2026 20:49:00 -0500 Subject: [PATCH 3/7] refactor: apply code style and import ordering cleanups to gallery components and converters --- packages/admin/src/components/PortableTextEditor.tsx | 2 +- .../admin/src/components/editor/GalleryDetailPanel.tsx | 8 +++++++- packages/core/src/content/converters/gallery.ts | 4 +--- .../core/tests/unit/converters/gallery-round-trip.test.ts | 6 +----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index e098f35bed..3846b88bd5 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -95,9 +95,9 @@ import { CaretNext } from "./ArrowIcons.js"; import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField"; import { CodeBlockExtension } from "./editor/CodeBlockNode"; import { DragHandleWrapper } from "./editor/DragHandleWrapper"; -import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; 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"; import { diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index d4d77ec445..62b068dcbf 100644 --- a/packages/admin/src/components/editor/GalleryDetailPanel.tsx +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -212,7 +212,13 @@ interface SortableGalleryRowProps { onRemove: () => void; } -function SortableGalleryRow({ image, index, onChange, onReplace, onRemove }: SortableGalleryRowProps) { +function SortableGalleryRow({ + image, + index, + onChange, + onReplace, + onRemove, +}: SortableGalleryRowProps) { const { t } = useLingui(); const [showReplacePicker, setShowReplacePicker] = React.useState(false); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ diff --git a/packages/core/src/content/converters/gallery.ts b/packages/core/src/content/converters/gallery.ts index 12da3348a2..d0db26f70a 100644 --- a/packages/core/src/content/converters/gallery.ts +++ b/packages/core/src/content/converters/gallery.ts @@ -35,9 +35,7 @@ export function sanitizeGalleryImages( : "", asset: { _ref: typeof assetRecord._ref === "string" ? assetRecord._ref : "", - ...(typeof assetRecord.url === "string" && assetRecord.url - ? { url: assetRecord.url } - : {}), + ...(typeof assetRecord.url === "string" && assetRecord.url ? { url: assetRecord.url } : {}), }, }; if (typeof record.alt === "string" && record.alt) image.alt = record.alt; diff --git a/packages/core/tests/unit/converters/gallery-round-trip.test.ts b/packages/core/tests/unit/converters/gallery-round-trip.test.ts index c4d0b01036..9d95d4bb17 100644 --- a/packages/core/tests/unit/converters/gallery-round-trip.test.ts +++ b/packages/core/tests/unit/converters/gallery-round-trip.test.ts @@ -85,11 +85,7 @@ describe("gallery block round-trip (core converters)", () => { const dirty = { _type: "gallery", _key: "gal003", - images: [ - null, - "junk", - { _type: "image", _key: "ok1", asset: { _ref: "media-b" } }, - ], + images: [null, "junk", { _type: "image", _key: "ok1", asset: { _ref: "media-b" } }], }; const pm = portableTextToProsemirror([dirty as never]); From 08f1432570741a220b868a094d46677687b9090d Mon Sep 17 00:00:00 2001 From: fermin Date: Sat, 11 Jul 2026 20:53:06 -0500 Subject: [PATCH 4/7] feat: add expandable settings panel to gallery images for editing details and replacing assets --- .../components/editor/GalleryDetailPanel.tsx | 95 +++++++++++++------ 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index 62b068dcbf..d02c8de434 100644 --- a/packages/admin/src/components/editor/GalleryDetailPanel.tsx +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -18,11 +18,12 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useLingui } from "@lingui/react/macro"; -import { X, Plus, Trash, DotsSixVertical, ImageSquare } from "@phosphor-icons/react"; +import { X, Plus, Trash, DotsSixVertical, ImageSquare, CaretDown } from "@phosphor-icons/react"; import * as React from "react"; import type { MediaItem } from "../../lib/api"; import { cn } from "../../lib/utils"; +import { CaretNext } from "../ArrowIcons.js"; import { MediaPickerModal } from "../MediaPickerModal"; import { galleryImageUrl, type GalleryAttributes, type GalleryImage } from "./GalleryNode"; @@ -221,6 +222,7 @@ function SortableGalleryRow({ }: SortableGalleryRowProps) { const { t } = useLingui(); const [showReplacePicker, setShowReplacePicker] = React.useState(false); + const [expanded, setExpanded] = React.useState(false); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: image._key, }); @@ -230,21 +232,33 @@ function SortableGalleryRow({ transition, }; + const hasOriginalSize = typeof image.width === "number" && typeof image.height === "number"; + return (
-
+ {/* Header — click to expand per-image settings */} +
setExpanded((prev) => !prev)} + > e.stopPropagation()} /> + {expanded ? ( + + ) : ( + + )} {image.alt - {image.alt || image.asset._ref || t`Untitled image`} + {image.alt || image.caption || image.asset._ref || t`Untitled image`} -
+ + {/* Expanded per-image settings — mirrors the single-image panel */} + {expanded && ( +
+
+ {image.alt +
+ +
+
+ {hasOriginalSize && ( +
+ {t`Original:`} + + {image.width} × {image.height} + +
+ )} + onChange({ alt: e.target.value })} + placeholder={t`Describe the image...`} + /> + onChange({ caption: e.target.value || undefined })} + placeholder={t`Optional caption`} + /> +
+ )} + - onChange({ alt: e.target.value })} - placeholder={t`Describe the image...`} - /> - onChange({ caption: e.target.value || undefined })} - placeholder={t`Optional caption`} - />
); } From e21e5170a700f714e75af541e6885d31d2f29e1d Mon Sep 17 00:00:00 2001 From: fermin Date: Sat, 11 Jul 2026 21:04:54 -0500 Subject: [PATCH 5/7] refactor: replace list-based gallery editor with grid-based thumbnail view and external settings panel --- .../components/editor/GalleryDetailPanel.tsx | 226 +++++++++--------- 1 file changed, 116 insertions(+), 110 deletions(-) diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index d02c8de434..b521dacffc 100644 --- a/packages/admin/src/components/editor/GalleryDetailPanel.tsx +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -10,20 +10,14 @@ import { Button, Input, Label, Select } from "@cloudflare/kumo"; import { DndContext, closestCenter } from "@dnd-kit/core"; import type { DragEndEvent } from "@dnd-kit/core"; -import { - SortableContext, - verticalListSortingStrategy, - useSortable, - arrayMove, -} from "@dnd-kit/sortable"; +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, DotsSixVertical, ImageSquare, CaretDown } from "@phosphor-icons/react"; +import { X, Plus, Trash, ImageSquare } from "@phosphor-icons/react"; import * as React from "react"; import type { MediaItem } from "../../lib/api"; import { cn } from "../../lib/utils"; -import { CaretNext } from "../ArrowIcons.js"; import { MediaPickerModal } from "../MediaPickerModal"; import { galleryImageUrl, type GalleryAttributes, type GalleryImage } from "./GalleryNode"; @@ -61,6 +55,7 @@ export function GalleryDetailPanel({ }: GalleryDetailPanelProps) { const { t } = useLingui(); const [showMediaPicker, setShowMediaPicker] = React.useState(false); + const [selectedKey, setSelectedKey] = React.useState(null); // `attributes` is a snapshot taken when the sidebar opened; it does not // refresh after onUpdate. Local state is the live source of truth while @@ -72,6 +67,9 @@ export function GalleryDetailPanel({ }); 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 })); @@ -84,6 +82,7 @@ export function GalleryDetailPanel({ const handleRemove = (key: string) => { apply({ images: images.filter((image) => image._key !== key) }); + setSelectedKey((prev) => (prev === key ? null : prev)); }; const handleImageChange = (key: string, patch: Partial) => { @@ -158,18 +157,17 @@ export function GalleryDetailPanel({

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

) : ( - image._key)} - strategy={verticalListSortingStrategy} - > -
+ image._key)} strategy={rectSortingStrategy}> +
{images.map((image, index) => ( - handleImageChange(image._key, patch)} - onReplace={(item) => handleReplace(image._key, item)} + selected={selectedKey === image._key} + onSelect={() => + setSelectedKey((prev) => (prev === image._key ? null : image._key)) + } onRemove={() => handleRemove(image._key)} /> ))} @@ -178,6 +176,15 @@ export function GalleryDetailPanel({ )} + {selectedImage && ( + handleImageChange(selectedImage._key, patch)} + onReplace={(item) => handleReplace(selectedImage._key, item)} + /> + )} + @@ -205,25 +212,23 @@ export function GalleryDetailPanel({ ); } -interface SortableGalleryRowProps { +interface SortableGalleryThumbProps { image: GalleryImage; index: number; - onChange: (patch: Partial) => void; - onReplace: (item: MediaItem) => void; + selected: boolean; + onSelect: () => void; onRemove: () => void; } -function SortableGalleryRow({ +function SortableGalleryThumb({ image, index, - onChange, - onReplace, + selected, + onSelect, onRemove, -}: SortableGalleryRowProps) { +}: SortableGalleryThumbProps) { const { t } = useLingui(); - const [showReplacePicker, setShowReplacePicker] = React.useState(false); - const [expanded, setExpanded] = React.useState(false); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: image._key, }); @@ -232,101 +237,102 @@ function SortableGalleryRow({ transition, }; - const hasOriginalSize = typeof image.width === "number" && typeof image.height === "number"; - return ( -
- {/* Header — click to expand per-image settings */} -
setExpanded((prev) => !prev)} +
+ + + + {index + 1} + +
+ ); +} + +interface GalleryImageSettingsProps { + image: GalleryImage; + onChange: (patch: Partial) => void; + onReplace: (item: MediaItem) => void; +} + +function GalleryImageSettings({ image, onChange, onReplace }: GalleryImageSettingsProps) { + const { t } = useLingui(); + const [showReplacePicker, setShowReplacePicker] = React.useState(false); + + const hasOriginalSize = typeof image.width === "number" && typeof image.height === "number"; + + return ( +
+
{image.alt - - {image.alt || image.caption || image.asset._ref || t`Untitled image`} - - +
+ +
- - {/* Expanded per-image settings — mirrors the single-image panel */} - {expanded && ( -
-
- {image.alt -
- -
-
- {hasOriginalSize && ( -
- {t`Original:`} - - {image.width} × {image.height} - -
- )} - onChange({ alt: e.target.value })} - placeholder={t`Describe the image...`} - /> - onChange({ caption: e.target.value || undefined })} - placeholder={t`Optional caption`} - /> + {hasOriginalSize && ( +
+ {t`Original:`} + + {image.width} × {image.height} +
)} + onChange({ alt: e.target.value })} + placeholder={t`Describe the image...`} + /> + onChange({ caption: e.target.value || undefined })} + placeholder={t`Optional caption`} + /> Date: Sat, 11 Jul 2026 21:20:36 -0500 Subject: [PATCH 6/7] feat(editor): WordPress-import fidelity and per-image editing for gallery blocks Preserves asset._type "reference" (the shape the Gutenberg importer emits and the import media pass rewrites) through both converter pairs, with a round-trip test against the exact imported shape. Clicking an image inside the gallery node now opens the sidebar with that image's settings selected, and the panel resyncs its local state when reopened for a different gallery node so edits can't leak between galleries. Adds the release changeset. --- .changeset/pt-gallery-editing.md | 6 ++ .../src/components/PortableTextEditor.tsx | 1 + .../components/editor/GalleryDetailPanel.tsx | 28 +++++++++- .../src/components/editor/GalleryNode.tsx | 40 +++++++++---- .../core/src/content/converters/gallery.ts | 1 + packages/core/src/content/converters/types.ts | 2 + .../converters/gallery-round-trip.test.ts | 56 +++++++++++++++++++ 7 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 .changeset/pt-gallery-editing.md diff --git a/.changeset/pt-gallery-editing.md b/.changeset/pt-gallery-editing.md new file mode 100644 index 0000000000..9060b37dec --- /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/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 3846b88bd5..6528f34fb8 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -196,6 +196,7 @@ function sanitizeGalleryImages(value: unknown, withKeys = false): 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) } : {}), }, diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index b521dacffc..49efd6d0f5 100644 --- a/packages/admin/src/components/editor/GalleryDetailPanel.tsx +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -39,7 +39,7 @@ export function mediaItemToGalleryImage(item: MediaItem): GalleryImage { return { _type: "image", _key: generateKey(), - asset: { _ref: item.id, url: item.url }, + asset: { _type: "reference", _ref: item.id, url: item.url }, alt: item.alt || "", width: item.width, height: item.height, @@ -55,7 +55,22 @@ export function GalleryDetailPanel({ }: GalleryDetailPanelProps) { const { t } = useLingui(); const [showMediaPicker, setShowMediaPicker] = React.useState(false); - const [selectedKey, setSelectedKey] = React.useState(null); + // `selectedImageKey` is transient UI state passed in via `attributes` when + // the gallery node view opens the sidebar for a specific image (e.g. + // clicking an image in the canvas grid) — it is never persisted to node + // attrs. + const selectedImageKey = (attributes as GalleryAttributes & { selectedImageKey?: string }) + .selectedImageKey; + 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 @@ -65,6 +80,13 @@ export function GalleryDetailPanel({ images: attributes.images ?? [], columns: attributes.columns, }); + + // A new `attributes` identity means the sidebar was (re)opened — possibly + // for a DIFFERENT gallery node. Resync or edits would write this panel's + // stale images into the other node. + React.useEffect(() => { + setGallery({ images: attributes.images ?? [], columns: attributes.columns }); + }, [attributes]); const images = gallery.images; const columns = gallery.columns ?? 3; const selectedImage = selectedKey @@ -98,7 +120,7 @@ export function GalleryDetailPanel({ image._key === key ? { ...image, - asset: { _ref: item.id, url: item.url }, + asset: { _type: "reference", _ref: item.id, url: item.url }, alt: item.alt || "", width: item.width, height: item.height, diff --git a/packages/admin/src/components/editor/GalleryNode.tsx b/packages/admin/src/components/editor/GalleryNode.tsx index e201f85d03..91d61b2ff6 100644 --- a/packages/admin/src/components/editor/GalleryNode.tsx +++ b/packages/admin/src/components/editor/GalleryNode.tsx @@ -21,7 +21,7 @@ import { cn } from "../../lib/utils"; export interface GalleryImage { _type: "image"; _key: string; - asset: { _ref: string; url?: string }; + asset: { _type?: "reference"; _ref: string; url?: string }; alt?: string; caption?: string; width?: number; @@ -36,7 +36,12 @@ export interface GalleryAttributes { /** Panel descriptor passed to the block sidebar (see BlockSidebarPanel). */ export interface GallerySidebarPanel { type: "gallery"; - attrs: GalleryAttributes; + /** + * `selectedImageKey` is transient UI state (which image's settings card + * is open) — it must never be written into node attrs via + * `updateAttributes`. + */ + attrs: GalleryAttributes & { selectedImageKey?: string }; onUpdate: (attrs: Partial) => void; onReplace: (attrs: GalleryAttributes) => void; onDelete: () => void; @@ -70,14 +75,14 @@ function GalleryNodeView({ node, updateAttributes, selected, deleteNode, editor columns: typeof node.attrs.columns === "number" ? node.attrs.columns : undefined, }); - const openSidebar = () => { + 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", - attrs: getAttrs(), + attrs: { ...getAttrs(), selectedImageKey }, onUpdate: (attrs) => updateAttributes(attrs), onReplace: (attrs) => updateAttributes(attrs), onDelete: () => deleteNode(), @@ -125,7 +130,7 @@ function GalleryNodeView({ node, updateAttributes, selected, deleteNode, editor type="button" className="w-full rounded-lg border-2 border-dashed p-8 flex flex-col items-center gap-2 text-kumo-subtle hover:border-kumo-brand transition-colors" onMouseDown={(e) => e.preventDefault()} - onClick={openSidebar} + onClick={() => openSidebar()} > {t`Empty gallery — open settings to add images`} @@ -135,14 +140,25 @@ function GalleryNodeView({ node, updateAttributes, selected, deleteNode, editor className="grid gap-2 rounded-lg" style={{ gridTemplateColumns: `repeat(${Math.max(1, columns)}, 1fr)` }} > - {images.map((image) => ( + {images.map((image, index) => (
- {image.alt + {image.caption && (
{image.caption} diff --git a/packages/core/src/content/converters/gallery.ts b/packages/core/src/content/converters/gallery.ts index d0db26f70a..024e5d43db 100644 --- a/packages/core/src/content/converters/gallery.ts +++ b/packages/core/src/content/converters/gallery.ts @@ -34,6 +34,7 @@ export function sanitizeGalleryImages( ? generateKey() : "", asset: { + _type: "reference", _ref: typeof assetRecord._ref === "string" ? assetRecord._ref : "", ...(typeof assetRecord.url === "string" && assetRecord.url ? { url: assetRecord.url } : {}), }, diff --git a/packages/core/src/content/converters/types.ts b/packages/core/src/content/converters/types.ts index 8596dc95f7..57acbd6103 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -78,6 +78,8 @@ export interface PortableTextGalleryImage { _type: "image"; _key: string; asset: { + /** Present on WordPress-imported galleries; always emitted on round-trip */ + _type?: "reference"; _ref: string; url?: string; }; diff --git a/packages/core/tests/unit/converters/gallery-round-trip.test.ts b/packages/core/tests/unit/converters/gallery-round-trip.test.ts index 9d95d4bb17..5afba4e91c 100644 --- a/packages/core/tests/unit/converters/gallery-round-trip.test.ts +++ b/packages/core/tests/unit/converters/gallery-round-trip.test.ts @@ -104,6 +104,62 @@ describe("gallery block round-trip (core converters)", () => { 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 galleries among other block types", () => { const blocks = [ { From ad3d904c0c79fd43a7c4d010fcfcbec7c8c8cd0c Mon Sep 17 00:00:00 2001 From: fermin Date: Sat, 11 Jul 2026 21:30:43 -0500 Subject: [PATCH 7/7] feat: add PointerSensor with distance constraint to gallery drag-and-drop to enable click events on thumbnails --- .../admin/src/components/editor/GalleryDetailPanel.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/components/editor/GalleryDetailPanel.tsx b/packages/admin/src/components/editor/GalleryDetailPanel.tsx index 49efd6d0f5..4ce282938b 100644 --- a/packages/admin/src/components/editor/GalleryDetailPanel.tsx +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -8,7 +8,7 @@ */ import { Button, Input, Label, Select } from "@cloudflare/kumo"; -import { DndContext, closestCenter } from "@dnd-kit/core"; +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"; @@ -54,6 +54,10 @@ export function GalleryDetailPanel({ 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` is transient UI state passed in via `attributes` when // the gallery node view opens the sidebar for a specific image (e.g. @@ -178,7 +182,7 @@ export function GalleryDetailPanel({ {images.length === 0 ? (

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

) : ( - + image._key)} strategy={rectSortingStrategy}>
{images.map((image, index) => (