From e80eac9633b20937847e232715f80a0ae9519845 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 29 May 2026 11:44:17 -0700 Subject: [PATCH 1/3] feat: first-class HTML block in the admin editor 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/html-block-editor.md | 6 + .../src/components/PortableTextEditor.tsx | 35 +++ .../src/components/editor/HtmlBlockNode.tsx | 240 ++++++++++++++++++ .../components/InlinePortableTextEditor.tsx | 69 +++++ .../portable-text-to-prosemirror.ts | 7 + .../prosemirror-to-portable-text.ts | 16 ++ packages/core/src/content/converters/types.ts | 10 + packages/core/src/index.ts | 1 + .../converters/html-block-round-trip.test.ts | 87 +++++++ 9 files changed, 471 insertions(+) create mode 100644 .changeset/html-block-editor.md create mode 100644 packages/admin/src/components/editor/HtmlBlockNode.tsx create mode 100644 packages/core/tests/unit/converters/html-block-round-trip.test.ts diff --git a/.changeset/html-block-editor.md b/.changeset/html-block-editor.md new file mode 100644 index 000000000..3aa0fc206 --- /dev/null +++ b/.changeset/html-block-editor.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +First-class HTML block in the admin editor. The existing `htmlBlock` Portable Text type (produced by the WordPress and Contentful importers) is now a fully editable block in the rich-text editor. Authors can insert an HTML block via the `/html` slash command, edit raw HTML in a textarea, and toggle a sanitized preview. Imported `htmlBlock` content that previously fell through to an opaque `pluginBlock` placeholder is now rendered in the same editable UI. The inline (visual-editing) editor preserves HTML blocks as read-only placeholders to prevent data loss. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index b7b3a0ddb..8e54a8874 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -56,6 +56,7 @@ import { Minus, LinkBreak, ArrowSquareOut, + BracketsAngle, CodeBlock, Stack, Eye, @@ -93,6 +94,7 @@ import { CaretNext } from "./ArrowIcons.js"; import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField"; import { CodeBlockExtension } from "./editor/CodeBlockNode"; import { DragHandleWrapper } from "./editor/DragHandleWrapper"; +import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; import { ImageExtension } from "./editor/ImageNode"; import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension"; import { @@ -277,6 +279,15 @@ function convertPMNode(node: { }; } + case "htmlBlock": { + const rawHtml = node.attrs?.html; + return { + _type: "htmlBlock", + _key: generateKey(), + html: typeof rawHtml === "string" ? rawHtml : "", + }; + } + case "image": { const attrs = node.attrs ?? {}; const provider = attrStr(attrs.provider); @@ -640,6 +651,14 @@ function convertPTBlock(block: PortableTextBlock): unknown { case "break": return { type: "horizontalRule" }; + case "htmlBlock": { + const htmlBlock = block as { _type: "htmlBlock"; _key: string; html?: string }; + return { + type: "htmlBlock", + attrs: { html: htmlBlock.html || "" }, + }; + } + case "table": { const tableBlock = block as { _type: "table"; @@ -922,6 +941,21 @@ const defaultSlashCommands: SlashCommandItem[] = [ editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); }, }, + { + id: "htmlBlock", + title: msg`HTML`, + description: msg`Insert raw HTML`, + icon: BracketsAngle, + aliases: ["html", "raw", "markup"], + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ type: "htmlBlock", attrs: { html: "" } }) + .run(); + }, + }, { id: "divider", title: msg`Divider`, @@ -2088,6 +2122,7 @@ export function PortableTextEditor({ underline: {}, }), CodeBlockExtension, + HtmlBlockExtension, ImageExtension, MarkdownLinkExtension, PluginBlockExtension, diff --git a/packages/admin/src/components/editor/HtmlBlockNode.tsx b/packages/admin/src/components/editor/HtmlBlockNode.tsx new file mode 100644 index 000000000..2d8b34ea8 --- /dev/null +++ b/packages/admin/src/components/editor/HtmlBlockNode.tsx @@ -0,0 +1,240 @@ +/** + * HTML block node for the admin editor. + * + * Renders a first-class `htmlBlock` in the Portable Text editor with: + * - A textarea for editing raw HTML source + * - A "Preview" toggle that sanitizes and renders the HTML + * - Selection ring, drag handle, and delete action + * + * Modeled on `PluginBlockNode.tsx` (atom node with React node view) and + * the existing `{ _type: "htmlBlock", _key, html }` Portable Text shape + * used by the WordPress and Contentful importers. + * + * The preview runs through DOMPurify so authors see what will actually + * render, matching the server-side `sanitizeContent` in core. + */ + +import { Button } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { BracketsAngle, DotsSixVertical, Eye, PencilSimple, Trash } from "@phosphor-icons/react"; +import { Node, mergeAttributes } from "@tiptap/core"; +import type { NodeViewProps } from "@tiptap/react"; +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; +import DOMPurify from "dompurify"; +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +// --------------------------------------------------------------------------- +// Node View +// --------------------------------------------------------------------------- + +function HtmlBlockNodeView({ node, updateAttributes, selected, deleteNode }: NodeViewProps) { + const { t } = useLingui(); + const html = typeof node.attrs.html === "string" ? node.attrs.html : ""; + const [showPreview, setShowPreview] = React.useState(false); + const [draft, setDraft] = React.useState(html); + const textareaRef = React.useRef(null); + + // Sync draft when the stored html changes from outside the node view. + React.useEffect(() => { + if (!showPreview) { + setDraft(html); + } + }, [html, showPreview]); + + // Auto-resize textarea to fit content. + React.useEffect(() => { + const el = textareaRef.current; + if (el && !showPreview) { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + } + }, [draft, showPreview]); + + const commitHtml = React.useCallback( + (value: string) => { + updateAttributes({ html: value }); + }, + [updateAttributes], + ); + + const handleChange = React.useCallback( + (e: React.ChangeEvent) => { + setDraft(e.target.value); + commitHtml(e.target.value); + }, + [commitHtml], + ); + + const sanitizedHtml = React.useMemo(() => DOMPurify.sanitize(html), [html]); + + return ( + +
+ {/* Drag handle */} +
+ +
+ + {/* Main block */} +
+ {/* Header */} +
+
+ +
+ +
+
{t`HTML`}
+
+ {html ? `${html.length} ${t`characters`}` : t`Empty`} +
+
+ + {/* Actions */} +
+ + +
+
+ + {/* Content area */} +
+ {showPreview ? ( + html ? ( +
+ ) : ( +
+ {t`No HTML content to preview`} +
+ ) + ) : ( +