|
|
@@ -9,6 +9,7 @@
|
|
9
|
9
|
*/
|
|
10
|
10
|
|
|
11
|
11
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
|
12
|
+import { createPortal } from 'react-dom';
|
|
12
|
13
|
import { Tooltip, InputNumber, Popover, Dropdown } from 'antd';
|
|
13
|
14
|
import {
|
|
14
|
15
|
BoldOutlined,
|
|
|
@@ -27,6 +28,7 @@ import {
|
|
27
|
28
|
VerticalAlignBottomOutlined,
|
|
28
|
29
|
} from '@ant-design/icons';
|
|
29
|
30
|
import type { TableBlock, TableCell as TableCellType } from '../../../types/editor';
|
|
|
31
|
+import { getTableCellsForVisualBounds, getTableVisualCellPositions } from '../../../utils/blockOperations';
|
|
30
|
32
|
import './RichTextToolbar.css';
|
|
31
|
33
|
|
|
32
|
34
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
@@ -36,6 +38,12 @@ import './RichTextToolbar.css';
|
|
36
|
38
|
export interface RichTextToolbarProps {
|
|
37
|
39
|
/** 工具栏位置 */
|
|
38
|
40
|
position: { top: number; left: number };
|
|
|
41
|
+ /** 编辑器元素,用于计算脱离 contenteditable 后的固定定位 */
|
|
|
42
|
+ editorElement?: HTMLElement | null;
|
|
|
43
|
+ /** 收起状态下是否显示启动按钮 */
|
|
|
44
|
+ visible?: boolean;
|
|
|
45
|
+ /** 工具栏触发器悬停状态变化 */
|
|
|
46
|
+ onHoverChange?: (hovered: boolean) => void;
|
|
39
|
47
|
/** 关闭回调 */
|
|
40
|
48
|
onClose: () => void;
|
|
41
|
49
|
/** 格式变更回调 */
|
|
|
@@ -60,6 +68,12 @@ export interface RichTextToolbarProps {
|
|
60
|
68
|
endRow: number;
|
|
61
|
69
|
endCol: number;
|
|
62
|
70
|
} | null;
|
|
|
71
|
+ selectedVisualRange?: {
|
|
|
72
|
+ rowStart: number;
|
|
|
73
|
+ rowEnd: number;
|
|
|
74
|
+ colStart: number;
|
|
|
75
|
+ colEnd: number;
|
|
|
76
|
+ } | null;
|
|
63
|
77
|
onStyleChange: (styleUpdates: Partial<TableCellType['style']>) => void;
|
|
64
|
78
|
};
|
|
65
|
79
|
}
|
|
|
@@ -155,6 +169,21 @@ function restoreSavedOrCurrentSelection(saved: SavedRange | null, editor?: HTMLE
|
|
155
|
169
|
return !!selectedEditor && (!editor || selectedEditor === editor);
|
|
156
|
170
|
}
|
|
157
|
171
|
|
|
|
172
|
+function normalizeHexColor(color: string | undefined, fallback = '#000000'): string {
|
|
|
173
|
+ const normalized = (color || '').trim().replace(/^#/, '');
|
|
|
174
|
+ return /^[0-9a-f]{3,8}$/i.test(normalized) ? `#${normalized}` : fallback;
|
|
|
175
|
+}
|
|
|
176
|
+
|
|
|
177
|
+function getCommonCellStyleValue<Key extends keyof TableCellType['style']>(
|
|
|
178
|
+ cells: TableCellType[],
|
|
|
179
|
+ key: Key,
|
|
|
180
|
+): TableCellType['style'][Key] | undefined {
|
|
|
181
|
+ if (cells.length === 0) return undefined;
|
|
|
182
|
+
|
|
|
183
|
+ const firstValue = cells[0].style?.[key];
|
|
|
184
|
+ return cells.every((cell) => cell.style?.[key] === firstValue) ? firstValue : undefined;
|
|
|
185
|
+}
|
|
|
186
|
+
|
|
158
|
187
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
159
|
188
|
// Helper: 格式化应用
|
|
160
|
189
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
@@ -262,6 +291,9 @@ type ContentFormat = 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-
|
|
262
|
291
|
|
|
263
|
292
|
export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
264
|
293
|
position,
|
|
|
294
|
+ editorElement,
|
|
|
295
|
+ visible = false,
|
|
|
296
|
+ onHoverChange,
|
|
265
|
297
|
onClose,
|
|
266
|
298
|
onFormat,
|
|
267
|
299
|
currentContentFormat,
|
|
|
@@ -288,9 +320,31 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
288
|
320
|
// 判断是否处于表格编辑模式
|
|
289
|
321
|
const isTableMode = !!tableContext;
|
|
290
|
322
|
|
|
|
323
|
+ const selectedTableCells = isTableMode && tableContext
|
|
|
324
|
+ ? (() => {
|
|
|
325
|
+ const positions = getTableVisualCellPositions(tableContext.block);
|
|
|
326
|
+ const selectedPosition = positions.get(
|
|
|
327
|
+ `${tableContext.selectedCell.row}-${tableContext.selectedCell.col}`,
|
|
|
328
|
+ );
|
|
|
329
|
+ const bounds = tableContext.selectedVisualRange || selectedPosition;
|
|
|
330
|
+ return bounds ? getTableCellsForVisualBounds(tableContext.block, bounds) : [];
|
|
|
331
|
+ })()
|
|
|
332
|
+ : [];
|
|
|
333
|
+ const hasSelectedTableCells = selectedTableCells.length > 0;
|
|
|
334
|
+ const allSelectedCellsHave = (key: 'bold' | 'italic' | 'underline') => (
|
|
|
335
|
+ hasSelectedTableCells && selectedTableCells.every((cell) => cell.style?.[key] === true)
|
|
|
336
|
+ );
|
|
|
337
|
+ const allSelectedCellsHaveBold = allSelectedCellsHave('bold');
|
|
|
338
|
+ const allSelectedCellsHaveItalic = allSelectedCellsHave('italic');
|
|
|
339
|
+ const allSelectedCellsHaveUnderline = allSelectedCellsHave('underline');
|
|
|
340
|
+ const commonFontSize = getCommonCellStyleValue(selectedTableCells, 'font_size');
|
|
|
341
|
+ const commonColor = getCommonCellStyleValue(selectedTableCells, 'color');
|
|
|
342
|
+ const commonTextAlign = getCommonCellStyleValue(selectedTableCells, 'align');
|
|
|
343
|
+ const commonVerticalAlign = getCommonCellStyleValue(selectedTableCells, 'valign');
|
|
|
344
|
+
|
|
291
|
345
|
const getEditorElement = useCallback(() => (
|
|
292
|
|
- toolbarRef.current?.parentElement?.querySelector<HTMLElement>('.rich-text-editor') ?? null
|
|
293
|
|
- ), []);
|
|
|
346
|
+ editorElement ?? null
|
|
|
347
|
+ ), [editorElement]);
|
|
294
|
348
|
|
|
295
|
349
|
// 预设颜色
|
|
296
|
350
|
const presetColors = [
|
|
|
@@ -321,16 +375,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
321
|
375
|
|
|
322
|
376
|
if (isTableMode && tableContext) {
|
|
323
|
377
|
// 表格模式:加载当前单元格的样式
|
|
324
|
|
- const currentCell = tableContext.block.content.rows[tableContext.selectedCell.row]?.cells[tableContext.selectedCell.col];
|
|
325
|
|
- if (currentCell) {
|
|
326
|
|
- const style = currentCell.style || {};
|
|
327
|
|
- setFontSize(style.font_size || 12);
|
|
328
|
|
- setCurrentColor(style.color ? `#${style.color}` : '#000000');
|
|
329
|
|
- setTextAlign(style.align || 'center');
|
|
330
|
|
- setVerticalAlign(style.valign || 'middle');
|
|
331
|
|
- setIsBold(!!style.bold);
|
|
332
|
|
- setIsItalic(!!style.italic);
|
|
333
|
|
- setIsUnderline(!!style.underline);
|
|
|
378
|
+ if (hasSelectedTableCells) {
|
|
|
379
|
+ setFontSize(commonFontSize || 12);
|
|
|
380
|
+ setCurrentColor(normalizeHexColor(commonColor));
|
|
|
381
|
+ setTextAlign(commonTextAlign || 'center');
|
|
|
382
|
+ setVerticalAlign(commonVerticalAlign || 'middle');
|
|
|
383
|
+ setIsBold(allSelectedCellsHaveBold);
|
|
|
384
|
+ setIsItalic(allSelectedCellsHaveItalic);
|
|
|
385
|
+ setIsUnderline(allSelectedCellsHaveUnderline);
|
|
334
|
386
|
}
|
|
335
|
387
|
} else {
|
|
336
|
388
|
// 如果提供了 currentAlign,使用它
|
|
|
@@ -383,7 +435,20 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
383
|
435
|
return () => {
|
|
384
|
436
|
canceled = true;
|
|
385
|
437
|
};
|
|
386
|
|
- }, [getEditorElement, isTableMode, tableContext, currentAlign]);
|
|
|
438
|
+ }, [
|
|
|
439
|
+ getEditorElement,
|
|
|
440
|
+ isTableMode,
|
|
|
441
|
+ tableContext,
|
|
|
442
|
+ currentAlign,
|
|
|
443
|
+ hasSelectedTableCells,
|
|
|
444
|
+ commonFontSize,
|
|
|
445
|
+ commonColor,
|
|
|
446
|
+ commonTextAlign,
|
|
|
447
|
+ commonVerticalAlign,
|
|
|
448
|
+ allSelectedCellsHaveBold,
|
|
|
449
|
+ allSelectedCellsHaveItalic,
|
|
|
450
|
+ allSelectedCellsHaveUnderline,
|
|
|
451
|
+ ]);
|
|
387
|
452
|
|
|
388
|
453
|
useEffect(() => {
|
|
389
|
454
|
if (isTableMode) return;
|
|
|
@@ -407,37 +472,22 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
407
|
472
|
return () => document.removeEventListener('selectionchange', handleSelectionChange);
|
|
408
|
473
|
}, [getEditorElement, isTableMode]);
|
|
409
|
474
|
|
|
410
|
|
- // ── 点击外部关闭 ───────────────────────────────────────────────────────────
|
|
411
|
|
- useEffect(() => {
|
|
412
|
|
- const handleClickOutside = (e: MouseEvent) => {
|
|
413
|
|
- const target = e.target as Element;
|
|
414
|
|
- const isToolbarPopup = target.closest('.ant-select-dropdown, .ant-popover, .ant-dropdown');
|
|
415
|
|
-
|
|
416
|
|
- if (toolbarRef.current && !toolbarRef.current.contains(target) && !isToolbarPopup) {
|
|
417
|
|
- const editor = getEditorElement();
|
|
418
|
|
- if (!editor?.contains(target)) {
|
|
419
|
|
- setIsExpanded(false);
|
|
420
|
|
- onClose();
|
|
421
|
|
- }
|
|
422
|
|
- }
|
|
423
|
|
- };
|
|
424
|
|
-
|
|
425
|
|
- document.addEventListener('mousedown', handleClickOutside);
|
|
426
|
|
- return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
427
|
|
- }, [getEditorElement, onClose]);
|
|
428
|
|
-
|
|
429
|
475
|
// ── 切换格式 (Bold, Italic, Underline) ─────────────────────────────────────
|
|
430
|
476
|
const toggleFormat = useCallback((tagName: string) => {
|
|
431
|
477
|
if (isTableMode && tableContext) {
|
|
432
|
478
|
// 表格模式:切换单元格样式
|
|
433
|
479
|
const styleKey = tagName === 'strong' ? 'bold' : tagName === 'em' ? 'italic' : 'underline';
|
|
434
|
|
- const currentValue = styleKey === 'bold' ? isBold : styleKey === 'italic' ? isItalic : isUnderline;
|
|
|
480
|
+ const currentValue = styleKey === 'bold'
|
|
|
481
|
+ ? allSelectedCellsHaveBold
|
|
|
482
|
+ : styleKey === 'italic'
|
|
|
483
|
+ ? allSelectedCellsHaveItalic
|
|
|
484
|
+ : allSelectedCellsHaveUnderline;
|
|
435
|
485
|
|
|
436
|
486
|
tableContext.onStyleChange({ [styleKey]: !currentValue });
|
|
437
|
487
|
|
|
438
|
|
- if (styleKey === 'bold') setIsBold(!isBold);
|
|
439
|
|
- else if (styleKey === 'italic') setIsItalic(!isItalic);
|
|
440
|
|
- else setIsUnderline(!isUnderline);
|
|
|
488
|
+ if (styleKey === 'bold') setIsBold(!currentValue);
|
|
|
489
|
+ else if (styleKey === 'italic') setIsItalic(!currentValue);
|
|
|
490
|
+ else setIsUnderline(!currentValue);
|
|
441
|
491
|
|
|
442
|
492
|
return;
|
|
443
|
493
|
}
|
|
|
@@ -458,7 +508,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
458
|
508
|
|
|
459
|
509
|
savedRangeRef.current = saveSelection();
|
|
460
|
510
|
onFormat();
|
|
461
|
|
- }, [getEditorElement, isTableMode, tableContext, isBold, isItalic, isUnderline, onFormat]);
|
|
|
511
|
+ }, [getEditorElement, isTableMode, tableContext, allSelectedCellsHaveBold, allSelectedCellsHaveItalic, allSelectedCellsHaveUnderline, onFormat]);
|
|
462
|
512
|
|
|
463
|
513
|
// ── 处理字号变化 ───────────────────────────────────────────────────────────
|
|
464
|
514
|
const handleFontSizeChange = useCallback((value: number | null) => {
|
|
|
@@ -497,7 +547,10 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
497
|
547
|
|
|
498
|
548
|
if (isTableMode && tableContext) {
|
|
499
|
549
|
// 表格模式
|
|
500
|
|
- const hexColor = color.replace('#', '');
|
|
|
550
|
+ const hexColor = color.trim().replace(/^#/, '').toUpperCase();
|
|
|
551
|
+ if (!/^[0-9A-F]{3,8}$/.test(hexColor)) {
|
|
|
552
|
+ return;
|
|
|
553
|
+ }
|
|
501
|
554
|
tableContext.onStyleChange({ color: hexColor });
|
|
502
|
555
|
setTimeout(() => setColorPickerOpen(false), 100);
|
|
503
|
556
|
return;
|
|
|
@@ -627,7 +680,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
627
|
680
|
e.preventDefault();
|
|
628
|
681
|
};
|
|
629
|
682
|
|
|
630
|
|
- const handleLauncherClick = useCallback(() => {
|
|
|
683
|
+ const handleLauncherMouseDown = useCallback(() => {
|
|
631
|
684
|
if (!isTableMode) {
|
|
632
|
685
|
const editor = getEditorElement();
|
|
633
|
686
|
const selection = window.getSelection();
|
|
|
@@ -639,7 +692,6 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
639
|
692
|
savedRangeRef.current = saveSelection();
|
|
640
|
693
|
}
|
|
641
|
694
|
}
|
|
642
|
|
- setIsExpanded(true);
|
|
643
|
695
|
}, [getEditorElement, isTableMode]);
|
|
644
|
696
|
|
|
645
|
697
|
// ── 渲染 ───────────────────────────────────────────────────────────────────
|
|
|
@@ -677,20 +729,12 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
677
|
729
|
</div>
|
|
678
|
730
|
);
|
|
679
|
731
|
|
|
680
|
|
- return (
|
|
|
732
|
+ const expandedToolbar = (
|
|
681
|
733
|
<div
|
|
682
|
734
|
ref={toolbarRef}
|
|
683
|
|
- className={`rich-text-toolbar${isExpanded ? ' expanded' : ' collapsed'}`}
|
|
684
|
|
- style={{
|
|
685
|
|
- top: `${position.top + (isExpanded ? 34 : 0)}px`,
|
|
686
|
|
- left: `${position.left}px`,
|
|
687
|
|
- }}
|
|
|
735
|
+ className="rich-text-toolbar expanded"
|
|
688
|
736
|
onMouseDown={handleMouseDown}
|
|
689
|
737
|
>
|
|
690
|
|
- {!isExpanded ? (
|
|
691
|
|
- <ToolbarLauncher onClick={handleLauncherClick} />
|
|
692
|
|
- ) : (
|
|
693
|
|
- <>
|
|
694
|
738
|
{!isTableMode && (
|
|
695
|
739
|
<>
|
|
696
|
740
|
<div className="toolbar-row toolbar-row-primary toolbar-row-content">
|
|
|
@@ -738,7 +782,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
738
|
782
|
prefix={<FontSizeOutlined />}
|
|
739
|
783
|
/>
|
|
740
|
784
|
</Tooltip>
|
|
741
|
|
- <Popover open={colorPickerOpen} onOpenChange={setColorPickerOpen} content={colorContent} trigger="click" placement="bottom">
|
|
|
785
|
+ <Popover
|
|
|
786
|
+ open={colorPickerOpen}
|
|
|
787
|
+ onOpenChange={setColorPickerOpen}
|
|
|
788
|
+ content={colorContent}
|
|
|
789
|
+ trigger="click"
|
|
|
790
|
+ placement="bottom"
|
|
|
791
|
+ overlayClassName="rich-text-color-picker"
|
|
|
792
|
+ >
|
|
742
|
793
|
<button type="button" className="toolbar-icon-button" title="文字颜色">
|
|
743
|
794
|
<FontColorsOutlined style={{ color: currentColor }} />
|
|
744
|
795
|
</button>
|
|
|
@@ -753,7 +804,9 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
753
|
804
|
items: alignmentMenuItems,
|
|
754
|
805
|
selectedKeys: [textAlign],
|
|
755
|
806
|
}}
|
|
756
|
|
- trigger={['click']}
|
|
|
807
|
+ trigger={['hover']}
|
|
|
808
|
+ mouseEnterDelay={0}
|
|
|
809
|
+ mouseLeaveDelay={0.15}
|
|
757
|
810
|
placement="bottomLeft"
|
|
758
|
811
|
overlayClassName="rich-text-alignment-menu"
|
|
759
|
812
|
>
|
|
|
@@ -781,10 +834,40 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
|
|
781
|
834
|
<div className="toolbar-row toolbar-row-actions toolbar-row-bottom">
|
|
782
|
835
|
<ToolbarButton title="删除" onClick={handleDelete}><DeleteOutlined /></ToolbarButton>
|
|
783
|
836
|
</div>
|
|
784
|
|
- </>
|
|
785
|
|
- )}
|
|
786
|
837
|
</div>
|
|
787
|
838
|
);
|
|
|
839
|
+
|
|
|
840
|
+ const toolbar = (
|
|
|
841
|
+ <Dropdown
|
|
|
842
|
+ open={isExpanded}
|
|
|
843
|
+ onOpenChange={(open) => {
|
|
|
844
|
+ setIsExpanded(open);
|
|
|
845
|
+ if (!open) {
|
|
|
846
|
+ setColorPickerOpen(false);
|
|
|
847
|
+ onClose();
|
|
|
848
|
+ }
|
|
|
849
|
+ }}
|
|
|
850
|
+ trigger={['click']}
|
|
|
851
|
+ placement="bottomLeft"
|
|
|
852
|
+ overlayClassName="rich-text-toolbar-dropdown"
|
|
|
853
|
+ popupRender={() => expandedToolbar}
|
|
|
854
|
+ >
|
|
|
855
|
+ <div
|
|
|
856
|
+ className={`rich-text-toolbar collapsed${visible ? ' visible' : ''}`}
|
|
|
857
|
+ style={{
|
|
|
858
|
+ top: `${(editorElement?.getBoundingClientRect().top ?? 0) + position.top}px`,
|
|
|
859
|
+ left: `${(editorElement?.getBoundingClientRect().left ?? 0) + position.left}px`,
|
|
|
860
|
+ }}
|
|
|
861
|
+ onMouseDown={handleLauncherMouseDown}
|
|
|
862
|
+ onMouseEnter={() => onHoverChange?.(true)}
|
|
|
863
|
+ onMouseLeave={() => onHoverChange?.(false)}
|
|
|
864
|
+ >
|
|
|
865
|
+ <ToolbarLauncher expanded={isExpanded} />
|
|
|
866
|
+ </div>
|
|
|
867
|
+ </Dropdown>
|
|
|
868
|
+ );
|
|
|
869
|
+
|
|
|
870
|
+ return typeof document === 'undefined' ? toolbar : createPortal(toolbar, document.body);
|
|
788
|
871
|
};
|
|
789
|
872
|
|
|
790
|
873
|
interface ToolbarButtonProps {
|
|
|
@@ -813,13 +896,13 @@ function ToolbarButton({ title, active = false, disabled = false, onClick, child
|
|
813
|
896
|
);
|
|
814
|
897
|
}
|
|
815
|
898
|
|
|
816
|
|
-export function ToolbarLauncher({ onClick }: { onClick?: () => void }) {
|
|
|
899
|
+export function ToolbarLauncher({ onClick, expanded = false }: { onClick?: () => void; expanded?: boolean }) {
|
|
817
|
900
|
return (
|
|
818
|
901
|
<button
|
|
819
|
902
|
type="button"
|
|
820
|
903
|
className="toolbar-launch-button"
|
|
821
|
904
|
aria-label="打开格式工具栏"
|
|
822
|
|
- aria-expanded="false"
|
|
|
905
|
+ aria-expanded={expanded}
|
|
823
|
906
|
title="打开格式工具栏"
|
|
824
|
907
|
onClick={onClick}
|
|
825
|
908
|
>
|