Преглед на файлове

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

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

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

@@ -13,6 +13,60 @@ import { richTextToHtml, htmlToRichText } from '../../../utils/richTextConverter
13
 import { RichTextToolbar } from './RichTextToolbar';
13
 import { RichTextToolbar } from './RichTextToolbar';
14
 import './RichTextEditor.css';
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
 // Component Props
71
 // Component Props
18
 // ══════════════════════════════════════════════════════════════════════════════
72
 // ══════════════════════════════════════════════════════════════════════════════
@@ -34,6 +88,10 @@ export interface RichTextEditorProps {
34
   autoFocus?: boolean;
88
   autoFocus?: boolean;
35
   /** 失焦回调 */
89
   /** 失焦回调 */
36
   onBlur?: () => void;
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
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
96
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
39
   /** 当前对齐方式(用于初始化工具栏) */
97
   /** 当前对齐方式(用于初始化工具栏) */
@@ -83,6 +141,8 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
83
   baseStyle = {},
141
   baseStyle = {},
84
   autoFocus = false,
142
   autoFocus = false,
85
   onBlur,
143
   onBlur,
144
+  currentContentFormat,
145
+  onContentFormatChange,
86
   onAlignChange,
146
   onAlignChange,
87
   currentAlign,
147
   currentAlign,
88
   tableContext,
148
   tableContext,
@@ -118,6 +178,15 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
118
     }
178
     }
119
   }, [value, baseStyle.fontSize, baseStyle.fontFamily]);
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
   useEffect(() => {
191
   useEffect(() => {
123
     if (autoFocus && editorRef.current && !readOnly) {
192
     if (autoFocus && editorRef.current && !readOnly) {
@@ -132,10 +201,14 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
132
     // 标记正在输入
201
     // 标记正在输入
133
     isFormattingRef.current = true;
202
     isFormattingRef.current = true;
134
     
203
     
204
+    if (currentContentFormat === 'ordered-list') {
205
+      normalizeOrderedListNumbers(editorRef.current);
206
+    }
207
+
135
     const html = editorRef.current.innerHTML;
208
     const html = editorRef.current.innerHTML;
136
     const richText = htmlToRichText(html);
209
     const richText = htmlToRichText(html);
137
     onChange(richText);
210
     onChange(richText);
138
-  }, [onChange]);
211
+  }, [currentContentFormat, onChange]);
139
 
212
 
140
   // ── 处理格式变更(工具栏修改) ──────────────────────────────────────────────
213
   // ── 处理格式变更(工具栏修改) ──────────────────────────────────────────────
141
   const handleFormatChange = useCallback(() => {
214
   const handleFormatChange = useCallback(() => {
@@ -168,6 +241,28 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
168
 
241
 
169
   // ── 处理键盘快捷键 ─────────────────────────────────────────────────────────
242
   // ── 处理键盘快捷键 ─────────────────────────────────────────────────────────
170
   const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
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
     if (singleLine && e.key === 'Enter') {
267
     if (singleLine && e.key === 'Enter') {
173
       e.preventDefault();
268
       e.preventDefault();
@@ -194,7 +289,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
194
           break;
289
           break;
195
       }
290
       }
196
     }
291
     }
197
-  }, [singleLine, handleInput]);
292
+  }, [singleLine, currentContentFormat, handleInput]);
198
 
293
 
199
   // ── 处理选中文本(显示工具栏) ───────────────────────────────────────────────
294
   // ── 处理选中文本(显示工具栏) ───────────────────────────────────────────────
200
   const handleMouseUp = useCallback(() => {
295
   const handleMouseUp = useCallback(() => {
@@ -298,6 +393,8 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
298
           position={toolbarPosition}
393
           position={toolbarPosition}
299
           onClose={() => setShowToolbar(false)}
394
           onClose={() => setShowToolbar(false)}
300
           onFormat={handleFormatChange}
395
           onFormat={handleFormatChange}
396
+          currentContentFormat={currentContentFormat}
397
+          onContentFormatChange={onContentFormatChange}
301
           onAlignChange={onAlignChange}
398
           onAlignChange={onAlignChange}
302
           currentAlign={currentAlign}
399
           currentAlign={currentAlign}
303
           tableContext={tableContext}
400
           tableContext={tableContext}

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

@@ -69,3 +69,13 @@
69
   flex-shrink: 0;
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
 import React, { useEffect, useRef, useState, useCallback } from 'react';
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
 import {
13
 import {
14
   BoldOutlined,
14
   BoldOutlined,
15
   ItalicOutlined,
15
   ItalicOutlined,
16
   UnderlineOutlined,
16
   UnderlineOutlined,
17
   FontSizeOutlined,
17
   FontSizeOutlined,
18
   FontColorsOutlined,
18
   FontColorsOutlined,
19
+  OrderedListOutlined,
19
   AlignLeftOutlined,
20
   AlignLeftOutlined,
20
   AlignCenterOutlined,
21
   AlignCenterOutlined,
21
   AlignRightOutlined,
22
   AlignRightOutlined,
@@ -37,6 +38,10 @@ export interface RichTextToolbarProps {
37
   onClose: () => void;
38
   onClose: () => void;
38
   /** 格式变更回调 */
39
   /** 格式变更回调 */
39
   onFormat: () => void;
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
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
46
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
42
   /** 当前对齐方式(用于初始化) */
47
   /** 当前对齐方式(用于初始化) */
@@ -259,6 +264,28 @@ function hasFormat(tagName?: string, styleCheck?: (el: HTMLElement) => boolean):
259
   return false;
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
 // Component
290
 // Component
264
 // ══════════════════════════════════════════════════════════════════════════════
291
 // ══════════════════════════════════════════════════════════════════════════════
@@ -267,6 +294,8 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
267
   position,
294
   position,
268
   onClose,
295
   onClose,
269
   onFormat,
296
   onFormat,
297
+  currentContentFormat,
298
+  onContentFormatChange,
270
   onAlignChange,
299
   onAlignChange,
271
   currentAlign,
300
   currentAlign,
272
   tableContext,
301
   tableContext,
@@ -364,9 +393,12 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
364
   // ── 点击外部关闭 ───────────────────────────────────────────────────────────
393
   // ── 点击外部关闭 ───────────────────────────────────────────────────────────
365
   useEffect(() => {
394
   useEffect(() => {
366
     const handleClickOutside = (e: MouseEvent) => {
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
         const editor = document.querySelector('.rich-text-editor');
400
         const editor = document.querySelector('.rich-text-editor');
369
-        if (!editor?.contains(e.target as Node)) {
401
+        if (!editor?.contains(target)) {
370
           onClose();
402
           onClose();
371
         }
403
         }
372
       }
404
       }
@@ -519,6 +551,45 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
519
     // 非表格模式:暂不支持,但保留接口
551
     // 非表格模式:暂不支持,但保留接口
520
   }, [isTableMode, tableContext]);
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
   const isActive = useCallback((tagName: string): boolean => {
594
   const isActive = useCallback((tagName: string): boolean => {
524
     if (isTableMode) {
595
     if (isTableMode) {
@@ -552,6 +623,36 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
552
       onMouseDown={handleMouseDown}
623
       onMouseDown={handleMouseDown}
553
     >
624
     >
554
       <div className="toolbar-content">
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
         <div className="toolbar-group">
657
         <div className="toolbar-group">
557
           <Tooltip title="加粗 (Ctrl+B)">
658
           <Tooltip title="加粗 (Ctrl+B)">

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

@@ -26,6 +26,7 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
26
   readOnly,
26
   readOnly,
27
 }) => {
27
 }) => {
28
   const updateBlock = useEditorStore((state) => state.updateBlock);
28
   const updateBlock = useEditorStore((state) => state.updateBlock);
29
+  const saveBlock = useEditorStore((state) => state.saveBlock);
29
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
30
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
30
   const addBlock = useEditorStore((state) => state.addBlock);
31
   const addBlock = useEditorStore((state) => state.addBlock);
31
   const blocks = useEditorStore((state) => state.blocks);
32
   const blocks = useEditorStore((state) => state.blocks);
@@ -64,6 +65,44 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
64
     [block.id, block.style, updateBlock]
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
   const handleDelete = useCallback(async () => {
108
   const handleDelete = useCallback(async () => {
@@ -245,6 +284,8 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
245
         <RichTextEditor
284
         <RichTextEditor
246
           value={block.content}
285
           value={block.content}
247
           onChange={handleChange}
286
           onChange={handleChange}
287
+          currentContentFormat={`heading-${block.level}`}
288
+          onContentFormatChange={handleContentFormatChange}
248
           onAlignChange={handleAlignChange}
289
           onAlignChange={handleAlignChange}
249
           currentAlign={block.style?.align}
290
           currentAlign={block.style?.align}
250
           readOnly={readOnly}
291
           readOnly={readOnly}

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

@@ -26,6 +26,7 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
26
   readOnly,
26
   readOnly,
27
 }) => {
27
 }) => {
28
   const updateBlock = useEditorStore((state) => state.updateBlock);
28
   const updateBlock = useEditorStore((state) => state.updateBlock);
29
+  const saveBlock = useEditorStore((state) => state.saveBlock);
29
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
30
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
30
   const addBlock = useEditorStore((state) => state.addBlock);
31
   const addBlock = useEditorStore((state) => state.addBlock);
31
   const blocks = useEditorStore((state) => state.blocks);
32
   const blocks = useEditorStore((state) => state.blocks);
@@ -64,6 +65,43 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
64
     [block.id, block.style, updateBlock]
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
   const handleDelete = useCallback(async () => {
107
   const handleDelete = useCallback(async () => {
@@ -243,6 +281,12 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
243
         <RichTextEditor
281
         <RichTextEditor
244
           value={block.content}
282
           value={block.content}
245
           onChange={handleChange}
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
           onAlignChange={handleAlignChange}
290
           onAlignChange={handleAlignChange}
247
           currentAlign={block.style?.align}
291
           currentAlign={block.style?.align}
248
           readOnly={readOnly}
292
           readOnly={readOnly}

+ 50 - 4
src/stores/editorStore.ts

@@ -409,11 +409,33 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
409
             // 获取该块的重试次数
409
             // 获取该块的重试次数
410
             const attempts = retryAttempts.get(block.id) || 0;
410
             const attempts = retryAttempts.get(block.id) || 0;
411
             
411
             
412
-            // 序列化表格块的content(将富文本数组转为纯字符串
412
+            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段
413
             let contentToSave = block.content;
413
             let contentToSave = block.content;
414
+            let styleToSave = block.style;
415
+            
414
             if (block.type === 'table') {
416
             if (block.type === 'table') {
415
               const serializedTable = serializeTableBlock(block as TableBlock);
417
               const serializedTable = serializeTableBlock(block as TableBlock);
416
               contentToSave = serializedTable.content;
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
             try {
441
             try {
@@ -422,7 +444,7 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
422
                 block.id, 
444
                 block.id, 
423
                 {
445
                 {
424
                   content: contentToSave as any,
446
                   content: contentToSave as any,
425
-                  style: block.style,
447
+                  style: styleToSave,
426
                   word_style: block.word_style,
448
                   word_style: block.word_style,
427
                   metadata: block.metadata,
449
                   metadata: block.metadata,
428
                 },
450
                 },
@@ -617,11 +639,33 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
617
         // 使用并发限制器重试保存
639
         // 使用并发限制器重试保存
618
         const tasks = blocksToRetry.map(block => 
640
         const tasks = blocksToRetry.map(block => 
619
           saveConcurrencyLimit(() => {
641
           saveConcurrencyLimit(() => {
620
-            // 序列化表格块的content(将富文本数组转为纯字符串
642
+            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段
621
             let contentToSave = block.content;
643
             let contentToSave = block.content;
644
+            let styleToSave = block.style;
645
+            
622
             if (block.type === 'table') {
646
             if (block.type === 'table') {
623
               const serializedTable = serializeTableBlock(block as TableBlock);
647
               const serializedTable = serializeTableBlock(block as TableBlock);
624
               contentToSave = serializedTable.content;
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
             return blockService.updateBlock(
671
             return blockService.updateBlock(
@@ -629,7 +673,7 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
629
               block.id, 
673
               block.id, 
630
               {
674
               {
631
                 content: contentToSave as any,
675
                 content: contentToSave as any,
632
-                style: block.style,
676
+                style: styleToSave,
633
                 word_style: block.word_style,
677
                 word_style: block.word_style,
634
                 metadata: block.metadata,
678
                 metadata: block.metadata,
635
               },
679
               },
@@ -1125,6 +1169,8 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1125
       }
1169
       }
1126
       
1170
       
1127
       await blockService.updateBlock(documentId, id, {
1171
       await blockService.updateBlock(documentId, id, {
1172
+        type: block.type,
1173
+        level: block.level,
1128
         content: contentToSave as any, // 类型断言:不同block类型的content类型不同
1174
         content: contentToSave as any, // 类型断言:不同block类型的content类型不同
1129
         style: block.style,
1175
         style: block.style,
1130
         word_style: block.word_style,
1176
         word_style: block.word_style,

+ 4 - 1
src/types/editor.ts

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