# Rich Text Editor

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

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/rich-text-editor).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/rich-text-editor
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/rich-text-editor-light-0725aeaabfa3ca31.png
- Registry item: https://ui.ilinxa.com/r/rich-text-editor.json
- Categories: data, richtext
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/rich-text-editor.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@floating-ui/react`, `@platejs/basic-nodes`, `@platejs/basic-styles`, `@platejs/caption`, `@platejs/code-block`, `@platejs/indent`, `@platejs/link`, `@platejs/list`, `@platejs/media`, `@platejs/resizable`, `@platejs/table`, `highlight.js`, `lowlight`, `lucide-react`, `platejs`
- Registry items: `button`

## Files

### `src/registry/components/data/rich-text-editor/rich-text-editor.tsx`

```tsx
"use client";

import { useCallback, useEffect, useRef } from "react";
import { Plate, PlateContent, usePlateEditor } from "platejs/react";
import { cn } from "@/lib/utils";
import { richTextPlugins } from "./plugins/editor-kit";
import { EditorToolbar } from "./parts/editor-toolbar";
import { FloatingToolbar } from "./parts/floating-toolbar";
import {
  RICH_TEXT_DEFAULT_PLACEHOLDER,
  RICH_TEXT_EMPTY_VALUE,
  type RichTextEditorProps,
  type RichTextValue,
} from "./types";

const SAVE_KEY_DESCRIPTOR =
  typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
    ? "Cmd+S"
    : "Ctrl+S";

export function RichTextEditor(props: RichTextEditorProps) {
  const {
    value: controlledValue,
    defaultValue,
    onChange,
    onSave,
    readOnly = false,
    placeholder = RICH_TEXT_DEFAULT_PLACEHOLDER,
    onImageUpload,
    className,
    toolbarClassName,
    contentClassName,
    containerClassName,
    hideToolbar = false,
    autoFocus = false,
  } = props;

  const initialValue =
    controlledValue ?? defaultValue ?? RICH_TEXT_EMPTY_VALUE;

  const editor = usePlateEditor({
    plugins: richTextPlugins,
    value: initialValue,
    autoSelect: autoFocus ? "end" : undefined,
  });

  // v0.2.2 — echo-guarded sync, now content-keyed. When `controlledValue`
  // prop changes by REFERENCE only (e.g., RHF-controlled forms emit a fresh
  // reference on every state change even when content is identical), the
  // previous ref-equality check would fire `editor.tf.setValue` on every
  // render. That re-applies the editor's current content as a "new" value,
  // which resets Slate's selection — the user's cursor disappears
  // immediately after every click, and typing can't land because each
  // keystroke is followed by a setValue that wipes selection. Worse, on
  // RHF + React 19, the rapid setValue → onChange → setState cascade
  // tripped React error #185 (Maximum update depth exceeded). The fix is
  // to gate the sync on the content key (cheap stringify of the Plate
  // tree) — fire setValue only when the content actually differs from
  // what the editor last emitted or accepted, regardless of how many fresh
  // references the consumer pipes through `value`.
  const lastSyncedKeyRef = useRef<string>(serializeValueKey(initialValue));
  useEffect(() => {
    if (!controlledValue) return;
    const nextKey = serializeValueKey(controlledValue);
    if (nextKey === lastSyncedKeyRef.current) return;
    editor.tf.setValue(controlledValue);
    lastSyncedKeyRef.current = nextKey;
  }, [controlledValue, editor]);

  const handleChange = useCallback(
    ({ value }: { value: RichTextValue }) => {
      lastSyncedKeyRef.current = serializeValueKey(value);
      onChange?.(value);
    },
    [onChange]
  );

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s" && onSave) {
        e.preventDefault();
        const current = editor.children as RichTextValue;
        Promise.resolve(onSave(current)).catch((err) => {
          console.error("onSave failed", err);
        });
      }
    },
    [editor, onSave]
  );

  return (
    <div
      className={cn(
        "rounded-lg border border-border bg-card overflow-hidden",
        className
      )}
    >
      <Plate editor={editor} onChange={handleChange} readOnly={readOnly}>
        {!hideToolbar && !readOnly ? (
          <EditorToolbar
            className={toolbarClassName}
            onImageUpload={onImageUpload}
          />
        ) : null}

        <div
          className={cn(
            "max-h-150 overflow-y-auto px-6 py-4",
            containerClassName
          )}
        >
          <PlateContent
            placeholder={placeholder}
            className={cn(
              "min-h-50 focus:outline-none",
              "prose prose-sm dark:prose-invert max-w-none",
              "[&_:where(p,h1,h2,h3,h4)]:my-0",
              contentClassName
            )}
            onKeyDown={handleKeyDown}
            spellCheck
          />
        </div>

        {!readOnly ? <FloatingToolbar /> : null}

        {!readOnly && onSave ? (
          <div className="border-t border-border bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
            Press{" "}
            <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.85em]">
              {SAVE_KEY_DESCRIPTOR}
            </kbd>{" "}
            to save
          </div>
        ) : null}
      </Plate>
    </div>
  );
}

/**
 * v0.2.2 — content key for the echo-guarded controlled-value sync.
 * Stringify is adequate for Plate JSON (no functions, no cycles) and
 * cheap relative to a keystroke's frame budget for documents that fit on
 * one screen. Returns a random hash on circular-ref edge cases so the
 * effect still fires (defensive — we'd rather over-sync than skip a real
 * external update). */
function serializeValueKey(value: RichTextValue): string {
  try {
    return JSON.stringify(value);
  } catch {
    return String(Math.random());
  }
}

// Re-exports for cross-procomp consumers (e.g. json-form's `richtext` field
// renderer). Imports targeting `./types` from another procomp's shipped
// source get rewritten by shadcn 4.6.0 to `./types` of the CURRENT slug
// (F-S1 cross-procomp `/types` bug), so cross-procomp imports must come from
// this component file instead — its path the rewriter handles correctly.
export {
  RICH_TEXT_EMPTY_VALUE,
  type RichTextValue,
  type ImageUploader,
} from "./types";
```

### `src/registry/components/data/rich-text-editor/rich-text-viewer.tsx`

```tsx
import { createStaticEditor, PlateStatic } from "platejs/static";
import { cn } from "@/lib/utils";
import { richTextViewerPlugins } from "./plugins/viewer-kit";
import {
  RICH_TEXT_EMPTY_VALUE,
  type RichTextValue,
  type RichTextViewerProps,
} from "./types";

const VIEWER_PROSE_CLASSES =
  "prose prose-sm dark:prose-invert max-w-none [&_:where(p,h1,h2,h3,h4)]:my-0";

export function RichTextViewer({
  value,
  className,
  fallback,
}: RichTextViewerProps) {
  if (!value || value.length === 0) {
    if (fallback !== undefined) return <>{fallback}</>;
    return (
      <div className={cn(VIEWER_PROSE_CLASSES, className)}>
        <p className="text-muted-foreground">No content.</p>
      </div>
    );
  }

  const editor = createStaticEditor({
    plugins: richTextViewerPlugins,
    value: value as RichTextValue,
  });

  return (
    <div className={cn(VIEWER_PROSE_CLASSES, className)}>
      <PlateStatic editor={editor} value={value as RichTextValue} />
    </div>
  );
}

// Re-export for ergonomic destructuring + the empty-value sentinel.
export { RICH_TEXT_EMPTY_VALUE };
```

### `src/registry/components/data/rich-text-editor/index.ts`

```ts
export { RichTextEditor } from "./rich-text-editor";
export { RichTextViewer } from "./rich-text-viewer";
export {
  RICH_TEXT_DEFAULT_PLACEHOLDER,
  RICH_TEXT_EMPTY_VALUE,
  type RichTextEditorProps,
  type RichTextValue,
  type RichTextViewerProps,
  type ImageUploadResult,
  type ImageUploader,
} from "./types";
export {
  serializeRichTextToHtml,
  type SerializeHtmlOptions,
} from "./lib/serialize-html";
```

### `src/registry/components/data/rich-text-editor/types.ts`

```ts
import type { ReactNode } from "react";
import type { Value } from "platejs";

export type RichTextValue = Value;

export interface ImageUploadResult {
  src: string;
  alt?: string;
  width?: number;
  height?: number;
}

export type ImageUploader = (file: File) => Promise<ImageUploadResult>;

export interface RichTextEditorProps {
  value?: RichTextValue;
  defaultValue?: RichTextValue;
  onChange?: (value: RichTextValue) => void;
  onSave?: (value: RichTextValue) => void | Promise<void>;
  readOnly?: boolean;
  placeholder?: string;
  onImageUpload?: ImageUploader;
  className?: string;
  toolbarClassName?: string;
  contentClassName?: string;
  containerClassName?: string;
  hideToolbar?: boolean;
  autoFocus?: boolean;
}

export interface RichTextViewerProps {
  value: RichTextValue;
  className?: string;
  fallback?: ReactNode;
}

export const RICH_TEXT_EMPTY_VALUE: RichTextValue = [
  { type: "p", children: [{ text: "" }] },
];

export const RICH_TEXT_DEFAULT_PLACEHOLDER = "Write your article here…";
```

### `src/registry/components/data/rich-text-editor/plugins/editor-kit.ts`

```ts
"use client";

import {
  BlockquotePlugin,
  BoldPlugin,
  CodePlugin,
  H1Plugin,
  H2Plugin,
  H3Plugin,
  H4Plugin,
  HighlightPlugin,
  HorizontalRulePlugin,
  ItalicPlugin,
  KbdPlugin,
  StrikethroughPlugin,
  SubscriptPlugin,
  SuperscriptPlugin,
  UnderlinePlugin,
} from "@platejs/basic-nodes/react";
import {
  FontBackgroundColorPlugin,
  FontColorPlugin,
  FontFamilyPlugin,
  FontSizePlugin,
} from "@platejs/basic-styles/react";
import { CaptionPlugin } from "@platejs/caption/react";
import { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from "@platejs/code-block/react";
import { IndentPlugin } from "@platejs/indent/react";
import { LinkPlugin } from "@platejs/link/react";
import { ListPlugin } from "@platejs/list/react";
import { ImagePlugin, MediaEmbedPlugin } from "@platejs/media/react";
import { KEYS } from "platejs";

import { lowlight } from "../lib/lowlight-instance";
import {
  TableCellHeaderPlugin,
  TableCellPlugin,
  TablePlugin,
  TableRowPlugin,
} from "@platejs/table/react";
import { ParagraphPlugin } from "platejs/react";

import {
  BlockquoteElement,
  CodeBlockElement,
  CodeLineElement,
  H1Element,
  H2Element,
  H3Element,
  H4Element,
  HrElement,
  ImageElement,
  LinkElement,
  ListElement,
  MediaEmbedElement,
  ParagraphElement,
  TableCellElement,
  TableCellHeaderElement,
  TableElement,
  TableRowElement,
} from "../parts/element-renderers";
import {
  CodeLeaf,
  HighlightLeaf,
  KbdLeaf,
} from "../parts/leaf-renderers";

/**
 * The plugin set for the editor side.
 * The viewer reuses these plugin definitions via createSlateEditor + components map
 * (see rich-text-viewer.tsx) so element/leaf rendering stays consistent.
 */
export const richTextPlugins = [
  // Blocks
  ParagraphPlugin.withComponent(ParagraphElement),
  H1Plugin.withComponent(H1Element),
  H2Plugin.withComponent(H2Element),
  H3Plugin.withComponent(H3Element),
  H4Plugin.withComponent(H4Element),
  BlockquotePlugin.withComponent(BlockquoteElement),
  HorizontalRulePlugin.withComponent(HrElement),

  // Lists
  ListPlugin.withComponent(ListElement),

  // Code blocks (with lowlight syntax highlighting)
  CodeBlockPlugin.configure({
    options: { lowlight },
    node: { component: CodeBlockElement },
  }),
  CodeLinePlugin.withComponent(CodeLineElement),
  CodeSyntaxPlugin,

  // Tables
  TablePlugin.withComponent(TableElement),
  TableRowPlugin.withComponent(TableRowElement),
  TableCellPlugin.withComponent(TableCellElement),
  TableCellHeaderPlugin.withComponent(TableCellHeaderElement),

  // Media + captions (caption applies to image/video/audio/file/embed)
  ImagePlugin.withComponent(ImageElement),
  MediaEmbedPlugin.withComponent(MediaEmbedElement),
  CaptionPlugin.configure({
    options: {
      query: {
        allow: [KEYS.img, KEYS.video, KEYS.audio, KEYS.file, KEYS.mediaEmbed],
      },
    },
  }),

  // Inline
  LinkPlugin.withComponent(LinkElement),

  // Marks
  BoldPlugin,
  ItalicPlugin,
  UnderlinePlugin,
  StrikethroughPlugin,
  CodePlugin.withComponent(CodeLeaf),
  HighlightPlugin.withComponent(HighlightLeaf),
  SubscriptPlugin,
  SuperscriptPlugin,
  KbdPlugin.withComponent(KbdLeaf),

  // Styles (text-level attributes — no element component)
  FontFamilyPlugin,
  FontSizePlugin,
  FontColorPlugin,
  FontBackgroundColorPlugin,

  // Layout
  IndentPlugin.configure({
    inject: {
      targetPlugins: [
        ParagraphPlugin.key,
        H1Plugin.key,
        H2Plugin.key,
        H3Plugin.key,
        H4Plugin.key,
        BlockquotePlugin.key,
        CodeBlockPlugin.key,
      ],
    },
  }),
];
```

### `src/registry/components/data/rich-text-editor/plugins/viewer-kit.ts`

```ts
import {
  BlockquotePlugin,
  BoldPlugin,
  CodePlugin,
  H1Plugin,
  H2Plugin,
  H3Plugin,
  H4Plugin,
  HighlightPlugin,
  HorizontalRulePlugin,
  ItalicPlugin,
  KbdPlugin,
  StrikethroughPlugin,
  SubscriptPlugin,
  SuperscriptPlugin,
  UnderlinePlugin,
} from "@platejs/basic-nodes/react";
import {
  FontBackgroundColorPlugin,
  FontColorPlugin,
  FontFamilyPlugin,
  FontSizePlugin,
} from "@platejs/basic-styles/react";
import { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from "@platejs/code-block/react";
import { LinkPlugin } from "@platejs/link/react";
import { ListPlugin } from "@platejs/list/react";
import { ImagePlugin, MediaEmbedPlugin } from "@platejs/media/react";

import { lowlight } from "../lib/lowlight-instance";
import {
  TableCellHeaderPlugin,
  TableCellPlugin,
  TablePlugin,
  TableRowPlugin,
} from "@platejs/table/react";
import { ParagraphPlugin } from "platejs/react";

import {
  StaticBlockquote,
  StaticCodeBlock,
  StaticCodeLeaf,
  StaticCodeLine,
  StaticH1,
  StaticH2,
  StaticH3,
  StaticH4,
  StaticHighlightLeaf,
  StaticHr,
  StaticImage,
  StaticKbdLeaf,
  StaticLink,
  StaticList,
  StaticMediaEmbed,
  StaticParagraph,
  StaticTable,
  StaticTableCell,
  StaticTableCellHeader,
  StaticTableRow,
} from "../static-elements/static-element-renderers";

/**
 * Plugin set for the static (RSC) viewer side.
 * Mirrors editor-kit.ts but binds Slate{Element,Leaf}-based renderers
 * — those don't use client-only hooks, so the viewer is server-renderable.
 */
export const richTextViewerPlugins = [
  // Blocks
  ParagraphPlugin.withComponent(StaticParagraph),
  H1Plugin.withComponent(StaticH1),
  H2Plugin.withComponent(StaticH2),
  H3Plugin.withComponent(StaticH3),
  H4Plugin.withComponent(StaticH4),
  BlockquotePlugin.withComponent(StaticBlockquote),
  HorizontalRulePlugin.withComponent(StaticHr),

  // Lists
  ListPlugin.withComponent(StaticList),

  // Code blocks (with lowlight syntax highlighting)
  CodeBlockPlugin.configure({
    options: { lowlight },
    node: { component: StaticCodeBlock },
  }),
  CodeLinePlugin.withComponent(StaticCodeLine),
  CodeSyntaxPlugin,

  // Tables
  TablePlugin.withComponent(StaticTable),
  TableRowPlugin.withComponent(StaticTableRow),
  TableCellPlugin.withComponent(StaticTableCell),
  TableCellHeaderPlugin.withComponent(StaticTableCellHeader),

  // Media
  ImagePlugin.withComponent(StaticImage),
  MediaEmbedPlugin.withComponent(StaticMediaEmbed),

  // Inline
  LinkPlugin.withComponent(StaticLink),

  // Marks
  BoldPlugin,
  ItalicPlugin,
  UnderlinePlugin,
  StrikethroughPlugin,
  CodePlugin.withComponent(StaticCodeLeaf),
  HighlightPlugin.withComponent(StaticHighlightLeaf),
  SubscriptPlugin,
  SuperscriptPlugin,
  KbdPlugin.withComponent(StaticKbdLeaf),

  // Styles
  FontFamilyPlugin,
  FontSizePlugin,
  FontColorPlugin,
  FontBackgroundColorPlugin,
];
```

### `src/registry/components/data/rich-text-editor/parts/editor-toolbar.tsx`

```tsx
"use client";

import {
  Bold,
  Code,
  Heading1,
  Heading2,
  Heading3,
  Highlighter,
  Image as ImageIcon,
  Italic,
  Link as LinkIcon,
  List,
  ListOrdered,
  Minus,
  Quote,
  Strikethrough,
  Subscript as SubIcon,
  Superscript as SupIcon,
  Table as TableIcon,
  TerminalSquare,
  Underline as UnderlineIcon,
} from "lucide-react";
import {
  BoldPlugin,
  CodePlugin,
  H1Plugin,
  H2Plugin,
  H3Plugin,
  HighlightPlugin,
  HorizontalRulePlugin,
  ItalicPlugin,
  StrikethroughPlugin,
  SubscriptPlugin,
  SuperscriptPlugin,
  UnderlinePlugin,
} from "@platejs/basic-nodes/react";
import { type ComponentType, type ReactNode, useCallback } from "react";
import { CodeBlockPlugin } from "@platejs/code-block/react";
import { LinkPlugin } from "@platejs/link/react";
import { ListPlugin } from "@platejs/list/react";
import { ImagePlugin } from "@platejs/media/react";
import { TablePlugin } from "@platejs/table/react";
import { ParagraphPlugin, useEditorRef } from "platejs/react";
import { cn } from "@/lib/utils";
import type { ImageUploader } from "../types";
import { BlockButton, MarkButton, useBlockToggle } from "./toolbar-buttons";

function HeadingButton({
  blockType,
  icon,
  label,
}: {
  blockType: string;
  icon: ComponentType<{ className?: string }>;
  label: string;
}) {
  const { isActive, toggle } = useBlockToggle(blockType);
  return <BlockButton isActive={isActive} onActivate={toggle} icon={icon} label={label} />;
}

function InsertHrButton() {
  const editor = useEditorRef();
  const handleClick = useCallback(() => {
    editor.tf.insertNodes({
      type: HorizontalRulePlugin.key,
      children: [{ text: "" }],
    });
    editor.tf.focus();
  }, [editor]);
  return (
    <BlockButton
      isActive={false}
      onActivate={handleClick}
      icon={Minus}
      label="Insert horizontal rule"
    />
  );
}

function InsertListButton({ ordered }: { ordered: boolean }) {
  const editor = useEditorRef();
  const handleClick = useCallback(() => {
    editor.tf.insertNodes({
      type: ListPlugin.key,
      listStyleType: ordered ? "decimal" : "disc",
      indent: 1,
      children: [{ text: "" }],
    });
    editor.tf.focus();
  }, [editor, ordered]);
  return (
    <BlockButton
      isActive={false}
      onActivate={handleClick}
      icon={ordered ? ListOrdered : List}
      label={ordered ? "Insert ordered list" : "Insert bullet list"}
    />
  );
}

function InsertTableButton() {
  const editor = useEditorRef();
  const handleClick = useCallback(() => {
    const cell = (text = "") => ({
      type: "td",
      children: [{ type: ParagraphPlugin.key, children: [{ text }] }],
    });
    const headerCell = (text = "") => ({
      type: "th",
      children: [{ type: ParagraphPlugin.key, children: [{ text }] }],
    });
    editor.tf.insertNodes({
      type: TablePlugin.key,
      children: [
        { type: "tr", children: [headerCell("Column 1"), headerCell("Column 2"), headerCell("Column 3")] },
        { type: "tr", children: [cell(), cell(), cell()] },
        { type: "tr", children: [cell(), cell(), cell()] },
      ],
    });
    editor.tf.focus();
  }, [editor]);
  return (
    <BlockButton
      isActive={false}
      onActivate={handleClick}
      icon={TableIcon}
      label="Insert table"
    />
  );
}

function InsertImageButton({ onImageUpload }: { onImageUpload?: ImageUploader }) {
  const editor = useEditorRef();

  const handleClick = useCallback(async () => {
    if (onImageUpload) {
      const input = document.createElement("input");
      input.type = "file";
      input.accept = "image/*";
      input.onchange = async () => {
        const file = input.files?.[0];
        if (!file) return;
        try {
          const result = await onImageUpload(file);
          editor.tf.insertNodes({
            type: ImagePlugin.key,
            url: result.src,
            alt: result.alt ?? file.name,
            width: result.width,
            height: result.height,
            children: [{ text: "" }],
          });
          editor.tf.focus();
        } catch (err) {
          console.error("Image upload failed", err);
        }
      };
      input.click();
      return;
    }

    const url = window.prompt("Image URL");
    if (!url) return;
    editor.tf.insertNodes({
      type: ImagePlugin.key,
      url,
      children: [{ text: "" }],
    });
    editor.tf.focus();
  }, [editor, onImageUpload]);

  return (
    <BlockButton
      isActive={false}
      onActivate={handleClick}
      icon={ImageIcon}
      label="Insert image"
    />
  );
}

function InsertLinkButton() {
  const editor = useEditorRef();
  const handleClick = useCallback(() => {
    const url = window.prompt("Link URL");
    if (!url) return;
    const text = window.getSelection()?.toString() || url;
    editor.tf.insertNodes({
      type: LinkPlugin.key,
      url,
      children: [{ text }],
    });
    editor.tf.focus();
  }, [editor]);
  return (
    <BlockButton
      isActive={false}
      onActivate={handleClick}
      icon={LinkIcon}
      label="Insert link"
    />
  );
}

function FontFamilySelect() {
  const editor = useEditorRef();
  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLSelectElement>) => {
      const value = e.target.value;
      if (value === "default") {
        editor.tf.removeMark("fontFamily");
      } else {
        editor.tf.addMark("fontFamily", value);
      }
      editor.tf.focus();
    },
    [editor]
  );
  return (
    <select
      onMouseDown={(e) => e.preventDefault()}
      onChange={handleChange}
      className="h-8 rounded-md border border-input bg-background px-2 text-xs"
      title="Font family"
      aria-label="Font family"
      defaultValue="default"
    >
      <option value="default">Default font</option>
      <option value="var(--font-serif)">Serif</option>
      <option value="var(--font-sans)">Sans</option>
      <option value="var(--font-mono)">Mono</option>
    </select>
  );
}

function FontSizeSelect() {
  const editor = useEditorRef();
  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLSelectElement>) => {
      const value = e.target.value;
      if (value === "default") {
        editor.tf.removeMark("fontSize");
      } else {
        editor.tf.addMark("fontSize", value);
      }
      editor.tf.focus();
    },
    [editor]
  );
  return (
    <select
      onMouseDown={(e) => e.preventDefault()}
      onChange={handleChange}
      className="h-8 rounded-md border border-input bg-background px-2 text-xs"
      title="Font size"
      aria-label="Font size"
      defaultValue="default"
    >
      <option value="default">Size</option>
      <option value="12px">12</option>
      <option value="14px">14</option>
      <option value="16px">16</option>
      <option value="18px">18</option>
      <option value="20px">20</option>
      <option value="24px">24</option>
      <option value="32px">32</option>
    </select>
  );
}

function FontColorInput() {
  const editor = useEditorRef();
  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      editor.tf.addMark("color", e.target.value);
      editor.tf.focus();
    },
    [editor]
  );
  return (
    <input
      type="color"
      onMouseDown={(e) => e.preventDefault()}
      onChange={handleChange}
      className="h-8 w-8 cursor-pointer rounded-md border border-input bg-background"
      title="Text color"
      aria-label="Text color"
      defaultValue="#000000"
    />
  );
}

function ToolbarSeparator() {
  return <span className="mx-1 h-6 w-px bg-border" aria-hidden="true" />;
}

function ToolbarGroup({ children }: { children: ReactNode }) {
  return <div className="flex items-center gap-0.5">{children}</div>;
}

interface EditorToolbarProps {
  className?: string;
  onImageUpload?: ImageUploader;
}

export function EditorToolbar({ className, onImageUpload }: EditorToolbarProps) {
  return (
    <div
      role="toolbar"
      aria-label="Rich text editor toolbar"
      className={cn(
        "flex flex-wrap items-center gap-1 border-b border-border bg-card px-2 py-1.5",
        className
      )}
    >
      <ToolbarGroup>
        <MarkButton nodeType={BoldPlugin.key} icon={Bold} label="Bold" />
        <MarkButton nodeType={ItalicPlugin.key} icon={Italic} label="Italic" />
        <MarkButton nodeType={UnderlinePlugin.key} icon={UnderlineIcon} label="Underline" />
        <MarkButton nodeType={StrikethroughPlugin.key} icon={Strikethrough} label="Strikethrough" />
        <MarkButton nodeType={CodePlugin.key} icon={Code} label="Inline code" />
        <MarkButton nodeType={HighlightPlugin.key} icon={Highlighter} label="Highlight" />
        <MarkButton nodeType={SubscriptPlugin.key} icon={SubIcon} label="Subscript" />
        <MarkButton nodeType={SuperscriptPlugin.key} icon={SupIcon} label="Superscript" />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <HeadingButton blockType={H1Plugin.key} icon={Heading1} label="Heading 1" />
        <HeadingButton blockType={H2Plugin.key} icon={Heading2} label="Heading 2" />
        <HeadingButton blockType={H3Plugin.key} icon={Heading3} label="Heading 3" />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <HeadingButton blockType="blockquote" icon={Quote} label="Blockquote" />
        <HeadingButton blockType={CodeBlockPlugin.key} icon={TerminalSquare} label="Code block" />
        <InsertHrButton />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <InsertListButton ordered={false} />
        <InsertListButton ordered />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <InsertLinkButton />
        <InsertImageButton onImageUpload={onImageUpload} />
        <InsertTableButton />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <FontFamilySelect />
        <FontSizeSelect />
        <FontColorInput />
      </ToolbarGroup>
    </div>
  );
}
```

### `src/registry/components/data/rich-text-editor/parts/floating-toolbar.tsx`

```tsx
"use client";

import {
  Bold,
  Code,
  Highlighter,
  Italic,
  Link as LinkIcon,
  Strikethrough,
  Underline as UnderlineIcon,
} from "lucide-react";
import {
  BoldPlugin,
  CodePlugin,
  HighlightPlugin,
  ItalicPlugin,
  StrikethroughPlugin,
  UnderlinePlugin,
} from "@platejs/basic-nodes/react";
import { LinkPlugin } from "@platejs/link/react";
import {
  autoUpdate,
  flip,
  offset,
  shift,
  useFloating,
} from "@floating-ui/react";
import { useEditorRef, useEditorSelection } from "platejs/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { MarkButton } from "./toolbar-buttons";

function getSelectionRect(): DOMRect | null {
  if (typeof window === "undefined") return null;
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
  const rect = sel.getRangeAt(0).getBoundingClientRect();
  // Selection-near-empty case (collapsed-but-not-collapsed): width 0
  if (rect.width === 0 && rect.height === 0) return null;
  return rect;
}

export function FloatingToolbar() {
  const editor = useEditorRef();
  const selection = useEditorSelection();
  const [visible, setVisible] = useState(false);

  const isCollapsed = useMemo(() => {
    if (!selection) return true;
    const a = selection.anchor;
    const f = selection.focus;
    if (a.offset !== f.offset) return false;
    if (JSON.stringify(a.path) !== JSON.stringify(f.path)) return false;
    return true;
  }, [selection]);

  const { refs, floatingStyles } = useFloating({
    placement: "top",
    middleware: [offset(8), flip(), shift({ padding: 8 })],
    whileElementsMounted: autoUpdate,
  });

  // Sync the floating-UI reference with the live window selection.
  // setVisible() inside an effect is unavoidable here: the source of truth is
  // a DOM-level Selection that React doesn't track. The rule is meant for
  // derived-state sync; this is genuine external-system subscription.
  useEffect(() => {
    if (isCollapsed) {
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setVisible(false);
      return;
    }
    const update = () => {
      const rect = getSelectionRect();
      if (!rect) {
        setVisible(false);
        return;
      }
      refs.setReference({
        getBoundingClientRect: () => rect,
        contextElement: undefined,
      });
      setVisible(true);
    };
    update();

    // Re-poll on scroll / resize so the toolbar stays attached to selection.
    document.addEventListener("scroll", update, true);
    window.addEventListener("resize", update);
    return () => {
      document.removeEventListener("scroll", update, true);
      window.removeEventListener("resize", update);
    };
  }, [isCollapsed, refs, selection]);

  const handleAddLink = useCallback(() => {
    const url = window.prompt("Link URL");
    if (!url) return;
    editor.tf.insertNodes({
      type: LinkPlugin.key,
      url,
      children: [{ text: window.getSelection()?.toString() || url }],
    });
    editor.tf.focus();
  }, [editor]);

  if (!visible || isCollapsed) return null;

  return (
    <div
      // floating-ui's setFloating is a stable ref-callback, not a `.current`
      // access. Lint can't tell the difference.
      // eslint-disable-next-line react-hooks/refs
      ref={refs.setFloating}
      style={floatingStyles}
      className="z-50 flex items-center gap-0.5 rounded-md border border-border bg-card px-1 py-0.5 shadow-lg"
      role="toolbar"
      aria-label="Selection actions"
      onMouseDown={(e) => e.preventDefault()}
    >
      <MarkButton nodeType={BoldPlugin.key} icon={Bold} label="Bold" size="sm" />
      <MarkButton nodeType={ItalicPlugin.key} icon={Italic} label="Italic" size="sm" />
      <MarkButton nodeType={UnderlinePlugin.key} icon={UnderlineIcon} label="Underline" size="sm" />
      <MarkButton nodeType={StrikethroughPlugin.key} icon={Strikethrough} label="Strikethrough" size="sm" />
      <MarkButton nodeType={CodePlugin.key} icon={Code} label="Inline code" size="sm" />
      <MarkButton nodeType={HighlightPlugin.key} icon={Highlighter} label="Highlight" size="sm" />
      <span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
      <Button
        type="button"
        variant="ghost"
        size="icon"
        className="h-7 w-7"
        title="Insert link"
        aria-label="Insert link"
        onMouseDown={(e) => e.preventDefault()}
        onClick={handleAddLink}
      >
        <LinkIcon className="h-4 w-4" />
      </Button>
    </div>
  );
}
```

### `src/registry/components/data/rich-text-editor/parts/toolbar-buttons.tsx`

```tsx
"use client";

import {
  type ComponentType,
  type MouseEvent,
  useCallback,
} from "react";
import {
  useEditorRef,
  useEditorSelector,
  useMarkToolbarButton,
  useMarkToolbarButtonState,
} from "platejs/react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface MarkButtonProps {
  nodeType: string;
  icon: ComponentType<{ className?: string }>;
  label: string;
  size?: "sm" | "md";
}

export function MarkButton({
  nodeType,
  icon: Icon,
  label,
  size = "md",
}: MarkButtonProps) {
  const state = useMarkToolbarButtonState({ nodeType });
  const { props } = useMarkToolbarButton(state);
  return (
    <Button
      type="button"
      variant="ghost"
      size="icon"
      className={cn(
        size === "sm" ? "h-7 w-7" : "h-8 w-8",
        state.pressed && "bg-muted text-foreground"
      )}
      title={label}
      aria-label={label}
      aria-pressed={state.pressed}
      onMouseDown={props.onMouseDown}
      onClick={props.onClick}
    >
      <Icon className="h-4 w-4" />
    </Button>
  );
}

interface BlockButtonProps {
  isActive: boolean;
  onActivate: () => void;
  icon: ComponentType<{ className?: string }>;
  label: string;
  size?: "sm" | "md";
}

export function BlockButton({
  isActive,
  onActivate,
  icon: Icon,
  label,
  size = "md",
}: BlockButtonProps) {
  const handleMouseDown = useCallback(
    (e: MouseEvent<HTMLButtonElement>) => {
      e.preventDefault();
    },
    []
  );

  return (
    <Button
      type="button"
      variant="ghost"
      size="icon"
      className={cn(
        size === "sm" ? "h-7 w-7" : "h-8 w-8",
        isActive && "bg-muted text-foreground"
      )}
      title={label}
      aria-label={label}
      aria-pressed={isActive}
      onMouseDown={handleMouseDown}
      onClick={onActivate}
    >
      <Icon className="h-4 w-4" />
    </Button>
  );
}

export function useBlockToggle(blockType: string) {
  const editor = useEditorRef();
  const isActive = useEditorSelector(
    (e) => {
      const entry = e.api.block();
      return entry?.[0]?.type === blockType;
    },
    [blockType]
  );
  const toggle = useCallback(() => {
    editor.tf.toggleBlock(blockType);
    editor.tf.focus();
  }, [editor, blockType]);
  return { isActive, toggle };
}
```

### `src/registry/components/data/rich-text-editor/parts/element-renderers.tsx`

```tsx
"use client";

import type { ComponentProps, ReactNode } from "react";
import { PlateElement, type PlateElementProps } from "platejs/react";
import { cn } from "@/lib/utils";

type ElementProps = ComponentProps<typeof PlateElement>;

// Plate's `<PlateElement as="X">` discriminates the type of `attributes` per
// HTML element kind. When we forward `props.attributes` (typed for a wider
// element kind), TS rejects the narrower target. The runtime is just data-slate-*
// attributes — fine on any HTML element. Cast via this helper.
//
// Without this we'd pepper `attributes={props.attributes as never}` on every
// element. One central cast keeps the noise local.
function withAs<As extends string>(props: ElementProps, as: As): ElementProps & { as: As } {
  // The cast is intentional: we know `props.attributes` is the same data-slate-*
  // shape regardless of `as`. TS's discriminated-union narrowing doesn't help here.
  return { ...props, as, attributes: props.attributes as never } as ElementProps & {
    as: As;
  };
}

function Wrapped(props: ElementProps & { as?: string; className?: string; children: ReactNode }) {
  return <PlateElement {...(props as ElementProps)}>{props.children}</PlateElement>;
}

export function ParagraphElement(props: ElementProps) {
  return (
    <Wrapped {...withAs(props, "p")} className={cn("my-4 leading-relaxed", props.className)}>
      {props.children}
    </Wrapped>
  );
}

export function H1Element(props: ElementProps) {
  return (
    <Wrapped {...withAs(props, "h1")} className={cn("mt-10 mb-4 text-4xl font-serif font-bold tracking-tight", props.className)}>
      {props.children}
    </Wrapped>
  );
}

export function H2Element(props: ElementProps) {
  return (
    <Wrapped {...withAs(props, "h2")} className={cn("mt-8 mb-3 text-3xl font-serif font-bold tracking-tight", props.className)}>
      {props.children}
    </Wrapped>
  );
}

export function H3Element(props: ElementProps) {
  return (
    <Wrapped {...withAs(props, "h3")} className={cn("mt-6 mb-2 text-2xl font-serif font-bold", props.className)}>
      {props.children}
    </Wrapped>
  );
}

export function H4Element(props: ElementProps) {
  return (
    <Wrapped {...withAs(props, "h4")} className={cn("mt-4 mb-2 text-xl font-serif font-semibold", props.className)}>
      {props.children}
    </Wrapped>
  );
}

export function BlockquoteElement(props: ElementProps) {
  return (
    <Wrapped
      {...withAs(props, "blockquote")}
      className={cn(
        "my-6 border-l-4 border-primary pl-6 py-2 italic text-muted-foreground bg-muted/30 rounded-r-md",
        props.className
      )}
    >
      {props.children}
    </Wrapped>
  );
}

export function HrElement(props: ElementProps) {
  return (
    <PlateElement
      {...props}
      className={cn("my-8", props.className)}
      attributes={{ ...(props.attributes as Record<string, unknown>), contentEditable: false } as never}
    >
      <div>
        <hr className="border-border" />
      </div>
      {props.children}
    </PlateElement>
  );
}

export function ListElement(props: ElementProps) {
  const { element } = props as PlateElementProps;
  const listStyleType = (element as { listStyleType?: string }).listStyleType ?? "disc";
  const indent = ((element as { indent?: number }).indent ?? 1) - 1;
  const tag = (listStyleType === "decimal" ? "ol" : "ul") as "ol" | "ul";

  return (
    <Wrapped
      {...withAs(props, tag)}
      className={cn(
        "my-4 ml-6",
        listStyleType === "decimal" ? "list-decimal" : "list-disc",
        props.className
      )}
      style={{
        ...(props.style ?? {}),
        marginLeft: indent > 0 ? `${indent * 1.5}rem` : undefined,
      }}
    >
      <li>{props.children}</li>
    </Wrapped>
  );
}

export function CodeBlockElement(props: ElementProps) {
  return (
    <Wrapped
      {...withAs(props, "pre")}
      className={cn(
        "my-6 overflow-x-auto rounded-md border border-border bg-muted p-4 font-mono text-sm",
        props.className
      )}
    >
      <code>{props.children}</code>
    </Wrapped>
  );
}

export function CodeLineElement(props: ElementProps) {
  return (
    <Wrapped {...withAs(props, "div")} className={cn("font-mono", props.className)}>
      {props.children}
    </Wrapped>
  );
}

export function TableElement(props: ElementProps) {
  return (
    <PlateElement {...props} className={cn("my-6 overflow-x-auto", props.className)}>
      <table className="w-full border-collapse text-sm">
        <tbody>{props.children}</tbody>
      </table>
    </PlateElement>
  );
}

export function TableRowElement(props: ElementProps) {
  return (
    <Wrapped {...withAs(props, "tr")} className={cn(props.className)}>
      {props.children}
    </Wrapped>
  );
}

export function TableCellElement(props: ElementProps) {
  return (
    <Wrapped
      {...withAs(props, "td")}
      className={cn("border border-border px-3 py-2 align-top", props.className)}
    >
      {props.children}
    </Wrapped>
  );
}

export function TableCellHeaderElement(props: ElementProps) {
  return (
    <Wrapped
      {...withAs(props, "th")}
      className={cn(
        "border border-border bg-muted/40 px-3 py-2 text-left font-semibold align-top",
        props.className
      )}
    >
      {props.children}
    </Wrapped>
  );
}

// ImageElement is sealed in its own file (parts/image-element.tsx) — handles
// resize handle + caption editing. Re-exported here for downstream barrel-import
// continuity (editor-kit.ts imports it from this module).
export { ImageElement } from "./image-element";

export function MediaEmbedElement(props: ElementProps) {
  const { element } = props as PlateElementProps;
  const url = (element as { url?: string }).url;
  return (
    <PlateElement
      {...props}
      className={cn("my-6", props.className)}
      attributes={{ ...(props.attributes as Record<string, unknown>), contentEditable: false } as never}
    >
      <div className="aspect-video overflow-hidden rounded-md border border-border bg-muted/30">
        {url ? (
          <iframe src={url} className="h-full w-full" allowFullScreen />
        ) : (
          <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
            (embed)
          </div>
        )}
      </div>
      {props.children}
    </PlateElement>
  );
}

export function LinkElement(props: ElementProps) {
  const { element } = props as PlateElementProps;
  const url = (element as { url?: string }).url ?? "#";
  return (
    <Wrapped
      {...withAs(props, "a")}
      attributes={{
        ...(props.attributes as Record<string, unknown>),
        href: url,
        target: "_blank",
        rel: "noopener noreferrer",
      } as never}
      className={cn("text-primary underline underline-offset-2 hover:text-primary/80", props.className)}
    >
      {props.children}
    </Wrapped>
  );
}
```

### `src/registry/components/data/rich-text-editor/parts/image-element.tsx`

```tsx
"use client";

import {
  type ChangeEvent,
  type ComponentProps,
  type CSSProperties,
  type MouseEvent as ReactMouseEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";
import {
  PlateElement,
  type PlateElementProps,
  useEditorRef,
  useNodePath,
  useReadOnly,
} from "platejs/react";
import { cn } from "@/lib/utils";

type ElementProps = ComponentProps<typeof PlateElement>;

interface ImageNode {
  url?: string;
  alt?: string;
  caption?: string;
  width?: string | number;
  height?: string | number;
}

const MIN_WIDTH_PCT = 20;
const MAX_WIDTH_PCT = 100;

function parseWidthPercent(width: string | number | undefined): number {
  if (typeof width === "number") return width;
  if (!width) return MAX_WIDTH_PCT;
  const match = /^(\d+(?:\.\d+)?)\s*%$/.exec(width);
  return match ? Number(match[1]) : MAX_WIDTH_PCT;
}

export function ImageElement(props: ElementProps) {
  const { element } = props as PlateElementProps;
  const node = element as ImageNode & { type: string };
  const url = node.url;
  const alt = node.alt ?? "";
  const caption = node.caption ?? "";

  const editor = useEditorRef();
  const path = useNodePath(element);
  const readOnly = useReadOnly();

  const widthPct = parseWidthPercent(node.width);
  const [draftWidthPct, setDraftWidthPct] = useState<number | null>(null);
  const wrapperRef = useRef<HTMLDivElement>(null);
  const isDraggingRef = useRef(false);

  const commitWidth = useCallback(
    (next: number) => {
      const clamped = Math.max(MIN_WIDTH_PCT, Math.min(MAX_WIDTH_PCT, next));
      editor.tf.setNodes({ width: `${clamped.toFixed(0)}%` }, { at: path });
    },
    [editor, path]
  );

  const handleResizeStart = useCallback(
    (e: ReactMouseEvent<HTMLButtonElement>) => {
      e.preventDefault();
      e.stopPropagation();
      const wrapper = wrapperRef.current;
      if (!wrapper) return;
      const parent = wrapper.parentElement;
      if (!parent) return;

      const parentWidth = parent.getBoundingClientRect().width;
      const startX = e.clientX;
      const startPct = parseWidthPercent(node.width);
      isDraggingRef.current = true;

      const onMove = (ev: MouseEvent) => {
        const deltaPx = ev.clientX - startX;
        const deltaPct = (deltaPx / parentWidth) * 100;
        const next = Math.max(
          MIN_WIDTH_PCT,
          Math.min(MAX_WIDTH_PCT, startPct + deltaPct)
        );
        setDraftWidthPct(next);
      };

      const onUp = (ev: MouseEvent) => {
        const deltaPx = ev.clientX - startX;
        const deltaPct = (deltaPx / parentWidth) * 100;
        commitWidth(startPct + deltaPct);
        setDraftWidthPct(null);
        isDraggingRef.current = false;
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
      };

      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    },
    [commitWidth, node.width]
  );

  // Cleanup if component unmounts mid-drag.
  useEffect(() => {
    return () => {
      isDraggingRef.current = false;
    };
  }, []);

  const handleCaptionChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      editor.tf.setNodes({ caption: e.target.value }, { at: path });
    },
    [editor, path]
  );

  const effectivePct = draftWidthPct ?? widthPct;
  const wrapperStyle: CSSProperties = { width: `${effectivePct.toFixed(0)}%` };

  return (
    <PlateElement
      {...props}
      className={cn("my-6", props.className)}
      attributes={
        {
          ...(props.attributes as Record<string, unknown>),
          contentEditable: false,
        } as never
      }
    >
      <figure className="group/img flex flex-col items-center">
        <div ref={wrapperRef} className="relative" style={wrapperStyle}>
          {url ? (
            <img
              src={url}
              alt={alt}
              loading="lazy"
              className="w-full rounded-md border border-border"
              draggable={false}
            />
          ) : (
            <div className="flex aspect-video items-center justify-center rounded-md border border-dashed border-border bg-muted/30 text-sm text-muted-foreground">
              (image)
            </div>
          )}
          {!readOnly ? (
            <button
              type="button"
              aria-label="Resize image"
              onMouseDown={handleResizeStart}
              className={cn(
                "absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2",
                "h-12 w-1.5 cursor-ew-resize rounded-full bg-primary/40",
                "opacity-0 transition-opacity hover:opacity-100 group-hover/img:opacity-100",
                "ring-1 ring-primary/20 hover:bg-primary/70"
              )}
              tabIndex={-1}
            />
          ) : null}
        </div>

        {(caption || !readOnly) ? (
          readOnly ? (
            caption ? (
              <figcaption className="mt-2 max-w-[80%] text-center text-xs text-muted-foreground">
                {caption}
              </figcaption>
            ) : null
          ) : (
            <input
              type="text"
              value={caption}
              onChange={handleCaptionChange}
              placeholder="Caption (optional)"
              aria-label="Image caption"
              className={cn(
                "mt-2 w-3/4 max-w-prose bg-transparent text-center text-xs",
                "text-muted-foreground placeholder:text-muted-foreground/60",
                "focus:outline-none focus:ring-1 focus:ring-ring focus:rounded-sm"
              )}
            />
          )
        ) : null}
      </figure>
      {props.children}
    </PlateElement>
  );
}
```

### `src/registry/components/data/rich-text-editor/parts/leaf-renderers.tsx`

```tsx
"use client";

import type { ComponentProps } from "react";
import { PlateLeaf } from "platejs/react";
import { cn } from "@/lib/utils";

type LeafProps = ComponentProps<typeof PlateLeaf>;

export function CodeLeaf(props: LeafProps) {
  return (
    <PlateLeaf
      {...props}
      as="code"
      className={cn(
        "rounded bg-muted px-1.5 py-0.5 font-mono text-[0.9em]",
        props.className
      )}
    >
      {props.children}
    </PlateLeaf>
  );
}

export function HighlightLeaf(props: LeafProps) {
  return (
    <PlateLeaf
      {...props}
      as="mark"
      className={cn(
        "rounded bg-primary/20 px-0.5 text-foreground",
        props.className
      )}
    >
      {props.children}
    </PlateLeaf>
  );
}

export function KbdLeaf(props: LeafProps) {
  return (
    <PlateLeaf
      {...props}
      as="kbd"
      className={cn(
        "rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.85em] shadow-sm",
        props.className
      )}
    >
      {props.children}
    </PlateLeaf>
  );
}
```

### `src/registry/components/data/rich-text-editor/static-elements/static-element-renderers.tsx`

```tsx
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";

interface StaticElementProps {
  attributes?: Record<string, unknown>;
  children?: ReactNode;
  element: { type: string; [key: string]: unknown };
  className?: string;
  style?: CSSProperties;
}

interface StaticLeafProps {
  attributes?: Record<string, unknown>;
  children?: ReactNode;
  leaf: { [key: string]: unknown };
  className?: string;
  style?: CSSProperties;
}

// Plate's `attributes` carry data-slate-* metadata + a ref typed for various
// element kinds. We render bare HTML, so we cast to a permissive shape here.
// Strip `ref` — it's typed for a specific HTML element and TS can't reconcile
// across our union of output tags. The viewer is read-only; refs aren't load-bearing.
type Attrs = Omit<HTMLAttributes<HTMLElement>, "color" | "ref">;

function spreadAttrs(attributes: Record<string, unknown> | undefined): Attrs {
  if (!attributes) return {};
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const { ref: _ref, ...rest } = attributes as { ref?: unknown };
  return rest as Attrs;
}

export function StaticParagraph(props: StaticElementProps) {
  return (
    <p {...spreadAttrs(props.attributes)} className={cn("my-4 leading-relaxed", props.className)}>
      {props.children}
    </p>
  );
}

export function StaticH1(props: StaticElementProps) {
  return (
    <h1 {...spreadAttrs(props.attributes)} className={cn("mt-10 mb-4 text-4xl font-serif font-bold tracking-tight", props.className)}>
      {props.children}
    </h1>
  );
}

export function StaticH2(props: StaticElementProps) {
  return (
    <h2 {...spreadAttrs(props.attributes)} className={cn("mt-8 mb-3 text-3xl font-serif font-bold tracking-tight", props.className)}>
      {props.children}
    </h2>
  );
}

export function StaticH3(props: StaticElementProps) {
  return (
    <h3 {...spreadAttrs(props.attributes)} className={cn("mt-6 mb-2 text-2xl font-serif font-bold", props.className)}>
      {props.children}
    </h3>
  );
}

export function StaticH4(props: StaticElementProps) {
  return (
    <h4 {...spreadAttrs(props.attributes)} className={cn("mt-4 mb-2 text-xl font-serif font-semibold", props.className)}>
      {props.children}
    </h4>
  );
}

export function StaticBlockquote(props: StaticElementProps) {
  return (
    <blockquote
      {...spreadAttrs(props.attributes)}
      className={cn(
        "my-6 border-l-4 border-primary pl-6 py-2 italic text-muted-foreground bg-muted/30 rounded-r-md",
        props.className
      )}
    >
      {props.children}
    </blockquote>
  );
}

export function StaticHr(props: StaticElementProps) {
  return (
    <div {...spreadAttrs(props.attributes)} className={cn("my-8", props.className)}>
      <hr className="border-border" />
      {props.children}
    </div>
  );
}

export function StaticList(props: StaticElementProps) {
  const element = props.element as { listStyleType?: string; indent?: number };
  const listStyleType = element.listStyleType ?? "disc";
  const indent = (element.indent ?? 1) - 1;
  const ordered = listStyleType === "decimal";
  const className = cn(
    "my-4 ml-6",
    ordered ? "list-decimal" : "list-disc",
    props.className
  );
  const style = {
    ...(props.style ?? {}),
    marginLeft: indent > 0 ? `${indent * 1.5}rem` : undefined,
  };
  const attrs = spreadAttrs(props.attributes);

  if (ordered) {
    return (
      <ol {...attrs} className={className} style={style}>
        <li>{props.children}</li>
      </ol>
    );
  }
  return (
    <ul {...attrs} className={className} style={style}>
      <li>{props.children}</li>
    </ul>
  );
}

export function StaticCodeBlock(props: StaticElementProps) {
  return (
    <pre
      {...spreadAttrs(props.attributes)}
      className={cn(
        "my-6 overflow-x-auto rounded-md border border-border bg-muted p-4 font-mono text-sm",
        props.className
      )}
    >
      <code>{props.children}</code>
    </pre>
  );
}

export function StaticCodeLine(props: StaticElementProps) {
  return (
    <div {...spreadAttrs(props.attributes)} className={cn("font-mono", props.className)}>
      {props.children}
    </div>
  );
}

export function StaticTable(props: StaticElementProps) {
  return (
    <div {...spreadAttrs(props.attributes)} className={cn("my-6 overflow-x-auto", props.className)}>
      <table className="w-full border-collapse text-sm">
        <tbody>{props.children}</tbody>
      </table>
    </div>
  );
}

export function StaticTableRow(props: StaticElementProps) {
  return <tr {...spreadAttrs(props.attributes)} className={cn(props.className)}>{props.children}</tr>;
}

export function StaticTableCell(props: StaticElementProps) {
  return (
    <td
      {...spreadAttrs(props.attributes)}
      className={cn("border border-border px-3 py-2 align-top", props.className)}
    >
      {props.children}
    </td>
  );
}

export function StaticTableCellHeader(props: StaticElementProps) {
  return (
    <th
      {...spreadAttrs(props.attributes)}
      className={cn(
        "border border-border bg-muted/40 px-3 py-2 text-left font-semibold align-top",
        props.className
      )}
    >
      {props.children}
    </th>
  );
}

export function StaticImage(props: StaticElementProps) {
  const element = props.element as {
    url?: string;
    alt?: string;
    caption?: string;
    width?: string | number;
  };
  // Width comes through as a percentage string (e.g. "75%") set by the editor.
  // Numbers are interpreted as pixels for backward compatibility.
  const widthStyle =
    element.width !== undefined
      ? typeof element.width === "number"
        ? `${element.width}px`
        : element.width
      : undefined;

  return (
    <div {...spreadAttrs(props.attributes)} className={cn("my-6", props.className)}>
      <figure className="flex flex-col items-center">
        {element.url ? (
          <img
            src={element.url}
            alt={element.alt ?? ""}
            loading="lazy"
            className="rounded-md border border-border"
            style={widthStyle ? { width: widthStyle } : undefined}
          />
        ) : null}
        {element.caption ? (
          <figcaption className="mt-2 max-w-[80%] text-center text-xs text-muted-foreground">
            {element.caption}
          </figcaption>
        ) : null}
      </figure>
      {props.children}
    </div>
  );
}

export function StaticMediaEmbed(props: StaticElementProps) {
  const element = props.element as { url?: string };
  return (
    <div {...spreadAttrs(props.attributes)} className={cn("my-6", props.className)}>
      {element.url ? (
        <div className="aspect-video overflow-hidden rounded-md border border-border bg-muted/30">
          <iframe src={element.url} className="h-full w-full" allowFullScreen />
        </div>
      ) : null}
      {props.children}
    </div>
  );
}

export function StaticLink(props: StaticElementProps) {
  const element = props.element as { url?: string };
  return (
    <a
      {...spreadAttrs(props.attributes)}
      href={element.url ?? "#"}
      target="_blank"
      rel="noopener noreferrer"
      className={cn(
        "text-primary underline underline-offset-2 hover:text-primary/80",
        props.className
      )}
    >
      {props.children}
    </a>
  );
}

export function StaticCodeLeaf(props: StaticLeafProps) {
  return (
    <code
      {...(spreadAttrs(props.attributes) as HTMLAttributes<HTMLElement>)}
      className={cn("rounded bg-muted px-1.5 py-0.5 font-mono text-[0.9em]", props.className)}
    >
      {props.children}
    </code>
  );
}

export function StaticHighlightLeaf(props: StaticLeafProps) {
  return (
    <mark
      {...(spreadAttrs(props.attributes) as HTMLAttributes<HTMLElement>)}
      className={cn("rounded bg-primary/20 px-0.5 text-foreground", props.className)}
    >
      {props.children}
    </mark>
  );
}

export function StaticKbdLeaf(props: StaticLeafProps) {
  return (
    <kbd
      {...(spreadAttrs(props.attributes) as HTMLAttributes<HTMLElement>)}
      className={cn(
        "rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.85em] shadow-sm",
        props.className
      )}
    >
      {props.children}
    </kbd>
  );
}
```

### `src/registry/components/data/rich-text-editor/lib/lowlight-instance.ts`

```ts
import { createLowlight } from "lowlight";

import bash from "highlight.js/lib/languages/bash";
import css from "highlight.js/lib/languages/css";
import diff from "highlight.js/lib/languages/diff";
import go from "highlight.js/lib/languages/go";
import java from "highlight.js/lib/languages/java";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import markdown from "highlight.js/lib/languages/markdown";
import python from "highlight.js/lib/languages/python";
import rust from "highlight.js/lib/languages/rust";
import shell from "highlight.js/lib/languages/shell";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import yaml from "highlight.js/lib/languages/yaml";

// 15 languages cover ~95% of realistic rich-text code blocks.
// Selective registration keeps the bundle small (~30KB gzip vs ~250KB
// for `lowlight/all`). Add more via `lowlight.register(...)` if needed.
export const lowlight = createLowlight();

lowlight.register("bash", bash);
lowlight.register("sh", shell);
lowlight.register("shell", shell);
lowlight.register("css", css);
lowlight.register("diff", diff);
lowlight.register("go", go);
lowlight.register("html", xml);
lowlight.register("xml", xml);
lowlight.register("java", java);
lowlight.register("javascript", javascript);
lowlight.register("js", javascript);
lowlight.register("json", json);
lowlight.register("markdown", markdown);
lowlight.register("md", markdown);
lowlight.register("python", python);
lowlight.register("py", python);
lowlight.register("rust", rust);
lowlight.register("rs", rust);
lowlight.register("sql", sql);
lowlight.register("typescript", typescript);
lowlight.register("ts", typescript);
lowlight.register("yaml", yaml);
lowlight.register("yml", yaml);
```

### `src/registry/components/data/rich-text-editor/lib/serialize-html.ts`

```ts
import { createStaticEditor, serializeHtml } from "platejs/static";

import { richTextViewerPlugins } from "../plugins/viewer-kit";
import type { RichTextValue } from "../types";

export interface SerializeHtmlOptions {
  /** Drop className attributes from the output. Default: false. */
  stripClassNames?: boolean;
  /**
   * Class names to keep when `stripClassNames` is true. Useful when you
   * want to preserve specific design-system classes but drop the rest.
   */
  preserveClassNames?: string[];
  /**
   * Drop data-slate-* attributes from the output. Default: true —
   * exported HTML usually shouldn't carry editor metadata.
   */
  stripDataAttributes?: boolean;
}

/**
 * Serialize an RichTextValue to a static HTML string.
 *
 * Useful at export boundaries — RSS feeds, email digests, OG tag previews,
 * static-site rendering. Don't round-trip through HTML for storage; keep
 * JSON canonical and call this when you need the HTML form.
 *
 * Async because Plate uses `react-dom/server` under the hood. Server-only;
 * don't call from client components.
 */
export async function serializeRichTextToHtml(
  value: RichTextValue,
  options: SerializeHtmlOptions = {}
): Promise<string> {
  const {
    stripClassNames = false,
    preserveClassNames,
    stripDataAttributes = true,
  } = options;

  const editor = createStaticEditor({
    plugins: richTextViewerPlugins,
    value,
  });

  return serializeHtml(editor, {
    stripClassNames,
    preserveClassNames,
    stripDataAttributes,
  });
}
```
