Kaynağa Gözat

feat(编辑器): 添加撤销/重做功能,优化编辑器状态管理

Zhang Yice 1 ay önce
ebeveyn
işleme
7bab9228da

+ 24 - 0
src/components/Editor/BlockEditor.tsx

@@ -91,6 +91,30 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
91 91
     };
92 92
   }, [documentId, loadDocument]);
93 93
 
94
+  // 在编辑器范围内接管撤销/重做快捷键,避免影响聊天输入框。
95
+  useEffect(() => {
96
+    const handleKeyDown = (event: KeyboardEvent) => {
97
+      if (readOnly || !event.target || !(event.target as HTMLElement).closest('.block-editor')) {
98
+        return;
99
+      }
100
+
101
+      const modifierPressed = event.ctrlKey || event.metaKey;
102
+      if (!modifierPressed || event.key.toLowerCase() !== 'z') {
103
+        return;
104
+      }
105
+
106
+      event.preventDefault();
107
+      if (event.shiftKey) {
108
+        useEditorStore.getState().redo();
109
+      } else {
110
+        useEditorStore.getState().undo();
111
+      }
112
+    };
113
+
114
+    window.addEventListener('keydown', handleKeyDown);
115
+    return () => window.removeEventListener('keydown', handleKeyDown);
116
+  }, [readOnly]);
117
+
94 118
   // ── Save handler ───────────────────────────────────────────────────────────
95 119
   const handleSave = useCallback(async () => {
96 120
     if (readOnly) return;

+ 33 - 0
src/components/Editor/toolbar/MainToolbar.tsx

@@ -20,6 +20,8 @@ import {
20 20
   DownOutlined,
21 21
   LoadingOutlined,
22 22
   ExclamationCircleOutlined,
23
+  UndoOutlined,
24
+  RedoOutlined,
23 25
 } from '@ant-design/icons';
24 26
 import { useEditorStore } from '../../../stores/editorStore';
25 27
 import { exportToWord } from '../../../services/exportService';
@@ -73,6 +75,12 @@ export const MainToolbar: React.FC<MainToolbarProps> = ({
73 75
     setAutoSaveEnabled,
74 76
     lastSaveTime,
75 77
     savingProgress,
78
+    pastLength,
79
+    futureLength,
80
+    isHistoryApplying,
81
+    pendingStructuralOperations,
82
+    undo,
83
+    redo,
76 84
   } = useEditorStore(useShallow((state) => ({
77 85
     hasModified: state.hasModified,
78 86
     isSaving: state.isSaving,
@@ -80,6 +88,12 @@ export const MainToolbar: React.FC<MainToolbarProps> = ({
80 88
     setAutoSaveEnabled: state.setAutoSaveEnabled,
81 89
     lastSaveTime: state.lastSaveTime,
82 90
     savingProgress: state.savingProgress,
91
+    pastLength: state.past.length,
92
+    futureLength: state.future.length,
93
+    isHistoryApplying: state.isHistoryApplying,
94
+    pendingStructuralOperations: state.pendingStructuralOperations,
95
+    undo: state.undo,
96
+    redo: state.redo,
83 97
   })));
84 98
   
85 99
   // ── 格式化上次保存时间 ────────────────────────────────────────────────────
@@ -232,6 +246,25 @@ export const MainToolbar: React.FC<MainToolbarProps> = ({
232 246
       <div className="toolbar-right">
233 247
         {!readOnly && (
234 248
           <>
249
+            <Button
250
+              type="text"
251
+              size="small"
252
+              icon={<UndoOutlined />}
253
+              onClick={undo}
254
+              disabled={pastLength === 0 || isSaving || isHistoryApplying || pendingStructuralOperations > 0}
255
+              title="撤销 (Ctrl/Cmd+Z)"
256
+              aria-label="撤销"
257
+            />
258
+            <Button
259
+              type="text"
260
+              size="small"
261
+              icon={<RedoOutlined />}
262
+              onClick={redo}
263
+              disabled={futureLength === 0 || isSaving || isHistoryApplying || pendingStructuralOperations > 0}
264
+              title="重做 (Ctrl/Cmd+Shift+Z)"
265
+              aria-label="重做"
266
+            />
267
+
235 268
             {/* 自动保存切换 */}
236 269
             <Button
237 270
               type={autoSaveEnabled ? 'default' : 'text'}

+ 242 - 10
src/stores/editorStore.ts

@@ -33,6 +33,17 @@ const AUTO_SAVE_DELAY = 3000; // 3秒无操作后自动保存
33 33
 
34 34
 // 最大重试次数
35 35
 const MAX_RETRY_ATTEMPTS = 3;
36
+const MAX_HISTORY_ENTRIES = 100;
37
+
38
+interface EditorHistoryEntry {
39
+  blocks: DocumentBlock[];
40
+  selectedBlockId: string | null;
41
+}
42
+
43
+function snapshotBlocks(blocks: DocumentBlock[]): DocumentBlock[] {
44
+  // 块更新采用不可变替换,历史快照只需复制数组即可复用块对象。
45
+  return blocks.slice();
46
+}
36 47
 
37 48
 /**
38 49
  * 计算块内容的哈希值
@@ -77,6 +88,10 @@ interface EditorStore {
77 88
   documentTitle: string;
78 89
   blocks: DocumentBlock[];
79 90
   selectedBlockId: string | null;
91
+  past: EditorHistoryEntry[];
92
+  future: EditorHistoryEntry[];
93
+  isHistoryApplying: boolean;
94
+  pendingStructuralOperations: number;
80 95
   
81 96
   // ── 加载/保存状态 ──────────────────────────────────────────────────────
82 97
   isLoading: boolean;
@@ -232,6 +247,10 @@ interface EditorStore {
232 247
    * 重置状态
233 248
    */
234 249
   reset: () => void;
250
+  /** 撤销最近一次编辑 */
251
+  undo: () => Promise<void>;
252
+  /** 重做最近一次撤销 */
253
+  redo: () => Promise<void>;
235 254
 }
236 255
 
237 256
 // ══════════════════════════════════════════════════════════════════════════════
@@ -288,6 +307,10 @@ const initialState = {
288 307
   documentTitle: '',
289 308
   blocks: [],
290 309
   selectedBlockId: null,
310
+  past: [],
311
+  future: [],
312
+  isHistoryApplying: false,
313
+  pendingStructuralOperations: 0,
291 314
   isLoading: false,
292 315
   isSaving: false,
293 316
   error: null,
@@ -308,8 +331,100 @@ const initialState = {
308 331
   retryTimer: null,
309 332
 };
310 333
 
311
-export const useEditorStore = create<EditorStore>((set, get) => ({
312
-  ...initialState,
334
+export const useEditorStore = create<EditorStore>((set, get) => {
335
+  const pushHistory = (blocks: DocumentBlock[], selectedBlockId: string | null) => {
336
+    set((state) => ({
337
+      past: [
338
+        ...state.past,
339
+        { blocks: snapshotBlocks(blocks), selectedBlockId },
340
+      ].slice(-MAX_HISTORY_ENTRIES),
341
+      future: [],
342
+    }));
343
+  };
344
+
345
+  const restoreHistoryEntry = async (entry: EditorHistoryEntry) => {
346
+    const { blockHashes, autoSaveEnabled, blocks: currentBlocks, documentId } = get();
347
+    let restoredBlocks = entry.blocks;
348
+    let restoredSelectedBlockId = entry.selectedBlockId;
349
+    const currentIds = new Set(currentBlocks.map((block) => block.id));
350
+    const targetIds = new Set(entry.blocks.map((block) => block.id));
351
+    const hasStructuralChange = currentBlocks.some((block) => !targetIds.has(block.id)) ||
352
+      entry.blocks.some((block) => !currentIds.has(block.id));
353
+
354
+    if (hasStructuralChange && documentId) {
355
+      await Promise.all(
356
+        currentBlocks
357
+          .filter((block) => !targetIds.has(block.id))
358
+          .map((block) => blockService.deleteBlock(documentId, block.id)),
359
+      );
360
+
361
+      const resolvedIds = new Map<string, string>();
362
+      let previousBlockId: string | null = null;
363
+      for (const block of [...entry.blocks].sort((a, b) => a.block_order - b.block_order)) {
364
+        if (currentIds.has(block.id)) {
365
+          resolvedIds.set(block.id, block.id);
366
+        } else {
367
+          const response = await blockService.createBlock(documentId, {
368
+            type: block.type,
369
+            level: block.level,
370
+            index: block.index,
371
+            content: block.content,
372
+            word_style: block.word_style,
373
+            style: block.style,
374
+            metadata: block.metadata,
375
+            after_block_id: previousBlockId,
376
+          });
377
+          if (!response.blockId) {
378
+            throw new Error('撤销操作创建块失败');
379
+          }
380
+          resolvedIds.set(block.id, response.blockId);
381
+        }
382
+        previousBlockId = resolvedIds.get(block.id) || null;
383
+      }
384
+
385
+      restoredBlocks = entry.blocks.map((block) => ({
386
+        ...block,
387
+        id: resolvedIds.get(block.id) || block.id,
388
+      }));
389
+      restoredSelectedBlockId = entry.selectedBlockId
390
+        ? resolvedIds.get(entry.selectedBlockId) || entry.selectedBlockId
391
+        : null;
392
+    }
393
+
394
+    const restoredHashes = new Map(blockHashes);
395
+    currentBlocks.forEach((block) => {
396
+      if (!restoredBlocks.some((restoredBlock) => restoredBlock.id === block.id)) {
397
+        restoredHashes.delete(block.id);
398
+      }
399
+    });
400
+    restoredBlocks.forEach((block) => {
401
+      if (!restoredHashes.has(block.id)) {
402
+        restoredHashes.set(block.id, computeBlockHash(block));
403
+      }
404
+    });
405
+    const dirtyBlocks = new Set(
406
+      restoredBlocks
407
+        .filter((block) => restoredHashes.get(block.id) !== computeBlockHash(block))
408
+        .map((block) => block.id),
409
+    );
410
+
411
+    set({
412
+      blocks: restoredBlocks,
413
+      selectedBlockId: restoredSelectedBlockId,
414
+      dirtyBlocks,
415
+      hasModified: dirtyBlocks.size > 0,
416
+      failedBlocks: [],
417
+      error: null,
418
+      blockHashes: restoredHashes,
419
+    });
420
+
421
+    if (autoSaveEnabled && dirtyBlocks.size > 0) {
422
+      get().triggerAutoSave();
423
+    }
424
+  };
425
+
426
+  return ({
427
+    ...initialState,
313 428
 
314 429
   // ── loadDocument ────────────────────────────────────────────────────────
315 430
   loadDocument: async (documentId: string) => {
@@ -366,6 +481,8 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
366 481
         failedBlocks: [], // 清空失败块列表
367 482
         blockHashes: initialHashes, // 初始化哈希表
368 483
         loadAbortController: null,
484
+        past: [],
485
+        future: [],
369 486
       });
370 487
     } catch (error: unknown) {
371 488
       if (controller.signal.aborted || isCanceledRequest(error)) return;
@@ -859,12 +976,16 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
859 976
 
860 977
   // ── addBlock ────────────────────────────────────────────────────────────
861 978
   addBlock: async (partialBlock: PartialBlock, afterBlockId?: string) => {
862
-    const { blocks, blockHashes, documentId } = get();
979
+    const { blocks, blockHashes, documentId, isHistoryApplying, pendingStructuralOperations } = get();
863 980
     
864
-    if (!documentId) {
981
+    if (!documentId || isHistoryApplying || pendingStructuralOperations > 0) {
982
+      if (!documentId) {
865 983
       message.error('没有打开的文档');
984
+      }
866 985
       return;
867 986
     }
987
+
988
+    set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
868 989
     
869 990
     let orderedBlocks = blocks;
870 991
 
@@ -939,6 +1060,11 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
939 1060
       if (!realBlockId) {
940 1061
         throw new Error('后端未返回blockId');
941 1062
       }
1063
+
1064
+      if (get().documentId !== documentId) {
1065
+        await blockService.deleteBlock(documentId, realBlockId).catch(() => undefined);
1066
+        return;
1067
+      }
942 1068
       
943 1069
       // 用后端返回的真实ID替换临时ID
944 1070
       const { blocks: currentBlocks } = get();
@@ -962,6 +1088,7 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
962 1088
           blocks: updatedBlocks,
963 1089
           blockHashes: newBlockHashes,
964 1090
         });
1091
+        pushHistory(blocks, get().selectedBlockId);
965 1092
       } else {
966 1093
         throw new Error('无法找到创建的块');
967 1094
       }
@@ -976,18 +1103,28 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
976 1103
       
977 1104
       message.error(getErrorMessage(error) || '创建块失败');
978 1105
       throw error;
1106
+    } finally {
1107
+      set((state) => ({
1108
+        pendingStructuralOperations: Math.max(0, state.pendingStructuralOperations - 1),
1109
+      }));
979 1110
     }
980 1111
   },
981 1112
 
982 1113
   // ── updateBlock ─────────────────────────────────────────────────────────
983 1114
   updateBlock: (id: string, updates: BlockUpdate) => {
984
-    const { blocks, dirtyBlocks, blockHashes, autoSaveEnabled } = get();
1115
+    const { blocks, dirtyBlocks, blockHashes, autoSaveEnabled, isHistoryApplying, pendingStructuralOperations } = get();
1116
+
1117
+    if (isHistoryApplying || pendingStructuralOperations > 0) {
1118
+      return;
1119
+    }
985 1120
 
986 1121
     const blockIndex = blocks.findIndex((block) => block.id === id);
987 1122
     if (blockIndex < 0) {
988 1123
       return;
989 1124
     }
990 1125
 
1126
+    const previousBlocks = blocks;
1127
+
991 1128
     const newBlocks = blocks.slice();
992 1129
     newBlocks[blockIndex] = { ...blocks[blockIndex], ...updates } as DocumentBlock;
993 1130
 
@@ -1014,6 +1151,9 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1014 1151
       dirtyBlocks: newDirtyBlocks,
1015 1152
       hasModified: newDirtyBlocks.size > 0,
1016 1153
     });
1154
+    if (newHash !== computeBlockHash(blocks[blockIndex])) {
1155
+      pushHistory(previousBlocks, get().selectedBlockId);
1156
+    }
1017 1157
     
1018 1158
     // 触发自动保存
1019 1159
     if (autoSaveEnabled && newDirtyBlocks.size > 0) {
@@ -1023,12 +1163,16 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1023 1163
 
1024 1164
   // ── deleteBlock ─────────────────────────────────────────────────────────
1025 1165
   deleteBlock: async (id: string) => {
1026
-    const { blocks, dirtyBlocks, blockHashes, documentId } = get();
1166
+    const { blocks, dirtyBlocks, blockHashes, documentId, isHistoryApplying, pendingStructuralOperations } = get();
1027 1167
     
1028
-    if (!documentId) {
1168
+    if (!documentId || isHistoryApplying || pendingStructuralOperations > 0) {
1169
+      if (!documentId) {
1029 1170
       message.error('没有打开的文档');
1171
+      }
1030 1172
       return;
1031 1173
     }
1174
+
1175
+    set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
1032 1176
     
1033 1177
     // 找到要删除的块
1034 1178
     const blockToDelete = blocks.find(b => b.id === id);
@@ -1121,7 +1265,12 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1121 1265
           hasModified: latestState.dirtyBlocks.size > 0,
1122 1266
         });
1123 1267
       }
1268
+      pushHistory(blocks, get().selectedBlockId);
1124 1269
     } catch (error: unknown) {
1270
+      if (get().documentId !== documentId) {
1271
+        return;
1272
+      }
1273
+
1125 1274
       // 删除或更新失败,回滚本地状态
1126 1275
       
1127 1276
       // 恢复被删除的块
@@ -1152,18 +1301,96 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1152 1301
       
1153 1302
       message.error(getErrorMessage(error) || '删除块失败');
1154 1303
       throw error;
1304
+    } finally {
1305
+      set((state) => ({
1306
+        pendingStructuralOperations: Math.max(0, state.pendingStructuralOperations - 1),
1307
+      }));
1155 1308
     }
1156 1309
   },
1157 1310
 
1158 1311
   // ── moveBlock ───────────────────────────────────────────────────────────
1159 1312
   moveBlock: (id: string, targetOrder: number) => {
1160
-    const { blocks } = get();
1313
+    const { blocks, isHistoryApplying, pendingStructuralOperations } = get();
1314
+    if (isHistoryApplying || pendingStructuralOperations > 0) {
1315
+      return;
1316
+    }
1317
+    const block = blocks.find((item) => item.id === id);
1318
+    if (!block || block.block_order === targetOrder) {
1319
+      return;
1320
+    }
1321
+    pushHistory(blocks, get().selectedBlockId);
1161 1322
     
1162 1323
     const newBlocks = blocks.map((block) =>
1163 1324
       block.id === id ? { ...block, block_order: targetOrder } : block
1164 1325
     );
1165 1326
     
1166
-    set({ blocks: newBlocks });
1327
+    const { dirtyBlocks, blockHashes } = get();
1328
+    const newDirtyBlocks = new Set(dirtyBlocks);
1329
+    const movedBlock = newBlocks.find((item) => item.id === id);
1330
+    if (movedBlock && blockHashes.get(id) !== computeBlockHash(movedBlock)) {
1331
+      newDirtyBlocks.add(id);
1332
+    }
1333
+    set({
1334
+      blocks: newBlocks,
1335
+      dirtyBlocks: newDirtyBlocks,
1336
+      hasModified: newDirtyBlocks.size > 0,
1337
+    });
1338
+    if (get().autoSaveEnabled) {
1339
+      get().triggerAutoSave();
1340
+    }
1341
+  },
1342
+
1343
+  // ── undo / redo ────────────────────────────────────────────────────────
1344
+  undo: async () => {
1345
+    const { past, blocks, selectedBlockId, isHistoryApplying, pendingStructuralOperations } = get();
1346
+    const previous = past[past.length - 1];
1347
+    if (!previous || isHistoryApplying || pendingStructuralOperations > 0) return;
1348
+
1349
+    set({ isHistoryApplying: true });
1350
+    try {
1351
+      await restoreHistoryEntry(previous);
1352
+      set((state) => ({
1353
+        past: state.past.slice(0, -1),
1354
+        future: [
1355
+          ...state.future,
1356
+          { blocks: snapshotBlocks(blocks), selectedBlockId },
1357
+        ].slice(-MAX_HISTORY_ENTRIES),
1358
+        isHistoryApplying: false,
1359
+      }));
1360
+    } catch (error: unknown) {
1361
+      const documentId = get().documentId;
1362
+      if (documentId) {
1363
+        await get().loadDocument(documentId).catch(() => undefined);
1364
+      }
1365
+      set({ isHistoryApplying: false });
1366
+      message.error(getErrorMessage(error) || '撤销失败');
1367
+    }
1368
+  },
1369
+
1370
+  redo: async () => {
1371
+    const { future, blocks, selectedBlockId, isHistoryApplying, pendingStructuralOperations } = get();
1372
+    const next = future[future.length - 1];
1373
+    if (!next || isHistoryApplying || pendingStructuralOperations > 0) return;
1374
+
1375
+    set({ isHistoryApplying: true });
1376
+    try {
1377
+      await restoreHistoryEntry(next);
1378
+      set((state) => ({
1379
+        future: state.future.slice(0, -1),
1380
+        past: [
1381
+          ...state.past,
1382
+          { blocks: snapshotBlocks(blocks), selectedBlockId },
1383
+        ].slice(-MAX_HISTORY_ENTRIES),
1384
+        isHistoryApplying: false,
1385
+      }));
1386
+    } catch (error: unknown) {
1387
+      const documentId = get().documentId;
1388
+      if (documentId) {
1389
+        await get().loadDocument(documentId).catch(() => undefined);
1390
+      }
1391
+      set({ isHistoryApplying: false });
1392
+      message.error(getErrorMessage(error) || '重做失败');
1393
+    }
1167 1394
   },
1168 1395
 
1169 1396
   // ── selectBlock ─────────────────────────────────────────────────────────
@@ -1311,6 +1538,10 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1311 1538
   recomputeBlockOrders: () => {
1312 1539
     const { blocks } = get();
1313 1540
     const newBlocks = rebalanceOrders(blocks);
1541
+    if (JSON.stringify(newBlocks) === JSON.stringify(blocks)) {
1542
+      return;
1543
+    }
1544
+    pushHistory(blocks, get().selectedBlockId);
1314 1545
     set({ blocks: newBlocks });
1315 1546
   },
1316 1547
   
@@ -1376,6 +1607,7 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1376 1607
     
1377 1608
     set(initialState);
1378 1609
   },
1379
-}));
1610
+  });
1611
+});
1380 1612
 
1381 1613
 export default useEditorStore;