新编辑器开发手册.md 21 KB

新编辑器开发手册

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 安装依赖

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:

// 块类型
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<string, any>;
}

// 样式覆盖
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

// 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<void>;
  
  // 块操作
  addBlock: (block: Partial<DocumentBlock>, afterBlockId?: string) => void;
  updateBlock: (id: string, updates: Partial<DocumentBlock>) => void;
  deleteBlock: (id: string) => void;
  moveBlock: (id: string, targetOrder: number) => void;
  
  // 保存
  saveDocument: () => Promise<void>;
  
  // 历史
  undo: () => void;
  redo: () => void;
  pushHistory: () => void;
  
  // 辅助
  getBlockById: (id: string) => DocumentBlock | undefined;
  setSelectedBlock: (id: string | null) => void;
}

export const useEditorStore = create<EditorStore>((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

// src/services/blockService.ts
import apiClient from './api';
import { DocumentBlock } from '../types/editor';

export const blockService = {
  /**
   * 获取文档的所有blocks
   */
  async getBlocks(documentId: string): Promise<DocumentBlock[]> {
    const res = await apiClient.get(`/api/v1/documents/${documentId}/blocks`);
    return res.data.data.blocks;
  },
  
  /**
   * 批量更新blocks
   */
  async updateBlocks(documentId: string, blocks: DocumentBlock[]): Promise<void> {
    await apiClient.put(`/api/v1/documents/${documentId}/blocks`, { blocks });
  },
  
  /**
   * 单块更新
   */
  async updateBlock(
    documentId: string,
    blockId: string,
    updates: Partial<DocumentBlock>
  ): Promise<void> {
    await apiClient.patch(`/api/v1/documents/${documentId}/blocks/${blockId}`, updates);
  },
  
  /**
   * 删除块
   */
  async deleteBlock(documentId: string, blockId: string): Promise<void> {
    await apiClient.delete(`/api/v1/documents/${documentId}/blocks/${blockId}`);
  }
};

5. 工具函数

5.1 ID生成器

// 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 富文本转换器

// 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 = `<strong>${html}</strong>`;
    if (italic) html = `<em>${html}</em>`;
    if (underline) html = `<u>${html}</u>`;
    if (styles.length > 0) {
      html = `<span style="${styles.join('; ')}">${html}</span>`;
    }
    
    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<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
  };
  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主组件

// 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<BlockEditorProps> = ({ 
  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 (
    <div className="block-editor">
      {!readOnly && <MainToolbar onClose={onClose} />}
      <BlockCanvas blocks={blocks} readOnly={readOnly} />
    </div>
  );
};

6.2 BlockRenderer渲染器

// 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<BlockRendererProps> = ({ block, readOnly }) => {
  switch (block.type) {
    case 'heading':
      return <HeadingBlock block={block} readOnly={readOnly} />;
    case 'paragraph':
      return <ParagraphBlock block={block} readOnly={readOnly} />;
    case 'table':
      return <TableBlock block={block} readOnly={readOnly} />;
    case 'image':
      return <ImageBlock block={block} readOnly={readOnly} />;
    default:
      console.warn('Unknown block type:', (block as any).type);
      return null;
  }
};

7. 开发步骤

阶段1: 基础框架(第1-2周)

任务清单:

  • 创建类型定义文件
  • 实现editorStore
  • 实现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变化:

// 启用Redux DevTools
import { devtools } from 'zustand/middleware';

export const useEditorStore = create<EditorStore>()(
  devtools((set, get) => ({
    // ...store定义
  }), { name: 'EditorStore' })
);

8.3 Console日志

关键操作添加日志:

updateBlock: (id, updates) => {
  console.log('[EditorStore] updateBlock', { id, updates });
  // ...
};

9. 常见问题

Q1: contenteditable光标位置丢失?

解决方案: 保存和恢复光标位置

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语法

<div 
  className="table-cell"
  style={{
    gridColumn: `span ${cell.colspan}`,
    gridRow: `span ${cell.rowspan}`
  }}
>
  {cell.text}
</div>

Q3: 大文档性能问题?

解决方案: 使用react-window虚拟滚动

import { VariableSizeList } from 'react-window';

<VariableSizeList
  height={800}
  itemCount={blocks.length}
  itemSize={(index) => getBlockHeight(blocks[index])}
  width="100%"
>
  {({ index, style }) => (
    <div style={style}>
      <BlockRenderer block={blocks[index]} />
    </div>
  )}
</VariableSizeList>

10. 代码规范

10.1 命名规范

  • 组件: PascalCase, 如BlockEditor
  • 函数: camelCase, 如loadDocument
  • 常量: UPPER_SNAKE_CASE, 如MAX_BLOCKS
  • 类型: PascalCase, 如DocumentBlock

10.2 文件组织

  • 一个组件一个文件
  • 相关组件放在同一目录
  • 测试文件与源文件同目录,后缀.test.tsx

10.3 注释规范

/**
 * 加载文档的所有blocks
 * @param documentId 文档ID
 * @returns Promise<DocumentBlock[]>
 */
async loadDocument(documentId: string): Promise<DocumentBlock[]> {
  // ...
}

11. 参考资源


文档版本: v1.0
创建日期: 2026-07-03
维护者: 开发团队