|
|
@@ -82,6 +82,35 @@ function isCanceledRequest(error: unknown): boolean {
|
|
82
|
82
|
);
|
|
83
|
83
|
}
|
|
84
|
84
|
|
|
|
85
|
+function serializeBlockForSave(block: DocumentBlock): Pick<BlockUpdate, 'content' | 'style'> {
|
|
|
86
|
+ if (block.type === 'table') {
|
|
|
87
|
+ const serializedTable = serializeTableBlock(block as TableBlock);
|
|
|
88
|
+ return {
|
|
|
89
|
+ content: serializedTable.content,
|
|
|
90
|
+ style: block.style,
|
|
|
91
|
+ };
|
|
|
92
|
+ }
|
|
|
93
|
+
|
|
|
94
|
+ if ((block.type === 'heading' || block.type === 'paragraph') && Array.isArray(block.content)) {
|
|
|
95
|
+ const firstStyle = block.content[0]?.style;
|
|
|
96
|
+ const allSameStyle = !!firstStyle && block.content.every((segment) =>
|
|
|
97
|
+ JSON.stringify(segment.style) === JSON.stringify(firstStyle),
|
|
|
98
|
+ );
|
|
|
99
|
+
|
|
|
100
|
+ return {
|
|
|
101
|
+ content: block.content.map((segment) => segment.text).join(''),
|
|
|
102
|
+ style: allSameStyle && Object.keys(firstStyle).length > 0
|
|
|
103
|
+ ? { ...block.style, ...firstStyle }
|
|
|
104
|
+ : block.style,
|
|
|
105
|
+ };
|
|
|
106
|
+ }
|
|
|
107
|
+
|
|
|
108
|
+ return {
|
|
|
109
|
+ content: block.content,
|
|
|
110
|
+ style: block.style,
|
|
|
111
|
+ };
|
|
|
112
|
+}
|
|
|
113
|
+
|
|
85
|
114
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
86
|
115
|
// Store State Interface
|
|
87
|
116
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
@@ -582,34 +611,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
582
|
611
|
// 获取该块的重试次数
|
|
583
|
612
|
const attempts = retryAttempts.get(block.id) || 0;
|
|
584
|
613
|
|
|
585
|
|
- // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段)
|
|
586
|
|
- let contentToSave = block.content;
|
|
587
|
|
- let styleToSave = block.style;
|
|
588
|
|
-
|
|
589
|
|
- if (block.type === 'table') {
|
|
590
|
|
- const serializedTable = serializeTableBlock(block as TableBlock);
|
|
591
|
|
- contentToSave = serializedTable.content;
|
|
592
|
|
- } else if (block.type === 'heading' || block.type === 'paragraph') {
|
|
593
|
|
- // 对于标题和段落块,如果content是富文本数组,需要序列化
|
|
594
|
|
- if (Array.isArray(block.content)) {
|
|
595
|
|
- // 提取纯文本
|
|
596
|
|
- contentToSave = block.content.map(seg => seg.text).join('');
|
|
597
|
|
-
|
|
598
|
|
- // 如果所有片段的样式一致,提取到块级style
|
|
599
|
|
- const allSegments = block.content;
|
|
600
|
|
- if (allSegments.length > 0) {
|
|
601
|
|
- const firstStyle = allSegments[0].style;
|
|
602
|
|
- const allSameStyle = allSegments.every(seg =>
|
|
603
|
|
- JSON.stringify(seg.style) === JSON.stringify(firstStyle)
|
|
604
|
|
- );
|
|
605
|
|
-
|
|
606
|
|
- if (allSameStyle && Object.keys(firstStyle).length > 0) {
|
|
607
|
|
- // 所有片段样式一致,合并到块级style
|
|
608
|
|
- styleToSave = { ...block.style, ...firstStyle };
|
|
609
|
|
- }
|
|
610
|
|
- }
|
|
611
|
|
- }
|
|
612
|
|
- }
|
|
|
614
|
+ const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
|
|
613
|
615
|
|
|
614
|
616
|
try {
|
|
615
|
617
|
const result = await blockService.updateBlock(
|
|
|
@@ -825,6 +827,9 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
825
|
827
|
const blocksToRetry = blocks.filter(block =>
|
|
826
|
828
|
failedBlocks.includes(block.id)
|
|
827
|
829
|
);
|
|
|
830
|
+ const savedHashes = new Map(
|
|
|
831
|
+ blocksToRetry.map((block) => [block.id, computeBlockHash(block)]),
|
|
|
832
|
+ );
|
|
828
|
833
|
|
|
829
|
834
|
const totalBlocks = blocksToRetry.length;
|
|
830
|
835
|
|
|
|
@@ -837,34 +842,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
837
|
842
|
// 使用并发限制器重试保存
|
|
838
|
843
|
const tasks = blocksToRetry.map(block =>
|
|
839
|
844
|
saveConcurrencyLimit(() => {
|
|
840
|
|
- // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段)
|
|
841
|
|
- let contentToSave = block.content;
|
|
842
|
|
- let styleToSave = block.style;
|
|
843
|
|
-
|
|
844
|
|
- if (block.type === 'table') {
|
|
845
|
|
- const serializedTable = serializeTableBlock(block as TableBlock);
|
|
846
|
|
- contentToSave = serializedTable.content;
|
|
847
|
|
- } else if (block.type === 'heading' || block.type === 'paragraph') {
|
|
848
|
|
- // 对于标题和段落块,如果content是富文本数组,需要序列化
|
|
849
|
|
- if (Array.isArray(block.content)) {
|
|
850
|
|
- // 提取纯文本
|
|
851
|
|
- contentToSave = block.content.map(seg => seg.text).join('');
|
|
852
|
|
-
|
|
853
|
|
- // 如果所有片段的样式一致,提取到块级style
|
|
854
|
|
- const allSegments = block.content;
|
|
855
|
|
- if (allSegments.length > 0) {
|
|
856
|
|
- const firstStyle = allSegments[0].style;
|
|
857
|
|
- const allSameStyle = allSegments.every(seg =>
|
|
858
|
|
- JSON.stringify(seg.style) === JSON.stringify(firstStyle)
|
|
859
|
|
- );
|
|
860
|
|
-
|
|
861
|
|
- if (allSameStyle && Object.keys(firstStyle).length > 0) {
|
|
862
|
|
- // 所有片段样式一致,合并到块级style
|
|
863
|
|
- styleToSave = { ...block.style, ...firstStyle };
|
|
864
|
|
- }
|
|
865
|
|
- }
|
|
866
|
|
- }
|
|
867
|
|
- }
|
|
|
845
|
+ const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
|
|
868
|
846
|
|
|
869
|
847
|
return blockService.updateBlock(
|
|
870
|
848
|
documentId,
|
|
|
@@ -901,23 +879,20 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
901
|
879
|
}
|
|
902
|
880
|
});
|
|
903
|
881
|
|
|
904
|
|
- // 从脏块集合中移除成功的块
|
|
905
|
|
- const { dirtyBlocks } = get();
|
|
906
|
|
- const newDirtyBlocks = new Set(dirtyBlocks);
|
|
907
|
|
- successIds.forEach(id => newDirtyBlocks.delete(id));
|
|
|
882
|
+ const latestState = get();
|
|
|
883
|
+ const newDirtyBlocks = new Set(latestState.dirtyBlocks);
|
|
|
884
|
+ const newBlockHashes = new Map(latestState.blockHashes);
|
|
|
885
|
+ successIds.forEach(id => {
|
|
|
886
|
+ const latestBlock = latestState.blocks.find((block) => block.id === id);
|
|
|
887
|
+ const savedHash = savedHashes.get(id);
|
|
|
888
|
+ if (latestBlock && savedHash === computeBlockHash(latestBlock)) {
|
|
|
889
|
+ newDirtyBlocks.delete(id);
|
|
|
890
|
+ newBlockHashes.set(id, savedHash);
|
|
|
891
|
+ }
|
|
|
892
|
+ });
|
|
908
|
893
|
|
|
909
|
894
|
if (stillFailedIds.length > 0) {
|
|
910
|
895
|
// 仍有块保存失败
|
|
911
|
|
-
|
|
912
|
|
- // 更新成功保存的块的哈希值
|
|
913
|
|
- const newBlockHashes = new Map(get().blockHashes);
|
|
914
|
|
- successIds.forEach(id => {
|
|
915
|
|
- const block = blocks.find(b => b.id === id);
|
|
916
|
|
- if (block) {
|
|
917
|
|
- newBlockHashes.set(id, computeBlockHash(block));
|
|
918
|
|
- }
|
|
919
|
|
- });
|
|
920
|
|
-
|
|
921
|
896
|
set({
|
|
922
|
897
|
isSaving: false,
|
|
923
|
898
|
failedBlocks: stillFailedIds,
|
|
|
@@ -932,28 +907,23 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
932
|
907
|
throw new Error(`${stillFailedIds.length} 个块保存失败`);
|
|
933
|
908
|
} else {
|
|
934
|
909
|
// 全部重试成功
|
|
935
|
|
- const snapshot = JSON.stringify(blocks);
|
|
936
|
|
-
|
|
937
|
|
- // 更新所有成功保存的块的哈希值
|
|
938
|
|
- const newBlockHashes = new Map(get().blockHashes);
|
|
939
|
|
- successIds.forEach(id => {
|
|
940
|
|
- const block = blocks.find(b => b.id === id);
|
|
941
|
|
- if (block) {
|
|
942
|
|
- newBlockHashes.set(id, computeBlockHash(block));
|
|
|
910
|
+ const latestBlocks = get().blocks;
|
|
|
911
|
+ const snapshots = newDirtyBlocks.size === 0
|
|
|
912
|
+ ? {
|
|
|
913
|
+ originalBlocksSnapshot: JSON.stringify(latestBlocks),
|
|
|
914
|
+ lastSavedSnapshot: JSON.stringify(latestBlocks),
|
|
943
|
915
|
}
|
|
944
|
|
- });
|
|
945
|
|
-
|
|
|
916
|
+ : {};
|
|
946
|
917
|
set({
|
|
947
|
918
|
isSaving: false,
|
|
948
|
919
|
hasModified: newDirtyBlocks.size > 0,
|
|
949
|
|
- originalBlocksSnapshot: snapshot,
|
|
950
|
|
- lastSavedSnapshot: snapshot,
|
|
951
|
920
|
failedBlocks: [],
|
|
952
|
921
|
dirtyBlocks: newDirtyBlocks,
|
|
953
|
|
- blockHashes: newBlockHashes, // 更新哈希表
|
|
|
922
|
+ blockHashes: newBlockHashes,
|
|
954
|
923
|
currentSavePromise: null,
|
|
955
|
924
|
saveAbortController: null,
|
|
956
|
925
|
savingProgress: null,
|
|
|
926
|
+ ...snapshots,
|
|
957
|
927
|
});
|
|
958
|
928
|
}
|
|
959
|
929
|
} catch (error: unknown) {
|
|
|
@@ -1183,14 +1153,14 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
1183
|
1153
|
return;
|
|
1184
|
1154
|
}
|
|
1185
|
1155
|
|
|
1186
|
|
- set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
|
|
1187
|
|
-
|
|
1188
|
1156
|
// 找到要删除的块
|
|
1189
|
1157
|
const blockToDelete = blocks.find(b => b.id === id);
|
|
1190
|
1158
|
if (!blockToDelete) {
|
|
1191
|
1159
|
return;
|
|
1192
|
1160
|
}
|
|
1193
|
1161
|
|
|
|
1162
|
+ set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
|
|
|
1163
|
+
|
|
1194
|
1164
|
const wasDirty = dirtyBlocks.has(id);
|
|
1195
|
1165
|
|
|
1196
|
1166
|
// 先从本地状态删除(乐观更新)
|
|
|
@@ -1475,20 +1445,16 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
1475
|
1445
|
}
|
|
1476
|
1446
|
|
|
1477
|
1447
|
set({ isSaving: true, error: null });
|
|
|
1448
|
+ const savedHash = currentHash;
|
|
1478
|
1449
|
|
|
1479
|
1450
|
try {
|
|
1480
|
|
- // 序列化表格块的content(将富文本数组转为纯字符串)
|
|
1481
|
|
- let contentToSave = block.content;
|
|
1482
|
|
- if (block.type === 'table') {
|
|
1483
|
|
- const serializedTable = serializeTableBlock(block as TableBlock);
|
|
1484
|
|
- contentToSave = serializedTable.content;
|
|
1485
|
|
- }
|
|
|
1451
|
+ const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
|
|
1486
|
1452
|
|
|
1487
|
1453
|
await blockService.updateBlock(documentId, id, {
|
|
1488
|
1454
|
type: block.type,
|
|
1489
|
1455
|
level: block.level,
|
|
1490
|
1456
|
content: contentToSave,
|
|
1491
|
|
- style: block.style,
|
|
|
1457
|
+ style: styleToSave,
|
|
1492
|
1458
|
word_style: block.word_style,
|
|
1493
|
1459
|
metadata: block.metadata,
|
|
1494
|
1460
|
block_order: block.block_order,
|
|
|
@@ -1500,13 +1466,17 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
1500
|
1466
|
const latestBlock = latestBlocks.find(b => b.id === id);
|
|
1501
|
1467
|
|
|
1502
|
1468
|
if (latestBlock) {
|
|
1503
|
|
- // 保存成功后,从脏块集合中移除该块
|
|
|
1469
|
+ const latestHash = computeBlockHash(latestBlock);
|
|
|
1470
|
+ if (latestHash !== savedHash) {
|
|
|
1471
|
+ set({ isSaving: false });
|
|
|
1472
|
+ return;
|
|
|
1473
|
+ }
|
|
|
1474
|
+
|
|
1504
|
1475
|
const newDirtyBlocks = new Set(latestDirtyBlocks);
|
|
1505
|
1476
|
newDirtyBlocks.delete(id);
|
|
1506
|
1477
|
|
|
1507
|
|
- // **关键修复**: 使用保存成功时的块内容计算哈希值
|
|
1508
|
1478
|
const newBlockHashes = new Map(latestBlockHashes);
|
|
1509
|
|
- newBlockHashes.set(id, computeBlockHash(latestBlock));
|
|
|
1479
|
+ newBlockHashes.set(id, savedHash);
|
|
1510
|
1480
|
|
|
1511
|
1481
|
set({
|
|
1512
|
1482
|
isSaving: false,
|