"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, History, Ellipsis, } from "lucide-react"; import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api"; import { toast } from "@/lib/toast"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import ImageInsertModal from "@/components/ImageInsertModal"; import TimelineEditor from "@/components/TimelineEditor"; import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks"; import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; import { postPath } from "@/lib/urlStyle"; import { useUrlStyle } from "@/components/SiteBrandProvider"; type MarkdownEditorProps = { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; disabled?: boolean; onSubmitShortcut?: () => void; id?: string; /** 挂载后聚焦 textarea(回复/编辑评论等) */ autoFocus?: boolean; /** 正文插图体积上限(MB),缺省 5 */ imageMaxMB?: number; /** 是否提供隐藏可见([hide]) */ allowHide?: boolean; /** * 是否允许「回复可见」。 * 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。 */ allowReplyHide?: boolean; /** default=发帖完整工具栏;comment=评论紧凑栏,低频工具进「更多」 */ density?: "default" | "comment"; /** 输入框可访问名称(占位文案不能代替) */ ariaLabel?: string; /** 底栏左侧提示;缺省按模式生成 */ hint?: string; /** 评论模式快捷键文案,例如「发表」「回复」「保存」 */ shortcutName?: string; /** 底栏右侧操作(发表 / 保存),纳入编辑器边框内 */ footer?: ReactNode; /** 正文字符数上限;设置后计数器显示「已输入 / 上限」并限制输入 */ maxChars?: number; /** 提交失败等原因,显示在编辑器内 */ error?: string; onUploadingChange?: (uploading: boolean) => void; }; type EditorMode = "write" | "preview"; type WrapOpts = { before: string; after?: string; placeholder?: string; linePrefix?: string; }; type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | 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; } } /** 链接弹窗允许的目标:http(s) 完整链接,或站内相对路径(如 /post/123) */ function isLinkTargetUrl(raw: string): boolean { const u = raw.trim(); if (!u) return false; if (isHttpUrl(u)) return true; return u.startsWith("/") && !u.startsWith("//"); } export default function MarkdownEditor({ value, onChange, placeholder, rows = 16, disabled, onSubmitShortcut, id, autoFocus = false, imageMaxMB = 5, allowHide = false, allowReplyHide = true, density = "default", ariaLabel, hint, shortcutName, footer, maxChars, error, onUploadingChange, }: MarkdownEditorProps) { const taRef = useRef(null); const urlStyle = useUrlStyle(); 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 [postQuery, setPostQuery] = useState(""); const [postResults, setPostResults] = useState([]); const [postLoading, setPostLoading] = useState(false); const [linkAdvanced, setLinkAdvanced] = useState(false); const [imageModalOpen, setImageModalOpen] = useState(false); const [codeQuery, setCodeQuery] = useState(""); const [hidePoints, setHidePoints] = useState(10); const [hidePassword, setHidePassword] = useState(""); const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu"); const [toolNarrow, setToolNarrow] = useState(false); const [uploadError, setUploadError] = useState(""); const [modKey, setModKey] = useState("Ctrl"); const retryFile = useRef(null); const moreBtnRef = useRef(null); 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 modeMounted = useRef(false); const isComment = density === "comment"; useEffect(() => { const ua = navigator.userAgent || ""; const platform = navigator.platform || ""; if (/Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS|iPhone|iPad/i.test(ua)) { setModKey("⌘"); } }, []); const onUploadingChangeRef = useRef(onUploadingChange); onUploadingChangeRef.current = onUploadingChange; useEffect(() => { onUploadingChangeRef.current?.(uploading); }, [uploading]); useEffect(() => { if (!isComment) return; const el = toolbarRef.current; if (!el) return; const apply = () => setToolNarrow(el.clientWidth < 520); apply(); const ro = new ResizeObserver(apply); ro.observe(el); return () => ro.disconnect(); }, [isComment]); useEffect(() => { if (!isComment) return; const el = taRef.current; if (!el) return; if (fullscreen || mode !== "write") { el.style.height = ""; return; } const prev = el.scrollTop; el.style.height = "auto"; const next = Math.min(Math.max(el.scrollHeight, 96), 320); el.style.height = `${next}px`; el.scrollTop = prev; }, [value, isComment, fullscreen, mode]); 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 (!modeMounted.current) { modeMounted.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); }, [mode]); useEffect(() => { if (!fullscreen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); // 插图弹窗自管 ESC,不连退全屏 if (imageModalOpen) return; 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, imageModalOpen]); 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") return; e.preventDefault(); const kind = popover; setPopover(null); if (kind === "more") moreBtnRef.current?.focus(); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onEsc); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onEsc); }; }, [popover]); // 链接弹窗打开且展开高级选项时,搜索站内帖子(输入防抖;空关键词给最近发布) useEffect(() => { if (popover !== "link" || !linkAdvanced) return; let cancelled = false; const kw = postQuery.trim(); setPostLoading(true); const t = setTimeout(() => { void apiSearchPosts(kw, 8) .then((res) => { if (cancelled) return; setPostResults(res.posts || []); }) .catch(() => { if (!cancelled) setPostResults([]); }) .finally(() => { if (!cancelled) setPostLoading(false); }); }, kw ? 250 : 0); return () => { cancelled = true; clearTimeout(t); }; }, [popover, postQuery, linkAdvanced]); 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; if (document.activeElement !== el && savedSel.current) { el.setSelectionRange(savedSel.current.start, savedSel.current.end); } 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; retryFile.current = file; setUploadError(""); if (!/^image\/(jpeg|png|webp|gif)$/i.test(file.type)) { setUploadError("仅支持 JPEG / PNG / WebP / GIF"); toast("仅支持 JPEG / PNG / WebP / GIF", "error"); return; } const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024; if (file.size > maxBytes) { const msg = `图片不能超过 ${imageMaxMB}MB`; setUploadError(msg); toast(msg, "error"); return; } setUploading(true); try { const res = await apiUploadPostImage(file); if (!res.url) { const msg = res.error || "上传失败"; setUploadError(msg); toast(msg, "error"); return; } const alt = file.name.replace(/\.[^.]+$/, "") || "图片"; insertAtCursor(`![${alt}](${res.url})`); setMode("write"); setUploadError(""); retryFile.current = null; setImageModalOpen(false); toast("图片已插入", "ok"); } catch { setUploadError("上传失败"); 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://"); setPostQuery(""); setPostResults([]); setLinkAdvanced(false); } if (kind === "code") setCodeQuery(""); if (kind === "hide") { setHideStep("menu"); setHidePoints(10); setHidePassword(""); } setPopover((prev) => (prev === kind ? null : kind)); }; /** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */ const pickPostLink = (p: PostListItem) => { setLinkUrl(postPath(urlStyle, p.id)); setLinkText(p.title); }; const applyLink = () => { const url = linkUrl.trim(); if (!isLinkTargetUrl(url)) { toast("请输入 http(s) 链接或站内路径(如 /post/123)", "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 openImageModal = () => { rememberSel(); setImageModalOpen(true); }; /** 链接 tab 提交:校验后在光标处插入,失败 toast 并留在弹窗 */ const insertImageUrl = (rawUrl: string, rawAlt: string) => { const url = rawUrl.trim(); if (!isHttpUrl(url)) { toast("请输入有效的图片 URL(http/https)", "error"); return; } const alt = rawAlt.trim() || "图片"; insertAtCursor(`![${alt}](${url})`); setImageModalOpen(false); }; 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: "粗体" })} /> {!isComment && ( } onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })} /> )} {!isComment && ( } onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })} /> )} {!isComment && } {!isComment && (
} active={popover === "heading"} onClick={() => openPopover("heading")} /> {popover === "heading" && (
)}
)} {(!isComment || !toolNarrow) && } {(!isComment || !toolNarrow) && ( } onClick={() => runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }) } /> )} {(!isComment || !toolNarrow) && ( } onClick={() => runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }) } /> )} {!isComment && ( } onClick={insertOrderedList} /> )} {!isComment && } onClick={insertTable} />} {!isComment && ( } onClick={() => insertAtCursor("---")} /> )} } onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })} /> {(!isComment || !toolNarrow) && (
} 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" && (
{linkAdvanced && (
setPostQuery(e.target.value)} />
{postLoading ? (

搜索中…

) : postResults.length === 0 ? (

{postQuery.trim() ? "无匹配帖子" : "暂无最近帖子"}

) : ( postResults.map((p) => ( )) )}
)}
)}
} active={imageModalOpen} disabled={uploading} onClick={openImageModal} />
{isComment && (
{popover === "more" && (
{toolNarrow && ( <> )} {toolNarrow && ( )}
)} {toolNarrow && 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 && (

无匹配语言

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