浏览代码

feat(editorStore): 重构编辑器状态管理,优化块操作和历史记录处理;删除冗余类型定义

Zhang Yice 1 月之前
父节点
当前提交
e50cf79f36

+ 25 - 9
src/services/webMcpAgentService.ts

@@ -307,14 +307,24 @@ const parseLocalDocumentCommand = async (
307 307
   if (isBlockAction) {
308 308
     const blockMatch = remainder.match(/\b(block-[\w-]+)\b/i);
309 309
     blockId = blockMatch?.[1];
310
-    blockContent = remainder.match(/(?:为|改为|内容为|插入)[::]?\s*(.+)$/)?.[1];
311
-    remainder = remainder
312
-      .replace(blockId || '', '')
313
-      .replace(/(?:为|改为|内容为|插入)[::]?\s*.+$/, '')
314
-      .trim();
315
-    const parsedReference = parseBlockReference(remainder);
316
-    blockReference = parsedReference.reference;
317
-    remainder = parsedReference.remainder;
310
+    remainder = remainder.replace(blockId || '', '').trim();
311
+
312
+    // 支持“第二行:内容”以及“第二行改为:内容”两种自然表达。
313
+    const positionedContent = remainder.match(
314
+      /^(.*?)(第\s*(?:\d+|[零〇一二两三四五六七八九十百]+)\s*(?:个)?\s*(?:行|段|块|标题|段落|条)|(?:最后|末尾|末)\s*(?:一行|一段|一个块|一块|一条|行|段|块|条)|首行|第一行)\s*(?::|:|为|改为|内容为)\s*(.+)$/i
315
+    );
316
+    if (positionedContent) {
317
+      const parsedReference = parseBlockReference(positionedContent[2].trim());
318
+      blockReference = parsedReference.reference;
319
+      blockContent = positionedContent[3].trim();
320
+      remainder = positionedContent[1].trim();
321
+    } else {
322
+      blockContent = remainder.match(/(?:为|改为|内容为|插入)[::]?\s*(.+)$/)?.[1];
323
+      remainder = remainder.replace(/(?:为|改为|内容为|插入)[::]?\s*.+$/, '').trim();
324
+      const parsedReference = parseBlockReference(remainder);
325
+      blockReference = parsedReference.reference;
326
+      remainder = parsedReference.remainder;
327
+    }
318 328
     remainder = remainder.replace(/(?:这个|该)?(?:文档|文件)\s*$/i, '').trim();
319 329
   }
320 330
 
@@ -363,7 +373,13 @@ const parseLocalDocumentCommand = async (
363 373
     toolName === 'download_export_record'
364 374
       ? { recordId: candidate.recordId || candidate.documentId }
365 375
       : { documentId: candidate.documentId };
366
-  if (blockId) input.blockId = blockId;
376
+  if (blockId) {
377
+    if (toolName === 'insert_block') {
378
+      input.afterBlockId = blockId;
379
+    } else {
380
+      input.blockId = blockId;
381
+    }
382
+  }
367 383
   if (toolName === 'update_block' && blockContent) input.content = blockContent;
368 384
   if (toolName === 'insert_block' && blockContent) {
369 385
     input.type = 'paragraph';

+ 25 - 7
src/services/webMcpService.ts

@@ -110,6 +110,12 @@ const numberOr = (input: Record<string, unknown>, key: string, fallback: number)
110 110
 	return value;
111 111
 };
112 112
 
113
+const contentProperty = {
114
+	type: ['string', 'object', 'array'],
115
+	description: '块内容:字符串、结构化对象或数组',
116
+	maxLength: 200000,
117
+};
118
+
113 119
 const run = async (operation: () => Promise<unknown>): Promise<WebMcpToolResult> => {
114 120
 	try {
115 121
 		return text(await operation());
@@ -140,13 +146,23 @@ const validateToolInput = (tool: WebMcpTool, input: Record<string, unknown>): st
140 146
 	for (const [key, definition] of Object.entries(schema.properties)) {
141 147
 		const value = input[key];
142 148
 		if (value === undefined || value === null) continue;
143
-		if (definition.type === 'string' && typeof value !== 'string') return `${key} 必须是字符串`;
144
-		if (definition.type === 'string' && typeof value === 'string') {
149
+		const expectedTypes = Array.isArray(definition.type) ? definition.type : [definition.type];
150
+		const actualType = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
151
+		if (!expectedTypes.includes(actualType)) {
152
+			return `${key} 必须是${expectedTypes.join('、')}`;
153
+		}
154
+		if (expectedTypes.includes('string') && typeof value === 'string') {
145 155
 			if (definition.minLength !== undefined && value.length < Number(definition.minLength)) return `${key} 长度过短`;
146 156
 			if (definition.maxLength !== undefined && value.length > Number(definition.maxLength)) return `${key} 长度过长`;
157
+		} else if (definition.maxLength !== undefined && (Array.isArray(value) || isRecord(value))) {
158
+			try {
159
+				if (JSON.stringify(value).length > Number(definition.maxLength)) return `${key} 内容过大`;
160
+			} catch {
161
+				return `${key} 内容无法序列化`;
162
+			}
147 163
 		}
148
-		if (definition.type === 'number' && (typeof value !== 'number' || !Number.isFinite(value))) return `${key} 必须是数字`;
149
-		if (definition.type === 'number' && typeof value === 'number') {
164
+		if (expectedTypes.includes('number') && typeof value === 'number' && !Number.isFinite(value)) return `${key} 必须是数字`;
165
+		if (expectedTypes.includes('number') && typeof value === 'number') {
150 166
 			if (definition.minimum !== undefined && value < Number(definition.minimum)) return `${key} 不能小于 ${definition.minimum}`;
151 167
 			if (definition.maximum !== undefined && value > Number(definition.maximum)) return `${key} 不能大于 ${definition.maximum}`;
152 168
 		}
@@ -265,7 +281,7 @@ const tools: WebMcpTool[] = [
265 281
 			{
266 282
 				documentId: documentIdProperty,
267 283
 				type: { type: 'string', enum: ['heading', 'paragraph', 'table', 'image', 'toc'] },
268
-				content: { type: 'string', description: '块内容', maxLength: 200000 },
284
+				content: contentProperty,
269 285
 				level: { type: 'number', minimum: 0, maximum: 6 },
270 286
 				afterBlockId: { type: 'string', description: '插入到此块之后,可选' },
271 287
 				clientBlockId: { type: 'string', description: '可选,客户端幂等块 ID' },
@@ -301,11 +317,13 @@ const tools: WebMcpTool[] = [
301 317
 		title: '修改内容块',
302 318
 		description: '更新文档块内容。需要用户确认。',
303 319
 		requiresConfirmation: true,
304
-		inputSchema: objectSchema({ documentId: documentIdProperty, blockId: blockIdProperty, content: { type: 'string', description: '新的块内容' } }, ['documentId', 'blockId', 'content']),
320
+		inputSchema: objectSchema({ documentId: documentIdProperty, blockId: blockIdProperty, content: contentProperty }, ['documentId', 'blockId', 'content']),
305 321
 		execute: (input) => run(() => {
306 322
 			const documentId = requiredString(input, 'documentId');
307 323
 			const blockId = requiredString(input, 'blockId');
308
-			const updates: UpdateBlockRequest = { content: requiredString(input, 'content') };
324
+			const updates: UpdateBlockRequest = {
325
+				content: requiredContent(input, 'content') as UpdateBlockRequest['content'],
326
+			};
309 327
 			return blockService.updateBlock(documentId, blockId, updates).then((result) => {
310 328
 				window.dispatchEvent(new CustomEvent(BLOCK_UPDATED_EVENT, {
311 329
 					detail: { documentId, blockId, updates },

+ 12 - 1
src/share/webmcp/translator.ts

@@ -11,6 +11,17 @@ export const translateWebMcpInput = async (
11 11
   input: string,
12 12
   context: WebMcpTranslationContext = {}
13 13
 ): Promise<WebMcpTranslationResponse> => {
14
+  const normalizedContext = {
15
+    ...context,
16
+    documentCandidates: context.documentCandidates?.map((candidate) => ({
17
+      documentId: candidate.documentId,
18
+      title: candidate.title,
19
+      ...(candidate.recordId ? { recordId: candidate.recordId } : {}),
20
+      ...('aliases' in candidate && Array.isArray(candidate.aliases) && candidate.aliases.length > 0
21
+        ? { aliases: candidate.aliases.join(' | ') }
22
+        : {}),
23
+    })),
24
+  };
14 25
   const controller = new AbortController();
15 26
   const timeoutId = window.setTimeout(() => controller.abort(), TRANSLATION_TIMEOUT_MS);
16 27
   let response: Response;
@@ -19,7 +30,7 @@ export const translateWebMcpInput = async (
19 30
       method: 'POST',
20 31
       headers: { 'Content-Type': 'application/json' },
21 32
       credentials: 'include',
22
-      body: JSON.stringify({ input, context }),
33
+      body: JSON.stringify({ input, context: normalizedContext }),
23 34
       signal: controller.signal,
24 35
     });
25 36
   } catch (error) {

+ 377 - 28
src/stores/editorStore.ts

@@ -12,6 +12,7 @@
12 12
 
13 13
 import { create } from 'zustand';
14 14
 import pLimit from 'p-limit';
15
+import hashSum from 'hash-sum';
15 16
 import { v4 as uuidv4 } from 'uuid';
16 17
 import { message } from 'antd';
17 18
 import type {
@@ -23,20 +24,11 @@ import type {
23 24
 } from '../types/editor';
24 25
 import { blockService } from '../services/blockService';
25 26
 import { getErrorMessage, isApiError } from '../services/api';
26
-import { normalizeTableBlock, validateTableStructure } from '../utils/blockOperations';
27 27
 import {
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';
28
+  normalizeTableBlock,
29
+  serializeTableBlock,
30
+  validateTableStructure,
31
+} from '../utils/blockOperations';
40 32
 
41 33
 // 并发保存限制器(最多同时进行 3 个请求,降低服务器压力)
42 34
 const saveConcurrencyLimit = pLimit(3);
@@ -50,6 +42,350 @@ const MAX_RETRY_ATTEMPTS = 3;
50 42
 const MAX_HISTORY_ENTRIES = 100;
51 43
 const HISTORY_COALESCE_WINDOW = 500;
52 44
 
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
+  applyRemoteBlockInsert: (block: DocumentBlock) => void;
270
+
271
+  /** 应用来自聊天/WebMCP 的已保存块删除 */
272
+  applyRemoteBlockDelete: (id: string) => void;
273
+
274
+  /**
275
+   * 标记文档已修改
276
+   */
277
+  markAsModified: () => void;
278
+
279
+  /**
280
+   * 标记文档已保存
281
+   */
282
+  markAsSaved: () => void;
283
+
284
+  /**
285
+   * 删除块
286
+   * @param id 块ID
287
+   */
288
+  deleteBlock: (id: string) => Promise<void>;
289
+
290
+  /**
291
+   * 移动块
292
+   * @param id 块ID
293
+   * @param targetOrder 目标位置
294
+   */
295
+  moveBlock: (id: string, targetOrder: number) => void;
296
+
297
+  /**
298
+   * 选中块
299
+   * @param id 块ID
300
+   */
301
+  selectBlock: (id: string | null) => void;
302
+
303
+  /**
304
+   * 根据ID获取块
305
+   */
306
+  getBlockById: (id: string) => DocumentBlock | undefined;
307
+
308
+  /**
309
+   * 根据类型获取块
310
+   */
311
+  getBlocksByType: (type: BlockType) => DocumentBlock[];
312
+
313
+  /**
314
+   * 检查块是否为脏块(已修改未保存)
315
+   */
316
+  isBlockDirty: (id: string) => boolean;
317
+
318
+  /**
319
+   * 获取所有脏块
320
+   */
321
+  getDirtyBlocks: () => DocumentBlock[];
322
+
323
+  /**
324
+   * 保存单个block的更改
325
+   */
326
+  saveBlock: (id: string) => Promise<void>;
327
+
328
+  /**
329
+   * 重新计算所有块的block_order(稀疏排序)
330
+   */
331
+  recomputeBlockOrders: () => void;
332
+
333
+  /**
334
+   * 启用/禁用自动保存
335
+   */
336
+  setAutoSaveEnabled: (enabled: boolean) => void;
337
+
338
+  /**
339
+   * 触发自动保存(带防抖)
340
+   */
341
+  triggerAutoSave: () => void;
342
+
343
+  /**
344
+   * 取消自动保存定时器
345
+   */
346
+  cancelAutoSave: () => void;
347
+
348
+  /**
349
+   * 重置状态
350
+   */
351
+  reset: () => void;
352
+  /** 撤销最近一次编辑 */
353
+  undo: () => Promise<void>;
354
+  /** 重做最近一次撤销 */
355
+  redo: () => Promise<void>;
356
+}
357
+
358
+// ══════════════════════════════════════════════════════════════════════════════
359
+// Utility Functions
360
+// ══════════════════════════════════════════════════════════════════════════════
361
+
362
+/**
363
+ * 计算插入位置的block_order
364
+ * 稀疏排序策略:在两个块之间找到中间值
365
+ */
366
+function computeInsertOrder(prevOrder: number, nextOrder: number): number {
367
+  const gap = nextOrder - prevOrder;
368
+
369
+  if (gap > 1) {
370
+    // 有间隙,直接取中间值
371
+    return Math.floor((prevOrder + nextOrder) / 2);
372
+  }
373
+
374
+  // 间隙不足,需要重排
375
+  return -1;
376
+}
377
+
378
+/**
379
+ * 重新平衡block_order(稀疏排序,间隔100)
380
+ */
381
+function rebalanceOrders(blocks: DocumentBlock[]): DocumentBlock[] {
382
+  const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
383
+  return sorted.map((block, index) => ({
384
+    ...block,
385
+    block_order: index * 100,
386
+  }));
387
+}
388
+
53 389
 // ══════════════════════════════════════════════════════════════════════════════
54 390
 // Store Implementation
55 391
 // ══════════════════════════════════════════════════════════════════════════════
@@ -319,14 +655,14 @@ export const useEditorStore = create<EditorStore>((set, get) => {
319 655
         return;
320 656
       }
321 657
 
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
-      }
658
+      const dirtyBlocks = new Set(
659
+        blocks
660
+          .filter(
661
+            (block) =>
662
+              currentDirtyBlocks.has(block.id) && block.type !== 'toc' && !isReadonlyBlock(block)
663
+          )
664
+          .map((block) => block.id)
665
+      );
330 666
 
331 667
       if (dirtyBlocks.size === 0) {
332 668
         set({
@@ -367,6 +703,15 @@ export const useEditorStore = create<EditorStore>((set, get) => {
367 703
       // 创建保存 Promise
368 704
       const savePromise = (async () => {
369 705
         try {
706
+          // 获取需要保存的块(只保存脏块)
707
+          const blocksToSave = blocks.filter((block) => {
708
+            // 必须在脏块列表中
709
+            if (!dirtyBlocks.has(block.id)) {
710
+              return false;
711
+            }
712
+
713
+            return true;
714
+          });
370 715
           const pendingUpdatesAtSave = new Map(currentPendingBlockUpdates);
371 716
 
372 717
           const totalBlocks = blocksToSave.length;
@@ -389,9 +734,12 @@ export const useEditorStore = create<EditorStore>((set, get) => {
389 734
               );
390 735
 
391 736
               try {
392
-                const result = await blockService.updateBlock(documentId, block.id, payload, {
393
-                  signal: newAbortController.signal,
394
-                });
737
+                const result = await blockService.updateBlock(
738
+                  documentId,
739
+                  block.id,
740
+                  payload,
741
+                  { signal: newAbortController.signal }
742
+                );
395 743
                 if (newAbortController.signal.aborted || get().documentId !== documentId) {
396 744
                   return result;
397 745
                 }
@@ -925,7 +1273,10 @@ export const useEditorStore = create<EditorStore>((set, get) => {
925 1273
       if (originalHash && newHash === originalHash) {
926 1274
         newPendingBlockUpdates.delete(id);
927 1275
       } else {
928
-        newPendingBlockUpdates.set(id, mergeBlockUpdates(pendingBlockUpdates.get(id), updates));
1276
+        newPendingBlockUpdates.set(
1277
+          id,
1278
+          mergeBlockUpdates(pendingBlockUpdates.get(id), updates)
1279
+        );
929 1280
       }
930 1281
 
931 1282
       set({
@@ -977,9 +1328,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
977 1328
       const { documentId, blocks, blockHashes, dirtyBlocks, pendingBlockUpdates } = get();
978 1329
       if (!documentId || blocks.some((currentBlock) => currentBlock.id === block.id)) return;
979 1330
 
980
-      const updatedBlocks = [...blocks, block].sort(
981
-        (left, right) => left.block_order - right.block_order
982
-      );
1331
+      const updatedBlocks = [...blocks, block].sort((left, right) => left.block_order - right.block_order);
983 1332
       const updatedHashes = new Map(blockHashes);
984 1333
       updatedHashes.set(block.id, computeBlockHash(block));
985 1334
       const snapshot = JSON.stringify(updatedBlocks);

+ 0 - 70
src/stores/editorStoreTypes.ts

@@ -1,70 +0,0 @@
1
-import type { BlockType, BlockUpdate, DocumentBlock, PartialBlock } from '../types/editor';
2
-
3
-export interface EditorHistoryEntry {
4
-  blocks: DocumentBlock[];
5
-  selectedBlockId: string | null;
6
-}
7
-
8
-export interface EditorStore {
9
-  documentId: string | null;
10
-  documentTitle: string;
11
-  blocks: DocumentBlock[];
12
-  selectedBlockId: string | null;
13
-  focusRequestId: number;
14
-  past: EditorHistoryEntry[];
15
-  future: EditorHistoryEntry[];
16
-  lastHistoryEditBlockId: string | null;
17
-  lastHistoryEditAt: number | null;
18
-  isHistoryApplying: boolean;
19
-  pendingStructuralOperations: number;
20
-
21
-  isLoading: boolean;
22
-  isSaving: boolean;
23
-  error: string | null;
24
-  savingProgress: { current: number; total: number } | null;
25
-
26
-  hasModified: boolean;
27
-  originalBlocksSnapshot: string | null;
28
-  failedBlocks: string[];
29
-  dirtyBlocks: Set<string>;
30
-  lastSavedSnapshot: string | null;
31
-  blockHashes: Map<string, string>;
32
-  pendingBlockUpdates: Map<string, BlockUpdate>;
33
-
34
-  currentSavePromise: Promise<void> | null;
35
-  saveAbortController: AbortController | null;
36
-  loadAbortController: AbortController | null;
37
-
38
-  autoSaveTimer: ReturnType<typeof setTimeout> | null;
39
-  autoSaveEnabled: boolean;
40
-  lastSaveTime: number | null;
41
-
42
-  retryAttempts: Map<string, number>;
43
-  retryTimer: ReturnType<typeof setTimeout> | null;
44
-
45
-  loadDocument: (documentId: string) => Promise<void>;
46
-  saveDocument: () => Promise<void>;
47
-  retryFailedBlocks: () => Promise<void>;
48
-  addBlock: (block: PartialBlock, afterBlockId?: string) => void;
49
-  updateBlock: (id: string, updates: BlockUpdate) => void;
50
-  applyRemoteBlockUpdate: (id: string, updates: BlockUpdate) => void;
51
-  applyRemoteBlockInsert: (block: DocumentBlock) => void;
52
-  applyRemoteBlockDelete: (id: string) => void;
53
-  markAsModified: () => void;
54
-  markAsSaved: () => void;
55
-  deleteBlock: (id: string) => Promise<void>;
56
-  moveBlock: (id: string, targetOrder: number) => void;
57
-  selectBlock: (id: string | null) => void;
58
-  getBlockById: (id: string) => DocumentBlock | undefined;
59
-  getBlocksByType: (type: BlockType) => DocumentBlock[];
60
-  isBlockDirty: (id: string) => boolean;
61
-  getDirtyBlocks: () => DocumentBlock[];
62
-  saveBlock: (id: string) => Promise<void>;
63
-  recomputeBlockOrders: () => void;
64
-  setAutoSaveEnabled: (enabled: boolean) => void;
65
-  triggerAutoSave: () => void;
66
-  cancelAutoSave: () => void;
67
-  reset: () => void;
68
-  undo: () => Promise<void>;
69
-  redo: () => Promise<void>;
70
-}

+ 0 - 121
src/utils/editorStoreHelpers.ts

@@ -1,121 +0,0 @@
1
-import hashSum from 'hash-sum';
2
-import type { BlockUpdate, DocumentBlock, TableBlock } from '../types/editor';
3
-import { serializeTableBlock } from './blockOperations';
4
-
5
-export function waitForStructuralOperations(isReady: () => boolean): Promise<void> {
6
-  return new Promise((resolve) => {
7
-    const check = () => {
8
-      if (isReady()) {
9
-        resolve();
10
-        return;
11
-      }
12
-      setTimeout(check, 16);
13
-    };
14
-    check();
15
-  });
16
-}
17
-
18
-export function computeInsertOrder(prevOrder: number, nextOrder: number): number {
19
-  const gap = nextOrder - prevOrder;
20
-  return gap > 1 ? Math.floor((prevOrder + nextOrder) / 2) : -1;
21
-}
22
-
23
-export function rebalanceOrders(blocks: DocumentBlock[]): DocumentBlock[] {
24
-  return [...blocks]
25
-    .sort((left, right) => left.block_order - right.block_order)
26
-    .map((block, index) => ({ ...block, block_order: index * 100 }));
27
-}
28
-
29
-export function snapshotBlocks(blocks: DocumentBlock[]): DocumentBlock[] {
30
-  return blocks.slice();
31
-}
32
-
33
-export function computeBlockHash(block: DocumentBlock): string {
34
-  return hashSum({
35
-    type: block.type,
36
-    content: block.content,
37
-    style: block.style,
38
-    word_style: block.word_style,
39
-    level: block.level,
40
-    block_order: block.block_order,
41
-  });
42
-}
43
-
44
-export function isReadonlyBlock(block: DocumentBlock): boolean {
45
-  const metadata = block.metadata as { readonly?: unknown; is_auto_generated?: unknown };
46
-  return metadata.readonly === true || metadata.is_auto_generated === true;
47
-}
48
-
49
-export function isCanceledRequest(error: unknown): boolean {
50
-  return (
51
-    error instanceof Error &&
52
-    (error.name === 'AbortError' ||
53
-      error.name === 'CanceledError' ||
54
-      error.message.toLowerCase().includes('cancel'))
55
-  );
56
-}
57
-
58
-export function serializeBlockForSave(
59
-  block: DocumentBlock
60
-): Pick<BlockUpdate, 'content' | 'style'> {
61
-  if (block.type === 'table') {
62
-    const serializedTable = serializeTableBlock(block as TableBlock);
63
-    return { content: serializedTable.content, style: block.style };
64
-  }
65
-
66
-  if ((block.type === 'heading' || block.type === 'paragraph') && Array.isArray(block.content)) {
67
-    const firstStyle = block.content[0]?.style;
68
-    const firstStyleSerialized = firstStyle ? JSON.stringify(firstStyle) : undefined;
69
-    const allSameStyle =
70
-      !!firstStyle &&
71
-      block.content.every((segment) => JSON.stringify(segment.style) === firstStyleSerialized);
72
-
73
-    return {
74
-      content: block.content.map((segment) => segment.text).join(''),
75
-      style:
76
-        allSameStyle && Object.keys(firstStyle).length > 0
77
-          ? { ...block.style, ...firstStyle }
78
-          : block.style,
79
-    };
80
-  }
81
-
82
-  return { content: block.content, style: block.style };
83
-}
84
-
85
-export function mergeBlockUpdates(
86
-  previous: BlockUpdate | undefined,
87
-  next: BlockUpdate
88
-): BlockUpdate {
89
-  return { ...(previous || {}), ...next };
90
-}
91
-
92
-export function serializePendingBlockUpdate(
93
-  block: DocumentBlock,
94
-  pending: BlockUpdate | undefined
95
-): BlockUpdate {
96
-  if (!pending) {
97
-    const serialized = serializeBlockForSave(block);
98
-    return {
99
-      type: block.type,
100
-      level: block.level,
101
-      content: serialized.content,
102
-      style: serialized.style,
103
-      word_style: block.word_style,
104
-      metadata: block.metadata,
105
-      block_order: block.block_order,
106
-    };
107
-  }
108
-
109
-  const payload: BlockUpdate = {};
110
-  if ('type' in pending) payload.type = block.type;
111
-  if ('level' in pending) payload.level = block.level;
112
-  if ('content' in pending || 'style' in pending) {
113
-    const serialized = serializeBlockForSave(block);
114
-    if ('content' in pending) payload.content = serialized.content;
115
-    if ('style' in pending) payload.style = serialized.style;
116
-  }
117
-  if ('word_style' in pending) payload.word_style = block.word_style;
118
-  if ('metadata' in pending) payload.metadata = block.metadata;
119
-  if ('block_order' in pending) payload.block_order = block.block_order;
120
-  return payload;
121
-}