电子书阅读器之笔记高亮

电子书阅读器的笔记高亮功能通过整合文本选择、颜色标记、笔记编辑等多个模块,提供强大的阅读辅助工具。本文介绍了这些功能的基础实现,如文本选择、标色弹框、标记事件、笔记弹框、文本高亮、笔记列表等等。

高亮

高亮标注(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>
  );
});
相关推荐
tiandyoin1 小时前
调教 DeepSeek - 输出精致的 HTML MARKDOWN
前端·html
Electrolux3 小时前
【使用教程】一个前端写的自动化rpa工具
前端·javascript·程序员
赵大仁4 小时前
深入理解 Pinia:Vue 状态管理的革新与实践
前端·javascript·vue.js
小小小小宇4 小时前
业务项目中使用自定义Webpack 插件
前端
小小小小宇4 小时前
前端AST 节点类型
前端
小小小小宇5 小时前
业务项目中使用自定义eslint插件
前端
babicu1235 小时前
CSS Day07
java·前端·css
小小小小宇5 小时前
业务项目使用自定义babel插件
前端
前端码虫5 小时前
JS分支和循环
开发语言·前端·javascript
GISer_Jing5 小时前
MonitorSDK_性能监控(从Web Vital性能指标、PerformanceObserver API和具体代码实现)
开发语言·前端·javascript