评论编辑器改为紧凑底栏与精简工具栏,评论操作收进更多菜单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 00:56:53 +08:00
parent c0462a3500
commit acde7ee288
4 changed files with 1182 additions and 526 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import {
Upload,
Link2,
History,
Ellipsis,
} from "lucide-react";
import { apiUploadPostImage } from "@/lib/api";
import { toast } from "@/lib/toast";
@@ -60,6 +61,19 @@ type MarkdownEditorProps = {
* 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。
*/
allowReplyHide?: boolean;
/** default=发帖完整工具栏;comment=评论紧凑栏,低频工具进「更多」 */
density?: "default" | "comment";
/** 输入框可访问名称(占位文案不能代替) */
ariaLabel?: string;
/** 底栏左侧提示;缺省按模式生成 */
hint?: string;
/** 评论模式快捷键文案,例如「发表」「回复」「保存」 */
shortcutName?: string;
/** 底栏右侧操作(发表 / 保存),纳入编辑器边框内 */
footer?: ReactNode;
/** 提交失败等原因,显示在编辑器内 */
error?: string;
onUploadingChange?: (uploading: boolean) => void;
};
type EditorMode = "write" | "preview";
@@ -71,7 +85,7 @@ type WrapOpts = {
linePrefix?: string;
};
type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | null;
type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | "more" | null;
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
@@ -124,6 +138,13 @@ export default function MarkdownEditor({
imageMaxMB = 5,
allowHide = false,
allowReplyHide = true,
density = "default",
ariaLabel,
hint,
shortcutName,
footer,
error,
onUploadingChange,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
@@ -142,6 +163,11 @@ export default function MarkdownEditor({
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<File | null>(null);
const moreBtnRef = useRef<HTMLButtonElement>(null);
const [timelineOpen, setTimelineOpen] = useState(false);
const [timelineBody, setTimelineBody] = useState("");
const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>(
@@ -149,6 +175,48 @@ export default function MarkdownEditor({
);
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;
@@ -179,6 +247,23 @@ export default function MarkdownEditor({
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) => {
@@ -209,7 +294,11 @@ export default function MarkdownEditor({
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setPopover(null);
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);
@@ -237,6 +326,9 @@ export default function MarkdownEditor({
(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(() => {
@@ -278,27 +370,37 @@ export default function MarkdownEditor({
const uploadImageFile = async (file: File | null) => {
if (!file || disabled) return;
retryFile.current = file;
setUploadError("");
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
setUploadError("仅支持 JPEG / PNG / WebP");
toast("仅支持 JPEG / PNG / WebP", "error");
return;
}
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
if (file.size > maxBytes) {
toast(`图片不能超过 ${imageMaxMB}MB`, "error");
const msg = `图片不能超过 ${imageMaxMB}MB`;
setUploadError(msg);
toast(msg, "error");
return;
}
setUploading(true);
try {
const res = await apiUploadPostImage(file);
if (!res.url) {
toast(res.error || "上传失败", "error");
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;
toast("图片已插入", "ok");
} catch {
setUploadError("上传失败");
toast("上传失败", "error");
} finally {
setUploading(false);
@@ -570,6 +672,10 @@ export default function MarkdownEditor({
data-tip={label}
aria-label={label}
disabled={disabled || dis}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={onClick}
>
{icon}
@@ -579,7 +685,7 @@ export default function MarkdownEditor({
const Sep = () => <span className="j13-md-sep" aria-hidden />;
return (
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}`}>
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${isComment ? " is-comment" : ""}`}>
<div className="j13-md-toolbar" ref={toolbarRef}>
<div className="j13-md-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
<ToolBtn
@@ -587,86 +693,103 @@ export default function MarkdownEditor({
icon={<Bold size={15} />}
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
/>
<ToolBtn
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
<ToolBtn
label="删除线"
icon={<Strikethrough size={15} />}
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
<Sep />
<div className="j13-md-popover-anchor relative">
{!isComment && (
<ToolBtn
label="标题"
icon={
<span className="inline-flex items-center gap-0.5">
<Heading2 size={15} />
<ChevronDown size={11} />
</span>
}
active={popover === "heading"}
onClick={() => openPopover("heading")}
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
{popover === "heading" && (
<div className="j13-md-popover" role="menu">
<button
type="button"
role="menuitem"
onClick={() => {
runWrap({ before: "## ", after: "", placeholder: "二级标题" });
setPopover(null);
}}
>
<Heading2 size={14} /> 二级标题
</button>
<button
type="button"
role="menuitem"
onClick={() => {
runWrap({ before: "### ", after: "", placeholder: "三级标题" });
setPopover(null);
}}
>
<Heading3 size={14} /> 三级标题
</button>
</div>
)}
</div>
<Sep />
<ToolBtn
label="引用"
icon={<Quote size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" })
}
/>
<ToolBtn
label="无序列表"
icon={<List size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" })
}
/>
<ToolBtn
label="有序列表"
icon={<ListOrdered size={15} />}
onClick={insertOrderedList}
/>
<ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />
<ToolBtn
label="分割线"
icon={<Minus size={15} />}
onClick={() => insertAtCursor("---")}
/>
)}
{!isComment && (
<ToolBtn
label="删除线"
icon={<Strikethrough size={15} />}
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
)}
{!isComment && <Sep />}
{!isComment && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="标题"
icon={
<span className="inline-flex items-center gap-0.5">
<Heading2 size={15} />
<ChevronDown size={11} />
</span>
}
active={popover === "heading"}
onClick={() => openPopover("heading")}
/>
{popover === "heading" && (
<div className="j13-md-popover" role="menu">
<button
type="button"
role="menuitem"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runWrap({ before: "## ", after: "", placeholder: "二级标题" });
setPopover(null);
}}
>
<Heading2 size={14} /> 二级标题
</button>
<button
type="button"
role="menuitem"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
runWrap({ before: "### ", after: "", placeholder: "三级标题" });
setPopover(null);
}}
>
<Heading3 size={14} /> 三级标题
</button>
</div>
)}
</div>
)}
{(!isComment || !toolNarrow) && <Sep />}
{(!isComment || !toolNarrow) && (
<ToolBtn
label="引用"
icon={<Quote size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" })
}
/>
)}
{(!isComment || !toolNarrow) && (
<ToolBtn
label="无序列表"
icon={<List size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" })
}
/>
)}
{!isComment && (
<ToolBtn
label="有序列表"
icon={<ListOrdered size={15} />}
onClick={insertOrderedList}
/>
)}
{!isComment && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && (
<ToolBtn
label="分割线"
icon={<Minus size={15} />}
onClick={() => insertAtCursor("---")}
/>
)}
<Sep />
<ToolBtn
label="行内代码"
icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/>
{(!isComment || !toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="代码块 Ctrl+Shift+C"
@@ -706,6 +829,7 @@ export default function MarkdownEditor({
</div>
)}
</div>
)}
<Sep />
<div className="j13-md-popover-anchor relative">
<ToolBtn
@@ -816,12 +940,84 @@ export default function MarkdownEditor({
</div>
)}
</div>
<Sep />
{isComment && (
<div className="j13-md-popover-anchor relative">
<button
ref={moreBtnRef}
type="button"
className={`j13-md-tool${popover === "more" || (toolNarrow && popover === "code") ? " is-active" : ""}`}
data-tip="更多工具"
aria-label="更多工具"
aria-haspopup="menu"
aria-expanded={popover === "more"}
disabled={disabled}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => openPopover("more")}
>
<Ellipsis size={15} />
</button>
{popover === "more" && (
<div className="j13-md-popover" role="menu" aria-label="更多工具">
{toolNarrow && (
<>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }); setPopover(null); }}>引用</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }); setPopover(null); }}>无序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("code")}>代码块</button>
</>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "## ", after: "", placeholder: "二级标题" }); setPopover(null); }}>二级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "### ", after: "", placeholder: "三级标题" }); setPopover(null); }}>三级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertOrderedList(); setPopover(null); }}>有序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertTable(); setPopover(null); }}>表格</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor("---"); setPopover(null); }}>分隔线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
</div>
)}
{toolNarrow && popover === "code" && (
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button key={lang} type="button" onClick={() => insertCodeBlock(lang)}>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
</div>
)}
</div>
)}
{!isComment && <Sep />}
{!isComment && (
<ToolBtn
label="时间线"
icon={<History size={15} />}
onClick={openTimelineEditor}
/>
)}
{allowHide ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
@@ -973,6 +1169,10 @@ export default function MarkdownEditor({
role="tab"
aria-selected={mode === "write"}
className={mode === "write" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => setMode("write")}
>
<Pencil size={12} /> 编写
@@ -982,21 +1182,30 @@ export default function MarkdownEditor({
role="tab"
aria-selected={mode === "preview"}
className={mode === "preview" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => setMode("preview")}
>
<Eye size={12} /> 预览
</button>
</div>
{(!isComment || !toolNarrow) && (
<button
type="button"
className="j13-md-tool"
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
aria-label={fullscreen ? "退出全屏" : "全屏写作"}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={toggleFullscreen}
>
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
)}
</div>
</div>
@@ -1037,14 +1246,16 @@ export default function MarkdownEditor({
id={id}
value={value}
disabled={disabled}
rows={rows}
rows={isComment ? 3 : rows}
placeholder={placeholder}
aria-label={ariaLabel || placeholder || "正文"}
className="j13-md-textarea"
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onTextareaKeyDown}
onPaste={(e) => void onPaste(e)}
onSelect={rememberSel}
onBlur={rememberCaret}
/>
</div>
<div className="j13-md-preview" hidden={mode === "write"}>
@@ -1060,9 +1271,43 @@ export default function MarkdownEditor({
</div>
<div className="j13-md-foot">
<span>支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交</span>
<span className="tabular-nums">{value.length} 字</span>
<span className="j13-md-foot-hint">
{hint ??
(isComment
? `支持 Markdown · ${modKey}+Enter ${shortcutName || "发表"}`
: "支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交")}
</span>
<span className="j13-md-foot-actions">
<span className="j13-md-count tabular-nums">{value.length} 字</span>
{footer}
</span>
</div>
{(uploading || uploadError) && (
<p className="j13-md-upload" role={uploadError ? "alert" : "status"}>
{uploading ? "图片上传中…" : uploadError}
{uploadError && !uploading ? (
<>
<button type="button" onClick={() => void uploadImageFile(retryFile.current)}>
重试
</button>
<button
type="button"
onClick={() => {
setUploadError("");
retryFile.current = null;
}}
>
关闭
</button>
</>
) : null}
</p>
)}
{error ? (
<p className="j13-md-error" role="alert">
{error}
</p>
) : null}
<TimelineEditor
open={timelineOpen}