电子书阅读器之笔记高亮

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

高亮

高亮标注(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>
  );
});
相关推荐
孤水寒月2 小时前
基于HTML的悬窗可拖动记事本
前端·css·html
祝余呀2 小时前
html初学者第一天
前端·html
脑袋大大的3 小时前
JavaScript 性能优化实战:减少 DOM 操作引发的重排与重绘
开发语言·javascript·性能优化
速易达网络4 小时前
RuoYi、Vue CLI 和 uni-app 结合构建跨端全家桶方案
javascript·vue.js·低代码
耶啵奶膘4 小时前
uniapp+firstUI——上传视频组件fui-upload-video
前端·javascript·uni-app
JoJo_Way4 小时前
LeetCode三数之和-js题解
javascript·算法·leetcode
视频砖家5 小时前
移动端Html5播放器按钮变小的问题解决方法
前端·javascript·viewport功能
lyj1689975 小时前
vue-i18n+vscode+vue 多语言使用
前端·vue.js·vscode
小白变怪兽7 小时前
一、react18+项目初始化(vite)
前端·react.js
ai小鬼头7 小时前
AIStarter如何快速部署Stable Diffusion?**新手也能轻松上手的AI绘图
前端·后端·github