{
  "name": "RichTextEditor",
  "package": "@magicblocksai/ui",
  "file": "packages/ui/src/components/RichTextEditor.tsx",
  "chapterTag": "04 Forms & Inputs",
  "chapter": "05-forms-and-inputs.html",
  "sectionId": "rich-text-editor",
  "elName": "RichTextEditor",
  "demoUrl": "https://brand.magicblocks.ai/components/05-forms-and-inputs#rich-text-editor",
  "hasLiveDemo": false,
  "description": "TipTap-shaped rich text editor backed by a sanitised contenteditable.",
  "useClient": true,
  "interactivity": "interactive",
  "namedExports": [
    {
      "name": "RichTextEditor",
      "isPrincipal": true,
      "isType": false
    },
    {
      "name": "RichTextEditorProps",
      "isPrincipal": false,
      "isType": true
    },
    {
      "name": "RichTextEditorHandle",
      "isPrincipal": false,
      "isType": false
    },
    {
      "name": "RteToolbarButton",
      "isPrincipal": false,
      "isType": false
    }
  ],
  "importStatement": "import { RichTextEditor } from \"@magicblocksai/ui\";",
  "props": [
    {
      "name": "value",
      "optional": false,
      "type": "string",
      "doc": "Current HTML body. Sanitised on every change."
    },
    {
      "name": "onChange",
      "optional": false,
      "type": "(html: string) => void",
      "doc": "Fires after each user edit with the sanitised HTML."
    },
    {
      "name": "toolbar",
      "optional": true,
      "type": "RteToolbarButton[]",
      "doc": "Toolbar buttons to render. Default: bold, italic, link, bullet, image, attachments."
    },
    {
      "name": "mergeTags",
      "optional": true,
      "type": "string[]",
      "doc": "Merge-tag literals offered when the user types `{{`."
    },
    {
      "name": "placeholder",
      "optional": true,
      "type": "string",
      "doc": "Placeholder shown when the editor is empty."
    },
    {
      "name": "onAttach",
      "optional": true,
      "type": "(files: File[]) => void",
      "doc": "Fires when the user picks files from the attachments button."
    },
    {
      "name": "onImagePick",
      "optional": true,
      "type": "(files: File[]) => void",
      "doc": "Fires when the user picks files from the image button."
    },
    {
      "name": "disabled",
      "optional": true,
      "type": "boolean",
      "doc": "Disable the editor (read-only render of the value)."
    },
    {
      "name": "ariaLabel",
      "optional": true,
      "type": "string; } /** * The RichTextEditor handle exposed via ref, so consumers can imperatively * focus or insert content (e.g. when responding to AI suggestions). */ export interface RichTextEditorHandle { focus(): void",
      "doc": "Override the underlying contenteditable's aria-label."
    },
    {
      "name": "html",
      "optional": false,
      "type": "string): void",
      "doc": ""
    },
    {
      "name": "DEFAULT_TOOLBAR",
      "optional": false,
      "type": "RteToolbarButton[] = [ \"bold\", \"italic\", \"link\", \"bullet\", \"image\", \"attachments\", ]; // ─────────────────────────────────────────────────────────────────────────── // Toolbar icons (inline SVG for zero-asset shipping) // ─────────────────────────────────────────────────────────────────────────── function ToolbarIcon({ name }: { name: RteToolbarButton }) { const common = { width: 14, height: 14, viewBox: \"0 0 16 16\", fill: \"currentColor\" } as const",
      "doc": ""
    },
    {
      "name": "bold",
      "optional": false,
      "type": "return ( <svg {...common} aria-hidden=\"true\"> <path d=\"M4 2h4.2a3 3 0 0 1 1.7 5.5A3.2 3.2 0 0 1 8.5 14H4V2Zm2 5h2a1.2 1.2 0 0 0 0-2.4H6V7Zm0 5h2.4a1.4 1.4 0 0 0 0-2.8H6V12Z\" /> </svg> )",
      "doc": ""
    },
    {
      "name": "italic",
      "optional": false,
      "type": "return ( <svg {...common} aria-hidden=\"true\"> <path d=\"M6 2h6v1.6H9.6L8 12.4h2V14H4v-1.6h2.4L8 3.6H6V2Z\" /> </svg> )",
      "doc": ""
    },
    {
      "name": "link",
      "optional": false,
      "type": "return ( <svg {...common} aria-hidden=\"true\"> <path d=\"M9.4 4.6a3 3 0 0 1 4.2 4.2L12 10.4l-1-1 1.6-1.6a1.6 1.6 0 0 0-2.2-2.2L8.8 7.2l-1-1 1.6-1.6Zm-3 6.8a3 3 0 0 1-4.2-4.2L4 5.6l1 1-1.6 1.6a1.6 1.6 0 0 0 2.2 2.2l1.6-1.6 1 1L6.4 11.4Z\" /> <path d=\"m6 10 4-4 1 1-4 4-1-1Z\" /> </svg> )",
      "doc": ""
    },
    {
      "name": "bullet",
      "optional": false,
      "type": "return ( <svg {...common} aria-hidden=\"true\"> <circle cx=\"3\" cy=\"4\" r=\"1.2\" /> <circle cx=\"3\" cy=\"8\" r=\"1.2\" /> <circle cx=\"3\" cy=\"12\" r=\"1.2\" /> <rect x=\"6\" y=\"3\" width=\"9\" height=\"2\" rx=\"0.6\" /> <rect x=\"6\" y=\"7\" width=\"9\" height=\"2\" rx=\"0.6\" /> <rect x=\"6\" y=\"11\" width=\"9\" height=\"2\" rx=\"0.6\" /> </svg> )",
      "doc": ""
    },
    {
      "name": "ordered",
      "optional": false,
      "type": "return ( <svg {...common} aria-hidden=\"true\"> <text x=\"0\" y=\"6\" fontSize=\"5\" fontWeight=\"700\"> 1 </text> <text x=\"0\" y=\"11\" fontSize=\"5\" fontWeight=\"700\"> 2 </text> <rect x=\"6\" y=\"3\" width=\"9\" height=\"2\" rx=\"0.6\" /> <rect x=\"6\" y=\"7\" width=\"9\" height=\"2\" rx=\"0.6\" /> <rect x=\"6\" y=\"11\" width=\"9\" height=\"2\" rx=\"0.6\" /> </svg> )",
      "doc": ""
    },
    {
      "name": "image",
      "optional": false,
      "type": "return ( <svg {...common} aria-hidden=\"true\"> <path d=\"M2 3h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm0 1v6.6l3-2.6 2.4 2.4 3-3L14 11.6V4H2Zm3 1.5a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4Z\" /> </svg> )",
      "doc": ""
    },
    {
      "name": "attachments",
      "optional": false,
      "type": "return ( <svg {...common} aria-hidden=\"true\"> <path d=\"M11.6 4.4a2.5 2.5 0 0 0-3.6 0L4.4 8a3.5 3.5 0 0 0 5 5l3.2-3.2-1-1-3.2 3.2a2.1 2.1 0 1 1-3-3l3.6-3.6a1.1 1.1 0 0 1 1.6 1.6L7 10.6l1 1 3.6-3.6a2.5 2.5 0 0 0 0-3.6Z\" /> </svg> ); } } const TOOLBAR_LABEL: Record<RteToolbarButton, string> = { bold: \"Bold\", italic: \"Italic\", link: \"Insert link\", bullet: \"Bulleted list\", ordered: \"Numbered list\", image: \"Insert image\", attachments: \"Add attachments\", }; // ─────────────────────────────────────────────────────────────────────────── // Component // ─────────────────────────────────────────────────────────────────────────── /** * TipTap-shaped rich text editor backed by a sanitised contenteditable. * * Decisions: * - Implementation uses `contenteditable` + `document.execCommand` (still * well-supported for the bold/italic/link/list ops the brief requires) * instead of pulling in TipTap (`@tiptap/core` + 4 extensions ≈ 150KB * minified). The kit explicitly avoids heavy npm dependencies in `lib/` * primitives and the npm install would push us past the surface this * component actually needs. If TipTap-grade collaborative editing, * schemas, or transactions are needed later, the public API will hold — * we'd just swap the engine inside. * - Pasted and emitted HTML is sanitised via the shared `lib/sanitiseHtml` * allowlist — all `on*` attributes and `<script>` / `<style>` are dropped. * - Merge-tag autocomplete opens when the user types `{{`. Same keyboard * model as `<MergeTagInput>` (↑/↓/Enter/Esc). * * Drop-in: * ```tsx * <RichTextEditor * value={html} * onChange={setHtml} * toolbar={[\"bold\", \"italic\", \"link\", \"bullet\", \"image\", \"attachments\"]} * mergeTags={[\"{{contact.first_name}}\", \"{{unsubscribe_url}}\"]} * placeholder=\"Write a message…\" * /> * ``` */ export const RichTextEditor = forwardRef< RichTextEditorHandle, RichTextEditorProps >( ( { className, value, onChange, toolbar = DEFAULT_TOOLBAR, mergeTags = [], placeholder = \"Write a message…\", onAttach, onImagePick, disabled = false, ariaLabel = \"Message body\", ...props }, ref, ) => { const editorRef = useRef<HTMLDivElement | null>(null)",
      "doc": ""
    },
    {
      "name": "focus",
      "optional": false,
      "type": "() => editorRef.current?.focus(), insertHtml: (html: string) => execHtmlInsert(html), getElement: () => editorRef.current, }), [], ); // Hydrate editor content when `value` is set imperatively (controlled // mode), but skip if the user is mid-edit so we don't reset their caret. useEffect(() => { const el = editorRef.current",
      "doc": ""
    },
    {
      "name": "html",
      "optional": false,
      "type": "string) => { editorRef.current?.focus()",
      "doc": ""
    },
    {
      "name": "command",
      "optional": false,
      "type": "string, val?: string) => { editorRef.current?.focus()",
      "doc": ""
    },
    {
      "name": "tag",
      "optional": false,
      "type": "string) => { if (typeof window === \"undefined\") return",
      "doc": ""
    },
    {
      "name": "e",
      "optional": false,
      "type": "KeyboardEvent<HTMLDivElement>) => { if (tagOpen && filteredTags.length > 0) { if (e.key === \"ArrowDown\") { e.preventDefault()",
      "doc": ""
    },
    {
      "name": "e",
      "optional": false,
      "type": "ClipboardEvent<HTMLDivElement>) => { // Hand the paste to the sanitiser, then insert as plain HTML so the // browser doesn't drop pasted styles into the contenteditable. const html = e.clipboardData.getData(\"text/html\")",
      "doc": ""
    },
    {
      "name": "handlers",
      "optional": false,
      "type": "Partial<Record<RteToolbarButton, () => void>> = { bold: () => exec(\"bold\"), italic: () => exec(\"italic\"), link: openLinkPopover, bullet: () => exec(\"insertUnorderedList\"), ordered: () => exec(\"insertOrderedList\"), image: triggerImage, attachments: triggerAttachments, }",
      "doc": ""
    },
    {
      "name": "anchorRef",
      "optional": false,
      "type": "React.RefObject<HTMLElement | null>",
      "doc": ""
    },
    {
      "name": "url",
      "optional": false,
      "type": "string",
      "doc": ""
    },
    {
      "name": "onUrlChange",
      "optional": false,
      "type": "(url: string) => void",
      "doc": ""
    },
    {
      "name": "onApply",
      "optional": false,
      "type": "() => void",
      "doc": ""
    },
    {
      "name": "onClose",
      "optional": false,
      "type": "() => void; } function RteLinkPopover({ anchorRef, url, onUrlChange, onApply, onClose, }: RteLinkPopoverProps) { const contentRef = useRef<HTMLDivElement | null>(null)",
      "doc": ""
    },
    {
      "name": "triggerRef",
      "optional": false,
      "type": "anchorRef, contentRef, placement: \"top-start\", offset: 4, isOpen: true, })",
      "doc": ""
    },
    {
      "name": "position",
      "optional": false,
      "type": "\"absolute\", top: coords.top, left: coords.left, zIndex: 1100, }} > <input type=\"url\" autoFocus value={url} placeholder=\"https://\" className=\"input rte-link-popover-input\" onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => { if (e.key === \"Enter\") { e.preventDefault()",
      "doc": ""
    },
    {
      "name": "anchorRef",
      "optional": false,
      "type": "React.RefObject<HTMLElement | null>",
      "doc": ""
    },
    {
      "name": "tags",
      "optional": false,
      "type": "string[]",
      "doc": ""
    },
    {
      "name": "activeIndex",
      "optional": false,
      "type": "number",
      "doc": ""
    },
    {
      "name": "onSelect",
      "optional": false,
      "type": "(tag: string) => void; } function RteTagPopover({ anchorRef, tags, activeIndex, onSelect, }: RteTagPopoverProps) { const contentRef = useRef<HTMLDivElement | null>(null)",
      "doc": ""
    },
    {
      "name": "triggerRef",
      "optional": false,
      "type": "anchorRef, contentRef, placement: \"bottom-start\", offset: 4, isOpen: true, })",
      "doc": ""
    },
    {
      "name": "position",
      "optional": false,
      "type": "\"absolute\", top: coords.top, left: coords.left, zIndex: 1100, }} > {tags.map((tag, i) => ( <button key={tag} type=\"button\" role=\"option\" aria-selected={i === activeIndex} className={cn( \"rte-tag-option\", i === activeIndex && \"rte-tag-option-active\", )} onMouseDown={(e) => { e.preventDefault()",
      "doc": ""
    }
  ],
  "classesUsed": [
    "btn-link",
    "icon-btn",
    "input",
    "rich-text-editor",
    "rich-text-editor-disabled",
    "rte-content",
    "rte-hidden-input",
    "rte-link-popover",
    "rte-link-popover-apply",
    "rte-link-popover-input",
    "rte-tag-option",
    "rte-tag-option-active",
    "rte-tag-popover",
    "rte-toolbar",
    "rte-toolbar-btn"
  ],
  "examples": {
    "react": null,
    "html": "<div class=\"rich-text-editor\" style=\"min-width:340px;max-width:520px;\">\n  <div role=\"toolbar\" aria-label=\"Formatting\" class=\"rte-toolbar\">\n    <button type=\"button\" class=\"icon-btn rte-toolbar-btn\" aria-label=\"Bold\"><strong>B</strong></button>\n    <button type=\"button\" class=\"icon-btn rte-toolbar-btn\" aria-label=\"Italic\"><em>I</em></button>\n    <button type=\"button\" class=\"icon-btn rte-toolbar-btn\" aria-label=\"Link\">∞</button>\n    <button type=\"button\" class=\"icon-btn rte-toolbar-btn\" aria-label=\"Bulleted list\">•</button>\n  </div>\n  <div class=\"rte-content\" contenteditable=\"true\" role=\"textbox\" aria-multiline=\"true\">Write a message…</div>\n</div>",
    "css": ".btn-link {\n  display: inline-flex; align-items: center; gap: var(--s-2);\n  color: var(--fg);\n  font: 600 14.5px/1 var(--f-display);\n  text-decoration: none;\n  padding: 2px 0;\n  border-bottom: 1px solid var(--hair);\n  transition: color var(--dur-2) var(--ease),\n              border-color var(--dur-2) var(--ease),\n              transform var(--dur-2) var(--ease);\n}\n\n.icon-btn {\n  width: 36px; height: 36px;\n  display: inline-flex; align-items: center; justify-content: center;\n  background: var(--bg-paper);\n  border: 1px solid var(--hair);\n  border-radius: var(--r-md);\n  color: var(--fg);\n  cursor: pointer;\n  transition: background var(--dur-2) var(--ease),\n              border-color var(--dur-2) var(--ease),\n              color var(--dur-2) var(--ease);\n}\n\n.input {\n  display: block; width: 100%;\n  font: 400 14.5px/1.4 var(--f-body);\n  color: var(--fg);\n  background: var(--bg-paper);\n  border: 1px solid var(--hair);\n  border-radius: var(--r-sm);\n  padding: 10px 14px;\n  transition: border-color var(--dur-2) var(--ease),\n              box-shadow var(--dur-2) var(--ease);\n  appearance: none; -webkit-appearance: none; -moz-appearance: none;\n}\n\n.rich-text-editor {\n  display: flex;\n  flex-direction: column;\n  border: 1px solid var(--hair);\n  border-radius: var(--r-md);\n  background: var(--paper);\n  position: relative;\n  min-height: 160px;\n}\n\n.rich-text-editor-disabled {\n  opacity: 0.6;\n  pointer-events: none;\n}\n\n.rte-content {\n  padding: var(--s-3);\n  min-height: 120px;\n  font: 400 14px/1.55 var(--f-body);\n  color: var(--fg);\n  outline: 0;\n  overflow-y: auto;\n}\n\n.rte-hidden-input {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  border: 0;\n}\n\n.rte-link-popover {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  background: var(--paper);\n  border: 1px solid var(--hair);\n  border-radius: var(--r-sm);\n  box-shadow: var(--sh-2);\n  padding: 6px;\n}\n\n.rte-link-popover-apply {\n  font-size: 12.5px;\n}\n\n.rte-link-popover-input {\n  width: 240px;\n  height: 32px;\n  font-size: 13px;\n}\n\n.rte-tag-option {\n  padding: 6px 10px;\n  border: 0;\n  background: transparent;\n  border-radius: var(--r-xs);\n  text-align: left;\n  cursor: pointer;\n  transition: background var(--dur-1) var(--ease);\n  font: 400 12.5px/1.3 var(--f-mono, var(--f-body));\n  color: var(--fg);\n}\n\n.rte-tag-option-active {\n  background: var(--warm-3);\n}\n\n.rte-tag-popover {\n  background: var(--paper);\n  border: 1px solid var(--hair);\n  border-radius: var(--r-sm);\n  box-shadow: var(--sh-2);\n  padding: 4px;\n  min-width: 220px;\n  max-height: 280px;\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n}\n\n.rte-toolbar {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 6px 8px;\n  border-bottom: 1px solid var(--hair);\n  background: var(--bg-paper, var(--paper));\n  border-radius: var(--r-md) var(--r-md) 0 0;\n  flex-wrap: wrap;\n}\n\n.rte-toolbar-btn {\n  width: 28px;\n  height: 28px;\n  border-radius: var(--r-xs);\n}"
  }
}
