Skip to content

feat(gallery): Portable Text block editable #1967

Open
Rimander wants to merge 10 commits into
emdash-cms:mainfrom
Rimander:feat/pt-gallery-editing
Open

feat(gallery): Portable Text block editable #1967
Rimander wants to merge 10 commits into
emdash-cms:mainfrom
Rimander:feat/pt-gallery-editing

Conversation

@Rimander

@Rimander Rimander commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Makes the gallery Portable Text block, editable in the admin editor. Today the block is import/render-only: @emdash-cms/gutenberg-to-portable-text emits it and Gallery.astro renders it, but there is no TipTap node for it — and worse than invisible, the editor's PM→PT serializer dropped the block entirely, so opening and saving a migrated post silently destroyed its galleries. This PR closes the loop requested in #1436: insert, edit, reorder, and configure galleries from the admin, with WordPress-imported galleries surviving the full edit round-trip.

Core:

  • portable-text-to-prosemirror.ts / prosemirror-to-portable-text.ts gain a symmetric gallery mapping with a shared image sanitizer. Malformed galleries (no images array) keep the visible unknown-block placeholder instead of becoming an empty grid.
  • Converter types add PortableTextGalleryBlock / PortableTextGalleryImage, preserving asset._type: "reference" — the exact shape the Gutenberg importer emits and the WP-import media pass rewrites — so imported galleries round-trip without loss. Unit tests include a fixture matching the importer's real output (rewritten _ref/url, per-image captions, columns).

Admin:

  • The admin's internal converter pair (duplicated inside PortableTextEditor.tsx) gets the same two-way gallery mapping. Previously a loaded gallery fell into the plugin-block fallback and a gallery node serialized to nothing.
  • New GalleryNode (TipTap node view): WYSIWYG grid preview honoring columns, per-image captions, selection overlay with settings/delete, empty-state placeholder. Clicking an individual image opens the sidebar with that image pre-selected (transient selection — never persisted into node attrs).
  • New GalleryDetailPanel (sidebar): compact 3-per-row thumbnail grid with drag-and-drop reordering (dnd-kit PointerSensor with a 6px activation distance so real clicks select instead of being swallowed by drag tracking), multi-select "Add Images", column count, and a per-image settings card (large preview, hover Replace, original dimensions, alt text, caption). Panel state composes sequential edits and resyncs when reopened for a different gallery node, so edits cannot leak across galleries.
  • MediaPickerModal gains an additive multi-select mode (multiple + onSelectMany, selection in click order); single-select behavior is untouched.
  • Insert entry points: toolbar button and /gallery slash command. Multiple galleries per document are supported. The gallery panel is wired into the content editor, section editor, and widgets editors.

Closes #1436

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Code

Screenshots / test output

Verified end-to-end in demos/simple (Node + SQLite):

  • Inserted a gallery from the toolbar, picked 3 images at once; autosave persisted a well-formed gallery PT block (columns, ordered image refs) — confirmed via the content API and directly in SQLite.
  • Reordered by drag-and-drop and edited captions/columns from the panel; captions travel with their image through reorders; every state change persisted.
  • Reloaded the editor: gallery still visible and editable (full converter round-trip; no [Unknown block type] placeholder, no data loss on save).
  • Clicked individual images in the canvas and in the panel's thumbnail grid: the settings card opens pre-selected and switches correctly; the transient selection never leaks into the stored block.
  • Two galleries in one document: the panel switches image sets correctly between them.
  • Published and confirmed the public page renders .emdash-gallery with the right figure count, --columns value, and captions.
  • Arabic (RTL): node view and panel mirror correctly, no crashes.
image image

Rimander added 7 commits July 11, 2026 20:27
…ordering, and side-panel configuration in the editor
…lery 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-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2eaa229

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,296 lines across 13 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1967

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1967

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1967

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1967

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1967

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1967

emdash

npm i https://pkg.pr.new/emdash@1967

create-emdash

npm i https://pkg.pr.new/create-emdash@1967

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1967

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1967

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1967

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1967

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1967

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1967

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1967

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1967

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1967

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1967

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1967

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1967

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1967

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1967

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1967

commit: 2eaa229

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for the problem it describes: today WordPress-imported gallery blocks round-trip through prosemirror-to-portable-text into nothing, so opening and saving a migrated post silently destroys its galleries. The PR closes that loop with a symmetric converter mapping plus a real editor experience.

The approach fits EmDash:

  • Core side mirrors the existing image/CodeBlock patterns: a new gallery case in both converters driven by a shared sanitizeGalleryImages sanitizer, with asset._type: "reference" preserved so the Gutenberg importer's real output survives.
  • Admin side follows the established image-node/block-sidebar pattern (GalleryNodeGallerySidebarPanelGalleryDetailPanel) and extends MediaPickerModal with additive multi-select.
  • User-facing strings are Lingui-wrapped, Tailwind uses logical properties (end-*, start-*, border-s), and there is a sensible changeset.

I read the diff, the full changed files, and the relevant existing ImageNode/Gallery.astro/media-utils code. No security, SQL, authorization, or locale-filter issues are introduced by these frontend-only changes.

The only real caveats are polish/convention points:

  1. Admin UI tests are missing. The diff adds only the core converter round-trip tests. The new gallery node, detail panel, and multi-select picker behavior should have unit/component tests.
  2. Widget editor nests DndContexts. The gallery panel (with its own drag-and-drop thumbnail grid) is rendered inside the Widgets page-level DndContext, which can conflict.
  3. Empty gallery uses a hand-rolled <button>. AGENTS.md asks for Kumo components rather than manually styled buttons.
  4. Gallery images lose provider/LQIP metadata. For parity with ImageNode, provider and LQIP fields should be carried through.
  5. Panel resync is keyed on attributes identity. Stable in current callers, but fragile.

None of these block the stated goal, so this is a comment review.

@@ -66,6 +66,10 @@ export interface MediaPickerModalProps {
open: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The diff only adds core converter tests for the gallery round-trip. The new multi-select MediaPickerModal behavior, GalleryNode node view, and GalleryDetailPanel interactions are not covered by new tests. Add tests for: confirming multiple items preserves click order, toggling selection, upload-in-multi-select, opening the gallery panel from a node view, thumbnail drag reorder, and a full PT→PM→PT round-trip through the admin's duplicated converters. This guards against the core/admin converters drifting.

onClose={handleBlockSidebarClose}
/>
)}
{blockSidebarPanel?.type === "gallery" && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The gallery detail panel is rendered inside the page-level <DndContext> that handles widget drag-and-drop, but the panel itself contains another <DndContext> for thumbnail reordering. Nested DndContexts can compete for pointer events, so thumbnail reorder may be unreliable in the Widgets editor. Either pass inline and render the panel inside the widget card's own layout, or move the {blockSidebarPanel?.type === "gallery" && ...} block outside the page-level <DndContext> so the thumbnail grid has a single context.

)}
>
{images.length === 0 ? (
<button

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The empty-gallery placeholder uses a raw <button> with hand-rolled styling (border-dashed, hover utilities, etc.) instead of a Kumo Button. AGENTS.md asks not to hand-roll buttons. Use Kumo’s <Button variant="outline" ...> (or buttonVariants() if you need a non-button wrapper) so focus, hover, disabled, dark mode, and RTL states stay consistent with the rest of the admin.

}

/** Map a picked MediaItem to the gallery's Portable Text image shape. */
export function mediaItemToGalleryImage(item: MediaItem): GalleryImage {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] mediaItemToGalleryImage drops provider, blurhash, dominantColor, and meta from MediaItem. For external-provider images, losing provider means the public renderer cannot fall back to provider-aware resolution if the stored url expires or is missing. For parity with ImageNode, preserve at least provider (and ideally LQIP fields) on GalleryImage and thread them through the converters to Gallery.astro. That also requires extending PortableTextGalleryImage in packages/core/src/content/converters/types.ts and the shared sanitizer.

// 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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The panel resets its local gallery state whenever the attributes object identity changes. Current callers keep the panel object stable, so this works, but a parent that ever re-wraps attrs would clobber sequential edits. Make the resync conditional on a stable gallery/node identifier (e.g., the TipTap node position or the gallery _key) instead of on object identity.

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jul 12, 2026
…operties to gallery block and improve multi-select media picker behavior.
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 12, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for the problem it describes, and the follow-up commits fully address the five concerns from the previous review.

What’s fixed since the last review

  1. Admin UI tests are now present. Three new test files cover the gallery detail panel behavior (selection, caption edits, removal, and the nodeKey resync edge case), the multi-select media picker, and the admin-side converter round-trip.
  2. DndContext nesting in Widgets is resolved. The GalleryDetailPanel is now rendered outside the page-level DndContext, so its internal thumbnail drag-and-drop cannot conflict with the widget-area sensors.
  3. Empty gallery uses Kumo. The empty-state placeholder is now a <Button> from @cloudflare/kumo.
  4. Provider and LQIP metadata are preserved through the whole edit round-trip. mediaItemToGalleryImage, the core sanitizeGalleryImages, GalleryNode, and Gallery.astro props all carry provider, blurhash, and dominantColor.
  5. Panel resync is keyed by nodeKey. GalleryDetailPanel only resets local state when the gallery node identity changes, and there is a regression test proving in-progress edits survive a parent re-render with a fresh attrs object identity.

The converters and new node panel are well-structured and mirror the existing image-node pattern. I did not run the test suite or typecheck (none of that tooling is available here); I rely on the PR checklist for those results.

Remaining polish / convention notes

I have two non-blocking suggestions:

  • Gallery.astro accepts blurhash and provider in its props but only renders dominantColor. For parity with Image.astro — which turns blurhash into a placeholder via @unpic/placeholder and resolves provider embeds — these fields should be consumed. As written, provider-hosted gallery images might not render the same optimized URLs as standalone images, and stored blurhash values are unused on the public site.
  • GalleryDetailPanel still has one hand-rolled <button> for the sortable thumbnail grid. AGENTS.md asks for Kumo components; the dnd-kit attributes/listeners can be forwarded through Kumo’s Button.

There is also a process note: the PR checklist links to …/discussions/00 as the “approved Discussion,” which is clearly a placeholder. That should be replaced with a real discussion or at least the roadmap issue reference before merge if the maintainer enforces the feature-discussion requirement.

None of these block the gallery editing workflow, so this is a comment review and the PR is essentially ready.

Comment on lines +28 to +54
@@ -45,8 +51,11 @@ if (!images.length) {
id: image.asset._ref,
});
const hasSize = image.width && image.height;
const lqipStyle = image.dominantColor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The gallery renderer now accepts blurhash and provider in the image shape (lines 28–31) but only uses dominantColor for the LQIP style. For parity with Image.astro — which renders blurhash placeholders via @unpic/placeholder and resolves provider embeds — Gallery.astro should also consume these fields. As written, stored blurhash values are dead on the public site and provider-hosted gallery images may not render with the same optimized URLs as standalone images.

Suggested change
const hasSize = image.width && image.height;
const lqipStyle = image.dominantColor
? `background-color: ${image.dominantColor}`
: undefined;

Consider adding the blurhash placeholder generation and provider-aware URL resolution that Image.astro already implements.


return (
<div ref={setNodeRef} style={style} className="relative group">
<button

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This sortable thumbnail is a hand-rolled <button> with manual Tailwind classes. AGENTS.md asks to use Kumo components instead of rolling custom buttons; the dnd-kit attributes and listeners can be spread onto Kumo’s Button.

Suggested change
<button
<Button
type="button"
variant="ghost"
shape="square"
className={cn(
"aspect-square w-full rounded-md border overflow-hidden",
selected && "ring-2 ring-kumo-brand",
)}
onClick={onSelect}
{...attributes}
{...listeners}
>

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 12, 2026
…ery component and update UI panel button styles
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/core bot:review Trigger an emdashbot code review on this PR overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(admin): editable gallery Portable Text block (no TipTap editor node — import/render-only)

2 participants