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/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..6528f34fb8 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,38 @@ 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) } : {}), + }, + }; + 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 +379,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 +757,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 +2200,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 +2271,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 +2378,7 @@ export function PortableTextEditor({ }), CodeBlockExtension, HtmlBlockExtension, + GalleryExtension, ImageExtension, MarkdownLinkExtension, PluginBlockExtension, @@ -2413,17 +2501,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 +2548,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 +2690,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 +3078,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 +3340,9 @@ function EditorToolbar({ setMediaPickerOpen(true)} title={t`Insert Image`}> + setGalleryPickerOpen(true)} title={t`Insert Gallery`}> + editor @@ -3266,6 +3404,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..4ce282938b --- /dev/null +++ b/packages/admin/src/components/editor/GalleryDetailPanel.tsx @@ -0,0 +1,375 @@ +/** + * 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 { 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 }, + alt: item.alt || "", + width: item.width, + height: item.height, + }; +} + +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` 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 + // 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, + }); + + // 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 + ? (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 }, + 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; + 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 0000000000..91d61b2ff6 --- /dev/null +++ b/packages/admin/src/components/editor/GalleryNode.tsx @@ -0,0 +1,259 @@ +/** + * 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 }; + 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"; + /** + * `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; + 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 = (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(), selectedImageKey }, + 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/core/src/content/converters/gallery.ts b/packages/core/src/content/converters/gallery.ts new file mode 100644 index 0000000000..024e5d43db --- /dev/null +++ b/packages/core/src/content/converters/gallery.ts @@ -0,0 +1,55 @@ +/** + * 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 } : {}), + }, + }; + 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..57acbd6103 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -70,6 +70,35 @@ 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; + }; + 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 +134,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..5afba4e91c --- /dev/null +++ b/packages/core/tests/unit/converters/gallery-round-trip.test.ts @@ -0,0 +1,186 @@ +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 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"]); + }); +});