Files
ai-agent/web/components/editor/markdown.tsx
T

172 lines
4.5 KiB
TypeScript
Raw Normal View History

2026-04-09 10:01:23 +08:00
"use client"
import { useRef } from "react"
import {
BoldIcon,
CodeIcon,
Heading1Icon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
} from "lucide-react"
import { Textarea } from "@/components/ui/textarea"
import { EditorToolbar } from "./toolbar"
import type { BaseEditorProps } from "./types"
export type MarkdownEditorProps = BaseEditorProps & {
value: string
onChange: (nextValue: string) => void
rows?: number
}
export function MarkdownEditor({
value,
onChange,
placeholder = ".",
disabled = false,
rows = 16,
className,
}: MarkdownEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const handleWrapSelection = (prefix: string, suffix = prefix) => {
const textarea = textareaRef.current
if (!textarea || disabled) {
return
}
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
const selected = value.slice(start, end)
const next = `${value.slice(0, start)}${prefix}${selected}${suffix}${value.slice(end)}`
onChange(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(start + prefix.length, end + prefix.length)
})
}
const handleInsertLinePrefix = (prefix: string) => {
const textarea = textareaRef.current
if (!textarea || disabled) {
return
}
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
const lineStart = value.lastIndexOf("\n", start - 1) + 1
const lineEndRaw = value.indexOf("\n", end)
const lineEnd = lineEndRaw === -1 ? value.length : lineEndRaw
const selectedLines = value.slice(lineStart, lineEnd)
const nextLines = selectedLines
.split("\n")
.map((line) => `${prefix}${line}`)
.join("\n")
const next = `${value.slice(0, lineStart)}${nextLines}${value.slice(lineEnd)}`
onChange(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart + nextLines.length)
})
}
const handleInsertLink = () => {
const textarea = textareaRef.current
if (!textarea || disabled) {
return
}
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
const selected = value.slice(start, end) || "链接文本"
const markdown = `[${selected}](https://)`
const next = `${value.slice(0, start)}${markdown}${value.slice(end)}`
onChange(next)
requestAnimationFrame(() => {
textarea.focus()
const urlStart = start + markdown.lastIndexOf("https://")
textarea.setSelectionRange(urlStart, urlStart + "https://".length)
})
}
const toolbarActions = [
{
key: "heading1",
label: "一级标题",
icon: Heading1Icon,
disabled,
onClick: () => handleInsertLinePrefix("# "),
},
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
label: "粗体",
icon: BoldIcon,
disabled,
onClick: () => handleWrapSelection("**"),
},
{
key: "italic",
label: "斜体",
icon: ItalicIcon,
disabled,
onClick: () => handleWrapSelection("*"),
},
{
key: "code",
label: "行内代码",
icon: CodeIcon,
disabled,
onClick: () => handleWrapSelection("`"),
},
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
label: "无序列表",
icon: ListIcon,
disabled,
onClick: () => handleInsertLinePrefix("- "),
},
{
key: "orderedList",
label: "有序列表",
icon: ListOrderedIcon,
disabled,
onClick: () => handleInsertLinePrefix("1. "),
},
{
key: "blockquote",
label: "引用",
icon: QuoteIcon,
disabled,
onClick: () => handleInsertLinePrefix("> "),
},
{
key: "link",
label: "链接",
icon: LinkIcon,
disabled,
onClick: handleInsertLink,
},
] as const
return (
<div className="rounded-lg border bg-background">
<EditorToolbar actions={toolbarActions} />
<div className="p-2">
<Textarea
ref={textareaRef}
value={value}
rows={rows}
disabled={disabled}
placeholder={placeholder}
className={`min-h-64 max-h-96 resize-y border-0 px-2 py-2 text-sm leading-7 shadow-none focus-visible:ring-0 ${className ?? ""}`}
onChange={(event) => onChange(event.target.value)}
/>
</div>
</div>
)
}