补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
8.9 KiB
TypeScript
321 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState, useCallback, useEffect, type ReactNode } from "react";
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Code,
|
|
Quote,
|
|
List,
|
|
Heading2,
|
|
Link as LinkIcon,
|
|
Image as ImageIcon,
|
|
Eye,
|
|
Pencil,
|
|
Maximize2,
|
|
Minimize2,
|
|
} from "lucide-react";
|
|
import { apiUploadPostImage } from "@/lib/api";
|
|
import { toast } from "@/lib/toast";
|
|
import MarkdownBody from "@/components/MarkdownBody";
|
|
|
|
type MarkdownEditorProps = {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
rows?: number;
|
|
disabled?: boolean;
|
|
onSubmitShortcut?: () => void;
|
|
id?: string;
|
|
};
|
|
|
|
type WrapOpts = {
|
|
before: string;
|
|
after?: string;
|
|
placeholder?: string;
|
|
linePrefix?: string;
|
|
};
|
|
|
|
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 };
|
|
}
|
|
|
|
export default function MarkdownEditor({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
rows = 16,
|
|
disabled,
|
|
onSubmitShortcut,
|
|
id,
|
|
}: MarkdownEditorProps) {
|
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const [mode, setMode] = useState<"write" | "preview">("write");
|
|
const [uploading, setUploading] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!fullscreen) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
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]);
|
|
|
|
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) => {
|
|
const el = taRef.current;
|
|
if (!el) {
|
|
onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text);
|
|
return;
|
|
}
|
|
const start = el.selectionStart;
|
|
const 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);
|
|
const caret = start + inserted.length;
|
|
requestAnimationFrame(() => {
|
|
el.focus();
|
|
el.setSelectionRange(caret, caret);
|
|
});
|
|
},
|
|
[value, onChange]
|
|
);
|
|
|
|
const handleImagePick = 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(``);
|
|
setMode("write");
|
|
toast("图片已插入", "ok");
|
|
} catch {
|
|
toast("上传失败", "error");
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileRef.current) fileRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const tools: {
|
|
key: string;
|
|
label: string;
|
|
icon: ReactNode;
|
|
onClick: () => void;
|
|
}[] = [
|
|
{
|
|
key: "bold",
|
|
label: "粗体",
|
|
icon: <Bold size={15} />,
|
|
onClick: () => runWrap({ before: "**", after: "**", placeholder: "粗体" }),
|
|
},
|
|
{
|
|
key: "italic",
|
|
label: "斜体",
|
|
icon: <Italic size={15} />,
|
|
onClick: () => runWrap({ before: "*", after: "*", placeholder: "斜体" }),
|
|
},
|
|
{
|
|
key: "code",
|
|
label: "行内代码",
|
|
icon: <Code size={15} />,
|
|
onClick: () => runWrap({ before: "`", after: "`", placeholder: "code" }),
|
|
},
|
|
{
|
|
key: "quote",
|
|
label: "引用",
|
|
icon: <Quote size={15} />,
|
|
onClick: () =>
|
|
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }),
|
|
},
|
|
{
|
|
key: "list",
|
|
label: "列表",
|
|
icon: <List size={15} />,
|
|
onClick: () =>
|
|
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }),
|
|
},
|
|
{
|
|
key: "h2",
|
|
label: "二级标题",
|
|
icon: <Heading2 size={15} />,
|
|
onClick: () => runWrap({ before: "## ", after: "", placeholder: "标题" }),
|
|
},
|
|
{
|
|
key: "link",
|
|
label: "链接",
|
|
icon: <LinkIcon size={15} />,
|
|
onClick: () =>
|
|
runWrap({ before: "[", after: "](https://)", placeholder: "链接文字" }),
|
|
},
|
|
{
|
|
key: "image",
|
|
label: uploading ? "上传中…" : "插入图片",
|
|
icon: <ImageIcon size={15} />,
|
|
onClick: () => fileRef.current?.click(),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}`}>
|
|
<div className="j13-md-toolbar">
|
|
<div className="flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
|
|
{tools.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
type="button"
|
|
className="j13-md-tool"
|
|
data-tip={t.label}
|
|
aria-label={t.label}
|
|
disabled={disabled || (t.key === "image" && uploading)}
|
|
onClick={t.onClick}
|
|
>
|
|
{t.icon}
|
|
</button>
|
|
))}
|
|
</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 === "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) => handleImagePick(e.target.files?.[0] ?? null)}
|
|
/>
|
|
|
|
{mode === "write" ? (
|
|
<textarea
|
|
ref={taRef}
|
|
id={id}
|
|
value={value}
|
|
disabled={disabled}
|
|
rows={rows}
|
|
placeholder={placeholder}
|
|
className="j13-md-textarea"
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
|
e.preventDefault();
|
|
onSubmitShortcut?.();
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="j13-md-preview">
|
|
{value.trim() ? (
|
|
<MarkdownBody content={value} />
|
|
) : (
|
|
<p className="meta text-center py-16">暂无内容可预览</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="j13-md-foot">
|
|
<span>Markdown · Ctrl/⌘ + Enter 提交</span>
|
|
<span className="tabular-nums">{value.length} 字</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|