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>
|
|
|
|
|
)
|
|
|
|
|
}
|