Просмотр исходного кода

feat(编辑器): 优化富文本字号设置与占位符管理

- 优化字号检测逻辑,向上遍历 DOM 树查找第一个明确设置的 fontSize 元素
- 从 baseStyle 中提取默认字号作为后备值,支持 pt/px 单位转换
- 实现光标位置字号设置功能,插入占位符 span 确保用户输入时使用新字号
- 添加 handleInput 时的占位符清理逻辑,移除零宽空格并移除数据属性
- 完善 RichTextToolbar 组件的字号状态管理和选区状态处理
- 传递 baseStyle 属性到工具栏组件,提高字号设置的准确性和一致性
Zhang Yice 1 месяц назад
Родитель
Сommit
799c2e28d8

+ 14 - 0
src/components/Editor/RichTextEditor/RichTextEditor.tsx

@@ -259,6 +259,19 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
259
   const handleInput = useCallback(() => {
259
   const handleInput = useCallback(() => {
260
     if (!editorRef.current || !onChange || isComposingRef.current) return;
260
     if (!editorRef.current || !onChange || isComposingRef.current) return;
261
     
261
     
262
+    // 清理字号占位符(带有零宽空格的span)
263
+    const placeholders = editorRef.current.querySelectorAll('span[data-font-size-placeholder="true"]');
264
+    placeholders.forEach((placeholder) => {
265
+      if (placeholder.textContent && placeholder.textContent.length > 1) {
266
+        // 用户已经输入了文字,移除零宽空格
267
+        placeholder.textContent = placeholder.textContent.replace(/\u200B/g, '');
268
+        placeholder.removeAttribute('data-font-size-placeholder');
269
+      } else if (placeholder.textContent === '\u200B' && placeholder.previousSibling) {
270
+        // 如果占位符还是空的且有前一个节点,可能需要清理(用户删除了文字)
271
+        // 这里保留占位符,让用户继续输入
272
+      }
273
+    });
274
+    
262
     // 标记正在输入
275
     // 标记正在输入
263
     isFormattingRef.current = true;
276
     isFormattingRef.current = true;
264
 
277
 
@@ -455,6 +468,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
455
           onDelete={onDelete}
468
           onDelete={onDelete}
456
           onAlignChange={onAlignChange}
469
           onAlignChange={onAlignChange}
457
           currentAlign={currentAlign}
470
           currentAlign={currentAlign}
471
+          baseStyle={baseStyle}
458
           tableContext={tableContext}
472
           tableContext={tableContext}
459
         />
473
         />
460
       )}
474
       )}

+ 81 - 15
src/components/Editor/RichTextEditor/RichTextToolbar.tsx

@@ -58,6 +58,8 @@ export interface RichTextToolbarProps {
58
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
58
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
59
   /** 当前对齐方式(用于初始化) */
59
   /** 当前对齐方式(用于初始化) */
60
   currentAlign?: 'left' | 'center' | 'right' | 'justify';
60
   currentAlign?: 'left' | 'center' | 'right' | 'justify';
61
+  /** 基础样式(用于获取默认字号) */
62
+  baseStyle?: React.CSSProperties;
61
   /** 表格上下文(可选) - 如果提供,则显示表格样式控制 */
63
   /** 表格上下文(可选) - 如果提供,则显示表格样式控制 */
62
   tableContext?: {
64
   tableContext?: {
63
     block: TableBlock;
65
     block: TableBlock;
@@ -301,6 +303,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
301
   onDelete,
303
   onDelete,
302
   onAlignChange,
304
   onAlignChange,
303
   currentAlign,
305
   currentAlign,
306
+  baseStyle,
304
   tableContext,
307
   tableContext,
305
 }) => {
308
 }) => {
306
   const toolbarRef = useRef<HTMLDivElement>(null);
309
   const toolbarRef = useRef<HTMLDivElement>(null);
@@ -442,6 +445,21 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
442
         setTextAlign(currentAlign);
445
         setTextAlign(currentAlign);
443
       }
446
       }
444
       
447
       
448
+      // 从 baseStyle 提取默认字号(作为后备值)
449
+      let defaultFontSize = 12;
450
+      if (baseStyle?.fontSize) {
451
+        const baseFontSizeStr = String(baseStyle.fontSize);
452
+        const numericValue = parseFloat(baseFontSizeStr);
453
+        if (baseFontSizeStr.endsWith('pt')) {
454
+          defaultFontSize = Math.round(numericValue);
455
+        } else if (baseFontSizeStr.endsWith('px')) {
456
+          defaultFontSize = Math.round(numericValue * 0.75);
457
+        } else if (!isNaN(numericValue) && numericValue > 0) {
458
+          // 纯数字,假设是 pt
459
+          defaultFontSize = Math.round(numericValue);
460
+        }
461
+      }
462
+      
445
       const selection = window.getSelection();
463
       const selection = window.getSelection();
446
       if (selection && selection.rangeCount > 0) {
464
       if (selection && selection.rangeCount > 0) {
447
         const range = selection.getRangeAt(0);
465
         const range = selection.getRangeAt(0);
@@ -451,24 +469,37 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
451
           : container as HTMLElement;
469
           : container as HTMLElement;
452
         
470
         
453
         if (element) {
471
         if (element) {
454
-          const computedStyle = window.getComputedStyle(element);
472
+          // 检测是否有显式设置的字号(在 span 标签的 style 中)
473
+          let currentFontSize = defaultFontSize;
474
+          let currentElement: HTMLElement | null = element;
455
           
475
           
456
-          // 检测字号
457
-          const fontSizeStr = computedStyle.fontSize;
458
-          let currentFontSize = 12;
459
-          
460
-          if (fontSizeStr) {
461
-            if (fontSizeStr.endsWith('pt')) {
462
-              currentFontSize = Math.round(parseFloat(fontSizeStr));
463
-            } else if (fontSizeStr.endsWith('px')) {
464
-              currentFontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
476
+          // 向上遍历 DOM 树,查找第一个明确设置了 fontSize 的元素
477
+          while (currentElement && currentElement.classList?.contains('rich-text-editor') === false) {
478
+            if (currentElement.style.fontSize) {
479
+              const fontSizeStr = currentElement.style.fontSize;
480
+              const numericValue = parseFloat(fontSizeStr);
481
+              
482
+              if (fontSizeStr.endsWith('pt')) {
483
+                currentFontSize = Math.round(numericValue);
484
+                break;
485
+              } else if (fontSizeStr.endsWith('px')) {
486
+                // px 转 pt: 1pt = 4/3px, 所以 pt = px * 0.75
487
+                currentFontSize = Math.round(numericValue * 0.75);
488
+                break;
489
+              } else if (!isNaN(numericValue) && numericValue > 0) {
490
+                // 无单位,假设是 pt
491
+                currentFontSize = Math.round(numericValue);
492
+                break;
493
+              }
465
             }
494
             }
495
+            currentElement = currentElement.parentElement;
466
           }
496
           }
467
           
497
           
468
           setFontSize(currentFontSize);
498
           setFontSize(currentFontSize);
469
           
499
           
470
           // 检测对齐方式(如果没有提供 currentAlign)
500
           // 检测对齐方式(如果没有提供 currentAlign)
471
           if (!currentAlign) {
501
           if (!currentAlign) {
502
+            const computedStyle = window.getComputedStyle(element);
472
             const textAlignValue = computedStyle.textAlign;
503
             const textAlignValue = computedStyle.textAlign;
473
             if (textAlignValue === 'left' || textAlignValue === 'center' || 
504
             if (textAlignValue === 'left' || textAlignValue === 'center' || 
474
                 textAlignValue === 'right' || textAlignValue === 'justify') {
505
                 textAlignValue === 'right' || textAlignValue === 'justify') {
@@ -480,6 +511,9 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
480
             }
511
             }
481
           }
512
           }
482
         }
513
         }
514
+      } else {
515
+        // 没有选区时,使用默认字号
516
+        setFontSize(defaultFontSize);
483
       }
517
       }
484
     }
518
     }
485
     });
519
     });
@@ -492,6 +526,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
492
     isTableMode,
526
     isTableMode,
493
     tableContext,
527
     tableContext,
494
     currentAlign,
528
     currentAlign,
529
+    baseStyle,
495
     hasSelectedTableCells,
530
     hasSelectedTableCells,
496
     commonFontSize,
531
     commonFontSize,
497
     commonColor,
532
     commonColor,
@@ -583,14 +618,45 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
583
       return;
618
       return;
584
     }
619
     }
585
     
620
     
586
-    const applied = wrapSelectionWithTag('span', { fontSize: `${value}pt` });
587
-    if (!applied) {
621
+    const range = selection.getRangeAt(0);
622
+    
623
+    // 情况1:有选中的文本
624
+    if (!selection.isCollapsed) {
625
+      const applied = wrapSelectionWithTag('span', { fontSize: `${value}pt` });
626
+      if (!applied) {
627
+        return;
628
+      }
629
+      
630
+      setFontSize(value);
631
+      savedRangeRef.current = saveSelection();
632
+      onFormat();
588
       return;
633
       return;
589
     }
634
     }
590
     
635
     
591
-    setFontSize(value);
592
-    savedRangeRef.current = saveSelection();
593
-    onFormat();
636
+    // 情况2:没有选中文本(光标位置)
637
+    // 在光标位置创建一个带有新字号的空span,用户后续输入的文字将使用这个字号
638
+    const span = document.createElement('span');
639
+    span.style.fontSize = `${value}pt`;
640
+    span.setAttribute('data-font-size-placeholder', 'true');
641
+    // 插入一个零宽空格,确保光标可以定位到这个span中
642
+    span.textContent = '\u200B'; // 零宽空格
643
+    
644
+    try {
645
+      range.insertNode(span);
646
+      
647
+      // 将光标移动到span内部
648
+      const newRange = document.createRange();
649
+      newRange.setStart(span.firstChild!, 1);
650
+      newRange.collapse(true);
651
+      selection.removeAllRanges();
652
+      selection.addRange(newRange);
653
+      
654
+      setFontSize(value);
655
+      savedRangeRef.current = saveSelection();
656
+      onFormat();
657
+    } catch (error) {
658
+      console.error('插入字号样式失败:', error);
659
+    }
594
   }, [getEditorElement, isTableMode, tableContext, onFormat]);
660
   }, [getEditorElement, isTableMode, tableContext, onFormat]);
595
 
661
 
596
   // ── 处理颜色变化 ───────────────────────────────────────────────────────────
662
   // ── 处理颜色变化 ───────────────────────────────────────────────────────────

+ 33 - 11
src/utils/richTextConverter.ts

@@ -136,13 +136,16 @@ export function richTextToHtml(content: string | RichText[], baseStyle?: { fontS
136
         styles.push(`color: #${color}`);
136
         styles.push(`color: #${color}`);
137
       }
137
       }
138
       
138
       
139
-      // 字号:优先使用片段自己的字号,否则使用基础样式的字号
140
-      const finalFontSize = font_size !== undefined ? font_size : baseStyle?.fontSize;
141
-      if (finalFontSize) styles.push(`font-size: ${finalFontSize}pt`);
139
+      // 字号:只有当片段明确设置了字号时才添加样式,否则继承容器的字号
140
+      // 这样可以避免重复设置,保持 DOM 的简洁性
141
+      if (font_size !== undefined) {
142
+        styles.push(`font-size: ${font_size}pt`);
143
+      }
142
       
144
       
143
-      // 字体:优先使用片段自己的字体,否则使用基础样式的字体
144
-      const finalFontName = font_name || baseStyle?.fontFamily;
145
-      if (finalFontName) styles.push(`font-family: ${toCssString(finalFontName)}`);
145
+      // 字体:只有当片段明确设置了字体时才添加样式,否则继承容器的字体
146
+      if (font_name) {
147
+        styles.push(`font-family: ${toCssString(font_name)}`);
148
+      }
146
       
149
       
147
       // 包装HTML标签
150
       // 包装HTML标签
148
       if (bold) html = `<strong>${html}</strong>`;
151
       if (bold) html = `<strong>${html}</strong>`;
@@ -209,7 +212,7 @@ function extractStyleFromElement(element: HTMLElement | null): RichTextStyle {
209
         
212
         
210
         if (fontSizeStr.endsWith('pt')) {
213
         if (fontSizeStr.endsWith('pt')) {
211
           // pt单位直接解析
214
           // pt单位直接解析
212
-          fontSize = parseFloat(fontSizeStr);
215
+          fontSize = Math.round(parseFloat(fontSizeStr));
213
         } else if (fontSizeStr.endsWith('px')) {
216
         } else if (fontSizeStr.endsWith('px')) {
214
           // px转pt: 1pt = 4/3 px, 所以 px * 0.75 = pt
217
           // px转pt: 1pt = 4/3 px, 所以 px * 0.75 = pt
215
           fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
218
           fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
@@ -217,14 +220,29 @@ function extractStyleFromElement(element: HTMLElement | null): RichTextStyle {
217
           // em相对单位,假设基准是16px
220
           // em相对单位,假设基准是16px
218
           fontSize = Math.round(parseFloat(fontSizeStr) * 16 * 0.75);
221
           fontSize = Math.round(parseFloat(fontSizeStr) * 16 * 0.75);
219
         } else {
222
         } else {
220
-          // 无单位或其他单位,尝试直接解析为数字
221
-          fontSize = parseFloat(fontSizeStr);
223
+          // 无单位或其他单位,尝试直接解析为数字(假设是pt)
224
+          const numValue = parseFloat(fontSizeStr);
225
+          if (!isNaN(numValue) && numValue > 0) {
226
+            fontSize = Math.round(numValue);
227
+          }
222
         }
228
         }
223
         
229
         
224
         if (fontSize && !isNaN(fontSize) && fontSize > 0) {
230
         if (fontSize && !isNaN(fontSize) && fontSize > 0) {
225
           style.font_size = fontSize;
231
           style.font_size = fontSize;
226
         }
232
         }
227
       }
233
       }
234
+      
235
+      // 检查 font-family
236
+      if (el.style.fontFamily && !style.font_name) {
237
+        // 移除引号并取第一个字体名
238
+        const fontFamily = el.style.fontFamily
239
+          .split(',')[0]
240
+          .trim()
241
+          .replace(/^["']|["']$/g, '');
242
+        if (fontFamily) {
243
+          style.font_name = fontFamily;
244
+        }
245
+      }
228
     }
246
     }
229
     
247
     
230
     // 特殊处理:检查 FONT 标签(execCommand 可能会创建)
248
     // 特殊处理:检查 FONT 标签(execCommand 可能会创建)
@@ -257,11 +275,15 @@ function extractStyleFromElement(element: HTMLElement | null): RichTextStyle {
257
         let fontSize: number | undefined;
275
         let fontSize: number | undefined;
258
         
276
         
259
         if (fontSizeStr.endsWith('pt')) {
277
         if (fontSizeStr.endsWith('pt')) {
260
-          fontSize = parseFloat(fontSizeStr);
278
+          fontSize = Math.round(parseFloat(fontSizeStr));
261
         } else if (fontSizeStr.endsWith('px')) {
279
         } else if (fontSizeStr.endsWith('px')) {
262
           fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
280
           fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
263
         } else {
281
         } else {
264
-          fontSize = parseFloat(fontSizeStr);
282
+          // 无单位,假设是pt
283
+          const numValue = parseFloat(fontSizeStr);
284
+          if (!isNaN(numValue) && numValue > 0) {
285
+            fontSize = Math.round(numValue);
286
+          }
265
         }
287
         }
266
         
288
         
267
         if (fontSize && !isNaN(fontSize) && fontSize > 0) {
289
         if (fontSize && !isNaN(fontSize) && fontSize > 0) {

+ 2 - 0
src/utils/styleResolver.ts

@@ -193,11 +193,13 @@ export function resolveBlockStyle(
193
   
193
   
194
   // 4. 移除会影响富文本子元素的可继承属性
194
   // 4. 移除会影响富文本子元素的可继承属性
195
   // 这些属性应该由富文本内部的标签控制
195
   // 这些属性应该由富文本内部的标签控制
196
+  // 但保留 fontSize 和 fontFamily,因为它们需要作为 baseStyle 传递给 RichTextEditor
196
   const safeStyles = { ...merged };
197
   const safeStyles = { ...merged };
197
   delete safeStyles.color;
198
   delete safeStyles.color;
198
   delete safeStyles.fontWeight;
199
   delete safeStyles.fontWeight;
199
   delete safeStyles.fontStyle;
200
   delete safeStyles.fontStyle;
200
   delete safeStyles.textDecoration;
201
   delete safeStyles.textDecoration;
202
+  // 保留 fontSize 和 fontFamily
201
   
203
   
202
   return safeStyles;
204
   return safeStyles;
203
 }
205
 }