Kaynağa Gözat

feat(RichTextEditor): Add font size and color picker controls with selection management

- Add font size dropdown selector with 16 preset sizes (8pt to 72pt)
- Add color picker popover with 16 preset colors and custom color input
- Implement selection save/restore mechanism to preserve text selection across toolbar interactions
- Add format detection to display current font size and color of selected text
- Replace native execCommand with custom span-based formatting for better control
- Add helper functions (isActive, getContainerElement) for format state detection
- Update toolbar styling for Ant Design Select and button alignment
- Add BgColorsOutlined icon and additional React hooks (useState, useCallback)
- Improve selection management with cloned Range objects for reliability
Zhang Yice 1 ay önce
ebeveyn
işleme
042a9776f4

+ 7 - 16
src/components/Editor/RichTextEditor/RichTextToolbar.css

@@ -24,23 +24,14 @@
24 24
   }
25 25
 }
26 26
 
27
-/* 颜色选择器 */
28
-.rich-text-toolbar .color-picker {
29
-  width: 24px;
30
-  height: 24px;
31
-  border: none;
32
-  border-radius: 2px;
33
-  cursor: pointer;
34
-  outline: none;
35
-  padding: 0;
36
-  background: transparent;
27
+/* 字号选择器样式 */
28
+.rich-text-toolbar .ant-select-selector {
29
+  border-radius: 2px !important;
37 30
 }
38 31
 
39
-.rich-text-toolbar .color-picker::-webkit-color-swatch-wrapper {
40
-  padding: 0;
32
+/* 确保按钮和选择器对齐 */
33
+.rich-text-toolbar .ant-select,
34
+.rich-text-toolbar .ant-btn {
35
+  vertical-align: middle;
41 36
 }
42 37
 
43
-.rich-text-toolbar .color-picker::-webkit-color-swatch {
44
-  border: 1px solid #d9d9d9;
45
-  border-radius: 2px;
46
-}

+ 405 - 31
src/components/Editor/RichTextEditor/RichTextToolbar.tsx

@@ -6,13 +6,14 @@
6 6
  * @module components/Editor/RichTextEditor
7 7
  */
8 8
 
9
-import React, { useEffect, useRef } from 'react';
10
-import { Button, Tooltip, Space, Divider } from 'antd';
9
+import React, { useEffect, useRef, useState, useCallback } from 'react';
10
+import { Button, Tooltip, Space, Divider, Select, Popover } from 'antd';
11 11
 import {
12 12
   BoldOutlined,
13 13
   ItalicOutlined,
14 14
   UnderlineOutlined,
15 15
   FontSizeOutlined,
16
+  BgColorsOutlined,
16 17
 } from '@ant-design/icons';
17 18
 import './RichTextToolbar.css';
18 19
 
@@ -44,6 +45,139 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
44 45
   onFormat,
45 46
 }) => {
46 47
   const toolbarRef = useRef<HTMLDivElement>(null);
48
+  const [currentFontSize, setCurrentFontSize] = useState<string>('12');
49
+  const [currentColor, setCurrentColor] = useState<string>('#000000');
50
+  const [colorPickerOpen, setColorPickerOpen] = useState(false);
51
+  
52
+  // 保存选区的引用(使用更可靠的方式)
53
+  const savedSelectionRef = useRef<{
54
+    range: Range;
55
+    text: string;
56
+  } | null>(null);
57
+
58
+  // ── 保存选区 ───────────────────────────────────────────────────────────────
59
+  const saveSelection = useCallback(() => {
60
+    const selection = window.getSelection();
61
+    if (selection && selection.rangeCount > 0) {
62
+      const range = selection.getRangeAt(0);
63
+      savedSelectionRef.current = {
64
+        range: range.cloneRange(),
65
+        text: range.toString()
66
+      };
67
+    }
68
+  }, []);
69
+
70
+  // ── 恢复选区 ───────────────────────────────────────────────────────────────
71
+  const restoreSelection = useCallback(() => {
72
+    if (savedSelectionRef.current) {
73
+      const selection = window.getSelection();
74
+      if (selection) {
75
+        try {
76
+          selection.removeAllRanges();
77
+          selection.addRange(savedSelectionRef.current.range);
78
+        } catch (error) {
79
+          console.error('Failed to restore selection:', error);
80
+        }
81
+      }
82
+    }
83
+  }, []);
84
+
85
+  // ── 检测当前选区的格式 ─────────────────────────────────────────────────────
86
+  useEffect(() => {
87
+    const updateCurrentFormat = () => {
88
+      const selection = window.getSelection();
89
+      if (!selection || selection.rangeCount === 0) return;
90
+      
91
+      // 保存选区
92
+      console.log('[updateCurrentFormat] Saving selection');
93
+      saveSelection();
94
+      
95
+      const range = selection.getRangeAt(0);
96
+      const container = range.commonAncestorContainer;
97
+      const parentElement = container.nodeType === Node.TEXT_NODE 
98
+        ? container.parentElement 
99
+        : container as HTMLElement;
100
+      
101
+      if (parentElement) {
102
+        // 检测字号
103
+        const computedStyle = window.getComputedStyle(parentElement);
104
+        if (computedStyle.fontSize) {
105
+          const fontSizeStr = computedStyle.fontSize;
106
+          let fontSize: number;
107
+          
108
+          if (fontSizeStr.endsWith('pt')) {
109
+            fontSize = Math.round(parseFloat(fontSizeStr));
110
+          } else if (fontSizeStr.endsWith('px')) {
111
+            fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
112
+          } else {
113
+            fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
114
+          }
115
+          
116
+          if (fontSize && !isNaN(fontSize)) {
117
+            setCurrentFontSize(String(fontSize));
118
+          }
119
+        }
120
+        
121
+        // 检测颜色
122
+        if (computedStyle.color) {
123
+          const rgb = computedStyle.color;
124
+          // 将 rgb(r, g, b) 转换为十六进制
125
+          const match = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
126
+          if (match) {
127
+            const r = parseInt(match[1]).toString(16).padStart(2, '0');
128
+            const g = parseInt(match[2]).toString(16).padStart(2, '0');
129
+            const b = parseInt(match[3]).toString(16).padStart(2, '0');
130
+            const hexColor = `#${r}${g}${b}`;
131
+            console.log('[updateCurrentFormat] Detected color:', hexColor);
132
+            setCurrentColor(hexColor);
133
+          }
134
+        }
135
+      }
136
+    };
137
+    
138
+    // 工具栏显示时更新格式
139
+    updateCurrentFormat();
140
+  }, [position, saveSelection]); // position 变化表示工具栏重新显示
141
+
142
+  // 字号选项
143
+  const fontSizeOptions = [
144
+    { label: '8', value: '8' },
145
+    { label: '9', value: '9' },
146
+    { label: '10', value: '10' },
147
+    { label: '11', value: '11' },
148
+    { label: '12', value: '12' },
149
+    { label: '14', value: '14' },
150
+    { label: '16', value: '16' },
151
+    { label: '18', value: '18' },
152
+    { label: '20', value: '20' },
153
+    { label: '22', value: '22' },
154
+    { label: '24', value: '24' },
155
+    { label: '26', value: '26' },
156
+    { label: '28', value: '28' },
157
+    { label: '36', value: '36' },
158
+    { label: '48', value: '48' },
159
+    { label: '72', value: '72' },
160
+  ];
161
+
162
+  // 预设颜色
163
+  const presetColors = [
164
+    '#000000', // 黑色
165
+    '#FFFFFF', // 白色
166
+    '#FF0000', // 红色
167
+    '#00FF00', // 绿色
168
+    '#0000FF', // 蓝色
169
+    '#FFFF00', // 黄色
170
+    '#FF00FF', // 洋红
171
+    '#00FFFF', // 青色
172
+    '#808080', // 灰色
173
+    '#800000', // 栗色
174
+    '#008000', // 深绿
175
+    '#000080', // 海军蓝
176
+    '#808000', // 橄榄色
177
+    '#800080', // 紫色
178
+    '#008080', // 青色(暗)
179
+    '#C0C0C0', // 银色
180
+  ];
47 181
 
48 182
   // ── 点击外部关闭 ───────────────────────────────────────────────────────────
49 183
   useEffect(() => {
@@ -63,14 +197,181 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
63 197
 
64 198
   // ── 格式化命令 ─────────────────────────────────────────────────────────────
65 199
   const execCommand = (command: string, value?: string) => {
66
-    document.execCommand(command, false, value);
200
+    const selection = window.getSelection();
201
+    if (!selection || selection.rangeCount === 0) return;
202
+    
203
+    const range = selection.getRangeAt(0);
204
+    if (range.collapsed) return;
205
+    
206
+    // 创建一个包装元素
207
+    const span = document.createElement('span');
208
+    
209
+    // 根据命令类型应用样式
210
+    switch (command) {
211
+      case 'bold':
212
+        span.style.fontWeight = isActive('bold') ? 'normal' : 'bold';
213
+        break;
214
+      case 'italic':
215
+        span.style.fontStyle = isActive('italic') ? 'normal' : 'italic';
216
+        break;
217
+      case 'underline':
218
+        span.style.textDecoration = isActive('underline') ? 'none' : 'underline';
219
+        break;
220
+      case 'foreColor':
221
+        if (value) span.style.color = value;
222
+        break;
223
+      default:
224
+        return;
225
+    }
226
+    
227
+    // 提取内容并包装
228
+    const contents = range.extractContents();
229
+    span.appendChild(contents);
230
+    range.insertNode(span);
231
+    
232
+    // 重新选中
233
+    const newRange = document.createRange();
234
+    newRange.selectNodeContents(span);
235
+    selection.removeAllRanges();
236
+    selection.addRange(newRange);
237
+    
238
+    onFormat();
239
+  };
240
+
241
+  // ── 设置字号 ───────────────────────────────────────────────────────────────
242
+  const handleFontSizeChange = (size: string) => {
243
+    setCurrentFontSize(size);
244
+    
245
+    // 恢复选区
246
+    restoreSelection();
247
+    
248
+    const selection = window.getSelection();
249
+    if (!selection || selection.rangeCount === 0) return;
250
+    
251
+    const range = selection.getRangeAt(0);
252
+    if (range.collapsed) return;
253
+    
254
+    // 创建一个 span 元素来包装选中的文本
255
+    const span = document.createElement('span');
256
+    span.style.fontSize = `${size}pt`;
257
+    
258
+    // 提取选中的内容
259
+    const contents = range.extractContents();
260
+    span.appendChild(contents);
261
+    
262
+    // 插入新的 span
263
+    range.insertNode(span);
264
+    
265
+    // 重新选中内容
266
+    const newRange = document.createRange();
267
+    newRange.selectNodeContents(span);
268
+    selection.removeAllRanges();
269
+    selection.addRange(newRange);
270
+    
67 271
     onFormat();
68 272
   };
69 273
 
274
+  // ── 设置颜色(简化版,直接操作 DOM)───────────────────────────────────────
275
+  const handleColorChange = (color: string) => {
276
+    console.log('[handleColorChange] Start - color:', color);
277
+    setCurrentColor(color);
278
+    
279
+    // 获取编辑器元素
280
+    const editor = document.querySelector('.rich-text-editor') as HTMLElement;
281
+    if (!editor) {
282
+      console.error('[handleColorChange] Editor element not found');
283
+      return;
284
+    }
285
+    
286
+    // 确保编辑器有焦点
287
+    if (document.activeElement !== editor) {
288
+      console.log('[handleColorChange] Editor not focused, focusing...');
289
+      editor.focus();
290
+    }
291
+    
292
+    // 恢复保存的选区
293
+    restoreSelection();
294
+    
295
+    // 再次确认选区
296
+    const selection = window.getSelection();
297
+    if (!selection) {
298
+      console.error('[handleColorChange] No selection object');
299
+      return;
300
+    }
301
+    
302
+    if (selection.rangeCount === 0) {
303
+      console.error('[handleColorChange] No range in selection');
304
+      return;
305
+    }
306
+    
307
+    const range = selection.getRangeAt(0);
308
+    if (range.collapsed) {
309
+      console.error('[handleColorChange] Selection is collapsed');
310
+      return;
311
+    }
312
+    
313
+    console.log('[handleColorChange] Selection valid, applying color...');
314
+    console.log('[handleColorChange] Selected text:', range.toString());
315
+    
316
+    // 使用现代方法直接操作 DOM
317
+    try {
318
+      const span = document.createElement('span');
319
+      span.style.color = color;
320
+      
321
+      const contents = range.extractContents();
322
+      span.appendChild(contents);
323
+      range.insertNode(span);
324
+      
325
+      // 重新选中
326
+      const newRange = document.createRange();
327
+      newRange.selectNodeContents(span);
328
+      selection.removeAllRanges();
329
+      selection.addRange(newRange);
330
+      
331
+      console.log('[handleColorChange] Color applied successfully');
332
+    } catch (error) {
333
+      console.error('[handleColorChange] Failed to apply color:', error);
334
+      return;
335
+    }
336
+    
337
+    // 触发 input 事件
338
+    console.log('[handleColorChange] Triggering input event');
339
+    const inputEvent = new Event('input', { bubbles: true, cancelable: false });
340
+    editor.dispatchEvent(inputEvent);
341
+    
342
+    // 调用 onFormat
343
+    console.log('[handleColorChange] Calling onFormat');
344
+    onFormat();
345
+    
346
+    console.log('[handleColorChange] Complete');
347
+  };
348
+
70 349
   // ── 检查当前格式状态 ───────────────────────────────────────────────────────
71 350
   const isActive = (command: string): boolean => {
72 351
     try {
73
-      return document.queryCommandState(command);
352
+      const selection = window.getSelection();
353
+      if (!selection || selection.rangeCount === 0) return false;
354
+      
355
+      const range = selection.getRangeAt(0);
356
+      const container = range.commonAncestorContainer;
357
+      const parentElement = container.nodeType === Node.TEXT_NODE 
358
+        ? container.parentElement 
359
+        : container as HTMLElement;
360
+      
361
+      if (!parentElement) return false;
362
+      
363
+      const computedStyle = window.getComputedStyle(parentElement);
364
+      
365
+      switch (command) {
366
+        case 'bold':
367
+          return parseInt(computedStyle.fontWeight) >= 600 || computedStyle.fontWeight === 'bold';
368
+        case 'italic':
369
+          return computedStyle.fontStyle === 'italic';
370
+        case 'underline':
371
+          return computedStyle.textDecoration.includes('underline');
372
+        default:
373
+          return false;
374
+      }
74 375
     } catch {
75 376
       return false;
76 377
     }
@@ -121,39 +422,112 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
121 422
 
122 423
         <Divider type="vertical" style={{ margin: '0 4px' }} />
123 424
 
124
-        {/* 字号 */}
425
+        {/* 字号选择器 */}
125 426
         <Tooltip title="字号">
126
-          <Button
127
-            type="text"
427
+          <Select
428
+            value={currentFontSize}
128 429
             size="small"
129
-            icon={<FontSizeOutlined />}
130
-            onClick={() => {
131
-              const size = prompt('输入字号(磅):', '12');
132
-              if (size) {
133
-                execCommand('fontSize', '7');
134
-                // 查找刚创建的font标签并修改
135
-                const selection = window.getSelection();
136
-                if (selection && selection.anchorNode) {
137
-                  const parent = selection.anchorNode.parentElement;
138
-                  if (parent?.tagName === 'FONT') {
139
-                    parent.removeAttribute('size');
140
-                    parent.style.fontSize = `${size}pt`;
141
-                  }
142
-                }
143
-                onFormat();
144
-              }
145
-            }}
430
+            style={{ width: 65 }}
431
+            onChange={handleFontSizeChange}
432
+            options={fontSizeOptions}
433
+            suffixIcon={<FontSizeOutlined />}
146 434
           />
147 435
         </Tooltip>
148 436
 
149
-        {/* 颜色 */}
437
+        {/* 颜色选择器 */}
150 438
         <Tooltip title="文字颜色">
151
-          <input
152
-            type="color"
153
-            className="color-picker"
154
-            onChange={(e) => execCommand('foreColor', e.target.value)}
155
-            title="文字颜色"
156
-          />
439
+          <Popover
440
+            open={colorPickerOpen}
441
+            onOpenChange={(open) => {
442
+              if (open) {
443
+                // 打开时保存选区
444
+                saveSelection();
445
+              }
446
+              setColorPickerOpen(open);
447
+            }}
448
+            content={
449
+              <div 
450
+                style={{ width: 200 }}
451
+                onMouseDown={(e) => {
452
+                  // 阻止 mousedown 事件,防止失去焦点
453
+                  e.preventDefault();
454
+                  e.stopPropagation();
455
+                }}
456
+                onClick={(e) => {
457
+                  // 阻止 click 事件冒泡
458
+                  e.stopPropagation();
459
+                }}
460
+              >
461
+                <div style={{ marginBottom: 8 }}>
462
+                  <div style={{ 
463
+                    display: 'grid', 
464
+                    gridTemplateColumns: 'repeat(8, 1fr)', 
465
+                    gap: 4 
466
+                  }}>
467
+                    {presetColors.map((color) => (
468
+                      <div
469
+                        key={color}
470
+                        onMouseDown={(e) => {
471
+                          // 阻止失去焦点
472
+                          e.preventDefault();
473
+                          e.stopPropagation();
474
+                        }}
475
+                        onClick={(e) => {
476
+                          e.preventDefault();
477
+                          e.stopPropagation();
478
+                          console.log('Color clicked:', color);
479
+                          handleColorChange(color);
480
+                          // 延迟关闭,确保颜色应用完成
481
+                          setTimeout(() => setColorPickerOpen(false), 100);
482
+                        }}
483
+                        style={{
484
+                          width: 20,
485
+                          height: 20,
486
+                          backgroundColor: color,
487
+                          border: color === '#FFFFFF' ? '1px solid #d9d9d9' : 'none',
488
+                          borderRadius: 2,
489
+                          cursor: 'pointer',
490
+                          boxShadow: currentColor === color ? '0 0 0 2px #1890ff' : 'none',
491
+                        }}
492
+                        title={color}
493
+                      />
494
+                    ))}
495
+                  </div>
496
+                </div>
497
+                <div style={{ marginTop: 8 }}>
498
+                  <input
499
+                    type="color"
500
+                    value={currentColor}
501
+                    onChange={(e) => {
502
+                      e.preventDefault();
503
+                      e.stopPropagation();
504
+                      console.log('Color input changed:', e.target.value);
505
+                      handleColorChange(e.target.value);
506
+                    }}
507
+                    onMouseDown={(e) => {
508
+                      e.stopPropagation();
509
+                    }}
510
+                    onClick={(e) => {
511
+                      e.stopPropagation();
512
+                    }}
513
+                    style={{ width: '100%', height: 32, cursor: 'pointer' }}
514
+                  />
515
+                </div>
516
+              </div>
517
+            }
518
+            trigger="click"
519
+            placement="bottom"
520
+          >
521
+            <Button
522
+              type="text"
523
+              size="small"
524
+              icon={<BgColorsOutlined style={{ color: currentColor }} />}
525
+              onMouseDown={(e) => {
526
+                // 打开颜色选择器前保存选区
527
+                saveSelection();
528
+              }}
529
+            />
530
+          </Popover>
157 531
         </Tooltip>
158 532
       </Space>
159 533
     </div>

+ 88 - 7
src/utils/richTextConverter.ts

@@ -61,7 +61,7 @@ export function rgbToHex(rgb: string): string {
61 61
   const b = parseInt(match[3], 10);
62 62
   
63 63
   const toHex = (n: number) => {
64
-    const hex = n.toString(16);
64
+    const hex = n.toString(16).toUpperCase();  // 统一转换为大写
65 65
     return hex.length === 1 ? '0' + hex : hex;
66 66
   };
67 67
   
@@ -160,17 +160,98 @@ function extractStyleFromElement(element: HTMLElement | null): RichTextStyle {
160 160
     
161 161
     // 检查内联样式
162 162
     if (el.style) {
163
-      if (el.style.color) {
164
-        const color = rgbToHex(el.style.color);
165
-        if (color !== '000000') style.color = color;
163
+      // 颜色提取(改进版)
164
+      if (el.style.color && !style.color) {
165
+        // 检查是否是 rgb 格式
166
+        if (el.style.color.startsWith('rgb')) {
167
+          const hexColor = rgbToHex(el.style.color);
168
+          // 存储颜色,不排除黑色(因为黑色也可能是有意设置的)
169
+          style.color = hexColor;
170
+        } else if (el.style.color.startsWith('#')) {
171
+          // 十六进制格式,移除 # 号
172
+          style.color = el.style.color.substring(1).toUpperCase();
173
+        } else {
174
+          // 其他格式(如颜色名称),尝试转换
175
+          const tempDiv = document.createElement('div');
176
+          tempDiv.style.color = el.style.color;
177
+          document.body.appendChild(tempDiv);
178
+          const computed = window.getComputedStyle(tempDiv).color;
179
+          document.body.removeChild(tempDiv);
180
+          if (computed) {
181
+            style.color = rgbToHex(computed);
182
+          }
183
+        }
166 184
       }
185
+      
167 186
       if (el.style.fontFamily) {
168 187
         style.font_name = el.style.fontFamily.replace(/["']/g, '');
169 188
       }
189
+      
170 190
       if (el.style.fontSize) {
171
-        // 转换为磅(pt)
172
-        const pxSize = parseFloat(el.style.fontSize);
173
-        style.font_size = Math.round(pxSize * 0.75);
191
+        // 解析字号,支持 pt、px、em 等单位
192
+        const fontSizeStr = el.style.fontSize;
193
+        let fontSize: number | undefined;
194
+        
195
+        if (fontSizeStr.endsWith('pt')) {
196
+          // pt单位直接解析
197
+          fontSize = parseFloat(fontSizeStr);
198
+        } else if (fontSizeStr.endsWith('px')) {
199
+          // px转pt: 1pt = 4/3 px, 所以 px * 0.75 = pt
200
+          fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
201
+        } else if (fontSizeStr.endsWith('em')) {
202
+          // em相对单位,假设基准是16px
203
+          fontSize = Math.round(parseFloat(fontSizeStr) * 16 * 0.75);
204
+        } else {
205
+          // 无单位或其他单位,尝试直接解析为数字
206
+          fontSize = parseFloat(fontSizeStr);
207
+        }
208
+        
209
+        if (fontSize && !isNaN(fontSize) && fontSize > 0) {
210
+          style.font_size = fontSize;
211
+        }
212
+      }
213
+    }
214
+    
215
+    // 特殊处理:检查 FONT 标签(execCommand 可能会创建)
216
+    if (tagName === 'FONT') {
217
+      const fontEl = el as HTMLFontElement;
218
+      
219
+      // 检查 color 属性(FONT 标签的 color 属性)
220
+      if (fontEl.color && !style.color) {
221
+        // color 属性可能是 #RRGGBB 格式或颜色名称
222
+        if (fontEl.color.startsWith('#')) {
223
+          style.color = fontEl.color.substring(1).toUpperCase();
224
+        } else if (fontEl.color.startsWith('rgb')) {
225
+          style.color = rgbToHex(fontEl.color);
226
+        } else {
227
+          // 颜色名称,需要转换
228
+          const tempDiv = document.createElement('div');
229
+          tempDiv.style.color = fontEl.color;
230
+          document.body.appendChild(tempDiv);
231
+          const computed = window.getComputedStyle(tempDiv).color;
232
+          document.body.removeChild(tempDiv);
233
+          if (computed) {
234
+            style.color = rgbToHex(computed);
235
+          }
236
+        }
237
+      }
238
+      
239
+      // 检查 style.fontSize(优先)
240
+      if (fontEl.style.fontSize && !style.font_size) {
241
+        const fontSizeStr = fontEl.style.fontSize;
242
+        let fontSize: number | undefined;
243
+        
244
+        if (fontSizeStr.endsWith('pt')) {
245
+          fontSize = parseFloat(fontSizeStr);
246
+        } else if (fontSizeStr.endsWith('px')) {
247
+          fontSize = Math.round(parseFloat(fontSizeStr) * 0.75);
248
+        } else {
249
+          fontSize = parseFloat(fontSizeStr);
250
+        }
251
+        
252
+        if (fontSize && !isNaN(fontSize) && fontSize > 0) {
253
+          style.font_size = fontSize;
254
+        }
174 255
       }
175 256
     }
176 257