Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/html-block-editor.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions docs/src/content/docs/guides/working-with-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ EmDash's editor supports:
- **Links** - Internal and external
- **Images** - Insert from media library
- **Code blocks** - With syntax highlighting
- **HTML blocks** - Raw HTML for custom embeds and widgets
- **Embeds** - YouTube, Vimeo, Twitter
- **Sections** - Reusable content blocks via `/section` command

Expand All @@ -75,6 +76,7 @@ Type `/` to access quick insert commands:
| `/section` | Insert a reusable section |
| `/image` | Insert an image from media library |
| `/code` | Insert a code block |
| `/html` | Insert a raw HTML block |

### Keyboard Shortcuts

Expand All @@ -99,6 +101,61 @@ Type `/` to access quick insert commands:

5. Click **Insert**

### HTML Blocks

Use `/html` to insert a raw HTML block. This is useful for embedding third-party widgets, custom markup, or content that doesn't fit the standard block types. HTML blocks are also created automatically when importing content from WordPress or Contentful that contains markup EmDash can't convert to native Portable Text blocks.

<Aside type="caution">
HTML blocks are sanitized before rendering on the frontend to prevent XSS attacks. By default, iframes are only allowed from `www.youtube.com` and `player.vimeo.com`. Iframes from other providers (Cloudflare Stream, Loom, Wistia, Google Maps, etc.) will be stripped during sanitization.
</Aside>

To allow iframes from additional providers, override the `htmlBlock` component in your Portable Text rendering:

```astro
---
// src/components/MyHtmlBlock.astro
import sanitizeHtml from "sanitize-html";

const { node } = Astro.props;

if (!node?.html) {
return null;
}

const sanitized = sanitizeHtml(node.html, {
allowedTags: [...sanitizeHtml.defaults.allowedTags, "img", "span", "iframe"],
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
"*": ["class", "id", "data-*", "style"],
iframe: ["src", "width", "height", "frameborder", "allow", "allowfullscreen"],
img: ["src", "srcset", "alt", "title", "width", "height", "loading"],
},
allowedIframeHostnames: [
"www.youtube.com",
"player.vimeo.com",
"iframe.videodelivery.net", // Cloudflare Stream
// Add your providers here
],
});
---

<div class="html-block" set:html={sanitized} />
```

Then pass it to `<PortableText>`:

```astro
---
import { PortableText } from "emdash/ui";
import MyHtmlBlock from "../components/MyHtmlBlock.astro";
---

<PortableText
value={post.data.content}
components={{ type: { htmlBlock: MyHtmlBlock } }}
/>
```

## Editing Content

1. Navigate to the collection containing the content
Expand Down
35 changes: 35 additions & 0 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
Minus,
LinkBreak,
ArrowSquareOut,
BracketsAngle,
CodeBlock,
Stack,
Eye,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -2088,6 +2122,7 @@ export function PortableTextEditor({
underline: {},
}),
CodeBlockExtension,
HtmlBlockExtension,
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
Expand Down
202 changes: 202 additions & 0 deletions packages/admin/src/components/editor/HtmlBlockNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* 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
* - 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.
*/

import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { BracketsAngle, DotsSixVertical, Trash } from "@phosphor-icons/react";
import { Node, mergeAttributes } from "@tiptap/core";
import type { NodeViewProps } from "@tiptap/react";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
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 [draft, setDraft] = React.useState(html);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);

// Sync draft when the stored html changes from outside the node view.
React.useEffect(() => {
setDraft(html);
}, [html]);

// Auto-resize textarea to fit content.
React.useEffect(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
}, [draft]);

const commitHtml = React.useCallback(
(value: string) => {
updateAttributes({ html: value });
},
[updateAttributes],
);

const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraft(e.target.value);
commitHtml(e.target.value);
},
[commitHtml],
);

return (
<NodeViewWrapper
className={cn(
"html-block relative my-3",
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
)}
contentEditable={false}
data-drag-handle
>
<div className="relative group">
{/* Drag handle */}
<div
className={cn(
"absolute -start-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing",
selected && "opacity-100",
)}
data-drag-handle
>
<DotsSixVertical className="h-5 w-5 text-kumo-subtle/50" />
</div>

{/* Main block */}
<div
className={cn(
"rounded-lg border bg-kumo-base transition-colors overflow-hidden",
selected ? "border-kumo-brand/50 bg-kumo-tint/30" : "hover:border-kumo-line",
)}
>
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-kumo-tint flex items-center justify-center text-kumo-subtle">
<BracketsAngle className="h-5 w-5" />
</div>

<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{t`HTML`}</div>
</div>

{/* Actions */}
<div
className={cn(
"flex items-center gap-1 transition-opacity",
selected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
<Button
type="button"
variant="ghost"
shape="square"
className="h-8 w-8 text-kumo-danger hover:text-kumo-danger hover:bg-kumo-danger/10"
onClick={() => deleteNode()}
title={t`Delete`}
aria-label={t`Delete HTML block`}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>

{/* Content area */}
<div className="px-4 pb-4">
<textarea
ref={textareaRef}
value={draft}
onChange={handleChange}
placeholder={t`Enter HTML...`}
className="w-full min-h-[100px] resize-y rounded-md border bg-kumo-overlay p-3 font-mono text-sm text-kumo-strong placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-brand"
spellCheck={false}
aria-label={t`HTML source`}
/>
</div>
</div>
</div>
</NodeViewWrapper>
);
}

// ---------------------------------------------------------------------------
// TipTap Extension
// ---------------------------------------------------------------------------

/**
* TipTap extension: first-class HTML block.
*
* An atom node that stores raw HTML in a `html` attribute. Round-trips
* through Portable Text as `{ _type: "htmlBlock", _key, html }`.
*/
export const HtmlBlockExtension = Node.create({
name: "htmlBlock",
group: "block",
atom: true,
draggable: true,
selectable: true,

addAttributes() {
return {
html: {
default: "",
},
};
},

parseHTML() {
return [
{
tag: "div[data-html-block]",
},
];
},

renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-html-block": "" })];
},

addNodeView() {
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();
return true;
}
return false;
},
};
},
});
Loading
Loading