Rich Text Editor
alphav0.3.0Plate-powered WYSIWYG editor and read-only viewer for long-form articles — code blocks, captioned images, floating toolbar, HTML export.
Context
First Plate (platejs) component in pro-ui. Two exports from one folder: `<RichTextEditor>` is a 'use client' editor (~165KB gzip after v0.2 additions) with a fixed top toolbar (marks / headings / lists / blockquote / link / image / table / code-block / font-family / font-size / color) PLUS a selection-anchored floating toolbar (marks + link). `<RichTextViewer>` is server-renderable via `platejs/static` (~32KB gzip). Both consume the same Plate `Value` JSON shape. Storage format is JSON, not HTML — Plate's docs warn against HTML round-trips. v0.2 adds: lowlight code-block syntax highlighting (15 languages registered, tokens themed via chart-1..5 palette in globals.css); per-image resize handle + inline caption editor (width stored as %); floating toolbar (selection-anchored, anchored via @floating-ui/react); HTML serialization escape hatch via `serializeRichTextToHtml(value)` for RSS / email / OG-tag export boundaries.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/rich-text-editorAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/rich-text-editor-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Press Cmd/Ctrl+S to fire onSave. Select text to surface the floating toolbar.
Demo source
"use client"; import { useEffect, useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { RichTextEditor } from "./rich-text-editor";import { RichTextViewer } from "./rich-text-viewer";import { RICH_TEXT_DUMMY_CODE, RICH_TEXT_DUMMY_EMPTY, RICH_TEXT_DUMMY_IMAGE, RICH_TEXT_DUMMY_RICH, RICH_TEXT_DUMMY_SIMPLE, RICH_TEXT_DUMMY_TABLE,} from "./dummy-data";import { serializeRichTextToHtml } from "./lib/serialize-html";import type { RichTextValue, ImageUploader } from "./types"; const fakeUploader: ImageUploader = async (file) => { await new Promise((r) => setTimeout(r, 400)); return { src: URL.createObjectURL(file), alt: file.name, };}; function HtmlExportPanel({ value }: { value: RichTextValue }) { const [html, setHtml] = useState<string>(""); const [error, setError] = useState<string | null>(null); useEffect(() => { let cancelled = false; serializeRichTextToHtml(value, { stripDataAttributes: true }) .then((result) => { if (!cancelled) setHtml(result); }) .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); }); return () => { cancelled = true; }; }, [value]); if (error) { return ( <pre className="rounded-md border border-destructive/50 bg-destructive/5 p-4 font-mono text-xs text-destructive"> Serialization failed: {error} </pre> ); } return ( <pre className="max-h-96 overflow-auto rounded-md border border-border bg-muted p-4 font-mono text-xs"> <code>{html || "(serializing…)"}</code> </pre> );} export default function RichTextEditorDemo() { const [edited, setEdited] = useState<RichTextValue>( RICH_TEXT_DUMMY_RICH ); const [savedAt, setSavedAt] = useState<string | null>(null); return ( <Tabs defaultValue="editor" className="w-full"> <SwipeTabsList> <TabsTrigger value="editor">Editor</TabsTrigger> <TabsTrigger value="viewer">Viewer</TabsTrigger> <TabsTrigger value="roundtrip">Edit ↔ View</TabsTrigger> <TabsTrigger value="code">Code (syntax)</TabsTrigger> <TabsTrigger value="image">Image (resize + caption)</TabsTrigger> <TabsTrigger value="export">HTML export</TabsTrigger> <TabsTrigger value="empty">Empty</TabsTrigger> <TabsTrigger value="json">JSON</TabsTrigger> </SwipeTabsList> <TabsContent value="editor" className="mt-6"> <RichTextEditor defaultValue={RICH_TEXT_DUMMY_RICH} onImageUpload={fakeUploader} onSave={(value) => { setSavedAt(new Date().toLocaleTimeString()); setEdited(value); }} /> {savedAt ? ( <p className="mt-2 text-xs text-muted-foreground"> Saved at {savedAt}. Select text to surface the floating toolbar. </p> ) : ( <p className="mt-2 text-xs text-muted-foreground"> Press Cmd/Ctrl+S to fire <code>onSave</code>. Select text to surface the floating toolbar. </p> )} </TabsContent> <TabsContent value="viewer" className="mt-6"> <div className="rounded-lg border border-border bg-card p-6"> <RichTextViewer value={RICH_TEXT_DUMMY_RICH} /> </div> <p className="mt-2 text-xs text-muted-foreground"> The viewer is a pure server-renderable component — no editor instance mounted. </p> </TabsContent> <TabsContent value="roundtrip" className="mt-6 space-y-4"> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Editor </p> <RichTextEditor value={edited} onChange={setEdited} onImageUpload={fakeUploader} /> </div> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Viewer (live) </p> <div className="rounded-lg border border-border bg-card p-6"> <RichTextViewer value={edited} /> </div> </div> </TabsContent> <TabsContent value="code" className="mt-6 space-y-4"> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Editor — code blocks with lowlight syntax highlighting </p> <RichTextEditor defaultValue={RICH_TEXT_DUMMY_CODE} /> </div> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Viewer (server-rendered, identical highlighting) </p> <div className="rounded-lg border border-border bg-card p-6"> <RichTextViewer value={RICH_TEXT_DUMMY_CODE} /> </div> </div> <p className="text-xs text-muted-foreground"> Languages registered: bash, css, diff, go, html, java, javascript, json, markdown, python, rust, sql, typescript, xml, yaml. Token colors map to the chart-1..5 palette in <code>globals.css</code>. </p> </TabsContent> <TabsContent value="image" className="mt-6 space-y-4"> <p className="text-xs text-muted-foreground"> Hover the image to reveal the right-edge resize handle. Click the caption text below it to edit. Both width and caption are stored on the node and persist through the JSON. </p> <RichTextEditor defaultValue={RICH_TEXT_DUMMY_IMAGE} onImageUpload={fakeUploader} /> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Viewer (preserves width + caption) </p> <div className="rounded-lg border border-border bg-card p-6"> <RichTextViewer value={RICH_TEXT_DUMMY_IMAGE} /> </div> </div> </TabsContent> <TabsContent value="export" className="mt-6 space-y-4"> <p className="text-xs text-muted-foreground"> Storage stays JSON (Plate <code>Value</code>). Call{" "} <code>serializeRichTextToHtml(value)</code> at export boundaries (RSS / email / OG tags) to get a clean HTML string. Async — uses react-dom/server under the hood. Server-only. </p> <RichTextEditor value={edited} onChange={setEdited} onImageUpload={fakeUploader} /> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Live HTML export </p> <HtmlExportPanel value={edited} /> </div> </TabsContent> <TabsContent value="empty" className="mt-6"> <RichTextEditor defaultValue={RICH_TEXT_DUMMY_EMPTY} /> <p className="mt-2 text-xs text-muted-foreground"> Empty starting state. Type to begin. </p> </TabsContent> <TabsContent value="json" className="mt-6 space-y-4"> <RichTextEditor defaultValue={RICH_TEXT_DUMMY_SIMPLE} onChange={setEdited} /> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Live JSON </p> <pre className="max-h-80 overflow-auto rounded-md border border-border bg-muted p-4 font-mono text-xs"> <code>{JSON.stringify(edited, null, 2)}</code> </pre> </div> <div className="grid gap-4 md:grid-cols-2"> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Code-block fixture </p> <div className="rounded-lg border border-border bg-card p-6"> <RichTextViewer value={RICH_TEXT_DUMMY_CODE} /> </div> </div> <div> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Table fixture </p> <div className="rounded-lg border border-border bg-card p-6"> <RichTextViewer value={RICH_TEXT_DUMMY_TABLE} /> </div> </div> </div> </TabsContent> </Tabs> );} Usage
When to use
A WYSIWYG rich-text editor + read-only viewer for long-form article bodies — news, blog posts, doc pages, knowledge-base entries. Built on Plate (platejs), pro-ui's WYSIWYG substrate.
Two exports from one folder: RichTextEditor is a "use client" editor (~150KB gzip); RichTextVieweris server-renderable (~30KB gzip). Storage format is JSON — Plate's docs warn against HTML round-trips.
Editor
"use client"
import {
RichTextEditor,
type RichTextValue,
} from "@/components/rich-text-editor"
export function ArticleEditPage({ initial }: { initial: RichTextValue }) {
const [value, setValue] = useState(initial)
return (
<RichTextEditor
value={value}
onChange={setValue}
onSave={async (v) => {
await fetch("/api/articles/save", { method: "POST", body: JSON.stringify(v) })
}}
onImageUpload={async (file) => {
const fd = new FormData()
fd.append("file", file)
const res = await fetch("/api/upload", { method: "POST", body: fd })
return res.json() // { src, alt?, width?, height? }
}}
/>
)
}Viewer (RSC)
The viewer is a server component — no "use client" boundary needed. It uses platejs/static to render the JSON without instantiating an editor.
import { RichTextViewer } from "@/components/rich-text-editor"
export default async function NewsArticle({ params }: { params: { id: string } }) {
const article = await fetchArticle(params.id)
return (
<article>
<h1>{article.title}</h1>
<RichTextViewer value={article.body} />
</article>
)
}Composed with the news-domain article column
<article className="lg:col-span-8">
<h1>{article.title}</h1>
<ArticleMeta divider items={metaItems} />
<RichTextViewer value={article.body} />
<ShareBar targets={[...]} title={article.title} headingAs="h4" divider />
</article>Image upload contract
The component never talks to a backend directly. Pass onImageUpload — a function that takes a File and resolves to { src, alt?, width?, height? }. Without this prop, the editor falls back to a URL prompt.
Toolbar
Marks: bold, italic, underline, strikethrough, inline code, highlight, subscript, superscript. Blocks: H1–H3, blockquote, code block, horizontal rule. Lists: bullet, ordered. Insert: link, image, table. Styles: font family, font size, text color.
Notes
- Storage format is the Plate
Valuetype (an array of element nodes). Don't round-trip through HTML — keep JSON authoritative; serialize to HTML only at export boundaries (RSS, email, etc.). - The viewer renders bare HTML with Plate's data-slate-* attributes stripped — no editor instance, no client hooks. Safe in Server Components.
- Press
Cmd/Ctrl+Sto fireonSavewith the current document. OverrideautoFocusto focus the editor on mount. - The editor is
echo-guardedon thevalueprop: external updates flow in viasetValueon the editor without triggering an extraonChangeemit. - Toolbar buttons are
onMouseDown-driven so the editor selection isn't lost when clicking a button. - Plate ships a brand-recognizable suite of plugins under
@platejs/*. This component pins to v53. When bumping Plate, re-verify SSR, image upload, and the JSON shape.
Features
- Editor + Viewer split — same JSON shape, different bundle profiles
- Fixed top toolbar (marks, headings, lists, blockquote, link, image, table, code, font-family, font-size, color)
- Floating toolbar (selection-anchored marks + link insertion) — appears on text selection, anchored via @floating-ui/react virtual element
- Lowlight syntax highlighting on code blocks (15 languages: bash / css / diff / go / html / java / javascript / json / markdown / python / rust / shell / sql / typescript / xml / yaml; tokens themed via pro-ui chart palette)
- Image resize handle + inline caption editor (width stored as percentage, caption editable inline)
- HTML serialization escape hatch via serializeRichTextToHtml(value) — async, server-only, for export boundaries (RSS / email / OG tags)
- Image insertion via onImageUpload(file) Promise OR URL prompt fallback
- Tables (insert + keyboard-driven row/col ops via Plate's table plugin)
- Indent / outdent on paragraphs / headings / blockquotes / code blocks
- Controlled OR uncontrolled value (echo-guarded sync from external value prop)
- Cmd/Ctrl+S → onSave(value); platform-aware key descriptor in footer
- JSON-as-storage (not HTML round-trips per Plate's storage guidance)
- Read-only mode hides both toolbars; editor still selectable
- Pure server-renderable viewer via createStaticEditor + PlateStatic
- Tailwind v4 + signal-lime token integration; prose styling via @tailwindcss/typography