新编辑器架构设计.md 34 KB

新编辑器架构设计文档

1. 概述

1.1 背景

当前前端使用MDXEditor组件编辑Markdown文档,后端基于旧架构。新后端已升级为基于SQLite的blocks存储架构,使用结构化的blocks数据来表示文档内容,支持更精细的内容管理和样式控制。

现有应用架构: 双面板布局

  • 左侧面板(40%): ChatPanel - 聊天消息列表和输入
  • 右侧面板(60%): 动态切换
    • 默认: ExportRecordList (导出记录列表)
    • 编辑时: EditorPanel (文档编辑器)

集成目标: 保留聊天面板和双面板布局,仅替换EditorPanel内部的编辑器内核(MDXEditor → BlockEditor)。

1.2 核心目标

  • 自研编辑器组件:脱离MDXEditor依赖,实现完全自主可控的编辑器
  • blocks数据模型适配:前端数据结构与后端blocks模型无缝对接
  • 表格编辑增强:支持表格渲染、编辑、合并单元格、列宽行高调整
  • 图片编辑增强:支持图片插入、尺寸调整、对齐方式设置
  • 富文本支持:段落和单元格内支持部分文本样式覆盖(加粗、颜色、字号等)
  • 保持现有布局:聊天面板和ResizableLayout布局完全不变

1.3 技术栈选型

技术 选型 理由
编辑器核心 自研基于contenteditable 完全控制渲染和交互逻辑
状态管理 Zustand 保持现有技术栈一致
UI组件库 Ant Design 保持现有技术栈一致
表格渲染 原生div+CSS Grid 灵活控制布局和样式
图片处理 原生img+Base64 简化上传流程

2. 后端blocks数据模型

2.1 document_blocks表结构

CREATE TABLE document_blocks (
    id TEXT PRIMARY KEY,              -- 如 "block-h1-0"
    block_order INTEGER NOT NULL,     -- 文档中的绝对位置(稀疏排序)
    type TEXT NOT NULL,               -- heading / paragraph / table / image
    level INTEGER DEFAULT 0,          -- 标题级别 1-6, 段落为0
    "index" INTEGER DEFAULT 0,        -- 该级别的序号
    content TEXT,                     -- 文本内容或JSON结构
    word_style TEXT,                  -- Word样式名
    style TEXT,                       -- 自定义样式JSON
    metadata TEXT                     -- 元数据JSON
);

2.2 Block类型说明

Heading(标题)

interface HeadingBlock {
  id: string;                  // "block-h1-0"
  block_order: number;         // 100
  type: "heading";
  level: number;               // 1-6
  index: number;               // 该级别序号
  content: string | RichText[]; // 纯文本或富文本数组
  word_style: string;          // "Heading 1"
  style: StyleOverrides;       // 自定义样式
  metadata: {
    parent_id: string | null;  // 父标题ID
  };
}

Paragraph(段落)

interface ParagraphBlock {
  id: string;
  block_order: number;
  type: "paragraph";
  level: 0;
  index: 0;
  content: string | RichText[];
  word_style: "Normal";
  style: StyleOverrides;
  metadata: {
    parent_heading_id: string | null;
  };
}

Table(表格)

interface TableBlock {
  id: string;
  block_order: number;
  type: "table";
  level: 0;
  index: 0;
  content: {
    rows: TableRow[];
  };
  word_style: "Table Grid";
  style: StyleOverrides;
  metadata: {
    cols: number;
    rows: number;
    table_width: number;
    table_width_unit: "percent" | "cm" | "inch";
    col_widths: number[];
    row_heights?: number[];  // 可选,单位cm
    parent_heading_id: string | null;
  };
}

interface TableRow {
  cells: TableCell[];
}

interface TableCell {
  text: string | RichText[];
  rowspan: number;
  colspan: number;
  style: CellStyleOverrides;
}

Image(图片)

interface ImageBlock {
  id: string;
  block_order: number;
  type: "image";
  level: 0;
  index: 0;
  content: string;  // Base64 Data URL
  word_style: "Normal";
  style: {
    width: number;
    height: number;
    unit: "cm" | "inch" | "px";
    align: "left" | "center" | "right";
  };
  metadata: {
    alt: string;
    para_style: string;
    parent_heading_id: string | null;
  };
}

2.3 富文本格式(RichText)

块内部分文本可有不同样式:

interface RichText {
  text: string;
  style: {
    bold?: boolean;
    italic?: boolean;
    underline?: boolean;
    color?: string;        // 十六进制,如"FF0000"
    font_name?: string;
    font_size?: number;    // 磅
  };
}

// 示例
content: [
  { text: "普通文本", style: {} },
  { text: "红色加粗", style: { bold: true, color: "FF0000" } }
]

3. 前端数据层设计

3.1 应用架构(保留现有布局)

┌─────────────────────────────────────────────────────────┐
│                   App.tsx (根组件)                       │
├──────────────────────┬──────────────────────────────────┤
│  ChatPanel (左40%)   │  右侧面板(动态切换):              │
│  ✅ 完全保留不变      │  - ExportRecordList (默认)       │
│  - MessageList       │  - EditorPanel (编辑时) ⭐        │
│  - MessageInput      │    └── BlockEditor (新内核)      │
│  - chatStore         │        └── blocks编辑            │
└──────────────────────┴──────────────────────────────────┘

集成策略:

  • App.tsx和ResizableLayout 完全不变
  • ChatPanel及其所有子组件 完全不变
  • EditorPanel 保留外壳,内部根据feature flag切换编辑器: typescript {USE_NEW_EDITOR ? <BlockEditor /> : <WYSIWYGEditor />}

3.2 数据流向

用户在Chat中点击"预览"按钮
        ↓
MessageItem触发 onPreviewDocument(documentId)
        ↓
uiStore.openDocumentPreview(documentId) ✅ 现有逻辑保持
        ↓
App.tsx检测到previewDocumentId变化
        ↓
右侧面板从ExportRecordList切换到EditorPanel ✅ 现有逻辑保持
        ↓
EditorPanel加载文档 ⭐ 内部切换到BlockEditor
        ↓
BlockEditor: GET /api/v1/documents/{id}/blocks
        ↓
前端Store(blocks数组按block_order排序)
        ↓
    编辑器渲染(BlockRenderer组件树)
        ↓
   用户编辑操作(插入/删除/修改)
        ↓
Store更新+本地block_order重算
        ↓
   PUT /api/v1/documents/{id}/blocks
        ↓
后端更新SQLite

3.3 Zustand Store设计

// src/stores/editorStore.ts
interface EditorStore {
  // 文档元数据
  documentId: string | null;
  documentTitle: string;
  
  // blocks数据(按block_order排序)
  blocks: DocumentBlock[];
  
  // 选中状态
  selectedBlockId: string | null;
  
  // 操作方法
  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>;
  
  // 辅助方法
  getBlockById: (id: string) => DocumentBlock | undefined;
  getBlocksByType: (type: BlockType) => DocumentBlock[];
  recomputeBlockOrders: () => void;  // 重新分配稀疏block_order
}

与现有Store的关系:

  • uiStore: 继续管理previewDocumentId、打开/关闭编辑器等UI状态
  • chatStore: 继续管理聊天消息列表
  • editorStore: ⭐ 新增,专门管理blocks编辑状态

Store之间不直接耦合,通过documentId传递关联。

3.4 API Service层

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

4. 编辑器组件架构

4.1 组件层级结构

<BlockEditor>                       // 顶层编辑器容器
├── <Toolbar>                       // 工具栏(插入块、格式化等)
├── <BlockCanvas>                   // 画布区域
│   ├── <BlockRenderer>             // 块渲染器(根据type渲染)
│   │   ├── <HeadingBlock>          // 标题块
│   │   ├── <ParagraphBlock>        // 段落块
│   │   ├── <TableBlock>            // 表格块
│   │   │   └── <TableCell>         // 单元格(支持富文本)
│   │   └── <ImageBlock>            // 图片块
│   └── <BlockControls>             // 块级控制(拖拽、删除等)
└── <FloatingToolbar>               // 浮动工具栏(文本选中时)

4.2 核心组件设计

BlockEditor(主编辑器)

// src/components/Editor/BlockEditor.tsx
interface BlockEditorProps {
  documentId: string;
  readOnly?: boolean;
  onSave?: () => void;
}

export const BlockEditor: React.FC<BlockEditorProps> = ({ documentId, readOnly }) => {
  const { blocks, loadDocument, saveDocument } = useEditorStore();
  
  useEffect(() => {
    loadDocument(documentId);
  }, [documentId]);
  
  return (
    <div className="block-editor">
      <Toolbar readOnly={readOnly} />
      <BlockCanvas blocks={blocks} readOnly={readOnly} />
    </div>
  );
};

BlockRenderer(块渲染器)

// src/components/Editor/BlockRenderer.tsx
interface BlockRendererProps {
  block: DocumentBlock;
  readOnly?: boolean;
}

export const BlockRenderer: React.FC<BlockRendererProps> = ({ block, readOnly }) => {
  switch (block.type) {
    case 'heading':
      return <HeadingBlock block={block as HeadingBlock} readOnly={readOnly} />;
    case 'paragraph':
      return <ParagraphBlock block={block as ParagraphBlock} readOnly={readOnly} />;
    case 'table':
      return <TableBlock block={block as TableBlock} readOnly={readOnly} />;
    case 'image':
      return <ImageBlock block={block as ImageBlock} readOnly={readOnly} />;
    default:
      return null;
  }
};

5. 表格编辑实现

5.1 表格渲染结构

使用div+CSS Grid实现灵活的表格布局:

// src/components/Editor/TableBlock.tsx
export const TableBlock: React.FC<{ block: TableBlock }> = ({ block }) => {
  const { rows } = block.content;
  const { cols, col_widths, table_width, table_width_unit } = block.metadata;
  
  return (
    <div 
      className="table-block"
      style={{
        display: 'grid',
        gridTemplateColumns: col_widths.map(w => `${w}${table_width_unit === 'percent' ? '%' : 'px'}`).join(' '),
        width: `${table_width}${table_width_unit === 'percent' ? '%' : 'px'}`
      }}
    >
      {rows.map((row, rowIdx) => 
        row.cells.map((cell, colIdx) => (
          <TableCell 
            key={`${rowIdx}-${colIdx}`}
            cell={cell}
            onUpdate={(updates) => handleCellUpdate(rowIdx, colIdx, updates)}
          />
        ))
      )}
    </div>
  );
};

5.2 单元格编辑

// src/components/Editor/TableCell.tsx
interface TableCellProps {
  cell: TableCell;
  onUpdate: (updates: Partial<TableCell>) => void;
}

export const TableCell: React.FC<TableCellProps> = ({ cell, onUpdate }) => {
  const [isEditing, setIsEditing] = useState(false);
  
  const handleDoubleClick = () => {
    setIsEditing(true);
  };
  
  return (
    <div
      className="table-cell"
      style={{
        gridColumn: `span ${cell.colspan}`,
        gridRow: `span ${cell.rowspan}`,
        ...applyCellStyles(cell.style)
      }}
      onDoubleClick={handleDoubleClick}
    >
      {isEditing ? (
        <RichTextEditor 
          content={cell.text}
          onChange={(newContent) => onUpdate({ text: newContent })}
          onBlur={() => setIsEditing(false)}
        />
      ) : (
        <RichTextDisplay content={cell.text} styles={cell.style} />
      )}
    </div>
  );
};

5.3 表格操作功能

  • 插入行/列: 在指定位置插入新行或列
  • 删除行/列: 删除选中的行或列
  • 合并单元格: 调整rowspancolspan
  • 调整列宽: 拖拽列边界,更新col_widths数组
  • 调整行高: 拖拽行边界,更新row_heights数组(可选)
// 表格操作辅助函数
export const tableOperations = {
  insertRow: (table: TableBlock, afterRow: number): TableBlock => {
    const newRow: TableRow = {
      cells: Array(table.metadata.cols).fill(null).map(() => ({
        text: '',
        rowspan: 1,
        colspan: 1,
        style: {}
      }))
    };
    const rows = [...table.content.rows];
    rows.splice(afterRow + 1, 0, newRow);
    return {
      ...table,
      content: { rows },
      metadata: { ...table.metadata, rows: rows.length }
    };
  },
  
  // insertColumn, deleteRow, deleteColumn, mergeCells...
};

6. 图片编辑实现

6.1 图片上传和插入

// src/components/Editor/ImageBlock.tsx
export const ImageBlock: React.FC<{ block: ImageBlock }> = ({ block }) => {
  const [isResizing, setIsResizing] = useState(false);
  const { width, height, align } = block.style;
  
  const handleImageUpload = async (file: File) => {
    const base64 = await fileToBase64(file);
    const dataUrl = `data:image/${file.type.split('/')[1]};base64,${base64}`;
    
    // 创建新的image block
    const imageBlock: ImageBlock = {
      id: generateBlockId('img'),
      block_order: computeNextOrder(),
      type: 'image',
      level: 0,
      index: 0,
      content: dataUrl,
      word_style: 'Normal',
      style: {
        width: 10,  // 默认10cm
        height: 7,
        unit: 'cm',
        align: 'center'
      },
      metadata: {
        alt: file.name,
        para_style: 'Normal',
        parent_heading_id: getCurrentHeadingId()
      }
    };
    
    addBlock(imageBlock);
  };
  
  return (
    <div className={`image-block align-${align}`}>
      <img 
        src={block.content}
        alt={block.metadata.alt}
        style={{
          width: `${width}cm`,
          height: `${height}cm`
        }}
        draggable={false}
      />
      {!readOnly && (
        <ResizeHandles 
          onResize={(newWidth, newHeight) => {
            updateBlock(block.id, {
              style: { ...block.style, width: newWidth, height: newHeight }
            });
          }}
        />
      )}
    </div>
  );
};

6.2 图片调整功能

  • 尺寸调整: 拖拽边角控制点
  • 对齐方式: 左对齐/居中/右对齐
  • 替换图片: 点击图片重新上传
  • 删除图片: 删除整个image block

7. 富文本编辑实现

7.1 RichTextEditor组件

// src/components/Editor/RichTextEditor.tsx
interface RichTextEditorProps {
  content: string | RichText[];
  onChange: (content: RichText[]) => void;
  onBlur?: () => void;
}

export const RichTextEditor: React.FC<RichTextEditorProps> = ({ content, onChange }) => {
  const editorRef = useRef<HTMLDivElement>(null);
  
  // 初始化编辑器内容
  useEffect(() => {
    if (editorRef.current) {
      editorRef.current.innerHTML = richTextToHtml(content);
    }
  }, []);
  
  // 处理格式化命令
  const execCommand = (command: string, value?: string) => {
    document.execCommand(command, false, value);
    updateContent();
  };
  
  const updateContent = () => {
    if (editorRef.current) {
      const richText = htmlToRichText(editorRef.current.innerHTML);
      onChange(richText);
    }
  };
  
  return (
    <div className="rich-text-editor">
      <div className="format-toolbar">
        <button onClick={() => execCommand('bold')}>粗体</button>
        <button onClick={() => execCommand('italic')}>斜体</button>
        <button onClick={() => execCommand('underline')}>下划线</button>
        <input 
          type="color" 
          onChange={(e) => execCommand('foreColor', e.target.value)}
          title="文字颜色"
        />
      </div>
      <div
        ref={editorRef}
        contentEditable
        onInput={updateContent}
        className="editor-content"
      />
    </div>
  );
};

7.2 富文本格式转换

// src/utils/richTextConverter.ts

// RichText数组 → HTML字符串(用于contenteditable渲染)
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数组(从contenteditable提取)
export function htmlToRichText(html: string): RichText[] {
  const tempDiv = document.createElement('div');
  tempDiv.innerHTML = html;
  
  const richText: RichText[] = [];
  
  function traverseNode(node: Node) {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent || '';
      if (text) {
        richText.push({
          text,
          style: extractStyleFromParent(node.parentElement)
        });
      }
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      node.childNodes.forEach(traverseNode);
    }
  }
  
  traverseNode(tempDiv);
  return mergeAdjacentSameStyle(richText);
}

function extractStyleFromParent(element: HTMLElement | null): RichTextStyle {
  const style: RichTextStyle = {};
  let el = element;
  
  while (el) {
    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 !== '000000') style.color = color;
    }
    if (computedStyle.fontFamily) style.font_name = computedStyle.fontFamily.replace(/['"]/g, '');
    if (computedStyle.fontSize) style.font_size = parseFloat(computedStyle.fontSize) * 0.75; // px to pt
    
    el = el.parentElement;
  }
  
  return style;
}

8. 块操作和block_order管理

8.1 稀疏排序策略

后端使用稀疏排序(间隔100),避免频繁更新block_order:

初始: 0, 100, 200, 300, 400
插入: 0, 100, 150(新), 200, 300, 400  // 无需更新其他块

前端插入块时计算中间值:

export function computeInsertOrder(prevOrder: number, nextOrder: number): number {
  const gap = nextOrder - prevOrder;
  
  if (gap > 1) {
    // 有间隙,直接取中间值
    return Math.floor((prevOrder + nextOrder) / 2);
  } else {
    // 间隙不足,触发局部重排
    return -1;  // 标记需要重排
  }
}

8.2 局部重排

export function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] {
  // 按block_order排序
  const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
  
  // 重新分配:每个块间隔100
  return sorted.map((block, index) => ({
    ...block,
    block_order: index * 100
  }));
}

// Store中的应用
export const editorStore = create<EditorStore>((set, get) => ({
  // ...
  addBlock: (block, afterBlockId) => {
    const blocks = get().blocks;
    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 = computeInsertOrder(prevOrder, nextOrder);
    
    if (newOrder === -1) {
      // 需要重排
      const newBlocks = rebalanceBlockOrders([...blocks, { ...block, block_order: 0 }]);
      set({ blocks: newBlocks });
    } else {
      set({ blocks: [...blocks, { ...block, block_order: newOrder }].sort((a, b) => a.block_order - b.block_order) });
    }
  }
}));

9. 样式系统适配

9.1 样式继承层级

word_style(样式文件) → block.style(块级覆盖) → richText.style(文本片段覆盖)

9.2 样式应用逻辑

// src/utils/styleResolver.ts

export function resolveBlockStyle(block: DocumentBlock): CSSProperties {
  // 1. 从word_style加载基础样式(后续可扩展为从样式库查询)
  const baseStyle = getWordStyleDefinition(block.word_style);
  
  // 2. 应用block.style覆盖
  const blockOverrides = parseStyleJSON(block.style);
  
  // 3. 合并
  return {
    ...baseStyle,
    ...blockOverrides
  };
}

export function resolveRichTextStyle(
  blockStyle: CSSProperties, 
  richTextStyle: RichTextStyle
): CSSProperties {
  return {
    ...blockStyle,
    fontWeight: richTextStyle.bold ? 'bold' : blockStyle.fontWeight,
    fontStyle: richTextStyle.italic ? 'italic' : blockStyle.fontStyle,
    textDecoration: richTextStyle.underline ? 'underline' : blockStyle.textDecoration,
    color: richTextStyle.color ? `#${richTextStyle.color}` : blockStyle.color,
    fontFamily: richTextStyle.font_name || blockStyle.fontFamily,
    fontSize: richTextStyle.font_size ? `${richTextStyle.font_size}pt` : blockStyle.fontSize
  };
}

10. 交互设计

10.1 键盘快捷键

快捷键 功能
Ctrl+B 加粗
Ctrl+I 斜体
Ctrl+U 下划线
Ctrl+S 保存
Enter 新建段落块
Shift+Enter 块内换行
Ctrl+Shift+T 插入表格
Ctrl+Shift+I 插入图片
Delete 删除选中块

10.2 拖拽排序

使用react-beautiful-dnddnd-kit实现块级拖拽重排:

import { DndContext, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';

export const BlockCanvas: React.FC = () => {
  const { blocks, moveBlock } = useEditorStore();
  
  const handleDragEnd = (event) => {
    const { active, over } = event;
    if (active.id !== over.id) {
      moveBlock(active.id, over.id);
    }
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd} collisionDetection={closestCenter}>
      <SortableContext items={blocks.map(b => b.id)} strategy={verticalListSortingStrategy}>
        {blocks.map(block => (
          <SortableBlock key={block.id} block={block} />
        ))}
      </SortableContext>
    </DndContext>
  );
};

11. 导出功能适配

11.1 导出流程

用户点击"导出" → 前端调用 POST /api/v1/export/doc
                    ↓
后端读取document_blocks表 → 按blocks生成Word
                    ↓
返回下载链接 → 前端触发下载

前端只需调用API,无需处理blocks到Word的转换(后端负责):

// src/services/exportService.ts
export const exportService = {
  async exportToDoc(documentId: string, styleId?: string): Promise<ExportResponse> {
    const res = await apiClient.post('/api/v1/export/doc', {
      documentId,
      styleId: styleId || null
    });
    return res.data.data;
  }
};

// 使用
const handleExport = async () => {
  const { downloadUrl, fileName } = await exportService.exportToDoc(documentId);
  
  // 触发浏览器下载
  const link = document.createElement('a');
  link.href = downloadUrl;
  link.download = fileName;
  link.click();
};

11.2 导出记录管理

// src/components/ExportHistory.tsx
export const ExportHistory: React.FC = () => {
  const [records, setRecords] = useState<ExportRecord[]>([]);
  
  useEffect(() => {
    loadRecords();
  }, []);
  
  const loadRecords = async () => {
    const res = await apiClient.get('/api/v1/export/records', {
      params: { userId: currentUserId }
    });
    setRecords(res.data.data.records);
  };
  
  const handleDelete = async (recordId: string) => {
    await apiClient.delete(`/api/v1/export/records/${recordId}`, {
      params: { userId: currentUserId }
    });
    loadRecords();
  };
  
  return (
    <Table dataSource={records}>
      <Column title="文件名" dataIndex="fileName" />
      <Column title="大小" dataIndex="fileSize" render={formatBytes} />
      <Column title="创建时间" dataIndex="createdAt" render={formatDate} />
      <Column title="操作" render={(_, record) => (
        <>
          <a href={record.downloadUrl} download>下载</a>
          <Button onClick={() => handleDelete(record.recordId)}>删除</Button>
        </>
      )} />
    </Table>
  );
};

12. 性能优化策略

12.1 虚拟滚动

大文档(1000+ blocks)使用虚拟列表渲染:

import { VariableSizeList } from 'react-window';

export const BlockCanvas: React.FC = () => {
  const { blocks } = useEditorStore();
  
  return (
    <VariableSizeList
      height={800}
      itemCount={blocks.length}
      itemSize={(index) => getBlockHeight(blocks[index])}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <BlockRenderer block={blocks[index]} />
        </div>
      )}
    </VariableSizeList>
  );
};

12.2 防抖保存

编辑过程中自动保存,防抖500ms:

const debouncedSave = useDebouncedCallback(
  () => {
    saveDocument();
  },
  500
);

// 在updateBlock中调用
updateBlock: (id, updates) => {
  // ...更新逻辑
  debouncedSave();
}

12.3 图片懒加载

export const ImageBlock: React.FC = ({ block }) => {
  return (
    <img 
      src={block.content}
      alt={block.metadata.alt}
      loading="lazy"  // 浏览器原生懒加载
    />
  );
};

13. 迁移路径

13.1 阶段性迁移

阶段1: 并行开发

  • 保留WYSIWYGEditor.tsx(MDXEditor)
  • 新建BlockEditor.tsx(新编辑器)
  • 通过feature flag切换:
export const DocumentEditorModal: React.FC = () => {
  const useNewEditor = useFeatureFlag('new-block-editor');
  
  return useNewEditor 
    ? <BlockEditor documentId={documentId} />
    : <WYSIWYGEditor markdown={markdown} onChange={setMarkdown} />;
};

阶段2: 数据迁移

  • 后端提供迁移脚本:旧Markdown文档 → blocks格式
  • 前端调用迁移API:
const migrateDocument = async (documentId: string) => {
  await apiClient.post(`/api/v1/documents/${documentId}/migrate-to-blocks`);
};

阶段3: 全面切换

  • 移除MDXEditor依赖
  • 删除旧编辑器代码
  • 更新所有引用

13.2 兼容性处理

对于尚未迁移的旧文档,前端自动检测并提示:

const { blocks, isLegacyDocument } = await loadDocument(documentId);

if (isLegacyDocument) {
  showModal({
    title: '文档需要升级',
    content: '此文档使用旧格式,需要升级后才能编辑',
    onOk: () => migrateDocument(documentId)
  });
}

14. 测试策略

14.1 单元测试

// src/components/Editor/__tests__/BlockRenderer.test.tsx
describe('BlockRenderer', () => {
  it('应正确渲染标题块', () => {
    const block: HeadingBlock = {
      id: 'block-h1-0',
      type: 'heading',
      level: 1,
      content: '测试标题',
      // ...
    };
    const { getByText } = render(<BlockRenderer block={block} />);
    expect(getByText('测试标题')).toBeInTheDocument();
  });
  
  it('应正确渲染富文本内容', () => {
    const block: ParagraphBlock = {
      id: 'block-p-0',
      type: 'paragraph',
      content: [
        { text: '普通', style: {} },
        { text: '加粗', style: { bold: true } }
      ],
      // ...
    };
    const { container } = render(<BlockRenderer block={block} />);
    expect(container.querySelector('strong')).toHaveTextContent('加粗');
  });
});

14.2 集成测试

// src/components/Editor/__tests__/BlockEditor.integration.test.tsx
describe('BlockEditor集成测试', () => {
  it('应能完整流程:加载→编辑→保存', async () => {
    mockApiGet('/documents/doc-123/blocks', {
      blocks: [/* mock数据 */]
    });
    
    const { getByText, getByRole } = render(<BlockEditor documentId="doc-123" />);
    
    // 等待加载完成
    await waitFor(() => expect(getByText('标题1')).toBeInTheDocument());
    
    // 编辑操作
    const heading = getByText('标题1');
    fireEvent.doubleClick(heading);
    fireEvent.input(heading, { target: { textContent: '新标题' } });
    
    // 保存
    mockApiPut('/documents/doc-123/blocks', {});
    const saveBtn = getByRole('button', { name: '保存' });
    fireEvent.click(saveBtn);
    
    await waitFor(() => {
      expect(mockApiPut).toHaveBeenCalledWith('/documents/doc-123/blocks', {
        blocks: expect.arrayContaining([
          expect.objectContaining({ content: '新标题' })
        ])
      });
    });
  });
});

14.3 E2E测试(Playwright)

// e2e/block-editor.spec.ts
test('表格编辑流程', async ({ page }) => {
  await page.goto('/editor/doc-123');
  
  // 插入表格
  await page.click('[aria-label="插入表格"]');
  await page.fill('[name="rows"]', '3');
  await page.fill('[name="cols"]', '3');
  await page.click('text=确定');
  
  // 编辑单元格
  const cell = page.locator('.table-cell').first();
  await cell.dblclick();
  await cell.fill('测试内容');
  await cell.press('Escape');
  
  // 保存
  await page.click('text=保存');
  await expect(page.locator('text=保存成功')).toBeVisible();
});

15. 技术风险和应对

15.1 contenteditable的坑

风险: contenteditable在不同浏览器行为不一致

应对:

  • 使用document.execCommand标准API
  • 禁用浏览器默认的格式化行为
  • 自行管理光标位置和选区
// 统一处理contenteditable
const makeContentEditable = (element: HTMLElement) => {
  element.contentEditable = 'true';
  
  // 禁用默认格式化
  element.addEventListener('paste', (e) => {
    e.preventDefault();
    const text = e.clipboardData?.getData('text/plain');
    document.execCommand('insertText', false, text);
  });
  
  // 阻止浏览器自动添加div/span
  element.addEventListener('input', () => {
    normalizeContentEditable(element);
  });
};

15.2 大表格性能

风险: 大表格(100x100单元格)渲染卡顿

应对:

  • 虚拟滚动渲染可视区域
  • 单元格使用React.memo防止无效重渲染
  • 延迟加载表格内图片

15.3 数据同步冲突

风险: 多标签页同时编辑导致数据冲突

应对:

  • 使用BroadcastChannel API实现标签页间通信
  • 编辑时加乐观锁(version字段)
  • 冲突时提示用户选择保留哪个版本
// 标签页同步
const channel = new BroadcastChannel('editor-sync');

channel.onmessage = (event) => {
  if (event.data.documentId === currentDocumentId) {
    showNotification('其他标签页已更新文档,是否重新加载?');
  }
};

// 保存时广播
const saveDocument = async () => {
  await apiClient.put(`/documents/${documentId}`, { blocks, version });
  channel.postMessage({ documentId, action: 'saved' });
};

16. 未来扩展方向

16.1 协同编辑(阶段2)

使用Yjs实现CRDT协同:

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

const ydoc = new Y.Doc();
const provider = new WebsocketProvider('ws://api.axonix.com/collab', documentId, ydoc);
const yblocks = ydoc.getArray('blocks');

// 监听远程更新
yblocks.observe(() => {
  const blocks = yblocks.toArray();
  updateStore(blocks);
});

// 本地修改同步
const updateBlock = (id, updates) => {
  const index = yblocks.toArray().findIndex(b => b.id === id);
  yblocks.delete(index, 1);
  yblocks.insert(index, [{ ...yblocks.get(index), ...updates }]);
};

16.2 评论和批注

在block级别添加评论:

interface CommentThread {
  id: string;
  blockId: string;
  comments: Comment[];
  resolved: boolean;
}

interface Comment {
  id: string;
  userId: string;
  content: string;
  createdAt: number;
}

16.3 版本对比

可视化显示两个版本的diff:

export const VersionDiff: React.FC<{ fromVersion, toVersion }> = ({ fromVersion, toVersion }) => {
  const diff = computeDiff(fromVersion.blocks, toVersion.blocks);
  
  return (
    <div className="version-diff">
      {diff.map(change => (
        <div className={`diff-${change.type}`}>
          {change.type === 'added' && <BlockRenderer block={change.block} />}
          {change.type === 'removed' && <BlockRenderer block={change.block} />}
          {change.type === 'modified' && <ModifiedBlockView before={change.before} after={change.after} />}
        </div>
      ))}
    </div>
  );
};

17. 参考资料

17.1 后端文档

17.2 技术参考


文档版本: v1.0
创建日期: 2026-07-03
作者: AI Assistant
状态: 设计阶段