完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, type ReactNode,
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
||||
} from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
@@ -9,14 +10,28 @@ import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {
|
||||
Bold, Italic, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
||||
import {
|
||||
wrapMarkdownSelection,
|
||||
prefixMarkdownLines,
|
||||
cycleMarkdownHeading,
|
||||
insertMarkdownMembersOnly,
|
||||
insertMarkdownLink,
|
||||
} from '../utils/markdownFormat';
|
||||
import { countWords } from '../utils/text';
|
||||
import { api } from '../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
import { TabIndent } from './editor/TabIndentExtension';
|
||||
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
getHTML: () => string;
|
||||
@@ -30,14 +45,21 @@ interface Props {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type EditorMode = 'rich' | 'markdown';
|
||||
type LinkTarget = 'rich' | 'markdown';
|
||||
|
||||
interface ToolBtn {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||||
|
||||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
@@ -48,7 +70,7 @@ function isEditorEmpty(editor: Editor): boolean {
|
||||
return editor.state.doc.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 标题循环:无标题 → H2 → H3 → … → H6 → 正文 */
|
||||
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
|
||||
function cycleHeading(editor: Editor) {
|
||||
for (let level = 2; level <= 6; level += 1) {
|
||||
if (editor.isActive('heading', { level })) {
|
||||
@@ -63,6 +85,51 @@ function cycleHeading(editor: Editor) {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
}
|
||||
|
||||
/** 触发图片文件选择并上传 */
|
||||
async function uploadPostImageFile(): Promise<string | null> {
|
||||
return new Promise(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
resolve(url);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** 渲染工具栏按钮列表 */
|
||||
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 = '在此撰写正文…' },
|
||||
ref,
|
||||
@@ -70,11 +137,18 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
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 [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
|
||||
const markdownRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
||||
heading: { levels: [2, 3, 4, 5, 6] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
@@ -83,8 +157,17 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
defaultProtocol: 'https',
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||||
return MEMBERS_ONLY_PLACEHOLDER;
|
||||
}
|
||||
return placeholder;
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
MembersOnly,
|
||||
TabIndent,
|
||||
],
|
||||
content: sanitizeHtml(value) || '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
@@ -92,20 +175,41 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
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);
|
||||
},
|
||||
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) return;
|
||||
if (!editor || mode !== 'rich') return;
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
@@ -114,46 +218,104 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
if (next === lastValueRef.current) return;
|
||||
lastValueRef.current = next;
|
||||
editor.commands.setContent(next || '', { emitUpdate: false });
|
||||
}, [value, 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: () => (editor ? sanitizeHtml(editor.getHTML()) : value),
|
||||
isEmpty: () => (editor ? isEditorEmpty(editor) : !value.trim()),
|
||||
focus: () => editor?.commands.focus(),
|
||||
}), [editor, value]);
|
||||
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;
|
||||
}
|
||||
editor?.commands.focus();
|
||||
},
|
||||
}), [editor, value, mode, markdownSource]);
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
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 prev = editor.getAttributes('link').href as string | undefined;
|
||||
setLinkDialogUrl(prev ?? '');
|
||||
} else {
|
||||
setLinkDialogUrl('');
|
||||
}
|
||||
setLinkTarget(target);
|
||||
setLinkDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const applyLink = useCallback((url: string) => {
|
||||
if (linkTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea || !url) return;
|
||||
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes('link').href as string | undefined;
|
||||
const url = window.prompt('链接地址', prev ?? 'https://');
|
||||
if (url === null) return;
|
||||
if (!url.trim()) {
|
||||
if (!url) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const removeLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
}, [editor]);
|
||||
|
||||
const setImage = useCallback(() => {
|
||||
const setImage = useCallback(async () => {
|
||||
if (!editor) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
const url = await uploadPostImageFile();
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}, [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();
|
||||
@@ -162,113 +324,197 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
editor.chain().focus().insertMembersOnly().run();
|
||||
}, [editor]);
|
||||
|
||||
const buildTools = useCallback((): ToolBtn[] => {
|
||||
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 });
|
||||
}
|
||||
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 insertMarkdownImage = useCallback(async () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const url = await uploadPostImageFile();
|
||||
if (!url) return;
|
||||
insertAtCursor(textarea, markdownSource, `\n\n\n\n`, handleMarkdownChange);
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const markdownPreviewHtml = useMemo(
|
||||
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
|
||||
[markdownSource],
|
||||
);
|
||||
|
||||
const buildRichTools = useCallback((): ToolBtn[] => {
|
||||
if (!editor) return [];
|
||||
return [
|
||||
{
|
||||
icon: <strong>H</strong>,
|
||||
title: '标题(H2,再次点击升级)',
|
||||
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: <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: '代码块',
|
||||
active: editor.isActive('codeBlock'),
|
||||
action: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
icon: <LinkIcon size={15} />,
|
||||
title: '链接',
|
||||
active: editor.isActive('link'),
|
||||
action: setLink,
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon size={15} />,
|
||||
title: '上传图片',
|
||||
action: setImage,
|
||||
},
|
||||
{ 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: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见(选中文字后点击可包裹)',
|
||||
title: '登录可见',
|
||||
hint: '独立输入区;Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
];
|
||||
}, [editor, setLink, setImage, wrapMembersOnly]);
|
||||
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
|
||||
|
||||
const tools = buildTools();
|
||||
const words = editor ? countWords(editor.getText()) : 0;
|
||||
const buildMarkdownTools = useCallback((): 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: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入 <members-only> 区块',
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
], [withMarkdown, openLinkDialog, insertMarkdownImage]);
|
||||
|
||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||
const words = mode === 'markdown'
|
||||
? countWords(markdownSource)
|
||||
: (editor ? countWords(editor.getText()) : 0);
|
||||
|
||||
return (
|
||||
<div className="article-editor">
|
||||
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
|
||||
<div className="article-editor-bar">
|
||||
<div className="article-editor-tools">
|
||||
{tools.map((t, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
title={t.title}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
))}
|
||||
{renderToolButtons(tools)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="article-editor-body">
|
||||
<EditorContent editor={editor} className="article-editor-content" />
|
||||
{mode === 'rich' ? (
|
||||
<div className="article-editor-pane article-editor-pane--rich">
|
||||
<div className="article-editor-scroll">
|
||||
<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">
|
||||
<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>
|
||||
<div
|
||||
className="article-editor-markdown-preview-body post-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status">
|
||||
<span>{words} 字</span>
|
||||
<span>富文本</span>
|
||||
<div className="article-editor-status-meta">
|
||||
<span>{words} 字</span>
|
||||
<span className="article-editor-status-sep">·</span>
|
||||
<span>
|
||||
{mode === 'rich' ? '富文本' : 'Markdown 源码'}
|
||||
{' · Tab 缩进 / Shift+Tab 回退'}
|
||||
{mode === 'rich' ? ' · 登录可见内 Ctrl+Enter 退出' : ''}
|
||||
</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"
|
||||
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}
|
||||
onConfirm={applyLink}
|
||||
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user