评论编辑器改为紧凑底栏与精简工具栏,评论操作收进更多菜单。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2174,10 +2174,76 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
||||
gap: 12px;
|
||||
padding: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
color: var(--ink-2);
|
||||
border-top: 1px solid var(--line);
|
||||
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 {
|
||||
@@ -6649,6 +6715,7 @@ a.j13-mention-token:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 正文内嵌按钮链接:未分层的 .prose-content a 会盖掉 @layer 的 .btn-primary 字色 */
|
||||
.prose-content a.btn,
|
||||
@@ -7020,7 +7087,7 @@ html.dark .prose-content pre[data-theme] span {
|
||||
}
|
||||
|
||||
/* 评论区的 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 blockquote { margin: 0.5rem 0; padding: 0.45rem 0.9rem; }
|
||||
.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-reply-row {
|
||||
padding: 8px 0;
|
||||
.cmt-item {
|
||||
padding: 16px 0;
|
||||
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 {
|
||||
border-top: none;
|
||||
@@ -7106,9 +7341,9 @@ html.dark .prose-content pre[data-theme] span {
|
||||
|
||||
/* 子回复昵称 */
|
||||
.cmt-reply-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 楼层号标签(主评论昵称旁) */
|
||||
@@ -7128,13 +7363,15 @@ html.dark .prose-content pre[data-theme] span {
|
||||
|
||||
/* 回复文字按钮:放在时间右侧,抖音风格 */
|
||||
.cmt-reply-btn {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
padding: 0 4px;
|
||||
min-height: 32px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cmt-reply-btn:hover {
|
||||
color: var(--accent);
|
||||
@@ -7172,6 +7409,10 @@ html.dark .prose-content pre[data-theme] span {
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.cmt-children .cmt-children .cmt-children {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
/* 展开/收起按钮 */
|
||||
.cmt-fold-btn {
|
||||
@@ -7200,39 +7441,33 @@ html.dark .prose-content pre[data-theme] span {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 评论区公共编辑器:矮于发帖画布,带边框贴近原 field */
|
||||
/* 评论区编辑器:矮输入区,底栏含提交 */
|
||||
.cmt-md-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.35rem 0.85rem 0.55rem;
|
||||
padding: 0.25rem 0.75rem 0.55rem;
|
||||
background: var(--panel);
|
||||
container-type: inline-size;
|
||||
}
|
||||
.cmt-md-editor .j13-md-editor:not(.is-fullscreen) {
|
||||
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 {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.cmt-md-editor .j13-md-foot {
|
||||
font-size: 11.5px;
|
||||
font-size: 12.5px;
|
||||
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 {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.cmt-children {
|
||||
padding-left: 8px;
|
||||
.cmt-children,
|
||||
.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
@@ -33,6 +33,7 @@ import {
|
||||
Upload,
|
||||
Link2,
|
||||
History,
|
||||
Ellipsis,
|
||||
} from "lucide-react";
|
||||
import { apiUploadPostImage } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
@@ -60,6 +61,19 @@ type MarkdownEditorProps = {
|
||||
* 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。
|
||||
*/
|
||||
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";
|
||||
@@ -71,7 +85,7 @@ type WrapOpts = {
|
||||
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;
|
||||
|
||||
@@ -124,6 +138,13 @@ export default function MarkdownEditor({
|
||||
imageMaxMB = 5,
|
||||
allowHide = false,
|
||||
allowReplyHide = true,
|
||||
density = "default",
|
||||
ariaLabel,
|
||||
hint,
|
||||
shortcutName,
|
||||
footer,
|
||||
error,
|
||||
onUploadingChange,
|
||||
}: MarkdownEditorProps) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -142,6 +163,11 @@ export default function MarkdownEditor({
|
||||
const [hidePoints, setHidePoints] = useState(10);
|
||||
const [hidePassword, setHidePassword] = useState("");
|
||||
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 [timelineBody, setTimelineBody] = useState("");
|
||||
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 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 el = taRef.current;
|
||||
@@ -179,6 +247,23 @@ export default function MarkdownEditor({
|
||||
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) => {
|
||||
@@ -209,7 +294,11 @@ export default function MarkdownEditor({
|
||||
}
|
||||
};
|
||||
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("keydown", onEsc);
|
||||
@@ -237,6 +326,9 @@ export default function MarkdownEditor({
|
||||
(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(() => {
|
||||
@@ -278,27 +370,37 @@ export default function MarkdownEditor({
|
||||
|
||||
const uploadImageFile = async (file: File | null) => {
|
||||
if (!file || disabled) return;
|
||||
retryFile.current = file;
|
||||
setUploadError("");
|
||||
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
|
||||
setUploadError("仅支持 JPEG / PNG / WebP");
|
||||
toast("仅支持 JPEG / PNG / WebP", "error");
|
||||
return;
|
||||
}
|
||||
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast(`图片不能超过 ${imageMaxMB}MB`, "error");
|
||||
const msg = `图片不能超过 ${imageMaxMB}MB`;
|
||||
setUploadError(msg);
|
||||
toast(msg, "error");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await apiUploadPostImage(file);
|
||||
if (!res.url) {
|
||||
toast(res.error || "上传失败", "error");
|
||||
const msg = res.error || "上传失败";
|
||||
setUploadError(msg);
|
||||
toast(msg, "error");
|
||||
return;
|
||||
}
|
||||
const alt = file.name.replace(/\.[^.]+$/, "") || "图片";
|
||||
insertAtCursor(``);
|
||||
setMode("write");
|
||||
setUploadError("");
|
||||
retryFile.current = null;
|
||||
toast("图片已插入", "ok");
|
||||
} catch {
|
||||
setUploadError("上传失败");
|
||||
toast("上传失败", "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -570,6 +672,10 @@ export default function MarkdownEditor({
|
||||
data-tip={label}
|
||||
aria-label={label}
|
||||
disabled={disabled || dis}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
rememberCaret();
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
@@ -579,7 +685,7 @@ export default function MarkdownEditor({
|
||||
const Sep = () => <span className="j13-md-sep" aria-hidden />;
|
||||
|
||||
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-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
|
||||
<ToolBtn
|
||||
@@ -587,86 +693,103 @@ export default function MarkdownEditor({
|
||||
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">
|
||||
{!isComment && (
|
||||
<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")}
|
||||
label="斜体 Ctrl+I"
|
||||
icon={<Italic size={15} />}
|
||||
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
|
||||
/>
|
||||
{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("---")}
|
||||
/>
|
||||
)}
|
||||
{!isComment && (
|
||||
<ToolBtn
|
||||
label="删除线"
|
||||
icon={<Strikethrough size={15} />}
|
||||
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
|
||||
/>
|
||||
)}
|
||||
{!isComment && <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>
|
||||
)}
|
||||
{(!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 />
|
||||
<ToolBtn
|
||||
label="行内代码"
|
||||
icon={<Code size={15} />}
|
||||
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
|
||||
/>
|
||||
{(!isComment || !toolNarrow) && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label="代码块 Ctrl+Shift+C"
|
||||
@@ -706,6 +829,7 @@ export default function MarkdownEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Sep />
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
@@ -816,12 +940,84 @@ export default function MarkdownEditor({
|
||||
</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
|
||||
label="时间线"
|
||||
icon={<History size={15} />}
|
||||
onClick={openTimelineEditor}
|
||||
/>
|
||||
)}
|
||||
{allowHide ? (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
@@ -973,6 +1169,10 @@ export default function MarkdownEditor({
|
||||
role="tab"
|
||||
aria-selected={mode === "write"}
|
||||
className={mode === "write" ? "is-on" : ""}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
rememberCaret();
|
||||
}}
|
||||
onClick={() => setMode("write")}
|
||||
>
|
||||
<Pencil size={12} /> 编写
|
||||
@@ -982,21 +1182,30 @@ export default function MarkdownEditor({
|
||||
role="tab"
|
||||
aria-selected={mode === "preview"}
|
||||
className={mode === "preview" ? "is-on" : ""}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
rememberCaret();
|
||||
}}
|
||||
onClick={() => setMode("preview")}
|
||||
>
|
||||
<Eye size={12} /> 预览
|
||||
</button>
|
||||
</div>
|
||||
{(!isComment || !toolNarrow) && (
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
|
||||
aria-label={fullscreen ? "退出全屏" : "全屏写作"}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
rememberCaret();
|
||||
}}
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1037,14 +1246,16 @@ export default function MarkdownEditor({
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
rows={isComment ? 3 : rows}
|
||||
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"}>
|
||||
@@ -1060,9 +1271,43 @@ export default function MarkdownEditor({
|
||||
</div>
|
||||
|
||||
<div className="j13-md-foot">
|
||||
<span>支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交</span>
|
||||
<span className="tabular-nums">{value.length} 字</span>
|
||||
<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">{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}
|
||||
|
||||
@@ -54,3 +54,12 @@ export function formatDateTime(dateStr: string): string {
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user