feat: first-class HTML block in the admin editor#1215
Conversation
Add HtmlBlockNode TipTap extension to the admin editor so the existing
htmlBlock Portable Text type (produced by WordPress and Contentful
importers) is a fully editable block rather than falling through to an
opaque pluginBlock placeholder.
- New HtmlBlockNode.tsx: atom node with textarea for source editing and
a Preview toggle that sanitizes via DOMPurify
- Slash command entry (/html) and aliases (raw, markup)
- Round-trip conversion in all three converter locations:
- PortableTextEditor.tsx (admin editor)
- InlinePortableTextEditor.tsx (visual-editing editor)
- Core standalone converters (prosemirror-to-portable-text.ts,
portable-text-to-prosemirror.ts)
- New PortableTextHtmlBlock type in core converter types, exported from
the emdash package
- Inline editor renders htmlBlock as a read-only placeholder to prevent
data loss during visual editing
- Round-trip test for the core converters
Closes discussion #1185
🦋 Changeset detectedLatest commit: 1a0b6a2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 1a0b6a2 | Jun 02 2026, 10:29 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 1a0b6a2 | Jun 02 2026, 10:30 AM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@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-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 1a0b6a2 | Jun 02 2026, 10:30 AM |
Drop the Eye/Preview toggle (DOMPurify-based sanitized preview) and the character count subtitle from the HTML block header. The block is now a straightforward textarea with a delete button. This also removes the dompurify import from the component.
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. |
Add HTML block documentation to the Working with Content guide: - Add /html to the slash commands table - Add HTML blocks to the editor feature list - New 'HTML Blocks' section explaining the feature and its use cases - Document the iframe sanitization allowlist limitation (youtube, vimeo only by default) - Provide a full code example for overriding the htmlBlock component to allow additional iframe providers
There was a problem hiding this comment.
Approach judgment: This is the right change for the right problem. Exposing htmlBlock as a first-class editable atom node fits EmDash's architecture and follows the established PluginBlockNode pattern. The read-only placeholder in the inline editor is a pragmatic choice that preserves data without bloating the visual-editing bundle.
Implementation: The converter updates in core and admin are consistent, round-trip tests confirm data fidelity, and all new UI strings in the admin node view are properly wrapped with Lingui. RTL-safe Tailwind is used throughout.
Issues found:
-
HtmlBlockExtension'sBackspace/Deletekeyboard shortcuts run at the editor level and intercept keystrokes even when the user is typing inside the HTML<textarea>. This deletes the entire block instead of a single character — a concrete usability blocker that needs a guard ondocument.activeElement. -
The
htmlattribute is serialized to the DOM as a barehtml="..."attribute, leaking raw markup into HTML output and clipboard serialization. A customrenderHTML/parseHTMLpair (likePluginBlockExtensiondoes fordata-plugin-data) would keep the markup semantic. -
The PR description mentions a DOMPurify-powered preview toggle that is not present in the code. The current textarea-only implementation is internally coherent, but the description should be updated if the preview was intentionally deferred.
| return ReactNodeViewRenderer(HtmlBlockNodeView); | ||
| }, | ||
|
|
||
| addKeyboardShortcuts() { | ||
| return { | ||
| Backspace: () => { | ||
| const { selection } = this.editor.state; | ||
| const node = this.editor.state.doc.nodeAt(selection.from); | ||
| if (node?.type.name === "htmlBlock") { | ||
| this.editor.commands.deleteSelection(); | ||
| return true; | ||
| } | ||
| return false; | ||
| }, | ||
| Delete: () => { | ||
| const { selection } = this.editor.state; | ||
| const node = this.editor.state.doc.nodeAt(selection.from); | ||
| if (node?.type.name === "htmlBlock") { | ||
| this.editor.commands.deleteSelection(); |
There was a problem hiding this comment.
[needs fixing] The Backspace and Delete keyboard shortcuts run at the editor level and intercept keydown events even when focus is inside the HTML <textarea>. When a user presses Backspace while editing source, the entire block is deleted instead of a single character.
Add a guard that returns false when focus is inside an input or textarea so the native editing behavior is preserved:
| return ReactNodeViewRenderer(HtmlBlockNodeView); | |
| }, | |
| addKeyboardShortcuts() { | |
| return { | |
| Backspace: () => { | |
| const { selection } = this.editor.state; | |
| const node = this.editor.state.doc.nodeAt(selection.from); | |
| if (node?.type.name === "htmlBlock") { | |
| this.editor.commands.deleteSelection(); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| Delete: () => { | |
| const { selection } = this.editor.state; | |
| const node = this.editor.state.doc.nodeAt(selection.from); | |
| if (node?.type.name === "htmlBlock") { | |
| this.editor.commands.deleteSelection(); | |
| addKeyboardShortcuts() { | |
| return { | |
| Backspace: () => { | |
| const active = document.activeElement; | |
| if (active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT")) { | |
| return false; | |
| } | |
| const { selection } = this.editor.state; | |
| const node = this.editor.state.doc.nodeAt(selection.from); | |
| if (node?.type.name === "htmlBlock") { | |
| this.editor.commands.deleteSelection(); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| Delete: () => { | |
| const active = document.activeElement; | |
| if (active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT")) { | |
| return false; | |
| } | |
| const { selection } = this.editor.state; | |
| const node = this.editor.state.doc.nodeAt(selection.from); | |
| if (node?.type.name === "htmlBlock") { | |
| this.editor.commands.deleteSelection(); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| }; | |
| }, |
| draggable: true, | ||
| selectable: true, | ||
|
|
||
| addAttributes() { | ||
| return { | ||
| html: { | ||
| default: "", |
There was a problem hiding this comment.
[suggestion] The html attribute is rendered to the DOM as a bare html="..." attribute, which leaks arbitrary markup into the editor's HTML output and clipboard serialization. Consider adding a custom renderHTML/parseHTML pair to store it in a semantic data-html attribute — matching how PluginBlockExtension uses data-plugin-data for its payload:
| draggable: true, | |
| selectable: true, | |
| addAttributes() { | |
| return { | |
| html: { | |
| default: "", | |
| addAttributes() { | |
| return { | |
| html: { | |
| default: "", | |
| parseHTML: (el: HTMLElement) => el.getAttribute("data-html") || "", | |
| renderHTML: (attrs: Record<string, unknown>) => | |
| typeof attrs.html === "string" ? { "data-html": attrs.html } : {}, | |
| }, | |
| }; | |
| }, |
Add PT->PM->PT round-trip assertions for htmlBlock against the converter seams the admin and inline (visual-editing) editors actually exercise, mirroring the existing core converter round-trip test. Correct the changeset: there is no sanitized-preview toggle; the editor renders only a textarea.
ascorbic
left a comment
There was a problem hiding this comment.
Looking good! Tested in the playground and it works well
There was a problem hiding this comment.
This is still the right change for the right problem: making htmlBlock a first-class editable atom node fits EmDash's architecture and follows the established PluginBlockNode pattern. The read-only placeholder in the inline editor remains a pragmatic choice.
What's been addressed since the last review:
- The DOMPurify preview toggle discrepancy is resolved — it was removed in
daf0bf2band the changeset was corrected infda0961cto reflect that the editor renders only a textarea. - Additional round-trip tests covering the admin and inline editor converter seams were added in
fda0961c. - Documentation for HTML blocks and iframe sanitization was added in
d698e89d.
What remains unaddressed:
Two issues flagged in the prior review are still present and should be fixed before merge:
-
Backspace/Delete keyboard shortcuts hijack the
<textarea>. TheHtmlBlockExtensionkeymap interceptsBackspaceandDeletewhenever the ProseMirror selection is on anhtmlBlock, even while the user is typing inside the nested<textarea>. This deletes the entire block instead of a single character. A guard ondocument.activeElement(or the equivalent) is needed. -
Raw
htmlmarkup leaks into DOM attributes.renderHTMLpassesHTMLAttributes(which includes the barehtmlattribute) straight to the DOM, so the editor serializes potentially large raw markup ashtml="..."on every drag, copy, and clipboard operation. The attribute should use a customparseHTML/renderHTMLpair — exactly likePluginBlockExtensiondoes fordata-plugin-data— to store the content in a semanticdata-html-contentattribute instead.
New note (minor): The admin editor's local PortableTextBlock union type does not include the new htmlBlock shape, so TypeScript won't narrow correctly for consumers of the editor's onChange callback. It's a small type gap worth closing while the file is open.
Implementation is otherwise consistent across core, admin, and inline surfaces; tests cover the round-trip paths; all new UI strings are properly wrapped with Lingui; and RTL-safe Tailwind is used throughout.
- Guard Backspace/Delete editor shortcuts so they no longer hijack keystrokes while editing the nested source textarea (deferring to native field editing when focus is in an input/textarea). - Store raw markup in a semantic data-html-content attribute via custom parseHTML/renderHTML instead of leaking a bare html="..." attribute on DOM/clipboard serialization. - Add a dedicated htmlBlock shape to the editor's PortableTextBlock union so consumers narrow correctly. - Cover the serialization round-trip with editor-level tests.
What does this PR do?
Add a first-class
HtmlBlockNodeto the admin editor so the existinghtmlBlockPortable Text type (produced by the WordPress and Contentful importers) becomes a fully editable block. Previously, importedhtmlBlockcontent fell through to an opaquepluginBlockplaceholder with thehtmlfield becoming inaccessible. Now authors can create and edit HTML blocks natively in the rich-text editor.Closes discussion #1185
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Implementation details
New files
packages/admin/src/components/editor/HtmlBlockNode.tsx— TipTap atom node extension with React node view. Provides a textarea for editing raw HTML source, a Preview toggle that sanitizes via DOMPurify, drag handle, delete action, and selection ring. Modeled onPluginBlockNode.tsx.packages/core/tests/unit/converters/html-block-round-trip.test.ts— Round-trip test verifyinghtmlBlocksurvives PT → PM → PT conversion in the core standalone converters.Modified files
packages/admin/src/components/PortableTextEditor.tsx— AddedhtmlBlockcases toconvertPMNodeandconvertPTBlockfor round-trip conversion. Added/htmlslash command. RegisteredHtmlBlockExtensionin the extensions array.packages/core/src/components/InlinePortableTextEditor.tsx— AddedhtmlBlockcases to both converters, a read-onlyHtmlBlockNodeTipTap extension (placeholder, likePluginBlockNode),/htmlslash command, and registered the extension.packages/core/src/content/converters/types.ts— NewPortableTextHtmlBlockinterface, added to thePortableTextBlockunion.packages/core/src/content/converters/prosemirror-to-portable-text.ts— NewconvertHtmlBlockhandler in theconvertNodeswitch.packages/core/src/content/converters/portable-text-to-prosemirror.ts— NewhtmlBlockcase inconvertBlock.packages/core/src/index.ts— ExportedPortableTextHtmlBlocktype.Key design decisions
Atom node, not editable content — HTML blocks are atom nodes (like images and plugin blocks), not inline-editable text regions. The textarea is rendered via the React node view, not ProseMirror content editing.
DOMPurify for preview — The admin package already depends on
dompurify. The preview toggle runs throughDOMPurify.sanitize()so authors see what will actually render, matching the server-sidesanitizeContentin core.All user-facing strings wrapped with Lingui —
ttemplate literals for all button labels, descriptions, placeholder text, and aria attributes.RTL-safe — Uses logical properties (
start/end) throughout.Inline editor as read-only placeholder — The visual-editing (inline) editor shows HTML blocks as a simple "HTML block (edit in admin)" placeholder, preserving the data losslessly on round-trip without needing to mount the full editing UI.
Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/html-block-editor. Updated automatically when the playground redeploys.