Skip to contentVibraUI
Inputs & filters

Rich text editor

A formatting toolbar, markdown shortcuts and a character count over Tiptap, saving a JSON document that parseRichDoc checks and RichText draws.

The value is a document, not HTML: the JSON Tiptap (on ProseMirror) holds, read through parseRichDoc on every change, so what onValueChange hands you and what the hidden name input posts is already the shape a server accepts — paragraphs, headings 2 and 3, bullet and numbered lists, quotes, code blocks and line breaks, marked bold, italic, strike, code or as a link. Nothing else can get in. A paste from a word processor or a web page keeps that formatting and drops the rest — images, colours, fonts, a table's grid (its rows paste as lines) — and a link whose address is not http, https or mailto loses its link and keeps its words, as does one in a document the editor is handed; the Link field refuses such an address beside itself. A change that would leave more than 2,000 nodes or blocks nested past six (RICH_LIMITS) is refused as it is made, so the editor never holds a document parseRichDoc would turn away. The server re-reads it all the same: an action calls parseRichDoc(JSON.parse(formData.get(name)), { maxLength }) and refuses null, because a form field is whatever the browser chose to send. Store the document and, for a list's excerpt or a search index, richTextToPlain(doc). RichText draws a stored document with React elements — never dangerouslySetInnerHTML, never an attribute the document carries but a link's address — and is server-compatible, so a page renders a sent message where it reads it; import it from rich-text-editor/rich-text, not from the editor, and pass headingLevel so the document's headings sit under the page's own. The toolbar is the kit's Toolbar — one tab stop walked with the arrow keys — and every control is a toggle that says whether it is on (aria-pressed) and announces its chord: ⌘B, ⌘I, ⌘⇧X, ⌘E, ⌘⌥2, ⌘⌥3, ⌘⇧8, ⌘⇧7, ⌘⇧B, ⌘K for the link field — in the text it is the editor's, so a page's own ⌘K palette does not open over it — ⌘Z and ⌘⇧Z (Ctrl on Windows and Linux). Markdown works as you type: ## and ### for headings, - or 1. for lists, > for a quote, **bold**, *italic*, ~~strike~~ and code. toolbar="minimal" keeps the inline marks and the link. With maxLength typing stops at the limit and a counter reads N / max — a line break counts as one there, so the counter is a touch stricter than the server's maxLength, which counts text — and it is spoken only within the last tenth. The editor renders on the client only (the server sends the frame and the toolbar), and Tiptap is heavy: in a page, load the component with next/dynamic or an import() when the reader first opens it, so the page's first load does not carry it. value is controlled — set only when it differs from what the editor holds, so your own echo never moves the caret — and a document it cannot read opens empty.

Install

npx shadcn@latest add @vibra/rich-text-editor

Needs the @vibra registry in your components.json — set it up once.

Examples

Reply box

The minimal toolbar in a form: the posted field read back through parseRichDoc.

A saved message

RichText drawing a stored document, and the excerpt an inbox prints.

Props

PropTypeDefaultDescription
valueRichDoc—The document, controlled. Leave it out and the editor keeps its own.
defaultValueRichDocEMPTY_DOCWhere an uncontrolled editor starts.
onValueChange(doc: RichDoc) => void—Every change the reader makes, already read through parseRichDoc. Setting value is not reported back.
placeholderstring—Drawn in the empty field and given to it as aria-placeholder.
maxLengthnumber—Characters of text the document may hold. Typing and pasting stop at it, and a counter shows N / max under the text.
toolbar"full" | "minimal""full"minimal keeps bold, italic, strike, code and the link.
namestring—Posts the document with its form, as JSON in a hidden input of this name.
disabledbooleanfalseRead-only text and an inert toolbar.
autoFocusbooleanfalsePuts the caret at the end of the text once the editor is ready.
size"sm" | "default""default"sm is a shorter field with tighter padding, for a reply box.
aria-label / aria-labelledby / aria-describedby / aria-invalidstring / string / string / boolean—Land on the writing surface — the textbox — not on the frame around it. aria-invalid also draws the frame in the danger tone.
classNamestring—Merged onto the frame; the rest of the div props are spread there too.
RichTextReact.ComponentProps<"div"> & { doc: RichDoc | string; headingLevel?: 2 | 3 | 4 }—The server-compatible renderer, from rich-text-editor/rich-text. headingLevel (3 by default) is where the document's heading 2 lands; its heading 3 is one below. A string renders as a paragraph per blank line.
parseRichDoc(input: unknown, options?: { maxLength?: number }) => RichDoc | null—The check, from rich-text-editor/document: the document rebuilt from the allowed nodes and marks, or null for anything else.
isSafeHref(href: string) => boolean—Whether a link may point there: an absolute http, https or mailto address.
richTextToPlain / richTextLength(doc: RichDoc) => string / number—The text alone — blocks a blank line apart, list items a line each — and its length, which maxLength counts.
plainToRichDoc(text: string) => RichDoc—Typed text as a document: a paragraph per blank line, a line break per new line.
EMPTY_DOC / RICH_LIMITSRichDoc / { maxNodes: 2000; maxDepth: 6 }—The editor's empty document, and the size a document may reach.

Dependencies

Source

components/ui/rich-text-editor/index.tsx
"use client"

import * as React from "react"
import { EditorContent, Extension, useEditor, useEditorState } from "@tiptap/react"
import { Plugin } from "@tiptap/pm/state"
import StarterKit from "@tiptap/starter-kit"
import { CharacterCount, Placeholder } from "@tiptap/extensions"

import { cn } from "@/lib/utils"

import { EMPTY_DOC, isSafeHref, parseRichDoc, type RichDoc } from "./document"
import { RICH_TEXT_EDITOR_HEADINGS, RICH_TEXT_PROSE } from "./rich-text"
import { RichTextToolbar } from "./toolbar"

export type RichTextEditorProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** The document, controlled. Leave it out and the editor keeps its own, starting from `defaultValue`. */
  value?: RichDoc
  defaultValue?: RichDoc
  /** Every change the reader makes, as a document parseRichDoc accepts. */
  onValueChange?: (doc: RichDoc) => void
  placeholder?: string
  /** Characters of text the document may hold; typing stops there and a counter shows how many are left. */
  maxLength?: number
  toolbar?: "full" | "minimal"
  /** Posts the document with a form, as JSON in a hidden input of this name. */
  name?: string
  disabled?: boolean
  autoFocus?: boolean
  size?: "sm" | "default"
  "aria-label"?: string
  "aria-labelledby"?: string
  "aria-describedby"?: string
  "aria-invalid"?: boolean
}

/** A link mark whose address is not safe, as the editor would be handed it. */
const unsafeLink = (mark: unknown) =>
  typeof mark === "object" &&
  mark !== null &&
  (mark as { type?: unknown }).type === "link" &&
  !isSafeHref(String((mark as { attrs?: { href?: unknown } }).attrs?.href ?? ""))

/** The same JSON with every unsafe link taken off its words, which stay. */
function withoutUnsafeLinks(node: unknown): unknown {
  if (Array.isArray(node)) return node.map(withoutUnsafeLinks)
  if (typeof node !== "object" || node === null) return node
  const record = node as Record<string, unknown>
  return {
    ...record,
    ...(Array.isArray(record.marks) ? { marks: record.marks.filter((mark) => !unsafeLink(mark)) } : {}),
    ...(Array.isArray(record.content) ? { content: record.content.map(withoutUnsafeLinks) } : {}),
  }
}

/**
 * What the editor opens on: the document it was handed, with any link to an
 * address that is not safe reduced to its words — a document from somewhere
 * else is shown, never trusted — or the empty document when it cannot be read.
 */
function openable(input: RichDoc | undefined): RichDoc {
  return input === undefined ? EMPTY_DOC : (parseRichDoc(withoutUnsafeLinks(input)) ?? EMPTY_DOC)
}

/** Characters as the counter counts them: text, and a line break as one. */
function countOf(doc: RichDoc): number {
  let total = 0
  const walk = (nodes: readonly unknown[] | undefined) => {
    for (const node of nodes ?? []) {
      const { type, text, content } = node as { type: string; text?: string; content?: unknown[] }
      if (type === "text") total += text?.length ?? 0
      else if (type === "hardBreak") total += 1
      else walk(content)
    }
  }
  walk(doc.content)
  return total
}

/**
 * Refuses any change that would leave a document parseRichDoc refuses — more
 * nodes than it allows, lists nested past its depth — so the editor never
 * holds what a server would turn away.
 */
const WithinLimits = Extension.create({
  name: "richTextLimits",
  addProseMirrorPlugins() {
    return [new Plugin({ filterTransaction: (transaction) => !transaction.docChanged || parseRichDoc(transaction.doc.toJSON()) !== null })]
  },
})

/** ⌘⇧X strikes through, as the toolbar says (Tiptap's own chord, ⌘⇧S, still works). */
const EditorKeys = Extension.create({
  name: "richTextEditorKeys",
  addKeyboardShortcuts() {
    return { "Mod-Shift-x": () => this.editor.commands.toggleStrike() }
  },
})

/**
 * A table pasted from a sheet or a page, flattened before the editor reads
 * it: the document has no tables, and ProseMirror would otherwise run every
 * cell's words into one another. Each row becomes a line and each cell a
 * word group, inside one paragraph; every tag is dropped, so the browser's
 * own repair of misplaced table content never reorders it.
 */
function flattenTables(html: string): string {
  return html
    .replace(/<\/t[dh]\s*>\s*(?=<t[dh]\b)/gi, " ")
    .replace(/<\/tr\s*>(?:\s*<\/?(?:thead|tbody|tfoot)\b[^>]*>)*\s*(?=<tr\b)/gi, "<br>")
    .replace(/<table\b[^>]*>/gi, "<p>")
    .replace(/<\/table\s*>/gi, "</p>")
    .replace(/<\/?(?:caption|colgroup|col|thead|tbody|tfoot|tr|td|th)\b[^>]*>/gi, "")
}

/** ⌘K on a Mac, Ctrl+K elsewhere — the platforms ProseMirror reads "Mod" by. */
function isLinkChord(event: KeyboardEvent): boolean {
  const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform)
  const mod = mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
  return mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "k"
}

// Tiptap draws a placeholder as a decoration on the empty first paragraph;
// these set it in the field's own placeholder ink, at the start of the line.
const PLACEHOLDER =
  "[&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-start [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]"

/**
 * A rich-text field on Tiptap: a toolbar, markdown shortcuts, a placeholder
 * and a character count, whose value is a JSON document — RichDoc, which
 * parseRichDoc checks and RichText draws.
 */
function RichTextEditor({
  value,
  defaultValue,
  onValueChange,
  placeholder,
  maxLength,
  toolbar = "full",
  name,
  disabled = false,
  autoFocus = false,
  size = "default",
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  "aria-describedby": ariaDescribedBy,
  "aria-invalid": ariaInvalid,
  className,
  ...props
}: RichTextEditorProps) {
  const [linkOpen, setLinkOpen] = React.useState(false)
  const [initial] = React.useState(() => openable(value ?? defaultValue))
  const [written, setWritten] = React.useState(initial)
  const opened = React.useMemo(() => (value === undefined ? undefined : openable(value)), [value])
  const posted = opened ?? written

  // Built once: an editor's schema and plugins are fixed when it is created.
  const [extensions] = React.useState(() => [
    StarterKit.configure({
      heading: { levels: [2, 3] },
      horizontalRule: false,
      underline: false,
      // No empty paragraph kept after a final list or code block: a saved
      // document ends where the writer stopped.
      trailingNode: false,
      link: { openOnClick: false, autolink: true, isAllowedUri: (url) => isSafeHref(url) },
    }),
    ...(placeholder ? [Placeholder.configure({ placeholder })] : []),
    CharacterCount.configure({ limit: maxLength }),
    WithinLimits,
    EditorKeys,
  ])

  const attributes = React.useMemo(() => {
    const out: Record<string, string> = {
      role: "textbox",
      "aria-multiline": "true",
      class: cn(
        RICH_TEXT_PROSE,
        RICH_TEXT_EDITOR_HEADINGS,
        PLACEHOLDER,
        "outline-none",
        size === "sm" ? "min-h-16 px-2.5 py-2" : "min-h-28 px-3 py-2.5"
      ),
    }
    if (ariaLabel) out["aria-label"] = ariaLabel
    if (ariaLabelledBy) out["aria-labelledby"] = ariaLabelledBy
    if (ariaDescribedBy) out["aria-describedby"] = ariaDescribedBy
    if (ariaInvalid) out["aria-invalid"] = "true"
    if (placeholder) out["aria-placeholder"] = placeholder
    if (disabled) out["aria-disabled"] = "true"
    return out
  }, [ariaLabel, ariaLabelledBy, ariaDescribedBy, ariaInvalid, placeholder, disabled, size])
  const editorProps = React.useMemo(
    () => ({
      attributes,
      transformPastedHTML: flattenTables,
      // ⌘K in the text opens the link field. A page's own ⌘K — AppShell's
      // command palette — listens on the window, so the chord stops here.
      handleKeyDown: (_view: unknown, event: KeyboardEvent) => {
        if (!isLinkChord(event)) return false
        event.stopPropagation()
        setLinkOpen(true)
        return true
      },
    }),
    [attributes]
  )

  const editor = useEditor({
    // Rendered on the client only: the server sends the frame and the toolbar.
    immediatelyRender: false,
    extensions,
    content: initial,
    editable: !disabled,
    autofocus: autoFocus ? "end" : false,
    editorProps,
    onUpdate: ({ editor: current }) => {
      const next = parseRichDoc(current.getJSON(), { maxLength })
      if (!next) return
      if (value === undefined) setWritten(next)
      onValueChange?.(next)
    },
  })

  // A controlled document is set only when it differs from what the editor
  // holds, so the reader's own keystrokes coming back as `value` never move
  // the caret — and setting it is not reported as a change.
  React.useEffect(() => {
    if (!editor || !opened) return
    if (JSON.stringify(parseRichDoc(editor.getJSON())) !== JSON.stringify(opened)) {
      editor.commands.setContent(opened, { emitUpdate: false })
    }
  }, [editor, opened])

  React.useEffect(() => {
    if (editor && editor.isEditable === disabled) editor.setEditable(!disabled, false)
  }, [editor, disabled])

  const counted = useEditorState({ editor, selector: ({ editor: current }) => current?.storage.characterCount.characters() ?? null })
  const count = counted ?? countOf(posted)
  const nearLimit = maxLength !== undefined && count >= maxLength * 0.9

  return (
    <div
      data-slot="rich-text-editor"
      data-size={size}
      data-toolbar={toolbar}
      data-disabled={disabled || undefined}
      className={cn(
        "flex w-full min-w-0 flex-col rounded-lg border border-input bg-card transition-colors has-[[contenteditable=true]:focus-visible]:focus-outline has-[[role=textbox][aria-invalid=true]]:border-destructive data-disabled:bg-muted data-disabled:opacity-50",
        className
      )}
      {...props}
    >
      <RichTextToolbar editor={editor} variant={toolbar} disabled={disabled} linkOpen={linkOpen} onLinkOpenChange={setLinkOpen} />
      <EditorContent editor={editor} className="flex min-w-0 flex-1 flex-col *:flex-1" />
      {maxLength !== undefined ? (
        <div className="flex justify-end px-3 pb-2">
          <span
            data-slot="rich-text-editor-count"
            // A count reads left to right on either page: in a right-to-left run "5 / 20" would show as "20 / 5".
            dir="ltr"
            // Spoken only once the limit is close: a count read out on every
            // keystroke would drown out what is being typed.
            aria-live={nearLimit ? "polite" : undefined}
            className="text-xs text-muted-foreground tabular-nums"
          >
            {count} / {maxLength}
          </span>
        </div>
      ) : null}
      {name ? <input type="hidden" name={name} value={JSON.stringify(posted)} /> : null}
    </div>
  )
}

export { RichTextEditor }
components/ui/rich-text-editor/toolbar.tsx
"use client"

import * as React from "react"
import { useEditorState, type ChainedCommands, type Editor } from "@tiptap/react"
import {
  BoldIcon,
  CodeIcon,
  Heading2Icon,
  Heading3Icon,
  ItalicIcon,
  ListIcon,
  ListOrderedIcon,
  Redo2Icon,
  StrikethroughIcon,
  TextQuoteIcon,
  Undo2Icon,
  type LucideIcon,
} from "lucide-react"

import { Button } from "@/components/ui/button"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Toggle } from "@/components/ui/toggle"
import { Toolbar, ToolbarGroup, ToolbarSeparator, ToolbarSpacer } from "@/components/ui/toolbar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"

import { LinkPopover } from "./link-popover"

/** One formatting control: its name, its chord, whether it is on, and what it does. */
type Tool = {
  label: string
  icon: LucideIcon
  /** The chord, as KbdShortcut writes it — "mod" is ⌘ on a Mac and Ctrl elsewhere. */
  keys: string
  active: (editor: Editor) => boolean
  run: (chain: ChainedCommands) => ChainedCommands
}

const MARKS: Tool[] = [
  { label: "Bold", icon: BoldIcon, keys: "mod+b", active: (e) => e.isActive("bold"), run: (c) => c.toggleBold() },
  { label: "Italic", icon: ItalicIcon, keys: "mod+i", active: (e) => e.isActive("italic"), run: (c) => c.toggleItalic() },
  { label: "Strikethrough", icon: StrikethroughIcon, keys: "mod+shift+x", active: (e) => e.isActive("strike"), run: (c) => c.toggleStrike() },
  { label: "Inline code", icon: CodeIcon, keys: "mod+e", active: (e) => e.isActive("code"), run: (c) => c.toggleCode() },
]

const HEADINGS: Tool[] = [
  { label: "Heading 2", icon: Heading2Icon, keys: "mod+alt+2", active: (e) => e.isActive("heading", { level: 2 }), run: (c) => c.toggleHeading({ level: 2 }) },
  { label: "Heading 3", icon: Heading3Icon, keys: "mod+alt+3", active: (e) => e.isActive("heading", { level: 3 }), run: (c) => c.toggleHeading({ level: 3 }) },
]

const BLOCKS: Tool[] = [
  { label: "Bulleted list", icon: ListIcon, keys: "mod+shift+8", active: (e) => e.isActive("bulletList"), run: (c) => c.toggleBulletList() },
  { label: "Numbered list", icon: ListOrderedIcon, keys: "mod+shift+7", active: (e) => e.isActive("orderedList"), run: (c) => c.toggleOrderedList() },
  { label: "Quote", icon: TextQuoteIcon, keys: "mod+shift+b", active: (e) => e.isActive("blockquote"), run: (c) => c.toggleBlockquote() },
]

/** The chord as aria-keyshortcuts writes it: both platforms' spellings of "mod", modifiers first. */
function ariaKeys(keys: string): string {
  const spell = (mod: string) =>
    keys
      .split("+")
      .map((key) => (key === "mod" ? mod : key === "shift" ? "Shift" : key === "alt" ? "Alt" : key.toUpperCase()))
      .join("+")
  return `${spell("Meta")} ${spell("Control")}`
}

/** The arrow keys this toolbar has turned round, so it hands each on only once. */
const turned = new WeakSet<Event>()

/**
 * The kit's Toolbar reads ArrowRight as the next control in the DOM. In a
 * right-to-left page the row runs the other way, so the arrow a reader
 * presses is handed to the Toolbar as the other one — on the control that
 * has the focus — and the one pressed goes no further.
 */
function inReadingOrder(event: React.KeyboardEvent<HTMLDivElement>) {
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
  // The link field is portalled but still reached through this tree: its arrows move its caret.
  if (turned.has(event.nativeEvent) || !event.currentTarget.contains(event.target as Node)) return
  if (event.currentTarget.closest("[dir]")?.getAttribute("dir") !== "rtl") return
  event.preventDefault()
  const other = new KeyboardEvent("keydown", { key: event.key === "ArrowLeft" ? "ArrowRight" : "ArrowLeft", bubbles: true, cancelable: true })
  turned.add(other)
  event.target.dispatchEvent(other)
}

// A press on a control must not take the focus from the text, or the
// selection the control is about to format would be drawn as lost.
const keepFocus = (event: React.MouseEvent) => event.preventDefault()

function ToolToggle({ tool, editor, pressed, disabled }: { tool: Tool; editor: Editor | null; pressed: boolean; disabled: boolean }) {
  const Icon = tool.icon
  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Toggle
            size="sm"
            aria-label={tool.label}
            aria-keyshortcuts={ariaKeys(tool.keys)}
            pressed={pressed}
            disabled={disabled}
            onMouseDown={keepFocus}
            onPressedChange={() => {
              if (editor) tool.run(editor.chain().focus()).run()
            }}
            className="w-7 px-0"
          >
            <Icon aria-hidden="true" />
          </Toggle>
        }
      />
      <TooltipContent className="flex items-center gap-2">
        {tool.label}
        <KbdShortcut keys={tool.keys} size="sm" className="opacity-80" />
      </TooltipContent>
    </Tooltip>
  )
}

export type RichTextToolbarProps = {
  editor: Editor | null
  variant: "full" | "minimal"
  disabled: boolean
  linkOpen: boolean
  onLinkOpenChange: (open: boolean) => void
}

/**
 * The editor's formatting row: one tab stop, walked with the arrow keys (the
 * kit's Toolbar), each control a pressed-or-not toggle that names itself and
 * its chord. `minimal` keeps the inline marks and the link; lists, headings
 * and quotes are still a markdown shortcut away.
 */
export function RichTextToolbar({ editor, variant, disabled, linkOpen, onLinkOpenChange }: RichTextToolbarProps) {
  const tools = variant === "full" ? [...MARKS, ...HEADINGS, ...BLOCKS] : MARKS
  const state = useEditorState({
    editor,
    selector: ({ editor: current }) => ({
      active: tools.map((tool) => (current ? tool.active(current) : false)),
      canUndo: current?.can().undo() ?? false,
      canRedo: current?.can().redo() ?? false,
    }),
  })
  const pressed = (tool: Tool) => state?.active[tools.indexOf(tool)] ?? false
  const group = (list: Tool[]) =>
    list.map((tool) => <ToolToggle key={tool.label} tool={tool} editor={editor} pressed={pressed(tool)} disabled={disabled} />)
  const run = (command: (chain: ChainedCommands) => ChainedCommands) => {
    if (editor) command(editor.chain().focus()).run()
  }

  return (
    <TooltipProvider delay={400}>
      <Toolbar
        aria-label="Formatting"
        size="sm"
        onKeyDown={inReadingOrder}
        // The field's frame is the box: the row is a strip across its top,
        // ruled off from the text, and wraps on a phone rather than overflowing.
        className="w-full flex-wrap rounded-none border-b border-border bg-transparent shadow-none ring-0"
      >
        <ToolbarGroup>{group(MARKS)}</ToolbarGroup>
        {variant === "full" ? (
          <>
            <ToolbarSeparator />
            <ToolbarGroup>{group(HEADINGS)}</ToolbarGroup>
            <ToolbarSeparator />
            <ToolbarGroup>{group(BLOCKS)}</ToolbarGroup>
          </>
        ) : null}
        <ToolbarSeparator />
        <LinkPopover editor={editor} disabled={disabled} open={linkOpen} onOpenChange={onLinkOpenChange} />
        {variant === "full" ? (
          <>
            <ToolbarSpacer />
            <ToolbarGroup>
              <Button
                type="button"
                variant="ghost"
                size="icon-sm"
                aria-label="Undo"
                tooltip="Undo"
                shortcut="mod+z"
                disabled={disabled || !state?.canUndo}
                onMouseDown={keepFocus}
                onClick={() => run((chain) => chain.undo())}
                className="text-muted-foreground"
              >
                <Undo2Icon aria-hidden="true" className="rtl:-scale-x-100" />
              </Button>
              <Button
                type="button"
                variant="ghost"
                size="icon-sm"
                aria-label="Redo"
                tooltip="Redo"
                shortcut="mod+shift+z"
                disabled={disabled || !state?.canRedo}
                onMouseDown={keepFocus}
                onClick={() => run((chain) => chain.redo())}
                className="text-muted-foreground"
              >
                <Redo2Icon aria-hidden="true" className="rtl:-scale-x-100" />
              </Button>
            </ToolbarGroup>
          </>
        ) : null}
      </Toolbar>
    </TooltipProvider>
  )
}
components/ui/rich-text-editor/link-popover.tsx
"use client"

import * as React from "react"
import type { Editor } from "@tiptap/react"
import { LinkIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

import { isSafeHref } from "./document"

/** What the field says of an address it will not link to. */
export const UNSAFE_LINK_MESSAGE = "Links start with https://, http:// or mailto:"

/**
 * The address field, mounted each time the popover opens, so it starts on
 * the link under the selection — or empty, for a new one.
 */
function LinkForm({ editor, onDone }: { editor: Editor | null; onDone: () => void }) {
  const fieldId = React.useId()
  const errorId = React.useId()
  const [href, setHref] = React.useState<string>(() => editor?.getAttributes("link").href ?? "")
  const [inLink] = React.useState(() => editor?.isActive("link") ?? false)
  const [refused, setRefused] = React.useState(false)

  function remove() {
    editor?.chain().focus().extendMarkRange("link").unsetLink().run()
    onDone()
  }

  function apply(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    // The popover is portalled out of any form the editor sits in, but React
    // bubbles a submit through the tree it was rendered in: this one is the
    // link field's alone.
    event.stopPropagation()
    const address = href.trim()
    if (address === "") return remove()
    // The editor's own check would drop it silently; the reader is told why.
    if (!isSafeHref(address)) return setRefused(true)
    if (editor) {
      const chain = editor.chain().focus()
      // With nothing selected and no link under the caret, the address is
      // also the words: it goes in as linked text rather than as a mark on
      // whatever is typed next.
      if (editor.state.selection.empty && !editor.isActive("link")) {
        chain.insertContent({ type: "text", text: address, marks: [{ type: "link", attrs: { href: address } }] }).run()
      } else {
        chain.extendMarkRange("link").setLink({ href: address }).run()
      }
    }
    onDone()
  }

  return (
    <form noValidate onSubmit={apply} className="flex flex-col gap-2">
      <Label htmlFor={fieldId}>Link address</Label>
      <Input
        id={fieldId}
        value={href}
        inputMode="url"
        autoComplete="off"
        spellCheck={false}
        placeholder="https://"
        aria-invalid={refused || undefined}
        aria-describedby={refused ? errorId : undefined}
        onChange={(event) => {
          setHref(event.target.value)
          setRefused(false)
        }}
      />
      {refused ? (
        <FieldError id={errorId} className="text-xs">
          {UNSAFE_LINK_MESSAGE}
        </FieldError>
      ) : null}
      <div className="flex justify-end gap-2">
        {inLink ? (
          <Button type="button" variant="ghost" size="sm" onClick={remove}>
            Remove
          </Button>
        ) : null}
        <Button type="submit" size="sm">
          Apply
        </Button>
      </div>
    </form>
  )
}

export type LinkPopoverProps = {
  editor: Editor | null
  disabled?: boolean
  open: boolean
  onOpenChange: (open: boolean) => void
}

/**
 * The toolbar's Link button and the field it opens. Open from the button or
 * from ⌘K in the text; Apply links the selection (or writes the address as a
 * link at the caret), Remove unlinks it, and an address that is not http,
 * https or mailto is refused beside the field. The focus goes back to the
 * text whenever the popover closes on something done in it.
 */
export function LinkPopover({ editor, disabled = false, open, onOpenChange }: LinkPopoverProps) {
  return (
    <Popover
      open={open}
      onOpenChange={(next, details) => {
        onOpenChange(next)
        if (!next && details.reason === "escape-key") editor?.commands.focus()
      }}
    >
      <Tooltip>
        <TooltipTrigger
          render={
            <PopoverTrigger
              render={
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Link"
                  aria-keyshortcuts="Meta+K Control+K"
                  disabled={disabled}
                  className="text-muted-foreground"
                >
                  <LinkIcon aria-hidden="true" />
                </Button>
              }
            />
          }
        />
        <TooltipContent className="flex items-center gap-2">
          Link
          <KbdShortcut keys="mod+k" size="sm" className="opacity-80" />
        </TooltipContent>
      </Tooltip>
      <PopoverContent align="start" className="w-72" finalFocus={false}>
        <LinkForm editor={editor} onDone={() => onOpenChange(false)} />
      </PopoverContent>
    </Popover>
  )
}
components/ui/rich-text-editor/document.ts
/**
 * The rich-text editor's document, as it is saved: the JSON the editor hands
 * back, held to what the editor can make. No React and no editor in here, so a
 * server action reads a posted document with the same rules the browser wrote
 * it under — `parseRichDoc` is the check, and anything it does not know is a
 * refusal, never a guess.
 */

export type RichMark = { type: "bold" | "italic" | "strike" | "code" } | { type: "link"; attrs: { href: string } }
export type RichInline = { type: "text"; text: string; marks?: RichMark[] } | { type: "hardBreak" }
export type RichBlock =
  | { type: "paragraph"; content?: RichInline[] }
  | { type: "heading"; attrs: { level: 2 | 3 }; content?: RichInline[] }
  | { type: "codeBlock"; content?: { type: "text"; text: string }[] }
  | { type: "blockquote"; content: RichBlock[] }
  | { type: "bulletList"; content: RichListItem[] }
  | { type: "orderedList"; attrs?: { start: number }; content: RichListItem[] }
export type RichListItem = { type: "listItem"; content: RichBlock[] }
export type RichDoc = { type: "doc"; content: RichBlock[] }

/** How big a document may be: nodes of any kind, and blocks inside blocks. */
export const RICH_LIMITS = { maxNodes: 2000, maxDepth: 6 } as const

/** What the editor holds before anything is typed: one empty paragraph. */
export const EMPTY_DOC: RichDoc = { type: "doc", content: [{ type: "paragraph" }] }

const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:"])

/**
 * Whether a link may point here: an absolute http, https or mailto address,
 * written without spaces or control characters. Everything else — a
 * `javascript:` or `data:` address, a relative or protocol-relative one —
 * is refused.
 */
export function isSafeHref(href: string): boolean {
  if (href.length === 0 || href.length > 2_048 || href.trim() !== href) return false
  if (/[\u0000-\u001F\u007F\s]/.test(href)) return false
  try {
    return SAFE_PROTOCOLS.has(new URL(href).protocol)
  } catch {
    return false // relative and protocol-relative addresses have no protocol of their own
  }
}

type Budget = { nodes: number; chars: number }

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value)

const SIMPLE_MARKS = new Set(["bold", "italic", "strike", "code"])
const MAX_MARKS = 5

/** One more node read; false once the document has more than it may. */
function count(budget: Budget): boolean {
  budget.nodes += 1
  return budget.nodes <= RICH_LIMITS.maxNodes
}

function parseMarks(input: unknown): RichMark[] | null {
  if (!Array.isArray(input) || input.length > MAX_MARKS) return null
  const marks: RichMark[] = []
  for (const mark of input) {
    if (!isRecord(mark) || typeof mark.type !== "string") return null
    // A mark twice over is a document the editor never wrote.
    if (marks.some((kept) => kept.type === mark.type)) return null
    if (SIMPLE_MARKS.has(mark.type)) {
      marks.push({ type: mark.type as "bold" | "italic" | "strike" | "code" })
    } else if (mark.type === "link" && isRecord(mark.attrs) && typeof mark.attrs.href === "string" && isSafeHref(mark.attrs.href)) {
      marks.push({ type: "link", attrs: { href: mark.attrs.href } })
    } else {
      return null
    }
  }
  return marks
}

function parseText(node: Record<string, unknown>, budget: Budget): { type: "text"; text: string } | null {
  if (typeof node.text !== "string" || node.text.length === 0) return null
  budget.chars += node.text.length
  return { type: "text", text: node.text }
}

function parseInline(node: unknown, budget: Budget): RichInline | null {
  if (!isRecord(node) || !count(budget)) return null
  if (node.type === "hardBreak") return { type: "hardBreak" }
  if (node.type !== "text") return null
  const text = parseText(node, budget)
  if (!text) return null
  if (node.marks === undefined) return text
  const marks = parseMarks(node.marks)
  if (!marks) return null
  return marks.length > 0 ? { ...text, marks } : text
}

/** A list of children, each read by `parse`; null if any is refused, or if it must hold one and holds none. */
function parseAll<T>(input: unknown, parse: (node: unknown) => T | null, required: boolean): T[] | null {
  if (input === undefined && !required) return []
  if (!Array.isArray(input) || (required && input.length === 0)) return null
  const out: T[] = []
  for (const node of input) {
    const parsed = parse(node)
    if (!parsed) return null
    out.push(parsed)
  }
  return out
}

function parseListItem(node: unknown, depth: number, budget: Budget): RichListItem | null {
  if (!isRecord(node) || node.type !== "listItem" || !count(budget) || depth > RICH_LIMITS.maxDepth) return null
  const content = parseAll(node.content, (child) => parseBlock(child, depth + 1, budget), true)
  return content ? { type: "listItem", content } : null
}

function parseBlock(node: unknown, depth: number, budget: Budget): RichBlock | null {
  if (!isRecord(node) || !count(budget) || depth > RICH_LIMITS.maxDepth) return null
  const inlines = () => parseAll(node.content, (child) => parseInline(child, budget), false)
  const blocks = () => parseAll(node.content, (child) => parseBlock(child, depth + 1, budget), true)
  const items = () => parseAll(node.content, (child) => parseListItem(child, depth + 1, budget), true)

  // Inline content is omitted when there is none: the editor writes an empty
  // paragraph without it.
  switch (node.type) {
    case "paragraph": {
      const content = inlines()
      if (!content) return null
      return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" }
    }
    case "heading": {
      const level = isRecord(node.attrs) ? node.attrs.level : undefined
      if (level !== 2 && level !== 3) return null
      const content = inlines()
      if (!content) return null
      return content.length > 0 ? { type: "heading", attrs: { level }, content } : { type: "heading", attrs: { level } }
    }
    case "codeBlock": {
      // Code is text as typed: no marks, no breaks — a new line is a "\n".
      const content = parseAll(
        node.content,
        (child) =>
          isRecord(child) && child.type === "text" && (child.marks === undefined || (Array.isArray(child.marks) && child.marks.length === 0)) && count(budget)
            ? parseText(child, budget)
            : null,
        false
      )
      return content ? (content.length > 0 ? { type: "codeBlock", content } : { type: "codeBlock" }) : null
    }
    case "blockquote": {
      const content = blocks()
      return content ? { type: "blockquote", content } : null
    }
    case "bulletList": {
      const content = items()
      return content ? { type: "bulletList", content } : null
    }
    case "orderedList": {
      const content = items()
      if (!content) return null
      const start = isRecord(node.attrs) ? node.attrs.start : undefined
      return typeof start === "number" && Number.isInteger(start) && start >= 1 && start <= 10_000
        ? { type: "orderedList", attrs: { start }, content }
        : { type: "orderedList", content }
    }
    default:
      return null
  }
}

/**
 * The document, rebuilt from what was posted with only what the editor can
 * make — or null. Every node is one the editor writes (paragraphs, headings 2
 * and 3, lists, quotes, code blocks, line breaks, text), every mark is bold,
 * italic, strike, code or a link to a safe address, and nothing else is
 * copied across: an attribute the editor adds (a link's target, a list's
 * type) is dropped rather than trusted. A document past `maxLength`
 * characters of text, past RICH_LIMITS's nodes or nesting, is refused whole.
 */
export function parseRichDoc(input: unknown, options: { maxLength?: number } = {}): RichDoc | null {
  if (!isRecord(input) || input.type !== "doc") return null
  const budget: Budget = { nodes: 0, chars: 0 }
  const content = parseAll(input.content, (node) => parseBlock(node, 1, budget), true)
  if (!content) return null
  if (options.maxLength !== undefined && budget.chars > options.maxLength) return null
  return { type: "doc", content }
}

function inlineToPlain(content: RichInline[] | undefined): string {
  return (content ?? []).map((node) => (node.type === "text" ? node.text : "\n")).join("")
}

function itemToPlain(item: RichListItem, marker: string): string {
  // The item's own lines under its marker, and a nested list indented under them.
  return item.content
    .map(blockToPlain)
    .join("\n")
    .split("\n")
    .map((line, index) => (index === 0 ? `${marker}${line}` : `  ${line}`))
    .join("\n")
}

function blockToPlain(block: RichBlock): string {
  switch (block.type) {
    case "paragraph":
    case "heading":
      return inlineToPlain(block.content)
    case "codeBlock":
      return (block.content ?? []).map((node) => node.text).join("")
    case "blockquote":
      return block.content.map(blockToPlain).join("\n\n")
    case "bulletList":
      return block.content.map((item) => itemToPlain(item, "• ")).join("\n")
    case "orderedList": {
      const start = block.attrs?.start ?? 1
      return block.content.map((item, index) => itemToPlain(item, `${start + index}. `)).join("\n")
    }
  }
}

/** The document as plain text: blocks a blank line apart, list items a line each, a line break a new line. */
export function richTextToPlain(doc: RichDoc): string {
  return doc.content.map(blockToPlain).join("\n\n")
}

function blockLength(block: RichBlock | RichListItem): number {
  if (block.type === "paragraph" || block.type === "heading" || block.type === "codeBlock") {
    return (block.content ?? []).reduce((sum, node) => sum + (node.type === "text" ? node.text.length : 0), 0)
  }
  return (block.content as (RichBlock | RichListItem)[]).reduce((sum, child) => sum + blockLength(child), 0)
}

/** How many characters of text the document holds — what `maxLength` counts. */
export function richTextLength(doc: RichDoc): number {
  return doc.content.reduce((sum, block) => sum + blockLength(block), 0)
}

/**
 * Plain text as a document: a paragraph per run of blank lines, a line break
 * per single new line. The new lines at either end of the text start and end
 * nothing, so they are left out.
 */
export function plainToRichDoc(text: string): RichDoc {
  const paragraphs = text
    .replace(/\r\n?/g, "\n")
    .replace(/^\n+|\n+$/g, "")
    .split(/\n(?:[ \t]*\n)+/)
    .filter((paragraph) => paragraph.length > 0)
    .map((paragraph): RichBlock => {
      const content: RichInline[] = []
      paragraph.split("\n").forEach((line, index) => {
        if (index > 0) content.push({ type: "hardBreak" })
        if (line.length > 0) content.push({ type: "text", text: line })
      })
      return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" }
    })
  return { type: "doc", content: paragraphs.length > 0 ? paragraphs : [{ type: "paragraph" }] }
}
components/ui/rich-text-editor/rich-text.tsx
import * as React from "react"

import { cn } from "@/lib/utils"
import {
  isSafeHref,
  plainToRichDoc,
  type RichBlock,
  type RichDoc,
  type RichInline,
  type RichListItem,
  type RichMark,
} from "./document"

/**
 * How a document reads, set on the element that holds it: the space between
 * blocks, lists, quotes, code and links. RichText's root wears it, and so does
 * the editor's writing surface, so a message reads after it is sent the way it
 * did while it was written.
 */
export const RICH_TEXT_PROSE =
  "text-sm text-foreground text-pretty wrap-break-word [&>*+*]:mt-3 [&_ul]:list-disc [&_ul]:ps-5 [&_ol]:list-decimal [&_ol]:ps-5 [&_li]:marker:text-muted-foreground [&_li+li]:mt-1 [&_li>*+*]:mt-1 [&_blockquote]:border-s-2 [&_blockquote]:border-rule [&_blockquote]:ps-3 [&_blockquote]:text-muted-foreground [&_blockquote>*+*]:mt-2 [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono [&_pre]:text-label [&_pre]:whitespace-pre-wrap [&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-label [&_strong]:font-semibold [&_a]:text-brand [&_a]:underline [&_a]:underline-offset-4"

/** A document's two heading levels. RichText sets them on the element, at whatever tag the page needs. */
const HEADING_CLASSES = { 2: "text-base font-semibold", 3: "text-sm font-semibold" } as const

/** The same two levels where the editor draws them, as its h2 and h3. Keep in step with HEADING_CLASSES. */
export const RICH_TEXT_EDITOR_HEADINGS = "[&_h2]:text-base [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-semibold"

export type RichTextProps = React.ComponentProps<"div"> & {
  /** A saved document — read through parseRichDoc on its way in — or plain text, a paragraph per blank line. */
  doc: RichDoc | string
  /**
   * The level the document's own first heading level is drawn at; its second
   * level is one below. 3 by default, for a document under a card's h2.
   */
  headingLevel?: 2 | 3 | 4
}

const linkOf = (node: RichInline) =>
  node.type === "text" ? node.marks?.find((mark): mark is Extract<RichMark, { type: "link" }> => mark.type === "link") : undefined

/** One text node with every mark but the link, innermost first. */
function renderText(node: Extract<RichInline, { type: "text" }>, key: React.Key): React.ReactNode {
  let out: React.ReactNode = node.text
  const has = (type: RichMark["type"]) => node.marks?.some((mark) => mark.type === type)
  if (has("code")) out = <code>{out}</code>
  if (has("strike")) out = <s>{out}</s>
  if (has("italic")) out = <em>{out}</em>
  if (has("bold")) out = <strong>{out}</strong>
  return <React.Fragment key={key}>{out}</React.Fragment>
}

/**
 * A run of inline nodes. Neighbours that share a link are drawn inside one
 * anchor, so a link with a bold word in it is still one link; a link to an
 * address that is not safe is drawn as its words alone.
 */
function renderInlines(content: RichInline[] | undefined): React.ReactNode[] {
  const out: React.ReactNode[] = []
  const nodes = content ?? []
  for (let index = 0; index < nodes.length; ) {
    const href = linkOf(nodes[index])?.attrs.href
    let end = index + 1
    while (href !== undefined && end < nodes.length && linkOf(nodes[end])?.attrs.href === href) end += 1
    const run = nodes.slice(index, end).map((node, offset) =>
      node.type === "hardBreak" ? <br key={index + offset} /> : renderText(node, index + offset)
    )
    out.push(
      href !== undefined && isSafeHref(href) ? (
        <a key={index} href={href} rel="noopener noreferrer" className="focus-ring">
          {run}
        </a>
      ) : (
        <React.Fragment key={index}>{run}</React.Fragment>
      )
    )
    index = end
  }
  return out
}

function renderItem(item: RichListItem, headingLevel: number, key: React.Key) {
  return <li key={key}>{item.content.map((block, index) => renderBlock(block, headingLevel, index))}</li>
}

function renderBlock(block: RichBlock, headingLevel: number, key: React.Key): React.ReactNode {
  switch (block.type) {
    case "paragraph":
      // An empty paragraph is a blank line the writer left: a line high, as the editor drew it.
      return <p key={key}>{block.content?.length ? renderInlines(block.content) : <br />}</p>
    case "heading": {
      const Tag = `h${Math.min(headingLevel + block.attrs.level - 2, 6)}` as "h2" | "h3" | "h4" | "h5"
      return (
        <Tag key={key} className={HEADING_CLASSES[block.attrs.level]}>
          {renderInlines(block.content)}
        </Tag>
      )
    }
    case "codeBlock":
      return (
        <pre key={key}>
          <code>{(block.content ?? []).map((node) => node.text).join("")}</code>
        </pre>
      )
    case "blockquote":
      return <blockquote key={key}>{block.content.map((child, index) => renderBlock(child, headingLevel, index))}</blockquote>
    case "bulletList":
      return <ul key={key}>{block.content.map((item, index) => renderItem(item, headingLevel, index))}</ul>
    case "orderedList":
      return (
        <ol key={key} start={block.attrs?.start}>
          {block.content.map((item, index) => renderItem(item, headingLevel, index))}
        </ol>
      )
    default:
      // A block the document type does not know is not drawn: nothing it holds is trusted.
      return null
  }
}

/**
 * A saved document, drawn with React elements and nothing else — never
 * `dangerouslySetInnerHTML`, and never an attribute the document carries
 * beyond a link's address. Server-compatible: no hooks and no client
 * boundary, so a page renders a sent message where it reads it.
 */
function RichText({ doc, headingLevel = 3, className, ...props }: RichTextProps) {
  const document = typeof doc === "string" ? plainToRichDoc(doc) : doc
  return (
    <div data-slot="rich-text" className={cn(RICH_TEXT_PROSE, className)} {...props}>
      {document.content.map((block, index) => renderBlock(block, headingLevel, index))}
    </div>
  )
}

export { RichText }