{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "markdown-editor",
  "type": "registry:component",
  "title": "MarkdownEditor",
  "description": "Compose Markdown with formatting, task lists, images, source editing, and application-owned uploads.",
  "dependencies": [
    "@tiptap/core@3.31.4",
    "@tiptap/extension-image@3.31.4",
    "@tiptap/extension-list@3.31.4",
    "@tiptap/extension-paragraph@3.31.4",
    "@tiptap/extension-table@3.31.4",
    "@tiptap/markdown@3.31.4",
    "@tiptap/pm@3.31.4",
    "@tiptap/react@3.31.4",
    "@tiptap/starter-kit@3.31.4",
    "lucide-react@^1.47.0"
  ],
  "registryDependencies": [
    "https://control-ui.dev/r/button.json",
    "https://control-ui.dev/r/chat-composer-attachment.json",
    "https://control-ui.dev/r/core.json",
    "https://control-ui.dev/r/input.json",
    "https://control-ui.dev/r/label.json",
    "https://control-ui.dev/r/popover.json",
    "https://control-ui.dev/r/textarea.json",
    "https://control-ui.dev/r/toolbar.json",
    "https://control-ui.dev/r/tooltip.json"
  ],
  "files": [
    {
      "path": "src/registry/knob-contracts/markdown-editor-knobs.ts",
      "target": "@components/control-ui/knob-contracts/markdown-editor-knobs.ts",
      "type": "registry:component",
      "content": "// Generated from src/registry/sources/control-ui/recipes/markdown-editor.css by scripts/gen-knob-contracts.ts — run `bun run sync:knobs`.\nexport const markdownEditorKnobs = [\n  \"--cui-markdown-editor-background\",\n  \"--cui-markdown-editor-foreground\",\n  \"--cui-markdown-editor-border-color\",\n  \"--cui-markdown-editor-radius\",\n  \"--cui-markdown-editor-padding\",\n  \"--cui-markdown-editor-min-height\",\n  \"--cui-markdown-editor-max-height\",\n  \"--cui-markdown-editor-gap\",\n  \"--cui-markdown-editor-muted-foreground\",\n  \"--cui-markdown-editor-code-background\",\n  \"--cui-markdown-editor-border-width\",\n] as const;\nexport type MarkdownEditorKnobStyle = Partial<Record<(typeof markdownEditorKnobs)[number], string>>;\n"
    },
    {
      "path": "src/registry/sources/control-ui/markdown-editor.tsx",
      "target": "@components/control-ui/markdown-editor.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { Slice } from \"@tiptap/pm/model\";\nimport { EditorState } from \"@tiptap/pm/state\";\nimport { EditorContent, useEditor } from \"@tiptap/react\";\nimport { type ComponentProps, type CSSProperties, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { MarkdownEditorKnobStyle } from \"@/components/control-ui/knob-contracts/markdown-editor-knobs\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { Textarea } from \"@/components/control-ui/ui/textarea\";\nimport { MarkdownEditorContext, useMarkdownEditor } from \"./markdown-editor/context\";\nimport { createMarkdownCodec, hasMarkdownContent, markdownExtensions, requiresMarkdownSource } from \"./markdown-editor/extensions\";\nimport type { MarkdownImageUploader } from \"./markdown-editor/uploads\";\nimport { useImageUploads } from \"./markdown-editor/use-image-uploads\";\n\nexport type MarkdownEditorStatus = { isEmpty: boolean; hasPendingUploads: boolean };\nexport type MarkdownEditorProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"style\"> & {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (markdown: string) => void;\n  disabled?: boolean;\n  onUploadImage?: MarkdownImageUploader;\n  maxImageBytes?: number;\n  onStatusChange?: (status: MarkdownEditorStatus) => void;\n  style?: CSSProperties & MarkdownEditorKnobStyle;\n};\n\nexport function MarkdownEditor({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  disabled = false,\n  onUploadImage,\n  maxImageBytes = 10 * 1024 * 1024,\n  onStatusChange,\n  children,\n  className,\n  ...props\n}: MarkdownEditorProps) {\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const markdown = value ?? internalValue;\n  const [sourceMode, setSourceMode] = useState(false);\n  const [contentAttributes, setContentAttributes] = useState<Record<string, string>>({ role: \"textbox\", \"aria-multiline\": \"true\" });\n  const [codec] = useState(createMarkdownCodec);\n  const sourceRequired = useMemo(() => requiresMarkdownSource(markdown, codec), [markdown, codec]);\n  const source = sourceMode || sourceRequired;\n  const lastValue = useRef(markdown);\n  const [extensions] = useState(markdownExtensions);\n  const handlers = useRef<{ upload: (files: File[], position?: number) => void; cancelAll: () => void } | null>(null);\n\n  function setMarkdown(next: string) {\n    lastValue.current = next;\n    setInternalValue(next);\n    onValueChange?.(next);\n  }\n\n  const editor = useEditor({\n    extensions,\n    immediatelyRender: false,\n    shouldRerenderOnTransaction: false,\n    content: sourceRequired ? \"\" : markdown,\n    contentType: \"markdown\",\n    editable: !disabled,\n    onUpdate: ({ editor: currentEditor }) => setMarkdown(currentEditor.getMarkdown()),\n    editorProps: {\n      attributes: contentAttributes,\n      handlePaste: (view, event) => {\n        if (disabled) return false;\n        const files = Array.from(event.clipboardData?.files ?? []);\n        if (onUploadImage && files.length > 0) {\n          event.preventDefault();\n          handlers.current?.upload(files);\n          return true;\n        }\n        const text = event.clipboardData?.getData(\"text/plain\");\n        if (!text || event.clipboardData?.getData(\"text/html\") || requiresMarkdownSource(text, codec)) return false;\n        const document = view.state.schema.nodeFromJSON(codec.parse(text));\n        event.preventDefault();\n        view.dispatch(view.state.tr.replaceSelection(Slice.maxOpen(document.content)).scrollIntoView());\n        return true;\n      },\n      handleDrop: (view, event, _slice, moved) => {\n        const files = Array.from(event.dataTransfer?.files ?? []);\n        if (moved || !onUploadImage || disabled || files.length === 0) return false;\n        event.preventDefault();\n        const position = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;\n        handlers.current?.upload(files, position);\n        return true;\n      },\n    },\n  });\n  const imageUploads = useImageUploads(editor, onUploadImage, maxImageBytes);\n  useEffect(() => {\n    handlers.current = imageUploads;\n  });\n\n  useEffect(() => {\n    if (!editor) return;\n    editor.setEditable(!disabled, false);\n  }, [editor, disabled]);\n\n  useEffect(() => {\n    if (!editor) return;\n    const externalReset = lastValue.current !== markdown;\n    if (externalReset) handlers.current?.cancelAll();\n    if (source) {\n      lastValue.current = markdown;\n      return;\n    }\n    if (editor.getMarkdown() !== markdown) {\n      editor.commands.setContent(markdown, { contentType: \"markdown\", emitUpdate: false });\n      if (externalReset) {\n        editor.view.updateState(EditorState.create({ schema: editor.schema, doc: editor.state.doc, plugins: editor.state.plugins }));\n        editor.view.dispatch(editor.state.tr);\n      }\n    }\n    lastValue.current = markdown;\n  }, [editor, markdown, source]);\n\n  const isEmpty = source ? markdown.trim() === \"\" : !hasMarkdownContent(codec.parse(markdown));\n  const hasPendingUploads = imageUploads.uploads.length > 0;\n  useEffect(() => {\n    onStatusChange?.({ isEmpty, hasPendingUploads });\n  }, [onStatusChange, isEmpty, hasPendingUploads]);\n\n  return (\n    <MarkdownEditorContext\n      value={{\n        editor,\n        disabled,\n        source,\n        markdown,\n        sourceRequired,\n        setSource: setSourceMode,\n        setMarkdown,\n        setContentAttributes,\n        canUpload: Boolean(onUploadImage),\n        ...imageUploads,\n      }}\n    >\n      <div\n        data-control-ui=\"markdown-editor\"\n        data-control-family=\"markdown-editor\"\n        data-slot=\"root\"\n        data-disabled={disabled ? \"\" : undefined}\n        className={cn(\"min-w-0\", className)}\n        {...props}\n      >\n        {children}\n      </div>\n    </MarkdownEditorContext>\n  );\n}\n\nexport type MarkdownEditorContentProps = {\n  label: string;\n  placeholder?: string;\n  autoFocus?: boolean;\n  className?: string;\n};\n\nexport function MarkdownEditorContent({ label, placeholder, autoFocus = false, className }: MarkdownEditorContentProps) {\n  const { editor, disabled, source, sourceRequired, markdown, setMarkdown, setContentAttributes } = useMarkdownEditor();\n  useEffect(() => {\n    setContentAttributes({\n      role: \"textbox\",\n      \"aria-multiline\": \"true\",\n      \"aria-label\": label,\n      \"aria-disabled\": String(disabled),\n      \"data-placeholder\": placeholder ?? \"\",\n    });\n  }, [setContentAttributes, label, disabled, placeholder]);\n\n  useEffect(() => {\n    if (editor && autoFocus && !source && !disabled) editor.commands.focus(\"end\");\n  }, [editor, autoFocus, source, disabled]);\n\n  if (source || !editor)\n    return (\n      <div data-control-ui=\"markdown-editor\" data-control-family=\"markdown-editor\" data-slot=\"source\" className={className}>\n        {sourceRequired ? (\n          <p role=\"status\">This content uses Markdown that is only available in source mode. Its source is preserved.</p>\n        ) : null}\n        <Textarea\n          aria-label={label}\n          placeholder={placeholder}\n          value={markdown}\n          disabled={disabled || !editor}\n          onChange={(event) => setMarkdown(event.target.value)}\n          rows={5}\n        />\n      </div>\n    );\n  return (\n    <EditorContent\n      editor={editor}\n      data-control-ui=\"markdown-editor\"\n      data-control-family=\"markdown-editor\"\n      data-slot=\"content\"\n      className={className}\n    />\n  );\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/markdown-editor/context.ts",
      "target": "@components/control-ui/markdown-editor/context.ts",
      "type": "registry:component",
      "content": "import type { Editor } from \"@tiptap/core\";\nimport { createContext, useContext } from \"react\";\nimport type { MarkdownImageUpload } from \"./uploads\";\n\nexport type MarkdownEditorContextValue = {\n  editor: Editor | null;\n  disabled: boolean;\n  source: boolean;\n  markdown: string;\n  sourceRequired: boolean;\n  setSource: (source: boolean) => void;\n  setMarkdown: (value: string) => void;\n  setContentAttributes: (attributes: Record<string, string>) => void;\n  canUpload: boolean;\n  uploads: MarkdownImageUpload[];\n  upload: (files: File[], position?: number) => void;\n  retryUpload: (id: string) => void;\n  removeUpload: (id: string) => void;\n  uploadError: string | null;\n};\n\nexport const MarkdownEditorContext = createContext<MarkdownEditorContextValue | null>(null);\n\nexport function useMarkdownEditor() {\n  const context = useContext(MarkdownEditorContext);\n  if (!context) throw new Error(\"Markdown editor parts require a MarkdownEditor parent.\");\n  return context;\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/markdown-editor/extensions.ts",
      "target": "@components/control-ui/markdown-editor/extensions.ts",
      "type": "registry:component",
      "content": "import { type JSONContent, Node } from \"@tiptap/core\";\nimport Image from \"@tiptap/extension-image\";\nimport { TaskItem, TaskList } from \"@tiptap/extension-list\";\nimport Paragraph from \"@tiptap/extension-paragraph\";\nimport { TableKit } from \"@tiptap/extension-table\";\nimport { Markdown, MarkdownManager } from \"@tiptap/markdown\";\nimport StarterKit from \"@tiptap/starter-kit\";\n\nexport function isEditorUrl(value: string, image = false): boolean {\n  if (!value || /[\\s\\\\]/.test(value) || Array.from(value).some((character) => character.charCodeAt(0) < 32)) return false;\n  if (value.startsWith(\"/\") && !value.startsWith(\"//\")) return true;\n  if (!image && value.startsWith(\"#\")) return true;\n  try {\n    const url = new URL(value);\n    return [\"https:\", \"http:\", ...(image ? [] : [\"mailto:\"])].includes(url.protocol);\n  } catch {\n    return false;\n  }\n}\n\nconst SafeImage = Image.extend({\n  renderMarkdown: (node) => {\n    const src = String(node.attrs?.src ?? \"\").replace(/[()]/g, (character) => `\\\\${character}`);\n    const alt = String(node.attrs?.alt ?? \"\").replace(/[\\\\[\\]]/g, (character) => `\\\\${character}`);\n    const title = String(node.attrs?.title ?? \"\").replace(/[\\\\\"]/g, (character) => `\\\\${character}`);\n    return title ? `![${alt}](${src} \"${title}\")` : `![${alt}](${src})`;\n  },\n  addAttributes() {\n    return {\n      ...this.parent?.(),\n      src: {\n        default: null,\n        parseHTML: (element) => {\n          const src = element.getAttribute(\"src\") ?? \"\";\n          return isEditorUrl(src, true) ? src : null;\n        },\n        renderHTML: (attributes) => ({ src: isEditorUrl(attributes.src ?? \"\", true) ? attributes.src : undefined }),\n      },\n    };\n  },\n});\n\nconst MarkdownParagraph = Paragraph.extend({\n  parseMarkdown: (token, helpers) => {\n    const content = helpers.parseInline(token.tokens ?? []);\n    const first = content[0];\n    const empty = content.length === 1 && first?.type === \"text\" && [\"&nbsp;\", \"\\u00a0\"].includes(first.text ?? \"\");\n    return helpers.createNode(\"paragraph\", undefined, empty ? [] : content);\n  },\n});\n\nexport const ImageUpload = Node.create({\n  name: \"imageUpload\",\n  group: \"block\",\n  atom: true,\n  selectable: true,\n  addAttributes: () => ({ id: { default: null }, name: { default: \"Image\" } }),\n  renderHTML: ({ node }) => [\"div\", { \"data-image-upload\": \"\", role: \"status\" }, `Image: ${node.attrs.name}`],\n  renderMarkdown: () => \"\",\n});\n\nexport function markdownExtensions() {\n  return [\n    StarterKit.configure({\n      underline: false,\n      paragraph: false,\n      link: { openOnClick: false, isAllowedUri: (url) => isEditorUrl(url) },\n    }),\n    MarkdownParagraph,\n    SafeImage.configure({ allowBase64: false, inline: true }),\n    TaskList,\n    TaskItem.configure({ nested: true, HTMLAttributes: { \"data-type\": \"taskItem\" } }),\n    TableKit.configure({ table: { resizable: false } }),\n    ImageUpload,\n    Markdown,\n  ];\n}\n\nexport function createMarkdownCodec() {\n  return new MarkdownManager({ extensions: markdownExtensions() });\n}\n\nconst supportedTokens = new Set([\n  \"space\",\n  \"code\",\n  \"heading\",\n  \"hr\",\n  \"blockquote\",\n  \"list\",\n  \"list_item\",\n  \"paragraph\",\n  \"text\",\n  \"escape\",\n  \"link\",\n  \"image\",\n  \"strong\",\n  \"em\",\n  \"codespan\",\n  \"br\",\n  \"del\",\n  \"table\",\n  \"def\",\n  \"taskList\",\n  \"taskItem\",\n]);\n\nfunction unsupportedToken(value: unknown): boolean {\n  if (Array.isArray(value)) return value.some(unsupportedToken);\n  if (typeof value !== \"object\" || value === null) return false;\n  if (\"type\" in value && typeof value.type === \"string\") {\n    if (!supportedTokens.has(value.type)) return true;\n    if (\n      (value.type === \"image\" || value.type === \"link\") &&\n      \"href\" in value &&\n      typeof value.href === \"string\" &&\n      !isEditorUrl(value.href, value.type === \"image\")\n    )\n      return true;\n  }\n  return Object.values(value).some(unsupportedToken);\n}\n\nexport function requiresMarkdownSource(markdown: string, codec: MarkdownManager): boolean {\n  return unsupportedToken(codec.instance.lexer(markdown));\n}\n\nexport function hasMarkdownContent(document: JSONContent): boolean {\n  if (document.type === \"image\") return true;\n  if (document.type === \"text\" && document.text?.trim()) return true;\n  return document.content?.some(hasMarkdownContent) ?? false;\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/markdown-editor/toolbar.tsx",
      "target": "@components/control-ui/markdown-editor/toolbar.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { useEditorState } from \"@tiptap/react\";\nimport {\n  BoldIcon,\n  CodeIcon,\n  Heading2Icon,\n  ImageIcon,\n  ItalicIcon,\n  LinkIcon,\n  ListChecksIcon,\n  ListIcon,\n  ListOrderedIcon,\n  QuoteIcon,\n  RedoIcon,\n  SquareCodeIcon,\n  StrikethroughIcon,\n  UndoIcon,\n} from \"lucide-react\";\nimport { type ReactNode, useId, useRef, useState } from \"react\";\nimport { ChatComposerAttachment, ChatComposerAttachments } from \"@/components/control-ui/chat-composer-attachment\";\nimport { Button } from \"@/components/control-ui/ui/button\";\nimport { Input } from \"@/components/control-ui/ui/input\";\nimport { Label } from \"@/components/control-ui/ui/label\";\nimport { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from \"@/components/control-ui/ui/popover\";\nimport { Toolbar, ToolbarButton, ToolbarSeparator } from \"@/components/control-ui/ui/toolbar\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/control-ui/ui/tooltip\";\nimport { useMarkdownEditor } from \"./context\";\nimport { isEditorUrl } from \"./extensions\";\nimport { markdownImageTypes } from \"./uploads\";\n\nexport function MarkdownEditorToolbar({ children }: { children?: ReactNode }) {\n  const { editor, disabled, source, sourceRequired, setSource, uploads } = useMarkdownEditor();\n  const state = useEditorState({\n    editor,\n    selector: ({ editor: current }) => ({\n      bold: current?.isActive(\"bold\"),\n      italic: current?.isActive(\"italic\"),\n      strike: current?.isActive(\"strike\"),\n      code: current?.isActive(\"code\"),\n      heading: current?.isActive(\"heading\", { level: 2 }),\n      bulletList: current?.isActive(\"bulletList\"),\n      orderedList: current?.isActive(\"orderedList\"),\n      taskList: current?.isActive(\"taskList\"),\n      blockquote: current?.isActive(\"blockquote\"),\n      codeBlock: current?.isActive(\"codeBlock\"),\n      undo: current?.can().undo(),\n      redo: current?.can().redo(),\n    }),\n  });\n  const actions = [\n    { label: \"Bold\", Icon: BoldIcon, active: state?.bold, run: () => editor?.chain().focus().toggleBold().run() },\n    { label: \"Italic\", Icon: ItalicIcon, active: state?.italic, run: () => editor?.chain().focus().toggleItalic().run() },\n    { label: \"Strikethrough\", Icon: StrikethroughIcon, active: state?.strike, run: () => editor?.chain().focus().toggleStrike().run() },\n    { label: \"Inline code\", Icon: CodeIcon, active: state?.code, run: () => editor?.chain().focus().toggleCode().run() },\n    { label: \"Heading\", Icon: Heading2Icon, active: state?.heading, run: () => editor?.chain().focus().toggleHeading({ level: 2 }).run() },\n    { label: \"Bullet list\", Icon: ListIcon, active: state?.bulletList, run: () => editor?.chain().focus().toggleBulletList().run() },\n    {\n      label: \"Numbered list\",\n      Icon: ListOrderedIcon,\n      active: state?.orderedList,\n      run: () => editor?.chain().focus().toggleOrderedList().run(),\n    },\n    { label: \"Task list\", Icon: ListChecksIcon, active: state?.taskList, run: () => editor?.chain().focus().toggleTaskList().run() },\n    { label: \"Quote\", Icon: QuoteIcon, active: state?.blockquote, run: () => editor?.chain().focus().toggleBlockquote().run() },\n    { label: \"Code block\", Icon: SquareCodeIcon, active: state?.codeBlock, run: () => editor?.chain().focus().toggleCodeBlock().run() },\n  ];\n  return (\n    <TooltipProvider>\n      <Toolbar aria-label=\"Markdown formatting\" className=\"flex w-full flex-wrap\">\n        {actions.map(({ label, Icon, active, run }) => (\n          <Tooltip key={label}>\n            <TooltipTrigger\n              render={\n                <ToolbarButton\n                  type=\"button\"\n                  iconOnly\n                  aria-label={label}\n                  aria-pressed={Boolean(active)}\n                  disabled={disabled || source || !editor}\n                  onMouseDown={(event) => event.preventDefault()}\n                  onClick={run}\n                />\n              }\n            >\n              <Icon aria-hidden=\"true\" />\n            </TooltipTrigger>\n            <TooltipContent>{label}</TooltipContent>\n          </Tooltip>\n        ))}\n        <MarkdownEditorLink />\n        <MarkdownEditorImage />\n        <ToolbarSeparator />\n        <ToolbarButton\n          type=\"button\"\n          iconOnly\n          aria-label=\"Undo\"\n          disabled={disabled || source || !state?.undo}\n          onClick={() => editor?.chain().focus().undo().run()}\n        >\n          <UndoIcon aria-hidden=\"true\" />\n        </ToolbarButton>\n        <ToolbarButton\n          type=\"button\"\n          iconOnly\n          aria-label=\"Redo\"\n          disabled={disabled || source || !state?.redo}\n          onClick={() => editor?.chain().focus().redo().run()}\n        >\n          <RedoIcon aria-hidden=\"true\" />\n        </ToolbarButton>\n        <ToolbarButton\n          type=\"button\"\n          aria-pressed={source}\n          disabled={disabled || !editor || sourceRequired || uploads.length > 0}\n          onClick={() => setSource(!source)}\n        >\n          Markdown source\n        </ToolbarButton>\n        {children}\n      </Toolbar>\n    </TooltipProvider>\n  );\n}\n\nexport function MarkdownEditorLink() {\n  const { editor, disabled, source } = useMarkdownEditor();\n  const [open, setOpen] = useState(false);\n  const [url, setUrl] = useState(\"\");\n  const inputId = useId();\n  const valid = isEditorUrl(url.trim());\n  function apply() {\n    if (!valid || disabled || source) return;\n    editor?.chain().focus().extendMarkRange(\"link\").setLink({ href: url.trim() }).run();\n    setOpen(false);\n  }\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (next) setUrl(editor?.getAttributes(\"link\").href ?? \"\");\n      }}\n    >\n      <PopoverTrigger disabled={disabled || source || !editor} render={<ToolbarButton type=\"button\" iconOnly aria-label=\"Edit link\" />}>\n        <LinkIcon aria-hidden=\"true\" />\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"grid gap-3\"\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") {\n            event.preventDefault();\n            event.stopPropagation();\n            apply();\n          }\n        }}\n      >\n        <PopoverTitle>Edit link</PopoverTitle>\n        <Label htmlFor={inputId}>URL</Label>\n        <Input id={inputId} value={url} onChange={(event) => setUrl(event.target.value)} placeholder=\"https://example.com\" />\n        <div className=\"flex gap-2\">\n          <Button type=\"button\" disabled={!valid || disabled} onClick={apply}>\n            Apply link\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            disabled={disabled}\n            onClick={() => {\n              editor?.chain().focus().extendMarkRange(\"link\").unsetLink().run();\n              setOpen(false);\n            }}\n          >\n            Remove link\n          </Button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nexport function MarkdownEditorImage() {\n  const { editor, disabled, source, canUpload, upload } = useMarkdownEditor();\n  const [open, setOpen] = useState(false);\n  const [url, setUrl] = useState(\"\");\n  const [alt, setAlt] = useState(\"\");\n  const inputId = useId();\n  const altId = useId();\n  const fileInput = useRef<HTMLInputElement>(null);\n  const valid = isEditorUrl(url.trim(), true);\n  function insert() {\n    if (!valid || disabled || source) return;\n    editor?.chain().focus().setImage({ src: url.trim(), alt }).run();\n    setOpen(false);\n    setUrl(\"\");\n    setAlt(\"\");\n  }\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger disabled={disabled || source || !editor} render={<ToolbarButton type=\"button\" iconOnly aria-label=\"Insert image\" />}>\n        <ImageIcon aria-hidden=\"true\" />\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"grid gap-3\"\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") {\n            event.preventDefault();\n            event.stopPropagation();\n            insert();\n          }\n        }}\n      >\n        <PopoverTitle>Insert image</PopoverTitle>\n        <Label htmlFor={inputId}>Image URL</Label>\n        <Input id={inputId} value={url} onChange={(event) => setUrl(event.target.value)} placeholder=\"https://example.com/image.png\" />\n        <Label htmlFor={altId}>Image description</Label>\n        <Input id={altId} value={alt} onChange={(event) => setAlt(event.target.value)} />\n        <Button type=\"button\" disabled={!valid || disabled} onClick={insert}>\n          Insert image\n        </Button>\n        {canUpload ? (\n          <>\n            <input\n              ref={fileInput}\n              type=\"file\"\n              accept={markdownImageTypes.join(\",\")}\n              multiple\n              hidden\n              onChange={(event) => {\n                upload(Array.from(event.target.files ?? []));\n                event.target.value = \"\";\n                setOpen(false);\n              }}\n            />\n            <Button type=\"button\" variant=\"ghost\" disabled={disabled} onClick={() => fileInput.current?.click()}>\n              Upload images\n            </Button>\n          </>\n        ) : null}\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nexport function MarkdownEditorUploads() {\n  const { uploads, uploadError, retryUpload, removeUpload, disabled } = useMarkdownEditor();\n  return (\n    <>\n      {uploadError ? <p role=\"alert\">{uploadError}</p> : null}\n      {uploads.length > 0 ? (\n        <div data-control-ui=\"markdown-editor\" data-control-family=\"markdown-editor\" data-slot=\"uploads\">\n          <ChatComposerAttachments label=\"Image uploads\">\n            {uploads.map((item) => (\n              <ChatComposerAttachment\n                key={item.id}\n                file={item.file}\n                status={item.status}\n                progress={item.progress}\n                onRemove={disabled ? undefined : () => removeUpload(item.id)}\n                removeLabel={`Remove ${item.file.name}`}\n              />\n            ))}\n          </ChatComposerAttachments>\n          {uploads\n            .filter((item) => item.status === \"error\")\n            .map((item) => (\n              <div key={item.id} role=\"alert\" className=\"flex items-center gap-2\">\n                <span>\n                  {item.file.name}: {item.error}\n                </span>\n                <Button type=\"button\" size=\"xs\" disabled={disabled} onClick={() => retryUpload(item.id)}>\n                  Retry upload\n                </Button>\n              </div>\n            ))}\n        </div>\n      ) : null}\n    </>\n  );\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/markdown-editor/uploads.ts",
      "target": "@components/control-ui/markdown-editor/uploads.ts",
      "type": "registry:component",
      "content": "import type { Editor } from \"@tiptap/core\";\nimport type { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\nimport { isEditorUrl } from \"./extensions\";\n\nexport type MarkdownImage = { url: string; alt?: string; title?: string };\nexport type MarkdownImageUploader = (\n  file: File,\n  options: { signal: AbortSignal; onProgress: (progress: number) => void },\n) => Promise<MarkdownImage>;\nexport type MarkdownImageUpload = {\n  id: string;\n  file: File;\n  status: \"uploading\" | \"error\";\n  progress?: number;\n  error?: string;\n};\n\nexport function findImageUpload(document: ProseMirrorNode, id: string) {\n  let position: number | undefined;\n  document.descendants((node, pos) => {\n    if (node.type.name === \"imageUpload\" && node.attrs.id === id) position = pos;\n  });\n  return position;\n}\n\nexport function completeImageUpload(editor: Editor, id: string, image: MarkdownImage, fallbackAlt: string) {\n  if (!isEditorUrl(image.url, true)) throw new Error(\"The upload must return a permanent HTTP(S) or root-relative image URL.\");\n  const position = findImageUpload(editor.state.doc, id);\n  if (position === undefined) return;\n  const imageType = editor.schema.nodes.image;\n  const paragraphType = editor.schema.nodes.paragraph;\n  if (!imageType || !paragraphType) throw new Error(\"Image uploads require image and paragraph nodes.\");\n  const node = imageType.create({ src: image.url, alt: image.alt ?? fallbackAlt, title: image.title ?? null });\n  editor.view.dispatch(\n    editor.state.tr.replaceWith(position, position + 1, paragraphType.create(null, node)).setMeta(\"addToHistory\", false),\n  );\n}\n\nexport const markdownImageTypes = [\"image/png\", \"image/jpeg\", \"image/gif\", \"image/webp\", \"image/avif\"];\n"
    },
    {
      "path": "src/registry/sources/control-ui/markdown-editor/use-image-uploads.ts",
      "target": "@components/control-ui/markdown-editor/use-image-uploads.ts",
      "type": "registry:component",
      "content": "import type { Editor } from \"@tiptap/core\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { completeImageUpload, findImageUpload, type MarkdownImageUpload, type MarkdownImageUploader, markdownImageTypes } from \"./uploads\";\n\nexport function useImageUploads(editor: Editor | null, uploader: MarkdownImageUploader | undefined, maxImageBytes: number) {\n  const [uploads, setUploads] = useState<MarkdownImageUpload[]>([]);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n  const uploadPrefix = useId();\n  const nextId = useRef(0);\n  const jobs = useRef(new Map<string, { file: File; controller: AbortController }>());\n\n  function discard(id: string) {\n    jobs.current.get(id)?.controller.abort();\n    jobs.current.delete(id);\n    setUploads((current) => current.filter((item) => item.id !== id));\n  }\n\n  function cancelAll() {\n    for (const job of jobs.current.values()) job.controller.abort();\n    jobs.current.clear();\n    setUploads([]);\n  }\n\n  useEffect(() => {\n    const activeJobs = jobs.current;\n    return () => {\n      for (const job of activeJobs.values()) job.controller.abort();\n      activeJobs.clear();\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!editor) return;\n    const prune = () => {\n      for (const id of jobs.current.keys()) {\n        if (findImageUpload(editor.state.doc, id) === undefined) {\n          jobs.current.get(id)?.controller.abort();\n          jobs.current.delete(id);\n          setUploads((current) => current.filter((item) => item.id !== id));\n        }\n      }\n    };\n    editor.on(\"update\", prune);\n    return () => {\n      editor.off(\"update\", prune);\n    };\n  }, [editor]);\n\n  async function run(id: string, file: File) {\n    if (!editor || !uploader) return;\n    const controller = new AbortController();\n    jobs.current.set(id, { file, controller });\n    setUploads((current) => [...current.filter((item) => item.id !== id), { id, file, status: \"uploading\" }]);\n    try {\n      const image = await uploader(file, {\n        signal: controller.signal,\n        onProgress: (progress) => {\n          if (controller.signal.aborted || !Number.isFinite(progress)) return;\n          setUploads((current) =>\n            current.map((item) => (item.id === id ? { ...item, progress: Math.max(0, Math.min(100, progress)) } : item)),\n          );\n        },\n      });\n      if (controller.signal.aborted || editor.isDestroyed) return;\n      completeImageUpload(editor, id, image, file.name);\n      discard(id);\n    } catch (error) {\n      if (controller.signal.aborted) return;\n      setUploads((current) =>\n        current.map((item) =>\n          item.id === id\n            ? {\n                ...item,\n                status: \"error\",\n                error: error instanceof Error ? error.message : \"Image upload failed.\",\n              }\n            : item,\n        ),\n      );\n    }\n  }\n\n  function upload(files: File[], position?: number) {\n    if (!editor || !uploader || !editor.isEditable) return;\n    setUploadError(null);\n    let insertAt = position ?? editor.state.selection.to;\n    for (const file of files) {\n      if (!markdownImageTypes.includes(file.type) || file.size > maxImageBytes) {\n        setUploadError(`Choose a PNG, JPEG, GIF, WebP or AVIF image up to ${Math.round(maxImageBytes / 1024 / 1024)} MB.`);\n        continue;\n      }\n      const id = `${uploadPrefix}-${nextId.current++}`;\n      editor\n        .chain()\n        .focus()\n        .insertContentAt(insertAt, { type: \"imageUpload\", attrs: { id, name: file.name } })\n        .run();\n      insertAt = editor.state.selection.to;\n      void run(id, file);\n    }\n  }\n\n  function removeUpload(id: string) {\n    discard(id);\n    if (!editor) return;\n    const position = findImageUpload(editor.state.doc, id);\n    if (position !== undefined) editor.view.dispatch(editor.state.tr.delete(position, position + 1).setMeta(\"addToHistory\", false));\n  }\n\n  function retryUpload(id: string) {\n    const job = jobs.current.get(id);\n    if (job) {\n      job.controller.abort();\n      void run(id, job.file);\n    }\n  }\n\n  return { uploads, uploadError, upload, retryUpload, removeUpload, cancelAll };\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/recipes/markdown-editor.css",
      "target": "@components/control-ui/styles/recipes/markdown-editor.css",
      "type": "registry:file",
      "content": "@layer components {\n  :where([data-control-family=\"markdown-editor\"][data-slot=\"root\"]) {\n    --cui-markdown-editor-background: var(--background);\n    --cui-markdown-editor-foreground: var(--foreground);\n    --cui-markdown-editor-border-width: var(--control-rim-width);\n    --cui-markdown-editor-border-color: var(--border);\n    --cui-markdown-editor-radius: var(--radius-panel);\n    --cui-markdown-editor-padding: calc(var(--spacing) * 3);\n    --cui-markdown-editor-min-height: calc(var(--spacing) * 32);\n    --cui-markdown-editor-max-height: 60dvh;\n    --cui-markdown-editor-gap: calc(var(--spacing) * 3);\n    --cui-markdown-editor-muted-foreground: var(--muted-foreground);\n    --cui-markdown-editor-code-background: var(--muted);\n    color: var(--cui-markdown-editor-foreground);\n    background: var(--cui-markdown-editor-background);\n    border: var(--cui-markdown-editor-border-width) solid var(--cui-markdown-editor-border-color);\n    border-radius: var(--cui-markdown-editor-radius);\n    overflow: clip;\n    font-size: var(--text-body);\n    line-height: var(--leading-relaxed);\n  }\n  :where([data-control-family=\"markdown-editor\"][data-slot=\"content\"] .tiptap) {\n    min-block-size: var(--cui-markdown-editor-min-height);\n    max-block-size: var(--cui-markdown-editor-max-height);\n    overflow: auto;\n    padding: var(--cui-markdown-editor-padding);\n    overflow-wrap: anywhere;\n    white-space: pre-wrap;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap:focus-visible) {\n    outline: 2px solid var(--ring);\n    outline-offset: -2px;\n  }\n  :where([data-control-family=\"markdown-editor\"][data-slot=\"source\"]),\n  :where([data-control-family=\"markdown-editor\"][data-slot=\"uploads\"]) {\n    padding: var(--cui-markdown-editor-padding);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap > * + *) {\n    margin-block-start: var(--cui-markdown-editor-gap);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap:has(> p:only-child > br:only-child))::before {\n    content: attr(data-placeholder);\n    color: var(--cui-markdown-editor-muted-foreground);\n    pointer-events: none;\n    float: inline-start;\n    block-size: 0;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap h1) {\n    font-size: var(--text-heading-2);\n    font-weight: var(--font-weight-semibold);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap h2) {\n    font-size: var(--text-heading-3);\n    font-weight: var(--font-weight-semibold);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap :is(h3, h4, h5, h6)) {\n    font-size: var(--text-heading-4);\n    font-weight: var(--font-weight-semibold);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap strong) {\n    font-weight: var(--font-weight-semibold);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap em) {\n    font-style: italic;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap s) {\n    text-decoration: line-through;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap a) {\n    text-decoration: underline;\n    color: var(--primary);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap ul) {\n    list-style: disc;\n    padding-inline-start: calc(var(--spacing) * 6);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap ol) {\n    list-style: decimal;\n    padding-inline-start: calc(var(--spacing) * 6);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap ul[data-type=\"taskList\"]) {\n    list-style: none;\n    padding-inline-start: 0;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap li[data-type=\"taskItem\"]) {\n    display: flex;\n    align-items: baseline;\n    gap: var(--cui-markdown-editor-gap);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap li[data-type=\"taskItem\"] > div) {\n    flex: 1;\n    min-inline-size: 0;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap blockquote) {\n    border-inline-start: var(--cui-markdown-editor-border-width) solid var(--cui-markdown-editor-border-color);\n    padding-inline-start: var(--cui-markdown-editor-padding);\n    color: var(--cui-markdown-editor-muted-foreground);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap :is(code, pre)) {\n    font-family: var(--font-mono);\n    background: var(--cui-markdown-editor-code-background);\n    border-radius: var(--radius-sm);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap pre) {\n    padding: var(--cui-markdown-editor-padding);\n    overflow: auto;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap img[src]) {\n    display: block;\n    max-inline-size: 100%;\n    block-size: auto;\n    border-radius: var(--radius-sm);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap .ProseMirror-selectednode) {\n    outline: 2px solid var(--ring);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap [data-image-upload]) {\n    padding: var(--cui-markdown-editor-padding);\n    border: var(--cui-markdown-editor-border-width) dashed var(--cui-markdown-editor-border-color);\n    color: var(--cui-markdown-editor-muted-foreground);\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap table) {\n    border-collapse: collapse;\n    inline-size: 100%;\n  }\n  :where([data-control-family=\"markdown-editor\"] .tiptap :is(td, th)) {\n    border: var(--cui-markdown-editor-border-width) solid var(--cui-markdown-editor-border-color);\n    padding: var(--cui-markdown-editor-padding);\n  }\n}\n\n@property --cui-markdown-editor-background {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-markdown-editor-foreground {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-markdown-editor-border-color {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-markdown-editor-radius {\n  syntax: \"<length-percentage>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-markdown-editor-padding {\n  syntax: \"<length>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-markdown-editor-min-height {\n  syntax: \"<length>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-markdown-editor-max-height {\n  syntax: \"<length>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-markdown-editor-gap {\n  syntax: \"<length>\";\n  inherits: true;\n  initial-value: 0px;\n}\n\n@property --cui-markdown-editor-muted-foreground {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-markdown-editor-code-background {\n  syntax: \"<color>\";\n  inherits: true;\n  initial-value: transparent;\n}\n\n@property --cui-markdown-editor-border-width {\n  syntax: \"<length>\";\n  inherits: true;\n  initial-value: 0px;\n}\n"
    }
  ],
  "css": {
    "@import \"../components/control-ui/styles/recipes/markdown-editor.css\"": {}
  }
}
