"use client"; import { useRef, useState, useCallback, useEffect, useDeferredValue, type ReactNode, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { Bold, Italic, Strikethrough, Code, Code2, Quote, List, ListOrdered, Heading2, Heading3, Link as LinkIcon, Image as ImageIcon, Table, Minus, Eye, Pencil, Maximize2, Minimize2, Lock, ChevronDown, Upload, Link2, History, } from "lucide-react"; import { apiUploadPostImage } from "@/lib/api"; import { toast } from "@/lib/toast"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import TimelineEditor from "@/components/TimelineEditor"; import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks"; import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; type MarkdownEditorProps = { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; disabled?: boolean; onSubmitShortcut?: () => void; id?: string; /** 正文插图体积上限(MB),缺省 5 */ imageMaxMB?: number; /** 是否提供隐藏可见([hide]) */ allowHide?: boolean; /** * 是否允许「回复可见」。 * 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。 */ allowReplyHide?: boolean; }; type EditorMode = "write" | "preview"; type WrapOpts = { before: string; after?: string; placeholder?: string; linePrefix?: string; }; type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | null; const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const; 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 }; } function isHttpUrl(raw: string): boolean { try { const u = new URL(raw.trim()); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } } export default function MarkdownEditor({ value, onChange, placeholder, rows = 16, disabled, onSubmitShortcut, id, imageMaxMB = 5, allowHide = false, allowReplyHide = true, }: MarkdownEditorProps) { const taRef = useRef(null); const fileRef = useRef(null); const toolbarRef = useRef(null); const [mode, setMode] = useState("write"); const deferredValue = useDeferredValue(value); const [uploading, setUploading] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [dragOver, setDragOver] = useState(false); const [popover, setPopover] = useState(null); const [linkText, setLinkText] = useState(""); const [linkUrl, setLinkUrl] = useState("https://"); const [imageUrl, setImageUrl] = useState("https://"); const [imageAlt, setImageAlt] = useState("图片"); const [codeQuery, setCodeQuery] = useState(""); const [hidePoints, setHidePoints] = useState(10); const [hidePassword, setHidePassword] = useState(""); const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu"); const [timelineOpen, setTimelineOpen] = useState(false); const [timelineBody, setTimelineBody] = useState(""); const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>( null ); const savedSel = useRef<{ start: number; end: number } | null>(null); const fullscreenMounted = useRef(false); const rememberCaret = () => { const el = taRef.current; if (!el) return; savedSel.current = { start: el.selectionStart, end: el.selectionEnd }; }; const toggleFullscreen = () => { rememberCaret(); setFullscreen((v) => !v); }; useEffect(() => { if (!fullscreenMounted.current) { fullscreenMounted.current = true; return; } if (mode !== "write") return; const sel = savedSel.current; const el = taRef.current; if (!el || !sel) return; // 进出全屏后把焦点和光标放回退出前的位置 const frame = requestAnimationFrame(() => { el.focus(); const max = el.value.length; el.setSelectionRange(Math.min(sel.start, max), Math.min(sel.end, max)); }); return () => cancelAnimationFrame(frame); }, [fullscreen]); useEffect(() => { if (!fullscreen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); if (popover) setPopover(null); else { rememberCaret(); 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, popover]); useEffect(() => { if (!popover) return; const onDoc = (e: MouseEvent) => { const t = e.target as Node; if (toolbarRef.current && !toolbarRef.current.contains(t)) { setPopover(null); } }; const onEsc = (e: KeyboardEvent) => { if (e.key === "Escape") setPopover(null); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onEsc); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onEsc); }; }, [popover]); const rememberSel = () => { const el = taRef.current; if (!el) return; savedSel.current = { start: el.selectionStart, end: el.selectionEnd }; }; const restoreSel = () => { const el = taRef.current; const sel = savedSel.current; if (!el || !sel) return; el.focus(); el.setSelectionRange(sel.start, sel.end); }; 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, selectInner?: { offset: number; length: number }) => { const el = taRef.current; if (!el) { onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text); return; } const start = savedSel.current?.start ?? el.selectionStart; const end = savedSel.current?.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); requestAnimationFrame(() => { el.focus(); if (selectInner) { const a = start + padBefore.length + selectInner.offset; el.setSelectionRange(a, a + selectInner.length); } else { const caret = start + inserted.length; el.setSelectionRange(caret, caret); } }); savedSel.current = null; }, [value, onChange] ); const uploadImageFile = async (file: File | null) => { if (!file || disabled) return; if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) { toast("仅支持 JPEG / PNG / WebP", "error"); return; } const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024; if (file.size > maxBytes) { toast(`图片不能超过 ${imageMaxMB}MB`, "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 openPopover = (kind: PopoverKind) => { rememberSel(); if (kind === "link") { const el = taRef.current; const selected = el ? value.slice(el.selectionStart, el.selectionEnd) : ""; setLinkText(selected || "链接文字"); setLinkUrl("https://"); } if (kind === "imageUrl") { setImageUrl("https://"); setImageAlt("图片"); } if (kind === "code") setCodeQuery(""); if (kind === "hide") { setHideStep("menu"); setHidePoints(10); setHidePassword(""); } setPopover((prev) => (prev === kind ? null : kind)); }; const applyLink = () => { const url = linkUrl.trim(); if (!isHttpUrl(url)) { toast("请输入有效的 http(s) 链接", "error"); return; } const el = taRef.current; restoreSel(); const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const label = (selected || linkText.trim() || url).trim() || url; const md = `[${label}](${url})`; const next = value.slice(0, start) + md + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); el.setSelectionRange(start + md.length, start + md.length); }); setPopover(null); }; const applyImageUrl = () => { const url = imageUrl.trim(); if (!isHttpUrl(url)) { toast("请输入有效的图片 URL(http/https)", "error"); return; } const alt = imageAlt.trim() || "图片"; insertAtCursor(`![${alt}](${url})`); setPopover(null); }; const insertCodeBlock = (lang: string) => { const el = taRef.current; restoreSel(); const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const body = selected || "代码"; const fence = "```" + lang + "\n" + body + "\n```"; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + fence + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const bodyStart = start + padBefore.length + 3 + lang.length + 1; el.setSelectionRange(bodyStart, bodyStart + body.length); }); setPopover(null); }; const insertHide = ( kind: "login" | "reply" | "points" | "password", points?: number, password?: string ) => { if (!allowHide) return; restoreSel(); const el = taRef.current; const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const body = selected || "在此填写隐藏内容"; let open = `[hide ${kind}]`; if (kind === "points") { open = `[hide points=${points || 10}]`; } else if (kind === "password") { open = `[hide password=${password || "password"}]`; } const block = `${open}\n${body}\n[/hide]`; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + block + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const bodyStart = start + padBefore.length + open.length + 1; el.setSelectionRange(bodyStart, bodyStart + body.length); }); setPopover(null); }; const openTimelineEditor = () => { rememberSel(); const el = taRef.current; const cursor = el?.selectionStart ?? savedSel.current?.start ?? 0; const found = resolveTimelineBlockForEdit(value, cursor); if (found) { setTimelineBody(found.body); setTimelineRange({ start: found.start, end: found.end }); } else { setTimelineBody(""); setTimelineRange(null); } setPopover(null); setTimelineOpen(true); }; const applyTimelineBlock = (block: string) => { const el = taRef.current; if (timelineRange) { const next = value.slice(0, timelineRange.start) + block + value.slice(timelineRange.end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const pos = timelineRange.start + block.length; el.setSelectionRange(pos, pos); }); return; } restoreSel(); const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? start; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + block + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const pos = start + inserted.length; el.setSelectionRange(pos, pos); }); }; const insertOrderedList = () => { const el = taRef.current; if (!el || disabled) return; const start = el.selectionStart; const end = el.selectionEnd; const selected = value.slice(start, end) || "列表项"; const lines = selected.split("\n").map((l, i) => { if (/^\d+\.\s/.test(l)) return l; return `${i + 1}. ${l}`; }); const inserted = lines.join("\n"); const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(start, start + inserted.length); }); }; const insertTable = () => { insertAtCursor( "| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n| | | |" ); }; const onTextareaKeyDown = (e: ReactKeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { // 输入法组字期间的 Enter 不是提交 if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; e.preventDefault(); onSubmitShortcut?.(); return; } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "b") { e.preventDefault(); runWrap({ before: "**", after: "**", placeholder: "粗体" }); return; } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "i") { e.preventDefault(); runWrap({ before: "*", after: "*", placeholder: "斜体" }); return; } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "k") { e.preventDefault(); openPopover("link"); return; } if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") { e.preventDefault(); openPopover("code"); return; } }; const onPaste = async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (items) { for (const item of items) { if (item.type.startsWith("image/")) { e.preventDefault(); const file = item.getAsFile(); await uploadImageFile(file); return; } } } const text = e.clipboardData?.getData("text/plain")?.trim() || ""; if (text && isHttpUrl(text)) { const el = taRef.current; if (el && el.selectionStart !== el.selectionEnd) { e.preventDefault(); const start = el.selectionStart; const end = el.selectionEnd; const selected = value.slice(start, end); const md = `[${selected}](${text})`; const next = value.slice(0, start) + md + value.slice(end); onChange(next); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(start + md.length, start + md.length); }); } } }; const filteredLangs = CODE_LANGS.filter((l) => l.toLowerCase().includes(codeQuery.trim().toLowerCase()) ); const ToolBtn = ({ label, icon, onClick, active, disabled: dis, }: { label: string; icon: ReactNode; onClick: () => void; active?: boolean; disabled?: boolean; }) => ( ); const Sep = () => ; return (
} onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })} /> } onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })} /> } onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })} />
} active={popover === "heading"} onClick={() => openPopover("heading")} /> {popover === "heading" && (
)}
} onClick={() => runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }) } /> } onClick={() => runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }) } /> } onClick={insertOrderedList} /> } onClick={insertTable} /> } onClick={() => insertAtCursor("---")} /> } onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })} />
} active={popover === "code"} onClick={() => openPopover("code")} /> {popover === "code" && (
setCodeQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && filteredLangs[0]) { e.preventDefault(); insertCodeBlock(filteredLangs[0]); } }} />
{filteredLangs.map((lang) => ( ))} {filteredLangs.length === 0 && (

无匹配语言

)}
)}
} active={popover === "link"} onClick={() => openPopover("link")} /> {popover === "link" && (
)}
} active={popover === "image" || popover === "imageUrl"} disabled={uploading} onClick={() => openPopover("image")} /> {popover === "image" && (
)} {popover === "imageUrl" && (
)}
} onClick={openTimelineEditor} /> {allowHide ? (
} active={popover === "hide"} onClick={() => openPopover("hide")} /> {popover === "hide" && (
{hideStep === "menu" ? ( <> ) : hideStep === "points" ? ( <>
) : ( <>
)}
)}
) : null}
void uploadImageFile(e.target.files?.[0] ?? null)} />