{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rich-text-editor",
  "title": "Rich text editor",
  "author": "Tori Bryan",
  "description": "Formatted writing on Tiptap: headings, marks, lists, quotes and links from a toolbar or Markdown shortcuts, with an optional character limit.",
  "dependencies": [
    "@base-ui/react",
    "@tiptap/extensions",
    "@tiptap/react",
    "@tiptap/starter-kit",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://fibo.toribryan.com/r/button.json",
    "https://fibo.toribryan.com/r/input.json",
    "https://fibo.toribryan.com/r/tooltip.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/rich-text-editor.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Toolbar as ToolbarPrimitive } from \"@base-ui/react/toolbar\"\nimport { CharacterCount, Placeholder } from \"@tiptap/extensions\"\nimport {\n  EditorContent,\n  useEditor,\n  useEditorState,\n  type Editor,\n} from \"@tiptap/react\"\nimport StarterKit from \"@tiptap/starter-kit\"\nimport {\n  BoldIcon,\n  CodeIcon,\n  HeadingIcon,\n  ItalicIcon,\n  LinkIcon,\n  ListIcon,\n  ListOrderedIcon,\n  QuoteIcon,\n  Redo2Icon,\n  StrikethroughIcon,\n  Undo2Icon,\n  UnlinkIcon,\n} from \"lucide-react\"\n\nimport { Button, buttonVariants } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\n\ntype RichTextEditorTool =\n  | \"heading\"\n  | \"bold\"\n  | \"italic\"\n  | \"strike\"\n  | \"code\"\n  | \"bullet\"\n  | \"ordered\"\n  | \"quote\"\n  | \"link\"\n  | \"history\"\n\nconst ALL_TOOLS: RichTextEditorTool[] = [\n  \"heading\",\n  \"bold\",\n  \"italic\",\n  \"strike\",\n  \"code\",\n  \"bullet\",\n  \"ordered\",\n  \"quote\",\n  \"link\",\n  \"history\",\n]\n\n// Separators fall between these groups, and only between groups that show.\nconst TOOL_GROUPS: RichTextEditorTool[][] = [\n  [\"heading\"],\n  [\"bold\", \"italic\", \"strike\", \"code\"],\n  [\"bullet\", \"ordered\", \"quote\"],\n  [\"link\"],\n  [\"history\"],\n]\n\ntype RichTextEditorProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"onChange\" | \"defaultValue\" | \"children\"\n> & {\n  /** The starting content, as HTML. Later changes are ignored: the editor owns the document once it mounts. */\n  defaultValue?: string\n  /** Called on every edit with the document as HTML, or an empty string when it is empty, and the Tiptap editor. */\n  onChange?: (html: string, editor: Editor) => void\n  /** Shown in the empty writing area. */\n  placeholder?: string\n  /** The most characters the document may hold. Typing and pasting stop there, and a counter shows from 80%. */\n  maxLength?: number\n  /** Minimum height of the writing area, in pixels. */\n  minHeight?: number\n  /** Which tools the toolbar shows, in its fixed order. Shortcuts for hidden tools still work. */\n  tools?: RichTextEditorTool[]\n  /** False shows the document without a toolbar and takes no edits. */\n  editable?: boolean\n  /** Accessible name of the writing area. Ignored when aria-labelledby is set. */\n  label?: string\n  /** Id of the writing area, not the frame. */\n  id?: string\n  /** Ids of elements that name the writing area, such as a visible label. */\n  \"aria-labelledby\"?: string\n  /** Ids of elements that describe the writing area, such as a hint or an error. */\n  \"aria-describedby\"?: string\n  /** Marks the writing area invalid and draws the frame in the destructive color. */\n  \"aria-invalid\"?: boolean | \"true\" | \"false\"\n}\n\n/*\n * Platform modifier for tooltips. Read when a tooltip opens, which only\n * happens in a browser, so the server never needs to know.\n */\nfunction shortcut(keys: string) {\n  const mac =\n    typeof navigator !== \"undefined\" &&\n    /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent)\n  return keys\n    .replace(\"Mod+\", mac ? \"⌘\" : \"Ctrl+\")\n    .replace(\"Shift+\", mac ? \"⇧\" : \"Shift+\")\n}\n\nconst PROSE = [\n  \"min-w-0 px-3 py-2.5 text-sm leading-6 text-foreground outline-none\",\n  \"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0\",\n  \"[&_p]:my-2\",\n  \"[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:leading-7 [&_h2]:font-semibold\",\n  \"[&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold\",\n  \"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:pl-1 [&_li>p]:my-0\",\n  \"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground\",\n  \"[&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs\",\n  \"[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono [&_pre]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0\",\n  \"[&_a]:font-medium [&_a]:underline [&_a]:decoration-muted-foreground [&_a]:underline-offset-4\",\n  \"[&_hr]:my-4 [&_hr]:border-border\",\n  \"[&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-left [&_.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)]\",\n].join(\" \")\n\n/** What the toolbar reflects, read once per transaction. */\nfunction readState(editor: Editor | null) {\n  return {\n    heading: editor?.isActive(\"heading\", { level: 2 }) ?? false,\n    bold: editor?.isActive(\"bold\") ?? false,\n    italic: editor?.isActive(\"italic\") ?? false,\n    strike: editor?.isActive(\"strike\") ?? false,\n    code: editor?.isActive(\"code\") ?? false,\n    bullet: editor?.isActive(\"bulletList\") ?? false,\n    ordered: editor?.isActive(\"orderedList\") ?? false,\n    quote: editor?.isActive(\"blockquote\") ?? false,\n    link: editor?.isActive(\"link\") ?? false,\n    canUndo: editor?.can().undo() ?? false,\n    canRedo: editor?.can().redo() ?? false,\n    characters: editor?.storage.characterCount.characters() ?? 0,\n  }\n}\n\ntype ToolbarState = ReturnType<typeof readState>\n\ntype ToggleTool = Exclude<RichTextEditorTool, \"link\" | \"history\">\n\nconst TOGGLES: Record<\n  ToggleTool,\n  {\n    label: string\n    keys: string\n    icon: React.ReactNode\n    run: (editor: Editor) => void\n  }\n> = {\n  heading: {\n    label: \"Heading\",\n    keys: \"##\",\n    icon: <HeadingIcon />,\n    run: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),\n  },\n  bold: {\n    label: \"Bold\",\n    keys: \"Mod+B\",\n    icon: <BoldIcon />,\n    run: (editor) => editor.chain().focus().toggleBold().run(),\n  },\n  italic: {\n    label: \"Italic\",\n    keys: \"Mod+I\",\n    icon: <ItalicIcon />,\n    run: (editor) => editor.chain().focus().toggleItalic().run(),\n  },\n  strike: {\n    label: \"Strikethrough\",\n    keys: \"Mod+Shift+S\",\n    icon: <StrikethroughIcon />,\n    run: (editor) => editor.chain().focus().toggleStrike().run(),\n  },\n  code: {\n    label: \"Inline code\",\n    keys: \"Mod+E\",\n    icon: <CodeIcon />,\n    run: (editor) => editor.chain().focus().toggleCode().run(),\n  },\n  bullet: {\n    label: \"Bulleted list\",\n    keys: \"-\",\n    icon: <ListIcon />,\n    run: (editor) => editor.chain().focus().toggleBulletList().run(),\n  },\n  ordered: {\n    label: \"Numbered list\",\n    keys: \"1.\",\n    icon: <ListOrderedIcon />,\n    run: (editor) => editor.chain().focus().toggleOrderedList().run(),\n  },\n  quote: {\n    label: \"Quote\",\n    keys: \">\",\n    icon: <QuoteIcon />,\n    run: (editor) => editor.chain().focus().toggleBlockquote().run(),\n  },\n}\n\ntype ToolButtonProps = {\n  /** Names the button and heads its tooltip. */\n  label: string\n  /** The shortcut shown in the tooltip. */\n  keys: string\n  /** The tool it runs, for styling hooks. */\n  tool: string\n  /** Pressed state for toggles. Leave out for one-off actions. */\n  pressed?: boolean\n  /** True when the action can't run right now. */\n  disabled?: boolean\n  /** Runs the action. */\n  onClick: () => void\n  /** The icon. */\n  children: React.ReactNode\n} & Pick<React.ComponentProps<\"button\">, \"aria-expanded\" | \"aria-controls\">\n\nfunction ToolButton({\n  label,\n  keys,\n  tool,\n  pressed,\n  disabled,\n  onClick,\n  children,\n  ...props\n}: ToolButtonProps) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <ToolbarPrimitive.Button\n            data-slot=\"rich-text-editor-tool\"\n            data-tool={tool}\n            type=\"button\"\n            aria-label={label}\n            aria-pressed={pressed}\n            disabled={disabled}\n            // The pointer never takes focus from the writing area, so the\n            // selection the action applies to stays put.\n            onMouseDown={(event) => event.preventDefault()}\n            onClick={onClick}\n            className={cn(\n              buttonVariants({ variant: \"ghost\", size: \"icon-sm\" }),\n              \"rounded-md text-muted-foreground aria-pressed:bg-muted aria-pressed:text-foreground data-disabled:opacity-50 data-disabled:hover:bg-transparent\"\n            )}\n            {...props}\n          />\n        }\n      >\n        {children}\n      </TooltipTrigger>\n      <TooltipContent>\n        {label}\n        <span className=\"ml-2 text-muted-foreground\">{shortcut(keys)}</span>\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n\n/*\n * Extensions read their options when they run, so changing them in place\n * takes effect on the next transaction without rebuilding the editor.\n */\nfunction setExtensionOption(\n  editor: Editor,\n  name: string,\n  options: Record<string, unknown>\n) {\n  const extension = editor.extensionManager.extensions.find(\n    (candidate) => candidate.name === name\n  )\n  if (extension) Object.assign(extension.options, options)\n}\n\n/*\n * A Tiptap editor with a formatting toolbar. StarterKit brings the marks,\n * blocks, history and Markdown shortcuts (## heading, - list, > quote), plus\n * Link; Placeholder and CharacterCount come from @tiptap/extensions.\n */\nfunction RichTextEditor({\n  defaultValue = \"\",\n  onChange,\n  placeholder = \"Write something…\",\n  maxLength,\n  minHeight = 140,\n  tools = ALL_TOOLS,\n  editable = true,\n  label = \"Editor\",\n  id,\n  \"aria-labelledby\": labelledBy,\n  \"aria-describedby\": describedBy,\n  \"aria-invalid\": ariaInvalid,\n  className,\n  ...props\n}: RichTextEditorProps) {\n  const invalid = ariaInvalid === true || ariaInvalid === \"true\"\n  const linkRowId = React.useId()\n  const [linkOpen, setLinkOpen] = React.useState(false)\n  const [linkHref, setLinkHref] = React.useState(\"https://\")\n  const [linkRejected, setLinkRejected] = React.useState(false)\n  const linkErrorId = React.useId()\n  const linkInput = React.useRef<HTMLInputElement>(null)\n\n  // Made once, so a prop change never rebuilds the editor and loses the\n  // document, selection and undo history. The effects below update the\n  // placeholder and limit in place.\n  const [extensions] = React.useState(() => [\n    StarterKit.configure({\n      heading: { levels: [2, 3] },\n      // No tool shows underline, and underlined text reads as a link.\n      underline: false,\n      // Keeps a stray empty paragraph out of the HTML after every list,\n      // heading and quote. Enter on an empty item already exits them.\n      trailingNode: false,\n      link: {\n        openOnClick: false,\n        autolink: true,\n        HTMLAttributes: { rel: \"noopener noreferrer\" },\n      },\n    }),\n    Placeholder.configure({ placeholder }),\n    CharacterCount.configure({ limit: maxLength ?? null }),\n  ])\n\n  const editorProps = React.useMemo(\n    () => ({\n      attributes: {\n        \"data-slot\": \"rich-text-editor-content\",\n        role: \"textbox\",\n        \"aria-multiline\": \"true\",\n        // The writing area is the control, so naming and describing it\n        // goes here rather than on the frame.\n        ...(id ? { id } : {}),\n        ...(labelledBy\n          ? { \"aria-labelledby\": labelledBy }\n          : { \"aria-label\": label }),\n        ...(describedBy ? { \"aria-describedby\": describedBy } : {}),\n        ...(invalid ? { \"aria-invalid\": \"true\" } : {}),\n        \"aria-placeholder\": placeholder,\n        ...(editable ? {} : { \"aria-readonly\": \"true\" }),\n        // Room for the counter, which sits over the last line's corner.\n        class: cn(PROSE, maxLength !== undefined && editable && \"pb-8\"),\n        style: `min-height: ${minHeight}px`,\n      },\n    }),\n    [\n      id,\n      label,\n      labelledBy,\n      describedBy,\n      invalid,\n      placeholder,\n      editable,\n      maxLength,\n      minHeight,\n    ]\n  )\n\n  const editor = useEditor({\n    // Rendering waits for the browser, so server and client markup match.\n    immediatelyRender: false,\n    editable,\n    content: defaultValue,\n    extensions,\n    editorProps,\n  })\n\n  const state: ToolbarState =\n    useEditorState({\n      editor,\n      selector: ({ editor }) => readState(editor),\n    }) ?? readState(null)\n\n  React.useEffect(() => {\n    editor?.setEditable(editable)\n  }, [editor, editable])\n\n  React.useEffect(() => {\n    if (!editor) return\n    setExtensionOption(editor, \"placeholder\", { placeholder })\n    setExtensionOption(editor, \"characterCount\", { limit: maxLength ?? null })\n    // An empty transaction redraws the placeholder decoration.\n    editor.view.dispatch(editor.state.tr)\n  }, [editor, placeholder, maxLength])\n\n  React.useEffect(() => {\n    if (!editor || !onChange) return\n    const report = () =>\n      onChange(editor.isEmpty ? \"\" : editor.getHTML(), editor)\n    editor.on(\"update\", report)\n    return () => {\n      editor.off(\"update\", report)\n    }\n  }, [editor, onChange])\n\n  const closeLink = React.useCallback(\n    (refocus: boolean) => {\n      setLinkOpen(false)\n      if (refocus) editor?.commands.focus()\n    },\n    [editor]\n  )\n\n  const toggleLink = React.useCallback((editor: Editor) => {\n    if (editor.isActive(\"link\")) {\n      editor.chain().focus().extendMarkRange(\"link\").unsetLink().run()\n      return\n    }\n    setLinkHref(\"https://\")\n    setLinkRejected(false)\n    setLinkOpen(true)\n  }, [])\n\n  // Tiptap's Link has no shortcut of its own; Mod+K is the common one.\n  React.useEffect(() => {\n    if (!editor || !editable) return\n    const dom = editor.view.dom\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (\n        event.key.toLowerCase() !== \"k\" ||\n        !(event.metaKey || event.ctrlKey) ||\n        event.shiftKey ||\n        event.altKey\n      )\n        return\n      event.preventDefault()\n      toggleLink(editor)\n    }\n    dom.addEventListener(\"keydown\", onKeyDown)\n    return () => dom.removeEventListener(\"keydown\", onKeyDown)\n  }, [editor, editable, toggleLink])\n\n  React.useEffect(() => {\n    if (!linkOpen) return\n    const input = linkInput.current\n    if (!input) return\n    input.focus()\n    input.setSelectionRange(input.value.length, input.value.length)\n  }, [linkOpen])\n\n  function applyLink() {\n    if (!editor) return\n    const href = linkHref.trim()\n    if (!href || href === \"https://\") {\n      setLinkOpen(false)\n      editor.chain().focus().extendMarkRange(\"link\").unsetLink().run()\n      return\n    }\n    // setLink runs Link's isAllowedUri, which inserting the text below would\n    // skip, so a javascript: address never enters the document. A spot that\n    // takes no link, such as a code block, refuses any address, so the\n    // address is only to blame when a plain one would pass.\n    if (!editor.can().setLink({ href })) {\n      if (editor.can().setLink({ href: \"https://example.com\" })) {\n        setLinkRejected(true)\n        return\n      }\n      closeLink(true)\n      return\n    }\n    setLinkOpen(false)\n    if (editor.state.selection.empty && !editor.isActive(\"link\")) {\n      // With nothing selected, the address becomes the link text.\n      editor\n        .chain()\n        .focus()\n        .insertContent({\n          type: \"text\",\n          text: href,\n          marks: [{ type: \"link\", attrs: { href } }],\n        })\n        .run()\n      return\n    }\n    editor.chain().focus().extendMarkRange(\"link\").setLink({ href }).run()\n  }\n\n  const shown = TOOL_GROUPS.map((group) =>\n    group.filter((tool) => tools.includes(tool))\n  ).filter((group) => group.length > 0)\n\n  const showCounter =\n    editable && maxLength !== undefined && state.characters >= maxLength * 0.8\n  const remaining = maxLength === undefined ? 0 : maxLength - state.characters\n\n  // The visible count changes on every keystroke, which a live region would\n  // read out each time. Screen readers hear it only when it crosses 80%,\n  // each further ten characters, and the limit.\n  const step = !showCounter\n    ? null\n    : remaining <= 0\n      ? \"limit\"\n      : String(Math.ceil(remaining / 10))\n  const [announced, setAnnounced] = React.useState<{\n    step: string | null\n    text: string\n  }>({ step: null, text: \"\" })\n  if (announced.step !== step) {\n    setAnnounced({\n      step,\n      text:\n        step === null\n          ? \"\"\n          : step === \"limit\"\n            ? \"Character limit reached\"\n            : `${remaining} ${remaining === 1 ? \"character\" : \"characters\"} left`,\n    })\n  }\n\n  function renderTool(tool: RichTextEditorTool) {\n    if (tool === \"link\") {\n      return (\n        <ToolButton\n          key=\"link\"\n          tool=\"link\"\n          label={state.link ? \"Remove link\" : \"Add link\"}\n          keys=\"Mod+K\"\n          disabled={!editor}\n          aria-expanded={state.link ? undefined : linkOpen}\n          aria-controls={linkOpen ? linkRowId : undefined}\n          onClick={() => editor && toggleLink(editor)}\n        >\n          {state.link ? <UnlinkIcon /> : <LinkIcon />}\n        </ToolButton>\n      )\n    }\n    if (tool === \"history\") {\n      return (\n        <React.Fragment key=\"history\">\n          <ToolButton\n            tool=\"undo\"\n            label=\"Undo\"\n            keys=\"Mod+Z\"\n            disabled={!state.canUndo}\n            onClick={() => editor?.chain().focus().undo().run()}\n          >\n            <Undo2Icon />\n          </ToolButton>\n          <ToolButton\n            tool=\"redo\"\n            label=\"Redo\"\n            keys=\"Mod+Shift+Z\"\n            disabled={!state.canRedo}\n            onClick={() => editor?.chain().focus().redo().run()}\n          >\n            <Redo2Icon />\n          </ToolButton>\n        </React.Fragment>\n      )\n    }\n    const { label, keys, icon, run } = TOGGLES[tool]\n    return (\n      <ToolButton\n        key={tool}\n        tool={tool}\n        label={label}\n        keys={keys}\n        pressed={state[tool]}\n        disabled={!editor}\n        onClick={() => editor && run(editor)}\n      >\n        {icon}\n      </ToolButton>\n    )\n  }\n\n  return (\n    <div\n      data-slot=\"rich-text-editor\"\n      data-readonly={editable ? undefined : \"\"}\n      data-invalid={invalid ? \"\" : undefined}\n      className={cn(\n        \"@container/rich-text-editor relative flex w-full min-w-0 flex-col rounded-lg border border-input bg-input-subtle transition-colors has-[[data-slot=rich-text-editor-content]:focus-visible]:border-ring has-[[data-slot=rich-text-editor-content]:focus-visible]:ring-[3px] has-[[data-slot=rich-text-editor-content]:focus-visible]:ring-ring-subtle data-invalid:border-destructive data-invalid:ring-[3px] data-invalid:ring-destructive-ring data-readonly:bg-transparent\",\n        className\n      )}\n      {...props}\n    >\n      {editable && shown.length > 0 ? (\n        <TooltipProvider>\n          <ToolbarPrimitive.Root\n            data-slot=\"rich-text-editor-toolbar\"\n            aria-label=\"Formatting\"\n            // In a narrow frame two rows of tools would push the writing\n            // down, so the row scrolls instead, like Floating nav, and focus\n            // scrolls each tool into view as the arrows move.\n            className=\"flex flex-wrap items-center gap-0.5 border-b border-border p-1 @max-md/rich-text-editor:[scrollbar-width:none] @max-md/rich-text-editor:flex-nowrap @max-md/rich-text-editor:overflow-x-auto\"\n            onKeyDown={(event) => {\n              // Base UI's toolbar roves on arrows only; Home and End finish\n              // the pattern from the ARIA toolbar guidance.\n              if (event.key !== \"Home\" && event.key !== \"End\") return\n              const buttons = Array.from(\n                event.currentTarget.querySelectorAll<HTMLButtonElement>(\n                  \"[data-slot=rich-text-editor-tool]\"\n                )\n              )\n              const target =\n                event.key === \"Home\" ? buttons[0] : buttons[buttons.length - 1]\n              if (!target) return\n              event.preventDefault()\n              target.focus()\n            }}\n          >\n            {shown.map((group, index) => (\n              <React.Fragment key={group.join()}>\n                {index > 0 ? (\n                  <ToolbarPrimitive.Separator className=\"mx-1 h-5 w-px shrink-0 bg-border\" />\n                ) : null}\n                {group.map(renderTool)}\n              </React.Fragment>\n            ))}\n          </ToolbarPrimitive.Root>\n        </TooltipProvider>\n      ) : null}\n\n      {editable && linkOpen ? (\n        <form\n          id={linkRowId}\n          data-slot=\"rich-text-editor-link-row\"\n          // Native URL checks would block a relative link, and the bare\n          // https:// that removes one.\n          noValidate\n          className=\"flex flex-wrap items-center gap-2 border-b border-border p-2\"\n          onSubmit={(event) => {\n            event.preventDefault()\n            applyLink()\n          }}\n          onKeyDown={(event) => {\n            if (event.key !== \"Escape\") return\n            event.preventDefault()\n            event.stopPropagation()\n            closeLink(true)\n          }}\n        >\n          <Input\n            ref={linkInput}\n            type=\"url\"\n            size=\"sm\"\n            aria-label=\"Link address\"\n            aria-invalid={linkRejected || undefined}\n            aria-describedby={linkRejected ? linkErrorId : undefined}\n            value={linkHref}\n            onChange={(event) => {\n              setLinkHref(event.target.value)\n              setLinkRejected(false)\n            }}\n            className=\"flex-1\"\n          />\n          <Button type=\"submit\" size=\"sm\">\n            Apply\n          </Button>\n          <Button\n            type=\"button\"\n            size=\"sm\"\n            variant=\"ghost\"\n            onClick={() => closeLink(true)}\n          >\n            Cancel\n          </Button>\n          {linkRejected ? (\n            <p\n              id={linkErrorId}\n              role=\"alert\"\n              data-slot=\"rich-text-editor-link-error\"\n              className=\"w-full text-sm text-destructive\"\n            >\n              Enter a valid address, such as https://example.com\n            </p>\n          ) : null}\n        </form>\n      ) : null}\n\n      {editor ? (\n        <EditorContent editor={editor} />\n      ) : (\n        <div\n          aria-hidden\n          className={PROSE}\n          style={{ minHeight }}\n          data-slot=\"rich-text-editor-skeleton\"\n        />\n      )}\n\n      {maxLength !== undefined && editable ? (\n        <span\n          data-slot=\"rich-text-editor-count\"\n          data-limit={remaining <= 0 ? \"\" : undefined}\n          aria-hidden\n          className=\"pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground tabular-nums data-limit:text-destructive\"\n        >\n          {showCounter ? `${Math.max(0, remaining)} left` : \"\"}\n        </span>\n      ) : null}\n      {maxLength !== undefined && editable ? (\n        <span\n          data-slot=\"rich-text-editor-announcer\"\n          aria-live=\"polite\"\n          className=\"sr-only\"\n        >\n          {announced.text}\n        </span>\n      ) : null}\n    </div>\n  )\n}\n\nexport { RichTextEditor, type RichTextEditorProps, type RichTextEditorTool }\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "color-destructive-ring": "var(--destructive-ring)",
      "color-input-subtle": "var(--input-subtle)",
      "color-ring-subtle": "var(--ring-subtle)",
      "text-color-destructive": "var(--destructive-text)",
      "border-color-destructive": "var(--destructive-border)"
    },
    "light": {
      "destructive-ring": "color-mix(in oklch, var(--color-red-700) 20%, transparent)",
      "input-subtle": "color-mix(in oklch, var(--color-neutral-200) 20%, transparent)",
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-400) 50%, transparent)",
      "destructive-text": "var(--destructive)",
      "destructive-border": "var(--destructive)"
    },
    "dark": {
      "destructive-ring": "color-mix(in oklch, var(--color-red-400) 40%, transparent)",
      "input-subtle": "oklch(1 0 0 / 5%)",
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-500) 50%, transparent)",
      "destructive-text": "var(--destructive)",
      "destructive-border": "var(--destructive)"
    }
  },
  "meta": {
    "tier": "base-components",
    "group": "Forms",
    "docs": "https://fibo.toribryan.com/?path=/docs/base-components-forms-rich-text-editor--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/base-components-forms-rich-text-editor--docs",
  "categories": [
    "base-components",
    "forms"
  ],
  "type": "registry:ui"
}