'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import Highlight from '@tiptap/extension-highlight';
import Underline from '@tiptap/extension-underline';
import { TextStyle, Color, FontFamily } from '@tiptap/extension-text-style';
import TextAlign from '@tiptap/extension-text-align';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
import { MathInline, MathBlock, openMathEditor } from './MathExtension';
import { PageBreakExtension } from './PageBreakExtension';
import GhostMark from './GhostMark';
import { useEffect, useCallback, useRef, useState, useMemo, useId, forwardRef, useImperativeHandle } from 'react';
import { useAppStore } from '../store/useAppStore';
// ==================== AI 模式配置 ====================
const AI_MODES = [
{ key: 'continue', label: '✦ 续写', desc: '从光标处自然续写', needsSelection: false },
{ key: 'rewrite', label: '✎ 润色', desc: '提升选中文字质量', needsSelection: true },
{ key: 'expand', label: '⊕ 扩写', desc: '丰富细节与描写', needsSelection: true },
{ key: 'condense', label: '⊖ 精简', desc: '浓缩核心内容', needsSelection: true },
];
// ==================== 虚拟分页常量 ====================
const PAGE_HEIGHT = 1056; // A4 纸 @ 96dpi
const PAGE_GAP = 24; // 页间灰色间隙
const Editor = forwardRef(function Editor({ content, onUpdate, editable = true, onAiRequest, onArchiveGeneration, contextItems, contextSelection, setContextSelection }, ref) {
const clipPathId = useId();
const debounceRef = useRef(null);
const contentRef = useRef(null);
// 页数状态
const [pageCount, setPageCount] = useState(1);
// 页边距状态(从 localStorage 读取)
const [margins, setMargins] = useState(() => {
if (typeof window !== 'undefined') {
try {
const saved = JSON.parse(localStorage.getItem('author-margins'));
if (saved) return { x: saved.x ?? 96, y: saved.y ?? 96 };
} catch { }
}
return { x: 96, y: 96 };
});
// 边距变更自动保存
useEffect(() => {
localStorage.setItem('author-margins', JSON.stringify(margins));
}, [margins]);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder: '开始写作…让灵感自由流淌',
}),
CharacterCount,
Highlight.configure({ multicolor: true }),
Underline,
TextStyle,
Color,
FontFamily.configure({
types: ['textStyle'],
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
defaultAlignment: 'left',
}),
Subscript,
Superscript,
TaskList,
TaskItem.configure({
nested: true,
}),
Markdown.configure({
html: true,
tightLists: true,
bulletListMarker: '-',
transformPastedText: true,
transformCopiedText: false,
}),
MathInline,
MathBlock,
PageBreakExtension,
GhostMark,
],
content: content || '',
editable,
editorProps: {
attributes: {
class: 'tiptap',
},
},
onUpdate: ({ editor }) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const html = editor.getHTML();
const text = editor.getText();
onUpdate?.({
html,
text,
wordCount: text.replace(/\s/g, '').length,
});
}, 500);
},
});
// 防止父组件传来的 content 稍有差异即导致整个编辑器重置并跳动
// 仅当新内容与当前内容脱节时才重置(例如切换章节)
const previousChapterId = useRef(content);
useEffect(() => {
if (!editor || content === undefined) return;
const currentHtml = editor.getHTML();
// 简单启发式:如果长度差距极大(用户不可能一秒内打这么多字),或者内容完全不包含现有内容,才做全量替换
if (content !== currentHtml) {
// 我们需要区分是“用户打字后传回的最新内容”(不用动)还是“因为点击左侧栏切换了章节”(需要重置)
// 如果新传入的 content 和当前存在非常显著差异,才执行 setContent
if (Math.abs(content.length - currentHtml.length) > 50 || !currentHtml.includes(content.substring(0, 50))) {
editor.commands.setContent(content || '', false);
}
}
}, [content, editor]);
// 将方法暴露给父组件
useEffect(() => {
if (editor) {
editor.getSelectedText = () => {
const { from, to } = editor.state.selection;
if (from === to) return editor.getText();
return editor.state.doc.textBetween(from, to, ' ');
};
editor.insertText = (text) => {
editor.chain().focus().insertContent(text).run();
};
editor.replaceSelection = (text) => {
const { from, to } = editor.state.selection;
if (from === to) {
editor.chain().focus().insertContent(text).run();
} else {
editor.chain().focus().deleteSelection().insertContent(text).run();
}
};
}
}, [editor]);
// 通过 ref 暴露方法给父组件(侧栏存档插入 + 大纲读取用)
useImperativeHandle(ref, () => ({
getEditor: () => editor,
insertText: (text) => {
if (!editor) return;
// 规范化换行
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
// 按空行(双换行)分段,段内用
换行
const blocks = normalized.split(/\n\n+/);
const html = blocks
.map(block => {
const lines = block.split('\n').map(l => l.trim()).filter(l => l);
return `
${lines.join('
')}
`;
})
.filter(p => p !== '')
.join('');
editor.chain().focus().insertContent(html).run();
},
}), [editor]);
// ===== 核心:ResizeObserver 监听内容高度,计算页数 =====
const observerRef = useRef(null);
const contentCallbackRef = useCallback((node) => {
// 清理旧 observer
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (!node) return;
contentRef.current = node;
const observer = new ResizeObserver(() => {
if (!contentRef.current) return;
// scrollHeight 更准确地反映内容实际高度
const height = contentRef.current.scrollHeight;
// 把 PAGE_GAP 补进来算精确数学除法
const needed = Math.max(1, Math.ceil((height + PAGE_GAP) / (PAGE_HEIGHT + PAGE_GAP)));
setPageCount(prev => prev !== needed ? needed : prev);
});
observer.observe(node);
observerRef.current = observer;
}, []);
if (!editor) return null;
// 容器总高度 = 页数 × 单页高 + 间隙总高
const totalWorkspaceHeight = pageCount * PAGE_HEIGHT + (pageCount - 1) * PAGE_GAP;
return (
<>
{
// 点击灰色空隙处自动聚焦到文末
if (e.target.closest('.editor-container') && !e.target.closest('.tiptap')) {
editor?.chain().focus('end').run();
}
}}
>
{/* SVG clip definition — 每页一个矩形,文字只在页面内可见 */}
{/* ===== 底层:白色纸张卡片阵列 ===== */}
{Array.from({ length: pageCount }).map((_, i) => (
))}
{/* ===== 页间标签(在灰色间隙中显示页码)===== */}
{pageCount > 1 && Array.from({ length: pageCount - 1 }).map((_, i) => {
const gapTop = (i + 1) * PAGE_HEIGHT + i * PAGE_GAP;
return (
第 {i + 1} 页 / 共 {pageCount} 页
);
})}
{/* ===== 文字层(clipPath 严格裁切到页面区域)===== */}
>
);
});
export default Editor;
// ==================== Inline AI 组件 ====================
function InlineAI({ editor, onAiRequest, onArchiveGeneration, contextItems, contextSelection, setContextSelection }) {
const { setShowSettings, setJumpToNodeId } = useAppStore();
const [visible, setVisible] = useState(false);
const [mode, setMode] = useState('continue');
const [instruction, setInstruction] = useState('');
const [streaming, setStreaming] = useState(false);
const [pendingGhost, setPendingGhost] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const abortRef = useRef(null);
const inputRef = useRef(null);
const popoverRef = useRef(null);
const typeQueueRef = useRef([]);
const typingRef = useRef(false);
// Ghost text tracking
const ghostStartRef = useRef(null);
const ghostTextRef = useRef('');
// Rewrite backup
const originalTextRef = useRef(null);
const originalRangeRef = useRef(null);
const currentModeRef = useRef('continue');
// 文档快照:生成前保存,拒绝时恢复
const savedDocRef = useRef(null);
// 获取选中文本
const getSelectedText = useCallback(() => {
if (!editor) return '';
const { from, to } = editor.state.selection;
if (from === to) return '';
return editor.state.doc.textBetween(from, to, ' ');
}, [editor]);
// 获取上文(用于续写)
const getContextText = useCallback(() => {
if (!editor) return '';
const text = editor.getText();
return text.length > 1500 ? text.slice(-1500) : text;
}, [editor]);
// 计算浮窗位置(基于光标,使用视口坐标 position:fixed)
const updatePosition = useCallback(() => {
if (!editor) return;
const { view } = editor;
const head = editor.state.selection.head;
const coords = view.coordsAtPos(head, -1);
const GAP = 16;
const popoverW = 360;
const popoverH = 130;
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = coords.bottom + 8;
let left = coords.left;
left = Math.max(GAP, Math.min(left, vw - popoverW - GAP));
if (top + popoverH > vh - GAP) {
top = coords.top - popoverH - 8;
}
if (top < GAP) top = GAP;
setPosition({ top, left });
}, [editor]);
// 打开浮窗
const open = useCallback(() => {
if (pendingGhost) return; // 有待确认的 ghost 时不打开新的
const selected = getSelectedText();
setMode(selected ? 'rewrite' : 'continue');
setInstruction('');
updatePosition();
setVisible(true);
}, [getSelectedText, updatePosition, pendingGhost]);
// 关闭浮窗
const close = useCallback(() => {
if (streaming || pendingGhost) return;
setVisible(false);
setInstruction('');
editor?.chain().focus().run();
}, [streaming, pendingGhost, editor]);
// 停止生成
const stop = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
typeQueueRef.current = [];
typingRef.current = false;
setStreaming(false);
// 如果已经有 ghost 文本,进入待确认状态
if (ghostTextRef.current) {
setPendingGhost(true);
}
}, []);
// 打字机效果:逐字符插入编辑器,带 ghost mark
// 使用原生 ProseMirror transaction,彻底避免 scrollIntoView
const suppressScrollRef = useRef(false);
const startTyping = useCallback(() => {
if (typingRef.current) return;
typingRef.current = true;
const typeNext = () => {
if (typeQueueRef.current.length === 0) {
typingRef.current = false;
return;
}
const char = typeQueueRef.current.shift();
if (char === '\n') {
if (typeQueueRef.current[0] !== '\n') {
// 换行:使用原生 split,不调用 scrollIntoView
ghostTextRef.current += '\n';
const { state } = editor.view;
const tr = state.tr.split(state.selection.from);
editor.view.dispatch(tr);
}
} else {
// 用原生 ProseMirror transaction 插入字符 + 标记 ghost
const { state } = editor.view;
const tr = state.tr.insertText(char);
const ghostMark = state.schema.marks.ghostText.create();
const to = tr.selection.from;
const from = to - char.length;
tr.addMark(from, to, ghostMark);
// 故意不调用 tr.scrollIntoView() — 防止滚动跳回
editor.view.dispatch(tr);
ghostTextRef.current += char;
}
requestAnimationFrame(() => setTimeout(typeNext, 20));
};
typeNext();
}, [editor]);
// 将文本块加入打字队列
const enqueueText = useCallback((text) => {
for (const char of text) {
typeQueueRef.current.push(char);
}
startTyping();
}, [startTyping]);
// ========== Ghost 操作 ==========
// 接受:去掉 ghost mark,文本变成正式内容
const acceptGhost = useCallback(() => {
editor?.commands.acceptAllGhost();
// 归档
onArchiveGeneration?.({
mode: currentModeRef.current,
instruction: instruction.trim(),
text: ghostTextRef.current,
status: 'accepted',
});
ghostTextRef.current = '';
ghostStartRef.current = null;
originalTextRef.current = null;
originalRangeRef.current = null;
setPendingGhost(false);
setVisible(false);
editor?.chain().focus().run();
}, [editor, instruction, onArchiveGeneration]);
// 拒绝:删除 ghost 文本(含换行符),改写模式还原原文
const rejectGhost = useCallback(() => {
// 归档(标记为拒绝)
onArchiveGeneration?.({
mode: currentModeRef.current,
instruction: instruction.trim(),
text: ghostTextRef.current,
status: 'rejected',
});
// 直接恢复生成前的文档快照(最可靠,彻底消除残留空行)
if (savedDocRef.current && editor) {
editor.commands.setContent(savedDocRef.current, false);
} else {
// 回退:若无快照,使用 mark 删除
editor?.commands.removeAllGhost(ghostStartRef.current);
if (originalTextRef.current && originalRangeRef.current) {
const { from } = originalRangeRef.current;
editor?.chain()
.focus()
.insertContentAt(from, originalTextRef.current)
.run();
}
}
ghostTextRef.current = '';
ghostStartRef.current = null;
originalTextRef.current = null;
originalRangeRef.current = null;
savedDocRef.current = null;
setPendingGhost(false);
setVisible(false);
editor?.chain().focus().run();
}, [editor, instruction, onArchiveGeneration]);
// 重新生成:拒绝当前 ghost + 重新 generate
const regenerate = useCallback(() => {
// 先归档拒绝
onArchiveGeneration?.({
mode: currentModeRef.current,
instruction: instruction.trim(),
text: ghostTextRef.current,
status: 'rejected',
});
// 恢复文档快照
if (savedDocRef.current && editor) {
editor.commands.setContent(savedDocRef.current, false);
} else {
editor?.commands.removeAllGhost(ghostStartRef.current);
}
ghostTextRef.current = '';
setPendingGhost(false);
// 触发新一轮生成(savedDocRef 保留不清空,供下次拒绝使用)
setTimeout(() => generate(), 50);
}, [editor, instruction, onArchiveGeneration]);
// 执行 AI 生成
const generate = useCallback(async () => {
if (!onAiRequest || streaming) return;
const selectedText = getSelectedText();
const contextText = getContextText();
let actualMode = mode;
if (AI_MODES.find(m => m.key === mode)?.needsSelection && !selectedText) {
actualMode = 'continue';
setMode('continue');
}
currentModeRef.current = actualMode;
const text = selectedText || contextText;
if (!text.trim() && actualMode !== 'continue') return;
setStreaming(true);
setPendingGhost(false);
const controller = new AbortController();
abortRef.current = controller;
typeQueueRef.current = [];
ghostTextRef.current = '';
// 保存生成前的文档快照(在任何修改之前)
savedDocRef.current = editor.getJSON();
// 改写模式:备份原文
if (selectedText && actualMode !== 'continue') {
const { from, to } = editor.state.selection;
originalTextRef.current = selectedText;
originalRangeRef.current = { from, to };
editor?.chain().focus().deleteSelection().run();
} else {
originalTextRef.current = null;
originalRangeRef.current = null;
editor?.chain().focus().run();
}
ghostStartRef.current = editor.state.selection.head;
try {
await onAiRequest({
mode: actualMode,
text,
instruction: instruction.trim(),
signal: controller.signal,
onChunk: (chunk) => {
enqueueText(chunk);
},
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('AI 生成错误:', err);
}
} finally {
await new Promise(resolve => {
const check = () => {
if (typeQueueRef.current.length === 0 && !typingRef.current) resolve();
else setTimeout(check, 50);
};
check();
});
setStreaming(false);
abortRef.current = null;
// 进入待确认状态
if (ghostTextRef.current) {
setPendingGhost(true);
// 将光标(ghost 文本末端)滚入可视区域,确保操作栏可见
try {
const scrollContainer = editor.view.dom.closest('.editor-container');
if (scrollContainer) {
const head = editor.state.selection.head;
const coords = editor.view.coordsAtPos(head, -1);
const containerRect = scrollContainer.getBoundingClientRect();
const relativeBottom = coords.bottom - containerRect.top + scrollContainer.scrollTop;
const targetScroll = relativeBottom - containerRect.height + 80;
if (targetScroll > scrollContainer.scrollTop) {
scrollContainer.scrollTop = targetScroll;
}
}
} catch { /* 回退:不滚动也不阻塞 */ }
} else {
setVisible(false);
}
}
}, [onAiRequest, streaming, mode, instruction, getSelectedText, getContextText, editor, enqueueText, updatePosition]);
// 键盘快捷键:Ctrl+J 打开,Esc 关闭/拒绝,Tab 接受
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'j') {
e.preventDefault();
if (pendingGhost) return;
if (visible) close();
else open();
}
if (e.key === 'Escape' && (visible || pendingGhost)) {
e.preventDefault();
if (streaming) stop();
else if (pendingGhost) rejectGhost();
else close();
}
// Tab 接受 ghost text
if (e.key === 'Tab' && pendingGhost) {
e.preventDefault();
acceptGhost();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [visible, streaming, pendingGhost, open, close, stop, rejectGhost, acceptGhost]);
// 点击外部关闭(但待确认状态不自动关闭)
useEffect(() => {
if (!visible) return;
const handler = (e) => {
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
if (!streaming && !pendingGhost) close();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [visible, streaming, pendingGhost, close]);
// 待确认状态时不显示浮窗,改为在幽灵文本末尾显示操作栏
if (!visible && !pendingGhost) {
return null;
}
// 待确认状态:在幽灵文本末尾内联显示操作栏(Cursor 风格)
if (pendingGhost) {
// 获取光标位置(幽灵文本末尾)
let ghostPos = { top: 0, left: 0 };
try {
const head = editor.state.selection.head;
const coords = editor.view.coordsAtPos(head, -1);
ghostPos = { top: coords.bottom + 4, left: coords.left };
// 确保不超出视口
const vw = window.innerWidth;
if (ghostPos.left + 280 > vw) ghostPos.left = vw - 296;
if (ghostPos.left < 16) ghostPos.left = 16;
} catch { /* 位置获取失败时用默认值 */ }
return (
Tab 接受 · Esc 拒绝
);
}
const selectedText = getSelectedText();
const availableModes = selectedText
? AI_MODES
: AI_MODES.filter(m => !m.needsSelection);
return (
{/* 模式选择 */}
{availableModes.map(m => (
))}
{/* 参考设定集(可折叠) */}
{
setJumpToNodeId(nodeId);
setShowSettings(true);
}}
/>
{/* 指令输入 */}
setInstruction(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !streaming) {
e.preventDefault();
generate();
}
}}
disabled={streaming}
/>
{streaming ? (
) : (
)}
{/* 状态提示 */}
{streaming && (
▊ AI 正在写入编辑器…
)}
{!streaming && selectedText && (
已选中 {selectedText.length} 字
)}
{!streaming && !selectedText && (
将在光标处续写 · Ctrl+J 打开/关闭
)}
);
}
// ==================== Inline 参考面板(设定集勾选) ====================
function InlineContextPanel({ contextItems, contextSelection, setContextSelection, onJumpToNode }) {
const [expanded, setExpanded] = useState(false);
// 只显示设定集条目,不显示对话历史
const settingsItems = useMemo(() =>
(contextItems || []).filter(it => it.category !== 'dialogue'),
[contextItems]);
// 按分组归类,过滤掉空分组
const grouped = useMemo(() => {
const groups = {};
for (const item of settingsItems) {
const g = item.group || '其他';
if (!groups[g]) groups[g] = [];
groups[g].push(item);
}
return groups;
}, [settingsItems]);
const selectedCount = settingsItems.filter(it => contextSelection?.has(it.id)).length;
const totalCount = settingsItems.length;
if (totalCount === 0) return null;
const toggleItem = (itemId) => {
setContextSelection?.(prev => {
const next = new Set(prev);
if (next.has(itemId)) next.delete(itemId);
else next.add(itemId);
return next;
});
};
const toggleGroup = (groupName) => {
const items = grouped[groupName] || [];
setContextSelection?.(prev => {
const next = new Set(prev);
const allChecked = items.every(it => prev.has(it.id));
items.forEach(it => {
if (allChecked) next.delete(it.id);
else next.add(it.id);
});
return next;
});
};
return (
{expanded && (
{Object.entries(grouped).map(([groupName, items]) => {
const checkedCount = items.filter(it => contextSelection?.has(it.id)).length;
const allChecked = checkedCount === items.length;
return (
);
})}
)}
);
}
// ==================== 颜色选择器组件 ====================
const PRESET_COLORS = [
'#000000', '#434343', '#666666', '#999999', '#cccccc',
'#c0392b', '#e74c3c', '#e67e22', '#f39c12', '#f1c40f',
'#27ae60', '#2ecc71', '#1abc9c', '#2980b9', '#3498db',
'#8e44ad', '#9b59b6', '#e91e63', '#795548', '#607d8b',
];
function ColorPicker({ label, currentColor, onSelect, onClose }) {
return (
e.preventDefault()} onClick={e => e.stopPropagation()}>
{label}
{PRESET_COLORS.map(color => (
);
}
// ==================== 字体族选项 ====================
const FONT_FAMILIES = [
{ label: '默认(宋体)', value: '' },
{ label: '黑体', value: '"Noto Sans SC", "Microsoft YaHei", sans-serif' },
{ label: '楷体', value: '"KaiTi", "STKaiti", serif' },
{ label: '仿宋', value: '"FangSong", "STFangsong", serif' },
{ label: 'serif', value: '"Noto Serif SC", "Source Han Serif SC", Georgia, serif' },
{ label: 'monospace', value: '"SF Mono", "Cascadia Code", "Consolas", monospace' },
];
const FONT_SIZES = [12, 14, 15, 16, 17, 18, 20, 22, 24, 28, 32];
// ==================== 工具栏 ====================
function EditorToolbar({ editor, margins, setMargins }) {
if (!editor) return null;
const [showFontColor, setShowFontColor] = useState(false);
const [showBgColor, setShowBgColor] = useState(false);
const [showFontFamily, setShowFontFamily] = useState(false);
const [showFontSize, setShowFontSize] = useState(false);
const [showTypeset, setShowTypeset] = useState(false);
const [showMargins, setShowMargins] = useState(false);
const [fontSize, setFontSize] = useState(() => {
if (typeof window !== 'undefined') return parseInt(localStorage.getItem('author-font-size')) || 17;
return 17;
});
const [lineHeight, setLineHeight] = useState(() => {
if (typeof window !== 'undefined') return parseFloat(localStorage.getItem('author-line-height')) || 1.9;
return 1.9;
});
useEffect(() => {
document.documentElement.style.setProperty('--editor-font-size', `${fontSize}px`);
document.documentElement.style.setProperty('--editor-line-height', String(lineHeight));
localStorage.setItem('author-font-size', String(fontSize));
localStorage.setItem('author-line-height', String(lineHeight));
}, [fontSize, lineHeight]);
const closeAll = () => {
setShowFontColor(false);
setShowBgColor(false);
setShowFontFamily(false);
setShowFontSize(false);
setShowTypeset(false);
setShowMargins(false);
};
const toolbarRef = useRef(null);
useEffect(() => {
const handler = (e) => {
if (e.target.closest('.toolbar-dropdown-wrap')) return;
closeAll();
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
const currentFontFamily = editor.getAttributes('textStyle').fontFamily || '';
const currentFontLabel = FONT_FAMILIES.find(f => f.value === currentFontFamily)?.label || '默认';
const currentColor = editor.getAttributes('textStyle').color || '';
const currentHighlight = editor.getAttributes('highlight').color || '';
return (
{ if (e.target.tagName !== 'INPUT') e.preventDefault(); }}>
{/* 撤销/重做 */}
{/* 字体族 */}
e.stopPropagation()}>
{showFontFamily && (
{FONT_FAMILIES.map(f => (
))}
)}
{/* 格式按钮 */}
{/* 字体颜色 */}
e.stopPropagation()}>
{showFontColor && (
{
if (color) editor.chain().focus().setColor(color).run();
else editor.chain().focus().unsetColor().run();
}}
onClose={() => setShowFontColor(false)}
/>
)}
{/* 背景色/高亮 */}
e.stopPropagation()}>
{showBgColor && (
{
if (color) editor.chain().focus().toggleHighlight({ color }).run();
else editor.chain().focus().unsetHighlight().run();
}}
onClose={() => setShowBgColor(false)}
/>
)}
{/* 标题 */}
{/* 对齐 */}
{/* 字号行距 */}
e.stopPropagation()}>
{showTypeset && (
)}
{/* 📄 页面边距 */}
e.stopPropagation()}>
{showMargins && (
)}
{/* 列表和引用 */}
);
}
// ==================== 状态栏 ====================
function StatusBar({ editor, pageCount }) {
if (!editor) return null;
const characterCount = editor.storage.characterCount;
const chars = characterCount?.characters() ?? 0;
const words = editor.getText().replace(/\s/g, '').length;
return (
{words} 字
{chars} 字符
共 {pageCount} 页
Ctrl+J AI助手
自动保存
© 2026 YuanShiJiLoong
);
}