1203 lines
53 KiB
JavaScript
1203 lines
53 KiB
JavaScript
'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();
|
||
// 按空行(双换行)分段,段内用 <br> 换行
|
||
const blocks = normalized.split(/\n\n+/);
|
||
const html = blocks
|
||
.map(block => {
|
||
const lines = block.split('\n').map(l => l.trim()).filter(l => l);
|
||
return `<p>${lines.join('<br>')}</p>`;
|
||
})
|
||
.filter(p => p !== '<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 (
|
||
<>
|
||
<EditorToolbar editor={editor} margins={margins} setMargins={setMargins} />
|
||
<div
|
||
className="editor-container"
|
||
onClick={(e) => {
|
||
// 点击灰色空隙处自动聚焦到文末
|
||
if (e.target.closest('.editor-container') && !e.target.closest('.tiptap')) {
|
||
editor?.chain().focus('end').run();
|
||
}
|
||
}}
|
||
>
|
||
<div className="document-workspace" style={{ minHeight: totalWorkspaceHeight }}>
|
||
|
||
{/* SVG clip definition — 每页一个矩形,文字只在页面内可见 */}
|
||
<svg width="0" height="0" style={{ position: 'absolute' }}>
|
||
<defs>
|
||
<clipPath id={clipPathId} clipPathUnits="userSpaceOnUse">
|
||
{Array.from({ length: pageCount }).map((_, i) => {
|
||
const pageTop = i * (PAGE_HEIGHT + PAGE_GAP);
|
||
return <rect key={i} x="0" y={pageTop} width="10000" height={PAGE_HEIGHT} />;
|
||
})}
|
||
</clipPath>
|
||
</defs>
|
||
</svg>
|
||
|
||
{/* ===== 底层:白色纸张卡片阵列 ===== */}
|
||
<div className="pages-bg-layer">
|
||
{Array.from({ length: pageCount }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="page-card"
|
||
style={{
|
||
height: PAGE_HEIGHT,
|
||
marginBottom: i === pageCount - 1 ? 0 : PAGE_GAP,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* ===== 页间标签(在灰色间隙中显示页码)===== */}
|
||
{pageCount > 1 && Array.from({ length: pageCount - 1 }).map((_, i) => {
|
||
const gapTop = (i + 1) * PAGE_HEIGHT + i * PAGE_GAP;
|
||
return (
|
||
<div
|
||
key={`label-${i}`}
|
||
style={{
|
||
position: 'absolute',
|
||
top: gapTop,
|
||
left: 0,
|
||
right: 0,
|
||
height: PAGE_GAP,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
pointerEvents: 'none',
|
||
zIndex: 5,
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 11, color: 'var(--text-muted)', userSelect: 'none', opacity: 0.6 }}>
|
||
第 {i + 1} 页 / 共 {pageCount} 页
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* ===== 文字层(clipPath 严格裁切到页面区域)===== */}
|
||
<div
|
||
className="pages-fg-layer"
|
||
style={{
|
||
minHeight: totalWorkspaceHeight,
|
||
clipPath: `url(#${clipPathId})`,
|
||
WebkitClipPath: `url(#${clipPathId})`,
|
||
'--page-margin-x': `${margins.x}px`,
|
||
'--page-margin-y': `${margins.y}px`,
|
||
}}
|
||
>
|
||
<div ref={contentCallbackRef}>
|
||
<EditorContent editor={editor} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<InlineAI editor={editor} onAiRequest={onAiRequest} onArchiveGeneration={onArchiveGeneration} contextItems={contextItems} contextSelection={contextSelection} setContextSelection={setContextSelection} />
|
||
<StatusBar editor={editor} pageCount={pageCount} />
|
||
</>
|
||
);
|
||
});
|
||
|
||
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 (
|
||
<div
|
||
className="ghost-inline-bar"
|
||
style={{ top: Math.max(16, Math.min(ghostPos.top, window.innerHeight - 60)), left: ghostPos.left }}
|
||
>
|
||
<button className="ghost-accept-btn" onClick={acceptGhost} title="接受 (Tab)">
|
||
✓ 接受
|
||
</button>
|
||
<button className="ghost-reject-btn" onClick={rejectGhost} title="拒绝 (Esc)">
|
||
✗ 拒绝
|
||
</button>
|
||
<button className="ghost-regen-btn" onClick={regenerate} title="重新生成">
|
||
⟳
|
||
</button>
|
||
<span className="ghost-bar-shortcut">Tab 接受 · Esc 拒绝</span>
|
||
</div>
|
||
);
|
||
}
|
||
const selectedText = getSelectedText();
|
||
const availableModes = selectedText
|
||
? AI_MODES
|
||
: AI_MODES.filter(m => !m.needsSelection);
|
||
|
||
return (
|
||
<div
|
||
ref={popoverRef}
|
||
className="inline-ai-popover"
|
||
style={{ top: position.top, left: Math.max(16, position.left) }}
|
||
>
|
||
{/* 模式选择 */}
|
||
<div className="inline-ai-modes">
|
||
{availableModes.map(m => (
|
||
<button
|
||
key={m.key}
|
||
className={`inline-ai-mode-btn ${mode === m.key ? 'active' : ''}`}
|
||
onClick={() => setMode(m.key)}
|
||
disabled={streaming}
|
||
title={m.desc}
|
||
>
|
||
{m.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 参考设定集(可折叠) */}
|
||
<InlineContextPanel
|
||
contextItems={contextItems}
|
||
contextSelection={contextSelection}
|
||
setContextSelection={setContextSelection}
|
||
onJumpToNode={(nodeId) => {
|
||
setJumpToNodeId(nodeId);
|
||
setShowSettings(true);
|
||
}}
|
||
/>
|
||
|
||
{/* 指令输入 */}
|
||
<div className="inline-ai-input-row">
|
||
<input
|
||
ref={inputRef}
|
||
className="inline-ai-input"
|
||
placeholder={mode === 'continue' ? '补充指示(可选),如:写一段打斗场景' : '改写指示(可选),如:更有诗意'}
|
||
value={instruction}
|
||
onChange={e => setInstruction(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter' && !streaming) {
|
||
e.preventDefault();
|
||
generate();
|
||
}
|
||
}}
|
||
disabled={streaming}
|
||
/>
|
||
{streaming ? (
|
||
<button className="inline-ai-stop-btn" onClick={stop}>
|
||
⬛ 停止
|
||
</button>
|
||
) : (
|
||
<button className="inline-ai-go-btn" onClick={generate}>
|
||
✦ 生成
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 状态提示 */}
|
||
{streaming && (
|
||
<div className="inline-ai-status">
|
||
<span className="streaming-cursor">▊</span> AI 正在写入编辑器…
|
||
</div>
|
||
)}
|
||
{!streaming && selectedText && (
|
||
<div className="inline-ai-hint">
|
||
已选中 {selectedText.length} 字
|
||
</div>
|
||
)}
|
||
{!streaming && !selectedText && (
|
||
<div className="inline-ai-hint">
|
||
将在光标处续写 · Ctrl+J 打开/关闭
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
// ==================== 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 (
|
||
<div className="inline-context-panel">
|
||
<button
|
||
className="inline-context-toggle"
|
||
onClick={() => setExpanded(!expanded)}
|
||
>
|
||
<span className="inline-context-chevron">{expanded ? '▼' : '▶'}</span>
|
||
<span>📚 参考</span>
|
||
<span className="inline-context-count">({selectedCount}/{totalCount})</span>
|
||
</button>
|
||
{expanded && (
|
||
<div className="inline-context-list">
|
||
{Object.entries(grouped).map(([groupName, items]) => {
|
||
const checkedCount = items.filter(it => contextSelection?.has(it.id)).length;
|
||
const allChecked = checkedCount === items.length;
|
||
return (
|
||
<div key={groupName} className="inline-context-group">
|
||
<label className="inline-context-group-header">
|
||
<input
|
||
type="checkbox"
|
||
checked={allChecked && items.length > 0}
|
||
ref={el => { if (el) el.indeterminate = checkedCount > 0 && checkedCount < items.length; }}
|
||
onChange={() => toggleGroup(groupName)}
|
||
/>
|
||
<span className="inline-context-group-name">{groupName}</span>
|
||
<span className="inline-context-group-count">{checkedCount}/{items.length}</span>
|
||
</label>
|
||
{items.map(item => (
|
||
<div key={item.id} className="inline-context-item" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, flex: 1, cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={contextSelection?.has(item.id) || false}
|
||
onChange={() => toggleItem(item.id)}
|
||
/>
|
||
<span className="inline-context-item-name" title={item.name}>{item.name}</span>
|
||
</label>
|
||
{item._nodeId && onJumpToNode && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onJumpToNode(item._nodeId); }}
|
||
style={{
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
fontSize: 11, color: 'var(--accent)', padding: '0 4px',
|
||
opacity: 0.7, lineHeight: 1, flexShrink: 0,
|
||
}}
|
||
title="跳转到设定集"
|
||
>→</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ==================== 颜色选择器组件 ====================
|
||
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 (
|
||
<div className="color-picker-popover" onMouseDown={e => e.preventDefault()} onClick={e => e.stopPropagation()}>
|
||
<div className="color-picker-label">{label}</div>
|
||
<div className="color-picker-grid">
|
||
{PRESET_COLORS.map(color => (
|
||
<button
|
||
key={color}
|
||
className={`color-swatch ${currentColor === color ? 'active' : ''}`}
|
||
style={{ background: color }}
|
||
onClick={() => { onSelect(color); onClose(); }}
|
||
title={color}
|
||
/>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="color-picker-clear"
|
||
onClick={() => { onSelect(null); onClose(); }}
|
||
>
|
||
清除颜色
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ==================== 字体族选项 ====================
|
||
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 (
|
||
<div className="editor-toolbar" onMouseDown={e => { if (e.target.tagName !== 'INPUT') e.preventDefault(); }}>
|
||
{/* 撤销/重做 */}
|
||
<div className="toolbar-group">
|
||
<button className="toolbar-btn" onClick={() => editor.chain().focus().undo().run()} title="撤销 (Ctrl+Z)">↩</button>
|
||
<button className="toolbar-btn" onClick={() => editor.chain().focus().redo().run()} title="重做 (Ctrl+Y)">↪</button>
|
||
</div>
|
||
|
||
<div className="toolbar-divider" />
|
||
|
||
{/* 字体族 */}
|
||
<div className="toolbar-dropdown-wrap" onClick={e => e.stopPropagation()}>
|
||
<button className="toolbar-btn toolbar-dropdown-btn" onClick={() => { closeAll(); setShowFontFamily(!showFontFamily); }} title="字体">
|
||
{currentFontLabel} <span className="dropdown-arrow">▾</span>
|
||
</button>
|
||
{showFontFamily && (
|
||
<div className="toolbar-dropdown-menu">
|
||
{FONT_FAMILIES.map(f => (
|
||
<button
|
||
key={f.label}
|
||
className={`toolbar-dropdown-item ${currentFontFamily === f.value ? 'active' : ''}`}
|
||
style={{ fontFamily: f.value || 'inherit' }}
|
||
onMouseDown={e => e.preventDefault()}
|
||
onClick={() => {
|
||
if (f.value) {
|
||
editor.chain().focus().setFontFamily(f.value).run();
|
||
} else {
|
||
editor.chain().focus().unsetFontFamily().run();
|
||
}
|
||
setShowFontFamily(false);
|
||
}}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="toolbar-divider" />
|
||
|
||
{/* 格式按钮 */}
|
||
<div className="toolbar-group">
|
||
<button className={`toolbar-btn ${editor.isActive('bold') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleBold().run()} title="加粗 (Ctrl+B)" style={{ fontWeight: 'bold' }}>B</button>
|
||
<button className={`toolbar-btn ${editor.isActive('italic') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleItalic().run()} title="斜体 (Ctrl+I)" style={{ fontStyle: 'italic' }}>I</button>
|
||
<button className={`toolbar-btn ${editor.isActive('underline') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleUnderline().run()} title="下划线 (Ctrl+U)" style={{ textDecoration: 'underline' }}>U</button>
|
||
<button className={`toolbar-btn ${editor.isActive('strike') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleStrike().run()} title="删除线" style={{ textDecoration: 'line-through' }}>S</button>
|
||
<button className={`toolbar-btn ${editor.isActive('superscript') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleSuperscript().run()} title="上标" style={{ fontSize: 11 }}>X²</button>
|
||
<button className={`toolbar-btn ${editor.isActive('subscript') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleSubscript().run()} title="下标" style={{ fontSize: 11 }}>X₂</button>
|
||
</div>
|
||
|
||
<div className="toolbar-divider" />
|
||
|
||
{/* 字体颜色 */}
|
||
<div className="toolbar-dropdown-wrap" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
className="toolbar-btn toolbar-color-btn"
|
||
onClick={() => { closeAll(); setShowFontColor(!showFontColor); }}
|
||
title="字体颜色"
|
||
>
|
||
<span style={{ borderBottom: `3px solid ${currentColor || 'var(--text-primary)'}` }}>A</span>
|
||
<span className="dropdown-arrow">▾</span>
|
||
</button>
|
||
{showFontColor && (
|
||
<ColorPicker
|
||
label="字体颜色"
|
||
currentColor={currentColor}
|
||
onSelect={color => {
|
||
if (color) editor.chain().focus().setColor(color).run();
|
||
else editor.chain().focus().unsetColor().run();
|
||
}}
|
||
onClose={() => setShowFontColor(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 背景色/高亮 */}
|
||
<div className="toolbar-dropdown-wrap" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
className="toolbar-btn toolbar-color-btn"
|
||
onClick={() => { closeAll(); setShowBgColor(!showBgColor); }}
|
||
title="背景颜色(高亮)"
|
||
>
|
||
<span style={{
|
||
background: currentHighlight || 'var(--warning)',
|
||
padding: '0 3px',
|
||
borderRadius: 2,
|
||
color: currentHighlight ? '#fff' : 'inherit',
|
||
}}>高亮</span>
|
||
<span className="dropdown-arrow">▾</span>
|
||
</button>
|
||
{showBgColor && (
|
||
<ColorPicker
|
||
label="背景颜色"
|
||
currentColor={currentHighlight}
|
||
onSelect={color => {
|
||
if (color) editor.chain().focus().toggleHighlight({ color }).run();
|
||
else editor.chain().focus().unsetHighlight().run();
|
||
}}
|
||
onClose={() => setShowBgColor(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="toolbar-divider" />
|
||
|
||
{/* 标题 */}
|
||
<div className="toolbar-group">
|
||
<button className={`toolbar-btn ${editor.isActive('heading', { level: 1 }) ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} title="一级标题" style={{ fontSize: 13, fontWeight: 700 }}>H1</button>
|
||
<button className={`toolbar-btn ${editor.isActive('heading', { level: 2 }) ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} title="二级标题" style={{ fontSize: 12, fontWeight: 700 }}>H2</button>
|
||
<button className={`toolbar-btn ${editor.isActive('heading', { level: 3 }) ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} title="三级标题" style={{ fontSize: 11, fontWeight: 700 }}>H3</button>
|
||
</div>
|
||
|
||
<div className="toolbar-divider" />
|
||
|
||
{/* 对齐 */}
|
||
<div className="toolbar-group">
|
||
<button className={`toolbar-btn ${editor.isActive({ textAlign: 'left' }) ? 'active' : ''}`} onClick={() => editor.chain().focus().setTextAlign('left').run()} title="左对齐">≡</button>
|
||
<button className={`toolbar-btn ${editor.isActive({ textAlign: 'center' }) ? 'active' : ''}`} onClick={() => editor.chain().focus().setTextAlign('center').run()} title="居中">═</button>
|
||
<button className={`toolbar-btn ${editor.isActive({ textAlign: 'right' }) ? 'active' : ''}`} onClick={() => editor.chain().focus().setTextAlign('right').run()} title="右对齐">≢</button>
|
||
<button className={`toolbar-btn ${editor.isActive({ textAlign: 'justify' }) ? 'active' : ''}`} onClick={() => editor.chain().focus().setTextAlign('justify').run()} title="两端对齐">☰</button>
|
||
</div>
|
||
|
||
<div className="toolbar-divider" />
|
||
|
||
{/* 字号行距 */}
|
||
<div className="toolbar-dropdown-wrap" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
className={`toolbar-btn ${showTypeset ? 'active' : ''}`}
|
||
onClick={() => { closeAll(); setShowTypeset(!showTypeset); }}
|
||
title="字号与行距"
|
||
style={{ fontSize: 12 }}
|
||
>
|
||
Aa <span className="dropdown-arrow">▾</span>
|
||
</button>
|
||
{showTypeset && (
|
||
<div className="typeset-popover" style={{ position: 'absolute', top: '100%', bottom: 'auto', right: 0, marginTop: 4, zIndex: 120 }}>
|
||
<div className="typeset-row">
|
||
<label>字号</label>
|
||
<input
|
||
type="range" min="14" max="24" step="1"
|
||
value={fontSize}
|
||
onChange={e => setFontSize(Number(e.target.value))}
|
||
/>
|
||
<span className="typeset-value">{fontSize}px</span>
|
||
</div>
|
||
<div className="typeset-row">
|
||
<label>行距</label>
|
||
<input
|
||
type="range" min="1.4" max="2.6" step="0.1"
|
||
value={lineHeight}
|
||
onChange={e => setLineHeight(Number(e.target.value))}
|
||
/>
|
||
<span className="typeset-value">{lineHeight.toFixed(1)}</span>
|
||
</div>
|
||
<button className="typeset-reset" onClick={() => { setFontSize(17); setLineHeight(1.9); }}>
|
||
恢复默认
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 📄 页面边距 */}
|
||
<div className="toolbar-dropdown-wrap" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
className={`toolbar-btn ${showMargins ? 'active' : ''}`}
|
||
onClick={() => { closeAll(); setShowMargins(!showMargins); }}
|
||
title="页面设置"
|
||
style={{ fontSize: 12 }}
|
||
>
|
||
📄 <span className="dropdown-arrow">▾</span>
|
||
</button>
|
||
{showMargins && (
|
||
<div className="typeset-popover" style={{ position: 'absolute', top: '100%', bottom: 'auto', right: 0, marginTop: 4, zIndex: 120 }}>
|
||
<div className="typeset-row">
|
||
<label>上下</label>
|
||
<input
|
||
type="range" min="40" max="160" step="8"
|
||
value={margins.y}
|
||
onChange={e => setMargins(prev => ({ ...prev, y: Number(e.target.value) }))}
|
||
/>
|
||
<span className="typeset-value">{margins.y}px</span>
|
||
</div>
|
||
<div className="typeset-row">
|
||
<label>左右</label>
|
||
<input
|
||
type="range" min="40" max="160" step="8"
|
||
value={margins.x}
|
||
onChange={e => setMargins(prev => ({ ...prev, x: Number(e.target.value) }))}
|
||
/>
|
||
<span className="typeset-value">{margins.x}px</span>
|
||
</div>
|
||
<button className="typeset-reset" onClick={() => setMargins({ x: 96, y: 96 })}>
|
||
恢复默认
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="toolbar-divider" />
|
||
|
||
{/* 列表和引用 */}
|
||
<div className="toolbar-group">
|
||
<button className={`toolbar-btn ${editor.isActive('bulletList') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleBulletList().run()} title="无序列表">• 列</button>
|
||
<button className={`toolbar-btn ${editor.isActive('orderedList') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleOrderedList().run()} title="有序列表">1. 列</button>
|
||
<button className={`toolbar-btn ${editor.isActive('taskList') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleTaskList().run()} title="任务列表">☑ 任</button>
|
||
<button className={`toolbar-btn ${editor.isActive('blockquote') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleBlockquote().run()} title="引用块">❝ 引</button>
|
||
<button className={`toolbar-btn ${editor.isActive('codeBlock') ? 'active' : ''}`} onClick={() => editor.chain().focus().toggleCodeBlock().run()} title="代码块"></></button>
|
||
<button className="toolbar-btn" onClick={() => {
|
||
openMathEditor('', (latex) => {
|
||
editor.chain().focus().insertContent({ type: 'mathInline', attrs: { latex } }).run();
|
||
});
|
||
}} title="插入公式 (也可直接输入 $公式$)">∑</button>
|
||
<button className="toolbar-btn" onClick={() => editor.chain().focus().setHorizontalRule().run()} title="分割线">——</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ==================== 状态栏 ====================
|
||
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 (
|
||
<div className="status-bar">
|
||
<div className="status-bar-left">
|
||
<span>{words} 字</span>
|
||
<span>{chars} 字符</span>
|
||
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>共 {pageCount} 页</span>
|
||
</div>
|
||
<div className="status-bar-right">
|
||
<span className="status-bar-shortcut">Ctrl+J AI助手</span>
|
||
<span>自动保存</span>
|
||
<span style={{ opacity: 0.5, fontSize: '11px' }}>© 2026 YuanShiJiLoong</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|