如何实现划词效果

最近做了一个划词的需求,想和大家分享一下。划词在富文本编辑器里比较常用,效果图如下:

本篇文章将一步一步带大家实现这个效果。

实现字数统计

前置知识

window.getSelection

window.getSelection 可获取选中文本信息和选择内容,示例代码如下:

js 复制代码
document.addEventListener("mouseup", function () {
  const selection = window.getSelection();
  if (selection) {
    console.log('selection', selection)
    const selectedText = selection.toString();
    console.log(selectedText);
  }
});

执行结果截图:

实现输入框

主组件 TextCustom,包括两个组件:输入框和浮动工具栏。 这里的输入框是通过 div 元素模拟的,实现起来也很简单,只需要为 div 添加 contentEditable 属性 。

js 复制代码
const TextCustom = () => {
  return (
    <div>
      {/* 模拟输入框 */}
      <div
        id="editor" // 后面会用
        contentEditable={true}
        suppressContentEditableWarning={true} // suppressContentEditableWarning 是 React 框架中的一个特殊属性,用于‌抑制‌contentEditable 元素触发的常见警告
      />
    </div>
  );
};

实现浮动工具栏组件

浮动工具栏组件实现

浮动工具栏组件是通过固定定位实现的,工具栏的位置是跟随选中文本的位置改变而改变的,主要属性是 topleft,默认值都先设置成 0。

浮动工具栏组件的内容是选中文本的字数统计,先硬编码成"x 字"。

js 复制代码
const FloatingToolbar = () => {
  return (
    <div
      style={{
        position: "fixed",
        top: 0, // 在选中文本上方显示
        left: 0, // 居中显示
        transform: "translateX(-10%)", // 微调水平位置
        background: "#fff",
        border: "1px solid red",
        zIndex: 10000,
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div>
        📝 x 字
      </div>
    </div>
  );
};

引入到主组件

js 复制代码
const TextCustom = () => {
  return (
    <div>
      {/* 模拟输入框 */}
      <div
        id="editor" // 后面会用
        contentEditable={true}
        suppressContentEditableWarning={true} // suppressContentEditableWarning 是 React 框架中的一个特殊属性,用于‌抑制‌contentEditable 元素触发的常见警告
      />

      <FloatingToolbar />
    </div>
  );
};

实现自定义 hook

封装自定义 hook,可命名为 useTextSelection,入参是输入框 id,返回 selection 和 toolbarRef。 其中 selection 是用来获取文本选择信息,包括选中文本的位置和选中的文本内容;toolbarRef 是工具栏的引用,后面的鼠标事件会用到,大概结构如下。

js 复制代码
const useTextSelection = (target) => {
  // 存储文本选择信息
  const [selection, setSelection] = useState(null);
  
  // 标记用户是否正在与工具栏交互,比如点击工具栏
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     * 当用户在页面上选择文本时触发
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect(); // 获取选中文本的位置和尺寸

        // 获取选中文本的锚点节点(选择的起始位置)
        const anchorNode = selection.anchorNode;
        // 获取目标编辑器元素
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,        // 选中文本的位置信息
            selectedText: selectedText, // 选中的文本内容
          });
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     * 用于检测用户是否点击了工具栏区域
     * @param {MouseEvent} e - 鼠标事件对象
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true; // 用户正在与工具栏交互
      } else {
        isInToolbarRef.current = false; // 用户点击了工具栏外部
      }
    };

    /**
     * 处理鼠标抬起事件
     * 用户完成选择操作后,延迟检查选择内容
     */
    const handleMouseUp = () => {
      // 延迟 100ms 执行,确保浏览器已完成选择操作
      setTimeout(() => {
        // 如果用户没有与工具栏交互,处理选择变化
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     * 当用户点击工具栏和编辑器外部时,隐藏工具栏
     * @param {MouseEvent} e - 鼠标事件对象
     */
    const handleClickOutside = (e) => {
      // 检查点击是否发生在工具栏外部
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        // 检查点击是否也发生在编辑器外部
        if (targetElement && !targetElement.contains(e.target)) {
          // 重置选择信息,隐藏工具栏
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange); // 监听文本选择变化
    document.addEventListener("mousedown", handleMouseDown); // 监听鼠标按下
    document.addEventListener("mouseup", handleMouseUp); // 监听鼠标抬起
    document.addEventListener("mousedown", handleClickOutside); // 监听外部点击

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target]); // 依赖项:当 target 变化时重新运行 effect

  return {
    selection,    // 当前的选择信息
    toolbarRef,   // 工具栏的 ref 引用
  };
}

主组件修改如下:

js 复制代码
const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息和工具栏引用
  const { selection, toolbarRef } = useTextSelection("#editor");

  return (
    <div>
      <div
        id="editor"
        contentEditable={true}
        className={styles.editor}
        suppressContentEditableWarning={true}
      />

      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
      />
    </div>
  );
};

浮动工具栏修改如下:

js 复制代码
const FloatingToolbar = ({
  selection,
  toolbarRef,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect) return null;

  return (
    <div
      ref={toolbarRef}
      style={{
        position: "fixed",
        top: clientRect.top - 65, // 在选中文本上方显示,65 不是固定的,可以自行调整
        left: clientRect.left + clientRect.width / 2, // 居中显示
        transform: "translateX(-10%)", // 微调水平位置
        background: "#fff",
        border: "1px solid red",
        zIndex: 10000,
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div>
        📝 {selectedText.length} 字
      </div>
    </div>
  );
};

实现加粗

前置知识

document.execCommand

document.execCommand 可对选中文本进行操作,比如加粗、斜体、下划线等,举个简单的例子。

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文本编辑器示例</title>
  <style>
   .editor {
      border: 1px solid #ccc;
      padding: 10px;
      min-height: 200px;
    }
  </style>
</head>
<body>
  <div contentEditable="true" class="editor">
    请在这里输入文本,然后选中一部分文本并点击相应的按钮。
  </div>
  <button onclick="handleBold()">加粗</button>
  <button onclick="handleItalic()">斜体</button>
  <button onclick="handleUnderline()">下划线</button>

  <script>
    function handleBold() {
      document.execCommand("bold", false, null);
    }

    function handleItalic() {
      document.execCommand("italic", false, null);
    }

    function handleUnderline() {
      document.execCommand("underline", false, null);
    }
  </script>
</body>
</html>

document.queryCommandState("bold")

document.queryCommandState("bold") 可获取加粗状态,返回布尔值。当选择的文本,只有部分加粗时也会返回 true。

修改主组件

从自定义 Hook 中获取 isBoldActivehandleBoldClick,并传给浮动工具栏组件。

js 复制代码
const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    handleBoldClick 
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        isBoldActive={isBoldActive}
      />
    </div>
  );
};

修改浮动工具栏

js 复制代码
const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  isBoldActive = false,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>
    </div>
  );
};

修改自定义 Hook

js 复制代码
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前加粗状态
          setIsBoldActive(getBoldState());  // 回显加粗使用
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    setIsBoldActive(getBoldState());
  }, [getBoldState]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现斜体

加粗理解了,斜体就简单了,流程是一模一样的。

修改主组件

从自定义 Hook 中获取 isItalicActivehandleItalicClick,并传给浮动工具栏组件。

js 复制代码
const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    handleBoldClick,
    handleItalicClick
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
      />
    </div>
  );
};

修改浮动工具栏

增加斜体按钮。

js 复制代码
const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  isBoldActive = false,
  isItalicActive = false,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>
    </div>
  );
};

修改自定义 Hook

主要有两处改动:

  1. 封装了 updateFormattingStates 函数用于更新所有格式化状态,包括加粗和斜体。
  2. 增加处理斜体操作。
js 复制代码
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
  }, [getBoldState, getItalicState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现下划线

修改主组件

从自定义 Hook 中获取 isUnderlineActivehandleUnderlineClick,并传给浮动工具栏组件。

js 复制代码
const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    isUnderlineActive,
    handleBoldClick,
    handleItalicClick,
    handleUnderlineClick,
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        onUnderlineClick={handleUnderlineClick}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
        isUnderlineActive={isUnderlineActive}
      />
    </div>
  );
};

修改浮动工具栏

增加下划线按钮。

js 复制代码
const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  onUnderlineClick,
  isBoldActive = false,
  isItalicActive = false,
  isUnderlineActive = false,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>

      {/* 下划线按钮 */}
      <button
        onClick={() => onUnderlineClick && onUnderlineClick(range)}
        title="下划线"
        style={{
          background: isUnderlineActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          textDecoration: "underline",
        }}
      >
        U
      </button>
    </div>
  );
};

修改自定义 Hook

主要有两处改动:

  1. 增加获取下划线状态
  2. 增加处理下划线操作
js 复制代码
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  const [isUnderlineActive, setIsUnderlineActive] = useState(false);
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 获取下划线状态
  const getUnderlineState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("underline");
  }, []);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
    setIsUnderlineActive(getUnderlineState());
  }, [getBoldState, getItalicState, getUnderlineState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理下划线操作
  const handleUnderlineClick = useCallback((range) => {
    if (!range) return;

    // 执行下划线命令
    document.execCommand("underline", false, null);

    // 更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现字号

修改主组件

从自定义 Hook 中获取 fontSizehandleFontSizeChange,并传给浮动工具栏组件。

js 复制代码
const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    isUnderlineActive,
    fontSize,
    handleBoldClick,
    handleItalicClick,
    handleUnderlineClick,
    handleFontSizeChange,
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        onUnderlineClick={handleUnderlineClick}
        onFontSizeChange={handleFontSizeChange}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
        isUnderlineActive={isUnderlineActive}
        fontSize={fontSize}
      />
    </div>
  );
};

修改浮动工具栏

主要增加字号选择器。

js 复制代码
const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  onUnderlineClick,
  onFontSizeChange,
  isBoldActive = false,
  isItalicActive = false,
  isUnderlineActive = false,
  fontSize = "3",
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>

      {/* 下划线按钮 */}
      <button
        onClick={() => onUnderlineClick && onUnderlineClick(range)}
        title="下划线"
        style={{
          background: isUnderlineActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          textDecoration: "underline",
        }}
      >
        U
      </button>

      {/* 字号选择器 */}
      <select
        value={fontSize}
        onChange={(e) => onFontSizeChange && onFontSizeChange(e.target.value, range)}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
        style={{
          background: "#999",
          color: "white",
          border: "1px solid #999",
          borderRadius: "4px",
          padding: "6px 10px",
          cursor: "pointer",
          fontSize: "12px",
          width: "90px",
          appearance: "auto",
        }}
      >
        <option value="1">12px</option>
        <option value="2">14px</option>
        <option value="3">16px</option>
        <option value="4">18px</option>
        <option value="5">24px</option>
        <option value="6">32px</option>
        <option value="7">48px</option>
      </select>

      {/* 颜色选择器 */}
      <input
        type="color"
        value={color}
        onChange={(e) => onColorChange && onColorChange(e.target.value, range)}
        title="文字颜色"
        style={{
          width: "36px",
          height: "36px",
          border: "2px solid #4a627a",
          borderRadius: "6px",
          cursor: "pointer",
          padding: "0",
          backgroundColor: color || "#ffffff",
        }}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
      />
    </div>
  );
};

修改自定义 Hook

字号和后面的颜色都是通过增加嵌套 span 标签实现的。

js 复制代码
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  const [isUnderlineActive, setIsUnderlineActive] = useState(false);
  const [fontSize, setFontSize] = useState("3");
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 获取下划线状态
  const getUnderlineState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("underline");
  }, []);

  // 获取当前选择的字号
  const getFontSizeState = useCallback(() => {
    const selection = window.getSelection();
    if (!selection.rangeCount) return "3";

    const range = selection.getRangeAt(0);

    let element = range.startContainer;
    if (element.nodeType !== Node.ELEMENT_NODE) {
      element = element.parentElement;
    }

    // 遍历父元素查找字体大小
    while (element && element !== document.querySelector(target)) {
      if (element.nodeType === Node.ELEMENT_NODE) {
        const computedStyle = window.getComputedStyle(element);
        const computedFontSize = computedStyle.fontSize;

        if (computedFontSize) {
          const sizeInPx = parseInt(computedFontSize);
          if (sizeInPx >= 42) return "7";
          else if (sizeInPx >= 28) return "6";
          else if (sizeInPx >= 22) return "5";
          else if (sizeInPx >= 17) return "4";
          else if (sizeInPx >= 15) return "3";
          else if (sizeInPx >= 13) return "2";
          else return "1";
        }
      }
      element = element.parentElement;
    }

    return "3";
  }, [target]);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
    setIsUnderlineActive(getUnderlineState());
    setFontSize(getFontSizeState());
  }, [getBoldState, getItalicState, getUnderlineState, getFontSizeState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 设置字体大小
  const setFontSizeCommand = useCallback((sizeValue, range) => {
    if (!range) return;

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);

    const newRange = selection.getRangeAt(0);
    const span = document.createElement("span");

    const sizeMap = {
      "1": "12px",
      "2": "14px",
      "3": "16px",
      "4": "18px",
      "5": "24px",
      "6": "32px",
      "7": "48px",
    };

    span.style.fontSize = sizeMap[sizeValue] || "16px";

    try {
      // 检查选择范围是否已折叠(即没有选中文本,只有一个光标位置)
      // 如果是折叠状态,不执行字体大小设置操作
      if (newRange.collapsed) return;

      const clonedRange = newRange.cloneRange();
      const fragment = clonedRange.extractContents();
      span.appendChild(fragment);
      clonedRange.insertNode(span);

      // 清除当前选择,然后重新选中刚刚插入的span元素内容
      // 这样用户可以继续对同一段文本进行其他操作
      selection.removeAllRanges();
      const newSelectionRange = document.createRange();
      newSelectionRange.selectNodeContents(span);
      selection.addRange(newSelectionRange);
    } catch (error) {
      console.error("设置字体大小失败:", error);
    }

    // 更新状态
    setFontSize(sizeValue);
  }, []);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理下划线操作
  const handleUnderlineClick = useCallback((range) => {
    if (!range) return;

    // 执行下划线命令
    document.execCommand("underline", false, null);

    // 更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理字号变化
  const handleFontSizeChange = useCallback((sizeValue, range) => {
    if (!range) return;

    setFontSizeCommand(sizeValue, range);
    updateFormattingStates();
  }, [setFontSizeCommand, updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现颜色

修改主组件

从自定义 Hook 中获取 colorhandleColorChange,并传给浮动工具栏组件。

js 复制代码
const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    isUnderlineActive,
    fontSize,
    handleBoldClick,
    handleItalicClick,
    handleUnderlineClick,
    handleFontSizeChange,
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        onUnderlineClick={handleUnderlineClick}
        onFontSizeChange={handleFontSizeChange}
        onColorChange={handleColorChange}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
        isUnderlineActive={isUnderlineActive}
        fontSize={fontSize}
        color={color}
      />
    </div>
  );
};

修改浮动工具栏

js 复制代码
const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  onUnderlineClick,
  onFontSizeChange,
  onColorChange,
  isBoldActive = false,
  isItalicActive = false,
  isUnderlineActive = false,
  fontSize = "3",
  color = "#000000",
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>

      {/* 下划线按钮 */}
      <button
        onClick={() => onUnderlineClick && onUnderlineClick(range)}
        title="下划线"
        style={{
          background: isUnderlineActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          textDecoration: "underline",
        }}
      >
        U
      </button>

      {/* 字号选择器 */}
      <select
        value={fontSize}
        onChange={(e) => onFontSizeChange && onFontSizeChange(e.target.value, range)}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
        style={{
          background: "#999",
          color: "white",
          border: "1px solid #999",
          borderRadius: "4px",
          padding: "6px 10px",
          cursor: "pointer",
          fontSize: "12px",
          width: "90px",
          appearance: "auto",
        }}
      >
        <option value="1">12px</option>
        <option value="2">14px</option>
        <option value="3">16px</option>
        <option value="4">18px</option>
        <option value="5">24px</option>
        <option value="6">32px</option>
        <option value="7">48px</option>
      </select>

      {/* 颜色选择器 */}
      <input
        type="color"
        value={color}
        onChange={(e) => onColorChange && onColorChange(e.target.value, range)}
        title="文字颜色"
        style={{
          width: "36px",
          height: "36px",
          border: "2px solid #4a627a",
          borderRadius: "6px",
          cursor: "pointer",
          padding: "0",
          backgroundColor: color || "#ffffff",
        }}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
      />
    </div>
  );
};

修改自定义 Hook

在看具体实现前,先认识一个 API,document.createTreeWalke 是一个用于 深度优先遍历 DOM 树 的接口。

基本语法

js 复制代码
const walker = document.createTreeWalker(
  root,          // 遍历的起始节点
  whatToShow,    // 要显示哪些类型的节点
  filter         // 可选的过滤器函数
);

简单示例

js 复制代码
<div id="container">
  <h1>标题</h1>
  <p>段落 <span>文本</span></p>
  <ul>
    <li>项目1</li>
    <li>项目2</li>
  </ul>
</div>

<script>
const container = document.getElementById('container');

// 创建 TreeWalker: 从 container 开始,只遍历元素节点
const walker = document.createTreeWalker(
  container,
  NodeFilter.SHOW_ELEMENT,  // 只显示元素节点
  null  // 不过滤
);

const nodes = [];
let node;
while (node = walker.nextNode()) {
  nodes.push(node.tagName);
}

console.log(nodes); // 输出: ["H1", "P", "SPAN", "UL", "LI", "LI"] 深度优先遍历顺序
</script>
js 复制代码
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  const [isUnderlineActive, setIsUnderlineActive] = useState(false);
  const [fontSize, setFontSize] = useState("3");
  const [color, setColor] = useState("#000000");
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // RGB 转十六进制辅助函数
  const rgbToHex = useCallback((rgb) => {
    // 如果已经是十六进制格式,直接返回
    if (rgb.startsWith("#")) return rgb;

    // 匹配 RGB 或 RGBA 格式:rgb(255, 255, 255) 或 rgba(255, 255, 255, 0.5)
    const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
    if (match) {
      const r = parseInt(match[1]);
      const g = parseInt(match[2]);
      const b = parseInt(match[3]);

      // 将 RGB 值转换为十六进制,并确保两位显示
      return (
        "#" +
        r.toString(16).padStart(2, "0") +
        g.toString(16).padStart(2, "0") +
        b.toString(16).padStart(2, "0")
      );
    }

    // 无法解析的颜色,返回默认黑色
    return "#000000";
  }, []);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 获取下划线状态
  const getUnderlineState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("underline");
  }, []);

  // 获取当前选择的字号
  const getFontSizeState = useCallback(() => {
    const selection = window.getSelection();
    if (!selection.rangeCount) return "3";

    const range = selection.getRangeAt(0);

    let element = range.startContainer;
    if (element.nodeType !== Node.ELEMENT_NODE) {
      element = element.parentElement;
    }

    // 遍历父元素查找字体大小
    while (element && element !== document.querySelector(target)) {
      if (element.nodeType === Node.ELEMENT_NODE) {
        const computedStyle = window.getComputedStyle(element);
        const computedFontSize = computedStyle.fontSize;

        if (computedFontSize) {
          const sizeInPx = parseInt(computedFontSize);
          if (sizeInPx >= 42) return "7";
          else if (sizeInPx >= 28) return "6";
          else if (sizeInPx >= 22) return "5";
          else if (sizeInPx >= 17) return "4";
          else if (sizeInPx >= 15) return "3";
          else if (sizeInPx >= 13) return "2";
          else return "1";
        }
      }
      element = element.parentElement;
    }

    return "3";
  }, [target]);

  // 获取当前选择的颜色
  const getColorState = useCallback(() => {
    const selection = window.getSelection();
    if (!selection.rangeCount) return "#000000";

    const range = selection.getRangeAt(0);
    const commonAncestor = range.commonAncestorContainer;

    let element = range.startContainer;
    if (element.nodeType !== Node.ELEMENT_NODE) {
      element = element.parentElement;
    }

    // 遍历父元素查找颜色
    while (element && element !== document.querySelector(target)) {
      if (element.nodeType === Node.ELEMENT_NODE) {
        const computedStyle = window.getComputedStyle(element);
        const computedColor = computedStyle.color;

        // 检查颜色是否为有效值(非透明、非默认黑色)
        if (
          computedColor &&
          computedColor !== "rgba(0, 0, 0, 0)" &&
          computedColor !== "transparent" &&
          !computedColor.startsWith("rgba(0, 0, 0, ")
        ) {
          return rgbToHex(computedColor);
        }
      }
      element = element.parentElement;
    }

    // 如果没有找到,检查选择范围内的元素
    const walker = document.createTreeWalker(
      commonAncestor,
      NodeFilter.SHOW_ELEMENT,
      {
        acceptNode: (node) =>
          range.intersectsNode(node)
            ? NodeFilter.FILTER_ACCEPT
            : NodeFilter.FILTER_REJECT,
      },
    );

    // 当向上遍历找不到颜色时,它会深度遍历选择范围内的所有元素,找到第一个有效的文本颜色。
    while ((element = walker.nextNode())) {
      const computedStyle = window.getComputedStyle(element);
      const computedColor = computedStyle.color;

      if (
        computedColor &&
        computedColor !== "rgba(0, 0, 0, 0)" &&
        computedColor !== "transparent" &&
        !computedColor.startsWith("rgba(0, 0, 0, ")
      ) {
        return rgbToHex(computedColor);
      }
    }

    return "#000000";
  }, [target, rgbToHex]);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
    setIsUnderlineActive(getUnderlineState());
    setFontSize(getFontSizeState());
    setColor(getColorState());
  }, [getBoldState, getItalicState, getUnderlineState, getFontSizeState, getColorState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 设置字体大小
  const setFontSizeCommand = useCallback((sizeValue, range) => {
    if (!range) return;

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);

    const newRange = selection.getRangeAt(0);
    const span = document.createElement("span");

    const sizeMap = {
      "1": "12px",
      "2": "14px",
      "3": "16px",
      "4": "18px",
      "5": "24px",
      "6": "32px",
      "7": "48px",
    };

    span.style.fontSize = sizeMap[sizeValue] || "16px";

    try {
      // 检查选择范围是否已折叠(即没有选中文本,只有一个光标位置)
      // 如果是折叠状态,不执行字体大小设置操作
      if (newRange.collapsed) return;

      const clonedRange = newRange.cloneRange();
      const fragment = clonedRange.extractContents();
      span.appendChild(fragment);
      clonedRange.insertNode(span);

      // 清除当前选择,然后重新选中刚刚插入的span元素内容
      // 这样用户可以继续对同一段文本进行其他操作
      selection.removeAllRanges();
      const newSelectionRange = document.createRange();
      newSelectionRange.selectNodeContents(span);
      selection.addRange(newSelectionRange);
    } catch (error) {
      console.error("设置字体大小失败:", error);
    }

    // 更新状态
    setFontSize(sizeValue);
  }, []);

  // 设置文本颜色
  const setTextColor = useCallback((colorValue, range) => {
    if (!range) return;

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);

    const newRange = selection.getRangeAt(0);
    const span = document.createElement("span");

    span.style.color = colorValue;

    try {
      if (newRange.collapsed) return;

      const clonedRange = newRange.cloneRange();
      const fragment = clonedRange.extractContents();
      span.appendChild(fragment);
      clonedRange.insertNode(span);

      selection.removeAllRanges();
      const newSelectionRange = document.createRange();
      newSelectionRange.selectNodeContents(span);
      selection.addRange(newSelectionRange);
    } catch (error) {
      console.error("设置颜色失败:", error);
    }

    setColor(colorValue);
  }, []);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理下划线操作
  const handleUnderlineClick = useCallback((range) => {
    if (!range) return;

    // 执行下划线命令
    document.execCommand("underline", false, null);

    // 更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理字号变化
  const handleFontSizeChange = useCallback((sizeValue, range) => {
    if (!range) return;

    setFontSizeCommand(sizeValue, range);
    updateFormattingStates();
  }, [setFontSizeCommand, updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};
相关推荐
nvd111 天前
企业级 LLM 实战:在受限环境中基于 Copilot API 构建 ReAct MCP Agent
前端·copilot
Dragon Wu1 天前
TailWindCss cva+cn管理样式
前端·css
烤麻辣烫1 天前
Web开发概述
前端·javascript·css·vue.js·html
Front思1 天前
Vue3仿美团实现骑手路线规划
开发语言·前端·javascript
徐同保1 天前
Nano Banana AI 绘画创作前端代码(使用claude code编写)
前端
Ulyanov1 天前
PyVista与Tkinter桌面级3D可视化应用实战
开发语言·前端·python·3d·信息可视化·tkinter·gui开发
计算机程序设计小李同学1 天前
基于Web和Android的漫画阅读平台
java·前端·vue.js·spring boot·后端·uniapp
干前端1 天前
Message组件和Vue3 进阶:手动挂载组件与 Diff 算法深度解析
javascript·vue.js·算法
lkbhua莱克瓦241 天前
HTML与CSS核心概念详解
前端·笔记·html·javaweb
沛沛老爹1 天前
从Web到AI:Agent Skills CI/CD流水线集成实战指南
java·前端·人工智能·ci/cd·架构·llama·rag