Procházet zdrojové kódy

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

- 优化字号检测逻辑,向上遍历 DOM 树查找第一个明确设置的 fontSize 元素
- 从 baseStyle 中提取默认字号作为后备值,支持 pt/px 单位转换
- 实现光标位置字号设置功能,插入占位符 span 确保用户输入时使用新字号
- 添加 handleInput 时的占位符清理逻辑,移除零宽空格并移除数据属性
- 完善 RichTextToolbar 组件的字号状态管理和选区状态处理
- 传递 baseStyle 属性到工具栏组件,提高字号设置的准确性和一致性
Zhang Yice před 1 měsícem
rodič
revize
799c2e28d8

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

@@ -259,6 +259,19 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
259 259
   const handleInput = useCallback(() => {
260 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 276
     isFormattingRef.current = true;
264 277
 
@@ -455,6 +468,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
455 468
           onDelete={onDelete}
456 469
           onAlignChange={onAlignChange}
457 470
           currentAlign={currentAlign}
471
+          baseStyle={baseStyle}
458 472
           tableContext={tableContext}
459 473
         />
460 474
       )}

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

@@ -58,6 +58,8 @@ export interface RichTextToolbarProps {
58 58
   onAlignChange?: (align: 'left' | 'center' | 'right' | 'justify') => void;
59 59
   /** 当前对齐方式(用于初始化) */
60 60
   currentAlign?: 'left' | 'center' | 'right' | 'justify';
61
+  /** 基础样式(用于获取默认字号) */
62
+  baseStyle?: React.CSSProperties;
61 63
   /** 表格上下文(可选) - 如果提供,则显示表格样式控制 */
62 64
   tableContext?: {
63 65
     block: TableBlock;
@@ -301,6 +303,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
301 303
   onDelete,
302 304
   onAlignChange,
303 305
   currentAlign,
306
+  baseStyle,
304 307
   tableContext,
305 308
 }) => {
306 309
   const toolbarRef = useRef<HTMLDivElement>(null);
@@ -442,6 +445,21 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
442 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 463
       const selection = window.getSelection();
446 464
       if (selection && selection.rangeCount > 0) {
447 465
         const range = selection.getRangeAt(0);
@@ -451,24 +469,37 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
451 469
           : container as HTMLElement;
452 470
         
453 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 498
           setFontSize(currentFontSize);
469 499
           
470 500
           // 检测对齐方式(如果没有提供 currentAlign)
471 501
           if (!currentAlign) {
502
+            const computedStyle = window.getComputedStyle(element);
472 503
             const textAlignValue = computedStyle.textAlign;
473 504
             if (textAlignValue === 'left' || textAlignValue === 'center' || 
474 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 526
     isTableMode,
493 527
     tableContext,
494 528
     currentAlign,
529
+    baseStyle,
495 530
     hasSelectedTableCells,
496 531
     commonFontSize,
497 532
     commonColor,
@@ -583,14 +618,45 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
583 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 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 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 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 150
       // 包装HTML标签
148 151
       if (bold) html = `<strong>${html}</strong>`;
@@ -209,7 +212,7 @@ function extractStyleFromElement(element: HTMLElement | null): RichTextStyle {
209 212
         
210 213
         if (fontSizeStr.endsWith('pt')) {
211 214
           // pt单位直接解析
212
-          fontSize = parseFloat(fontSizeStr);
215
+          fontSize = Math.round(parseFloat(fontSizeStr));
213 216
         } else if (fontSizeStr.endsWith('px')) {
214 217
           // px转pt: 1pt = 4/3 px, 所以 px * 0.75 = pt
215 218
           fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
@@ -217,14 +220,29 @@ function extractStyleFromElement(element: HTMLElement | null): RichTextStyle {
217 220
           // em相对单位,假设基准是16px
218 221
           fontSize = Math.round(parseFloat(fontSizeStr) * 16 * 0.75);
219 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 230
         if (fontSize && !isNaN(fontSize) && fontSize > 0) {
225 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 248
     // 特殊处理:检查 FONT 标签(execCommand 可能会创建)
@@ -257,11 +275,15 @@ function extractStyleFromElement(element: HTMLElement | null): RichTextStyle {
257 275
         let fontSize: number | undefined;
258 276
         
259 277
         if (fontSizeStr.endsWith('pt')) {
260
-          fontSize = parseFloat(fontSizeStr);
278
+          fontSize = Math.round(parseFloat(fontSizeStr));
261 279
         } else if (fontSizeStr.endsWith('px')) {
262 280
           fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
263 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 289
         if (fontSize && !isNaN(fontSize) && fontSize > 0) {

+ 2 - 0
src/utils/styleResolver.ts

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