feat(admin): optional link on portable-text image blocks#704
feat(admin): optional link on portable-text image blocks#704drudge wants to merge 1 commit intoemdash-cms:mainfrom
Conversation
🦋 Changeset detectedLatest commit: c804623 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
Scope checkThis 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. |
Lunaria Status Overview🌕 This pull request will trigger status changes. Learn moreBy default, every PR changing files present in the Lunaria configuration's You can change this by adding one of the keywords present in the Tracked Files
Warnings reference
|
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
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>withsanitizeHref()inImage.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.
| editor | ||
| .chain() | ||
| .focus() | ||
| .updateAttributes("image", { link: trimmed ? { href: trimmed } : null }) |
There was a problem hiding this comment.
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.
| 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, | |
| }) |
| editor | ||
| .chain() | ||
| .focus() | ||
| .updateAttributes("image", { link: trimmed ? { href: trimmed } : null }) |
There was a problem hiding this comment.
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.
| 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, | |
| }) |
| @@ -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)} | |||
There was a problem hiding this comment.
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.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
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.
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.
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.
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 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.
de1eb7c to
c804623
Compare
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.
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.
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.
* 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>
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.astrowraps the rendered<img>in an<a>usingsanitizeHref()whenlink.hrefis set.ImageDetailPanelgains 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
repeaterBlockKit element will follow on the original PR #679 branch (force-pushed), andmedia_pickerin a third PR.Relative paths like
/pageare accepted — the Link URL input istype="text"(nottype="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.tscomment now describes real behavior ("the image is rendered inside an<a>usingsanitizeHref-validatedhref")ImageDetailPanel.tsxLink URL input istype="text"(was flagged when it wastype="url"and rejected relative paths)prosemirror-to-portable-text.tsdrops half-populated or whitespace-onlylinkobjects on the way out — new round-trip test covers itCloses #
Type of change
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
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runpnpm locale:extracthas been run (if applicable)AI-generated code disclosure
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 tests —
packages/core/tests/unit/converters/image-link.test.ts(5 cases, all passing):/page)blank: trueround-tripslink: {})href→ link droppedhref→ link droppedAlso verified manually in
demos/simple:/about→ Apply → rendered<figure>wraps<img>in<a href="/about">. Re-selecting the image shows the popover pre-filled.ImageDetailPanel: Link URL input + "Open in new tab" checkbox round-trips through save/reload.sanitizeHref()handles hostile inputs (covered by its own tests).arlocale (RTL): layout stays clean, new strings extract into all 11 catalogs.