需求说明
近期为了验证React所学接了一个私活,项目是开发一个H5。要求在手机网页上阅读PDF格式和Ebup格式的电子书且电子书是只有一个线上地址。好好好,没事苦一苦前端牛马。在网上找了诸多文章以及多次问AI得到的结论就是EbupJS, Ebupjs有一个组件react-reader。但是由于我这个要支持移动端以及还有其他自定义功能所以无法使用,所以只有参考一下自定义一个组件了。
初始化书籍
bookRef.current = book;
// 等待书籍加载完毕
await book.ready.catch(error => {
console.error('Book ready failed:', error);
throw error; // 向上抛出异常
});
// 创建新实例
const book = EpubJS(epubUrl);// epubUrl 你的书籍地址
// 等待书籍加载完毕
await book.ready.catch(error => {
console.error('Book ready failed:', error);
throw error; // 向上抛出异常
});
// 设置默认参数
const defaultParams = {
width: "100%",
height: "100%",
spread: "none", //"always":强制双页模式。"auto":根据屏幕宽度自动切换(宽屏启用双页)。"none" 表示 禁用双页模式,始终以单页形式显示内容。
allowPopups: true,//是否允许电子书内容中 弹出新窗口(如点击脚注时弹出注释框)true 表示启用弹窗功能
allowScriptedContent: true,//是否允许执行电子书内 嵌入的 JavaScript 代码。true 表示启用脚本执行 (解决跨域)
explicitWidth: true, // 强制使用 显式宽度 计算布局 解决某些情况下页面宽度计算错误的问题(如CSS未正确加载时)。
}
// 渲染
renditionRef.current = bookRef.current.renderTo(viewerRef.current, {
...defaultParams,
styles: {
body: {
// 强制重置滚动位置
"overflow-y": "auto !important",
"scroll-behavior": "auto !important",
"min-width": "100% !important" // 强制最小宽度
}
}
});
// 显示内容
renditionRef.current.display()
读书软件功能点
1. 目录功能
主要包含功能有去底部滚动、章节收费锁、以及选中和跳转章节。
获取目录
// 获取目录
const loadMetadata = async () => {
try {
if(!isFree){
const { data } = await checkDownloadApi({ bookId: bookId });
setCanReaderBookAllContent(data)
}
const metadata = await bookRef.current.loaded.metadata;
console.log('Book metadata:', metadata);
const toc = await bookRef.current.loaded.navigation;
function processItems(items) {
let counter = 0; // 全局计数器
let firstOverFiveId = null; // 记录第一个超过5的ID
const clonedItems = deepClone(items); // 深拷贝原始数据避免污染
// 深拷贝函数(递归处理子项)
function deepClone(data) {
if (!Array.isArray(data)) return data;
return data.map(item => ({
...item,
subitems: item.subitems ? deepClone(item.subitems) : undefined
}));
}
// 递归遍历并修改节点
function traverse(items) {
for (const item of items) {
counter++;
if(canReaderBookAllContent){
item.lock = true
}else {
item.lock = counter > 3 && userInfo.levelId < 1 && !isFree; // 动态添加 lock 字段
}
// 记录第一个超过4的ID(仅第一次)
if (counter === 4 && !firstOverFiveId) {
firstOverFiveId = String(item.href).split('#')[0];
}
// 处理epub3.0的href无法跳转,暂时先以这种方案。
if(item.href === item.id){
item.href = `Text/${item.href}`
}
// 递归处理子项
if (item.subitems) {
traverse(item.subitems);
}
}
}
traverse(clonedItems); // 处理拷贝后的数据
return {
processedData: clonedItems,
firstOverFiveId
};
}
// 使用示例
const { processedData, firstOverFiveId } = processItems(toc.toc);
tocRef.current = processedData
console.log('processedData', processedData);
setStartPayToc(firstOverFiveId)
} catch (error) {
console.error('Error loading book metadata:', error);
}
}
目录UI相关代码如下:
// render
{
menuType === 'catalog' && <div className={classNames('toc-box', theme === 'dark' && 'dark-theme')}>
<div className='toc-to-bottom' onClick={() => scrollToEnd()}>
<Icon src='/images/reader/icon_bottoms@2x.png' size={23} />
<span>去底部</span>
</div>
<div className='toc-box-content' ref={tocViewerRef}>
{
toc.map((item, index) => <div className='toc-item' onClick={(event) => clickToc(event, item)} key={index}>
<div className={classNames('toc-item-label', item.href.includes(String(currentCfi.href).split('_split_')[0]) && 'toc-label-acitve')}>
<div className='toc-item-label-text'>{item.label}</div>
{ item.lock ? <img src="/images/common/lock.png" alt="" /> : ''}
</div>
{
item.subitems.length > 0 && item.subitems.map((it, idx) => <div key={idx} onClick={(event) => clickToc(event, it)} className='toc-item-child'>
<div className={classNames('toc-item-label', (it.href.includes(String(currentCfi.href).split('_split_')[0])) && 'toc-label-acitve')}>
<div className='toc-item-label-text'>{it.label}</div>
{ it.lock ? <img src="/images/common/lock.png" alt="" /> : ''}
</div>
</div>)
}
</div>)
}
</div>
</div>
}
// 相关交互函数
const clickToc = (event, item) => {
// 阻止冒泡
event.stopPropagation()
if (item.href) {
if(item.lock) {
//书籍应该收费了
}
//跳转到对应章节
renditionRef.current.display(item.href);
}
}
2. 笔记功能
获取笔记,如何获取选中的文本这是一个需要解决的问题。在EBUPJS 提供了on监听操作其中就包含了touchstart、touchend、locationChanged、selected、click、error、asset:error、rendered等操作监听。我们需要的就是通过监听selected来获取选中内容。
代码如下:
renditionRef.current.on('selected', (cfiRange) => {
const range = renditionRef.current.getRange(cfiRange); // 获取 DOM Range 对象
const selectedText = range.toString(); // 提取选中的文本
if (selectedText) {
// 存储笔记内容
setSelectedText(selectedText);
// 记录笔记位置
setSelectedCfi(cfiRange);
}
});
3. 设置主题
// 主题切换
const onChangeTheme = useCallback((newTheme) => {
setTheme(newTheme);
if (!renditionRef.current) return;
// 获取实例主题类
const themes = renditionRef.current.themes;
const styles = {
dark: {
color: '#BCBCBC',
background: '#121212'
},
light: {
color: '#1C1C1C',
background: '#F8F9FD'
}
};
// 设置主题
themes.override('color', styles[newTheme].color);
themes.override('background', styles[newTheme].background);
// 通知其他模块函数
callBackTheme(newTheme)
}, []);
4. 字体大小
renditionRef.current?.themes.fontSize(`${size}px`);
5.实时读取进度和章节、记录阅读进度
实时读取进度和章节,这里需要Cfi,Cfi的作用是EBUPJS用来记录具体位置。用我们上面提到的on中的locationChanged来监听位置变化并获取到对应的位置信息,通过当前的位置信息记录和解析出当前位置以及当前目录章节和阅读进度。
// 监听位置信息变化 locationChanged
renditionRef.current.on('locationChanged', (loc) => {
console.log('locationChanged', loc);
// 计算进度
getCFIProgress(loc.start)
// 计算当前目录
getCurrentChapter()
// 记录当前位置
currentCfi.current = loc;
});
// 解析CFI
function parseCFI(cfi) {
// 示例 CFI: epubcfi(/6/4[chap01]!/4/2/1:3)
const match = cfi.match(/epubcfi\((\/\d+.*)\)/);
if (!match) return null;
const cfiPath = match[1];
const parts = cfiPath.split('!');
if (parts.length < 2) return null;
// 提取 Spine 项索引(如 "/6/4" -> 索引为 4)
const spinePath = parts[0];
const spineIndexMatch = spinePath.match(/\/(\d+)\/(\d+)/);
return spineIndexMatch ? parseInt(spineIndexMatch[2], 10) - 1 : 0; // 索引从 0 开始
}
// 计算进度
const getCFIProgress = async () => {
const spineItemIndex = parseCFI(currentCfi.current.start || '');
// spineItems来源于初始化时记录的书籍的主干它代表了电子书的阅读顺序和内容组织结构
let curProgress = (((spineItemIndex + 1) / spineItems.length) * 100)/ 2;
console.log('curProgress', curProgress.toFixed(2));
let value = Number(curProgress.toFixed(2));
setProgress(value);
return value
};
// 获取章节信息
const getCurrentChapter = () => {
// 2. 通过spine索引获取当前章节的URL
const currentHref = String(currentCfi.current.href).split('_split_')[0]
// 3. 在navigation.toc中匹配章节
let currentChapter = null;
// 递归查找匹配的目录项
function findChapter(items) {
for (const item of items) {
// 对比章节链接是否匹配
const chapterHref = String(item.href).split('#')[0].split('_split_')[0]
if (chapterHref === currentHref) {
currentChapter = item;
return true;
}
if (item.subitems && findChapter(item.subitems)) {
return true;
}
}
return false;
}
findChapter(tocRef.current);
let value = currentChapter?.label || "";
setChapter(value);
return value
}
6. 阅读模式切换
// 切换滚动模式
const onChangeScrollType = (type) => {
// 更新flow类型
setCurrentFlow(type);
};
// 根据 flow 模式选择 布局管理器:
// 若为滚动模式(scrolled),使用 'continuous' 管理器(优化连续滚动性能)。
// 其他模式(如分页)使用 'default' 管理器(标准分页渲染)。
// 分页方式改变重新加载书籍
useEffect(() => {
initBook();
}, [currentFlow]);
7. 翻页功能
// 上一页
renditionRef.current.prev();
// 下一页**加粗样式**
renditionRef.current.next();
踩坑记录
1. IOS在微信上白屏
// 指定电子书内容的 渲染方式
defaultParams.method = 'inline'
//'inline':将内容直接嵌入到当前DOM中(无隔离,易受外部CSS/JS影响)。
// 'iframe'(用iframe隔离内容,安全性更高但通信复杂)。
2. IOS在微信上图片展示问题
// 判断当前是否是ios
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
// 判断当前是否是微信
const isWechat = navigator.userAgent.toLowerCase().indexOf('micromessenger') !== -1
renditionRef.current.hooks.content.register((contents) => {
const body = contents.window.document.querySelector('body')
if(isIOS && isWechat){
// 设置body下的图片的宽度最大100vw 并且这个这是优先级最高
const iframeDocument = contents.window.document;
const styleElement = iframeDocument.createElement('style');
iframeDocument.head.appendChild(styleElement);
styleElement.textContent = `
img {
max-width: 100% !important;
height: auto;
}
`;
}
}
3. IOS+微信点击事件不生效
renditionRef.current.hooks.content.register((contents) => {
const body = contents.window.document.querySelector('body')
if (body) {
// 核心解决方案, 不要怀疑就是这个样式。没有它会导致添加的事件也不生效。
body.style.cursor = "pointer !important"
body.style.cursor = "pointer !important"
// 先移除之前的事件监听器,避免重复绑定
body.removeEventListener('click', () => {
setMenuVisible(prev => !prev);
setShowFloatingBubble(false)
}, {
passive: true, // 添加passive属性
capture: true
})
body.addEventListener('click', () => {
setMenuVisible(prev => !prev);
setShowFloatingBubble(false)
}, {
passive: true, // 添加passive属性
capture: true
})
}
4. 禁止默认事件
// 阻止默认右键菜单
renditionRef.current.hooks.content.register((contents) => {
const body = contents.window.document.querySelector('body')
if (body) {
body.oncontextmenu = () => false
body.keydown = (ev) => {
if(ev.keyCode === 123){
ev.preventDefault()
return false
}
}
// 阻止页面选中页面复制
body.style.userSelect = 'none'
body.style.webkitUserSelect = 'none';
body.style.mozUserSelect = 'none';
body.style.msUserSelect = 'none';
body.style.webkitTouchCallout = 'none';
body.style.pointerEvents = 'none'
}
})
5. 定位的指定位置
renditionRef.current.display('你要跳转的cfi')
完整代码
读书组件
// 读书组件
import './index.scss'
import React, { useEffect, useRef, useState, useCallback } from "react";
import EpubJS from "epubjs";
import util from "@/utils/util.js";
import ReaderMenu from "@/components/reader/components/ReaderMenu";
import Icon from '@/components/common/Icon'
import { FloatingBubble, DotLoading, Mask, Toast } from 'antd-mobile'
import { addNoteApi, endReadApi } from '@/apis/modules/book'
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux'
import { checkDownloadApi } from '@/apis/modules/book'
const CustomEpubReader = ({ callBackTheme, callBackReady, epubUrl, location, bookId, callBackPayBook, isFree, callBackMenu }) => {
const [ canReaderBookAllContent, setCanReaderBookAllContent ] = useState(false);
// 判断当前是否是ios
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
// 判断当前是否是微信
const isWechat = navigator.userAgent.toLowerCase().indexOf('micromessenger') !== -1
const navigate = useNavigate()
const { userInfo } = useSelector(state => state.login)
const bookRef = useRef(null);
const renditionRef = useRef(null);
const viewerRef = useRef(null);
const touchStartRef = useRef(0);
const [ bookLoading, setBookLoading ] = useState(false);
const [startPayToc, setStartPayToc] = useState(null)
const [progress, setProgress] = useState(0)
const [chapter, setChapter] = useState("")
// 状态管理
const [theme, setTheme] = useState('light');
const [menuVisible, setMenuVisible] = useState(false);
const [currentFlow, setCurrentFlow] = useState('paginated');
const currentCfi = useRef(JSON.parse(location || '{}'));
const [fontSize, setFontSize] = useState(16);
const tocRef = useRef([]);
const [showFloatingBubble, setShowFloatingBubble] = useState(false);
const [selectedText, setSelectedText] = useState(""); // 新增state存储选中文本
let spineItems= []
// 关闭菜单
const onCloseMenu = useCallback(() => {
setChildMenuType('')
setShowFloatingBubble(false)
console.log('onCloseMenu');
setMenuVisible(false);
}, []);
// 初始化电子书
const initBook = async () => {
if (!viewerRef.current) return;
if(epubUrl){
setBookLoading(true)
}else{
Toast.show({ content: '电子书加载失败' })
setTimeout(()=>navigate(-1) ,1000)
}
// 清理旧实例
if (renditionRef.current) {
renditionRef.current.destroy();
renditionRef.current = null;
viewerRef.current.innerHTML = ''; // 清空容器
}
if (bookRef.current) {
bookRef.current.destroy();
bookRef.current = null;
}
// 创建新实例
const book = EpubJS(epubUrl, { version: 3 });
bookRef.current = book;
// 等待书籍加载完毕
await book.ready.catch(error => {
console.error('Book ready failed:', error);
throw error; // 向上抛出异常
});
console.log('书籍加载完毕了');
setBookLoading(false)
const defaultParams = {
width: "100%",
height: "100%",
spread: "none",
flow: currentFlow, // 动态flow参数
manager:currentFlow === 'scrolled' ? 'continuous' : 'default',
allowPopups: true,
allowScriptedContent: true,
explicitWidth: true,
}
if(isIOS && isWechat){
defaultParams.method = 'inline'
}
renditionRef.current = bookRef.current.renderTo(viewerRef.current, {
...defaultParams,
styles: {
body: {
// 强制重置滚动位置
"overflow-y": "auto !important",
"scroll-behavior": "auto !important",
"min-width": "100% !important" // 强制最小宽度
}
}
});
loadMetadata();
spineItems = book.spine.spineItems;
// 阻止默认右键菜单
renditionRef.current.hooks.content.register((contents) => {
const body = contents.window.document.querySelector('body')
if(isIOS && isWechat){
// 设置body下的图片的宽度最大100vw 并且这个这是优先级最高
const iframeDocument = contents.window.document;
const styleElement = iframeDocument.createElement('style');
iframeDocument.head.appendChild(styleElement);
styleElement.textContent = `
img {
max-width: 100% !important;
height: auto;
}
`;
}
if (body) {
body.oncontextmenu = () => false
body.keydown = (ev) => {
if(ev.keyCode === 123){
ev.preventDefault()
return false
}
}
if(!userInfo.levelId){
body.style.userSelect = 'none'
body.style.webkitUserSelect = 'none';
body.style.mozUserSelect = 'none';
body.style.msUserSelect = 'none';
body.style.webkitTouchCallout = 'none';
body.style.pointerEvents = 'none'
}else{
body.style.userSelect = 'auto'
body.style.webkitUserSelect = 'auto';
body.style.mozUserSelect = 'auto';
body.style.msUserSelect = 'auto';
body.style.webkitTouchCallout = 'auto';
body.style.pointerEvents = 'auto'
}
}
})
// 显示内容
currentCfi.current ? renditionRef.current.display(currentCfi.current.end).catch((error) => {
console.error("display 渲染失败:", error);
}) : renditionRef.current.display().catch((error) => {
console.error("display 渲染失败:", error);
});
// 绑定事件
bindRenditionEvents();
callBackReady({
bookId:bookId,
chapter: getCurrentChapter(),
location: JSON.stringify(currentCfi.current),
progress: await getCFIProgress(),
})
renditionRef.current.themes.fontSize(`${fontSize}px`);
onChangeTheme(theme)
};
// 加载元数据
const loadMetadata = async () => {
try {
if(!isFree){
const { data } = await checkDownloadApi({ bookId: bookId });
setCanReaderBookAllContent(data)
}
const metadata = await bookRef.current.loaded.metadata;
console.log('Book metadata:', metadata);
const toc = await bookRef.current.loaded.navigation;
function processItems(items) {
let counter = 0; // 全局计数器
let firstOverFiveId = null; // 记录第一个超过5的ID
const clonedItems = deepClone(items); // 深拷贝原始数据避免污染
// 深拷贝函数(递归处理子项)
function deepClone(data) {
if (!Array.isArray(data)) return data;
return data.map(item => ({
...item,
subitems: item.subitems ? deepClone(item.subitems) : undefined
}));
}
// 递归遍历并修改节点
function traverse(items) {
for (const item of items) {
counter++;
if(canReaderBookAllContent){
item.lock = true
}else {
item.lock = counter > 3 && userInfo.levelId < 1 && !isFree; // 动态添加 lock 字段
}
// 记录第一个超过4的ID(仅第一次)
if (counter === 4 && !firstOverFiveId) {
firstOverFiveId = String(item.href).split('#')[0];
}
// 处理epub3.0的href无法跳转,暂时先以这种方案。
if(item.href === item.id){
item.href = `Text/${item.href}`
}
// 递归处理子项
if (item.subitems) {
traverse(item.subitems);
}
}
}
traverse(clonedItems); // 处理拷贝后的数据
return {
processedData: clonedItems,
firstOverFiveId
};
}
// 使用示例
const { processedData, firstOverFiveId } = processItems(toc.toc);
tocRef.current = processedData
console.log('processedData', processedData);
setStartPayToc(firstOverFiveId)
} catch (error) {
console.error('Error loading book metadata:', error);
}
};
// 绑定公共事件
const bindRenditionEvents = () => {
if (!renditionRef.current) return;
// renditionRef.current.on('rendered', function(section) {
// console.log('section:', section);
// const images = section.document.querySelectorAll('img');
// images.forEach(async img => {
// const src = img.getAttribute('src');
// if (src.includes(':') || src.includes(':') || src.includes('webp')) {
// const newPath = await customRequest(src)
// // 创建新路径
// const newSrc = newPath
// // 添加缓存破坏参数
// img.src = newSrc + '?t=' + Date.now();
// }
// });
// });
// 触摸事件处理
renditionRef.current.on('touchstart', (ev) => {
touchStartRef.current = ev.touches[0].clientX;
// Toast.show(`touchstart`)
});
renditionRef.current.on('touchend', (ev) => {
const currentHref = String(currentCfi.current.href).split('_split_')[0]
if(currentHref === startPayToc ){
if(!isFree && userInfo.levelId < 1 ){
return callBackPayBook()
}
}
if(currentFlow === 'paginated'){
const translateX = ev.changedTouches[0].clientX - touchStartRef.current;
if (Math.abs(translateX) > 30) {
if(!isIOS){
translateX > 0 ? renditionRef.current.prev() : renditionRef.current.next();
}
setShowFloatingBubble(false)
}
}
});
// 位置变化监听
renditionRef.current.on('locationChanged', (loc) => {
console.log('locationChanged', loc);
getCFIProgress(loc.start)
getCurrentChapter()
currentCfi.current = loc;
});
renditionRef.current.on('selected', (cfiRange) => {
const range = renditionRef.current.getRange(cfiRange); // 获取 DOM Range 对象
console.log('Selected Text:', range.getBoundingClientRect());
if (range && userInfo.levelId) {
const selectedText = range.toString(); // 提取选中的文本
if (selectedText) {
setSelectedText(selectedText);
setShowFloatingBubble(true)
}
}else{
setShowFloatingBubble(false)
}
});
// 点击事件
renditionRef.current.on('click', () => {
setShowFloatingBubble(false)
setMenuVisible(prev => !prev);
setShowFloatingBubble(false)
});
// 全局错误监听
renditionRef.current.on("error", (error) => {
console.error("全局错误:", error);
});
// 资源级错误监听
renditionRef.current.on("asset:error", (error) => {
console.error("资源错误:", error.url);
});
};
function parseCFI(cfi) {
// 示例 CFI: epubcfi(/6/4[chap01]!/4/2/1:3)
const match = cfi.match(/epubcfi\((\/\d+.*)\)/);
if (!match) return null;
const cfiPath = match[1];
const parts = cfiPath.split('!');
if (parts.length < 2) return null;
// 提取 Spine 项索引(如 "/6/4" -> 索引为 4)
const spinePath = parts[0];
const spineIndexMatch = spinePath.match(/\/(\d+)\/(\d+)/);
return spineIndexMatch ? parseInt(spineIndexMatch[2], 10) - 1 : 0; // 索引从 0 开始
}
const getCFIProgress = async () => {
const spineItemIndex = parseCFI(currentCfi.current.start || '');
let curProgress = (((spineItemIndex + 1) / spineItems.length) * 100)/ 2;
console.log('curProgress', curProgress.toFixed(2));
let value = Number(curProgress.toFixed(2));
setProgress(value);
return value
};
// 初始化组件
useEffect(() => {
// 获取当前浏览器
// const browser = navigator.userAgent;
// Toast.show({
// content: `当前浏览器: ${browser}`,
// duration: 5000,
// });
initBook();
return async () => {
const progress = await getCFIProgress()
if(progress && progress != Infinity && progress != -Infinity){
endReadApi({
bookId:bookId,
chapter:getCurrentChapter(),
location: JSON.stringify(currentCfi.current),
progress: await getCFIProgress(),
})
}
bookRef.current?.destroy();
renditionRef.current?.destroy();
};
}, []);
useEffect(() => {
initBook();
}, [currentFlow]);
useEffect(() => {
callBackMenu && callBackMenu({ visible: menuVisible });
}, [menuVisible, callBackMenu]);
// 切换滚动模式
const onChangeScrollType = (type) => {
if(!isFree && userInfo.levelId < 1 ){
return callBackPayBook()
}
// 更新flow类型
setCurrentFlow(type);
onCloseMenu()
};
// 修改字体大小
const onChangeFontSize = (size) => {
setFontSize(size);
renditionRef.current?.themes.fontSize(`${size}px`);
};
// 主题切换
const onChangeTheme = useCallback((newTheme) => {
setTheme(newTheme);
if (!renditionRef.current) return;
const themes = renditionRef.current.themes;
const styles = {
dark: {
color: '#BCBCBC',
background: '#121212'
},
light: {
color: '#1C1C1C',
background: '#F8F9FD'
}
};
themes.override('color', styles[newTheme].color);
themes.override('background', styles[newTheme].background);
callBackTheme(newTheme)
}, []);
// 目录
const onTocNavigation = (item) => {
if (item.href) {
if(item.lock) {
setMenuVisible(false)
return callBackPayBook()
}
renditionRef.current.display(item.href);
setTimeout(() => setMenuVisible(false), 600);
}
};
// 笔记
const [childMenuType, setChildMenuType] = useState('')
// 获取章节信息
const getCurrentChapter = () => {
// 2. 通过spine索引获取当前章节的URL
const currentHref = String(currentCfi.current.href).split('_split_')[0]
// 3. 在navigation.toc中匹配章节
let currentChapter = null;
// 递归查找匹配的目录项
function findChapter(items) {
for (const item of items) {
// 对比章节链接是否匹配
const chapterHref = String(item.href).split('#')[0].split('_split_')[0]
if (chapterHref === currentHref) {
currentChapter = item;
return true;
}
if (item.subitems && findChapter(item.subitems)) {
return true;
}
}
return false;
}
findChapter(tocRef.current);
let value = currentChapter?.label || "";
setChapter(value);
return value
}
const onSaveBijiChange = async (text) => {
const chapter = getCurrentChapter()
addNoteApi({
bookId:bookId,
note:text,
chapter:selectedText,
location: JSON.stringify(currentCfi.current),
progress: await getCFIProgress(),
})
console.log(text);
setSelectedText('');
setChildMenuType('')
setShowFloatingBubble(false)
};
const addNote = () => {
console.log('addNote');
setChildMenuType('biji')
setMenuVisible(true);
setShowFloatingBubble(false)
};
// 新增的 useEffect 监听
useEffect(() => {
if (!menuVisible) {
onCloseMenu(); // 状态变为 false 时执行关闭菜单逻辑
}
}, [menuVisible, onCloseMenu]); // 确保 onCloseMenu 是稳定的引用
const nextPage = () => {
const currentHref = String(currentCfi.current.href).split('_split_')[0]
if(currentHref === startPayToc ){
if(!isFree && userInfo.levelId < 1 ){
return callBackPayBook()
}
}
if (renditionRef.current) {
renditionRef.current.next();
}
}
const prevPage = () => {
if (renditionRef.current) {
renditionRef.current.prev();
}
}
return (
<div style={{ position: 'relative', height: '100%' }}>
<div
ref={viewerRef}
style={{
height: "100%",
width: "100%",
overflow: 'hidden',
backgroundColor: theme === 'dark' ? '#121212' : '#F8F9FD'
}}
/>
{showFloatingBubble && <FloatingBubble
axis='xy'
magnetic='x'
style={{
'--initial-position-top': '35%',
'--initial-position-right': '2px',
'--edge-distance': '2px',
'--z-index': '9999',
'--background': 'rgba(0, 0, 0, 0.7)',
}}
>
<div className='floating-bubble-button'>
<div className='floating-bubble-button-item' onClick={()=>{
util.copyToClipboard(selectedText)
setSelectedText('');
setShowFloatingBubble(false)
}}>
<Icon size='38' src='/images/reader/icon_copy@2x.png' />
<div>复制</div>
</div>
<div className='floating-bubble-button-item' onClick={() => addNote()}>
<Icon size='38' src='/images/reader/icon_add_notes@2x.png' />
<div>添加笔记</div>
</div>
</div>
</FloatingBubble>}
{ currentFlow =='paginated' && <div className='adm-floating-bubble-button-page'>
<FloatingBubble
axis='xy'
magnetic='x'
style={{
'--initial-position-top': '60%',
'--initial-position-right': '2px',
'--edge-distance': '2px',
'--background': 'rgba(0, 0, 0, 0.6)',
}}
>
<div className='floating-bubble-page'>
<div className='floating-bubble-page-item' onClick={()=> prevPage()}>上一页</div>
<div className='divider-line'></div>
<div className='floating-bubble-page-item' onClick={()=> nextPage()}>下一页</div>
</div>
</FloatingBubble>
</div>}
<div className='process'>
<div className='left'>{chapter}</div>
<div className='right'>{progress}%</div>
</div>
<Mask
visible={bookLoading}
opacity='thin'
>
<div className='reader-loading'>
<div className='reader-loading-text'>书籍加载中<DotLoading color='white' /></div>
</div>
</Mask>
<ReaderMenu
menuVisible={menuVisible}
onChangeFontSize={onChangeFontSize}
onChangeScrollType={onChangeScrollType}
theme={theme}
onChangeTheme={onChangeTheme}
currentFlow={currentFlow} // 新增传递当前flow状态
toc={tocRef.current}
selectedText={selectedText}
currentCfi={currentCfi.current}
onTocNavigation={onTocNavigation}
onCloseMenu={onCloseMenu}
onSaveBijiChange={onSaveBijiChange}
childMenuType={childMenuType}
/>
</div>
);
};
export default CustomEpubReader;
读书组件css
.adm-floating-bubble-button{
border-radius: 15px !important;
width: auto;
font-size: 27px;
min-height: 135px;
color: #FFFFFF;
.floating-bubble-button{
padding: 27px 10px;
display: flex;
align-items: center;
padding: 27px;
.floating-bubble-button-item{
width: 110px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
}
}
.adm-floating-bubble-button-page{
.adm-floating-bubble-button{
height: 280px !important;
}
.floating-bubble-page{
text-align: center;
width: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 0 10px;
.divider-line{
width: 100%;
height: 1px;
margin: 20px 0;
background-color: #FFFFFF;
}
.floating-bubble-page-item{
word-break: break-all;
word-wrap: break-word;
&:first-child{
margin-top: 0;
}
}
}
}
.reader-loading{
position: absolute;
top: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
.reader-loading-text{
// background-color: #FFFFFF;
border-radius: 10px;
height: 50px;
font-size: 31px;
color: #FFFFFF;
width: 100vw;
text-align: center;
}
}
.process{
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 112px;
// background: rgba($color: #000000, $alpha: 0.2);
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 60px 20px 0;
}
ReaderMenu组件
import { Popup, SafeArea, Slider, Input, Ellipsis } from 'antd-mobile'
import './index.scss'
import Icon from '@/components/common/Icon'
import React, { useState, useRef, useEffect } from 'react'
import classNames from 'classnames'
import { useSelector } from 'react-redux'
let ReaderMenuList = [
{
icon:'/images/reader/catalog.png',
acitveIcon: '/images/reader/catalog-active.png',
darkIcon: '/images/reader/catalog-dark.png',
type: 'catalog'
},
{
icon:'/images/reader/biji.png',
acitveIcon: '/images/reader/biji-active.png',
darkIcon: '/images/reader/biji-dark.png',
type: 'biji'
},
{
icon:'/images/reader/light.png',
acitveIcon: '/images/reader/light.png',
darkIcon: '/images/reader/light-dark.png',
type: 'light',
theme: 'dark'
},
{
icon:'/images/reader/text_setting.png',
acitveIcon: '/images/reader/text_setting-active.png',
darkIcon: '/images/reader/text_setting-dark.png',
type: 'text_setting'
}
]
const ReaderMenu = ({
menuVisible,
onCloseMenu,
onChangeFontSize,
onChangeScrollType,
theme,
onChangeTheme,
toc,
selectedText,
currentCfi,
onTocNavigation,
onSaveBijiChange,
childMenuType,
currentFlow
}) => {
const { userInfo } = useSelector(state => state.login)
const [ readerMenuList, setReaderMenuList] = useState([])
useEffect(() => {
if(!userInfo.levelId){
setReaderMenuList(ReaderMenuList.filter(item => item.type !== 'biji'))
}else{
setReaderMenuList(ReaderMenuList)
}
}, [])
// 字体
const [ fontSize, setFontSize ] = useState(16)
const changeFontSize = (value) => {
setFontSize(value)
onChangeFontSize(value)
}
// 滚动方式
const [ showScrollModule, setShowScrollModule ] = useState(false)
// const [ scrollType, setScrollType ] = useState('paginated')
const selectScrollType = (type) => {
// setScrollType(type)
onChangeScrollType(type)
setShowScrollModule(false)
}
// 目录
const tocViewerRef = useRef(null)
const scrollToEnd = () => {
if(tocViewerRef.current){
tocViewerRef.current.scrollTop = 99999
}
}
const clickToc = (event, item) => {
// 阻止冒泡
event.stopPropagation()
onTocNavigation(item)
}
// 笔记
const inputRef = useRef()
const [inputVal, setInputVal] = useState('')
const saveBiji = () => {
onSaveBijiChange(inputVal)
closeMenu()
}
// 切换菜单
const [menuType, setMenuType ] = useState('')
useEffect(() => setMenuType(childMenuType), [childMenuType])
useEffect(() => {
if(menuType === 'biji'){
inputRef.current.focus()
}
}, [menuType])
const onSelectMenuType = (item) => {
if(item.type === 'light'){
const newTheme = theme === 'light' ? 'dark' : 'light';
onChangeTheme(newTheme);
}else{
setMenuType(item.type)
}
if(menuVisible && menuType === item.type && !['light', 'biji'].includes(item.type)) closeMenu()
}
const closeMenu = () => {
setShowScrollModule(false)
setMenuType('')
setInputVal('')
onCloseMenu()
}
return <Popup
mask={false}
visible={menuVisible}
>
<div className={classNames('reader-menu-content', theme === 'dark' && 'dark-theme')}>
{/* 目录 */}
{
menuType === 'catalog' && <div className={classNames('toc-box', theme === 'dark' && 'dark-theme')}>
<div className='toc-to-bottom' onClick={() => scrollToEnd()}>
<Icon src='/images/reader/icon_bottoms@2x.png' size={23} />
<span>去底部</span>
</div>
<div className='toc-box-content' ref={tocViewerRef}>
{
toc.map((item, index) => <div className='toc-item' onClick={(event) => clickToc(event, item)} key={index}>
<div className={classNames('toc-item-label', item.href.includes(String(currentCfi.href).split('_split_')[0]) && 'toc-label-acitve')}>
<div className='toc-item-label-text'>{item.label}</div>
{ item.lock ? <img src="/images/common/lock.png" alt="" /> : ''}
</div>
{
item.subitems.length > 0 && item.subitems.map((it, idx) => <div key={idx} onClick={(event) => clickToc(event, it)} className='toc-item-child'>
<div className={classNames('toc-item-label', (it.href.includes(String(currentCfi.href).split('_split_')[0])) && 'toc-label-acitve')}>
<div className='toc-item-label-text'>{it.label}</div>
{ it.lock ? <img src="/images/common/lock.png" alt="" /> : ''}
</div>
</div>)
}
</div>)
}
</div>
</div>
}
{
menuType === 'biji' && <div className={classNames('biji-box', theme==='drak' && 'dark-theme')}>
{selectedText && <div className='biji-box-content-header'>
<Ellipsis direction='end' content={selectedText} />
</div>}
<div className='biji-box-content-input'>
{
theme === 'light' ? <Icon size='38' src='/images/reader/icon_notes@2x.png' />
: <Icon size='38' src='/images/reader/biji-dark.png' />
}
<Input ref={inputRef} className='biji-box-input' type="text" value={inputVal}
onChange={val => setInputVal(val)}
placeholder='写下你的想法...' />
<div className='biji-box-input-btn' onClick={()=> saveBiji()}>保存</div>
</div>
</div>
}
{
menuType === 'text_setting' && <>
{
showScrollModule ? <div className='scroll-info-content'>
<div className='scroll-info-content-header'>
<Icon onClick={() => setShowScrollModule(false)} className='close-icon' src='/images/reader/icon_put_away@2x.png' size={25} />
<div>翻页方式</div>
</div>
{/* <div className={classNames('scroll-model-item',
currentFlow === 'scrolled' && 'scroll-model-item_active' )}
onClick={() => selectScrollType('scrolled')}>
<span>上下滚动</span>
{ currentFlow === 'scrolled' && <Icon size='27' src='/images/reader/check.png' /> }
</div> */}
<div className={classNames('scroll-model-item',
currentFlow === 'paginated' && 'scroll-model-item_active' )} onClick={() => selectScrollType('paginated')}>
<span>左右滚动</span>
{ currentFlow === 'paginated' && <Icon size='27' src='/images/reader/check.png' /> }
</div>
</div>:<>
{/* 设置字体和翻页方式 */}
<div className='reader-size-content'>
<div className='reader-size-lider'>
<div className='reader-size-lider-text'>
<div className='min'>A</div>
<div className='max'>A</div>
</div>
<Slider onChange={changeFontSize}
min={12}
max={30}
defaultValue={fontSize}
style={{
'--fill-color': '#E3E3E3',
}}
icon={<div className='font-size-box'>{fontSize}</div>} />
</div>
<div className='reader-size-lider scroll-box' onClick={() => setShowScrollModule(true)}>
<div>翻页方式</div>
<div> { currentFlow==='scrolled' ?'上下滚动':'左右滚动' } <Icon className='arrow' size={19} src="/images/common/icon_put_away@2x.png" /></div>
</div>
</div>
</>
}
</>
}
</div>
<div className={classNames('reader-menu', theme==='dark' && 'dark-theme')}>
{
readerMenuList.map((item, index) => <div
key={index}
onClick={() => onSelectMenuType(item)}
className='reader-menu-item'>
<Icon
src={
theme === 'dark' ? item.darkIcon : (
menuType === item.type ? item.acitveIcon : item.icon
)
}
size='38'/>
</div>)
}
</div>
<SafeArea position='bottom' />
</Popup>
}
export default ReaderMenu
ReaderMenu组件CSS
@mixin flxe-box{
display: flex;
align-items: center;
}
.reader-menu{
height: 122px;
display: flex;
align-items: center;
justify-content: space-between;
.reader-menu-item{
min-width: 25%;
display: flex;
justify-content: center;
}
}
.reader-menu-content{
.reader-size-content{
padding:0 38px;
padding-top: 38px;
padding-bottom: 1px;
}
.reader-size-lider{
background-color: hsl(0, 0%, 96%);
border-radius: 34px;
overflow: hidden;
position: relative;
margin-bottom: 76px;
.reader-size-lider-text{
width: calc(100% - 69px);
height: 100%;
padding: 0 38px;
position: absolute;
top: 0;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
.min{
font-size: 19px;
}
.max{
font-size: 25px;
}
}
.adm-slider{
padding: 0;
width: calc(100% - 69px);
margin: 0 auto;
.adm-slider-track-container{
padding:0;
}
}
.adm-slider-track{
height: 66px !important;
border-radius: 34px;
.adm-slider-fill{
height: 66px !important;
border-radius: 34px;
margin:0 -35px;
padding:0 32px;
}
}
.adm-slider-thumb-container{
width: 66px !important;
height: 66px !important;
.adm-slider-thumb{
width: 66px !important;
height: 66px !important;
}
.font-size-box{
width: 66px !important;
height: 66px !important;
background-color: #FFFFFF;
color: #050041;
border-radius: 50%;
text-align: center;
line-height: 66px;
box-shadow: 0px 8 11px 0px rgba(0,0,0,0.15);
}
}
}
.scroll-box{
@include flxe-box;
justify-content: space-between;
padding:0 38px;
height: 69px;
background-color: #F4F4F4;
color: #868B95;
.arrow {
// 旋转180度
transform: rotate(-90deg);
margin-top: 2px;
}
}
.scroll-info-content{
font-size: 31px;
padding:0 38px;
.scroll-info-content-header{
font-size: 31px;
height: 109px;
@include flxe-box;
justify-content: center;
text-align: center;
position: relative;
border-bottom: 1px solid #F0F0F0;
margin-bottom: 28px;
.close-icon{
position: absolute;
left: 0;
}
}
.scroll-model-item{
width: 100%;
@include flxe-box;
justify-content: space-between;
padding:28px 0;
}
.scroll-model-item_active{
color: #381480;
}
}
.toc-box{
@mixin item-label{
font-size: 31px;
color: #868B95;
min-height: 118px;
display: flex;
align-items: center;
justify-content: space-between;
.toc-item-label-text{
width: calc(100% - 40px);
}
img{
width: 23px;
height: 23px;
}
}
background-color: #F8F9FD;
height: calc(100vh - 122px);
.toc-to-bottom{
padding: 0 38px;
display: flex;
justify-content: flex-end;
align-items: center;
font-size: 23px;
height: 100px;
border-bottom: 1px solid #F0F0F0;
span{
margin-left: 6px;
}
}
.toc-box-content{
height: calc(100% - 100px);
overflow-y: auto;
padding: 0 38px;
.toc-item{
min-height: 118px;
border-bottom: 1px solid #F0F0F0;
.toc-item-label{
@include item-label;
}
&:last-child{
border: none;
}
.toc-item-child{
padding-left: 55px;
border-top: 1px solid #F0F0F0;
font-size: 31px;
color: #868B95;
.toc-item-label{
@include item-label;
}
}
.toc-label-acitve{
font-weight: bold;
font-size: 31px;
color: #381480 !important;
}
}
}
}
.biji-box{
padding: 25px 38px;
width: calc(100% - 76px);
.biji-box-content-header{
height: 46px;
width: calc(100% - 10px);
display: flex;
align-items: center;
font-size: 27px;
color: #868B95;
border-left: 4px solid #F0F0F0;
margin-bottom: 25px;
padding-left: 10px;
}
.biji-box-content-input{
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid #F0F0F0;
padding-top: 31px;
.biji-box-input{
margin: 0 11px;
font-size: 31px;
color: #1C1C1C;
flex: 1;
}
.biji-box-input-btn{
color: #411be7;
font-size: 31px !important;
font-weight: bold;
width: 70px;
}
}
}
}
.dark-theme{
background-color: #1E1E1E !important;
color: #BCBCBC !important;
}
未使用到的一些功能
1. 选中文字进行高亮 划线 标记
rendition.on("selected", (cfiRange, contents) => {
// cfiRange 表示选中文字的范围
// 高亮
rendition.annotations.highlight(cfiRange, {
// 属性 例如 name:"name" -> data-name='name'
}, function() {
// 点击事件回调
}, 'className', {
// style
});
// cfiRange字符可作为标识存储记录,利用这一点可和后端配合延伸笔记等功能
// 设置高亮 划线 标记等是在dom同位置创建了svg,所以增加className为修饰svg元素。
// 划线
rendition.annotations.underline(cfiRange, {}, function() { }, 'className', {});
// 标记
rendition.annotations.mark(cfiRange, {}, function() { }, 'className', {});
// 取消选中文字
contents.window.getSelection().removeAllRanges();
})
// 添加
rendition.annotations.add(cfiRange,'highlight|underline|mark')
// 取消
rendition.annotations.remove(cfiRange,'highlight|underline|mark')
//判断当前选中区域是否存在已经选中过
// 记录已经选中并且标记的范围 数组
let cfiRangeArr = [];
const handleAddCf = (cfiRange: string): boolean => {
let cfiParts = cfiRange.split(',');
// 起始点
let _startCfi = cfiParts[0] + cfiParts[1] + ')';
// 终点
let _endCfi = cfiParts[0] + cfiParts[2];
let EF = new EpubCFI();
for (let i = 0; i < efiarr.length; i++) {
let itemEf = efiarr[i];
let { startCfi, endCfi } = itemEf;
// 四个点
let s1_s2 = EF.compare(startCfi, _startCfi);
let e1_s2 = EF.compare(endCfi, _startCfi);
// 判断 后者的起始点是否在前者的区间内
if (s1_s2 == -1 && e1_s2 == 1 || s1_s2 == 0 || e1_s2 == 0) {
console.log("起始点在区间内");
return false;
}
let s1_e2 = EF.compare(startCfi, _endCfi);
let e1_e2 = EF.compare(endCfi, _endCfi);
if (s1_e2 == -1 && e1_e2 == 1 || s1_e2 == 0 || e1_e2 == 0) {
console.log("终点在区间内");
return false;
}
if (s1_s2 == -1 && e1_e2 == 1) {
console.log("不能在已经标记内容内选择");
return false;
}
if (s1_s2 == 1 && e1_e2 == -1) {
console.log("选择范围内包含了已经标记内容");
return false;
}
}
cfiRangeArr.push({
cfiRange,
startCfi: _startCfi,
endCfi: _endCfi
});
return true;
}
rendition.on("selected", (cfiRange: string, contents: Contents) => {
if (!handleAddCf(cfiRange)) {
contents.window.getSelection().removeAllRanges();
return;
}
// 进行标记
});
2. 全文搜索 与 当前页搜索
const handleSearch = async (search:string) => {
let list:any[] = await Promise.all(
book.spine.spineItems.map(section => section.load(book.load
.bind(book))
.then(section.find.bind(section, search))
.finally(section.unload.bind(section)))
).then(results => Promise.resolve([].concat.apply([], results)))
console.log(list);
list.map((item) => {
let cfiRange = item.cfi;
})
}
//当前页搜索
const handleSearch = (search:string) => {
let list: any[] = book.spine.get(current.value).find(search);
}
3. 卸载
window.addEventListener("unload", function () {
console.log("unloading");
book.destroy();
});
依赖版本相关
"antd-mobile": "^5.33.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"epubjs": "^0.3.93",
"sass": "^1.86.3"
"react-redux": "^8.1.3",
"classnames": "^2.5.1",
相关库地址
epubjs:github.com/futurepress… antd-mobile:mobile.ant.design/zh classnames: github.com/JedWatson/c…
结语
上述代码为个人需求功能所需api运用,不完全对标Epubjs文档。且React为初学水平运用很水,不建议采纳,其中错误欢迎大佬指正。