{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rich-text-editor",
  "title": "Rich Text Editor",
  "description": "Rich Text Editor from the Get IT Done design system.",
  "dependencies": [],
  "registryDependencies": [
    "https://zero-design.itdone.de/r/labels-context.json",
    "https://zero-design.itdone.de/r/recipes.json",
    "https://zero-design.itdone.de/r/rich-text-markdown.json",
    "https://zero-design.itdone.de/r/rich-text-toolbar.json",
    "https://zero-design.itdone.de/r/utils.json",
    "https://zero-design.itdone.de/r/theme.json"
  ],
  "files": [
    {
      "path": "registry/ui/rich-text-editor.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\n\nimport { focusRing, invalidRing } from '@/lib/recipes';\nimport { cn } from '@/lib/utils';\nimport { activeRichTextActions, applyRichTextAction, continueList } from '@/components/ui/rich-text-markdown';\nimport { RichTextToolbar, type RichTextAction, type RichTextToolbarProps } from '@/components/ui/rich-text-toolbar';\nimport { useLabels } from '@/components/ui/labels-context';\n\nconst safeUrl = (url: string) => /^(https?:\\/\\/|mailto:|\\/|#)/i.test(url.trim());\n\n// Inline markdown subset to React nodes. Never builds HTML strings, and only allows safe link targets.\nconst inlinePattern = /(\\*\\*[^*]+\\*\\*|\\*[^*]+\\*|<u>[^<]*<\\/u>|`[^`]+`|!?\\[[^\\]]*\\]\\([^)]*\\))/g;\n\nconst renderInline = (text: string, keyPrefix: string): React.ReactNode[] =>\n  text.split(inlinePattern).map((part, index) => {\n    const key = `${keyPrefix}-${index}`;\n    if (part.startsWith('**') && part.endsWith('**') && part.length > 4)\n      return <strong key={key}>{part.slice(2, -2)}</strong>;\n    if (part.startsWith('*') && part.endsWith('*') && part.length > 2) return <em key={key}>{part.slice(1, -1)}</em>;\n    if (part.startsWith('<u>') && part.endsWith('</u>')) return <u key={key}>{part.slice(3, -4)}</u>;\n    if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {\n      return (\n        <code key={key} className=\"rounded-item bg-muted px-1 typography-inline-code font-normal\">\n          {part.slice(1, -1)}\n        </code>\n      );\n    }\n    const link = /^(!?)\\[([^\\]]*)\\]\\(([^)]*)\\)$/.exec(part);\n    if (link) {\n      const [, image, label, url] = link;\n      if (!safeUrl(url)) return <React.Fragment key={key}>{label}</React.Fragment>;\n      if (image) return <img key={key} src={url} alt={label} className=\"max-w-full\" />;\n      return (\n        <a key={key} href={url} className=\"text-text-brand underline underline-offset-4\" rel=\"noreferrer noopener\">\n          {label}\n        </a>\n      );\n    }\n    return <React.Fragment key={key}>{part}</React.Fragment>;\n  });\n\nexport const renderRichTextPreview = (value: string): React.ReactNode[] => {\n  const nodes: React.ReactNode[] = [];\n  const lines = value.split('\\n');\n  let index = 0;\n  while (index < lines.length) {\n    const line = lines[index];\n    const key = `b${index}`;\n    const heading = /^(#{1,2}) (.*)$/.exec(line);\n    if (heading) {\n      nodes.push(\n        heading[1].length === 1 ? (\n          <h2 key={key} className=\"typography-subheading text-foreground\">\n            {renderInline(heading[2], key)}\n          </h2>\n        ) : (\n          <h3 key={key} className=\"typography-ui-lg-semibold text-foreground\">\n            {renderInline(heading[2], key)}\n          </h3>\n        ),\n      );\n      index += 1;\n    } else if (line.startsWith('> ')) {\n      nodes.push(\n        <blockquote key={key} className=\"border-l-2 border-solid border-border-default pl-3 text-text-subtle\">\n          {renderInline(line.slice(2), key)}\n        </blockquote>,\n      );\n      index += 1;\n    } else if (/^- /.test(line) || /^\\d+\\. /.test(line)) {\n      const ordered = /^\\d+\\. /.test(line);\n      const items: string[] = [];\n      while (index < lines.length && (ordered ? /^\\d+\\. /.test(lines[index]) : /^- /.test(lines[index]))) {\n        items.push(lines[index].replace(ordered ? /^\\d+\\. / : /^- /, ''));\n        index += 1;\n      }\n      const List = ordered ? 'ol' : 'ul';\n      nodes.push(\n        <List key={key} className={cn('pl-5', ordered ? 'list-decimal' : 'list-disc')}>\n          {items.map((item, itemIndex) => (\n            <li key={`${key}-${itemIndex}`}>{renderInline(item, `${key}-${itemIndex}`)}</li>\n          ))}\n        </List>,\n      );\n    } else if (line.trim() === '') {\n      index += 1;\n    } else {\n      nodes.push(<p key={key}>{renderInline(line, key)}</p>);\n      index += 1;\n    }\n  }\n  return nodes;\n};\n\nexport interface RichTextEditorProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'>,\n    Pick<RichTextToolbarProps, 'type' | 'actions' | 'leading'> {\n  // Markdown source. The editor is a plain textarea that writes markdown markers around the selection, not a contenteditable.\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  placeholder?: string;\n  disabled?: boolean;\n  readOnly?: boolean;\n  invalid?: boolean;\n  // Shows the Write / Preview switch. The preview renders the markdown subset as React elements.\n  preview?: boolean;\n  // Accessible name of the text area.\n  textLabel?: string;\n  rows?: number;\n  // Message from a failed action, shown in the toolbar.\n  error?: React.ReactNode;\n}\n\nconst RichTextEditor = React.forwardRef<HTMLTextAreaElement, RichTextEditorProps>(\n  (\n    {\n      className,\n      value,\n      defaultValue = '',\n      onValueChange,\n      placeholder,\n      disabled = false,\n      readOnly = false,\n      invalid = false,\n      preview = true,\n      textLabel: textLabelProp,\n      rows = 8,\n      type,\n      actions,\n      leading,\n      error,\n      ...props\n    },\n    ref,\n  ) => {\n    const l = useLabels();\n    const textLabel = textLabelProp ?? l.content;\n    const [inner, setInner] = React.useState(defaultValue);\n    const current = value ?? inner;\n    const [mode, setMode] = React.useState<'write' | 'preview'>('write');\n    const [active, setActive] = React.useState<Partial<Record<RichTextAction, boolean>>>({});\n    const areaRef = React.useRef<HTMLTextAreaElement | null>(null);\n    const pending = React.useRef<{ start: number; end: number } | null>(null);\n\n    const setValue = (next: string) => {\n      setInner(next);\n      onValueChange?.(next);\n    };\n\n    const refreshActive = () => {\n      const area = areaRef.current;\n      if (area)\n        setActive(activeRichTextActions({ value: area.value, start: area.selectionStart, end: area.selectionEnd }));\n    };\n\n    React.useLayoutEffect(() => {\n      const area = areaRef.current;\n      if (area && pending.current) {\n        area.focus();\n        area.setSelectionRange(pending.current.start, pending.current.end);\n        pending.current = null;\n        refreshActive();\n      }\n    });\n\n    const apply = (action: RichTextAction) => {\n      const area = areaRef.current;\n      if (!area || disabled || readOnly) return;\n      const edit = applyRichTextAction(\n        { value: area.value, start: area.selectionStart, end: area.selectionEnd },\n        action,\n      );\n      pending.current = { start: edit.start, end: edit.end };\n      setValue(edit.value);\n    };\n\n    const shortcuts: Record<string, RichTextAction> = { b: 'bold', i: 'italic', u: 'underline', e: 'code', k: 'link' };\n\n    const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n      if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey) {\n        const action = shortcuts[event.key.toLowerCase()];\n        if (action) {\n          event.preventDefault();\n          apply(action);\n          return;\n        }\n      }\n      if (\n        event.key === 'Enter' &&\n        !event.shiftKey &&\n        !event.ctrlKey &&\n        !event.metaKey &&\n        !event.nativeEvent.isComposing\n      ) {\n        const area = event.currentTarget;\n        const edit = continueList({ value: area.value, start: area.selectionStart, end: area.selectionEnd });\n        if (edit) {\n          event.preventDefault();\n          pending.current = { start: edit.start, end: edit.end };\n          setValue(edit.value);\n        }\n      }\n    };\n\n    return (\n      <div\n        data-invalid={invalid || undefined}\n        className={cn(\n          'flex w-full flex-col gap-2 rounded-control border border-solid border-input bg-background p-2',\n          'focus-within:border-ring focus-within:ring-focus focus-within:ring-offset-focus focus-within:ring-offset-background focus-within:ring-ring-default',\n          'data-[invalid=true]:border-destructive data-[invalid=true]:ring-destructive/20',\n          disabled && 'opacity-disabled',\n          className,\n        )}\n        {...props}\n      >\n        <div className=\"flex items-start justify-between gap-2\">\n          <RichTextToolbar\n            type={type}\n            actions={actions}\n            leading={leading}\n            error={error}\n            pressed={active}\n            disabled={disabled || readOnly || mode === 'preview'}\n            onAction={apply}\n            className=\"flex-1\"\n          />\n          {preview ? (\n            <div role=\"group\" aria-label={l.editorMode} className=\"flex shrink-0 gap-1\">\n              {(['write', 'preview'] as const).map((entry) => (\n                <button\n                  key={entry}\n                  type=\"button\"\n                  aria-pressed={mode === entry}\n                  disabled={disabled}\n                  onClick={() => setMode(entry)}\n                  className={cn(\n                    'h-control-sm rounded-item px-2 typography-ui-medium capitalize text-text-subtle transition-colors hover:bg-muted motion-reduce:transition-none',\n                    'aria-[pressed=true]:bg-muted aria-[pressed=true]:text-foreground',\n                    focusRing,\n                  )}\n                >\n                  {entry === 'write' ? l.write : l.preview}\n                </button>\n              ))}\n            </div>\n          ) : null}\n        </div>\n        {mode === 'write' ? (\n          <textarea\n            ref={(node) => {\n              areaRef.current = node;\n              if (typeof ref === 'function') ref(node);\n              else if (ref) ref.current = node;\n            }}\n            aria-label={textLabel}\n            aria-invalid={invalid || undefined}\n            value={current}\n            rows={rows}\n            placeholder={placeholder}\n            disabled={disabled}\n            readOnly={readOnly}\n            spellCheck\n            className={cn(\n              'w-full resize-y rounded-item border-0 bg-transparent px-2 py-1 text-foreground outline-none typography-input-text',\n              // Below sm the field text is at least 16px so iOS Safari does not zoom on focus.\n              'max-sm:text-[max(16px,var(--typo-input-text-size,1rem))]',\n              'placeholder:text-text-subtle',\n              'disabled:cursor-not-allowed',\n              invalidRing,\n            )}\n            onChange={(event) => setValue(event.target.value)}\n            onKeyDown={handleKeyDown}\n            onSelect={refreshActive}\n            onKeyUp={refreshActive}\n            onClick={refreshActive}\n          />\n        ) : (\n          <div\n            data-rich-text-preview=\"\"\n            aria-label={l.previewRegion}\n            role=\"region\"\n            className=\"flex flex-col gap-2 px-2 py-2 typography-ui text-foreground\"\n          >\n            {current.trim() ? renderRichTextPreview(current) : <p className=\"text-text-subtle\">{l.nothingToPreview}</p>}\n          </div>\n        )}\n      </div>\n    );\n  },\n);\nRichTextEditor.displayName = 'RichTextEditor';\n\nexport { RichTextEditor };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}