feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
320
frontend/components/MarkdownEditor.tsx
Normal file
320
frontend/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user