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

本篇文章将一步一步带大家实现这个效果。
实现字数统计
前置知识
window.getSelection
window.getSelection 可获取选中文本信息和选择内容,示例代码如下:
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 属性 。
const TextCustom = () => {
return (
<div>
{/* 模拟输入框 */}
<div
id="editor" // 后面会用
contentEditable={true}
suppressContentEditableWarning={true} // suppressContentEditableWarning 是 React 框架中的一个特殊属性,用于抑制contentEditable 元素触发的常见警告
/>
</div>
);
};
实现浮动工具栏组件
浮动工具栏组件实现
浮动工具栏组件是通过固定定位实现的,工具栏的位置是跟随选中文本的位置改变而改变的,主要属性是 top 和 left,默认值都先设置成 0。
浮动工具栏组件的内容是选中文本的字数统计,先硬编码成“x 字”。
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>
);
};
引入到主组件
const TextCustom = () => {
return (
<div>
{/* 模拟输入框 */}
<div
id="editor" // 后面会用
contentEditable={true}
suppressContentEditableWarning={true} // suppressContentEditableWarning 是 React 框架中的一个特殊属性,用于抑制contentEditable 元素触发的常见警告
/>
<FloatingToolbar />
</div>
);
};
实现自定义 hook
封装自定义 hook,可命名为 useTextSelection,入参是输入框 id,返回 selection 和 toolbarRef。
其中 selection 是用来获取文本选择信息,包括选中文本的位置和选中的文本内容;toolbarRef 是工具栏的引用,后面的鼠标事件会用到,大概结构如下。
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 引用
};
}
主组件修改如下:
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>
);
};
浮动工具栏修改如下:
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 可对选中文本进行操作,比如加粗、斜体、下划线等,举个简单的例子。
<!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 中获取 isBoldActive 和 handleBoldClick,并传给浮动工具栏组件。
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>
);
};
修改浮动工具栏
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
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 中获取 isItalicActive 和 handleItalicClick,并传给浮动工具栏组件。
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>
);
};
修改浮动工具栏
增加斜体按钮。
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
主要有两处改动:
- 封装了
updateFormattingStates 函数用于更新所有格式化状态,包括加粗和斜体。
- 增加处理斜体操作。
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 中获取 isUnderlineActive 和 handleUnderlineClick,并传给浮动工具栏组件。
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>
);
};
修改浮动工具栏
增加下划线按钮。
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
主要有两处改动:
- 增加获取下划线状态
- 增加处理下划线操作
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 中获取 fontSize 和 handleFontSizeChange,并传给浮动工具栏组件。
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>
);
};
修改浮动工具栏
主要增加字号选择器。
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 标签实现的。
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 中获取 color 和 handleColorChange,并传给浮动工具栏组件。
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>
);
};
修改浮动工具栏
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 树 的接口。
基本语法
const walker = document.createTreeWalker(
root, // 遍历的起始节点
whatToShow, // 要显示哪些类型的节点
filter // 可选的过滤器函数
);
简单示例
<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>
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,
};
};