Skip to content
ilinxa/pro-ui

Rich Text Editor

alphav0.3.0

Plate-powered WYSIWYG editor and read-only viewer for long-form articles — code blocks, captioned images, floating toolbar, HTML export.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/rich-text-editor

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/rich-text-editor-fixtures

CLI 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

How sustainable cities are rethinking density

Across Europe and the Americas, urban planners are rewriting the rules — turning suburbs into 15-minute neighborhoods and waterfronts into thriving public spaces.

What changed in 2025

Three major policy shifts reshaped how cities approach growth: zoning reform, transit-oriented development, and carbon-constrained budgets.

We're no longer optimizing for cars — we're optimizing for the time people spend with their neighbors.

Three ideas worth tracking

Mixed-use density at every transit stop
Streets reclaimed as plazas (the Barcelona model)
Carbon-budgeted permitting (the Helsinki model)

For more, see  the full report .




Run an experiment:  code-friendly  variations of urban planning APIs.

Press Ctrl+S to save

Press Cmd/Ctrl+S to fire onSave. Select text to surface the floating toolbar.

Demo source

demo.tsxtsx
"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+S to fire onSave with the current document. Override autoFocus to focus the editor on mount.
  • The editor is echo-guarded on the value prop: external updates flow in via setValue on the editor without triggering an extra onChange emit.
  • 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

Tags

rich-text-editorrich-textwysiwygeditorviewerplateplatejslowlightsyntax-highlightingimagehtml-exportdata

Dependencies

shadcn primitives: button
npm peer deps: platejs@^53.0.3, @platejs/basic-nodes@^53.0.0, @platejs/basic-styles@^53.0.0, @platejs/caption@^53.0.0, @platejs/code-block@^53.0.0, @platejs/indent@^53.0.0, @platejs/link@^53.0.3, @platejs/list@^53.0.2, @platejs/media@^53.0.1, @platejs/table@^53.0.0, @floating-ui/react@^0.27.19, lowlight@^3.3.0, highlight.js@^11.11.1, lucide-react@^1.11.0