电子书阅读器的笔记高亮功能通过整合文本选择、颜色标记、笔记编辑等多个模块,提供强大的阅读辅助工具。本文介绍了这些功能的基础实现,如文本选择、标色弹框、标记事件、笔记弹框、文本高亮、笔记列表等等。
高亮
高亮标注(highlight)功能,是很常见的一个功能。
选中文字后,会出现一个弹框,里面可以选择颜色进行标记。
1. 文本选择
文本选择是高亮功能的基础,通过监听浏览器的 selectionchange
事件实现:
通过getSelection()
获取用户选择的文本范围或光标的当前位置信息。
具体可以查看官方文档getSelection API
selection?.getRangeAt(0)
获取选取的位置信息
jsx
// 初始化文本选择和标记功能
useEffect(() => {
const handleSelection = () => {
// 获取当前文本选择对象
const selection = window.getSelection();
// 获取选中的文本内容并去除首尾空格
const text = selection?.toString().trim() || "";
if (text && containerRef.current) {
// 获取第一个选区范围
const range = selection?.getRangeAt(0);
console.log("getRangeAt", selection);
// const {
// startContainer, // 起始节点
// startOffset, // 起始节点偏移量
// endContainer, // 终止节点
// endOffset, // 终止节点偏移量
// } = selection?.getRangeAt(0);
// 获取选中内容的公共祖先节点
const commonAncestor = range?.commonAncestorContainer;
// 通过自定义函数查找页面ID
const pageId = commonAncestor ? findPageIdFromNode(commonAncestor) : null;
// 更新当前页面ID状态
setCurrentPageId(pageId || "");
// 获取选中区域的边界矩形
const rect = range?.getBoundingClientRect();
// 获取所有矩形区域(用于多行选择)
const rect2 = range?.getClientRects();
console.log("选区信息getBoundingClientRect", rect);
console.log("选区信息getClientRects", rect2);
// 获取容器的边界矩形
const containerRect = containerRef.current.getBoundingClientRect();
if (rect) {
// 更新选中的文本状态
setSelectedText(text);
// 计算相对于容器的坐标:
// 1. 水平坐标 = 选区左边界 - 容器左边界 + 容器水平滚动距离
// 2. 垂直坐标 = 选区上边界 - 容器上边界 + 容器垂直滚动距离
const relativeX =
rect.left - containerRect.left + containerRef.current.scrollLeft;
const relativeY =
rect.top - containerRect.top + containerRef.current.scrollTop;
// 设置菜单位置:调整定位,转换坐标系
setMenuPosition({
x: relativeX + rect.width / 2,
y: relativeY,
});
// 防抖处理:避免频繁触发菜单显示
clearTimeout(selectionTimerRef.current);
selectionTimerRef.current = setTimeout(() => {
// 显示标色弹框
setShowMenu(true);
}, 300);
}
}
};
// 添加文本选择变化事件监听
document.addEventListener("selectionchange", handleSelection);
return () => {
// 移除事件监听
document.removeEventListener("selectionchange", handleSelection);
clearTimeout(selectionTimerRef.current);
};
}, []);

2. 标色弹框
当用户选择文本后,显示颜色选择弹框
目前设置了 5 种可选的颜色,弹框位置会跟随选区动态调整
注意坐标的转化,特别是存在滚动条的时候
jsx
{
showMenu && (
<div
style={{
position: "absolute",
left: menuPosition.x, // 设置菜单的x坐标
top: menuPosition.y, // 设置菜单的y坐标
zIndex: 1000,
}}
>
<Popover
placement="top"
// title="选色标注"
open={true}
content={
<div className="grid grid-cols-5 items-center gap-2">
{colorList.map((o) => (
<div
key={o.key}
className="cursor-pointer"
style={{ color: o.color }}
onClick={() => handleClick("highlight", o)}
>
<MdCircle style={{ fontSize: 30 }} />
</div>
))}
</div>
}
/>
</div>
);
}

3. 标记事件
用户选择颜色后,保存高亮标记数据,使用 localStorage 存储用户标记数据,并且按页面 ID 组织数据,便于快速检索。
菜单中可以配置不同的事件,例如复制、高亮、标记、笔记、AI 搜索等等。
目前我只处理了复制和高亮事件。
jsx
const { addMark } = useUserActions();
const handleClick = (action: string, o: any) => {
switch (action) {
case "copy": // 复制选中文本
navigator.clipboard.writeText(selectedText);
message.success("复制成功");
break;
case "highlight": {
// 高亮选中文本
const result = {
...o,
id: nanoid(), // 生成唯一ID
pageId: currentPageId,
text: selectedText,
date: dayjs().format("YYYY年MM月DD日 HH:mm"),
};
addMark(result);
message.success("标注成功");
break;
}
}
setShowMenu(false);
};
本地测试时,数据信息存储在 localStorage 中,数据结构如下:

笔记
高亮文本内容后,点击文本,会出现一个笔记弹框,用户可以填写笔记信息,并保存;也可以删除高亮标记。
1. 笔记弹框
主要是一个文本框,可以输入笔记内容;加上"删除标记"、"保存笔记"的按钮
jsx
// 提取 Popover 内容为单独组件
const MarkPopoverContent: FC<{
mark: { id: string, text: string, note: string },
onSave: (note: string) => void,
onDelete: () => void,
}> = ({ mark, onSave, onDelete }) => {
const [editingNote, setEditingNote] = useState(mark.note);
const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditingNote(e.target.value);
};
return (
<div className="w-64">
<div className="mb-2">
<TextArea
rows={4}
placeholder="请输入笔记内容"
value={editingNote}
onChange={handleNoteChange}
/>
</div>
<div className="flex justify-between">
<Button onClick={onDelete} danger>
删除标记
</Button>
<Button type="primary" onClick={() => onSave(editingNote)}>
保存笔记
</Button>
</div>
</div>
);
};

2. 文本高亮
目前只能处理同一段文字,不能跨段处理,需要优化
,主要是展示高亮文本的逻辑吧。
而且目前是笔记弹框显示在笔记上,每一个标记都有一个,肯定影响性能。正常情况下,应该固定在底部显示一个弹框就行了,选中时改变弹框内容即可,这个笔记弹框也是后期需要优化
处理的
将普通文本转换为包含高亮标记的 React 节点:
- 为每个标记文本包裹可交互的元素
- 添加 Popover 实现点击交互
- 应用 CSS 类实现颜色高亮
- 为每个标记元素设置唯一 ID 便于定位
jsx
const HighlightProcessor = ({
text,
}: // pageId,
{
text: string | React.ReactNode,
pageId: string,
}) => {
const marks = useMarkList();
const { removeMark, modifyMark } = useUserActions();
const currentMarks = marks;
const handleClick = useCallback(
(action: string, o: any) => {
switch (action) {
case "del":
removeMark(o.id);
message.success("删除标记成功");
break;
case "saveNote": {
modifyMark(o.id, o.note);
message.success("笔记保存成功");
}
}
},
[removeMark, modifyMark]
);
const processContent = useMemo(() => {
// 处理字符串内容
const processString = (str: string): (string | JSX.Element)[] => {
if (!currentMarks.length) return [str];
// 创建正则表达式匹配所有标记文本
const regex = new RegExp(
`(${currentMarks.map((m) => escapeRegex(m.text)).join("|")})`,
"g"
);
return str.split(regex).map((part, idx) => {
const mark = currentMarks.find((m) => m.text === part);
return mark ? (
<Popover
key={mark.id + idx.toString()}
placement="top"
trigger={["click"]}
content={
<MarkPopoverContent
mark={mark}
onSave={(note) =>
handleClick("saveNote", { id: mark.id, note })
}
onDelete={() => handleClick("del", mark)}
/>
}
>
{/* 加上ID,加上颜色 便于后期滚动定位处理 */}
<span
id={mark.id.toString()}
key={mark.id}
className={`mark-color-${mark.key}`}
>
{part}
</span>
</Popover>
) : (
part
);
});
};
// 递归处理 React 节点
const processNode = (node: React.ReactNode): React.ReactNode => {
if (typeof node === "string") {
return processString(node);
}
if (isValidElement(node)) {
return cloneElement(
node,
{ ...node.props, key: node.key },
...Children.toArray(node.props.children).map(processNode)
);
}
return node;
};
return processNode(text);
}, [currentMarks, text, handleClick]);
return <>{processContent}</>;
};
3. 笔记列表
展示所有笔记的列表页面:
- 按颜色分类过滤笔记
- 点击笔记可自动定位到原文位置
- 支持删除不再需要的笔记
jsx
const Page: FC = memo(() => {
const [current, setCurrent] = useState < number > -1;
const list = useMarkList();
const { removeMark } = useUserActions();
// 删除标记
const handleDel = (id: string) => {
removeMark(id);
};
// 滚动定位
const handleScroll = (id: string) => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
return (
<Container>
<div className="grid grid-cols-7 items-center gap-2">
<Button type="primary" onClick={() => setCurrent(-1)}>
全部
</Button>
{colorList.map(({ key, color }) => (
<div
key={key}
className={`cursor-pointer ${current === key ? "current" : ""}`}
style={{ color }}
onClick={() => setCurrent(key)}
>
<MdCircle style={{ fontSize: 30 }} />
</div>
))}
</div>
<List
className="mark_list"
dataSource={
current === -1 ? list : list.filter((item) => item.key === current)
}
renderItem={(o) => (
<List.Item key={o.id}>
<div className="content">
<div
className="title"
style={{ borderLeftColor: o.color }}
onClick={() => handleScroll(o.id)}
>
{o.text}
</div>
<DeleteOutlined color="red" onClick={() => handleDel(o.id)} />
</div>
{o.note && (
<div className="note" style={{ borderColor: o.color }}>
{o.note}
</div>
)}
<div className="date">{o.date}</div>
</List.Item>
)}
/>
</Container>
);
});
