Skip to content

feat(admin): optional link on portable-text image blocks#704

Open
drudge wants to merge 1 commit intoemdash-cms:mainfrom
drudge:drudge/image-link
Open

feat(admin): optional link on portable-text image blocks#704
drudge wants to merge 1 commit intoemdash-cms:mainfrom
drudge:drudge/image-link

Conversation

@drudge
Copy link
Copy Markdown
Contributor

@drudge drudge commented Apr 21, 2026

What does this PR do?

Adds an optional link: { href; blank? } on portable-text image blocks, round-tripped through the PT ↔ PM converters. The existing editor Link buttons (main toolbar and bubble menu) now operate on image selection: selecting an image and clicking Link opens the URL popover pre-populated with the image's current link, and apply/remove updates the image node's attrs. Image.astro wraps the rendered <img> in an <a> using sanitizeHref() when link.href is set. ImageDetailPanel gains a Link URL input and an "Open in new tab" checkbox as a secondary editing surface.

Split from #679 per @ascorbic's review ("it'd be worth opening those as separate PRs … the image link is definitely a separate concern"). The image-link portion and its Copilot follow-ups land here; the repeater BlockKit element will follow on the original PR #679 branch (force-pushed), and media_picker in a third PR.

Relative paths like /page are accepted — the Link URL input is type="text" (not type="url") so it matches how text-link inputs behave elsewhere in the editor. sanitizeHref() (already tested) enforces the allow-list at render time.

Copilot comments addressed in scope:

  • types.ts comment now describes real behavior ("the image is rendered inside an <a> using sanitizeHref-validated href")
  • ImageDetailPanel.tsx Link URL input is type="text" (was flagged when it was type="url" and rejected relative paths)
  • prosemirror-to-portable-text.ts drops half-populated or whitespace-only link objects on the way out — new round-trip test covers it

Closes #

Type of change

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

Maintainer sign-off for this scope is @ascorbic's review comment on #679 ("Thanks for this. It's a good addition.") combined with the instruction to split that PR into focused replacements.

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation and pnpm locale:extract has been run (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: see maintainer sign-off note above

AI-generated code disclosure

  • This PR includes AI-generated code

Claude-authored, human-reviewed. Reviewers should pay extra attention to the converter round-trip logic (empty-href drop) and the bubble-menu/main-toolbar branching on editor.isActive("image").

Screenshots / test output

New testspackages/core/tests/unit/converters/image-link.test.ts (5 cases, all passing):

  • Relative link round-trips (/page)
  • External link with blank: true round-trips
  • Absent link stays absent on the output (no empty link: {})
  • Empty href → link dropped
  • Whitespace-only href → link dropped

Also verified manually in demos/simple:

  • Select image → main toolbar Link → enter /about → Apply → rendered <figure> wraps <img> in <a href="/about">. Re-selecting the image shows the popover pre-filled.
  • Same flow via the EditorBubbleMenu Link button.
  • ImageDetailPanel: Link URL input + "Open in new tab" checkbox round-trips through save/reload.
  • Clearing the URL in either surface removes the anchor from the rendered output.
  • sanitizeHref() handles hostile inputs (covered by its own tests).
  • Switched admin to ar locale (RTL): layout stays clean, new strings extract into all 11 catalogs.

Copilot AI review requested due to automatic review settings April 21, 2026 14:05
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: c804623

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

This PR includes changesets to release 12 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-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/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 2,648 lines across 23 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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
packages/admin/src/locales/ar/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/de/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/en/messages.po Source changed, localizations will be marked as outdated.
packages/admin/src/locales/es-419/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/eu/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/fr/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ja/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ko/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pseudo/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pt-BR/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/zh-CN/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/zh-TW/messages.po Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 21, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

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

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: c804623

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds optional link support to portable-text image blocks across the admin editor, converter round-trips, and front-end rendering.

Changes:

  • Extend PT image block schema with optional link: { href, blank? } and round-trip it through PT ↔ ProseMirror converters (dropping empty/whitespace href).
  • Update admin editor link UI (toolbar + bubble menu) to operate on selected images and add link editing controls in ImageDetailPanel.
  • Render linked images by wrapping the <img> in an <a> with sanitizeHref() in Image.astro, plus update tests and locale catalogs.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/core/tests/unit/converters/image-link.test.ts Adds unit coverage for image link round-tripping and empty/whitespace link dropping.
packages/core/src/content/converters/types.ts Extends PortableTextImageBlock with optional link metadata.
packages/core/src/content/converters/prosemirror-to-portable-text.ts Normalizes/drops invalid image link attrs when converting PM → PT.
packages/core/src/content/converters/portable-text-to-prosemirror.ts Emits PM image attrs.link (or null) when converting PT → PM.
packages/core/src/components/Image.astro Wraps rendered images in a sanitized <a> when link.href is present.
packages/admin/tests/editor/toolbar.test.tsx Updates link popover tests to match the text input/placeholder behavior.
packages/admin/tests/editor/bubble-menu.test.tsx Updates bubble-menu link tests to match the text input/placeholder behavior.
packages/admin/src/locales/ar/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/de/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/en/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/es-419/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/eu/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/fr/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/ko/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/pt-BR/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/pseudo/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/zh-CN/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/locales/zh-TW/messages.po Updates extracted strings/locations for new ImageDetailPanel link UI.
packages/admin/src/components/editor/ImageNode.tsx Adds link attribute support to the TipTap image node/command typing + defaults.
packages/admin/src/components/editor/ImageDetailPanel.tsx Adds link URL + “open in new tab” editing surface for images.
packages/admin/src/components/PortableTextEditor.tsx Adds image-aware link handling in toolbar/bubble menu and includes link in PT↔PM conversion helpers.
.changeset/image-link-field.md Publishes the feature as a minor change for emdash and @emdash-cms/admin.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1840 to +1843
editor
.chain()
.focus()
.updateAttributes("image", { link: trimmed ? { href: trimmed } : null })
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

When editing an image link via the bubble-menu popover, updateAttributes("image", { link: { href } }) overwrites the entire link object and will drop an existing blank: true flag. This causes unexpected data loss if an image was set to “open in new tab” (e.g. via ImageDetailPanel) and the user re-applies/edits the link here. Consider preserving the existing link.blank value when updating href, or adding a blank control in the popover so the full { href, blank? } object is maintained.

Suggested change
editor
.chain()
.focus()
.updateAttributes("image", { link: trimmed ? { href: trimmed } : null })
const existingImageLink = editor.getAttributes("image").link as
| { href?: string; blank?: boolean }
| null
| undefined;
editor
.chain()
.focus()
.updateAttributes("image", {
link: trimmed ? { ...existingImageLink, href: trimmed } : null,
})

Copilot uses AI. Check for mistakes.
Comment on lines +2070 to +2073
editor
.chain()
.focus()
.updateAttributes("image", { link: trimmed ? { href: trimmed } : null })
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

Same issue in the main toolbar link popover: when editor.isActive("image"), calling updateAttributes("image", { link: { href } }) will remove any existing blank: true on the image’s link attrs. This will silently change stored content if the user opens the popover and clicks Apply without intending to change the “open in new tab” setting. Preserve the prior blank value when updating href, or expose blank in this popover.

Suggested change
editor
.chain()
.focus()
.updateAttributes("image", { link: trimmed ? { href: trimmed } : null })
const existingImageLink = editor.getAttributes("image").link as
| { href?: string; blank?: boolean }
| null
| undefined;
editor
.chain()
.focus()
.updateAttributes("image", {
link: trimmed ? { ...existingImageLink, href: trimmed } : null,
})

Copilot uses AI. Check for mistakes.
Comment on lines 2303 to 2320
@@ -2260,8 +2314,8 @@ function EditorToolbar({
<div className="flex items-center gap-1">
<Input
ref={linkInputRef}
type="url"
placeholder="https://..."
type="text"
placeholder="https://example.com or /page"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

The link popover introduces/updates user-visible strings (e.g. "Image Link" title and the new placeholder text) that are not wrapped with Lingui (t/<Trans>), so they won’t be translated even though the project requires wrapping user-visible strings in packages/admin/src/ (see CONTRIBUTING.md i18n section). Please wrap the new/updated strings so locale catalogs can translate the toolbar/bubble link UI consistently.

Copilot uses AI. Check for mistakes.
@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.

drudge added a commit to drudge/emdash that referenced this pull request Apr 22, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types (text/number/select/
  toggle/checkbox/combobox/date/radio as sub-fields — no nested
  repeaters, no buttons)
- `elements.repeater(...)` builder
- Schema validation for the repeater: sub-field type whitelist,
  non-negative integer `min_items` / `max_items` with
  `min_items <= max_items`
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
drudge added a commit to drudge/emdash that referenced this pull request Apr 23, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types. Sub-fields are
  restricted to the four scalar inputs the admin widget renders
  inline: `text_input`, `number_input`, `select`, `toggle`. No
  nested repeaters, no buttons.
- `elements.repeater(...)` builder
- Schema validation for the repeater: non-empty `fields` array,
  sub-field type whitelist, non-negative integer `min_items` /
  `max_items` with `min_items <= max_items`, and `initial_value`
  entries must be objects
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- `handleRemove` drops the removed item's key from the `expanded`
  Set so it doesn't retain stale entries
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
drudge added a commit to drudge/emdash that referenced this pull request Apr 24, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types. Sub-fields are
  restricted to the four scalar inputs the admin widget renders
  inline: `text_input`, `number_input`, `select`, `toggle`. No
  nested repeaters, no buttons.
- `elements.repeater(...)` builder
- Schema validation for the repeater: non-empty `fields` array,
  sub-field type whitelist, non-negative integer `min_items` /
  `max_items` with `min_items <= max_items`, and `initial_value`
  entries must be objects
- Unit tests in `tests/validation.test.ts` covering the new rules
  (valid repeater, empty fields, disallowed sub-field type,
  non-integer / inverted min/max, malformed `initial_value`)
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- `handleRemove` drops the removed item's key from the `expanded`
  Set so it doesn't retain stale entries
- Item-card summary trims the first text_input before falling back
  to "Item N" so whitespace-only values still show the index
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
Adds an optional `link: { href; blank? }` on portable-text image blocks,
round-tripped through the PT ↔ PM converters. The existing editor Link
buttons (main toolbar and bubble menu) now operate on image selection:
selecting an image and clicking Link opens the URL popover pre-populated
with the image's current link, and apply/remove updates the image node's
attrs. `Image.astro` wraps the rendered `<img>` in an `<a>` using
`sanitizeHref()` when `link.href` is set. `ImageDetailPanel` gains a
Link URL input and an "Open in new tab" checkbox as a secondary editing
surface.

Split from emdash-cms#679 per review; see discussion in that PR.
drudge added a commit to drudge/emdash that referenced this pull request Apr 24, 2026
Adds a `repeater` Block Kit element for plugin block forms: an
array-of-objects input with scalar sub-fields, drag-to-reorder
(dnd-kit), and collapsible item cards. Plugin blocks can now capture
repeating data (FAQ rows, carousel slides, card grids) inline in the
portable-text editor.

`@emdash-cms/blocks`
- `RepeaterElement` + `RepeaterSubField` types. Sub-fields are
  restricted to the four scalar inputs the admin widget renders
  inline: `text_input`, `number_input`, `select`, `toggle`. No
  nested repeaters, no buttons.
- `elements.repeater(...)` builder
- Schema validation for the repeater: non-empty `fields` array,
  sub-field type whitelist, non-negative integer `min_items` /
  `max_items` with `min_items <= max_items`, and `initial_value`
  entries must be objects
- Unit tests in `tests/validation.test.ts` covering the new rules
  (valid repeater, empty fields, disallowed sub-field type,
  non-integer / inverted min/max, malformed `initial_value`)
- Exhaustive runtime `renderElement` arm (returns null — repeater is
  admin-authoring only)

`@emdash-cms/admin`
- `BlockKitRepeater` + `BlockKitRepeaterItem` rendered inside
  `PortableTextEditor`
- Drag-to-reorder handle, collapsible item cards, Add / Remove
  controls
- Keyboard-accessible item header (real `<button>` with
  `aria-expanded`, labeled drag handle)
- `handleRemove` drops the removed item's key from the `expanded`
  Set so it doesn't retain stale entries
- Item-card summary trims the first text_input before falling back
  to "Item N" so whitespace-only values still show the index
- Stable `_key` preservation per item (same pattern as the merged
  `RepeaterField` fix in emdash-cms#678)
- `PluginBlockModal` body grows `max-h-[70vh] overflow-y-auto` so the
  modal scrolls when a repeater has many rows

Split from the original bundled emdash-cms#679 per maintainer review. Image-link
piece shipped in emdash-cms#704; `media_picker` follows in emdash-cms#731.
drudge added a commit to drudge/emdash that referenced this pull request Apr 24, 2026
Adds a `media_picker` Block Kit element: a thumbnail preview with a modal
library picker and mime-type filter. Usable in plugin block forms (inside
`PortableTextEditor`) and as a Block Kit field widget (`BlockKitFieldWidget`).

The stored value is the selected asset's URL string, so it is value-compatible
with a plain `text_input` — existing content continues to work after swapping.

Split from emdash-cms#679 per maintainer request. Image-link landed in emdash-cms#704; repeater
landed in the rescoped emdash-cms#679.
@drudge drudge force-pushed the drudge/image-link branch from de1eb7c to c804623 Compare April 24, 2026 14:15
drudge added a commit to drudge/emdash that referenced this pull request Apr 28, 2026
Adds a `media_picker` Block Kit element: a thumbnail preview with a modal
library picker and mime-type filter. Usable in plugin block forms (inside
`PortableTextEditor`) and as a Block Kit field widget (`BlockKitFieldWidget`).

The stored value is the selected asset's URL string, so it is value-compatible
with a plain `text_input` — existing content continues to work after swapping.

Split from emdash-cms#679 per maintainer request. Image-link landed in emdash-cms#704; repeater
landed in the rescoped emdash-cms#679.
drudge added a commit to drudge/emdash that referenced this pull request Apr 28, 2026
Adds a `media_picker` Block Kit element: a thumbnail preview with a modal
library picker and mime-type filter. Usable in plugin block forms (inside
`PortableTextEditor`) and as a Block Kit field widget (`BlockKitFieldWidget`).

The stored value is the selected asset's URL string, so it is value-compatible
with a plain `text_input` — existing content continues to work after swapping.

Split from emdash-cms#679 per maintainer request. Image-link landed in emdash-cms#704; repeater
landed in the rescoped emdash-cms#679.
drudge added a commit to drudge/emdash that referenced this pull request Apr 29, 2026
Adds a `media_picker` Block Kit element: a thumbnail preview with a modal
library picker and mime-type filter. Usable in plugin block forms (inside
`PortableTextEditor`) and as a Block Kit field widget (`BlockKitFieldWidget`).

The stored value is the selected asset's URL string, so it is value-compatible
with a plain `text_input` — existing content continues to work after swapping.

Split from emdash-cms#679 per maintainer request. Image-link landed in emdash-cms#704; repeater
landed in the rescoped emdash-cms#679.
ascorbic added a commit that referenced this pull request Apr 30, 2026
* feat(admin): add media_picker BlockKit element

Adds a `media_picker` Block Kit element: a thumbnail preview with a modal
library picker and mime-type filter. Usable in plugin block forms (inside
`PortableTextEditor`) and as a Block Kit field widget (`BlockKitFieldWidget`).

The stored value is the selected asset's URL string, so it is value-compatible
with a plain `text_input` — existing content continues to work after swapping.

Split from #679 per maintainer request. Image-link landed in #704; repeater
landed in the rescoped #679.

* fix(blocks): handle media_picker in runtime renderElement

Add a `case "media_picker"` arm to keep the Element union exhaustive.
The picker is an admin-authoring construct (thumbnail + modal library
picker) with no runtime render semantics, so returning null matches
the repeater pattern.

* chore: extract locale catalogs [skip ci]

* fix(admin,blocks): address PR #731 media_picker review feedback

- Extract shared BlockKitMediaPickerField; both BlockKitFieldWidget and
  PortableTextEditor now render through it, killing two near-identical
  copies that risked diverging.
- Fix URL-insert local-rewrite bug: MediaPickerModal returns URL-inserted
  items with id:"" and no provider/storageKey. Treating the absence of
  provider as "local" rewrote external URLs to a broken
  /_emdash/api/media/file/ path. Detect local explicitly via
  provider==="local" || !!storageKey and fall through to item.url.
- Validate URLs before previewing: only render <img> for safe http(s)
  URLs or relative paths starting with "/" (not "//"); fall back to the
  empty-state placeholder otherwise. Add referrerPolicy="no-referrer"
  and loading="lazy" on the preview <img>.
- Improve a11y on hover-revealed Change/Remove controls: also reveal on
  group-focus-within, and toggle pointer-events with the same group
  states so invisible controls don't absorb pointer events.
- Restrict mime_type_filter to image MIME types (image/ or image/<sub>),
  rejecting wildcards like image/* (unsupported by the picker's
  startsWith filter) and non-image types like video/.
- Add validation tests (3 valid + 6 invalid cases) and component tests
  (11 cases covering empty state, picker open, local pick, URL pick,
  preview attrs, unsafe-URL fallback, remove).

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants