# 新编辑器开发手册 ## 1. 开发环境准备 ### 1.1 技术栈版本 | 技术 | 版本 | 说明 | |------|------|------| | Node.js | 18.x+ | 建议使用LTS版本 | | React | 18.2+ | 当前项目版本 | | TypeScript | 5.0+ | 严格模式 | | Vite | 4.x+ | 构建工具 | | Zustand | 4.x+ | 状态管理 | | Ant Design | 5.x+ | UI组件库 | ### 1.2 安装依赖 ```bash cd ax-frontend-app # 安装新增依赖 npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities npm install react-window # 虚拟滚动 npm install use-debounce # 防抖Hook # 开发依赖 npm install -D @types/react-window ``` ### 1.3 目录结构 ``` src/ ├── components/ │ └── Editor/ # 新编辑器目录 │ ├── BlockEditor.tsx # 主编辑器组件 │ ├── BlockCanvas.tsx # 画布容器 │ ├── BlockRenderer.tsx # 块渲染器 │ ├── blocks/ # 各类块组件 │ │ ├── HeadingBlock.tsx │ │ ├── ParagraphBlock.tsx │ │ ├── TableBlock.tsx │ │ └── ImageBlock.tsx │ ├── toolbar/ # 工具栏 │ │ ├── MainToolbar.tsx │ │ └── FloatingToolbar.tsx │ ├── RichTextEditor.tsx # 富文本编辑器 │ └── BlockControls.tsx # 块控制组件 ├── stores/ │ └── editorStore.ts # 编辑器Store ├── services/ │ └── blockService.ts # Block API服务 ├── types/ │ └── editor.ts # 编辑器类型定义 └── utils/ ├── blockOperations.ts # 块操作工具函数 ├── richTextConverter.ts # 富文本转换 └── styleResolver.ts # 样式解析 ``` --- ## 2. 类型定义 ### 2.1 核心类型 创建 `src/types/editor.ts`: ```typescript // 块类型 export type BlockType = 'heading' | 'paragraph' | 'table' | 'image'; // 文档块基础接口 export interface BaseBlock { id: string; block_order: number; type: BlockType; level: number; index: number; word_style: string; style: string | StyleOverrides; metadata: string | Record; } // 样式覆盖 export interface StyleOverrides { font_name?: string; font_size?: number; bold?: boolean; italic?: boolean; underline?: boolean; color?: string; align?: 'left' | 'center' | 'right' | 'justify'; } // 富文本片段 export interface RichText { text: string; style: StyleOverrides; } // 标题块 export interface HeadingBlock extends BaseBlock { type: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; content: string | RichText[]; metadata: { parent_id: string | null; }; } // 段落块 export interface ParagraphBlock extends BaseBlock { type: 'paragraph'; level: 0; content: string | RichText[]; metadata: { parent_heading_id: string | null; }; } // 表格相关 export interface TableCell { text: string | RichText[]; rowspan: number; colspan: number; style: CellStyleOverrides; } export interface CellStyleOverrides extends StyleOverrides { valign?: 'top' | 'middle' | 'bottom'; } export interface TableRow { cells: TableCell[]; } export interface TableBlock extends BaseBlock { type: 'table'; content: { rows: TableRow[]; }; metadata: { cols: number; rows: number; table_width: number; table_width_unit: 'percent' | 'cm' | 'inch'; col_widths: number[]; row_heights?: number[]; parent_heading_id: string | null; }; } // 图片块 export interface ImageBlock extends BaseBlock { type: 'image'; content: string; // Base64 Data URL style: { width: number; height: number; unit: 'cm' | 'inch' | 'px'; align: 'left' | 'center' | 'right'; }; metadata: { alt: string; para_style: string; parent_heading_id: string | null; }; } // 联合类型 export type DocumentBlock = HeadingBlock | ParagraphBlock | TableBlock | ImageBlock; ``` --- ## 3. Store实现 ### 3.1 创建editorStore.ts ```typescript // src/stores/editorStore.ts import { create } from 'zustand'; import { DocumentBlock } from '../types/editor'; import { blockService } from '../services/blockService'; interface EditorStore { // 文档信息 documentId: string | null; documentTitle: string; // 块数据 blocks: DocumentBlock[]; // 选中状态 selectedBlockId: string | null; // 历史栈 history: { past: DocumentBlock[][]; present: DocumentBlock[]; future: DocumentBlock[][]; }; // 加载文档 loadDocument: (id: string) => Promise; // 块操作 addBlock: (block: Partial, afterBlockId?: string) => void; updateBlock: (id: string, updates: Partial) => void; deleteBlock: (id: string) => void; moveBlock: (id: string, targetOrder: number) => void; // 保存 saveDocument: () => Promise; // 历史 undo: () => void; redo: () => void; pushHistory: () => void; // 辅助 getBlockById: (id: string) => DocumentBlock | undefined; setSelectedBlock: (id: string | null) => void; } export const useEditorStore = create((set, get) => ({ documentId: null, documentTitle: '', blocks: [], selectedBlockId: null, history: { past: [], present: [], future: [] }, loadDocument: async (id: string) => { try { const blocks = await blockService.getBlocks(id); set({ documentId: id, blocks, history: { past: [], present: blocks, future: [] } }); } catch (error) { console.error('Failed to load document:', error); throw error; } }, addBlock: (partialBlock, afterBlockId) => { const { blocks, pushHistory } = get(); // 计算插入位置的block_order const afterIndex = afterBlockId ? blocks.findIndex(b => b.id === afterBlockId) : -1; const prevOrder = afterIndex >= 0 ? blocks[afterIndex].block_order : 0; const nextOrder = afterIndex + 1 < blocks.length ? blocks[afterIndex + 1].block_order : prevOrder + 200; let newOrder = Math.floor((prevOrder + nextOrder) / 2); // 间隙不足时触发重排 if (nextOrder - prevOrder <= 1) { const newBlocks = rebalanceBlockOrders([ ...blocks, { ...partialBlock as DocumentBlock, block_order: 0 } ]); set({ blocks: newBlocks }); pushHistory(); return; } const newBlock: DocumentBlock = { ...partialBlock, block_order: newOrder } as DocumentBlock; const newBlocks = [...blocks, newBlock].sort((a, b) => a.block_order - b.block_order); set({ blocks: newBlocks }); pushHistory(); }, updateBlock: (id, updates) => { const { blocks, pushHistory } = get(); const newBlocks = blocks.map(block => block.id === id ? { ...block, ...updates } : block ); set({ blocks: newBlocks }); pushHistory(); }, deleteBlock: (id) => { const { blocks, pushHistory } = get(); const newBlocks = blocks.filter(block => block.id !== id); set({ blocks: newBlocks, selectedBlockId: null }); pushHistory(); }, moveBlock: (id, targetOrder) => { const { blocks, pushHistory } = get(); const block = blocks.find(b => b.id === id); if (!block) return; const newBlock = { ...block, block_order: targetOrder }; const newBlocks = blocks .filter(b => b.id !== id) .concat(newBlock) .sort((a, b) => a.block_order - b.block_order); set({ blocks: newBlocks }); pushHistory(); }, saveDocument: async () => { const { documentId, blocks } = get(); if (!documentId) return; try { await blockService.updateBlocks(documentId, blocks); } catch (error) { console.error('Failed to save document:', error); throw error; } }, undo: () => { const { history } = get(); if (history.past.length === 0) return; const previous = history.past[history.past.length - 1]; set({ blocks: previous, history: { past: history.past.slice(0, -1), present: previous, future: [history.present, ...history.future] } }); }, redo: () => { const { history } = get(); if (history.future.length === 0) return; const next = history.future[0]; set({ blocks: next, history: { past: [...history.past, history.present], present: next, future: history.future.slice(1) } }); }, pushHistory: () => { const { blocks, history } = get(); set({ history: { past: [...history.past, history.present], present: [...blocks], future: [] } }); }, getBlockById: (id) => { return get().blocks.find(block => block.id === id); }, setSelectedBlock: (id) => { set({ selectedBlockId: id }); } })); // 辅助函数:重新平衡block_order function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] { const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order); return sorted.map((block, index) => ({ ...block, block_order: index * 100 })); } ``` --- ## 4. API Service层 ### 4.1 创建blockService.ts ```typescript // src/services/blockService.ts import apiClient from './api'; import { DocumentBlock } from '../types/editor'; export const blockService = { /** * 获取文档的所有blocks */ async getBlocks(documentId: string): Promise { const res = await apiClient.get(`/api/v1/documents/${documentId}/blocks`); return res.data.data.blocks; }, /** * 批量更新blocks */ async updateBlocks(documentId: string, blocks: DocumentBlock[]): Promise { await apiClient.put(`/api/v1/documents/${documentId}/blocks`, { blocks }); }, /** * 单块更新 */ async updateBlock( documentId: string, blockId: string, updates: Partial ): Promise { await apiClient.patch(`/api/v1/documents/${documentId}/blocks/${blockId}`, updates); }, /** * 删除块 */ async deleteBlock(documentId: string, blockId: string): Promise { await apiClient.delete(`/api/v1/documents/${documentId}/blocks/${blockId}`); } }; ``` --- ## 5. 工具函数 ### 5.1 ID生成器 ```typescript // src/utils/blockOperations.ts let idCounter = 0; /** * 生成唯一的block ID * 格式: block-{type}-{counter} */ export function generateBlockId(type: string): string { idCounter += 1; return `block-${type}-${idCounter}-${Date.now()}`; } /** * 计算插入位置的block_order */ export function computeInsertOrder(prevOrder: number, nextOrder: number): number { const gap = nextOrder - prevOrder; if (gap > 1) { return Math.floor((prevOrder + nextOrder) / 2); } return -1; // 需要重排 } ``` ### 5.2 富文本转换器 ```typescript // src/utils/richTextConverter.ts import { RichText, StyleOverrides } from '../types/editor'; /** * RichText数组 → HTML字符串 */ export function richTextToHtml(content: string | RichText[]): string { if (typeof content === 'string') { return escapeHtml(content); } return content.map(segment => { let html = escapeHtml(segment.text); const { bold, italic, underline, color, font_name, font_size } = segment.style; const styles: string[] = []; if (color) styles.push(`color: #${color}`); if (font_name) styles.push(`font-family: ${font_name}`); if (font_size) styles.push(`font-size: ${font_size}pt`); if (bold) html = `${html}`; if (italic) html = `${html}`; if (underline) html = `${html}`; if (styles.length > 0) { html = `${html}`; } return html; }).join(''); } /** * HTML字符串 → RichText数组 */ export function htmlToRichText(html: string): RichText[] { const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; const richText: RichText[] = []; function traverseNode(node: Node, inheritedStyle: StyleOverrides = {}) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ''; if (text.trim()) { richText.push({ text, style: { ...inheritedStyle } }); } } else if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement; const newStyle = extractStyleFromElement(el, inheritedStyle); el.childNodes.forEach(child => traverseNode(child, newStyle)); } } traverseNode(tempDiv); return mergeAdjacentSameStyle(richText); } function extractStyleFromElement( el: HTMLElement, inherited: StyleOverrides ): StyleOverrides { const style = { ...inherited }; if (el.tagName === 'STRONG' || el.tagName === 'B') style.bold = true; if (el.tagName === 'EM' || el.tagName === 'I') style.italic = true; if (el.tagName === 'U') style.underline = true; const computedStyle = window.getComputedStyle(el); if (computedStyle.color) { const color = rgbToHex(computedStyle.color); if (color && color !== '000000') style.color = color; } return style; } function escapeHtml(text: string): string { const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, m => map[m]); } function rgbToHex(rgb: string): string | null { const match = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); if (!match) return null; const [, r, g, b] = match; return [r, g, b] .map(x => parseInt(x).toString(16).padStart(2, '0')) .join('') .toUpperCase(); } function mergeAdjacentSameStyle(richText: RichText[]): RichText[] { if (richText.length === 0) return []; const merged: RichText[] = [richText[0]]; for (let i = 1; i < richText.length; i++) { const prev = merged[merged.length - 1]; const curr = richText[i]; if (JSON.stringify(prev.style) === JSON.stringify(curr.style)) { prev.text += curr.text; } else { merged.push(curr); } } return merged; } ``` --- ## 6. 核心组件实现 ### 6.1 BlockEditor主组件 ```tsx // src/components/Editor/BlockEditor.tsx import React, { useEffect } from 'react'; import { useEditorStore } from '../../stores/editorStore'; import { MainToolbar } from './toolbar/MainToolbar'; import { BlockCanvas } from './BlockCanvas'; import { message } from 'antd'; import { useDebouncedCallback } from 'use-debounce'; import './BlockEditor.css'; interface BlockEditorProps { documentId: string; readOnly?: boolean; onClose?: () => void; } export const BlockEditor: React.FC = ({ documentId, readOnly = false, onClose }) => { const { loadDocument, saveDocument, blocks } = useEditorStore(); // 加载文档 useEffect(() => { loadDocument(documentId).catch(err => { message.error('加载文档失败: ' + err.message); }); }, [documentId]); // 自动保存(防抖500ms) const debouncedSave = useDebouncedCallback( () => { if (!readOnly) { saveDocument().catch(err => { message.error('保存失败: ' + err.message); }); } }, 500 ); // 监听blocks变化触发自动保存 useEffect(() => { if (blocks.length > 0) { debouncedSave(); } }, [blocks]); // 快捷键处理 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveDocument(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); return (
{!readOnly && }
); }; ``` ### 6.2 BlockRenderer渲染器 ```tsx // src/components/Editor/BlockRenderer.tsx import React from 'react'; import { DocumentBlock } from '../../types/editor'; import { HeadingBlock } from './blocks/HeadingBlock'; import { ParagraphBlock } from './blocks/ParagraphBlock'; import { TableBlock } from './blocks/TableBlock'; import { ImageBlock } from './blocks/ImageBlock'; interface BlockRendererProps { block: DocumentBlock; readOnly?: boolean; } export const BlockRenderer: React.FC = ({ block, readOnly }) => { switch (block.type) { case 'heading': return ; case 'paragraph': return ; case 'table': return ; case 'image': return ; default: console.warn('Unknown block type:', (block as any).type); return null; } }; ``` --- ## 7. 开发步骤 ### 阶段1: 基础框架(第1-2周) **任务清单**: - [x] 创建类型定义文件 - [x] 实现editorStore - [x] 实现blockService - [ ] 实现BlockEditor主组件 - [ ] 实现BlockRenderer - [ ] 实现MainToolbar - [ ] 基础CSS样式 **验收标准**: - 能够加载文档并显示blocks - 能够保存文档 - 基础UI布局完成 ### 阶段2: 标题和段落编辑(第3周) **任务清单**: - [ ] HeadingBlock组件 - [ ] ParagraphBlock组件 - [ ] RichTextEditor组件 - [ ] 富文本工具栏 - [ ] 快捷键支持 **验收标准**: - 标题H1-H6可编辑 - 段落可编辑 - 支持加粗、斜体、下划线 - 支持文字颜色 ### 阶段3: 表格编辑(第4-5周) **任务清单**: - [ ] TableBlock组件 - [ ] TableCell组件 - [ ] 插入/删除行列 - [ ] 合并单元格 - [ ] 调整列宽行高 - [ ] 表格工具栏 **验收标准**: - 创建3x3表格 - 编辑单元格内容(含富文本) - 插入/删除行列 - 合并相邻单元格 - 拖拽调整列宽 ### 阶段4: 图片编辑(第6周) **任务清单**: - [ ] ImageBlock组件 - [ ] 图片上传(Base64) - [ ] 调整尺寸 - [ ] 对齐方式 - [ ] 替换/删除图片 **验收标准**: - 插入图片(转Base64) - 拖拽调整尺寸 - 左/中/右对齐 - 替换和删除 ### 阶段5: 高级功能(第7-8周) **任务清单**: - [ ] 拖拽排序 - [ ] 撤销/重做 - [ ] 搜索替换 - [ ] 导出功能集成 - [ ] 下载历史 **验收标准**: - 拖拽移动blocks - Ctrl+Z撤销, Ctrl+Y重做 - Ctrl+F搜索 - 导出Word - 查看下载历史 ### 阶段6: 优化和测试(第9-10周) **任务清单**: - [ ] 性能优化(虚拟滚动) - [ ] 单元测试 - [ ] 集成测试 - [ ] E2E测试 - [ ] 无障碍测试 - [ ] 浏览器兼容性测试 **验收标准**: - 1000+ blocks流畅渲染 - 测试覆盖率≥80% - 支持Chrome/Firefox/Safari/Edge --- ## 8. 调试技巧 ### 8.1 React DevTools 查看组件树和Store状态: ``` React DevTools → Components → BlockEditor ↓ props/state ↓ Store (Zustand) ``` ### 8.2 Redux DevTools(Zustand) 监听Store变化: ```typescript // 启用Redux DevTools import { devtools } from 'zustand/middleware'; export const useEditorStore = create()( devtools((set, get) => ({ // ...store定义 }), { name: 'EditorStore' }) ); ``` ### 8.3 Console日志 关键操作添加日志: ```typescript updateBlock: (id, updates) => { console.log('[EditorStore] updateBlock', { id, updates }); // ... }; ``` --- ## 9. 常见问题 ### Q1: contenteditable光标位置丢失? **解决方案**: 保存和恢复光标位置 ```typescript function saveSelection(): Range | null { const sel = window.getSelection(); return sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null; } function restoreSelection(range: Range | null) { if (!range) return; const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); } // 使用 const range = saveSelection(); // ...执行DOM操作 restoreSelection(range); ``` ### Q2: 表格单元格合并后如何渲染? **解决方案**: 使用CSS Grid的span语法 ```tsx
{cell.text}
``` ### Q3: 大文档性能问题? **解决方案**: 使用react-window虚拟滚动 ```tsx import { VariableSizeList } from 'react-window'; getBlockHeight(blocks[index])} width="100%" > {({ index, style }) => (
)}
``` --- ## 10. 代码规范 ### 10.1 命名规范 - 组件: PascalCase, 如`BlockEditor` - 函数: camelCase, 如`loadDocument` - 常量: UPPER_SNAKE_CASE, 如`MAX_BLOCKS` - 类型: PascalCase, 如`DocumentBlock` ### 10.2 文件组织 - 一个组件一个文件 - 相关组件放在同一目录 - 测试文件与源文件同目录,后缀`.test.tsx` ### 10.3 注释规范 ```typescript /** * 加载文档的所有blocks * @param documentId 文档ID * @returns Promise */ async loadDocument(documentId: string): Promise { // ... } ``` --- ## 11. 参考资源 - [后端API文档](../../ax-backend-v1/docs/text-editor-backend-api.md) - [后端SQLite设计](../../ax-backend-v1/docs/content-sqlite-design.md) - [React文档](https://react.dev/) - [Zustand文档](https://docs.pmnd.rs/zustand) - [dnd-kit文档](https://docs.dndkit.com/) - [Ant Design组件](https://ant.design/components) --- **文档版本**: v1.0 **创建日期**: 2026-07-03 **维护者**: 开发团队