{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "markdown-editor",
  "title": "Markdown Editor",
  "author": "AniBT <https://anibt.net>",
  "description": "AniBT Markdown field: 编写 / 分栏 / 预览, a nine-tool toolbar with Ctrl/Cmd shortcuts that undo cleanly, a react-markdown + GFM preview in A's prose, and a character count.",
  "dependencies": [
    "lucide-react",
    "radix-ui",
    "react-markdown",
    "remark-gfm"
  ],
  "registryDependencies": [
    "@anibt/theme"
  ],
  "files": [
    {
      "path": "registry/anibt/ui/markdown-editor.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  BoldIcon,\n  CodeIcon,\n  Columns2Icon,\n  EyeIcon,\n  Heading2Icon,\n  ImageIcon,\n  ItalicIcon,\n  LinkIcon,\n  ListIcon,\n  MinusIcon,\n  PencilLineIcon,\n  QuoteIcon,\n} from \"lucide-react\"\nimport { ToggleGroup as ToggleGroupPrimitive, Tooltip as TooltipPrimitive } from \"radix-ui\"\nimport ReactMarkdown from \"react-markdown\"\nimport remarkGfm from \"remark-gfm\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  applyMarkdownEdit,\n  formatMarkdown,\n  type MarkdownAction,\n  type MarkdownEdit,\n} from \"@/registry/anibt/ui/markdown-format\"\n\n/**\n * MarkdownEditor — AniBT 「贴纸手账」: the one editing surface for a field that\n * takes Markdown (release notes, a group's default notes).\n *\n * - **One field**: paper under the control edge; while the text area has\n *   focus the whole surface wears A's focus (a 1.5px pink edge and the pink\n *   halo). A head row holds the view switch and the toolbar, a foot row the\n *   「支持 Markdown」 note and the character count.\n * - **View**: 编写 / 预览 as a segmented control. A container 720px or wider\n *   adds 分栏 and starts there: text and preview side by side, the preview\n *   scrolling inside the text area's height. The width is measured after\n *   mount, so the server and the first client render draw the narrow layout.\n * - **Toolbar**: 加粗 · 斜体 · 标题 · 列表 · 引用 · 链接 · 图片 · 代码 · 分割线,\n *   28px quiet icon squares with tooltips. Ctrl/Cmd+B, I and K are bold,\n *   italic and link. Each command is a pure text edit (`markdown-format.ts`)\n *   applied through the browser's own insertion, so Ctrl/Cmd+Z undoes it.\n *   Nothing fires while an IME is composing.\n * - **Preview**: `react-markdown` + GFM in A's prose (`markdownProseClassName`)\n *   — headings, pink links, a pink-washed quote, a stitched rule, sunk code.\n *   Raw HTML is not rendered. Pass `renderPreview` to use your own renderer.\n * - **Height**: the text area grows with its text from the size's floor to\n *   its ceiling, then scrolls; it can still be dragged taller.\n * - **Count**: `n 字`, or `n / max` with `maxLength` — amber near the limit,\n *   red over it.\n *\n * Every string has a Chinese default and can be replaced through `labels`.\n */\n\ntype View = \"write\" | \"split\" | \"preview\"\n\ntype MarkdownEditorLabels = {\n  write: string\n  split: string\n  preview: string\n  view: string\n  toolbar: string\n  bold: string\n  italic: string\n  heading: string\n  list: string\n  quote: string\n  link: string\n  image: string\n  code: string\n  divider: string\n  /** The foot row's note. */\n  hint: string\n  /** The preview of an empty value. */\n  empty: string\n  count: (count: number) => string\n  countMax: (count: number, max: number) => string\n}\n\nconst DEFAULT_LABELS: MarkdownEditorLabels = {\n  write: \"编写\",\n  split: \"分栏\",\n  preview: \"预览\",\n  view: \"视图\",\n  toolbar: \"格式\",\n  bold: \"加粗\",\n  italic: \"斜体\",\n  heading: \"标题\",\n  list: \"列表\",\n  quote: \"引用\",\n  link: \"链接\",\n  image: \"图片\",\n  code: \"代码\",\n  divider: \"分割线\",\n  hint: \"支持 Markdown\",\n  empty: \"还没有可预览的内容\",\n  count: (count) => `${count} 字`,\n  countMax: (count, max) => `${count} / ${max}`,\n}\n\ntype MarkdownEditorProps = {\n  id?: string\n  value: string\n  onChange: (value: string) => void\n  placeholder?: string\n  disabled?: boolean\n  /** Rendered in the preview instead of `value` (an effective template). */\n  previewValue?: string\n  /** The text area's floor and ceiling: sm 120–320, md 180–440, lg 240–560. */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** The longest value the field accepts; the count shows `n / max`. */\n  maxLength?: number\n  showCount?: boolean\n  /** Replaces the 「支持 Markdown」 note in the foot row. */\n  hint?: React.ReactNode\n  /** The view to start in. Without it a wide editor starts in 分栏, a narrow one in 编写. */\n  defaultView?: View\n  /** Your own preview renderer; the default is react-markdown + GFM in A's prose. */\n  renderPreview?: (value: string) => React.ReactNode\n  labels?: Partial<MarkdownEditorLabels>\n  className?: string\n  \"aria-describedby\"?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  \"aria-labelledby\"?: string\n  \"aria-label\"?: string\n}\n\n/** A container this wide or wider gets 分栏: two panes of 360px or more. */\nconst SPLIT_MIN_WIDTH_PX = 720\n\nconst SIZE_CLASS: Record<NonNullable<MarkdownEditorProps[\"size\"]>, string> = {\n  sm: \"min-h-[120px] max-h-[min(40svh,320px)]\",\n  md: \"min-h-[180px] max-h-[min(55svh,440px)]\",\n  lg: \"min-h-[240px] max-h-[min(60svh,560px)]\",\n}\n\ntype ToolName = Exclude<keyof MarkdownEditorLabels, \"write\" | \"split\" | \"preview\" | \"view\" | \"toolbar\" | \"hint\" | \"empty\" | \"count\" | \"countMax\">\n\ntype Tool = {\n  action: MarkdownAction\n  label: ToolName\n  icon: React.ComponentType<{ className?: string; \"aria-hidden\"?: boolean | \"true\" }>\n  /** The letter of its Ctrl/Cmd shortcut. */\n  key?: string\n}\n\nconst TOOLS: readonly Tool[] = [\n  { action: \"bold\", label: \"bold\", icon: BoldIcon, key: \"b\" },\n  { action: \"italic\", label: \"italic\", icon: ItalicIcon, key: \"i\" },\n  { action: \"heading\", label: \"heading\", icon: Heading2Icon },\n  { action: \"list\", label: \"list\", icon: ListIcon },\n  { action: \"quote\", label: \"quote\", icon: QuoteIcon },\n  { action: \"link\", label: \"link\", icon: LinkIcon, key: \"k\" },\n  { action: \"image\", label: \"image\", icon: ImageIcon },\n  { action: \"code\", label: \"code\", icon: CodeIcon },\n  { action: \"divider\", label: \"divider\", icon: MinusIcon },\n]\n\nconst SHORTCUTS = new Map(TOOLS.flatMap((tool) => (tool.key ? [[tool.key, tool.action] as const] : [])))\n\n/**\n * A's prose for rendered Markdown: headings step down but never under 12px,\n * body copy in the secondary ink, pink links, a quote on the pink wash with\n * a pink stroke, a stitched rule, code on the sunk well, stitched tables.\n */\nconst markdownProseClassName = cn(\n  \"text-[13px] leading-6 text-anibt-ink2 [&>:first-child]:mt-0 [&>:last-child]:mb-0\",\n  \"[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-[20px] [&_h1]:leading-snug [&_h1]:font-extrabold [&_h1]:text-foreground\",\n  \"[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-[16px] [&_h2]:leading-snug [&_h2]:font-extrabold [&_h2]:text-foreground\",\n  \"[&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-[14px] [&_h3]:font-bold [&_h3]:text-foreground\",\n  \"[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-[13px] [&_h4]:font-bold [&_h4]:text-foreground\",\n  \"[&_h5]:mt-3 [&_h5]:mb-1 [&_h5]:text-[13px] [&_h5]:font-semibold\",\n  \"[&_h6]:mt-3 [&_h6]:mb-1 [&_h6]:text-[12px] [&_h6]:font-semibold [&_h6]:text-muted-foreground\",\n  \"[&_p]:my-2.5 [&_p]:leading-7\",\n  \"[&_strong]:font-bold [&_strong]:text-foreground\",\n  \"[&_a]:font-medium [&_a]:text-accent-foreground [&_a]:underline-offset-4 hover:[&_a]:underline\",\n  \"[&_blockquote]:my-4 [&_blockquote]:rounded-r-[10px] [&_blockquote]:border-l-[3px] [&_blockquote]:border-anibt-mark [&_blockquote]:bg-accent [&_blockquote]:px-4 [&_blockquote]:py-2 [&_blockquote_p]:my-0 [&_blockquote_p]:text-foreground\",\n  \"[&_ul]:my-2.5 [&_ul]:list-disc [&_ul]:space-y-1.5 [&_ul]:pl-5 [&_ol]:my-2.5 [&_ol]:list-decimal [&_ol]:space-y-1.5 [&_ol]:pl-5\",\n  \"[&_ul>li::marker]:text-anibt-mark [&_ol>li::marker]:text-muted-foreground [&_li>p]:my-0\",\n  \"[&_input[type=checkbox]]:mr-1.5 [&_input[type=checkbox]]:accent-anibt-mark [&_li:has(>input[type=checkbox])]:list-none\",\n  \"[&_code]:rounded-[6px] [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[12px] [&_code]:text-foreground\",\n  \"[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-[10px] [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-[12px] [&_pre]:shadow-[inset_0_0_0_1px_var(--border)] [&_pre_code]:bg-transparent [&_pre_code]:p-0\",\n  \"[&_hr]:anibt-stitch [&_hr]:my-5 [&_hr]:border-0\",\n  \"[&_img]:my-3 [&_img]:max-w-full [&_img]:rounded-[10px]\",\n  \"[&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_table]:text-[12px] [&_th]:anibt-stitch-under [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold [&_th]:text-muted-foreground [&_td]:anibt-stitch-under [&_td]:px-2 [&_td]:py-1.5\"\n)\n\nfunction MarkdownEditor({\n  id,\n  value,\n  onChange,\n  placeholder,\n  disabled,\n  previewValue,\n  size = \"md\",\n  maxLength,\n  showCount = true,\n  hint,\n  defaultView,\n  renderPreview,\n  labels: labelOverrides,\n  className,\n  \"aria-describedby\": describedBy,\n  \"aria-invalid\": invalid,\n  \"aria-labelledby\": labelledBy,\n  \"aria-label\": ariaLabel,\n}: MarkdownEditorProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelOverrides }\n  const reactId = React.useId()\n  const textareaId = id ?? `${reactId}-text`\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null)\n  const pendingSelection = React.useRef<[number, number] | null>(null)\n  const wide = useContainerAtLeast(rootRef, SPLIT_MIN_WIDTH_PX)\n  const [chosenView, setChosenView] = React.useState<View>(defaultView ?? \"split\")\n  const view: View = !wide && chosenView === \"split\" ? \"write\" : chosenView\n  const showWrite = view !== \"preview\"\n  const showPreview = view !== \"write\"\n  const preview = React.useDeferredValue(previewValue ?? value)\n\n  // A fallback edit (no `insertText`) re-renders the value; the selection it\n  // asked for is put back once the new value is on the text area.\n  React.useLayoutEffect(() => {\n    const selection = pendingSelection.current\n    const textarea = textareaRef.current\n    if (!selection || !textarea) return\n    pendingSelection.current = null\n    textarea.setSelectionRange(selection[0], selection[1])\n  }, [value])\n\n  const applyEdit = (textarea: HTMLTextAreaElement, edit: MarkdownEdit) => {\n    const expected = applyMarkdownEdit(textarea.value, edit)\n    if (maxLength !== undefined && expected.length > maxLength && expected.length > textarea.value.length) return\n    textarea.focus()\n    textarea.setSelectionRange(edit.from, edit.to)\n    // The browser's own insertion keeps Ctrl/Cmd+Z working and fires the\n    // input event the controlled value listens to.\n    const inserted =\n      typeof document.execCommand === \"function\" && document.execCommand(\"insertText\", false, edit.insert)\n    if (inserted && textarea.value === expected) {\n      textarea.setSelectionRange(edit.selectionStart, edit.selectionEnd)\n      return\n    }\n    pendingSelection.current = [edit.selectionStart, edit.selectionEnd]\n    onChange(expected)\n  }\n\n  const runCommand = (action: MarkdownAction) => {\n    const textarea = textareaRef.current\n    if (!textarea || disabled) return\n    applyEdit(textarea, formatMarkdown(textarea.value, textarea.selectionStart, textarea.selectionEnd, action))\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (event.nativeEvent.isComposing || event.keyCode === 229 || event.altKey || event.shiftKey) return\n    if (!(event.metaKey || event.ctrlKey)) return\n    const action = SHORTCUTS.get(event.key.toLowerCase())\n    if (!action) return\n    event.preventDefault()\n    event.stopPropagation()\n    runCommand(action)\n  }\n\n  const viewOptions = [\n    { value: \"write\" as const, label: labels.write, icon: PencilLineIcon },\n    ...(wide ? [{ value: \"split\" as const, label: labels.split, icon: Columns2Icon }] : []),\n    { value: \"preview\" as const, label: labels.preview, icon: EyeIcon },\n  ]\n\n  const count = value.length\n  const countTone =\n    maxLength === undefined\n      ? \"text-muted-foreground\"\n      : count > maxLength\n        ? \"text-destructive\"\n        : count >= maxLength * 0.9\n          ? \"text-anibt-warn\"\n          : \"text-muted-foreground\"\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"markdown-editor\"\n      data-view={view}\n      className={cn(\n        \"@container/md min-w-0 overflow-hidden rounded-[10px] bg-card inset-ring-1 inset-ring-input transition-[box-shadow] duration-150\",\n        \"hover:inset-ring-anibt-wash-line has-[textarea:focus-visible]:inset-ring-[1.5px] has-[textarea:focus-visible]:inset-ring-ring has-[textarea:focus-visible]:ring-4 has-[textarea:focus-visible]:ring-ring/20\",\n        \"has-[textarea[aria-invalid=true]]:inset-ring-[1.5px] has-[textarea[aria-invalid=true]]:inset-ring-anibt-danger-fill\",\n        disabled && \"bg-muted hover:inset-ring-input\",\n        className\n      )}\n    >\n      <div className=\"anibt-stitch-under flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 p-1.5\">\n        <ToggleGroupPrimitive.Root\n          type=\"single\"\n          value={view}\n          onValueChange={(next) => {\n            if (next) setChosenView(next as View)\n          }}\n          aria-label={labels.view}\n          className=\"anibt-seg shrink-0\"\n        >\n          {viewOptions.map((option) => (\n            <ToggleGroupPrimitive.Item\n              key={option.value}\n              value={option.value}\n              className=\"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:outline-solid\"\n            >\n              <option.icon className=\"size-3.5\" aria-hidden=\"true\" />\n              {option.label}\n            </ToggleGroupPrimitive.Item>\n          ))}\n        </ToggleGroupPrimitive.Root>\n        <TooltipPrimitive.Provider delayDuration={400}>\n          <div\n            role=\"toolbar\"\n            aria-label={labels.toolbar}\n            aria-controls={textareaId}\n            className={cn(\n              \"flex min-w-0 flex-wrap items-center gap-0.5\",\n              // In 预览 the tools keep their place, so the head does not jump;\n              // where they wrap onto a row of their own they leave instead of\n              // leaving an empty band.\n              !showWrite && \"invisible @max-[30rem]/md:hidden\"\n            )}\n          >\n            <span className=\"mx-1 h-4 w-px shrink-0 bg-input @max-[28rem]/md:hidden\" aria-hidden=\"true\" />\n            {TOOLS.map((tool) => (\n              <ToolButton\n                key={tool.action}\n                tool={tool}\n                label={labels[tool.label]}\n                disabled={disabled || !showWrite}\n                onRun={runCommand}\n              />\n            ))}\n          </div>\n        </TooltipPrimitive.Provider>\n      </div>\n\n      <div className={cn(\"grid min-w-0\", view === \"split\" && \"grid-cols-2\")}>\n        <textarea\n          ref={textareaRef}\n          id={textareaId}\n          value={value}\n          onChange={(event) => onChange(event.target.value)}\n          onKeyDown={onKeyDown}\n          placeholder={placeholder}\n          disabled={disabled}\n          maxLength={maxLength}\n          aria-describedby={describedBy}\n          aria-invalid={invalid}\n          aria-labelledby={labelledBy}\n          aria-label={ariaLabel}\n          className={cn(\n            showWrite ? \"block\" : \"hidden\",\n            \"w-full min-w-0 resize-y overflow-y-auto border-0 bg-transparent px-3 py-2.5 text-[16px] leading-6 text-foreground outline-none [field-sizing:content] placeholder:text-muted-foreground selection:bg-anibt-wash2 selection:text-foreground disabled:cursor-not-allowed disabled:text-muted-foreground md:text-[13px]\",\n            SIZE_CLASS[size]\n          )}\n        />\n        {showPreview ? (\n          <MarkdownPreview\n            value={preview}\n            split={view === \"split\"}\n            empty={labels.empty}\n            renderPreview={renderPreview}\n            className={view === \"split\" ? undefined : SIZE_CLASS[size]}\n          />\n        ) : null}\n      </div>\n\n      <div className=\"anibt-stitch-over flex h-7 min-w-0 items-center gap-3 px-3 text-[12px]\">\n        <span className=\"anibt-ell min-w-0 flex-1 text-muted-foreground\">{hint ?? labels.hint}</span>\n        {showCount ? (\n          <span className={cn(\"anibt-num shrink-0\", countTone)}>\n            {maxLength === undefined ? labels.count(count) : labels.countMax(count, maxLength)}\n          </span>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n\nfunction ToolButton({\n  tool,\n  label,\n  disabled,\n  onRun,\n}: {\n  tool: Tool\n  label: string\n  disabled?: boolean\n  onRun: (action: MarkdownAction) => void\n}) {\n  const Icon = tool.icon\n  const [shortcut, setShortcut] = React.useState<string | null>(null)\n\n  return (\n    <TooltipPrimitive.Root\n      onOpenChange={(open) => {\n        // Read the platform only when a tooltip opens: ⌘B on a Mac, Ctrl+B elsewhere.\n        if (open && tool.key) setShortcut(shortcutLabel(tool.key))\n      }}\n    >\n      <TooltipPrimitive.Trigger {...{ asChild: true }}>\n        <button\n          type=\"button\"\n          aria-label={label}\n          aria-keyshortcuts={tool.key ? `Control+${tool.key.toUpperCase()} Meta+${tool.key.toUpperCase()}` : undefined}\n          disabled={disabled}\n          // The text area keeps the focus and the selection the command reads.\n          onMouseDown={(event) => event.preventDefault()}\n          onClick={() => onRun(tool.action)}\n          className=\"anibt-icobtn anibt-icobtn-sm anibt-icobtn-ghost focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:outline-solid disabled:pointer-events-none disabled:text-muted-foreground\"\n        >\n          <Icon className=\"size-4\" aria-hidden=\"true\" />\n        </button>\n      </TooltipPrimitive.Trigger>\n      <TooltipPrimitive.Portal>\n        <TooltipPrimitive.Content\n          side=\"top\"\n          sideOffset={6}\n          className=\"anibt-tip z-50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0\"\n        >\n          {shortcut ? `${label} · ${shortcut}` : label}\n          <TooltipPrimitive.Arrow className=\"fill-anibt-tip\" width={10} height={5} />\n        </TooltipPrimitive.Content>\n      </TooltipPrimitive.Portal>\n    </TooltipPrimitive.Root>\n  )\n}\n\nfunction shortcutLabel(key: string): string {\n  const mac = typeof navigator !== \"undefined\" && /Mac|iPhone|iPad/u.test(navigator.platform || navigator.userAgent)\n  return mac ? `⌘${key.toUpperCase()}` : `Ctrl+${key.toUpperCase()}`\n}\n\n/**\n * The preview pane. Side by side it is laid over its grid cell (`absolute\n * inset-0`), so the text area alone decides the row's height and the preview\n * scrolls inside it.\n */\nfunction MarkdownPreview({\n  value,\n  split,\n  empty,\n  renderPreview,\n  className,\n}: {\n  value: string\n  split: boolean\n  empty: string\n  renderPreview?: (value: string) => React.ReactNode\n  className?: string\n}) {\n  const body = value.trim() ? (\n    renderPreview ? (\n      renderPreview(value)\n    ) : (\n      <div className={markdownProseClassName}>\n        <ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>\n      </div>\n    )\n  ) : (\n    <p className=\"text-[13px] text-muted-foreground\">{empty}</p>\n  )\n\n  if (split) {\n    return (\n      <div data-slot=\"markdown-preview\" className=\"anibt-stitch-side relative min-w-0 bg-muted/40 [background-position:0_0]\">\n        <div className=\"absolute inset-0 overflow-y-auto px-4 py-2.5\">{body}</div>\n      </div>\n    )\n  }\n  return (\n    <div data-slot=\"markdown-preview\" className={cn(\"min-w-0 overflow-y-auto px-4 py-2.5\", className)}>\n      {body}\n    </div>\n  )\n}\n\n/**\n * Whether the element is at least `minWidth` wide. False on the server and on\n * the first client render, so both draw the narrow layout; a ResizeObserver\n * answers after mount.\n */\nfunction useContainerAtLeast(ref: React.RefObject<HTMLElement | null>, minWidth: number): boolean {\n  const [atLeast, setAtLeast] = React.useState(false)\n  React.useEffect(() => {\n    const element = ref.current\n    if (!element || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver((entries) => {\n      const width = entries[0]?.contentRect.width ?? element.clientWidth\n      setAtLeast(width >= minWidth)\n    })\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [ref, minWidth])\n  return atLeast\n}\n\nexport { MarkdownEditor, markdownProseClassName }\nexport type { MarkdownEditorLabels, MarkdownEditorProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/anibt/ui/markdown-format.ts",
      "content": "/**\n * `@anibt/markdown-editor`'s formatting commands as pure text edits.\n *\n * A command reads the value and the selection and answers with one edit — the\n * range to replace, the text to put there, and where the selection lands\n * afterwards. The editor applies it through the browser's own text insertion\n * so Ctrl/Cmd+Z still undoes it; nothing here touches the DOM.\n *\n * - Inline marks (加粗 `**`, 斜体 `*`, 代码 `` ` ``) wrap the selection, or\n *   unwrap it when it is already wrapped. Spaces at the selection's edges stay\n *   outside the marks (`** x**` is not bold). With nothing selected the marks\n *   go in as a pair with the caret between them.\n * - Line marks (标题 `## `, 列表 `- `, 引用 `> `) apply to every line the\n *   selection touches, and come off again when every non-empty line has them.\n * - 链接 / 图片 put the selection in the text slot and select `url`; a\n *   selected URL goes in the target slot and the caret waits in the text slot.\n * - 分割线 and a multi-line code block sit on their own lines with a blank line\n *   either side, adding only the newlines that are missing.\n */\nexport type MarkdownAction = \"bold\" | \"italic\" | \"heading\" | \"list\" | \"quote\" | \"link\" | \"image\" | \"code\" | \"divider\"\n\nexport type MarkdownEdit = {\n  /** Start of the replaced range in the old value. */\n  from: number\n  /** End of the replaced range in the old value. */\n  to: number\n  /** Text that replaces `value.slice(from, to)`. */\n  insert: string\n  /** Selection in the new value. */\n  selectionStart: number\n  selectionEnd: number\n}\n\nexport function applyMarkdownEdit(value: string, edit: MarkdownEdit): string {\n  return value.slice(0, edit.from) + edit.insert + value.slice(edit.to)\n}\n\nexport function formatMarkdown(value: string, start: number, end: number, action: MarkdownAction): MarkdownEdit {\n  const from = Math.max(0, Math.min(start, end, value.length))\n  const to = Math.min(value.length, Math.max(start, end))\n  switch (action) {\n    case \"bold\":\n      return toggleInline(value, from, to, \"**\")\n    case \"italic\":\n      return toggleInline(value, from, to, \"*\")\n    case \"code\":\n      return value.slice(from, to).includes(\"\\n\") ? codeBlock(value, from, to) : toggleInline(value, from, to, \"`\")\n    case \"heading\":\n      return toggleLinePrefix(value, from, to, \"## \", /^#{1,6} /u)\n    case \"list\":\n      return toggleLinePrefix(value, from, to, \"- \", /^\\s*[-*+] /u)\n    case \"quote\":\n      return toggleLinePrefix(value, from, to, \"> \", /^> ?/u)\n    case \"link\":\n      return linkLike(value, from, to, \"\")\n    case \"image\":\n      return linkLike(value, from, to, \"!\")\n    case \"divider\":\n      return block(value, from, to, \"---\", \"\")\n  }\n}\n\n/** How many `*` sit directly before `at`, and directly from `at` on. */\nfunction starsBefore(value: string, at: number): number {\n  let count = 0\n  while (at - count - 1 >= 0 && value[at - count - 1] === \"*\") count += 1\n  return count\n}\n\nfunction starsAfter(value: string, at: number): number {\n  let count = 0\n  while (at + count < value.length && value[at + count] === \"*\") count += 1\n  return count\n}\n\n/** Whether a run of `*` of this length holds the mark (`*` = odd run, `**` = 2 or 3). */\nfunction runHolds(run: number, mark: string): boolean {\n  if (mark === \"**\") return run >= 2\n  if (mark === \"*\") return run % 2 === 1\n  return run >= 1\n}\n\nfunction surroundedBy(value: string, from: number, to: number, mark: string): boolean {\n  if (mark.startsWith(\"*\")) {\n    return runHolds(starsBefore(value, from), mark) && runHolds(starsAfter(value, to), mark)\n  }\n  return value.slice(from - mark.length, from) === mark && value.slice(to, to + mark.length) === mark\n}\n\nfunction wrappedInside(selected: string, mark: string): boolean {\n  if (selected.length < mark.length * 2) return false\n  if (mark.startsWith(\"*\")) {\n    let lead = 0\n    while (lead < selected.length && selected[lead] === \"*\") lead += 1\n    let trail = 0\n    while (trail < selected.length - lead && selected[selected.length - 1 - trail] === \"*\") trail += 1\n    return runHolds(lead, mark) && runHolds(trail, mark)\n  }\n  return selected.startsWith(mark) && selected.endsWith(mark)\n}\n\nfunction toggleInline(value: string, from: number, to: number, mark: string): MarkdownEdit {\n  const selected = value.slice(from, to)\n\n  // The marks sit just outside the selection: take them off.\n  if (from >= mark.length && surroundedBy(value, from, to, mark)) {\n    const start = from - mark.length\n    return {\n      from: start,\n      to: to + mark.length,\n      insert: selected,\n      selectionStart: start,\n      selectionEnd: start + selected.length,\n    }\n  }\n\n  // The selection includes its own marks: take them off.\n  if (wrappedInside(selected, mark)) {\n    const inner = selected.slice(mark.length, selected.length - mark.length)\n    return { from, to, insert: inner, selectionStart: from, selectionEnd: from + inner.length }\n  }\n\n  if (!selected) {\n    return { from, to, insert: mark + mark, selectionStart: from + mark.length, selectionEnd: from + mark.length }\n  }\n\n  // Edge spaces stay outside the marks; `** x**` would not render.\n  const lead = selected.length - selected.trimStart().length\n  const trail = selected.length - selected.trimEnd().length\n  const core = selected.slice(lead, selected.length - trail)\n  if (!core) {\n    return { from, to, insert: selected + mark + mark, selectionStart: to + mark.length, selectionEnd: to + mark.length }\n  }\n  const insert = selected.slice(0, lead) + mark + core + mark + selected.slice(selected.length - trail)\n  const coreStart = from + lead + mark.length\n  return { from, to, insert, selectionStart: coreStart, selectionEnd: coreStart + core.length }\n}\n\n/** The whole lines a selection touches: a selection ending at a line's\n * first column does not pull that line in. */\nfunction lineRange(value: string, from: number, to: number): { start: number; end: number } {\n  const start = value.lastIndexOf(\"\\n\", from - 1) + 1\n  const last = to > from && value[to - 1] === \"\\n\" ? to - 1 : to\n  const newline = value.indexOf(\"\\n\", last)\n  return { start, end: newline === -1 ? value.length : newline }\n}\n\nfunction toggleLinePrefix(value: string, from: number, to: number, prefix: string, pattern: RegExp): MarkdownEdit {\n  const range = lineRange(value, from, Math.max(from, to))\n  const lines = value.slice(range.start, range.end).split(\"\\n\")\n  const filled = lines.filter((line) => line.trim() !== \"\")\n  const remove = filled.length > 0 && filled.every((line) => pattern.test(line))\n\n  const next = lines.map((line) => {\n    if (remove) return line.replace(pattern, \"\")\n    if (line.trim() === \"\" && lines.length > 1) return line\n    // A heading replaces another level instead of stacking `## ### `.\n    return prefix + (prefix === \"## \" ? line.replace(/^#{1,6} /u, \"\") : line)\n  })\n  const insert = next.join(\"\\n\")\n\n  if (from === to && lines.length === 1) {\n    const caret = Math.max(range.start, from + (insert.length - (range.end - range.start)))\n    return { from: range.start, to: range.end, insert, selectionStart: caret, selectionEnd: caret }\n  }\n  return {\n    from: range.start,\n    to: range.end,\n    insert,\n    selectionStart: range.start,\n    selectionEnd: range.start + insert.length,\n  }\n}\n\nconst URL_PATTERN = /^(?:https?:\\/\\/|\\/)\\S+$/u\nconst URL_PLACEHOLDER = \"url\"\n\nfunction linkLike(value: string, from: number, to: number, bang: string): MarkdownEdit {\n  const selected = value.slice(from, to)\n  const trimmed = selected.trim()\n  if (trimmed && URL_PATTERN.test(trimmed)) {\n    const insert = `${bang}[](${trimmed})`\n    const caret = from + bang.length + 1\n    return { from, to, insert, selectionStart: caret, selectionEnd: caret }\n  }\n  const insert = `${bang}[${selected}](${URL_PLACEHOLDER})`\n  if (!selected) {\n    const caret = from + bang.length + 1\n    return { from, to, insert, selectionStart: caret, selectionEnd: caret }\n  }\n  const urlStart = from + bang.length + selected.length + 3\n  return { from, to, insert, selectionStart: urlStart, selectionEnd: urlStart + URL_PLACEHOLDER.length }\n}\n\nfunction codeBlock(value: string, from: number, to: number): MarkdownEdit {\n  return block(value, from, to, \"```\", value.slice(from, to))\n}\n\n/**\n * A block on its own lines with a blank line either side: a rule (`---`) or\n * a fence (```` ``` ```` around `body`). Only the newlines that are missing\n * are added.\n */\nfunction block(value: string, from: number, to: number, fence: string, body: string): MarkdownEdit {\n  const before = value.slice(0, from)\n  const after = value.slice(to)\n  const lead = before === \"\" || before.endsWith(\"\\n\\n\") ? \"\" : before.endsWith(\"\\n\") ? \"\\n\" : \"\\n\\n\"\n  const trail = after.startsWith(\"\\n\\n\") ? \"\" : after.startsWith(\"\\n\") ? \"\\n\" : after === \"\" ? \"\\n\" : \"\\n\\n\"\n  const content = fence === \"---\" ? fence : `${fence}\\n${body}\\n${fence}`\n  const insert = lead + content + trail\n  if (fence === \"---\") {\n    const caret = from + insert.length\n    return { from, to, insert, selectionStart: caret, selectionEnd: caret }\n  }\n  const bodyStart = from + lead.length + fence.length + 1\n  return { from, to, insert, selectionStart: bodyStart, selectionEnd: bodyStart + body.length }\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}