"use client"; import { useRef, useState, useCallback, useEffect, type ReactNode } from "react"; import { Bold, Italic, Code, Quote, List, Heading2, Link as LinkIcon, Image as ImageIcon, Eye, Pencil, Maximize2, Minimize2, } from "lucide-react"; import { apiUploadPostImage } from "@/lib/api"; import { toast } from "@/lib/toast"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; type MarkdownEditorProps = { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; disabled?: boolean; onSubmitShortcut?: () => void; id?: string; }; type WrapOpts = { before: string; after?: string; placeholder?: string; linePrefix?: string; }; function applyWrap( el: HTMLTextAreaElement, value: string, opts: WrapOpts ): { next: string; selStart: number; selEnd: number } { const start = el.selectionStart; const end = el.selectionEnd; const selected = value.slice(start, end); const after = opts.after ?? ""; if (opts.linePrefix) { const block = selected || opts.placeholder || "列表项"; const lines = block.split("\n").map((l) => l.startsWith(opts.linePrefix!) ? l : `${opts.linePrefix}${l}` ); const inserted = lines.join("\n"); const next = value.slice(0, start) + inserted + value.slice(end); return { next, selStart: start, selEnd: start + inserted.length, }; } const inner = selected || opts.placeholder || ""; const inserted = opts.before + inner + after; const next = value.slice(0, start) + inserted + value.slice(end); const selStart = start + opts.before.length; const selEnd = selStart + inner.length; return { next, selStart, selEnd }; } export default function MarkdownEditor({ value, onChange, placeholder, rows = 16, disabled, onSubmitShortcut, id, }: MarkdownEditorProps) { const taRef = useRef(null); const fileRef = useRef(null); const [mode, setMode] = useState<"write" | "preview">("write"); const [uploading, setUploading] = useState(false); const [fullscreen, setFullscreen] = useState(false); useEffect(() => { if (!fullscreen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); setFullscreen(false); } }; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); }; }, [fullscreen]); const runWrap = useCallback( (opts: WrapOpts) => { const el = taRef.current; if (!el || disabled) return; const { next, selStart, selEnd } = applyWrap(el, value, opts); onChange(next); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(selStart, selEnd); }); }, [value, onChange, disabled] ); const insertAtCursor = useCallback( (text: string) => { const el = taRef.current; if (!el) { onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text); return; } const start = el.selectionStart; const end = el.selectionEnd; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + text + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); const caret = start + inserted.length; requestAnimationFrame(() => { el.focus(); el.setSelectionRange(caret, caret); }); }, [value, onChange] ); const handleImagePick = async (file: File | null) => { if (!file || disabled) return; if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) { toast("仅支持 JPEG / PNG / WebP", "error"); return; } if (file.size > 5 * 1024 * 1024) { toast("图片不能超过 5MB", "error"); return; } setUploading(true); try { const res = await apiUploadPostImage(file); if (!res.url) { toast(res.error || "上传失败", "error"); return; } const alt = file.name.replace(/\.[^.]+$/, "") || "图片"; insertAtCursor(`![${alt}](${res.url})`); setMode("write"); toast("图片已插入", "ok"); } catch { toast("上传失败", "error"); } finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; } }; const tools: { key: string; label: string; icon: ReactNode; onClick: () => void; }[] = [ { key: "bold", label: "粗体", icon: , onClick: () => runWrap({ before: "**", after: "**", placeholder: "粗体" }), }, { key: "italic", label: "斜体", icon: , onClick: () => runWrap({ before: "*", after: "*", placeholder: "斜体" }), }, { key: "code", label: "行内代码", icon: , onClick: () => runWrap({ before: "`", after: "`", placeholder: "code" }), }, { key: "quote", label: "引用", icon: , onClick: () => runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }), }, { key: "list", label: "列表", icon: , onClick: () => runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }), }, { key: "h2", label: "二级标题", icon: , onClick: () => runWrap({ before: "## ", after: "", placeholder: "标题" }), }, { key: "link", label: "链接", icon: , onClick: () => runWrap({ before: "[", after: "](https://)", placeholder: "链接文字" }), }, { key: "image", label: uploading ? "上传中…" : "插入图片", icon: , onClick: () => fileRef.current?.click(), }, ]; return (
{tools.map((t) => ( ))}
handleImagePick(e.target.files?.[0] ?? null)} /> {mode === "write" ? (