Files
jiang13-bbs/frontend/components/MarkdownEditor.tsx
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

1903 lines
67 KiB
TypeScript
Raw 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,
Smile,
LayoutList,
Highlighter,
Keyboard,
Palette,
MonitorPlay,
Pipette,
} 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 { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
import EmojiPicker from "@/components/EmojiPicker";
import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks";
import { isValidColorValue } from "@/lib/inlineShortcodes";
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;
/** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */
entriesAuto?: boolean;
/** 图片上传时标记的 source(如 library_content),默认 post */
uploadSource?: string;
};
type EditorMode = "write" | "preview";
type WrapOpts = {
before: string;
after?: string;
placeholder?: string;
linePrefix?: string;
};
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | "color" | "video" | null;
/** 文字颜色弹窗预设色板(均为合法短代码颜色值) */
const COLOR_FG_PRESETS = [
"#e03131", "#e8590c", "#f08c00", "#2f9e44", "#0c8599",
"#1971c2", "#6741d9", "#c2255c", "#1f2328", "#5f6b7a",
];
const COLOR_BG_PRESETS = [
"#fff3bf", "#ffe3e3", "#d3f9d8", "#d0ebff",
"#e5dbff", "#ffd8a8", "#ffff8f", "#e9ecef",
];
type HSV = { h: number; s: number; v: number };
/** #rgb/#rrggbb → HSV;非 hex(如颜色名)返回 null */
function hexToHsv(hex: string): HSV | null {
const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return null;
let x = m[1];
if (x.length === 3) x = x.split("").map((c) => c + c).join("");
const r = parseInt(x.slice(0, 2), 16) / 255;
const g = parseInt(x.slice(2, 4), 16) / 255;
const b = parseInt(x.slice(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s: max === 0 ? 0 : d / max, v: max };
}
/** HSV → #rrggbb */
function hsvToHex({ h, s, v }: HSV): string {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const to = (n: number) =>
Math.max(0, Math.min(255, Math.round((n + m) * 255)))
.toString(16)
.padStart(2, "0");
return `#${to(r)}${to(g)}${to(b)}`;
}
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,
entriesAuto = false,
uploadSource,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle();
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");
/** 颜色弹窗:前景/背景独立选择,可同时设置;null=不设置该侧 */
const [colorFg, setColorFg] = useState<string | null>("#e03131");
const [colorBg, setColorBg] = useState<string | null>(null);
/** 色轮/自定义输入框的当前值(始终 #hex,供 HSV 取色器使用) */
const [colorFgWheel, setColorFgWheel] = useState("#e03131");
const [colorBgWheel, setColorBgWheel] = useState("#fff3bf");
/** 内联 HSV 取色器展开侧;打开期间 colorHsv 为该侧当前色 */
const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null);
const [colorHsv, setColorHsv] = useState<HSV>({ h: 0, s: 1, v: 1 });
const [videoInput, setVideoInput] = useState("");
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(() => {
const el = toolbarRef.current;
if (!el) return;
const apply = () => setToolNarrow(el.clientWidth < (isComment ? 520 : 640));
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]
);
/** 行内插入(无换行 padding):表情等短代码用 */
const insertInline = useCallback(
(text: string) => {
const el = taRef.current;
if (!el) {
onChange(value + text);
return;
}
const start = savedSel.current?.start ?? el.selectionStart;
const end = savedSel.current?.end ?? el.selectionEnd;
const next = value.slice(0, start) + text + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
const caret = start + text.length;
el.setSelectionRange(caret, caret);
});
savedSel.current = null;
},
[value, onChange]
);
const pickEmoji = useCallback(
(key: string) => {
insertInline(`[:${key}:]`);
setPopover(null);
},
[insertInline]
);
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, undefined, uploadSource);
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 === "color") {
setColorFg("#e03131");
setColorBg(null);
setColorFgWheel("#e03131");
setColorBgWheel("#fff3bf");
setActiveWheel(null);
}
if (kind === "video") setVideoInput("");
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);
};
/**
* 颜色短代码插入:前景/背景可独立或组合。
* 两侧都有 → [color=前景 bg=背景]…[/color];仅一侧 → [color] / [bg]。
*/
const applyColor = () => {
const fg = colorFg?.trim();
const bg = colorBg?.trim();
if (!fg && !bg) {
toast("请至少选择前景色或背景色", "error");
return;
}
if ((fg && !isValidColorValue(fg)) || (bg && !isValidColorValue(bg))) {
toast("颜色仅支持 #RGB/#RRGGBB/#RRGGBBAA 或英文颜色名", "error");
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 md: string;
if (fg && bg) md = `[color=${fg} bg=${bg}]${body}[/color]`;
else if (fg) md = `[color=${fg}]${body}[/color]`;
else md = `[bg=${bg}]${body}[/bg]`;
const next = value.slice(0, start) + md + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const caret = start + md.length;
el.setSelectionRange(caret, caret);
});
setPopover(null);
};
/** 内联 HSV 取色器开关:打开时以该侧当前 hex(或上次色轮值)初始化 */
const toggleColorWheel = (side: "fg" | "bg") => {
if (activeWheel === side) {
setActiveWheel(null);
return;
}
const wheelHex = side === "fg" ? colorFgWheel : colorBgWheel;
setColorHsv(hexToHsv(wheelHex) ?? { h: 0, s: 1, v: 1 });
setActiveWheel(side);
};
/** HSV 拖动 → 同步该侧 wheel hex 与选中色 */
const commitHsv = (next: HSV) => {
setColorHsv(next);
const hex = hsvToHex(next);
if (activeWheel === "fg") {
setColorFgWheel(hex);
setColorFg(hex);
} else if (activeWheel === "bg") {
setColorBgWheel(hex);
setColorBg(hex);
}
};
/**
* 饱和度/明度面板拖拽:坐标以真实渲染元素 rect 映射(pointer capture 保证移出仍跟手)。
* 注意:必须在同步阶段取出 currentTarget 存入 el——React 在事件派发结束后会把
* e.currentTarget 置空,延迟回调里再读会拿到 null 导致拖拽中断。
*/
const onSvPointer = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
const update = (clientX: number, clientY: number) => {
const rect = el.getBoundingClientRect();
const s = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const v = 1 - Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
setColorHsv((prev) => {
const next = { ...prev, s, v };
const hex = hsvToHex(next);
if (activeWheel === "fg") {
setColorFgWheel(hex);
setColorFg(hex);
} else if (activeWheel === "bg") {
setColorBgWheel(hex);
setColorBg(hex);
}
return next;
});
};
update(e.clientX, e.clientY);
const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
const cleanup = () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", cleanup);
el.removeEventListener("pointercancel", cleanup);
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", cleanup);
el.addEventListener("pointercancel", cleanup);
};
/** 色相条拖拽(水平);同样在同步阶段缓存元素 */
const onHuePointer = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
const update = (clientX: number) => {
const rect = el.getBoundingClientRect();
const h = Math.max(0, Math.min(360, ((clientX - rect.left) / rect.width) * 360));
commitHsv({ ...colorHsv, h });
};
update(e.clientX);
const onMove = (ev: PointerEvent) => update(ev.clientX);
const cleanup = () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", cleanup);
el.removeEventListener("pointercancel", cleanup);
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", cleanup);
el.addEventListener("pointercancel", cleanup);
};
/** [bilibili]/[youtube] 整行插入:表单裸输入统一走 parseVideoInput */
const applyVideo = () => {
const parsed = parseVideoInput(videoInput);
if (!parsed) {
toast("请输入 B站 BV 号/链接 或 YouTube 视频 ID/链接", "error");
return;
}
restoreSel();
const el = taRef.current;
const start = el?.selectionStart ?? 0;
const end = el?.selectionEnd ?? 0;
const tag = `[${parsed.provider}]${parsed.id}[/${parsed.provider}]`;
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
const inserted = padBefore + tag + padAfter;
const next = value.slice(0, start) + inserted + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const caret = start + inserted.length;
el.setSelectionRange(caret, caret);
});
setPopover(null);
};
/** [details 标题]…[/details] 折叠面板:包住选区或占位正文 */
const insertDetails = () => {
restoreSel();
const el = taRef.current;
const start = el?.selectionStart ?? 0;
const end = el?.selectionEnd ?? 0;
const selected = value.slice(start, end);
const body = selected || "折叠内容";
const open = "[details 点击展开]";
const block = `${open}\n${body}\n[/details]`;
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 codePopover =
popover === "code" ? (
<div
className={`j13-md-popover j13-md-popover-wide${toolNarrow ? " j13-md-popover-center" : ""}`}
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>
) : null;
// 隐藏内容弹层:同上,窄屏时改挂在「更多」锚点下
const hidePopover =
allowHide && popover === "hide" ? (
<div
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
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>
) : null;
// 文字颜色弹层:前景/背景两行独立选择,可同时设置;每行含预设、内联 HSV 取色器与自定义输入。
// 用普通函数调用而非 <ColorPickRow/> 组件:避免内联组件每次渲染重挂载导致输入框失焦。
const renderColorRow = (
side: "fg" | "bg",
label: string,
presets: readonly string[],
picked: string | null
) => {
const setPicked = (v: string | null) =>
side === "fg" ? setColorFg(v) : setColorBg(v);
const wheelOpen = activeWheel === side;
return (
<div className="j13-md-color-row">
<span className="j13-md-color-row-label">{label}</span>
<div className="j13-md-color-row-items">
<button
type="button"
className={`j13-md-color-none${picked === null ? " is-active" : ""}`}
onClick={() => setPicked(null)}
>
无
</button>
{presets.map((c) => (
<button
key={c}
type="button"
className={`j13-md-color-dot${picked?.toLowerCase() === c.toLowerCase() ? " is-active" : ""}`}
style={{ background: c }}
data-tip={c}
aria-label={`使用 ${c}`}
onClick={() => setPicked(c)}
/>
))}
<button
type="button"
className={`j13-md-color-wheel${wheelOpen ? " is-active" : ""}`}
data-tip="自定义颜色"
aria-label="自定义颜色"
aria-expanded={wheelOpen}
onClick={() => toggleColorWheel(side)}
>
<Pipette size={12} />
</button>
<input
className="j13-md-color-hex j13-md-popover-input"
value={picked ?? ""}
placeholder="#e03131 或 tomato"
onChange={(e) => {
const v = e.target.value;
setPicked(v);
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
if (side === "fg") setColorFgWheel(v);
else setColorBgWheel(v);
if (wheelOpen) setColorHsv(hexToHsv(v) ?? colorHsv);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyColor();
}
}}
/>
</div>
{wheelOpen && (
<div className="j13-md-hsv">
<div
className="j13-md-hsv-sv"
style={{
background: `linear-gradient(to top, #000, rgba(0,0,0,0)), linear-gradient(to right, #fff, ${hsvToHex({ h: colorHsv.h, s: 1, v: 1 })})`,
}}
onPointerDown={onSvPointer}
>
<span
className="j13-md-hsv-sv-thumb"
style={{
left: `${colorHsv.s * 100}%`,
top: `${(1 - colorHsv.v) * 100}%`,
}}
/>
</div>
<div className="j13-md-hsv-hue" onPointerDown={onHuePointer}>
<span
className="j13-md-hsv-hue-thumb"
style={{ left: `${(colorHsv.h / 360) * 100}%` }}
/>
</div>
</div>
)}
</div>
);
};
const colorPopover =
popover === "color" ? (
<div
className={`j13-md-popover j13-md-popover-form j13-md-color-popover${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="文字颜色"
>
<div className="j13-md-color-preview">
<span
style={{
color: colorFg ?? "inherit",
backgroundColor: colorBg ?? "transparent",
}}
>
Aa 示例文字
</span>
</div>
{renderColorRow("fg", "前景", COLOR_FG_PRESETS, colorFg)}
{renderColorRow("bg", "背景", COLOR_BG_PRESETS, colorBg)}
<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={() => applyColor()}>
插入
</button>
</div>
</div>
) : null;
// 视频嵌入弹层:裸输入统一交给 parseVideoInput 归一化
const videoPopover =
popover === "video" ? (
<div
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="插入视频"
>
<label className="j13-md-field">
<span>B站 / YouTube</span>
<input
autoFocus
value={videoInput}
onChange={(e) => setVideoInput(e.target.value)}
className="j13-md-popover-input"
placeholder="BV1… / 视频链接 / YouTube ID"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyVideo();
}
}}
/>
</label>
<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={applyVideo}>
插入
</button>
</div>
</div>
) : null;
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" : ""
}${toolNarrow ? " is-narrow" : ""}`}
>
<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 && !toolNarrow && (
<ToolBtn
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="删除线"
icon={<Strikethrough size={15} />}
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="高亮 =="
icon={<Highlighter size={15} />}
onClick={() => runWrap({ before: "==", after: "==", placeholder: "高亮" })}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="键帽"
icon={<Keyboard size={15} />}
onClick={() => runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" })}
/>
)}
{!toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="文字颜色"
icon={<Palette size={15} />}
active={popover === "color"}
onClick={() => openPopover("color")}
/>
{popover === "color" && colorPopover}
</div>
)}
{!isComment && !toolNarrow && <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>
)}
{!toolNarrow && <Sep />}
{!toolNarrow && (
<ToolBtn
label="引用"
icon={<Quote size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" })
}
/>
)}
{!toolNarrow && (
<ToolBtn
label="无序列表"
icon={<List size={15} />}
onClick={() =>
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" })
}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="有序列表"
icon={<ListOrdered size={15} />}
onClick={insertOrderedList}
/>
)}
{!isComment && !toolNarrow && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && !toolNarrow && (
<ToolBtn
label="分割线"
icon={<Minus size={15} />}
onClick={() => insertAtCursor("---")}
/>
)}
<Sep />
<ToolBtn
label="行内代码"
icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/>
{!toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="代码块 Ctrl+Shift+C"
icon={<Code2 size={15} />}
active={popover === "code"}
onClick={() => openPopover("code")}
/>
{codePopover}
</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>
{(isComment || !toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="表情"
icon={<Smile size={15} />}
active={popover === "emoji"}
onClick={() => openPopover("emoji")}
/>
{popover === "emoji" && (
<div className="j13-md-popover j13-md-emoji-popover" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
</div>
)}
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}
icon={<ImageIcon size={15} />}
active={imageModalOpen}
disabled={uploading}
onClick={openImageModal}
/>
</div>
{(isComment || toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<button
ref={moreBtnRef}
type="button"
className={`j13-md-tool${popover === "more" || (toolNarrow && (popover === "code" || popover === "emoji" || popover === "hide")) ? " 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: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" }); setPopover(null); }}>键帽</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("color")}>颜色…</button>
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</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>
{!isComment && allowHide && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("hide")}>隐藏内容</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => insertDetails()}>折叠面板</button>
)}
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("video")}>视频…</button>
)}
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
</div>
)}
{toolNarrow && codePopover}
{toolNarrow && popover === "color" && colorPopover}
{toolNarrow && popover === "video" && videoPopover}
{toolNarrow && popover === "emoji" && !isComment && (
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
{toolNarrow && hidePopover}
</div>
)}
{!isComment && !toolNarrow && <Sep />}
{!isComment && !toolNarrow && (
<ToolBtn
label="时间线"
icon={<History size={15} />}
onClick={openTimelineEditor}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="条目卡"
icon={<LayoutList size={15} />}
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
/>
)}
{!isComment && !toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="视频"
icon={<MonitorPlay size={15} />}
active={popover === "video"}
onClick={() => openPopover("video")}
/>
{popover === "video" && videoPopover}
</div>
)}
{allowHide && !toolNarrow ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="隐藏内容"
icon={<Lock size={15} />}
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{hidePopover}
</div>
) : null}
</div>
<div className="j13-md-toolbar-end 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"}
aria-label="编写模式"
className={mode === "write" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => setMode("write")}
>
<Pencil size={12} /> <span className="j13-md-mode-text">编写</span>
</button>
<button
type="button"
role="tab"
aria-selected={mode === "preview"}
aria-label="预览模式"
className={mode === "preview" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => setMode("preview")}
>
<Eye size={12} /> <span className="j13-md-mode-text">预览</span>
</button>
</div>
{!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,image/gif"
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} entriesAuto={entriesAuto} />
) : (
<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>
);
}