评论编辑器改为紧凑底栏与精简工具栏,评论操作收进更多菜单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 00:56:53 +08:00
parent c0462a3500
commit acde7ee288
4 changed files with 1182 additions and 526 deletions

View File

@@ -2174,10 +2174,76 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
gap: 12px; gap: 12px;
padding: 10px 0 0; padding: 10px 0 0;
font-size: 12.5px; font-size: 12.5px;
color: var(--ink-3); color: var(--ink-2);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
background: transparent; background: transparent;
} }
.j13-md-foot-hint {
min-width: 0;
}
.j13-md-foot-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-left: auto;
flex-shrink: 0;
}
.j13-md-count {
color: var(--ink-2);
font-size: 12.5px;
}
.j13-md-upload,
.j13-md-error {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.45;
}
.j13-md-upload {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--ink-2);
}
.j13-md-upload button {
color: var(--accent);
font-weight: 600;
font-size: 13px;
}
.j13-md-upload button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.j13-md-error {
color: var(--danger);
}
.j13-md-editor.is-comment:not(.is-fullscreen) .j13-md-textarea {
min-height: 96px;
max-height: 320px;
height: 96px;
overflow-y: auto;
padding: 0.5rem 0 0.35rem !important;
line-height: 1.65 !important;
}
.j13-md-editor.is-comment:not(.is-fullscreen) .j13-md-preview {
min-height: 96px;
max-height: 320px;
height: auto;
overflow-y: auto;
}
.j13-md-editor.is-comment .j13-md-toolbar {
flex-wrap: nowrap;
gap: 2px;
padding: 2px 0 8px;
}
.j13-md-editor.is-comment .j13-md-tool-groups {
flex-wrap: nowrap;
}
.j13-md-editor.is-comment .j13-md-mode button {
height: 32px;
padding: 0 8px;
}
/* 正文内嵌隐藏块 */ /* 正文内嵌隐藏块 */
.j13-hide-block { .j13-hide-block {
@@ -6649,6 +6715,7 @@ a.j13-mention-token:hover {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
font-weight: 500; font-weight: 500;
overflow-wrap: anywhere;
} }
/* 正文内嵌按钮链接:未分层的 .prose-content a 会盖掉 @layer 的 .btn-primary 字色 */ /* 正文内嵌按钮链接:未分层的 .prose-content a 会盖掉 @layer 的 .btn-primary 字色 */
.prose-content a.btn, .prose-content a.btn,
@@ -7020,7 +7087,7 @@ html.dark .prose-content pre[data-theme] span {
} }
/* 评论区的 Markdown 紧凑排版:继承 prose-content 全部规则,仅收紧行距与间距 */ /* 评论区的 Markdown 紧凑排版:继承 prose-content 全部规则,仅收紧行距与间距 */
.prose-comment { font-size: 14.5px; line-height: 1.7; } .prose-comment { font-size: 15.5px; line-height: 1.7; overflow-wrap: anywhere; }
.prose-comment p { margin: 0.35rem 0; } .prose-comment p { margin: 0.35rem 0; }
.prose-comment blockquote { margin: 0.5rem 0; padding: 0.45rem 0.9rem; } .prose-comment blockquote { margin: 0.5rem 0; padding: 0.45rem 0.9rem; }
.prose-comment .md-code-block { margin: 0.6rem 0; } .prose-comment .md-code-block { margin: 0.6rem 0; }
@@ -7090,14 +7157,182 @@ html.dark .prose-content pre[data-theme] span {
} }
} }
/* 评论楼层树:抖音风格 /* 评论楼层树
主评论全宽;子回复折叠在 panel-2 背景容器内,点击"展开 X 条回复"显示 主评论全宽;子回复默认折叠。视觉缩进最多两级,更深靠「回复谁」说明。 */
子回复之间用淡分隔线;depth≥2 时昵称后显示 ▶ 被回复者 */
/* 子回复行:紧凑布局,头像 28px */ .cmt-item {
.cmt-reply-row { padding: 16px 0;
padding: 8px 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
min-width: 0;
}
.cmt-item:first-child {
border-top: none;
padding-top: 2px;
}
.cmt-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
min-width: 0;
margin-bottom: 6px;
}
.cmt-meta-lead {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
min-width: 0;
flex: 1 1 auto;
}
.cmt-name-link {
min-width: 0;
max-width: 100%;
}
.cmt-name {
display: block;
max-width: 16em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14.5px;
font-weight: 600;
line-height: 1.35;
color: var(--ink);
}
.cmt-time {
font-size: 12.5px;
line-height: 1.4;
color: var(--ink-2);
background: none;
border: 0;
padding: 0;
cursor: default;
}
.cmt-time:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
.cmt-edited {
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
background: none;
border: 0;
padding: 0;
}
button.cmt-edited {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.cmt-badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1;
color: var(--accent);
background: var(--accent-soft);
flex-shrink: 0;
}
.cmt-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 10px;
margin-top: 8px;
min-width: 0;
}
.cmt-more {
position: relative;
}
.cmt-more-btn,
.cmt-reply-btn {
min-height: 32px;
padding: 0 4px;
}
.cmt-more-btn {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
background: none;
border: 0;
cursor: pointer;
border-radius: 8px;
}
.cmt-more-btn:hover,
.cmt-more-btn[aria-expanded="true"] {
color: var(--accent);
}
.cmt-more-menu {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
min-width: 148px;
padding: 4px;
border-radius: 12px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow-lg);
}
.cmt-more-menu button {
display: flex;
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--ink);
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.cmt-more-menu button:hover,
.cmt-more-menu button:focus-visible {
background: var(--panel-2);
outline: none;
}
.cmt-more-menu button.is-danger {
color: var(--danger);
}
.cmt-more-sep {
height: 1px;
margin: 4px 6px;
background: var(--line);
}
.cmt-posted-note {
margin-top: 10px;
font-size: 13px;
color: var(--ink-2);
}
.cmt-posted-note a {
color: var(--accent);
font-weight: 600;
}
.cmt-submit {
height: 38px;
padding: 0 16px;
font-size: 14px;
}
.cmt-submit:disabled {
background: var(--tint);
color: var(--ink-2);
box-shadow: none;
opacity: 1;
}
/* 子回复行:紧凑布局,头像 32px */
.cmt-reply-row {
padding: 14px 0;
border-top: 1px solid var(--line);
min-width: 0;
} }
.cmt-reply-row:first-child { .cmt-reply-row:first-child {
border-top: none; border-top: none;
@@ -7106,9 +7341,9 @@ html.dark .prose-content pre[data-theme] span {
/* 子回复昵称 */ /* 子回复昵称 */
.cmt-reply-name { .cmt-reply-name {
font-size: 13px; font-size: 14px;
font-weight: 500; font-weight: 600;
color: var(--ink-2); color: var(--ink);
} }
/* 楼层号标签(主评论昵称旁) */ /* 楼层号标签(主评论昵称旁) */
@@ -7128,13 +7363,15 @@ html.dark .prose-content pre[data-theme] span {
/* 回复文字按钮:放在时间右侧,抖音风格 */ /* 回复文字按钮:放在时间右侧,抖音风格 */
.cmt-reply-btn { .cmt-reply-btn {
font-size: 12px; font-size: 13px;
font-weight: 500; font-weight: 600;
color: var(--ink-3); color: var(--ink-2);
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0 4px;
min-height: 32px;
cursor: pointer; cursor: pointer;
border-radius: 8px;
} }
.cmt-reply-btn:hover { .cmt-reply-btn:hover {
color: var(--accent); color: var(--accent);
@@ -7172,6 +7409,10 @@ html.dark .prose-content pre[data-theme] span {
padding-left: 10px; padding-left: 10px;
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
} }
.cmt-children .cmt-children .cmt-children {
padding-left: 0;
border-left: 0;
}
/* 展开/收起按钮 */ /* 展开/收起按钮 */
.cmt-fold-btn { .cmt-fold-btn {
@@ -7200,39 +7441,33 @@ html.dark .prose-content pre[data-theme] span {
margin-top: 8px; margin-top: 8px;
} }
/* 评论区公共编辑器:矮于发帖画布,带边框贴近原 field */ /* 评论区编辑器:矮输入区,底栏含提交 */
.cmt-md-editor { .cmt-md-editor {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
padding: 0.35rem 0.85rem 0.55rem; padding: 0.25rem 0.75rem 0.55rem;
background: var(--panel); background: var(--panel);
container-type: inline-size;
} }
.cmt-md-editor .j13-md-editor:not(.is-fullscreen) { .cmt-md-editor .j13-md-editor:not(.is-fullscreen) {
min-height: 0; min-height: 0;
} }
.cmt-md-editor .j13-md-textarea,
.cmt-md-editor .j13-md-preview {
min-height: 7.5rem;
overflow-y: auto;
}
.cmt-md-editor-floor .j13-md-textarea,
.cmt-md-editor-floor .j13-md-preview {
min-height: 10rem;
}
.cmt-reply-form .cmt-md-editor {
background: var(--panel);
padding: 0.25rem 0.65rem 0.4rem;
}
.cmt-reply-form .cmt-md-editor .j13-md-textarea,
.cmt-reply-form .cmt-md-editor .j13-md-preview {
min-height: 6rem;
}
.cmt-md-editor .j13-md-toolbar { .cmt-md-editor .j13-md-toolbar {
padding-bottom: 8px; padding-bottom: 8px;
} }
.cmt-md-editor .j13-md-foot { .cmt-md-editor .j13-md-foot {
font-size: 11.5px; font-size: 12.5px;
padding-top: 8px; padding-top: 8px;
flex-wrap: wrap;
}
@container (max-width: 420px) {
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool {
width: 36px;
height: 36px;
}
.cmt-md-editor .j13-md-mode button {
padding: 0 6px;
}
} }
/* 软删占位:匿名马赛克(不含真实头像/昵称) */ /* 软删占位:匿名马赛克(不含真实头像/昵称) */
@@ -7415,8 +7650,18 @@ html.dark .prose-content pre[data-theme] span {
.cmt-replies-wrap { .cmt-replies-wrap {
padding: 4px 8px; padding: 4px 8px;
} }
.cmt-children { .cmt-children,
padding-left: 8px; .cmt-children .cmt-children {
padding-left: 0;
border-left: 0;
}
.cmt-reply-btn,
.cmt-more-btn,
.cmt-more-menu button {
min-height: 44px;
}
.cmt-name {
max-width: 9.5em;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import {
Upload, Upload,
Link2, Link2,
History, History,
Ellipsis,
} from "lucide-react"; } from "lucide-react";
import { apiUploadPostImage } from "@/lib/api"; import { apiUploadPostImage } from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@@ -60,6 +61,19 @@ type MarkdownEditorProps = {
* 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。 * 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。
*/ */
allowReplyHide?: boolean; allowReplyHide?: boolean;
/** default=发帖完整工具栏;comment=评论紧凑栏,低频工具进「更多」 */
density?: "default" | "comment";
/** 输入框可访问名称(占位文案不能代替) */
ariaLabel?: string;
/** 底栏左侧提示;缺省按模式生成 */
hint?: string;
/** 评论模式快捷键文案,例如「发表」「回复」「保存」 */
shortcutName?: string;
/** 底栏右侧操作(发表 / 保存),纳入编辑器边框内 */
footer?: ReactNode;
/** 提交失败等原因,显示在编辑器内 */
error?: string;
onUploadingChange?: (uploading: boolean) => void;
}; };
type EditorMode = "write" | "preview"; type EditorMode = "write" | "preview";
@@ -71,7 +85,7 @@ type WrapOpts = {
linePrefix?: string; linePrefix?: string;
}; };
type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | null; type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | "more" | null;
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const; const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
@@ -124,6 +138,13 @@ export default function MarkdownEditor({
imageMaxMB = 5, imageMaxMB = 5,
allowHide = false, allowHide = false,
allowReplyHide = true, allowReplyHide = true,
density = "default",
ariaLabel,
hint,
shortcutName,
footer,
error,
onUploadingChange,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -142,6 +163,11 @@ export default function MarkdownEditor({
const [hidePoints, setHidePoints] = useState(10); const [hidePoints, setHidePoints] = useState(10);
const [hidePassword, setHidePassword] = useState(""); const [hidePassword, setHidePassword] = useState("");
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu"); 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 [timelineOpen, setTimelineOpen] = useState(false);
const [timelineBody, setTimelineBody] = useState(""); const [timelineBody, setTimelineBody] = useState("");
const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>( const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>(
@@ -149,6 +175,48 @@ export default function MarkdownEditor({
); );
const savedSel = useRef<{ start: number; end: number } | null>(null); const savedSel = useRef<{ start: number; end: number } | null>(null);
const fullscreenMounted = useRef(false); 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 rememberCaret = () => {
const el = taRef.current; const el = taRef.current;
@@ -179,6 +247,23 @@ export default function MarkdownEditor({
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [fullscreen]); }, [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(() => { useEffect(() => {
if (!fullscreen) return; if (!fullscreen) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -209,7 +294,11 @@ export default function MarkdownEditor({
} }
}; };
const onEsc = (e: KeyboardEvent) => { const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setPopover(null); if (e.key !== "Escape") return;
e.preventDefault();
const kind = popover;
setPopover(null);
if (kind === "more") moreBtnRef.current?.focus();
}; };
document.addEventListener("mousedown", onDoc); document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onEsc); document.addEventListener("keydown", onEsc);
@@ -237,6 +326,9 @@ export default function MarkdownEditor({
(opts: WrapOpts) => { (opts: WrapOpts) => {
const el = taRef.current; const el = taRef.current;
if (!el || disabled) return; 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); const { next, selStart, selEnd } = applyWrap(el, value, opts);
onChange(next); onChange(next);
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -278,27 +370,37 @@ export default function MarkdownEditor({
const uploadImageFile = async (file: File | null) => { const uploadImageFile = async (file: File | null) => {
if (!file || disabled) return; if (!file || disabled) return;
retryFile.current = file;
setUploadError("");
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) { if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
setUploadError("仅支持 JPEG / PNG / WebP");
toast("仅支持 JPEG / PNG / WebP", "error"); toast("仅支持 JPEG / PNG / WebP", "error");
return; return;
} }
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024; const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
if (file.size > maxBytes) { if (file.size > maxBytes) {
toast(`图片不能超过 ${imageMaxMB}MB`, "error"); const msg = `图片不能超过 ${imageMaxMB}MB`;
setUploadError(msg);
toast(msg, "error");
return; return;
} }
setUploading(true); setUploading(true);
try { try {
const res = await apiUploadPostImage(file); const res = await apiUploadPostImage(file);
if (!res.url) { if (!res.url) {
toast(res.error || "上传失败", "error"); const msg = res.error || "上传失败";
setUploadError(msg);
toast(msg, "error");
return; return;
} }
const alt = file.name.replace(/\.[^.]+$/, "") || "图片"; const alt = file.name.replace(/\.[^.]+$/, "") || "图片";
insertAtCursor(`![${alt}](${res.url})`); insertAtCursor(`![${alt}](${res.url})`);
setMode("write"); setMode("write");
setUploadError("");
retryFile.current = null;
toast("图片已插入", "ok"); toast("图片已插入", "ok");
} catch { } catch {
setUploadError("上传失败");
toast("上传失败", "error"); toast("上传失败", "error");
} finally { } finally {
setUploading(false); setUploading(false);
@@ -570,6 +672,10 @@ export default function MarkdownEditor({
data-tip={label} data-tip={label}
aria-label={label} aria-label={label}
disabled={disabled || dis} disabled={disabled || dis}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={onClick} onClick={onClick}
> >
{icon} {icon}
@@ -579,7 +685,7 @@ export default function MarkdownEditor({
const Sep = () => <span className="j13-md-sep" aria-hidden />; const Sep = () => <span className="j13-md-sep" aria-hidden />;
return ( return (
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}`}> <div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${isComment ? " is-comment" : ""}`}>
<div className="j13-md-toolbar" ref={toolbarRef}> <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"> <div className="j13-md-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
<ToolBtn <ToolBtn
@@ -587,86 +693,103 @@ export default function MarkdownEditor({
icon={<Bold size={15} />} icon={<Bold size={15} />}
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })} onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
/> />
<ToolBtn {!isComment && (
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 <ToolBtn
label="标题" label="斜体 Ctrl+I"
icon={ icon={<Italic size={15} />}
<span className="inline-flex items-center gap-0.5"> onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
<Heading2 size={15} />
<ChevronDown size={11} />
</span>
}
active={popover === "heading"}
onClick={() => openPopover("heading")}
/> />
{popover === "heading" && ( )}
<div className="j13-md-popover" role="menu"> {!isComment && (
<button <ToolBtn
type="button" label="删除线"
role="menuitem" icon={<Strikethrough size={15} />}
onClick={() => { onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
runWrap({ before: "## ", after: "", placeholder: "二级标题" }); />
setPopover(null); )}
}} {!isComment && <Sep />}
> {!isComment && (
<Heading2 size={14} /> 二级标题 <div className="j13-md-popover-anchor relative">
</button> <ToolBtn
<button label="标题"
type="button" icon={
role="menuitem" <span className="inline-flex items-center gap-0.5">
onClick={() => { <Heading2 size={15} />
runWrap({ before: "### ", after: "", placeholder: "三级标题" }); <ChevronDown size={11} />
setPopover(null); </span>
}} }
> active={popover === "heading"}
<Heading3 size={14} /> 三级标题 onClick={() => openPopover("heading")}
</button> />
</div> {popover === "heading" && (
)} <div className="j13-md-popover" role="menu">
</div> <button
<Sep /> type="button"
<ToolBtn role="menuitem"
label="引用" onMouseDown={(e) => e.preventDefault()}
icon={<Quote size={15} />} onClick={() => {
onClick={() => runWrap({ before: "## ", after: "", placeholder: "二级标题" });
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }) setPopover(null);
} }}
/> >
<ToolBtn <Heading2 size={14} /> 二级标题
label="无序列表" </button>
icon={<List size={15} />} <button
onClick={() => type="button"
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }) role="menuitem"
} onMouseDown={(e) => e.preventDefault()}
/> onClick={() => {
<ToolBtn runWrap({ before: "### ", after: "", placeholder: "三级标题" });
label="有序列表" setPopover(null);
icon={<ListOrdered size={15} />} }}
onClick={insertOrderedList} >
/> <Heading3 size={14} /> 三级标题
<ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} /> </button>
<ToolBtn </div>
label="分割线" )}
icon={<Minus size={15} />} </div>
onClick={() => insertAtCursor("---")} )}
/> {(!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 /> <Sep />
<ToolBtn <ToolBtn
label="行内代码" label="行内代码"
icon={<Code size={15} />} icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })} onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/> />
{(!isComment || !toolNarrow) && (
<div className="j13-md-popover-anchor relative"> <div className="j13-md-popover-anchor relative">
<ToolBtn <ToolBtn
label="代码块 Ctrl+Shift+C" label="代码块 Ctrl+Shift+C"
@@ -706,6 +829,7 @@ export default function MarkdownEditor({
</div> </div>
)} )}
</div> </div>
)}
<Sep /> <Sep />
<div className="j13-md-popover-anchor relative"> <div className="j13-md-popover-anchor relative">
<ToolBtn <ToolBtn
@@ -816,12 +940,84 @@ export default function MarkdownEditor({
</div> </div>
)} )}
</div> </div>
<Sep /> {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 <ToolBtn
label="时间线" label="时间线"
icon={<History size={15} />} icon={<History size={15} />}
onClick={openTimelineEditor} onClick={openTimelineEditor}
/> />
)}
{allowHide ? ( {allowHide ? (
<div className="j13-md-popover-anchor relative"> <div className="j13-md-popover-anchor relative">
<ToolBtn <ToolBtn
@@ -973,6 +1169,10 @@ export default function MarkdownEditor({
role="tab" role="tab"
aria-selected={mode === "write"} aria-selected={mode === "write"}
className={mode === "write" ? "is-on" : ""} className={mode === "write" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => setMode("write")} onClick={() => setMode("write")}
> >
<Pencil size={12} /> 编写 <Pencil size={12} /> 编写
@@ -982,21 +1182,30 @@ export default function MarkdownEditor({
role="tab" role="tab"
aria-selected={mode === "preview"} aria-selected={mode === "preview"}
className={mode === "preview" ? "is-on" : ""} className={mode === "preview" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={() => setMode("preview")} onClick={() => setMode("preview")}
> >
<Eye size={12} /> 预览 <Eye size={12} /> 预览
</button> </button>
</div> </div>
{(!isComment || !toolNarrow) && (
<button <button
type="button" type="button"
className="j13-md-tool" className="j13-md-tool"
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"} data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
aria-label={fullscreen ? "退出全屏" : "全屏写作"} aria-label={fullscreen ? "退出全屏" : "全屏写作"}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => {
e.preventDefault();
rememberCaret();
}}
onClick={toggleFullscreen} onClick={toggleFullscreen}
> >
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />} {fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button> </button>
)}
</div> </div>
</div> </div>
@@ -1037,14 +1246,16 @@ export default function MarkdownEditor({
id={id} id={id}
value={value} value={value}
disabled={disabled} disabled={disabled}
rows={rows} rows={isComment ? 3 : rows}
placeholder={placeholder} placeholder={placeholder}
aria-label={ariaLabel || placeholder || "正文"}
className="j13-md-textarea" className="j13-md-textarea"
autoFocus={autoFocus} autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={onTextareaKeyDown} onKeyDown={onTextareaKeyDown}
onPaste={(e) => void onPaste(e)} onPaste={(e) => void onPaste(e)}
onSelect={rememberSel} onSelect={rememberSel}
onBlur={rememberCaret}
/> />
</div> </div>
<div className="j13-md-preview" hidden={mode === "write"}> <div className="j13-md-preview" hidden={mode === "write"}>
@@ -1060,9 +1271,43 @@ export default function MarkdownEditor({
</div> </div>
<div className="j13-md-foot"> <div className="j13-md-foot">
<span>支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交</span> <span className="j13-md-foot-hint">
<span className="tabular-nums">{value.length} 字</span> {hint ??
(isComment
? `支持 Markdown · ${modKey}+Enter ${shortcutName || "发表"}`
: "支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交")}
</span>
<span className="j13-md-foot-actions">
<span className="j13-md-count tabular-nums">{value.length} 字</span>
{footer}
</span>
</div> </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 <TimelineEditor
open={timelineOpen} open={timelineOpen}

View File

@@ -54,3 +54,12 @@ export function formatDateTime(dateStr: string): string {
if (Number.isNaN(d.getTime())) return ""; if (Number.isNaN(d.getTime())) return "";
return `${formatDate(dateStr)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; return `${formatDate(dateStr)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
} }
/** 评论列表短时间:同年「M月D日 HH:mm」,跨年补年份。悬停用 formatDateTime。 */
export function formatCommentTime(dateStr: string, now = new Date()): string {
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return "";
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
const md = `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
return d.getFullYear() === now.getFullYear() ? md : `${d.getFullYear()}年${md}`;
}