| 技术 | 版本 | 说明 |
|---|---|---|
| Node.js | 18.x+ | 建议使用LTS版本 |
| React | 18.2+ | 当前项目版本 |
| TypeScript | 5.0+ | 严格模式 |
| Vite | 4.x+ | 构建工具 |
| Zustand | 4.x+ | 状态管理 |
| Ant Design | 5.x+ | UI组件库 |
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
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 # 样式解析
创建 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;
// 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
}));
}
// 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}`);
}
};
// 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; // 需要重排
}
// 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> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
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;
}
// 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>
);
};
// 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;
}
};
任务清单:
验收标准:
任务清单:
验收标准:
任务清单:
验收标准:
任务清单:
验收标准:
任务清单:
验收标准:
任务清单:
验收标准:
查看组件树和Store状态:
React DevTools → Components → BlockEditor
↓ props/state
↓ Store (Zustand)
监听Store变化:
// 启用Redux DevTools
import { devtools } from 'zustand/middleware';
export const useEditorStore = create<EditorStore>()(
devtools((set, get) => ({
// ...store定义
}), { name: 'EditorStore' })
);
关键操作添加日志:
updateBlock: (id, updates) => {
console.log('[EditorStore] updateBlock', { id, updates });
// ...
};
解决方案: 保存和恢复光标位置
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);
解决方案: 使用CSS Grid的span语法
<div
className="table-cell"
style={{
gridColumn: `span ${cell.colspan}`,
gridRow: `span ${cell.rowspan}`
}}
>
{cell.text}
</div>
解决方案: 使用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>
BlockEditorloadDocumentMAX_BLOCKSDocumentBlock.test.tsx/**
* 加载文档的所有blocks
* @param documentId 文档ID
* @returns Promise<DocumentBlock[]>
*/
async loadDocument(documentId: string): Promise<DocumentBlock[]> {
// ...
}
文档版本: v1.0
创建日期: 2026-07-03
维护者: 开发团队