Files
jiang13-bbs/frontend/components/MarkdownEditor.tsx
freefire 31f5360c26 feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话
- Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal)
- 实时总线、账号守卫与用户事件适配
2026-09-26 01:10:10 +08:00

1381 lines
49 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
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<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<EditorMode>("write");
const deferredValue = useDeferredValue(value);
const [uploading, setUploading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [popover, setPopover] = useState<PopoverKind>(null);
const [linkText, setLinkText] = useState("");
const [linkUrl, setLinkUrl] = useState("https://");
// 链接弹窗:站内帖子搜索(空关键词=最近发布),折叠在「高级选项」内
const [postQuery, setPostQuery] = useState("");
const [postResults, setPostResults] = useState<PostListItem[]>([]);
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<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>(
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)$/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) {
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(`/post/${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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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;
}) => (
<button
type="button"
className={`j13-md-tool${active ? " is-active" : ""}`}
data-tip={label}
aria-label={label}
disabled={disabled || dis}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={onClick}
>
{icon}
</button>
);
const Sep = () => <span className="j13-md-sep" aria-hidden />;
return (
<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
label="粗体 Ctrl+B"
icon={<Bold size={15} />}
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
/>
{!isComment && (
<ToolBtn
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
)}
{!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"
icon={<Code2 size={15} />}
active={popover === "code"}
onClick={() => openPopover("code")}
/>
{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>
)}
<Sep />
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="链接 Ctrl+K"
icon={<LinkIcon size={15} />}
active={popover === "link"}
onClick={() => openPopover("link")}
/>
{popover === "link" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入链接">
<label className="j13-md-field">
<span>文字</span>
<input
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
className="j13-md-popover-input"
/>
</label>
<label className="j13-md-field">
<span>URL</span>
<input
autoFocus
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="j13-md-popover-input"
placeholder="https://… 或 /post/123"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
</label>
<button
type="button"
className="j13-md-adv-toggle"
aria-expanded={linkAdvanced}
onClick={() => setLinkAdvanced((v) => !v)}
>
高级选项
<ChevronDown
size={12}
className={`transition-transform${linkAdvanced ? " rotate-180" : ""}`}
/>
</button>
{linkAdvanced && (
<div className="j13-md-link-search">
<input
className="j13-md-popover-input"
placeholder="搜索帖子…"
value={postQuery}
onChange={(e) => setPostQuery(e.target.value)}
/>
<div className="j13-md-popover-list">
{postLoading ? (
<p className="meta px-2 py-3 text-center">搜索中…</p>
) : postResults.length === 0 ? (
<p className="meta px-2 py-3 text-center">
{postQuery.trim() ? "无匹配帖子" : "暂无最近帖子"}
</p>
) : (
postResults.map((p) => (
<button
key={p.id}
type="button"
className="j13-md-post-row"
data-tip={p.title}
onClick={() => pickPostLink(p)}
>
<span className="truncate flex-1">{p.title}</span>
<span className="j13-md-post-tag">帖子</span>
</button>
))
)}
</div>
</div>
)}
<div className="j13-md-popover-footer">
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={applyLink}>
插入
</button>
</div>
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}
icon={<ImageIcon size={15} />}
active={imageModalOpen}
disabled={uploading}
onClick={openImageModal}
/>
</div>
{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
label="隐藏内容"
icon={<Lock size={15} />}
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{popover === "hide" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入隐藏内容">
{hideStep === "menu" ? (
<>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
登录可见
</button>
<button
type="button"
className="j13-md-menu-item"
disabled={!allowReplyHide}
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
aria-disabled={!allowReplyHide}
onClick={() => {
if (!allowReplyHide) return;
insertHide("reply");
}}
>
回复可见
{!allowReplyHide ? (
<span className="j13-md-menu-item-hint">不可用</span>
) : null}
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("points")}
>
积分可见…
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("password")}
>
密码可见…
</button>
</>
) : hideStep === "points" ? (
<>
<label className="j13-md-field">
<span>所需积分</span>
<input
autoFocus
type="number"
min={1}
max={100000}
value={hidePoints}
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (hidePoints >= 1 && hidePoints <= 100000) {
insertHide("points", hidePoints);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
if (hidePoints < 1 || hidePoints > 100000) {
toast("积分须在 1–100000", "error");
return;
}
insertHide("points", hidePoints);
}}
>
插入
</button>
</div>
</>
) : (
<>
<label className="j13-md-field">
<span>查看密码(不含空格,1–64 字)</span>
<input
autoFocus
type="text"
value={hidePassword}
maxLength={64}
placeholder="例如:secret"
onChange={(e) => setHidePassword(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const p = hidePassword.trim();
if (p && !/\s/.test(p)) {
insertHide("password", undefined, p);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
const p = hidePassword.trim();
if (!p || /\s/.test(p) || p.length > 64) {
toast("密码须为 1–64 字符且不含空格", "error");
return;
}
insertHide("password", undefined, p);
}}
>
插入
</button>
</div>
</>
)}
</div>
)}
</div>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="j13-md-mode" role="tablist" aria-label="编辑模式">
<button
type="button"
role="tab"
aria-selected={mode === "write"}
className={mode === "write" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => setMode("write")}
>
<Pencil size={12} /> 编写
</button>
<button
type="button"
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();
rememberCaret();
}}
onClick={toggleFullscreen}
>
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
)}
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => void uploadImageFile(e.target.files?.[0] ?? null)}
/>
<div className={`j13-md-panes is-${mode}`}>
<div
className={`j13-md-write-wrap${dragOver ? " is-dragover" : ""}`}
hidden={mode === "preview"}
onDragEnter={(e) => {
e.preventDefault();
if (e.dataTransfer.types.includes("Files")) setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) void uploadImageFile(file);
}}
>
{dragOver && (
<div className="j13-md-drop-mask" aria-hidden>
放开以上传图片
</div>
)}
<textarea
ref={taRef}
id={id}
value={value}
disabled={disabled}
rows={isComment ? 3 : rows}
maxLength={maxChars}
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"}>
<div className="j13-md-preview-label meta" aria-hidden>
预览
</div>
{deferredValue.trim() ? (
<MarkdownBodyClient content={deferredValue} />
) : (
<p className="meta text-center py-16">暂无内容可预览</p>
)}
</div>
</div>
<div className="j13-md-foot">
<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">
{maxChars != null ? `${value.length} / ${maxChars}` : `${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}
initialBody={timelineBody}
onClose={() => {
setTimelineOpen(false);
setTimelineRange(null);
setTimelineBody("");
}}
onInsert={applyTimelineBlock}
/>
<ImageInsertModal
open={imageModalOpen}
onClose={() => setImageModalOpen(false)}
uploading={uploading}
uploadError={uploadError}
onChooseFile={() => fileRef.current?.click()}
onDropFile={(file) => void uploadImageFile(file)}
onInsertUrl={insertImageUrl}
onPickMedia={(item) => insertAtCursor(`![图片](${item.url})`)}
/>
</div>
);
}