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

173 lines
4.6 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"
2026-05-25 12:06:15 +08:00
import { useI18n } from "@/i18n/provider"
2026-04-09 10:01:23 +08:00
export type MarkdownEditorProps = BaseEditorProps & {
value: string
onChange: (nextValue: string) => void
rows?: number
}
export function MarkdownEditor({
value,
onChange,
placeholder = ".",
disabled = false,
rows = 16,
className,
}: MarkdownEditorProps) {
2026-05-25 12:06:15 +08:00
const t = useI18n()
2026-04-09 10:01:23 +08:00
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
2026-05-25 12:06:15 +08:00
const selected = value.slice(start, end) || t("editor.linkText")
2026-04-09 10:01:23 +08:00
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",
2026-05-25 12:06:15 +08:00
label: t("editor.heading1"),
2026-04-09 10:01:23 +08:00
icon: Heading1Icon,
disabled,
onClick: () => handleInsertLinePrefix("# "),
},
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
2026-05-25 12:06:15 +08:00
label: t("editor.bold"),
2026-04-09 10:01:23 +08:00
icon: BoldIcon,
disabled,
onClick: () => handleWrapSelection("**"),
},
{
key: "italic",
2026-05-25 12:06:15 +08:00
label: t("editor.italic"),
2026-04-09 10:01:23 +08:00
icon: ItalicIcon,
disabled,
onClick: () => handleWrapSelection("*"),
},
{
key: "code",
2026-05-25 12:06:15 +08:00
label: t("editor.inlineCode"),
2026-04-09 10:01:23 +08:00
icon: CodeIcon,
disabled,
onClick: () => handleWrapSelection("`"),
},
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
2026-05-25 12:06:15 +08:00
label: t("editor.bulletList"),
2026-04-09 10:01:23 +08:00
icon: ListIcon,
disabled,
onClick: () => handleInsertLinePrefix("- "),
},
{
key: "orderedList",
2026-05-25 12:06:15 +08:00
label: t("editor.orderedList"),
2026-04-09 10:01:23 +08:00
icon: ListOrderedIcon,
disabled,
onClick: () => handleInsertLinePrefix("1. "),
},
{
key: "blockquote",
2026-05-25 12:06:15 +08:00
label: t("editor.quote"),
2026-04-09 10:01:23 +08:00
icon: QuoteIcon,
disabled,
onClick: () => handleInsertLinePrefix("> "),
},
{
key: "link",
2026-05-25 12:06:15 +08:00
label: t("editor.link"),
2026-04-09 10:01:23 +08:00
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>
)
}