AniBTUI
✦ 组件@anibt/markdown-editorregistry JSON

Markdown Editor

收 Markdown 的唯一编辑面:编写 / 分栏 / 预览,九个格式工具和快捷键,预览用 A 的正文样式,带字数。

支持 Markdown188 / 2000

安装

npx shadcn@latest add @anibt/markdown-editor

第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。

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

用法

components/release-notes-field.tsx
import { useState } from "react"

import { MarkdownEditor } from "@/components/ui/markdown-editor"

export function ReleaseNotesField() {
  const [notes, setNotes] = useState("")
  return (
    <>
      <label id="notes-label" htmlFor="notes">发布说明</label>
      <MarkdownEditor
        id="notes"
        aria-labelledby="notes-label"
        value={notes}
        onChange={setNotes}
        maxLength={2000}
      />
    </>
  )
}

结构

  • 一块纸面:控件边包住头、文本区和尾;文本区聚焦时整块吃 A 的焦点(1.5px 粉边加粉色光晕),校验失败是红边。
  • 视图:「编写 / 预览」是分段控件。编辑器宽度 ≥720px 时多一个「分栏」并默认分栏:左边写、右边预览,预览在文本区的高度里滚动。宽度在挂载后测量,服务端和首次渲染都画窄版,不会出现水合不一致。
  • 工具栏:加粗、斜体、标题、列表、引用、链接、图片、代码、分割线,28px 安静图标方块,悬停有提示。Ctrl/Cmd+B、I、K 是加粗、斜体、链接。每个命令都是纯文本编辑(markdown-format.ts),通过浏览器自己的插入生效,Ctrl/Cmd+Z 能撤销;输入法组字时不触发。
  • 预览:默认用 react-markdown + GFM 渲染成 A 的正文:标题逐级变小但不小于 12、粉色链接、粉色淡底加粉色竖线的引用、缝线分割线、下沉底的代码、缝线表格。不渲染原始 HTML。
  • 高度:文本区随内容长高,sm 120–320、md 180–440、lg 240–560,超过上限滚动,还能手动拖高。
  • 字数:尾部右端是 n 字;有 maxLength 时是 n / max,接近上限变琥珀、超过变红。

窄的编辑器

每条新发布都会带上这段说明63 / 160

窄于 720px 时只有「编写」和「预览」两个视图。点「预览」看渲染结果:工具栏隐藏但保留位置,切换时头部不跳;窄到工具栏要单独占一行时(约 480px 以下)它直接收起,不留一条空白。

自己的渲染器

站点已经有 Markdown 渲染器时,用 renderPreview 接管预览,保证预览和正式页面一致。A 的正文样式单独导出为 markdownProseClassName:

接管预览
import { MarkdownEditor, markdownProseClassName } from "@/components/ui/markdown-editor"

<MarkdownEditor
  value={notes}
  onChange={setNotes}
  renderPreview={(value) => <SiteMarkdown className={markdownProseClassName} source={value} />}
/>

markdown-format.ts 里的 formatMarkdown(value, start, end, action) 和 applyMarkdownEdit(value, edit) 是纯函数,也可以单独用在别的输入框上。

属性

MarkdownEditor
value / onChangestring / (value: string) => void默认 —受控的文本
size"sm" | "md" | "lg"默认 "md"文本区的最低和最高高度
maxLengthnumber默认 —最多多少字;字数显示 n / max
showCountboolean默认 true尾部的字数
hintReactNode默认 "支持 Markdown"尾部左边的说明
defaultView"write" | "split" | "preview"默认 "split"初始视图;窄的编辑器没有分栏,从编写开始
previewValuestring默认 —预览这段文字而不是 value(模板的实际效果)
renderPreview(value: string) => ReactNode默认 —自己的预览渲染器
labelsPartial<MarkdownEditorLabels>默认 —替换任何一段界面文字(默认中文)
placeholder / disabled / idstring / boolean / string默认 —透传给文本区
aria-label / aria-labelledby / aria-describedby / aria-invalidstring默认 —文本区的名称、说明和校验状态