|
|
@@ -12,7 +12,6 @@
|
|
12
|
12
|
|
|
13
|
13
|
import { create } from 'zustand';
|
|
14
|
14
|
import pLimit from 'p-limit';
|
|
15
|
|
-import hashSum from 'hash-sum';
|
|
16
|
15
|
import { v4 as uuidv4 } from 'uuid';
|
|
17
|
16
|
import { message } from 'antd';
|
|
18
|
17
|
import type {
|
|
|
@@ -23,12 +22,21 @@ import type {
|
|
23
|
22
|
TableBlock,
|
|
24
|
23
|
} from '../types/editor';
|
|
25
|
24
|
import { blockService } from '../services/blockService';
|
|
26
|
|
-import { getErrorMessage } from '../services/api';
|
|
|
25
|
+import { getErrorMessage, isApiError } from '../services/api';
|
|
|
26
|
+import { normalizeTableBlock, validateTableStructure } from '../utils/blockOperations';
|
|
27
|
27
|
import {
|
|
28
|
|
- normalizeTableBlock,
|
|
29
|
|
- serializeTableBlock,
|
|
30
|
|
- validateTableStructure,
|
|
31
|
|
-} from '../utils/blockOperations';
|
|
|
28
|
+ computeBlockHash,
|
|
|
29
|
+ computeInsertOrder,
|
|
|
30
|
+ isCanceledRequest,
|
|
|
31
|
+ isReadonlyBlock,
|
|
|
32
|
+ mergeBlockUpdates,
|
|
|
33
|
+ rebalanceOrders,
|
|
|
34
|
+ serializeBlockForSave,
|
|
|
35
|
+ serializePendingBlockUpdate,
|
|
|
36
|
+ snapshotBlocks,
|
|
|
37
|
+ waitForStructuralOperations,
|
|
|
38
|
+} from '../utils/editorStoreHelpers';
|
|
|
39
|
+import type { EditorHistoryEntry, EditorStore } from './editorStoreTypes';
|
|
32
|
40
|
|
|
33
|
41
|
// 并发保存限制器(最多同时进行 3 个请求,降低服务器压力)
|
|
34
|
42
|
const saveConcurrencyLimit = pLimit(3);
|
|
|
@@ -42,347 +50,6 @@ const MAX_RETRY_ATTEMPTS = 3;
|
|
42
|
50
|
const MAX_HISTORY_ENTRIES = 100;
|
|
43
|
51
|
const HISTORY_COALESCE_WINDOW = 500;
|
|
44
|
52
|
|
|
45
|
|
-function waitForStructuralOperations(isReady: () => boolean): Promise<void> {
|
|
46
|
|
- return new Promise((resolve) => {
|
|
47
|
|
- const check = () => {
|
|
48
|
|
- if (isReady()) {
|
|
49
|
|
- resolve();
|
|
50
|
|
- return;
|
|
51
|
|
- }
|
|
52
|
|
- setTimeout(check, 16);
|
|
53
|
|
- };
|
|
54
|
|
- check();
|
|
55
|
|
- });
|
|
56
|
|
-}
|
|
57
|
|
-
|
|
58
|
|
-interface EditorHistoryEntry {
|
|
59
|
|
- blocks: DocumentBlock[];
|
|
60
|
|
- selectedBlockId: string | null;
|
|
61
|
|
-}
|
|
62
|
|
-
|
|
63
|
|
-function snapshotBlocks(blocks: DocumentBlock[]): DocumentBlock[] {
|
|
64
|
|
- // 块更新采用不可变替换,历史快照只需复制数组即可复用块对象。
|
|
65
|
|
- return blocks.slice();
|
|
66
|
|
-}
|
|
67
|
|
-
|
|
68
|
|
-/**
|
|
69
|
|
- * 计算块内容的哈希值
|
|
70
|
|
- * 用于检测内容是否真的发生了变化
|
|
71
|
|
- */
|
|
72
|
|
-function computeBlockHash(block: DocumentBlock): string {
|
|
73
|
|
- // 只计算可编辑字段的哈希,忽略 id 等不可变元数据
|
|
74
|
|
- const contentForHash = {
|
|
75
|
|
- type: block.type,
|
|
76
|
|
- content: block.content,
|
|
77
|
|
- style: block.style,
|
|
78
|
|
- word_style: block.word_style,
|
|
79
|
|
- level: block.level,
|
|
80
|
|
- block_order: block.block_order,
|
|
81
|
|
- };
|
|
82
|
|
- return hashSum(contentForHash);
|
|
83
|
|
-}
|
|
84
|
|
-
|
|
85
|
|
-function isReadonlyBlock(block: DocumentBlock): boolean {
|
|
86
|
|
- const metadata: object = block.metadata;
|
|
87
|
|
- return (
|
|
88
|
|
- ('readonly' in metadata && metadata.readonly === true) ||
|
|
89
|
|
- ('is_auto_generated' in metadata && metadata.is_auto_generated === true)
|
|
90
|
|
- );
|
|
91
|
|
-}
|
|
92
|
|
-
|
|
93
|
|
-function isCanceledRequest(error: unknown): boolean {
|
|
94
|
|
- return (
|
|
95
|
|
- error instanceof Error &&
|
|
96
|
|
- (error.name === 'AbortError' ||
|
|
97
|
|
- error.name === 'CanceledError' ||
|
|
98
|
|
- error.message.toLowerCase().includes('cancel'))
|
|
99
|
|
- );
|
|
100
|
|
-}
|
|
101
|
|
-
|
|
102
|
|
-function serializeBlockForSave(block: DocumentBlock): Pick<BlockUpdate, 'content' | 'style'> {
|
|
103
|
|
- if (block.type === 'table') {
|
|
104
|
|
- const serializedTable = serializeTableBlock(block as TableBlock);
|
|
105
|
|
- return {
|
|
106
|
|
- content: serializedTable.content,
|
|
107
|
|
- style: block.style,
|
|
108
|
|
- };
|
|
109
|
|
- }
|
|
110
|
|
-
|
|
111
|
|
- if ((block.type === 'heading' || block.type === 'paragraph') && Array.isArray(block.content)) {
|
|
112
|
|
- const firstStyle = block.content[0]?.style;
|
|
113
|
|
- const allSameStyle =
|
|
114
|
|
- !!firstStyle &&
|
|
115
|
|
- block.content.every(
|
|
116
|
|
- (segment) => JSON.stringify(segment.style) === JSON.stringify(firstStyle)
|
|
117
|
|
- );
|
|
118
|
|
-
|
|
119
|
|
- return {
|
|
120
|
|
- content: block.content.map((segment) => segment.text).join(''),
|
|
121
|
|
- style:
|
|
122
|
|
- allSameStyle && Object.keys(firstStyle).length > 0
|
|
123
|
|
- ? { ...block.style, ...firstStyle }
|
|
124
|
|
- : block.style,
|
|
125
|
|
- };
|
|
126
|
|
- }
|
|
127
|
|
-
|
|
128
|
|
- return {
|
|
129
|
|
- content: block.content,
|
|
130
|
|
- style: block.style,
|
|
131
|
|
- };
|
|
132
|
|
-}
|
|
133
|
|
-
|
|
134
|
|
-function mergeBlockUpdates(previous: BlockUpdate | undefined, next: BlockUpdate): BlockUpdate {
|
|
135
|
|
- return { ...(previous || {}), ...next };
|
|
136
|
|
-}
|
|
137
|
|
-
|
|
138
|
|
-function serializePendingBlockUpdate(
|
|
139
|
|
- block: DocumentBlock,
|
|
140
|
|
- pending: BlockUpdate | undefined
|
|
141
|
|
-): BlockUpdate {
|
|
142
|
|
- if (!pending) {
|
|
143
|
|
- const serialized = serializeBlockForSave(block);
|
|
144
|
|
- return {
|
|
145
|
|
- type: block.type,
|
|
146
|
|
- level: block.level,
|
|
147
|
|
- content: serialized.content,
|
|
148
|
|
- style: serialized.style,
|
|
149
|
|
- word_style: block.word_style,
|
|
150
|
|
- metadata: block.metadata,
|
|
151
|
|
- block_order: block.block_order,
|
|
152
|
|
- };
|
|
153
|
|
- }
|
|
154
|
|
- const updates = pending || {};
|
|
155
|
|
- const serialized = serializeBlockForSave(block);
|
|
156
|
|
- const payload: BlockUpdate = {};
|
|
157
|
|
-
|
|
158
|
|
- if ('type' in updates) payload.type = block.type;
|
|
159
|
|
- if ('level' in updates) payload.level = block.level;
|
|
160
|
|
- if ('content' in updates) payload.content = serialized.content;
|
|
161
|
|
- if ('style' in updates) payload.style = serialized.style;
|
|
162
|
|
- if ('word_style' in updates) payload.word_style = block.word_style;
|
|
163
|
|
- if ('metadata' in updates) payload.metadata = block.metadata;
|
|
164
|
|
- if ('block_order' in updates) payload.block_order = block.block_order;
|
|
165
|
|
-
|
|
166
|
|
- return payload;
|
|
167
|
|
-}
|
|
168
|
|
-
|
|
169
|
|
-// ══════════════════════════════════════════════════════════════════════════════
|
|
170
|
|
-// Store State Interface
|
|
171
|
|
-// ══════════════════════════════════════════════════════════════════════════════
|
|
172
|
|
-
|
|
173
|
|
-interface EditorStore {
|
|
174
|
|
- // ── 文档状态 ────────────────────────────────────────────────────────────
|
|
175
|
|
- documentId: string | null;
|
|
176
|
|
- documentTitle: string;
|
|
177
|
|
- blocks: DocumentBlock[];
|
|
178
|
|
- selectedBlockId: string | null;
|
|
179
|
|
- focusRequestId: number;
|
|
180
|
|
- past: EditorHistoryEntry[];
|
|
181
|
|
- future: EditorHistoryEntry[];
|
|
182
|
|
- lastHistoryEditBlockId: string | null;
|
|
183
|
|
- lastHistoryEditAt: number | null;
|
|
184
|
|
- isHistoryApplying: boolean;
|
|
185
|
|
- pendingStructuralOperations: number;
|
|
186
|
|
-
|
|
187
|
|
- // ── 加载/保存状态 ──────────────────────────────────────────────────────
|
|
188
|
|
- isLoading: boolean;
|
|
189
|
|
- isSaving: boolean;
|
|
190
|
|
- error: string | null;
|
|
191
|
|
-
|
|
192
|
|
- // ── 保存进度 ────────────────────────────────────────────────────────────
|
|
193
|
|
- /** 正在保存的块数量 */
|
|
194
|
|
- savingProgress: { current: number; total: number } | null;
|
|
195
|
|
-
|
|
196
|
|
- // ── 修改状态追踪 ────────────────────────────────────────────────────────
|
|
197
|
|
- /** 文档是否已被修改(用于控制保存按钮状态) */
|
|
198
|
|
- hasModified: boolean;
|
|
199
|
|
- /** 原始blocks快照(用于检测变化) */
|
|
200
|
|
- originalBlocksSnapshot: string | null;
|
|
201
|
|
- /** 保存失败的块ID列表 */
|
|
202
|
|
- failedBlocks: string[];
|
|
203
|
|
- /** 被修改但未保存的块ID集合 */
|
|
204
|
|
- dirtyBlocks: Set<string>;
|
|
205
|
|
- /** 上次成功保存的快照 */
|
|
206
|
|
- lastSavedSnapshot: string | null;
|
|
207
|
|
- /** 块内容哈希映射表(用于精确检测内容变化)*/
|
|
208
|
|
- blockHashes: Map<string, string>;
|
|
209
|
|
- /** 每个块尚未提交的字段变更,只在保存时转换为 API patch */
|
|
210
|
|
- pendingBlockUpdates: Map<string, BlockUpdate>;
|
|
211
|
|
-
|
|
212
|
|
- // ── 保存控制 ────────────────────────────────────────────────────────────
|
|
213
|
|
- /** 当前正在进行的保存 Promise */
|
|
214
|
|
- currentSavePromise: Promise<void> | null;
|
|
215
|
|
- /** 用于取消请求的 AbortController */
|
|
216
|
|
- saveAbortController: AbortController | null;
|
|
217
|
|
- /** 用于取消文档加载请求的 AbortController */
|
|
218
|
|
- loadAbortController: AbortController | null;
|
|
219
|
|
-
|
|
220
|
|
- // ── 自动保存 ────────────────────────────────────────────────────────────
|
|
221
|
|
- /** 自动保存定时器 */
|
|
222
|
|
- autoSaveTimer: ReturnType<typeof setTimeout> | null;
|
|
223
|
|
- /** 是否启用自动保存 */
|
|
224
|
|
- autoSaveEnabled: boolean;
|
|
225
|
|
- /** 上次保存时间戳 */
|
|
226
|
|
- lastSaveTime: number | null;
|
|
227
|
|
-
|
|
228
|
|
- // ── 重试机制 ────────────────────────────────────────────────────────────
|
|
229
|
|
- /** 块重试次数记录 */
|
|
230
|
|
- retryAttempts: Map<string, number>;
|
|
231
|
|
- /** 延迟重试定时器 */
|
|
232
|
|
- retryTimer: ReturnType<typeof setTimeout> | null;
|
|
233
|
|
-
|
|
234
|
|
- // ── 操作方法 ────────────────────────────────────────────────────────────
|
|
235
|
|
-
|
|
236
|
|
- /**
|
|
237
|
|
- * 加载文档
|
|
238
|
|
- */
|
|
239
|
|
- loadDocument: (documentId: string) => Promise<void>;
|
|
240
|
|
-
|
|
241
|
|
- /**
|
|
242
|
|
- * 保存文档
|
|
243
|
|
- */
|
|
244
|
|
- saveDocument: () => Promise<void>;
|
|
245
|
|
-
|
|
246
|
|
- /**
|
|
247
|
|
- * 重试保存失败的块
|
|
248
|
|
- */
|
|
249
|
|
- retryFailedBlocks: () => Promise<void>;
|
|
250
|
|
-
|
|
251
|
|
- /**
|
|
252
|
|
- * 添加块
|
|
253
|
|
- * @param block 块数据(部分字段)
|
|
254
|
|
- * @param afterBlockId 插入位置(在此块之后),不传则追加到末尾
|
|
255
|
|
- */
|
|
256
|
|
- addBlock: (block: PartialBlock, afterBlockId?: string) => void;
|
|
257
|
|
-
|
|
258
|
|
- /**
|
|
259
|
|
- * 更新块
|
|
260
|
|
- * @param id 块ID
|
|
261
|
|
- * @param updates 更新数据
|
|
262
|
|
- */
|
|
263
|
|
- updateBlock: (id: string, updates: BlockUpdate) => void;
|
|
264
|
|
-
|
|
265
|
|
- /** 应用来自聊天/WebMCP 的已保存块更新 */
|
|
266
|
|
- applyRemoteBlockUpdate: (id: string, updates: BlockUpdate) => void;
|
|
267
|
|
-
|
|
268
|
|
- /** 应用来自聊天/WebMCP 的已保存块删除 */
|
|
269
|
|
- applyRemoteBlockDelete: (id: string) => void;
|
|
270
|
|
-
|
|
271
|
|
- /**
|
|
272
|
|
- * 标记文档已修改
|
|
273
|
|
- */
|
|
274
|
|
- markAsModified: () => void;
|
|
275
|
|
-
|
|
276
|
|
- /**
|
|
277
|
|
- * 标记文档已保存
|
|
278
|
|
- */
|
|
279
|
|
- markAsSaved: () => void;
|
|
280
|
|
-
|
|
281
|
|
- /**
|
|
282
|
|
- * 删除块
|
|
283
|
|
- * @param id 块ID
|
|
284
|
|
- */
|
|
285
|
|
- deleteBlock: (id: string) => Promise<void>;
|
|
286
|
|
-
|
|
287
|
|
- /**
|
|
288
|
|
- * 移动块
|
|
289
|
|
- * @param id 块ID
|
|
290
|
|
- * @param targetOrder 目标位置
|
|
291
|
|
- */
|
|
292
|
|
- moveBlock: (id: string, targetOrder: number) => void;
|
|
293
|
|
-
|
|
294
|
|
- /**
|
|
295
|
|
- * 选中块
|
|
296
|
|
- * @param id 块ID
|
|
297
|
|
- */
|
|
298
|
|
- selectBlock: (id: string | null) => void;
|
|
299
|
|
-
|
|
300
|
|
- /**
|
|
301
|
|
- * 根据ID获取块
|
|
302
|
|
- */
|
|
303
|
|
- getBlockById: (id: string) => DocumentBlock | undefined;
|
|
304
|
|
-
|
|
305
|
|
- /**
|
|
306
|
|
- * 根据类型获取块
|
|
307
|
|
- */
|
|
308
|
|
- getBlocksByType: (type: BlockType) => DocumentBlock[];
|
|
309
|
|
-
|
|
310
|
|
- /**
|
|
311
|
|
- * 检查块是否为脏块(已修改未保存)
|
|
312
|
|
- */
|
|
313
|
|
- isBlockDirty: (id: string) => boolean;
|
|
314
|
|
-
|
|
315
|
|
- /**
|
|
316
|
|
- * 获取所有脏块
|
|
317
|
|
- */
|
|
318
|
|
- getDirtyBlocks: () => DocumentBlock[];
|
|
319
|
|
-
|
|
320
|
|
- /**
|
|
321
|
|
- * 保存单个block的更改
|
|
322
|
|
- */
|
|
323
|
|
- saveBlock: (id: string) => Promise<void>;
|
|
324
|
|
-
|
|
325
|
|
- /**
|
|
326
|
|
- * 重新计算所有块的block_order(稀疏排序)
|
|
327
|
|
- */
|
|
328
|
|
- recomputeBlockOrders: () => void;
|
|
329
|
|
-
|
|
330
|
|
- /**
|
|
331
|
|
- * 启用/禁用自动保存
|
|
332
|
|
- */
|
|
333
|
|
- setAutoSaveEnabled: (enabled: boolean) => void;
|
|
334
|
|
-
|
|
335
|
|
- /**
|
|
336
|
|
- * 触发自动保存(带防抖)
|
|
337
|
|
- */
|
|
338
|
|
- triggerAutoSave: () => void;
|
|
339
|
|
-
|
|
340
|
|
- /**
|
|
341
|
|
- * 取消自动保存定时器
|
|
342
|
|
- */
|
|
343
|
|
- cancelAutoSave: () => void;
|
|
344
|
|
-
|
|
345
|
|
- /**
|
|
346
|
|
- * 重置状态
|
|
347
|
|
- */
|
|
348
|
|
- reset: () => void;
|
|
349
|
|
- /** 撤销最近一次编辑 */
|
|
350
|
|
- undo: () => Promise<void>;
|
|
351
|
|
- /** 重做最近一次撤销 */
|
|
352
|
|
- redo: () => Promise<void>;
|
|
353
|
|
-}
|
|
354
|
|
-
|
|
355
|
|
-// ══════════════════════════════════════════════════════════════════════════════
|
|
356
|
|
-// Utility Functions
|
|
357
|
|
-// ══════════════════════════════════════════════════════════════════════════════
|
|
358
|
|
-
|
|
359
|
|
-/**
|
|
360
|
|
- * 计算插入位置的block_order
|
|
361
|
|
- * 稀疏排序策略:在两个块之间找到中间值
|
|
362
|
|
- */
|
|
363
|
|
-function computeInsertOrder(prevOrder: number, nextOrder: number): number {
|
|
364
|
|
- const gap = nextOrder - prevOrder;
|
|
365
|
|
-
|
|
366
|
|
- if (gap > 1) {
|
|
367
|
|
- // 有间隙,直接取中间值
|
|
368
|
|
- return Math.floor((prevOrder + nextOrder) / 2);
|
|
369
|
|
- }
|
|
370
|
|
-
|
|
371
|
|
- // 间隙不足,需要重排
|
|
372
|
|
- return -1;
|
|
373
|
|
-}
|
|
374
|
|
-
|
|
375
|
|
-/**
|
|
376
|
|
- * 重新平衡block_order(稀疏排序,间隔100)
|
|
377
|
|
- */
|
|
378
|
|
-function rebalanceOrders(blocks: DocumentBlock[]): DocumentBlock[] {
|
|
379
|
|
- const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
|
|
380
|
|
- return sorted.map((block, index) => ({
|
|
381
|
|
- ...block,
|
|
382
|
|
- block_order: index * 100,
|
|
383
|
|
- }));
|
|
384
|
|
-}
|
|
385
|
|
-
|
|
386
|
53
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
387
|
54
|
// Store Implementation
|
|
388
|
55
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
@@ -612,7 +279,10 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
612
|
279
|
});
|
|
613
|
280
|
} catch (error: unknown) {
|
|
614
|
281
|
if (controller.signal.aborted || isCanceledRequest(error)) return;
|
|
|
282
|
+ const documentMissing = isApiError(error) && error.status === 404;
|
|
615
|
283
|
set({
|
|
|
284
|
+ documentId: documentMissing ? null : documentId,
|
|
|
285
|
+ blocks: documentMissing ? [] : get().blocks,
|
|
616
|
286
|
error: getErrorMessage(error) || '加载文档失败',
|
|
617
|
287
|
isLoading: false,
|
|
618
|
288
|
loadAbortController: null,
|
|
|
@@ -649,14 +319,14 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
649
|
319
|
return;
|
|
650
|
320
|
}
|
|
651
|
321
|
|
|
652
|
|
- const dirtyBlocks = new Set(
|
|
653
|
|
- blocks
|
|
654
|
|
- .filter(
|
|
655
|
|
- (block) =>
|
|
656
|
|
- currentDirtyBlocks.has(block.id) && block.type !== 'toc' && !isReadonlyBlock(block)
|
|
657
|
|
- )
|
|
658
|
|
- .map((block) => block.id)
|
|
659
|
|
- );
|
|
|
322
|
+ const dirtyBlocks = new Set<string>();
|
|
|
323
|
+ const blocksToSave: DocumentBlock[] = [];
|
|
|
324
|
+ for (const block of blocks) {
|
|
|
325
|
+ if (currentDirtyBlocks.has(block.id) && block.type !== 'toc' && !isReadonlyBlock(block)) {
|
|
|
326
|
+ dirtyBlocks.add(block.id);
|
|
|
327
|
+ blocksToSave.push(block);
|
|
|
328
|
+ }
|
|
|
329
|
+ }
|
|
660
|
330
|
|
|
661
|
331
|
if (dirtyBlocks.size === 0) {
|
|
662
|
332
|
set({
|
|
|
@@ -697,15 +367,6 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
697
|
367
|
// 创建保存 Promise
|
|
698
|
368
|
const savePromise = (async () => {
|
|
699
|
369
|
try {
|
|
700
|
|
- // 获取需要保存的块(只保存脏块)
|
|
701
|
|
- const blocksToSave = blocks.filter((block) => {
|
|
702
|
|
- // 必须在脏块列表中
|
|
703
|
|
- if (!dirtyBlocks.has(block.id)) {
|
|
704
|
|
- return false;
|
|
705
|
|
- }
|
|
706
|
|
-
|
|
707
|
|
- return true;
|
|
708
|
|
- });
|
|
709
|
370
|
const pendingUpdatesAtSave = new Map(currentPendingBlockUpdates);
|
|
710
|
371
|
|
|
711
|
372
|
const totalBlocks = blocksToSave.length;
|
|
|
@@ -728,12 +389,9 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
728
|
389
|
);
|
|
729
|
390
|
|
|
730
|
391
|
try {
|
|
731
|
|
- const result = await blockService.updateBlock(
|
|
732
|
|
- documentId,
|
|
733
|
|
- block.id,
|
|
734
|
|
- payload,
|
|
735
|
|
- { signal: newAbortController.signal }
|
|
736
|
|
- );
|
|
|
392
|
+ const result = await blockService.updateBlock(documentId, block.id, payload, {
|
|
|
393
|
+ signal: newAbortController.signal,
|
|
|
394
|
+ });
|
|
737
|
395
|
if (newAbortController.signal.aborted || get().documentId !== documentId) {
|
|
738
|
396
|
return result;
|
|
739
|
397
|
}
|
|
|
@@ -788,11 +446,17 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
788
|
446
|
// 更新成功保存的块的哈希值
|
|
789
|
447
|
const newBlockHashes = new Map(latestStateBeforeResult.blockHashes);
|
|
790
|
448
|
const newPendingBlockUpdates = new Map(latestStateBeforeResult.pendingBlockUpdates);
|
|
|
449
|
+ const savedBlocksById = new Map(blocks.map((block) => [block.id, block]));
|
|
|
450
|
+ const latestBlocksById = new Map(
|
|
|
451
|
+ latestStateBeforeResult.blocks.map((block) => [block.id, block])
|
|
|
452
|
+ );
|
|
791
|
453
|
successIds.forEach((id) => {
|
|
792
|
|
- const block = blocks.find((b) => b.id === id);
|
|
793
|
|
- const latestBlock = latestStateBeforeResult.blocks.find((b) => b.id === id);
|
|
794
|
|
- if (block && latestBlock && computeBlockHash(block) === computeBlockHash(latestBlock)) {
|
|
795
|
|
- newBlockHashes.set(id, computeBlockHash(block));
|
|
|
454
|
+ const block = savedBlocksById.get(id);
|
|
|
455
|
+ const latestBlock = latestBlocksById.get(id);
|
|
|
456
|
+ if (!block || !latestBlock) return;
|
|
|
457
|
+ const savedHash = computeBlockHash(block);
|
|
|
458
|
+ if (savedHash === computeBlockHash(latestBlock)) {
|
|
|
459
|
+ newBlockHashes.set(id, savedHash);
|
|
796
|
460
|
newPendingBlockUpdates.delete(id);
|
|
797
|
461
|
}
|
|
798
|
462
|
});
|
|
|
@@ -837,9 +501,11 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
837
|
501
|
const newDirtyBlocks = new Set(latestState.dirtyBlocks);
|
|
838
|
502
|
const newBlockHashes = new Map(latestState.blockHashes);
|
|
839
|
503
|
const newPendingBlockUpdates = new Map(latestState.pendingBlockUpdates);
|
|
|
504
|
+ const savedBlocksById = new Map(blocks.map((block) => [block.id, block]));
|
|
|
505
|
+ const latestBlocksById = new Map(latestState.blocks.map((block) => [block.id, block]));
|
|
840
|
506
|
successIds.forEach((id) => {
|
|
841
|
|
- const savedBlock = blocks.find((block) => block.id === id);
|
|
842
|
|
- const latestBlock = latestState.blocks.find((block) => block.id === id);
|
|
|
507
|
+ const savedBlock = savedBlocksById.get(id);
|
|
|
508
|
+ const latestBlock = latestBlocksById.get(id);
|
|
843
|
509
|
if (!savedBlock || !latestBlock) return;
|
|
844
|
510
|
|
|
845
|
511
|
const savedHash = computeBlockHash(savedBlock);
|
|
|
@@ -942,7 +608,8 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
942
|
608
|
const savePromise = (async () => {
|
|
943
|
609
|
try {
|
|
944
|
610
|
// 获取失败的块
|
|
945
|
|
- const blocksToRetry = blocks.filter((block) => failedBlocks.includes(block.id));
|
|
|
611
|
+ const failedBlockIds = new Set(failedBlocks);
|
|
|
612
|
+ const blocksToRetry = blocks.filter((block) => failedBlockIds.has(block.id));
|
|
946
|
613
|
const savedHashes = new Map(
|
|
947
|
614
|
blocksToRetry.map((block) => [block.id, computeBlockHash(block)])
|
|
948
|
615
|
);
|
|
|
@@ -1000,8 +667,9 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
1000
|
667
|
const latestState = get();
|
|
1001
|
668
|
const newDirtyBlocks = new Set(latestState.dirtyBlocks);
|
|
1002
|
669
|
const newBlockHashes = new Map(latestState.blockHashes);
|
|
|
670
|
+ const latestBlocksById = new Map(latestState.blocks.map((block) => [block.id, block]));
|
|
1003
|
671
|
successIds.forEach((id) => {
|
|
1004
|
|
- const latestBlock = latestState.blocks.find((block) => block.id === id);
|
|
|
672
|
+ const latestBlock = latestBlocksById.get(id);
|
|
1005
|
673
|
const savedHash = savedHashes.get(id);
|
|
1006
|
674
|
if (latestBlock && savedHash === computeBlockHash(latestBlock)) {
|
|
1007
|
675
|
newDirtyBlocks.delete(id);
|
|
|
@@ -1257,10 +925,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
1257
|
925
|
if (originalHash && newHash === originalHash) {
|
|
1258
|
926
|
newPendingBlockUpdates.delete(id);
|
|
1259
|
927
|
} else {
|
|
1260
|
|
- newPendingBlockUpdates.set(
|
|
1261
|
|
- id,
|
|
1262
|
|
- mergeBlockUpdates(pendingBlockUpdates.get(id), updates)
|
|
1263
|
|
- );
|
|
|
928
|
+ newPendingBlockUpdates.set(id, mergeBlockUpdates(pendingBlockUpdates.get(id), updates));
|
|
1264
|
929
|
}
|
|
1265
|
930
|
|
|
1266
|
931
|
set({
|
|
|
@@ -1307,6 +972,29 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
1307
|
972
|
});
|
|
1308
|
973
|
},
|
|
1309
|
974
|
|
|
|
975
|
+ // ── applyRemoteBlockInsert ─────────────────────────────────────────────
|
|
|
976
|
+ applyRemoteBlockInsert: (block: DocumentBlock) => {
|
|
|
977
|
+ const { documentId, blocks, blockHashes, dirtyBlocks, pendingBlockUpdates } = get();
|
|
|
978
|
+ if (!documentId || blocks.some((currentBlock) => currentBlock.id === block.id)) return;
|
|
|
979
|
+
|
|
|
980
|
+ const updatedBlocks = [...blocks, block].sort(
|
|
|
981
|
+ (left, right) => left.block_order - right.block_order
|
|
|
982
|
+ );
|
|
|
983
|
+ const updatedHashes = new Map(blockHashes);
|
|
|
984
|
+ updatedHashes.set(block.id, computeBlockHash(block));
|
|
|
985
|
+ const snapshot = JSON.stringify(updatedBlocks);
|
|
|
986
|
+
|
|
|
987
|
+ set({
|
|
|
988
|
+ blocks: updatedBlocks,
|
|
|
989
|
+ blockHashes: updatedHashes,
|
|
|
990
|
+ dirtyBlocks,
|
|
|
991
|
+ pendingBlockUpdates,
|
|
|
992
|
+ hasModified: dirtyBlocks.size > 0,
|
|
|
993
|
+ originalBlocksSnapshot: snapshot,
|
|
|
994
|
+ lastSavedSnapshot: snapshot,
|
|
|
995
|
+ });
|
|
|
996
|
+ },
|
|
|
997
|
+
|
|
1310
|
998
|
// ── applyRemoteBlockDelete ─────────────────────────────────────────────
|
|
1311
|
999
|
applyRemoteBlockDelete: (id: string) => {
|
|
1312
|
1000
|
const { documentId, blocks, blockHashes, dirtyBlocks, pendingBlockUpdates } = get();
|
|
|
@@ -1390,69 +1078,11 @@ export const useEditorStore = create<EditorStore>((set, get) => {
|
|
1390
|
1078
|
});
|
|
1391
|
1079
|
|
|
1392
|
1080
|
try {
|
|
1393
|
|
- // 1. 调用后端API删除块
|
|
|
1081
|
+ // 稀疏 block_order 不需要因删除而重排,避免对剩余块逐个发起更新请求。
|
|
1394
|
1082
|
await blockService.deleteBlock(documentId, id);
|
|
1395
|
1083
|
|
|
1396
|
|
- // 2. 删除成功后,重新计算并更新所有块的 block_order
|
|
1397
|
|
- // 按照当前顺序重新分配 block_order(使用稀疏排序,间隔100)
|
|
1398
|
|
- const sortedBlocks = [...newBlocks].sort((a, b) => a.block_order - b.block_order);
|
|
1399
|
|
- const blocksNeedUpdate: Array<{ block: DocumentBlock; newOrder: number }> = [];
|
|
1400
|
|
-
|
|
1401
|
|
- sortedBlocks.forEach((block, index) => {
|
|
1402
|
|
- const expectedOrder = index * 100;
|
|
1403
|
|
- if (block.block_order !== expectedOrder) {
|
|
1404
|
|
- blocksNeedUpdate.push({
|
|
1405
|
|
- block,
|
|
1406
|
|
- newOrder: expectedOrder,
|
|
1407
|
|
- });
|
|
1408
|
|
- }
|
|
1409
|
|
- });
|
|
1410
|
|
-
|
|
1411
|
|
- // 3. 如果有块需要更新顺序,批量调用 PUT API 更新
|
|
1412
|
|
- if (blocksNeedUpdate.length > 0) {
|
|
1413
|
|
- // 并发调用 PUT API 更新所有受影响块的顺序。
|
|
1414
|
|
- // 只发送顺序字段,避免用删除前的块快照覆盖并发编辑。
|
|
1415
|
|
- const updatePromises = blocksNeedUpdate.map(({ block, newOrder }) =>
|
|
1416
|
|
- blockService.updateBlock(documentId, block.id, {
|
|
1417
|
|
- block_order: newOrder,
|
|
1418
|
|
- })
|
|
1419
|
|
- );
|
|
1420
|
|
-
|
|
1421
|
|
- await Promise.all(updatePromises);
|
|
1422
|
|
-
|
|
1423
|
|
- // 4. 更新本地状态中的 block_order,并更新这些块的哈希值
|
|
1424
|
|
- const { blocks: currentBlocks, blockHashes: currentBlockHashes } = get();
|
|
1425
|
|
- const updatedBlocks = currentBlocks.map((b) => {
|
|
1426
|
|
- const update = blocksNeedUpdate.find((u) => u.block.id === b.id);
|
|
1427
|
|
- if (update) {
|
|
1428
|
|
- return { ...b, block_order: update.newOrder };
|
|
1429
|
|
- }
|
|
1430
|
|
- return b;
|
|
1431
|
|
- });
|
|
1432
|
|
-
|
|
1433
|
|
- // 更新这些块的哈希值,因为后端已经保存了
|
|
1434
|
|
- const updatedBlockHashes = new Map(currentBlockHashes);
|
|
1435
|
|
- updatedBlocks.forEach((block) => {
|
|
1436
|
|
- if (blocksNeedUpdate.some((u) => u.block.id === block.id)) {
|
|
1437
|
|
- updatedBlockHashes.set(block.id, computeBlockHash(block));
|
|
1438
|
|
- }
|
|
1439
|
|
- });
|
|
1440
|
|
-
|
|
1441
|
|
- // 5. 更新本地状态,不标记为已修改(因为后端已经同步)
|
|
1442
|
|
- const latestState = get();
|
|
1443
|
|
- set({
|
|
1444
|
|
- blocks: updatedBlocks,
|
|
1445
|
|
- blockHashes: updatedBlockHashes,
|
|
1446
|
|
- dirtyBlocks: latestState.dirtyBlocks,
|
|
1447
|
|
- hasModified: latestState.dirtyBlocks.size > 0,
|
|
1448
|
|
- });
|
|
1449
|
|
- } else {
|
|
1450
|
|
- // 没有块需要更新顺序(可能删除的是最后一个块)
|
|
1451
|
|
- const latestState = get();
|
|
1452
|
|
- set({
|
|
1453
|
|
- hasModified: latestState.dirtyBlocks.size > 0,
|
|
1454
|
|
- });
|
|
1455
|
|
- }
|
|
|
1084
|
+ const latestState = get();
|
|
|
1085
|
+ set({ hasModified: latestState.dirtyBlocks.size > 0 });
|
|
1456
|
1086
|
pushHistory(blocks, get().selectedBlockId);
|
|
1457
|
1087
|
} catch (error: unknown) {
|
|
1458
|
1088
|
if (get().documentId !== documentId) {
|