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})`}
+
+ )}
)}
-
- )}
{t`Cancel`}
-
+
{t`Insert`}
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`Add Widget Area`}
-
- )}
- />
-
-
-
- {t`Create Widget Area`}
-
- (
-
-
- {t`Close`}
-
- )}
- />
-
-