'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) => { const pageTop = i * (PAGE_HEIGHT + PAGE_GAP); return ; })} {/* ===== 底层:白色纸张卡片阵列 ===== */}
{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 (
{items.map(item => (
{item._nodeId && onJumpToNode && ( )}
))}
); })}
)}
); } // ==================== 颜色选择器组件 ==================== 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 && (
setFontSize(Number(e.target.value))} /> {fontSize}px
setLineHeight(Number(e.target.value))} /> {lineHeight.toFixed(1)}
)}
{/* 📄 页面边距 */}
e.stopPropagation()}> {showMargins && (
setMargins(prev => ({ ...prev, y: Number(e.target.value) }))} /> {margins.y}px
setMargins(prev => ({ ...prev, x: Number(e.target.value) }))} /> {margins.x}px
)}
{/* 列表和引用 */}
); } // ==================== 状态栏 ==================== 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
); }