Files
jiang13-bbs/frontend/components/MarkdownEditor.tsx
freefire c44b0efa7d feat(hide): 密码可见隐藏块,发帖双栏预览与门禁体验修复
增加密码解锁与游客签名 cookie;发帖页对齐 1440 并默认双栏预览;修复 locked 解析、按钮对比度与回复聚焦。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 05:46:42 +08:00

952 lines
32 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,
Columns2,
Lock,
ChevronDown,
Upload,
Link2,
} from "lucide-react";
import { apiUploadPostImage } from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
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;
};
type EditorMode = "split" | "write" | "preview";
type WrapOpts = {
before: string;
after?: string;
placeholder?: string;
linePrefix?: string;
};
type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | null;
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
function applyWrap(
el: HTMLTextAreaElement,
value: string,
opts: WrapOpts
): { next: string; selStart: number; selEnd: number } {
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = value.slice(start, end);
const after = opts.after ?? "";
if (opts.linePrefix) {
const block = selected || opts.placeholder || "列表项";
const lines = block.split("\n").map((l) =>
l.startsWith(opts.linePrefix!) ? l : `${opts.linePrefix}${l}`
);
const inserted = lines.join("\n");
const next = value.slice(0, start) + inserted + value.slice(end);
return { next, selStart: start, selEnd: start + inserted.length };
}
const inner = selected || opts.placeholder || "";
const inserted = opts.before + inner + after;
const next = value.slice(0, start) + inserted + value.slice(end);
const selStart = start + opts.before.length;
const selEnd = selStart + inner.length;
return { next, selStart, selEnd };
}
function isHttpUrl(raw: string): boolean {
try {
const u = new URL(raw.trim());
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
export default function MarkdownEditor({
value,
onChange,
placeholder,
rows = 16,
disabled,
onSubmitShortcut,
id,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<EditorMode>("split");
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 [imageUrl, setImageUrl] = useState("https://");
const [imageAlt, setImageAlt] = useState("图片");
const [codeQuery, setCodeQuery] = useState("");
const [hidePoints, setHidePoints] = useState(10);
const [hidePassword, setHidePassword] = useState("");
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu");
const savedSel = useRef<{ start: number; end: number } | null>(null);
useEffect(() => {
if (!fullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
if (popover) setPopover(null);
else setFullscreen(false);
}
};
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [fullscreen, popover]);
useEffect(() => {
if (!popover) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (toolbarRef.current && !toolbarRef.current.contains(t)) {
setPopover(null);
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setPopover(null);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onEsc);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onEsc);
};
}, [popover]);
const rememberSel = () => {
const el = taRef.current;
if (!el) return;
savedSel.current = { start: el.selectionStart, end: el.selectionEnd };
};
const restoreSel = () => {
const el = taRef.current;
const sel = savedSel.current;
if (!el || !sel) return;
el.focus();
el.setSelectionRange(sel.start, sel.end);
};
const runWrap = useCallback(
(opts: WrapOpts) => {
const el = taRef.current;
if (!el || disabled) return;
const { next, selStart, selEnd } = applyWrap(el, value, opts);
onChange(next);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(selStart, selEnd);
});
},
[value, onChange, disabled]
);
const insertAtCursor = useCallback(
(text: string, selectInner?: { offset: number; length: number }) => {
const el = taRef.current;
if (!el) {
onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text);
return;
}
const start = savedSel.current?.start ?? el.selectionStart;
const end = savedSel.current?.end ?? el.selectionEnd;
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
const inserted = padBefore + text + padAfter;
const next = value.slice(0, start) + inserted + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
if (selectInner) {
const a = start + padBefore.length + selectInner.offset;
el.setSelectionRange(a, a + selectInner.length);
} else {
const caret = start + inserted.length;
el.setSelectionRange(caret, caret);
}
});
savedSel.current = null;
},
[value, onChange]
);
const uploadImageFile = async (file: File | null) => {
if (!file || disabled) return;
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
toast("仅支持 JPEG / PNG / WebP", "error");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast("图片不能超过 5MB", "error");
return;
}
setUploading(true);
try {
const res = await apiUploadPostImage(file);
if (!res.url) {
toast(res.error || "上传失败", "error");
return;
}
const alt = file.name.replace(/\.[^.]+$/, "") || "图片";
insertAtCursor(`![${alt}](${res.url})`);
setMode("write");
toast("图片已插入", "ok");
} catch {
toast("上传失败", "error");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const openPopover = (kind: PopoverKind) => {
rememberSel();
if (kind === "link") {
const el = taRef.current;
const selected = el ? value.slice(el.selectionStart, el.selectionEnd) : "";
setLinkText(selected || "链接文字");
setLinkUrl("https://");
}
if (kind === "imageUrl") {
setImageUrl("https://");
setImageAlt("图片");
}
if (kind === "code") setCodeQuery("");
if (kind === "hide") {
setHideStep("menu");
setHidePoints(10);
setHidePassword("");
}
setPopover((prev) => (prev === kind ? null : kind));
};
const applyLink = () => {
const url = linkUrl.trim();
if (!isHttpUrl(url)) {
toast("请输入有效的 http(s) 链接", "error");
return;
}
const el = taRef.current;
restoreSel();
const start = el?.selectionStart ?? 0;
const end = el?.selectionEnd ?? 0;
const selected = value.slice(start, end);
const label = (selected || linkText.trim() || url).trim() || url;
const md = `[${label}](${url})`;
const next = value.slice(0, start) + md + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
el.setSelectionRange(start + md.length, start + md.length);
});
setPopover(null);
};
const applyImageUrl = () => {
const url = imageUrl.trim();
if (!isHttpUrl(url)) {
toast("请输入有效的图片 URL(http/https)", "error");
return;
}
const alt = imageAlt.trim() || "图片";
insertAtCursor(`![${alt}](${url})`);
setPopover(null);
};
const insertCodeBlock = (lang: string) => {
const el = taRef.current;
restoreSel();
const start = el?.selectionStart ?? 0;
const end = el?.selectionEnd ?? 0;
const selected = value.slice(start, end);
const body = selected || "代码";
const fence = "```" + lang + "\n" + body + "\n```";
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
const inserted = padBefore + fence + padAfter;
const next = value.slice(0, start) + inserted + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const bodyStart = start + padBefore.length + 3 + lang.length + 1;
el.setSelectionRange(bodyStart, bodyStart + body.length);
});
setPopover(null);
};
const insertHide = (
kind: "login" | "reply" | "points" | "password",
points?: number,
password?: string
) => {
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:::`;
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 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") {
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}
onClick={onClick}
>
{icon}
</button>
);
const Sep = () => <span className="j13-md-sep" aria-hidden />;
return (
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}`}>
<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: "粗体" })}
/>
<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">
<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"
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("---")}
/>
<Sep />
<ToolBtn
label="行内代码"
icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/>
<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"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
</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={applyLink}>
插入
</button>
</div>
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}
icon={<ImageIcon size={15} />}
active={popover === "image" || popover === "imageUrl"}
disabled={uploading}
onClick={() => openPopover("image")}
/>
{popover === "image" && (
<div className="j13-md-popover" role="menu">
<button
type="button"
role="menuitem"
disabled={uploading}
onClick={() => {
setPopover(null);
fileRef.current?.click();
}}
>
<Upload size={14} /> 上传图片
</button>
<button
type="button"
role="menuitem"
onClick={() => openPopover("imageUrl")}
>
<Link2 size={14} /> 图片地址
</button>
</div>
)}
{popover === "imageUrl" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入图片 URL">
<label className="j13-md-field">
<span>说明</span>
<input
value={imageAlt}
onChange={(e) => setImageAlt(e.target.value)}
className="j13-md-popover-input"
/>
</label>
<label className="j13-md-field">
<span>URL</span>
<input
autoFocus
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyImageUrl();
}
}}
/>
</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={applyImageUrl}>
插入
</button>
</div>
</div>
)}
</div>
<Sep />
<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" onClick={() => insertHide("reply")}>
回复可见
</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>
</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" : ""}
onClick={() => setMode("write")}
>
<Pencil size={12} /> 编写
</button>
<button
type="button"
role="tab"
aria-selected={mode === "split"}
className={mode === "split" ? "is-on" : ""}
onClick={() => setMode("split")}
>
<Columns2 size={12} /> 双栏
</button>
<button
type="button"
role="tab"
aria-selected={mode === "preview"}
className={mode === "preview" ? "is-on" : ""}
onClick={() => setMode("preview")}
>
<Eye size={12} /> 预览
</button>
</div>
<button
type="button"
className="j13-md-tool"
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
aria-label={fullscreen ? "退出全屏" : "全屏写作"}
onClick={() => setFullscreen((v) => !v)}
>
{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={rows}
placeholder={placeholder}
className="j13-md-textarea"
onChange={(e) => onChange(e.target.value)}
onKeyDown={onTextareaKeyDown}
onPaste={(e) => void onPaste(e)}
onSelect={rememberSel}
/>
</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>支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交</span>
<span className="tabular-nums">{value.length} 字</span>
</div>
</div>
);
}