{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rich-text-editor",
  "title": "Rich Text Editor",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A rich text editor on Tiptap: headings, bold, lists, checklists, quotes, code and links, with a toolbar you drive by keyboard, a slash menu for blocks, a link popover, a character limit and HTML output.",
  "dependencies": [
    "@tiptap/core@^3",
    "@tiptap/react@^3",
    "@tiptap/pm@^3",
    "@tiptap/starter-kit@^3",
    "@tiptap/extensions@^3",
    "@tiptap/extension-list@^3",
    "@tiptap/extension-text-align@^3",
    "@tiptap/suggestion@^3",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/popover.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/rich-text-editor.tsx",
      "content": "// Ballmac UI: Rich Text Editor. https://ui.ballmac.com/components/rich-text-editor\n// Based on Tiptap (MIT, Copyright (c) 2025 Tiptap GmbH) and ProseMirror (MIT, Copyright (C) 2015-2017 by Marijn Haverbeke and others), with a keyboard toolbar, slash menu and link popover.\n\"use client\"\n\nimport * as React from \"react\"\nimport { Extension, type Editor, type Range } from \"@tiptap/core\"\nimport { TaskItem, TaskList } from \"@tiptap/extension-list\"\nimport TextAlign from \"@tiptap/extension-text-align\"\nimport { CharacterCount, Placeholder } from \"@tiptap/extensions\"\nimport { EditorContent, useEditor, useEditorState } from \"@tiptap/react\"\nimport type { EditorView } from \"@tiptap/pm/view\"\nimport StarterKit from \"@tiptap/starter-kit\"\nimport Suggestion, { type SuggestionProps } from \"@tiptap/suggestion\"\nimport {\n  AlignCenter,\n  AlignJustify,\n  AlignLeft,\n  AlignRight,\n  Bold,\n  Code,\n  Code2,\n  Heading1,\n  Heading2,\n  Heading3,\n  Italic,\n  Link2,\n  List,\n  ListChecks,\n  ListOrdered,\n  Minus,\n  Quote,\n  Redo2,\n  Strikethrough,\n  Underline as UnderlineIcon,\n  Undo2,\n  Unlink,\n} from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ballmac/popover\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useMessages, type Msg } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\ntype RichTextFeature =\n  | \"bold\"\n  | \"italic\"\n  | \"underline\"\n  | \"strike\"\n  | \"code\"\n  | \"link\"\n  | \"heading\"\n  | \"bulletList\"\n  | \"orderedList\"\n  | \"taskList\"\n  | \"blockquote\"\n  | \"codeBlock\"\n  | \"horizontalRule\"\n  | \"align\"\n  | \"undo\"\n  | \"slash\"\n\nconst DEFAULT_FEATURES: RichTextFeature[] = [\"heading\", \"bold\", \"italic\", \"underline\", \"strike\", \"code\", \"link\", \"bulletList\", \"orderedList\", \"taskList\", \"blockquote\", \"codeBlock\", \"horizontalRule\", \"undo\", \"slash\"]\n\ntype RichTextEditorProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> &\n  StateProps & {\n    /** What the editor is for, read by screen readers: \"Message\". */\n    label: string\n    /** The content, as HTML, when you hold it in state. Pair with `onChange`. */\n    value?: string\n    /** The content to start with, when the editor holds it. */\n    defaultValue?: string\n    /** Called with the HTML when the content changes. Clean it before you store or show it to others. */\n    onChange?: (html: string, editor: Editor) => void\n    placeholder?: string\n    /** Which tools to offer. Leave out `slash` to turn off the \"/\" menu. */\n    features?: RichTextFeature[]\n    disabled?: boolean\n    /** Stop at this many characters, and show the count. */\n    characterLimit?: number\n    /** Show the word and character count under the editor. */\n    showCount?: boolean\n    invalid?: boolean\n    /** Hint or error shown under the editor and linked to it. */\n    description?: React.ReactNode\n    minHeight?: number\n    maxHeight?: number\n    /** Focus the editor when it mounts. */\n    autoFocus?: boolean\n    /** Opens links in a new tab. Off by default. */\n    linksInNewTab?: boolean\n    /** Hands you the Tiptap editor, for anything this does not cover. */\n    editorRef?: React.Ref<Editor | null>\n  }\n\n/** The look of the content: headings, lists, quotes, code and links, without needing the Tailwind typography plugin. */\nconst PROSE = [\n  \"[&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[inherit] [&_.ProseMirror]:text-sm [&_.ProseMirror]:leading-relaxed\",\n  \"[&_.ProseMirror>*+*]:mt-3\",\n  \"[&_.ProseMirror_h1]:text-2xl [&_.ProseMirror_h1]:font-semibold [&_.ProseMirror_h1]:tracking-tight [&_.ProseMirror_h1]:leading-tight\",\n  \"[&_.ProseMirror_h2]:text-xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:tracking-tight [&_.ProseMirror_h2]:leading-snug\",\n  \"[&_.ProseMirror_h3]:text-base [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_h3]:leading-snug\",\n  \"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:ps-6 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:ps-6 [&_.ProseMirror_li>p]:m-0 [&_.ProseMirror_li+li]:mt-1\",\n  \"[&_.ProseMirror_ul[data-type=taskList]]:list-none [&_.ProseMirror_ul[data-type=taskList]]:ps-0\",\n  \"[&_.ProseMirror_li[data-type=taskItem]]:flex [&_.ProseMirror_li[data-type=taskItem]]:items-start [&_.ProseMirror_li[data-type=taskItem]]:gap-2\",\n  \"[&_.ProseMirror_li[data-type=taskItem]>label]:mt-[3px] [&_.ProseMirror_li[data-type=taskItem]>label_input]:size-4 [&_.ProseMirror_li[data-type=taskItem]>label_input]:accent-primary\",\n  \"[&_.ProseMirror_li[data-type=taskItem]>div]:min-w-0 [&_.ProseMirror_li[data-type=taskItem][data-checked=true]>div]:text-muted-foreground [&_.ProseMirror_li[data-type=taskItem][data-checked=true]>div]:line-through\",\n  \"[&_.ProseMirror_blockquote]:border-s-2 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:ps-4 [&_.ProseMirror_blockquote]:text-muted-foreground\",\n  \"[&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:border [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-[13px] [&_.ProseMirror_pre]:[direction:ltr] [&_.ProseMirror_pre]:text-start\",\n  \"[&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:bg-muted [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.85em]\",\n  \"[&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0\",\n  \"[&_.ProseMirror_hr]:my-4 [&_.ProseMirror_hr]:border-border\",\n  \"[&_.ProseMirror_a]:text-primary [&_.ProseMirror_a]:underline [&_.ProseMirror_a]:underline-offset-4 [&_.ProseMirror_a]:decoration-primary/40 hover:[&_.ProseMirror_a]:decoration-primary\",\n  \"[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-start [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]\",\n  \"[&_.ProseMirror_.selectedNode]:ring-2 [&_.ProseMirror_.selectedNode]:ring-ring/50\",\n].join(\" \")\n\n// ------------------------------------------------------------------------------------------------- slash commands\n\ntype SlashItem = { id: string; label: string; description: string; icon: React.ReactNode; keywords: string; run: (chain: ReturnType<Editor[\"chain\"]>) => void }\n\nfunction slashItems(msg: Msg): SlashItem[] {\n  const icon = (I: React.ComponentType<{ className?: string; \"aria-hidden\"?: boolean | \"true\" }>) => <I className=\"size-4\" aria-hidden=\"true\" />\n  return [\n    { id: \"h1\", label: msg(\"rich-text-editor.slash.h1\", \"Heading 1\"), description: msg(\"rich-text-editor.slash.h1Hint\", \"A large section heading\"), icon: icon(Heading1), keywords: \"title heading h1\", run: (c) => c.setNode(\"heading\", { level: 1 }).run() },\n    { id: \"h2\", label: msg(\"rich-text-editor.slash.h2\", \"Heading 2\"), description: msg(\"rich-text-editor.slash.h2Hint\", \"A medium heading\"), icon: icon(Heading2), keywords: \"subtitle heading h2\", run: (c) => c.setNode(\"heading\", { level: 2 }).run() },\n    { id: \"h3\", label: msg(\"rich-text-editor.slash.h3\", \"Heading 3\"), description: msg(\"rich-text-editor.slash.h3Hint\", \"A small heading\"), icon: icon(Heading3), keywords: \"heading h3\", run: (c) => c.setNode(\"heading\", { level: 3 }).run() },\n    { id: \"bullets\", label: msg(\"rich-text-editor.slash.bullets\", \"Bulleted list\"), description: msg(\"rich-text-editor.slash.bulletsHint\", \"A simple list of points\"), icon: icon(List), keywords: \"list bullets unordered ul\", run: (c) => c.toggleBulletList().run() },\n    { id: \"numbers\", label: msg(\"rich-text-editor.slash.numbers\", \"Numbered list\"), description: msg(\"rich-text-editor.slash.numbersHint\", \"Steps in order\"), icon: icon(ListOrdered), keywords: \"list numbers ordered ol steps\", run: (c) => c.toggleOrderedList().run() },\n    { id: \"tasks\", label: msg(\"rich-text-editor.slash.tasks\", \"Checklist\"), description: msg(\"rich-text-editor.slash.tasksHint\", \"Things to tick off\"), icon: icon(ListChecks), keywords: \"todo task checklist checkbox\", run: (c) => c.toggleTaskList().run() },\n    { id: \"quote\", label: msg(\"rich-text-editor.slash.quote\", \"Quote\"), description: msg(\"rich-text-editor.slash.quoteHint\", \"Set a passage apart\"), icon: icon(Quote), keywords: \"quote blockquote citation\", run: (c) => c.toggleBlockquote().run() },\n    { id: \"code\", label: msg(\"rich-text-editor.slash.code\", \"Code block\"), description: msg(\"rich-text-editor.slash.codeHint\", \"Monospaced code\"), icon: icon(Code2), keywords: \"code snippet pre\", run: (c) => c.toggleCodeBlock().run() },\n    { id: \"divider\", label: msg(\"rich-text-editor.slash.divider\", \"Divider\"), description: msg(\"rich-text-editor.slash.dividerHint\", \"A line between sections\"), icon: icon(Minus), keywords: \"divider rule hr line separator\", run: (c) => c.setHorizontalRule().run() },\n  ]\n}\n\ntype SlashState = { items: SlashItem[]; command: (item: SlashItem) => void; rect: DOMRect | null; index: number }\n\n/** The \"/\" menu. It is plain Tiptap suggestion; the list is drawn by the component, which owns its state and its keys. */\nfunction slashExtension(bridge: React.MutableRefObject<{ show: (s: SlashState | null) => void; key: (e: KeyboardEvent) => boolean; items: () => SlashItem[] }>) {\n  return Extension.create({\n    name: \"slashCommand\",\n    addProseMirrorPlugins() {\n      return [\n        Suggestion<SlashItem, SlashItem>({\n          editor: this.editor,\n          char: \"/\",\n          allowSpaces: false,\n          startOfLine: false,\n          items: ({ query }) => {\n            const q = query.trim().toLowerCase()\n            return bridge.current.items().filter((i) => !q || `${i.label} ${i.keywords}`.toLowerCase().includes(q))\n          },\n          command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => props.run(editor.chain().focus().deleteRange(range)),\n          render: () => {\n            const push = (p: SuggestionProps<SlashItem, SlashItem>, index = 0) => bridge.current.show({ items: p.items, command: (item) => p.command(item), rect: p.clientRect?.() ?? null, index })\n            return {\n              onStart: (p) => push(p),\n              onUpdate: (p) => push(p),\n              onKeyDown: ({ event }) => bridge.current.key(event),\n              onExit: () => bridge.current.show(null),\n            }\n          },\n        }),\n      ]\n    },\n  })\n}\n\n// ------------------------------------------------------------------------------------------------- toolbar\n\ntype ToolbarState = {\n  bold: boolean\n  italic: boolean\n  underline: boolean\n  strike: boolean\n  code: boolean\n  link: boolean\n  bulletList: boolean\n  orderedList: boolean\n  taskList: boolean\n  blockquote: boolean\n  codeBlock: boolean\n  heading: 0 | 1 | 2 | 3\n  align: \"left\" | \"center\" | \"right\" | \"justify\" | null\n  canUndo: boolean\n  canRedo: boolean\n  href: string\n}\n\nconst toolbarState = (editor: Editor): ToolbarState => ({\n  bold: editor.isActive(\"bold\"),\n  italic: editor.isActive(\"italic\"),\n  underline: editor.isActive(\"underline\"),\n  strike: editor.isActive(\"strike\"),\n  code: editor.isActive(\"code\"),\n  link: editor.isActive(\"link\"),\n  bulletList: editor.isActive(\"bulletList\"),\n  orderedList: editor.isActive(\"orderedList\"),\n  taskList: editor.isActive(\"taskList\"),\n  blockquote: editor.isActive(\"blockquote\"),\n  codeBlock: editor.isActive(\"codeBlock\"),\n  heading: editor.isActive(\"heading\", { level: 1 }) ? 1 : editor.isActive(\"heading\", { level: 2 }) ? 2 : editor.isActive(\"heading\", { level: 3 }) ? 3 : 0,\n  align: ([\"left\", \"center\", \"right\", \"justify\"] as const).find((a) => editor.isActive({ textAlign: a })) ?? null,\n  canUndo: editor.can().undo(),\n  canRedo: editor.can().redo(),\n  href: (editor.getAttributes(\"link\").href as string | undefined) ?? \"\",\n})\n\nconst toolButton =\n  \"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 aria-pressed:bg-accent aria-pressed:text-foreground motion-reduce:transition-none\"\n\nconst isMac = () => typeof navigator !== \"undefined\" && /Mac|iPhone|iPad/.test(navigator.platform)\n\n/** A web address people may link to. Adds https:// to a bare domain and refuses javascript: and other schemes. */\nfunction cleanUrl(input: string): string | null {\n  const raw = input.trim()\n  if (!raw) return null\n  if (/^(https?:|mailto:|tel:)/i.test(raw) || raw.startsWith(\"/\") || raw.startsWith(\"#\")) return raw\n  if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return null\n  return /^[^\\s/]+\\.[^\\s/]+/.test(raw) || raw.startsWith(\"localhost\") ? `https://${raw}` : null\n}\n\n// ------------------------------------------------------------------------------------------------- the editor\n\n/**\n * A rich text editor on Tiptap: headings, bold, italic, lists, checklists, quotes, code, links and dividers. It has a toolbar you can drive with the\n * arrow keys, markdown-style shortcuts, a \"/\" menu for blocks, a link popover (Ctrl+K), a character limit and a count. It hands you HTML.\n */\nfunction RichTextEditorCore({\n  label,\n  value,\n  defaultValue = \"\",\n  onChange,\n  placeholder,\n  features = DEFAULT_FEATURES,\n  disabled = false,\n  characterLimit,\n  showCount = false,\n  invalid = false,\n  description,\n  minHeight = 160,\n  maxHeight,\n  autoFocus = false,\n  linksInNewTab = false,\n  editorRef,\n  state,\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: RichTextEditorProps) {\n  const msg = useMessages()\n  const dir = useDirection()\n  const rtl = dir === \"rtl\"\n  const id = React.useId()\n  const has = (f: RichTextFeature) => features.includes(f)\n  const lastHtml = React.useRef(value ?? defaultValue)\n  const onChangeRef = React.useRef(onChange)\n  onChangeRef.current = onChange\n\n  // ----- the slash menu: Tiptap finds the \"/\", this component draws the list\n  const [slash, setSlash] = React.useState<SlashState | null>(null)\n  const slashRef = React.useRef<SlashState | null>(null)\n  slashRef.current = slash\n  const itemsRef = React.useRef<SlashItem[]>([])\n  itemsRef.current = slashItems(msg)\n  const bridge = React.useRef({\n    show: (s: SlashState | null) => setSlash(s),\n    items: () => itemsRef.current,\n    key: (e: KeyboardEvent) => {\n      const s = slashRef.current\n      if (!s || !s.items.length) return false\n      if (e.key === \"ArrowDown\") return setSlash({ ...s, index: (s.index + 1) % s.items.length }), true\n      if (e.key === \"ArrowUp\") return setSlash({ ...s, index: (s.index - 1 + s.items.length) % s.items.length }), true\n      if (e.key === \"Enter\" || e.key === \"Tab\") return s.command(s.items[s.index]!), true\n      if (e.key === \"Escape\") return setSlash(null), true\n      return false\n    },\n  })\n\n  const extensions = React.useMemo(\n    () => [\n      StarterKit.configure({\n        heading: { levels: [1, 2, 3] },\n        link: { openOnClick: false, autolink: true, defaultProtocol: \"https\", HTMLAttributes: linksInNewTab ? { target: \"_blank\", rel: \"noopener noreferrer nofollow\" } : { rel: \"noopener noreferrer nofollow\" } },\n      }),\n      Placeholder.configure({ placeholder: placeholder ?? msg(\"rich-text-editor.placeholder\", \"Write something, or type / for blocks\") }),\n      CharacterCount.configure({ limit: characterLimit }),\n      TaskList,\n      TaskItem.configure({ nested: true }),\n      TextAlign.configure({ types: [\"heading\", \"paragraph\"] }),\n      ...(has(\"slash\") ? [slashExtension(bridge)] : []),\n    ],\n    // the editor is built once; changing the tools later rebuilds it through the key below\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [characterLimit, linksInNewTab, placeholder, features.join(\",\")]\n  )\n\n  const editor = useEditor(\n    {\n      extensions,\n      content: value ?? defaultValue,\n      editable: !disabled,\n      autofocus: autoFocus ? \"end\" : false,\n      // a server render has no editor to show; it is made after the page loads, so server and browser agree\n      immediatelyRender: false,\n      editorProps: {\n        attributes: {\n          role: \"textbox\",\n          \"aria-multiline\": \"true\",\n          \"aria-label\": label,\n          \"aria-describedby\": `${id}-description`,\n          \"aria-invalid\": invalid ? \"true\" : \"false\",\n          \"aria-disabled\": disabled ? \"true\" : \"false\",\n          dir,\n          spellcheck: \"true\",\n        },\n        handleKeyDown: (_view: EditorView, event: KeyboardEvent) => {\n          // Ctrl or Cmd + K opens the link popover\n          if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"k\" && has(\"link\")) {\n            event.preventDefault()\n            setLinkOpen(true)\n            return true\n          }\n          return false\n        },\n      },\n      onUpdate: ({ editor }) => {\n        const html = editor.isEmpty ? \"\" : editor.getHTML()\n        lastHtml.current = html\n        onChangeRef.current?.(html, editor)\n      },\n    },\n    [extensions]\n  )\n\n  React.useImperativeHandle<Editor | null, Editor | null>(editorRef as React.Ref<Editor | null>, () => editor, [editor])\n\n  // a value the caller changed from outside replaces the content, without moving the cursor when it is what we just sent\n  React.useEffect(() => {\n    if (!editor || value === undefined) return\n    if (value === lastHtml.current) return\n    lastHtml.current = value\n    editor.commands.setContent(value, { emitUpdate: false })\n  }, [editor, value])\n  React.useEffect(() => {\n    editor?.setEditable(!disabled)\n  }, [editor, disabled])\n  React.useEffect(() => {\n    editor?.setOptions({ editorProps: { attributes: { ...(editor.options.editorProps.attributes as Record<string, string>), \"aria-invalid\": invalid ? \"true\" : \"false\", \"aria-label\": label, \"aria-disabled\": disabled ? \"true\" : \"false\", dir } } })\n  }, [editor, invalid, label, disabled, dir])\n\n  const s = useEditorState({ editor, selector: ({ editor }) => (editor ? toolbarState(editor) : null) })\n  const counts =\n    useEditorState({\n      editor,\n      selector: ({ editor }) => (editor ? { chars: editor.storage.characterCount.characters() as number, words: editor.storage.characterCount.words() as number } : { chars: 0, words: 0 }),\n    }) ?? { chars: 0, words: 0 }\n\n  // ----- links\n  const [linkOpen, setLinkOpen] = React.useState(false)\n  const [href, setHref] = React.useState(\"\")\n  const [linkError, setLinkError] = React.useState(\"\")\n  React.useEffect(() => {\n    if (linkOpen) {\n      setHref(s?.href ?? \"\")\n      setLinkError(\"\")\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [linkOpen])\n  const applyLink = () => {\n    if (!editor) return\n    const url = cleanUrl(href)\n    if (!url) return setLinkError(msg(\"rich-text-editor.linkInvalid\", \"Enter a web address like example.com.\"))\n    editor.chain().focus().extendMarkRange(\"link\").setLink({ href: url }).run()\n    setLinkOpen(false)\n  }\n  const removeLink = () => {\n    editor?.chain().focus().extendMarkRange(\"link\").unsetLink().run()\n    setLinkOpen(false)\n  }\n\n  // ----- the toolbar as one tab stop, moved with the arrow keys\n  const toolbar = React.useRef<HTMLDivElement>(null)\n  const [stop, setStop] = React.useState(0)\n  const onToolbarKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const items = Array.from(toolbar.current?.querySelectorAll<HTMLElement>(\"[data-toolbar-item]:not([disabled])\") ?? [])\n    const at = items.indexOf(document.activeElement as HTMLElement)\n    if (at < 0) return\n    const next = e.key === (rtl ? \"ArrowLeft\" : \"ArrowRight\") ? at + 1 : e.key === (rtl ? \"ArrowRight\" : \"ArrowLeft\") ? at - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? items.length - 1 : null\n    if (next === null) return\n    e.preventDefault()\n    const target = items[(next + items.length) % items.length]!\n    target.focus()\n    setStop(Number(target.dataset.toolbarItem))\n  }\n\n  const mod = isMac() ? \"⌘\" : \"Ctrl+\"\n  const T = (key: string, fallback: string) => msg(`rich-text-editor.${key}`, fallback)\n  let n = 0\n  const item = (extra: { id: string; label: string; shortcut?: string; icon: React.ReactNode; pressed?: boolean; disabled?: boolean; run: () => void }) => {\n    const index = n++\n    return (\n      <button\n        key={extra.id}\n        type=\"button\"\n        data-toolbar-item={index}\n        tabIndex={stop === index ? 0 : -1}\n        aria-label={extra.label}\n        aria-pressed={extra.pressed === undefined ? undefined : extra.pressed}\n        title={extra.shortcut ? `${extra.label} (${extra.shortcut})` : extra.label}\n        disabled={disabled || extra.disabled || !editor}\n        onClick={extra.run}\n        onFocus={() => setStop(index)}\n        // keep the selection in the editor while a tool is pressed with the pointer\n        onMouseDown={(e) => e.preventDefault()}\n        className={toolButton}\n      >\n        {extra.icon}\n      </button>\n    )\n  }\n  const icon = (I: React.ComponentType<{ className?: string; \"aria-hidden\"?: boolean | \"true\" }>) => <I className=\"size-4\" aria-hidden=\"true\" />\n  const chain = () => editor!.chain().focus()\n  const divider = (k: string) => <span key={k} role=\"separator\" aria-orientation=\"vertical\" className=\"mx-1 h-5 w-px shrink-0 bg-border\" />\n\n  const groups: React.ReactNode[][] = []\n  if (has(\"heading\")) {\n    groups.push([\n      item({ id: \"h1\", label: T(\"heading1\", \"Heading 1\"), icon: icon(Heading1), pressed: s?.heading === 1, run: () => chain().toggleHeading({ level: 1 }).run() }),\n      item({ id: \"h2\", label: T(\"heading2\", \"Heading 2\"), icon: icon(Heading2), pressed: s?.heading === 2, run: () => chain().toggleHeading({ level: 2 }).run() }),\n      item({ id: \"h3\", label: T(\"heading3\", \"Heading 3\"), icon: icon(Heading3), pressed: s?.heading === 3, run: () => chain().toggleHeading({ level: 3 }).run() }),\n    ])\n  }\n  const marks: React.ReactNode[] = []\n  if (has(\"bold\")) marks.push(item({ id: \"bold\", label: T(\"bold\", \"Bold\"), shortcut: `${mod}B`, icon: icon(Bold), pressed: s?.bold, run: () => chain().toggleBold().run() }))\n  if (has(\"italic\")) marks.push(item({ id: \"italic\", label: T(\"italic\", \"Italic\"), shortcut: `${mod}I`, icon: icon(Italic), pressed: s?.italic, run: () => chain().toggleItalic().run() }))\n  if (has(\"underline\")) marks.push(item({ id: \"underline\", label: T(\"underline\", \"Underline\"), shortcut: `${mod}U`, icon: icon(UnderlineIcon), pressed: s?.underline, run: () => chain().toggleUnderline().run() }))\n  if (has(\"strike\")) marks.push(item({ id: \"strike\", label: T(\"strike\", \"Strikethrough\"), shortcut: `${mod}Shift+S`, icon: icon(Strikethrough), pressed: s?.strike, run: () => chain().toggleStrike().run() }))\n  if (has(\"code\")) marks.push(item({ id: \"code\", label: T(\"code\", \"Inline code\"), shortcut: `${mod}E`, icon: icon(Code), pressed: s?.code, run: () => chain().toggleCode().run() }))\n  if (marks.length) groups.push(marks)\n  if (has(\"link\")) {\n    const index = n++\n    groups.push([\n      <Popover key=\"link\" open={linkOpen} onOpenChange={setLinkOpen}>\n        <PopoverTrigger\n          data-toolbar-item={index}\n          tabIndex={stop === index ? 0 : -1}\n          aria-label={T(\"link\", \"Link\")}\n          aria-pressed={!!s?.link}\n          title={`${T(\"link\", \"Link\")} (${mod}K)`}\n          disabled={disabled || !editor}\n          onFocus={() => setStop(index)}\n          onMouseDown={(e) => e.preventDefault()}\n          className={toolButton}\n        >\n          {icon(Link2)}\n        </PopoverTrigger>\n        <PopoverContent label={T(\"linkTitle\", \"Edit link\")} align=\"start\" className=\"w-80 p-3\">\n          <form\n            onSubmit={(e) => {\n              e.preventDefault()\n              applyLink()\n            }}\n            className=\"grid gap-2\"\n          >\n            <label htmlFor={`${id}-href`} className=\"text-sm font-medium\">\n              {T(\"linkAddress\", \"Web address\")}\n            </label>\n            <input\n              id={`${id}-href`}\n              type=\"text\"\n              inputMode=\"url\"\n              autoComplete=\"off\"\n              dir=\"ltr\"\n              value={href}\n              onChange={(e) => {\n                setHref(e.target.value)\n                setLinkError(\"\")\n              }}\n              aria-invalid={linkError ? true : undefined}\n              aria-describedby={linkError ? `${id}-href-error` : undefined}\n              placeholder={T(\"linkPlaceholder\", \"example.com\")}\n              className=\"h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20\"\n            />\n            {linkError ? (\n              <p id={`${id}-href-error`} role=\"alert\" className=\"text-xs text-destructive\">\n                {linkError}\n              </p>\n            ) : null}\n            <div className=\"flex justify-between gap-2\">\n              <Button type=\"button\" variant=\"ghost\" size=\"sm\" disabled={!s?.link} onClick={removeLink}>\n                <Unlink aria-hidden=\"true\" />\n                {T(\"linkRemove\", \"Remove\")}\n              </Button>\n              <Button type=\"submit\" size=\"sm\">\n                {T(\"linkApply\", \"Apply\")}\n              </Button>\n            </div>\n          </form>\n        </PopoverContent>\n      </Popover>,\n    ])\n  }\n  const lists: React.ReactNode[] = []\n  if (has(\"bulletList\")) lists.push(item({ id: \"bullets\", label: T(\"bulletList\", \"Bulleted list\"), shortcut: `${mod}Shift+8`, icon: icon(List), pressed: s?.bulletList, run: () => chain().toggleBulletList().run() }))\n  if (has(\"orderedList\")) lists.push(item({ id: \"numbers\", label: T(\"orderedList\", \"Numbered list\"), shortcut: `${mod}Shift+7`, icon: icon(ListOrdered), pressed: s?.orderedList, run: () => chain().toggleOrderedList().run() }))\n  if (has(\"taskList\")) lists.push(item({ id: \"tasks\", label: T(\"taskList\", \"Checklist\"), shortcut: `${mod}Shift+9`, icon: icon(ListChecks), pressed: s?.taskList, run: () => chain().toggleTaskList().run() }))\n  if (lists.length) groups.push(lists)\n  const blocks: React.ReactNode[] = []\n  if (has(\"blockquote\")) blocks.push(item({ id: \"quote\", label: T(\"blockquote\", \"Quote\"), shortcut: `${mod}Shift+B`, icon: icon(Quote), pressed: s?.blockquote, run: () => chain().toggleBlockquote().run() }))\n  if (has(\"codeBlock\")) blocks.push(item({ id: \"codeblock\", label: T(\"codeBlock\", \"Code block\"), shortcut: `${mod}Alt+C`, icon: icon(Code2), pressed: s?.codeBlock, run: () => chain().toggleCodeBlock().run() }))\n  if (has(\"horizontalRule\")) blocks.push(item({ id: \"hr\", label: T(\"horizontalRule\", \"Divider\"), icon: icon(Minus), run: () => chain().setHorizontalRule().run() }))\n  if (blocks.length) groups.push(blocks)\n  if (has(\"align\")) {\n    groups.push([\n      item({ id: \"left\", label: T(\"alignStart\", \"Align to the start\"), icon: icon(rtl ? AlignRight : AlignLeft), pressed: s?.align === (rtl ? \"right\" : \"left\"), run: () => chain().setTextAlign(rtl ? \"right\" : \"left\").run() }),\n      item({ id: \"center\", label: T(\"alignCenter\", \"Center\"), icon: icon(AlignCenter), pressed: s?.align === \"center\", run: () => chain().setTextAlign(\"center\").run() }),\n      item({ id: \"right\", label: T(\"alignEnd\", \"Align to the end\"), icon: icon(rtl ? AlignLeft : AlignRight), pressed: s?.align === (rtl ? \"left\" : \"right\"), run: () => chain().setTextAlign(rtl ? \"left\" : \"right\").run() }),\n      item({ id: \"justify\", label: T(\"alignJustify\", \"Justify\"), icon: icon(AlignJustify), pressed: s?.align === \"justify\", run: () => chain().setTextAlign(\"justify\").run() }),\n    ])\n  }\n  if (has(\"undo\")) {\n    groups.push([\n      item({ id: \"undo\", label: T(\"undo\", \"Undo\"), shortcut: `${mod}Z`, icon: <Undo2 className=\"size-4 rtl:-scale-x-100\" aria-hidden=\"true\" />, disabled: !s?.canUndo, run: () => chain().undo().run() }),\n      item({ id: \"redo\", label: T(\"redo\", \"Redo\"), shortcut: `${mod}Shift+Z`, icon: <Redo2 className=\"size-4 rtl:-scale-x-100\" aria-hidden=\"true\" />, disabled: !s?.canRedo, run: () => chain().redo().run() }),\n    ])\n  }\n\n  const near = characterLimit ? counts.chars >= characterLimit * 0.9 : false\n  const full = characterLimit ? counts.chars >= characterLimit : false\n\n  const skeleton = (\n    <div className=\"overflow-hidden rounded-xl border bg-card\" aria-hidden=\"true\">\n      <div className=\"flex gap-1 border-b p-1.5\">\n        {Array.from({ length: 8 }, (_, i) => (\n          <Skeleton key={i} className=\"size-8\" />\n        ))}\n      </div>\n      <div className=\"grid gap-2.5 p-4\" style={{ minHeight }}>\n        <Skeleton className=\"h-4 w-2/3\" />\n        <Skeleton className=\"h-4 w-full\" />\n        <Skeleton className=\"h-4 w-5/6\" />\n      </div>\n    </div>\n  )\n\n  const effective: StateProps[\"state\"] = state ?? \"ready\"\n\n  return (\n    <div data-slot=\"rich-text-editor\" data-invalid={invalid || undefined} data-disabled={disabled || undefined} className={cn(\"min-w-0\", className)} {...props}>\n      <StateView state={effective} loading={skeleton} error={errorState ?? {}} empty={emptyState ?? {}} onRetry={onRetry} label={msg(\"rich-text-editor.loading\", \"Loading the editor\")} bare className=\"rounded-xl\">\n        <div\n          className={cn(\n            \"overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none\",\n            \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n            invalid && \"border-destructive focus-within:border-destructive focus-within:ring-destructive/20\",\n            disabled && \"opacity-60\"\n          )}\n        >\n          <div\n            ref={toolbar}\n            role=\"toolbar\"\n            aria-label={msg(\"rich-text-editor.toolbar\", \"Formatting\")}\n            aria-controls={`${id}-content`}\n            aria-orientation=\"horizontal\"\n            onKeyDown={onToolbarKey}\n            className=\"flex flex-wrap items-center gap-0.5 border-b bg-muted/40 p-1.5\"\n          >\n            {groups.flatMap((g, i) => (i ? [divider(`d${i}`), ...g] : g))}\n          </div>\n          <div id={`${id}-content`} className={cn(\"overflow-y-auto px-4 py-3\", PROSE)} style={{ minHeight, maxHeight }} onClick={(e) => e.target === e.currentTarget && editor?.commands.focus(\"end\")}>\n            {editor ? <EditorContent editor={editor} /> : <div style={{ minHeight: minHeight - 24 }} aria-hidden=\"true\" />}\n          </div>\n          {showCount || characterLimit ? (\n            <div className=\"flex items-center justify-between gap-3 border-t bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground\">\n              <span className=\"tabular-nums\">{msg(\"rich-text-editor.words\", { one: \"{count} word\", other: \"{count} words\" }, { count: counts.words })}</span>\n              <span className={cn(\"tabular-nums\", near && \"text-foreground\", full && \"font-medium text-destructive\")} aria-live={near ? \"polite\" : \"off\"}>\n                {characterLimit ? msg(\"rich-text-editor.charsOf\", \"{count} of {limit} characters\", { count: counts.chars, limit: characterLimit }) : msg(\"rich-text-editor.chars\", { one: \"{count} character\", other: \"{count} characters\" }, { count: counts.chars })}\n              </span>\n            </div>\n          ) : null}\n        </div>\n        <div id={`${id}-description`} className={cn(\"mt-1.5 text-xs\", invalid ? \"text-destructive\" : \"text-muted-foreground\", !description && \"sr-only\")}>\n          {description ?? msg(\"rich-text-editor.hint\", \"Press Escape, then Tab, to leave the editor.\")}\n        </div>\n      </StateView>\n      {slash && slash.items.length ? <SlashMenu state={slash} rtl={rtl} onPick={(i) => slash.command(i)} onHover={(index) => setSlash({ ...slash, index })} /> : null}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {slash ? (slash.items.length ? msg(\"rich-text-editor.slashCount\", { one: \"{count} block available. {name} is highlighted.\", other: \"{count} blocks available. {name} is highlighted.\" }, { count: slash.items.length, name: slash.items[slash.index]?.label ?? \"\" }) : msg(\"rich-text-editor.slashNone\", \"No matching blocks\")) : \"\"}\n      </span>\n    </div>\n  )\n}\n\n/**\n * Tiptap makes its ids with `Math.random()`, which a page that is prerendered must not do while it renders. So the editor is made only in the\n * browser, after the page is shown: until then a same-size shell holds its place, which also means the server and the browser draw the same first frame.\n */\nfunction RichTextEditor(props: RichTextEditorProps) {\n  const [mounted, setMounted] = React.useState(false)\n  React.useEffect(() => setMounted(true), [])\n  if (mounted) return <RichTextEditorCore {...props} />\n  const { minHeight = 160, className } = props\n  return (\n    <div data-slot=\"rich-text-editor\" data-state=\"loading\" className={cn(\"min-w-0\", className)}>\n      <div className=\"overflow-hidden rounded-xl border bg-card\" aria-hidden=\"true\">\n        <div className=\"flex gap-1 border-b p-1.5\">\n          {Array.from({ length: 8 }, (_, i) => (\n            <Skeleton key={i} className=\"size-8\" />\n          ))}\n        </div>\n        <div style={{ minHeight }} />\n      </div>\n    </div>\n  )\n}\n\nfunction SlashMenu({ state, rtl, onPick, onHover }: { state: SlashState; rtl: boolean; onPick: (i: SlashItem) => void; onHover: (index: number) => void }) {\n  const id = React.useId()\n  const msg = useMessages()\n  const { rect } = state\n  const style: React.CSSProperties = rect\n    ? { position: \"fixed\", top: rect.bottom + 6, ...(rtl ? { right: Math.max(8, window.innerWidth - rect.right) } : { left: Math.max(8, rect.left) }) }\n    : { position: \"fixed\", top: 80, left: 16 }\n  return (\n    <div\n      role=\"listbox\"\n      aria-label={msg(\"rich-text-editor.slashLabel\", \"Blocks\")}\n      data-slot=\"rich-text-editor-slash\"\n      style={style}\n      className=\"z-50 max-h-72 w-72 overflow-y-auto rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg\"\n      onMouseDown={(e) => e.preventDefault()}\n    >\n      {state.items.map((item, i) => (\n        <div\n          key={item.id}\n          id={`${id}-${item.id}`}\n          role=\"option\"\n          aria-selected={i === state.index}\n          onMouseEnter={() => onHover(i)}\n          onClick={() => onPick(item)}\n          className={cn(\"flex cursor-default items-center gap-3 rounded-lg px-2 py-1.5 text-sm\", i === state.index && \"bg-accent text-accent-foreground\")}\n        >\n          <span className=\"flex size-8 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground\">{item.icon}</span>\n          <span className=\"min-w-0\">\n            <span className=\"block font-medium\">{item.label}</span>\n            <span className=\"block truncate text-xs text-muted-foreground\">{item.description}</span>\n          </span>\n        </div>\n      ))}\n    </div>\n  )\n}\n\nexport { RichTextEditor, cleanUrl, type RichTextEditorProps, type RichTextFeature }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/rich-text-editor.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "editor",
      "wysiwyg",
      "rich text",
      "tiptap",
      "markdown",
      "slash menu",
      "comments"
    ],
    "version": "1.0.0",
    "updated": "2026-10-19",
    "tests": true,
    "ai": {
      "summary": "Hold the HTML in state: <RichTextEditor label value={html} onChange={setHtml} />. Pick tools with features, add characterLimit and showCount, and mark invalid with description. Sanitize the HTML before you store or show it.",
      "whenToUse": [
        "Comments, descriptions, notes and release notes people format",
        "Content that is stored as HTML and shown elsewhere",
        "Forms that need a labelled, validated, accessible editor"
      ],
      "whenNotToUse": [
        "Plain multi-line text; use textarea",
        "Code editing with syntax highlighting; use a code editor",
        "Real-time collaboration, comments in the margin or tables out of the box; build on the Tiptap editor from editorRef"
      ],
      "composesWith": [
        "button",
        "popover",
        "label",
        "field"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "The toolbar is one tab stop, and the editor is the next; Escape then Tab leaves the editor"
        },
        {
          "keys": "Arrow keys / Home / End (toolbar)",
          "action": "Move between tools; they swap in right-to-left"
        },
        {
          "keys": "Ctrl or Cmd + B, I, U, K",
          "action": "Bold, italic, underline and the link popover; markdown shortcuts like ## and - work as you type"
        },
        {
          "keys": "/",
          "action": "Opens the block menu; Up and Down choose, Enter inserts, Escape closes, and the highlighted block is announced"
        },
        {
          "keys": "Screen readers",
          "action": "The editor is a labelled multiline textbox; tools are toggle buttons with aria-pressed; invalid and disabled are set on the textbox"
        }
      ],
      "customization": [
        "features: heading, bold, italic, underline, strike, code, link, bulletList, orderedList, taskList, blockquote, codeBlock, horizontalRule, align, undo, slash",
        "characterLimit, showCount, minHeight, maxHeight",
        "invalid with description for the hint and the error",
        "editorRef for the Tiptap editor, to add extensions or commands"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "rich-text-editor-loading",
        "trigger": "state=\"loading\"",
        "note": "A toolbar and lines drawn as skeletons, announced as loading, for content that is still being fetched."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "rich-text-editor-error",
        "trigger": "state=\"error\"",
        "note": "An error with a Try again button (onRetry), announced as an alert."
      },
      {
        "id": "disabled",
        "title": "Disabled",
        "example": "rich-text-editor-disabled",
        "trigger": "disabled",
        "note": "The content stays readable and selectable; the toolbar and typing are off and the editor says it is disabled."
      }
    ],
    "source": {
      "name": "Tiptap",
      "url": "https://github.com/ueberdosis/tiptap",
      "license": "MIT",
      "copyright": "Copyright (c) 2025, Tiptap GmbH",
      "modified": true
    },
    "examples": [
      "rich-text-editor-demo",
      "rich-text-editor-minimal",
      "rich-text-editor-form",
      "rich-text-editor-checklist",
      "rich-text-editor-disabled",
      "rich-text-editor-loading",
      "rich-text-editor-error"
    ],
    "url": "https://ui.ballmac.com/components/rich-text-editor"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}