如何实现划词效果

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

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

实现字数统计

前置知识

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,
  };
};
相关推荐
Irene19912 小时前
JavaScript 三种类型检测方法对比(instanceof、typeoff、Object.prototype.toString.call())
javascript·类型检测
前端老爷更车2 小时前
esp32 小智AI 项目
前端
destinying2 小时前
五年前端,我凌晨三点的电脑屏幕前终于想通了这件事
前端·javascript·vue.js
想学后端的前端工程师2 小时前
【React Hooks深度实战指南:从原理到最佳实践】
前端·react.js·前端框架
elangyipi1232 小时前
前端面试题:如何减少页面重绘跟重排
前端·面试·html
一棵开花的树,枝芽无限靠近你2 小时前
【face-api.js】2️⃣ NetInput - 神经网络输入封装类
开发语言·javascript·神经网络
想学后端的前端工程师2 小时前
【前端安全防护实战指南:从XSS到CSRF全面防御】
前端·安全·xss
TAEHENGV2 小时前
关于应用模块 Cordova 与 OpenHarmony 混合开发实战
android·javascript·数据库
资深低代码开发平台专家2 小时前
MicroQuickJS:为极致资源而生的嵌入式JavaScript革命
开发语言·javascript·ecmascript