Przeglądaj źródła

feat(编辑器): 增强有序列表编辑能力,优化内容格式管理

- 新增有序列表辅助函数(getEditorTextWithLineBreaks、getNextOrderedListNumber、normalizeOrderedListNumbers)
- 实现 Enter 键自动生成下一行有序列表项
- 自动修正已有有序列表编号,保持序列连贯性
- 在 RichTextEditor 中添加 currentContentFormat 和 onContentFormatChange 属性
- 在 RichTextToolbar 中新增内容格式选择器(段落、有序列表、标题等)
- 扩展工具栏样式,支持内容格式选择下拉菜单
- 优化编辑器键盘处理逻辑,增强有序列表输入交互体验
- 更新 editor.ts 类型定义和 editorStore.ts 状态管理,支持新的格式管理流程
Zhang Yice 1 miesiąc temu
rodzic
commit
ef7e2f5bac

+ 99 - 2
src/components/Editor/RichTextEditor/RichTextEditor.tsx

@@ -13,6 +13,60 @@ import { richTextToHtml, htmlToRichText } from '../../../utils/richTextConverter
13 13
 import { RichTextToolbar } from './RichTextToolbar';
14 14
 import './RichTextEditor.css';
15 15
 
16
+export function getEditorTextWithLineBreaks(node: Node): string {
17
+  if (node.nodeType === 3) {
18
+    return node.textContent || '';
19
+  }
20
+
21
+  if (node.nodeType !== 1 && node.nodeType !== 11) {
22
+    return '';
23
+  }
24
+
25
+  const element = node as HTMLElement;
26
+  if (element.tagName === 'BR') {
27
+    return '\n';
28
+  }
29
+
30
+  const text = Array.from(node.childNodes).map(getEditorTextWithLineBreaks).join('');
31
+  const blockTags = new Set(['DIV', 'P', 'LI']);
32
+  return element.tagName && blockTags.has(element.tagName) ? `${text}\n` : text;
33
+}
34
+
35
+export function getNextOrderedListNumber(text: string): number {
36
+  const itemNumbers = Array.from(text.matchAll(/(?:^|\n)\s*(\d+)\.(?=\s|$)/g))
37
+    .map((match) => Number(match[1]))
38
+    .filter(Number.isFinite);
39
+
40
+  return itemNumbers.length > 0 ? Math.max(...itemNumbers) + 1 : 1;
41
+}
42
+
43
+export function normalizeOrderedListNumbers(root: HTMLElement): boolean {
44
+  const walker = root.ownerDocument.createTreeWalker(root, 4);
45
+  let itemNumber = 0;
46
+  let changed = false;
47
+  let textNode = walker.nextNode();
48
+
49
+  while (textNode) {
50
+    const originalText = textNode.textContent || '';
51
+    const normalizedText = originalText.replace(
52
+      /(^|\n)(\s*)\d+\.(?=\s|$)/g,
53
+      (_match, lineStart: string, indent: string) => {
54
+        itemNumber += 1;
55
+        return `${lineStart}${indent}${itemNumber}.`;
56
+      }
57
+    );
58
+
59
+    if (normalizedText !== originalText) {
60
+      textNode.textContent = normalizedText;
61
+      changed = true;
62
+    }
63
+
64
+    textNode = walker.nextNode();
65
+  }
66
+
67
+  return changed;
68
+}
69
+
16 70
 // ══════════════════════════════════════════════════════════════════════════════
17 71
 // Component Props
18 72
 // ══════════════════════════════════════════════════════════════════════════════
@@ -34,6 +88,10 @@ export interface RichTextEditorProps {
34 88
   autoFocus?: boolean;
35 89
   /** 失焦回调 */
36 90
   onBlur?: () => void;
91
+  /** 当前 block 内容格式 */
92
+  currentContentFormat?: 'paragraph' | 'ordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`;
93
+  /** block 内容格式变更回调 */
94
+  onContentFormatChange?: (format: 'paragraph' | 'ordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`) => void;
37 95
   /** 对齐方式变更回调(用于段落块) */
38 96
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
39 97
   /** 当前对齐方式(用于初始化工具栏) */
@@ -83,6 +141,8 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
83 141
   baseStyle = {},
84 142
   autoFocus = false,
85 143
   onBlur,
144
+  currentContentFormat,
145
+  onContentFormatChange,
86 146
   onAlignChange,
87 147
   currentAlign,
88 148
   tableContext,
@@ -118,6 +178,15 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
118 178
     }
119 179
   }, [value, baseStyle.fontSize, baseStyle.fontFamily]);
120 180
 
181
+  // ── 修正已有有序列表编号 ───────────────────────────────────────────────────
182
+  useEffect(() => {
183
+    if (currentContentFormat !== 'ordered-list' || !editorRef.current || !onChange) return;
184
+
185
+    if (normalizeOrderedListNumbers(editorRef.current)) {
186
+      onChange(htmlToRichText(editorRef.current.innerHTML));
187
+    }
188
+  }, [currentContentFormat, onChange, value]);
189
+
121 190
   // ── 自动聚焦 ───────────────────────────────────────────────────────────────
122 191
   useEffect(() => {
123 192
     if (autoFocus && editorRef.current && !readOnly) {
@@ -132,10 +201,14 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
132 201
     // 标记正在输入
133 202
     isFormattingRef.current = true;
134 203
     
204
+    if (currentContentFormat === 'ordered-list') {
205
+      normalizeOrderedListNumbers(editorRef.current);
206
+    }
207
+
135 208
     const html = editorRef.current.innerHTML;
136 209
     const richText = htmlToRichText(html);
137 210
     onChange(richText);
138
-  }, [onChange]);
211
+  }, [currentContentFormat, onChange]);
139 212
 
140 213
   // ── 处理格式变更(工具栏修改) ──────────────────────────────────────────────
141 214
   const handleFormatChange = useCallback(() => {
@@ -168,6 +241,28 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
168 241
 
169 242
   // ── 处理键盘快捷键 ─────────────────────────────────────────────────────────
170 243
   const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
244
+    if (e.key === 'Enter' && currentContentFormat === 'ordered-list' && !e.nativeEvent.isComposing) {
245
+      e.preventDefault();
246
+
247
+      const selection = window.getSelection();
248
+      if (!editorRef.current || !selection || selection.rangeCount === 0) return;
249
+
250
+      const editorText = getEditorTextWithLineBreaks(editorRef.current);
251
+      const nextNumber = getNextOrderedListNumber(editorText);
252
+
253
+      document.execCommand('insertText', false, `\n${nextNumber}. `);
254
+      normalizeOrderedListNumbers(editorRef.current);
255
+
256
+      const endRange = document.createRange();
257
+      endRange.selectNodeContents(editorRef.current);
258
+      endRange.collapse(false);
259
+      selection.removeAllRanges();
260
+      selection.addRange(endRange);
261
+
262
+      handleInput();
263
+      return;
264
+    }
265
+
171 266
     // 单行模式禁止换行
172 267
     if (singleLine && e.key === 'Enter') {
173 268
       e.preventDefault();
@@ -194,7 +289,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
194 289
           break;
195 290
       }
196 291
     }
197
-  }, [singleLine, handleInput]);
292
+  }, [singleLine, currentContentFormat, handleInput]);
198 293
 
199 294
   // ── 处理选中文本(显示工具栏) ───────────────────────────────────────────────
200 295
   const handleMouseUp = useCallback(() => {
@@ -298,6 +393,8 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
298 393
           position={toolbarPosition}
299 394
           onClose={() => setShowToolbar(false)}
300 395
           onFormat={handleFormatChange}
396
+          currentContentFormat={currentContentFormat}
397
+          onContentFormatChange={onContentFormatChange}
301 398
           onAlignChange={onAlignChange}
302 399
           currentAlign={currentAlign}
303 400
           tableContext={tableContext}

+ 10 - 0
src/components/Editor/RichTextEditor/RichTextToolbar.css

@@ -69,3 +69,13 @@
69 69
   flex-shrink: 0;
70 70
 }
71 71
 
72
+.content-format-select {
73
+  width: 96px;
74
+}
75
+
76
+.content-format-option {
77
+  display: inline-flex;
78
+  align-items: center;
79
+  gap: 6px;
80
+}
81
+

+ 104 - 3
src/components/Editor/RichTextEditor/RichTextToolbar.tsx

@@ -9,13 +9,14 @@
9 9
  */
10 10
 
11 11
 import React, { useEffect, useRef, useState, useCallback } from 'react';
12
-import { Button, Tooltip, InputNumber, Popover } from 'antd';
12
+import { Button, Tooltip, InputNumber, Popover, Select } from 'antd';
13 13
 import {
14 14
   BoldOutlined,
15 15
   ItalicOutlined,
16 16
   UnderlineOutlined,
17 17
   FontSizeOutlined,
18 18
   FontColorsOutlined,
19
+  OrderedListOutlined,
19 20
   AlignLeftOutlined,
20 21
   AlignCenterOutlined,
21 22
   AlignRightOutlined,
@@ -37,6 +38,10 @@ export interface RichTextToolbarProps {
37 38
   onClose: () => void;
38 39
   /** 格式变更回调 */
39 40
   onFormat: () => void;
41
+  /** 当前 block 内容格式 */
42
+  currentContentFormat?: 'paragraph' | 'ordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`;
43
+  /** block 内容格式变更回调 */
44
+  onContentFormatChange?: (format: 'paragraph' | 'ordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`) => void;
40 45
   /** 对齐方式变更回调(用于段落块) */
41 46
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
42 47
   /** 当前对齐方式(用于初始化) */
@@ -259,6 +264,28 @@ function hasFormat(tagName?: string, styleCheck?: (el: HTMLElement) => boolean):
259 264
   return false;
260 265
 }
261 266
 
267
+type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
268
+type ContentFormat = 'paragraph' | 'ordered-list' | `heading-${HeadingLevel}`;
269
+
270
+function getTextWithLineBreaks(node: Node): string {
271
+  if (node.nodeType === Node.TEXT_NODE) {
272
+    return node.textContent || '';
273
+  }
274
+
275
+  if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
276
+    return '';
277
+  }
278
+
279
+  const element = node as HTMLElement;
280
+  if (element.tagName === 'BR') {
281
+    return '\n';
282
+  }
283
+
284
+  const text = Array.from(node.childNodes).map(getTextWithLineBreaks).join('');
285
+  const blockTags = new Set(['DIV', 'P', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
286
+  return element.tagName && blockTags.has(element.tagName) ? `${text}\n` : text;
287
+}
288
+
262 289
 // ══════════════════════════════════════════════════════════════════════════════
263 290
 // Component
264 291
 // ══════════════════════════════════════════════════════════════════════════════
@@ -267,6 +294,8 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
267 294
   position,
268 295
   onClose,
269 296
   onFormat,
297
+  currentContentFormat,
298
+  onContentFormatChange,
270 299
   onAlignChange,
271 300
   currentAlign,
272 301
   tableContext,
@@ -364,9 +393,12 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
364 393
   // ── 点击外部关闭 ───────────────────────────────────────────────────────────
365 394
   useEffect(() => {
366 395
     const handleClickOutside = (e: MouseEvent) => {
367
-      if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
396
+      const target = e.target as Element;
397
+      const isToolbarPopup = target.closest('.ant-select-dropdown, .ant-popover');
398
+
399
+      if (toolbarRef.current && !toolbarRef.current.contains(target) && !isToolbarPopup) {
368 400
         const editor = document.querySelector('.rich-text-editor');
369
-        if (!editor?.contains(e.target as Node)) {
401
+        if (!editor?.contains(target)) {
370 402
           onClose();
371 403
         }
372 404
       }
@@ -519,6 +551,45 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
519 551
     // 非表格模式:暂不支持,但保留接口
520 552
   }, [isTableMode, tableContext]);
521 553
 
554
+  // ── 处理内容格式 ──────────────────────────────────────────────────────────
555
+  const handleContentFormatChange = useCallback((format: ContentFormat) => {
556
+    if (isTableMode || !restoreSelection(savedRangeRef.current)) {
557
+      return;
558
+    }
559
+
560
+    const selection = window.getSelection();
561
+    if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
562
+      return;
563
+    }
564
+
565
+    if (format !== 'ordered-list') {
566
+      onContentFormatChange?.(format);
567
+      return;
568
+    } else {
569
+      const range = selection.getRangeAt(0);
570
+      const selectedContent = range.cloneContents();
571
+      const selectedText = getTextWithLineBreaks(selectedContent).replace(/\n+$/, '');
572
+      const lines = selectedText.split(/\r?\n/);
573
+      let itemNumber = 0;
574
+      const numberedText = lines.map((line) => {
575
+        if (!line.trim()) return '';
576
+        itemNumber += 1;
577
+        return `${itemNumber}. ${line.replace(/^\s*\d+\.\s+/, '')}`;
578
+      }).join('\n');
579
+
580
+      range.deleteContents();
581
+      const textNode = document.createTextNode(numberedText);
582
+      range.insertNode(textNode);
583
+      range.selectNodeContents(textNode);
584
+      selection.removeAllRanges();
585
+      selection.addRange(range);
586
+    }
587
+
588
+    savedRangeRef.current = saveSelection();
589
+    onFormat();
590
+    requestAnimationFrame(() => onContentFormatChange?.('ordered-list'));
591
+  }, [isTableMode, onContentFormatChange, onFormat]);
592
+
522 593
   // ── 检查格式状态 ───────────────────────────────────────────────────────────
523 594
   const isActive = useCallback((tagName: string): boolean => {
524 595
     if (isTableMode) {
@@ -552,6 +623,36 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
552 623
       onMouseDown={handleMouseDown}
553 624
     >
554 625
       <div className="toolbar-content">
626
+        {/* 内容格式 */}
627
+        {!isTableMode && (
628
+          <Tooltip title="内容格式">
629
+            <Select<ContentFormat>
630
+              className="content-format-select"
631
+              size="small"
632
+              placeholder="格式"
633
+              value={currentContentFormat}
634
+              onChange={handleContentFormatChange}
635
+              options={[
636
+                { value: 'paragraph', label: '正文' },
637
+                { value: 'heading-1', label: '1级标题' },
638
+                { value: 'heading-2', label: '2级标题' },
639
+                { value: 'heading-3', label: '3级标题' },
640
+                { value: 'heading-4', label: '4级标题' },
641
+                { value: 'heading-5', label: '5级标题' },
642
+                { value: 'heading-6', label: '6级标题' },
643
+                {
644
+                  value: 'ordered-list',
645
+                  label: (
646
+                    <span className="content-format-option">
647
+                      <OrderedListOutlined /> 有序列表
648
+                    </span>
649
+                  ),
650
+                },
651
+              ]}
652
+            />
653
+          </Tooltip>
654
+        )}
655
+
555 656
         {/* 基本文本格式 */}
556 657
         <div className="toolbar-group">
557 658
           <Tooltip title="加粗 (Ctrl+B)">

+ 41 - 0
src/components/Editor/blocks/HeadingBlock.tsx

@@ -26,6 +26,7 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
26 26
   readOnly,
27 27
 }) => {
28 28
   const updateBlock = useEditorStore((state) => state.updateBlock);
29
+  const saveBlock = useEditorStore((state) => state.saveBlock);
29 30
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
30 31
   const addBlock = useEditorStore((state) => state.addBlock);
31 32
   const blocks = useEditorStore((state) => state.blocks);
@@ -64,6 +65,44 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
64 65
     [block.id, block.style, updateBlock]
65 66
   );
66 67
 
68
+  const handleContentFormatChange = useCallback(async (
69
+    format: 'paragraph' | 'ordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`
70
+  ) => {
71
+    const isParagraph = format === 'paragraph' || format === 'ordered-list';
72
+    const isOrderedList = format === 'ordered-list';
73
+    const level = isParagraph
74
+      ? 0
75
+      : Number(format.split('-')[1]) as 1 | 2 | 3 | 4 | 5 | 6;
76
+    const style = block.style.align ? { align: block.style.align } : {};
77
+
78
+    updateBlock(block.id, isParagraph
79
+      ? {
80
+          type: 'paragraph',
81
+          level: 0,
82
+          word_style: 'Normal',
83
+          style,
84
+          metadata: {
85
+            parent_heading_id: block.metadata.parent_id ?? null,
86
+            ...(isOrderedList ? { list_type: 'ordered' } : {}),
87
+          },
88
+        }
89
+      : {
90
+          level,
91
+          word_style: `Heading ${level}`,
92
+          style,
93
+        }
94
+    );
95
+
96
+    try {
97
+      await saveBlock(block.id);
98
+      message.success(
99
+        isOrderedList ? '已设为有序列表' : isParagraph ? '已设为正文' : `已设为${level}级标题`
100
+      );
101
+    } catch (error) {
102
+      message.error(error instanceof Error ? error.message : '标题格式更新失败');
103
+    }
104
+  }, [block.id, block.metadata.parent_id, block.style.align, saveBlock, updateBlock]);
105
+
67 106
   // ── 块操作回调 ──────────────────────────────────────────────────────────
68 107
 
69 108
   const handleDelete = useCallback(async () => {
@@ -245,6 +284,8 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
245 284
         <RichTextEditor
246 285
           value={block.content}
247 286
           onChange={handleChange}
287
+          currentContentFormat={`heading-${block.level}`}
288
+          onContentFormatChange={handleContentFormatChange}
248 289
           onAlignChange={handleAlignChange}
249 290
           currentAlign={block.style?.align}
250 291
           readOnly={readOnly}

+ 44 - 0
src/components/Editor/blocks/ParagraphBlock.tsx

@@ -26,6 +26,7 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
26 26
   readOnly,
27 27
 }) => {
28 28
   const updateBlock = useEditorStore((state) => state.updateBlock);
29
+  const saveBlock = useEditorStore((state) => state.saveBlock);
29 30
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
30 31
   const addBlock = useEditorStore((state) => state.addBlock);
31 32
   const blocks = useEditorStore((state) => state.blocks);
@@ -64,6 +65,43 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
64 65
     [block.id, block.style, updateBlock]
65 66
   );
66 67
 
68
+  const handleContentFormatChange = useCallback(async (
69
+    format: 'paragraph' | 'ordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`
70
+  ) => {
71
+    if (format === 'paragraph' || format === 'ordered-list') {
72
+      updateBlock(block.id, {
73
+        metadata: {
74
+          ...block.metadata,
75
+          list_type: format === 'ordered-list' ? 'ordered' : undefined,
76
+        },
77
+      });
78
+
79
+      try {
80
+        await saveBlock(block.id);
81
+      } catch (error) {
82
+        message.error(error instanceof Error ? error.message : '列表格式更新失败');
83
+      }
84
+      return;
85
+    }
86
+
87
+    const level = Number(format.split('-')[1]) as 1 | 2 | 3 | 4 | 5 | 6;
88
+    const style = block.style.align ? { align: block.style.align } : {};
89
+    updateBlock(block.id, {
90
+      type: 'heading',
91
+      level,
92
+      word_style: `Heading ${level}`,
93
+      style,
94
+      metadata: { parent_id: null },
95
+    });
96
+
97
+    try {
98
+      await saveBlock(block.id);
99
+      message.success(`已设为${level}级标题`);
100
+    } catch (error) {
101
+      message.error(error instanceof Error ? error.message : '标题格式更新失败');
102
+    }
103
+  }, [block.id, block.metadata, block.style.align, saveBlock, updateBlock]);
104
+
67 105
   // ── 块操作回调 ──────────────────────────────────────────────────────────
68 106
 
69 107
   const handleDelete = useCallback(async () => {
@@ -243,6 +281,12 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
243 281
         <RichTextEditor
244 282
           value={block.content}
245 283
           onChange={handleChange}
284
+          currentContentFormat={
285
+            (block.metadata as Record<string, unknown>).list_type === 'ordered'
286
+              ? 'ordered-list'
287
+              : 'paragraph'
288
+          }
289
+          onContentFormatChange={handleContentFormatChange}
246 290
           onAlignChange={handleAlignChange}
247 291
           currentAlign={block.style?.align}
248 292
           readOnly={readOnly}

+ 50 - 4
src/stores/editorStore.ts

@@ -409,11 +409,33 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
409 409
             // 获取该块的重试次数
410 410
             const attempts = retryAttempts.get(block.id) || 0;
411 411
             
412
-            // 序列化表格块的content(将富文本数组转为纯字符串
412
+            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段
413 413
             let contentToSave = block.content;
414
+            let styleToSave = block.style;
415
+            
414 416
             if (block.type === 'table') {
415 417
               const serializedTable = serializeTableBlock(block as TableBlock);
416 418
               contentToSave = serializedTable.content;
419
+            } else if (block.type === 'heading' || block.type === 'paragraph') {
420
+              // 对于标题和段落块,如果content是富文本数组,需要序列化
421
+              if (Array.isArray(block.content)) {
422
+                // 提取纯文本
423
+                contentToSave = block.content.map(seg => seg.text).join('');
424
+                
425
+                // 如果所有片段的样式一致,提取到块级style
426
+                const allSegments = block.content;
427
+                if (allSegments.length > 0) {
428
+                  const firstStyle = allSegments[0].style;
429
+                  const allSameStyle = allSegments.every(seg => 
430
+                    JSON.stringify(seg.style) === JSON.stringify(firstStyle)
431
+                  );
432
+                  
433
+                  if (allSameStyle && Object.keys(firstStyle).length > 0) {
434
+                    // 所有片段样式一致,合并到块级style
435
+                    styleToSave = { ...block.style, ...firstStyle };
436
+                  }
437
+                }
438
+              }
417 439
             }
418 440
             
419 441
             try {
@@ -422,7 +444,7 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
422 444
                 block.id, 
423 445
                 {
424 446
                   content: contentToSave as any,
425
-                  style: block.style,
447
+                  style: styleToSave,
426 448
                   word_style: block.word_style,
427 449
                   metadata: block.metadata,
428 450
                 },
@@ -617,11 +639,33 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
617 639
         // 使用并发限制器重试保存
618 640
         const tasks = blocksToRetry.map(block => 
619 641
           saveConcurrencyLimit(() => {
620
-            // 序列化表格块的content(将富文本数组转为纯字符串
642
+            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段
621 643
             let contentToSave = block.content;
644
+            let styleToSave = block.style;
645
+            
622 646
             if (block.type === 'table') {
623 647
               const serializedTable = serializeTableBlock(block as TableBlock);
624 648
               contentToSave = serializedTable.content;
649
+            } else if (block.type === 'heading' || block.type === 'paragraph') {
650
+              // 对于标题和段落块,如果content是富文本数组,需要序列化
651
+              if (Array.isArray(block.content)) {
652
+                // 提取纯文本
653
+                contentToSave = block.content.map(seg => seg.text).join('');
654
+                
655
+                // 如果所有片段的样式一致,提取到块级style
656
+                const allSegments = block.content;
657
+                if (allSegments.length > 0) {
658
+                  const firstStyle = allSegments[0].style;
659
+                  const allSameStyle = allSegments.every(seg => 
660
+                    JSON.stringify(seg.style) === JSON.stringify(firstStyle)
661
+                  );
662
+                  
663
+                  if (allSameStyle && Object.keys(firstStyle).length > 0) {
664
+                    // 所有片段样式一致,合并到块级style
665
+                    styleToSave = { ...block.style, ...firstStyle };
666
+                  }
667
+                }
668
+              }
625 669
             }
626 670
             
627 671
             return blockService.updateBlock(
@@ -629,7 +673,7 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
629 673
               block.id, 
630 674
               {
631 675
                 content: contentToSave as any,
632
-                style: block.style,
676
+                style: styleToSave,
633 677
                 word_style: block.word_style,
634 678
                 metadata: block.metadata,
635 679
               },
@@ -1125,6 +1169,8 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1125 1169
       }
1126 1170
       
1127 1171
       await blockService.updateBlock(documentId, id, {
1172
+        type: block.type,
1173
+        level: block.level,
1128 1174
         content: contentToSave as any, // 类型断言:不同block类型的content类型不同
1129 1175
         style: block.style,
1130 1176
         word_style: block.word_style,

+ 4 - 1
src/types/editor.ts

@@ -89,6 +89,7 @@ export interface ParagraphBlock extends BaseBlock {
89 89
   content: string | RichText[];
90 90
   metadata: {
91 91
     parent_heading_id: string | null;
92
+    list_type?: 'ordered';
92 93
   };
93 94
 }
94 95
 
@@ -266,6 +267,8 @@ export interface GetBlockResponse {
266 267
  * 更新block的API请求(部分字段)
267 268
  */
268 269
 export interface UpdateBlockRequest {
270
+  type?: BlockType;
271
+  level?: number;
269 272
   content?: string | RichText[] | TableContent;
270 273
   style?: StyleOverrides;
271 274
   word_style?: string;
@@ -343,4 +346,4 @@ export type PartialBlock = Partial<DocumentBlock> & {
343 346
 /**
344 347
  * Block更新参数
345 348
  */
346
-export type BlockUpdate = Partial<Omit<DocumentBlock, 'id' | 'type'>>;
349
+export type BlockUpdate = Partial<Omit<DocumentBlock, 'id'>>;