|
|
@@ -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;
|