当前前端使用MDXEditor组件编辑Markdown文档,后端基于旧架构。新后端已升级为基于SQLite的blocks存储架构,使用结构化的blocks数据来表示文档内容,支持更精细的内容管理和样式控制。
现有应用架构: 双面板布局
集成目标: 保留聊天面板和双面板布局,仅替换EditorPanel内部的编辑器内核(MDXEditor → BlockEditor)。
| 技术 | 选型 | 理由 |
|---|---|---|
| 编辑器核心 | 自研基于contenteditable | 完全控制渲染和交互逻辑 |
| 状态管理 | Zustand | 保持现有技术栈一致 |
| UI组件库 | Ant Design | 保持现有技术栈一致 |
| 表格渲染 | 原生div+CSS Grid | 灵活控制布局和样式 |
| 图片处理 | 原生img+Base64 | 简化上传流程 |
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
);
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
};
}
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;
};
}
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;
}
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;
};
}
块内部分文本可有不同样式:
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" } }
]
┌─────────────────────────────────────────────────────────┐
│ App.tsx (根组件) │
├──────────────────────┬──────────────────────────────────┤
│ ChatPanel (左40%) │ 右侧面板(动态切换): │
│ ✅ 完全保留不变 │ - ExportRecordList (默认) │
│ - MessageList │ - EditorPanel (编辑时) ⭐ │
│ - MessageInput │ └── BlockEditor (新内核) │
│ - chatStore │ └── blocks编辑 │
└──────────────────────┴──────────────────────────────────┘
集成策略:
typescript
{USE_NEW_EDITOR ? <BlockEditor /> : <WYSIWYGEditor />}
用户在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
// 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的关系:
previewDocumentId、打开/关闭编辑器等UI状态Store之间不直接耦合,通过documentId传递关联。
// 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);
}
};
<BlockEditor> // 顶层编辑器容器
├── <Toolbar> // 工具栏(插入块、格式化等)
├── <BlockCanvas> // 画布区域
│ ├── <BlockRenderer> // 块渲染器(根据type渲染)
│ │ ├── <HeadingBlock> // 标题块
│ │ ├── <ParagraphBlock> // 段落块
│ │ ├── <TableBlock> // 表格块
│ │ │ └── <TableCell> // 单元格(支持富文本)
│ │ └── <ImageBlock> // 图片块
│ └── <BlockControls> // 块级控制(拖拽、删除等)
└── <FloatingToolbar> // 浮动工具栏(文本选中时)
// 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>
);
};
// 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;
}
};
使用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>
);
};
// 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>
);
};
rowspan和colspancol_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...
};
// 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>
);
};
// 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>
);
};
// 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;
}
后端使用稀疏排序(间隔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; // 标记需要重排
}
}
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) });
}
}
}));
word_style(样式文件) → block.style(块级覆盖) → richText.style(文本片段覆盖)
// 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
};
}
| 快捷键 | 功能 |
|---|---|
| Ctrl+B | 加粗 |
| Ctrl+I | 斜体 |
| Ctrl+U | 下划线 |
| Ctrl+S | 保存 |
| Enter | 新建段落块 |
| Shift+Enter | 块内换行 |
| Ctrl+Shift+T | 插入表格 |
| Ctrl+Shift+I | 插入图片 |
| Delete | 删除选中块 |
使用react-beautiful-dnd或dnd-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>
);
};
用户点击"导出" → 前端调用 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();
};
// 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>
);
};
大文档(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>
);
};
编辑过程中自动保存,防抖500ms:
const debouncedSave = useDebouncedCallback(
() => {
saveDocument();
},
500
);
// 在updateBlock中调用
updateBlock: (id, updates) => {
// ...更新逻辑
debouncedSave();
}
export const ImageBlock: React.FC = ({ block }) => {
return (
<img
src={block.content}
alt={block.metadata.alt}
loading="lazy" // 浏览器原生懒加载
/>
);
};
WYSIWYGEditor.tsx(MDXEditor)BlockEditor.tsx(新编辑器)export const DocumentEditorModal: React.FC = () => {
const useNewEditor = useFeatureFlag('new-block-editor');
return useNewEditor
? <BlockEditor documentId={documentId} />
: <WYSIWYGEditor markdown={markdown} onChange={setMarkdown} />;
};
const migrateDocument = async (documentId: string) => {
await apiClient.post(`/api/v1/documents/${documentId}/migrate-to-blocks`);
};
对于尚未迁移的旧文档,前端自动检测并提示:
const { blocks, isLegacyDocument } = await loadDocument(documentId);
if (isLegacyDocument) {
showModal({
title: '文档需要升级',
content: '此文档使用旧格式,需要升级后才能编辑',
onOk: () => migrateDocument(documentId)
});
}
// 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('加粗');
});
});
// 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: '新标题' })
])
});
});
});
});
// 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();
});
风险: 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);
});
};
风险: 大表格(100x100单元格)渲染卡顿
应对:
React.memo防止无效重渲染风险: 多标签页同时编辑导致数据冲突
应对:
BroadcastChannel API实现标签页间通信// 标签页同步
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' });
};
使用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 }]);
};
在block级别添加评论:
interface CommentThread {
id: string;
blockId: string;
comments: Comment[];
resolved: boolean;
}
interface Comment {
id: string;
userId: string;
content: string;
createdAt: number;
}
可视化显示两个版本的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>
);
};
文档版本: v1.0
创建日期: 2026-07-03
作者: AI Assistant
状态: 设计阶段