链接支持网址/文字与站内搜索,新标签由全站设置控制;文章与评论共用已上传图片选择器。 Co-authored-by: Cursor <cursoragent@cursor.com>
963 lines
36 KiB
TypeScript
963 lines
36 KiB
TypeScript
import {
|
||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
||
} from 'react';
|
||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||
import { TextSelection, NodeSelection } from '@tiptap/pm/state';
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import Link from '@tiptap/extension-link';
|
||
import Placeholder from '@tiptap/extension-placeholder';
|
||
import Underline from '@tiptap/extension-underline';
|
||
import { TableKit } from '@tiptap/extension-table';
|
||
import DOMPurify from 'dompurify';
|
||
import {
|
||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||
FileCode, PenLine, Maximize2, Minimize2,
|
||
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
||
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
|
||
MessageSquareLock, Coins,
|
||
} from 'lucide-react';
|
||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||
import PostContent from './PostContent';
|
||
import { handleMarkdownTabKey, insertAtCursor, applyTextareaChange } from '../utils/markdownIndent';
|
||
import {
|
||
wrapMarkdownSelection,
|
||
prefixMarkdownLines,
|
||
cycleMarkdownHeading,
|
||
insertMarkdownMembersOnly,
|
||
insertMarkdownReplyOnly,
|
||
insertMarkdownPointsOnly,
|
||
insertMarkdownLink,
|
||
} from '../utils/markdownFormat';
|
||
import { countWords } from '../utils/text';
|
||
import { notify } from '@/lib/notify';
|
||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||
import { ReplyOnly } from './editor/ReplyOnlyExtension';
|
||
import { PointsOnly } from './editor/PointsOnlyExtension';
|
||
import { TabIndent } from './editor/TabIndentExtension';
|
||
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
|
||
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
|
||
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
|
||
import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog';
|
||
import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog';
|
||
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
|
||
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
|
||
import {
|
||
ArticleTableDialog,
|
||
type TableInsertOptions,
|
||
} from './editor/ArticleTableDialog';
|
||
import {
|
||
formatFenceInfo,
|
||
type CodeBlockInsertOptions,
|
||
} from '../utils/codeBlockOptions';
|
||
import { fenceLengthForContent } from '../utils/markdownFences';
|
||
import { Tooltip } from './ui/Tooltip';
|
||
|
||
export interface ArticleEditorHandle {
|
||
getHTML: () => string;
|
||
isEmpty: () => boolean;
|
||
focus: () => void;
|
||
}
|
||
|
||
interface Props {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
/**
|
||
* 是否启用登录/回复/积分可见区块。
|
||
* 发帖默认 true;自定义单页等场景应关闭。
|
||
*/
|
||
enableContentGates?: boolean;
|
||
}
|
||
|
||
type EditorMode = 'rich' | 'markdown';
|
||
type LinkTarget = 'rich' | 'markdown';
|
||
type ImagePickerTarget = 'rich' | 'markdown';
|
||
type CodeBlockTarget = 'rich' | 'markdown';
|
||
type TableTarget = 'rich' | 'markdown';
|
||
|
||
interface ToolBtn {
|
||
icon: ReactNode;
|
||
title: string;
|
||
hint?: string;
|
||
align?: 'start' | 'center' | 'end';
|
||
active?: boolean;
|
||
className?: string;
|
||
action: () => void;
|
||
}
|
||
|
||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||
const REPLY_ONLY_PLACEHOLDER = '在此输入回复后可见的内容…';
|
||
|
||
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
||
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
|
||
const info = formatFenceInfo(opts);
|
||
const fence = '`'.repeat(fenceLengthForContent(body));
|
||
const open = info ? `${fence}${info}` : fence;
|
||
return `\n${open}\n${body}\n${fence}\n`;
|
||
}
|
||
|
||
/** 生成 GFM 管道表;源码侧始终带表头分隔行 */
|
||
function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
|
||
const cols = Math.max(1, opts.cols);
|
||
const totalRows = Math.max(1, opts.rows);
|
||
const bodyRows = Math.max(0, totalRows - 1);
|
||
const headerCells = Array.from({ length: cols }, (_, i) => `列${i + 1}`);
|
||
const sepCells = Array.from({ length: cols }, () => '---');
|
||
const emptyCells = Array.from({ length: cols }, () => ' ');
|
||
const lines = [
|
||
`| ${headerCells.join(' | ')} |`,
|
||
`| ${sepCells.join(' | ')} |`,
|
||
...Array.from({ length: bodyRows }, () => `| ${emptyCells.join(' | ')} |`),
|
||
];
|
||
return `\n\n${lines.join('\n')}\n\n`;
|
||
}
|
||
|
||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||
function sanitizeHtml(html: string): string {
|
||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||
}
|
||
|
||
/** 判断编辑器内容是否为空 */
|
||
function isEditorEmpty(editor: Editor): boolean {
|
||
return editor.state.doc.textContent.trim().length === 0;
|
||
}
|
||
|
||
/**
|
||
* 将光标放到第一个文本块开头。
|
||
* 文档以分割线等原子节点开头时,默认选区会变成 NodeSelection,出现整条高亮。
|
||
*/
|
||
function placeCaretInFirstTextblock(editor: Editor) {
|
||
const { state } = editor;
|
||
const { doc, selection } = state;
|
||
let pos: number | null = null;
|
||
doc.descendants((node, nodePos) => {
|
||
if (node.isTextblock) {
|
||
pos = nodePos + 1;
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
if (pos == null) return;
|
||
|
||
const needsMove =
|
||
selection instanceof NodeSelection
|
||
|| selection.from !== pos
|
||
|| selection.to !== pos;
|
||
if (!needsMove) return;
|
||
|
||
const tr = state.tr
|
||
.setSelection(TextSelection.create(doc, pos))
|
||
.setMeta('addToHistory', false);
|
||
editor.view.dispatch(tr);
|
||
}
|
||
|
||
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
|
||
function cycleHeading(editor: Editor) {
|
||
for (let level = 2; level <= 6; level += 1) {
|
||
if (editor.isActive('heading', { level })) {
|
||
if (level === 6) {
|
||
editor.chain().focus().setParagraph().run();
|
||
} else {
|
||
editor.chain().focus().toggleHeading({ level: (level + 1) as 2 | 3 | 4 | 5 | 6 }).run();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||
}
|
||
|
||
/** 渲染工具栏按钮列表 */
|
||
function renderToolButtons(tools: ToolBtn[]) {
|
||
return tools.map((t, i) => (
|
||
<span key={i} className={t.className === 'article-tool-btn--members' ? 'article-editor-tools-members' : undefined}>
|
||
{t.className === 'article-tool-btn--members' ? (
|
||
<span className="article-editor-tools-sep" aria-hidden="true" />
|
||
) : null}
|
||
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
|
||
<button
|
||
type="button"
|
||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||
onMouseDown={e => e.preventDefault()}
|
||
onClick={t.action}
|
||
>
|
||
{t.icon}
|
||
</button>
|
||
</Tooltip>
|
||
</span>
|
||
));
|
||
}
|
||
|
||
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
|
||
{ value, onChange, placeholder = '在此撰写正文…', enableContentGates = true },
|
||
ref,
|
||
) {
|
||
const isInternalUpdate = useRef(false);
|
||
const lastValueRef = useRef(value);
|
||
const [, setEditorTick] = useState(0);
|
||
const [mode, setMode] = useState<EditorMode>('rich');
|
||
const [fullscreen, setFullscreen] = useState(false);
|
||
const [markdownSource, setMarkdownSource] = useState('');
|
||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||
const [linkDialogUrl, setLinkDialogUrl] = useState('');
|
||
const [linkDialogText, setLinkDialogText] = useState('');
|
||
const [linkDialogEditing, setLinkDialogEditing] = useState(false);
|
||
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
|
||
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
||
const [imagePickerTarget, setImagePickerTarget] = useState<ImagePickerTarget>('rich');
|
||
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
|
||
const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
|
||
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
|
||
const [codeBlockInitial, setCodeBlockInitial] = useState<Partial<CodeBlockInsertOptions> | null>(null);
|
||
const [tableDialogOpen, setTableDialogOpen] = useState(false);
|
||
const [tableTarget, setTableTarget] = useState<TableTarget>('rich');
|
||
const [tableEditing, setTableEditing] = useState(false);
|
||
const markdownRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit.configure({
|
||
heading: { levels: [2, 3, 4, 5, 6] },
|
||
paragraph: false,
|
||
codeBlock: false,
|
||
// StarterKit v3 已内置;下面单独配置,需先关掉避免重复
|
||
link: false,
|
||
underline: false,
|
||
}),
|
||
ArticleCodeBlock,
|
||
TableKit.configure({
|
||
table: {
|
||
resizable: false,
|
||
HTMLAttributes: { class: 'article-table' },
|
||
},
|
||
}),
|
||
ClearFloatParagraph,
|
||
ClearFloatSync,
|
||
Underline,
|
||
Link.configure({
|
||
openOnClick: false,
|
||
autolink: true,
|
||
defaultProtocol: 'https',
|
||
// 新标签由全站设置在展示层处理,编辑器不写 target/rel
|
||
HTMLAttributes: {
|
||
target: null,
|
||
rel: null,
|
||
},
|
||
}),
|
||
ArticleImage.configure({ inline: false, allowBase64: false }),
|
||
ImageGroup,
|
||
Placeholder.configure({
|
||
placeholder: ({ node }) => {
|
||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||
return MEMBERS_ONLY_PLACEHOLDER;
|
||
}
|
||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') {
|
||
return REPLY_ONLY_PLACEHOLDER;
|
||
}
|
||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'pointsOnly') {
|
||
return '此处内容需积分解锁后可见…';
|
||
}
|
||
return placeholder;
|
||
},
|
||
includeChildren: true,
|
||
}),
|
||
...(enableContentGates ? [MembersOnly, ReplyOnly, PointsOnly] : []),
|
||
TabIndent,
|
||
],
|
||
content: sanitizeHtml(value) || '',
|
||
autofocus: false,
|
||
onCreate: ({ editor: ed }) => {
|
||
placeCaretInFirstTextblock(ed);
|
||
},
|
||
onUpdate: ({ editor: ed }) => {
|
||
const html = sanitizeHtml(ed.getHTML());
|
||
isInternalUpdate.current = true;
|
||
lastValueRef.current = html;
|
||
onChange(html);
|
||
|
||
// 清空后折叠选区,避免空文档残留全选高亮
|
||
if (isEditorEmpty(ed) && !ed.state.selection.empty) {
|
||
ed.commands.setTextSelection(1);
|
||
}
|
||
},
|
||
onSelectionUpdate: () => {
|
||
setEditorTick(t => t + 1);
|
||
},
|
||
onTransaction: () => {
|
||
setEditorTick(t => t + 1);
|
||
},
|
||
editorProps: {
|
||
attributes: {
|
||
class: 'article-prosemirror post-detail-content',
|
||
spellcheck: 'false',
|
||
},
|
||
handleKeyDown: (view, event) => {
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
||
if (!view.state.doc.textContent.trim()) {
|
||
event.preventDefault();
|
||
view.dispatch(view.state.tr.setSelection(
|
||
TextSelection.create(view.state.doc, 1),
|
||
));
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
},
|
||
});
|
||
|
||
// 外部 value 变更时同步到编辑器(如加载已有帖子)
|
||
useEffect(() => {
|
||
if (!editor || mode !== 'rich') return;
|
||
if (isInternalUpdate.current) {
|
||
isInternalUpdate.current = false;
|
||
return;
|
||
}
|
||
const next = sanitizeHtml(value);
|
||
if (next === lastValueRef.current) return;
|
||
lastValueRef.current = next;
|
||
editor.commands.setContent(next || '', { emitUpdate: false });
|
||
// 加载长文时避免首行分割线被 NodeSelection 选中
|
||
placeCaretInFirstTextblock(editor);
|
||
}, [value, editor, mode]);
|
||
|
||
// 全屏时锁定页面滚动,Esc 退出
|
||
useEffect(() => {
|
||
if (!fullscreen) return undefined;
|
||
|
||
const prevOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') setFullscreen(false);
|
||
};
|
||
window.addEventListener('keydown', onKeyDown);
|
||
|
||
return () => {
|
||
document.body.style.overflow = prevOverflow;
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
};
|
||
}, [fullscreen]);
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
getHTML: () => {
|
||
if (mode === 'markdown') {
|
||
return sanitizeHtml(markdownToHtml(markdownSource));
|
||
}
|
||
return editor ? sanitizeHtml(editor.getHTML()) : value;
|
||
},
|
||
isEmpty: () => {
|
||
if (mode === 'markdown') {
|
||
return markdownSource.trim().length === 0;
|
||
}
|
||
return editor ? isEditorEmpty(editor) : !value.trim();
|
||
},
|
||
focus: () => {
|
||
if (mode === 'markdown') {
|
||
markdownRef.current?.focus();
|
||
return;
|
||
}
|
||
if (!editor) return;
|
||
placeCaretInFirstTextblock(editor);
|
||
editor.commands.focus();
|
||
},
|
||
}), [editor, value, mode, markdownSource]);
|
||
|
||
const handleMarkdownChange = useCallback((next: string) => {
|
||
setMarkdownSource(next);
|
||
const html = sanitizeHtml(markdownToHtml(next));
|
||
isInternalUpdate.current = true;
|
||
lastValueRef.current = html;
|
||
onChange(html);
|
||
}, [onChange]);
|
||
|
||
const openLinkDialog = useCallback((target: LinkTarget) => {
|
||
if (target === 'rich') {
|
||
if (!editor) return;
|
||
const { from, to, empty } = editor.state.selection;
|
||
let text = empty ? '' : editor.state.doc.textBetween(from, to, '');
|
||
let href = '';
|
||
let editing = false;
|
||
if (editor.isActive('link')) {
|
||
const attrs = editor.getAttributes('link');
|
||
href = (attrs.href as string) || '';
|
||
editing = Boolean(href);
|
||
editor.chain().focus().extendMarkRange('link').run();
|
||
const sel = editor.state.selection;
|
||
text = editor.state.doc.textBetween(sel.from, sel.to, '') || text;
|
||
}
|
||
setLinkDialogUrl(href);
|
||
setLinkDialogText(text);
|
||
setLinkDialogEditing(editing);
|
||
} else {
|
||
const textarea = markdownRef.current;
|
||
const selected = textarea
|
||
? markdownSource.slice(textarea.selectionStart, textarea.selectionEnd)
|
||
: '';
|
||
setLinkDialogUrl('');
|
||
setLinkDialogText(selected);
|
||
setLinkDialogEditing(false);
|
||
}
|
||
setLinkTarget(target);
|
||
setLinkDialogOpen(true);
|
||
}, [editor, markdownSource]);
|
||
|
||
const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => {
|
||
setCodeBlockTarget(target);
|
||
if (target === 'rich' && editor?.isActive('codeBlock')) {
|
||
const attrs = editor.getAttributes('codeBlock');
|
||
setCodeBlockEditing(true);
|
||
setCodeBlockInitial({
|
||
language: (attrs.language as string) || '',
|
||
lineNumbers: Boolean(attrs.lineNumbers),
|
||
collapsed: Boolean(attrs.collapsed),
|
||
});
|
||
} else {
|
||
setCodeBlockEditing(false);
|
||
setCodeBlockInitial(null);
|
||
}
|
||
setCodeBlockDialogOpen(true);
|
||
}, [editor]);
|
||
|
||
const applyCodeBlock = useCallback((opts: CodeBlockInsertOptions) => {
|
||
if (codeBlockTarget === 'markdown') {
|
||
const textarea = markdownRef.current;
|
||
if (!textarea) return;
|
||
const { selectionStart, selectionEnd } = textarea;
|
||
const selected = markdownSource.slice(selectionStart, selectionEnd);
|
||
const body = selected || '代码';
|
||
const snippet = buildMarkdownCodeBlockSnippet(opts, body);
|
||
const next = markdownSource.slice(0, selectionStart) + snippet + markdownSource.slice(selectionEnd);
|
||
const bodyOffset = snippet.indexOf(body);
|
||
const newStart = bodyOffset >= 0 ? selectionStart + bodyOffset : selectionStart + snippet.length;
|
||
const newEnd = bodyOffset >= 0 ? newStart + body.length : newStart;
|
||
applyTextareaChange(textarea, next, newStart, newEnd, handleMarkdownChange);
|
||
return;
|
||
}
|
||
if (!editor) return;
|
||
editor.chain().focus().setArticleCodeBlock({
|
||
language: opts.language || null,
|
||
lineNumbers: opts.lineNumbers,
|
||
collapsed: opts.collapsed,
|
||
}).run();
|
||
}, [codeBlockTarget, editor, markdownSource, handleMarkdownChange]);
|
||
|
||
const removeCodeBlock = useCallback(() => {
|
||
if (!editor) return;
|
||
if (editor.isActive('codeBlock')) {
|
||
editor.chain().focus().toggleCodeBlock().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
const openTableDialog = useCallback((target: TableTarget) => {
|
||
setTableTarget(target);
|
||
setTableEditing(target === 'rich' && Boolean(editor?.isActive('table')));
|
||
setTableDialogOpen(true);
|
||
}, [editor]);
|
||
|
||
const applyTable = useCallback((opts: TableInsertOptions) => {
|
||
if (tableTarget === 'markdown') {
|
||
const textarea = markdownRef.current;
|
||
if (!textarea) return;
|
||
insertAtCursor(textarea, markdownSource, buildMarkdownTableSnippet(opts), handleMarkdownChange);
|
||
return;
|
||
}
|
||
if (!editor) return;
|
||
editor.chain().focus().insertTable({
|
||
rows: opts.rows,
|
||
cols: opts.cols,
|
||
withHeaderRow: opts.withHeaderRow,
|
||
}).run();
|
||
}, [tableTarget, editor, markdownSource, handleMarkdownChange]);
|
||
|
||
const removeTable = useCallback(() => {
|
||
if (!editor) return;
|
||
if (editor.isActive('table')) {
|
||
editor.chain().focus().deleteTable().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
const applyLink = useCallback((payload: ArticleLinkConfirm) => {
|
||
const { url, text } = payload;
|
||
if (linkTarget === 'markdown') {
|
||
const textarea = markdownRef.current;
|
||
if (!textarea || !url) return;
|
||
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange, { text });
|
||
return;
|
||
}
|
||
if (!editor) return;
|
||
if (!url) {
|
||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||
return;
|
||
}
|
||
// 新标签行为由全站 open_content_links_in_new_tab 在展示层处理
|
||
const linkAttrs = { href: url };
|
||
const { empty } = editor.state.selection;
|
||
const hasLink = editor.isActive('link');
|
||
if (!empty || hasLink) {
|
||
editor.chain().focus().extendMarkRange('link').setLink(linkAttrs).run();
|
||
const { from, to } = editor.state.selection;
|
||
const current = editor.state.doc.textBetween(from, to, '');
|
||
if (text && current !== text) {
|
||
editor.chain().focus().insertContentAt(
|
||
{ from, to },
|
||
{
|
||
type: 'text',
|
||
text,
|
||
marks: [{ type: 'link', attrs: linkAttrs }],
|
||
},
|
||
).run();
|
||
}
|
||
return;
|
||
}
|
||
editor.chain().focus().insertContent({
|
||
type: 'text',
|
||
text: text || '链接文字',
|
||
marks: [{ type: 'link', attrs: linkAttrs }],
|
||
}).run();
|
||
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
|
||
|
||
const removeLink = useCallback(() => {
|
||
if (!editor) return;
|
||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||
}, [editor]);
|
||
|
||
const openImagePicker = useCallback((target: ImagePickerTarget) => {
|
||
setImagePickerTarget(target);
|
||
setImagePickerOpen(true);
|
||
}, []);
|
||
|
||
const applyImageUrls = useCallback((urls: string[]) => {
|
||
if (!urls.length) return;
|
||
if (imagePickerTarget === 'markdown') {
|
||
const textarea = markdownRef.current;
|
||
if (!textarea) return;
|
||
if (urls.length === 1) {
|
||
insertAtCursor(textarea, markdownSource, `\n\n\n\n`, handleMarkdownChange);
|
||
return;
|
||
}
|
||
const layout = suggestImageGroupLayout(urls.length);
|
||
const imgs = urls.map(u => `<img src="${u}" alt="">`).join('');
|
||
const block = `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
|
||
insertAtCursor(textarea, markdownSource, block, handleMarkdownChange);
|
||
return;
|
||
}
|
||
if (!editor) return;
|
||
if (urls.length === 1) {
|
||
editor.chain().focus().setImage({ src: urls[0] }).run();
|
||
return;
|
||
}
|
||
editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run();
|
||
}, [editor, imagePickerTarget, markdownSource, handleMarkdownChange]);
|
||
|
||
const setImageDisplay = useCallback((display: ImageDisplay) => {
|
||
if (!editor) return;
|
||
editor.chain().focus().setImageDisplay(display).run();
|
||
}, [editor]);
|
||
|
||
const wrapSelectedAsGroup = useCallback(() => {
|
||
if (!editor) return;
|
||
if (!editor.commands.wrapImagesInGroup()) {
|
||
notify.warning('请先点击或靠近至少两张连续图片,再合并为图组');
|
||
}
|
||
}, [editor]);
|
||
|
||
const wrapMembersOnly = useCallback(() => {
|
||
if (!editor) return;
|
||
|
||
if (editor.isActive('membersOnly')) {
|
||
editor.chain().focus().exitMembersOnly().run();
|
||
return;
|
||
}
|
||
|
||
const { from, to, empty } = editor.state.selection;
|
||
if (!empty && from !== to) {
|
||
editor.chain().focus().wrapMembersOnly().run();
|
||
return;
|
||
}
|
||
editor.chain().focus().insertMembersOnly().run();
|
||
}, [editor]);
|
||
|
||
const wrapReplyOnly = useCallback(() => {
|
||
if (!editor) return;
|
||
|
||
if (editor.isActive('replyOnly')) {
|
||
editor.chain().focus().exitReplyOnly().run();
|
||
return;
|
||
}
|
||
|
||
const { from, to, empty } = editor.state.selection;
|
||
if (!empty && from !== to) {
|
||
editor.chain().focus().wrapReplyOnly().run();
|
||
return;
|
||
}
|
||
editor.chain().focus().insertReplyOnly().run();
|
||
}, [editor]);
|
||
|
||
const wrapPointsOnly = useCallback(() => {
|
||
if (!editor) return;
|
||
if (editor.isActive('pointsOnly')) {
|
||
editor.chain().focus().exitPointsOnly().run();
|
||
return;
|
||
}
|
||
const { from, to, empty } = editor.state.selection;
|
||
if (!empty && from !== to) {
|
||
editor.chain().focus().wrapPointsOnly(10).run();
|
||
return;
|
||
}
|
||
editor.chain().focus().insertPointsOnly(10).run();
|
||
}, [editor]);
|
||
|
||
const switchToMarkdown = useCallback(() => {
|
||
if (!editor) return;
|
||
const html = sanitizeHtml(editor.getHTML());
|
||
lastValueRef.current = html;
|
||
setMarkdownSource(htmlToMarkdown(html));
|
||
setMode('markdown');
|
||
}, [editor]);
|
||
|
||
const switchToRich = useCallback(() => {
|
||
const html = sanitizeHtml(markdownToHtml(markdownSource));
|
||
lastValueRef.current = html;
|
||
isInternalUpdate.current = true;
|
||
onChange(html);
|
||
if (editor) {
|
||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||
placeCaretInFirstTextblock(editor);
|
||
}
|
||
setMode('rich');
|
||
}, [editor, markdownSource, onChange]);
|
||
|
||
const withMarkdown = useCallback((fn: (
|
||
textarea: HTMLTextAreaElement,
|
||
value: string,
|
||
onChange: (v: string) => void,
|
||
) => void) => () => {
|
||
const textarea = markdownRef.current;
|
||
if (!textarea) return;
|
||
fn(textarea, markdownSource, handleMarkdownChange);
|
||
}, [markdownSource, handleMarkdownChange]);
|
||
|
||
const markdownPreviewHtml = useMemo(
|
||
() => sanitizeHtml(markdownToHtml(markdownSource)),
|
||
[markdownSource],
|
||
);
|
||
|
||
const buildRichTools = useCallback((): ToolBtn[] => {
|
||
if (!editor) return [];
|
||
const imageActive = editor.isActive('image');
|
||
const groupActive = editor.isActive('imageGroup');
|
||
const currentDisplay = (editor.getAttributes('image').display as ImageDisplay) || 'default';
|
||
|
||
const tools: ToolBtn[] = [
|
||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', active: editor.isActive('heading'), action: () => cycleHeading(editor) },
|
||
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
|
||
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
|
||
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
|
||
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
|
||
{ icon: <Minus size={15} />, title: '分割线', action: () => editor.chain().focus().setHorizontalRule().run() },
|
||
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
|
||
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
|
||
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
|
||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', active: editor.isActive('codeBlock'), action: () => openCodeBlockDialog('rich') },
|
||
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入表格;表内可增删行列', active: editor.isActive('table'), action: () => openTableDialog('rich') },
|
||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||
{
|
||
icon: <ImageIcon size={15} />,
|
||
title: '图片',
|
||
hint: '上传、链接或从已上传中选择',
|
||
action: () => openImagePicker('rich'),
|
||
},
|
||
{
|
||
icon: <Columns2 size={15} />,
|
||
title: '合并为图组',
|
||
hint: '点击一张图后合并其附近连续图片(无需框选多张)',
|
||
active: groupActive,
|
||
action: wrapSelectedAsGroup,
|
||
},
|
||
];
|
||
|
||
if (editor.isActive('table')) {
|
||
tools.push(
|
||
{
|
||
icon: <BetweenHorizonalStart size={15} />,
|
||
title: '下方插入行',
|
||
action: () => editor.chain().focus().addRowAfter().run(),
|
||
},
|
||
{
|
||
icon: <BetweenVerticalStart size={15} />,
|
||
title: '右侧插入列',
|
||
action: () => editor.chain().focus().addColumnAfter().run(),
|
||
},
|
||
{
|
||
icon: <Rows3 size={15} />,
|
||
title: '删除行',
|
||
hint: '删除当前行',
|
||
action: () => editor.chain().focus().deleteRow().run(),
|
||
},
|
||
{
|
||
icon: <Columns3 size={15} />,
|
||
title: '删除列',
|
||
hint: '删除当前列',
|
||
action: () => editor.chain().focus().deleteColumn().run(),
|
||
},
|
||
);
|
||
}
|
||
|
||
if (imageActive && !groupActive) {
|
||
tools.push(
|
||
{
|
||
icon: <StretchHorizontal size={15} />,
|
||
title: '通栏大图',
|
||
active: currentDisplay === 'wide',
|
||
action: () => setImageDisplay(currentDisplay === 'wide' ? 'default' : 'wide'),
|
||
},
|
||
{
|
||
icon: <PanelLeft size={15} />,
|
||
title: '左绕排',
|
||
hint: '图片居左,文字环绕',
|
||
active: currentDisplay === 'float-left',
|
||
action: () => setImageDisplay(currentDisplay === 'float-left' ? 'default' : 'float-left'),
|
||
},
|
||
{
|
||
icon: <PanelRight size={15} />,
|
||
title: '右绕排',
|
||
hint: '图片居右,文字环绕',
|
||
active: currentDisplay === 'float-right',
|
||
action: () => setImageDisplay(currentDisplay === 'float-right' ? 'default' : 'float-right'),
|
||
},
|
||
);
|
||
}
|
||
|
||
if (enableContentGates) {
|
||
tools.push(
|
||
{
|
||
icon: <LockKeyhole size={15} />,
|
||
title: '登录可见',
|
||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||
active: editor.isActive('membersOnly'),
|
||
className: 'article-tool-btn--members',
|
||
action: wrapMembersOnly,
|
||
},
|
||
{
|
||
icon: <MessageSquareLock size={15} />,
|
||
title: '回复可见',
|
||
hint: '读者回复后才可见;区块内 Ctrl+Enter 退出',
|
||
active: editor.isActive('replyOnly'),
|
||
className: 'article-tool-btn--reply',
|
||
action: wrapReplyOnly,
|
||
},
|
||
{
|
||
icon: <Coins size={15} />,
|
||
title: '积分可见',
|
||
hint: '读者花费积分解锁;可设价格',
|
||
active: editor.isActive('pointsOnly'),
|
||
className: 'article-tool-btn--points',
|
||
action: wrapPointsOnly,
|
||
},
|
||
);
|
||
}
|
||
|
||
return tools;
|
||
}, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||
|
||
const buildMarkdownTools = useCallback((): ToolBtn[] => {
|
||
const tools: ToolBtn[] = [
|
||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
|
||
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
||
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
|
||
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
|
||
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
|
||
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
|
||
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
|
||
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
|
||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
|
||
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
|
||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||
{
|
||
icon: <ImageIcon size={15} />,
|
||
title: '图片',
|
||
hint: '上传、链接或从已上传中选择',
|
||
action: () => openImagePicker('markdown'),
|
||
},
|
||
];
|
||
if (enableContentGates) {
|
||
tools.push(
|
||
{
|
||
icon: <LockKeyhole size={15} />,
|
||
title: '登录可见',
|
||
hint: '插入 <members-only> 区块',
|
||
className: 'article-tool-btn--members',
|
||
action: withMarkdown(insertMarkdownMembersOnly),
|
||
},
|
||
{
|
||
icon: <MessageSquareLock size={15} />,
|
||
title: '回复可见',
|
||
hint: '插入 <reply-only> 区块',
|
||
className: 'article-tool-btn--reply',
|
||
action: withMarkdown(insertMarkdownReplyOnly),
|
||
},
|
||
{
|
||
icon: <Coins size={15} />,
|
||
title: '积分可见',
|
||
hint: '插入 <points-only> 区块',
|
||
className: 'article-tool-btn--points',
|
||
action: withMarkdown(insertMarkdownPointsOnly),
|
||
},
|
||
);
|
||
}
|
||
return tools;
|
||
}, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]);
|
||
|
||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||
const words = mode === 'markdown'
|
||
? countWords(markdownSource)
|
||
: (editor ? countWords(editor.getText()) : 0);
|
||
|
||
return (
|
||
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
|
||
<div className="article-editor-bar">
|
||
<div className="article-editor-tools">
|
||
{renderToolButtons(tools)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="article-editor-body">
|
||
{mode === 'rich' ? (
|
||
<div className="article-editor-pane article-editor-pane--rich">
|
||
<div
|
||
className="article-editor-scroll"
|
||
onPointerDown={e => {
|
||
// 点击编辑器外空白区域时,聚焦并将光标置于文末
|
||
if (editor && (e.target === e.currentTarget || (e.target as HTMLElement).classList.contains('article-editor-content'))) {
|
||
e.preventDefault();
|
||
editor.chain().focus('end').run();
|
||
}
|
||
}}
|
||
>
|
||
<EditorContent editor={editor} className="article-editor-content" />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="article-editor-markdown">
|
||
<div className="article-editor-pane article-editor-pane--source">
|
||
<div
|
||
className="article-editor-scroll"
|
||
onPointerDown={e => {
|
||
if (e.target === e.currentTarget) {
|
||
e.preventDefault();
|
||
const ta = markdownRef.current;
|
||
if (ta) {
|
||
ta.focus();
|
||
const len = ta.value.length;
|
||
ta.setSelectionRange(len, len);
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
<textarea
|
||
ref={markdownRef}
|
||
className="article-editor-markdown-input"
|
||
value={markdownSource}
|
||
onChange={e => handleMarkdownChange(e.target.value)}
|
||
onKeyDown={e => handleMarkdownTabKey(e, handleMarkdownChange)}
|
||
placeholder="在此编写 Markdown 源码…"
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="article-editor-markdown-preview">
|
||
<div className="article-editor-markdown-preview-label">预览</div>
|
||
<PostContent
|
||
html={markdownPreviewHtml}
|
||
isLoggedIn
|
||
className="article-editor-markdown-preview-body post-detail-content"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="article-editor-status">
|
||
<div className="article-editor-status-meta">
|
||
<span className="article-editor-wordcount">{words} 字</span>
|
||
<span className="article-editor-status-sep" aria-hidden>·</span>
|
||
<span className="article-editor-mode-label">
|
||
{mode === 'rich' ? '所见即所得' : 'Markdown'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="article-editor-status-actions">
|
||
<Tooltip
|
||
content={mode === 'rich' ? 'Markdown 源码' : '富文本编辑'}
|
||
hint={mode === 'rich' ? '切换为 Markdown 源码编写' : '返回所见即所得编辑'}
|
||
align="end"
|
||
side="top"
|
||
>
|
||
<button
|
||
type="button"
|
||
className={`article-editor-view-btn${mode === 'markdown' ? ' active' : ''}`}
|
||
onMouseDown={e => e.preventDefault()}
|
||
onClick={mode === 'rich' ? switchToMarkdown : switchToRich}
|
||
>
|
||
{mode === 'rich' ? <FileCode size={15} /> : <PenLine size={15} />}
|
||
<span>{mode === 'rich' ? '源码' : '富文本'}</span>
|
||
</button>
|
||
</Tooltip>
|
||
|
||
<Tooltip
|
||
content={fullscreen ? '退出全屏' : '全屏编辑'}
|
||
hint={fullscreen ? 'Esc 也可退出' : '沉浸式编写长文'}
|
||
align="end"
|
||
side="top"
|
||
>
|
||
<button
|
||
type="button"
|
||
className={`article-editor-view-btn${fullscreen ? ' active' : ''}`}
|
||
onMouseDown={e => e.preventDefault()}
|
||
onClick={() => setFullscreen(v => !v)}
|
||
>
|
||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||
<span>{fullscreen ? '退出' : '全屏'}</span>
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<ArticleLinkDialog
|
||
open={linkDialogOpen}
|
||
onOpenChange={setLinkDialogOpen}
|
||
initialUrl={linkDialogUrl}
|
||
initialText={linkDialogText}
|
||
editing={linkDialogEditing}
|
||
onConfirm={applyLink}
|
||
onRemove={linkTarget === 'rich' && linkDialogEditing ? removeLink : undefined}
|
||
/>
|
||
<ArticleImagePickerDialog
|
||
open={imagePickerOpen}
|
||
onOpenChange={setImagePickerOpen}
|
||
onInsert={applyImageUrls}
|
||
/>
|
||
<ArticleCodeBlockDialog
|
||
open={codeBlockDialogOpen}
|
||
onOpenChange={setCodeBlockDialogOpen}
|
||
initial={codeBlockInitial}
|
||
editing={codeBlockEditing}
|
||
onConfirm={applyCodeBlock}
|
||
onRemove={codeBlockEditing ? removeCodeBlock : undefined}
|
||
/>
|
||
<ArticleTableDialog
|
||
open={tableDialogOpen}
|
||
onOpenChange={setTableDialogOpen}
|
||
editing={tableEditing}
|
||
onConfirm={applyTable}
|
||
onRemove={tableEditing ? removeTable : undefined}
|
||
/>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export default ArticleEditor;
|