modules alpha version init

main
NB 4 years ago
parent e22b6ad1be
commit 0bfa6da53f
  1. 40
      .babelrc
  2. 205
      .gitignore
  3. 11
      .npmignore
  4. 26
      README.md
  5. 92
      package.json
  6. 37
      public/css/global.css
  7. BIN
      public/favicons/favicon_24x24.png
  8. BIN
      public/favicons/favicon_48x48.png
  9. BIN
      public/favicons/favicon_96x96.png
  10. BIN
      public/files/Alices Adventures in Wonderland.epub
  11. 22
      public/index.html
  12. 15
      public/manifest.json
  13. 3
      public/robots.txt
  14. 44
      src/LoadingView.tsx
  15. 90
      src/components/commons/Snackbar.tsx
  16. 15
      src/components/commons/ViewerWrapper.tsx
  17. 72
      src/components/contextMenu/ColorItem.tsx
  18. 34
      src/components/contextMenu/Item.tsx
  19. 65
      src/components/contextMenu/Wrapper.tsx
  20. 28
      src/components/footer/Item.tsx
  21. 56
      src/components/footer/MoveBtn.tsx
  22. 17
      src/components/footer/Wrapper.tsx
  23. 34
      src/components/header/ControlBtn.tsx
  24. 132
      src/components/header/ControlIconBtn.tsx
  25. 34
      src/components/header/Layout.tsx
  26. 20
      src/components/header/VerticalLine.tsx
  27. 16
      src/components/header/Wrapper.tsx
  28. 11
      src/components/learning/Layout.tsx
  29. 34
      src/components/learning/SortedBtn.tsx
  30. 10
      src/components/learning/SortedBtnWrapper.tsx
  31. 21
      src/components/learning/highlight/PageNum.tsx
  32. 24
      src/components/learning/highlight/Post.tsx
  33. 9
      src/components/learning/highlight/Title.tsx
  34. 32
      src/components/learning/highlight/Wrapper.tsx
  35. 50
      src/components/nav/BookInfo.tsx
  36. 42
      src/components/nav/NavItem.tsx
  37. 79
      src/components/option/Dropdown.tsx
  38. 33
      src/components/option/DropdownItem.tsx
  39. 44
      src/components/option/DropdownItemWrapper.tsx
  40. 83
      src/components/option/DropdownValue.tsx
  41. 8
      src/components/option/Layout.tsx
  42. 9
      src/components/option/OptionTitle.tsx
  43. 18
      src/components/option/OptionValue.tsx
  44. 7
      src/components/option/OptionWrapper.tsx
  45. 56
      src/components/option/Slider.tsx
  46. 130
      src/components/option/SliderValue.tsx
  47. 39
      src/components/sideMenu/CloseBtn.tsx
  48. 23
      src/components/sideMenu/MenuEmpty.tsx
  49. 65
      src/components/sideMenu/Wrapper.tsx
  50. 25
      src/containers/Footer.tsx
  51. 125
      src/containers/Header.tsx
  52. 218
      src/containers/Reader.tsx
  53. 207
      src/containers/commons/ContextMenu.tsx
  54. 56
      src/containers/commons/Snackbar.tsx
  55. 52
      src/containers/menu/Nav.tsx
  56. 100
      src/containers/menu/Note.tsx
  57. 137
      src/containers/menu/Option.tsx
  58. 71
      src/containers/menu/commons/Highlight.tsx
  59. 6
      src/index.tsx
  60. 261
      src/lib/hooks/useHighlight.ts
  61. 124
      src/lib/hooks/useMenu.ts
  62. 62
      src/lib/styles/animations.ts
  63. 31
      src/lib/styles/media.ts
  64. 79
      src/lib/styles/palette.ts
  65. 24
      src/lib/styles/readerStyle.css
  66. 35
      src/lib/styles/styles.ts
  67. 14
      src/lib/styles/viewerLayout.ts
  68. 13
      src/lib/styles/zIndex.ts
  69. 36
      src/lib/svg/BookClose.js
  70. 32
      src/lib/svg/BookOpen.js
  71. 23
      src/lib/svg/Bookmark.js
  72. 22
      src/lib/svg/Bookmarker.js
  73. 26
      src/lib/svg/Close.js
  74. 35
      src/lib/svg/Controller.js
  75. 22
      src/lib/svg/Down.js
  76. 29
      src/lib/svg/Left.js
  77. 18
      src/lib/svg/Menu.js
  78. 26
      src/lib/svg/Next.js
  79. 26
      src/lib/svg/Prev.js
  80. 38
      src/lib/svg/ScrollHorizontal.js
  81. 33
      src/lib/svg/ScrollVertical.js
  82. 27
      src/lib/svg/Search.js
  83. 10
      src/lib/svg/TempIcon.tsx
  84. 1
      src/lib/svg/bookClose.svg
  85. 1
      src/lib/svg/bookOpen.svg
  86. 1
      src/lib/svg/bookmark.svg
  87. 1
      src/lib/svg/bookmarker.svg
  88. 1
      src/lib/svg/close.svg
  89. 1
      src/lib/svg/controller.svg
  90. 1
      src/lib/svg/down.svg
  91. 31
      src/lib/svg/index.ts
  92. 1
      src/lib/svg/left.svg
  93. 1
      src/lib/svg/menu.svg
  94. 1
      src/lib/svg/next.svg
  95. 1
      src/lib/svg/prev.svg
  96. 1
      src/lib/svg/scrollHorizontal.svg
  97. 1
      src/lib/svg/scrollVertical.svg
  98. 1
      src/lib/svg/search.svg
  99. 201
      src/lib/utils/commonUtil.ts
  100. 287
      src/modules/epubViewer/EpubViewer.tsx
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,40 @@
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"module-resolver",
{
"root": "./src",
"extensions": [
".ts",
".tsx"
]
}
],
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-proposal-optional-chaining",
"babel-plugin-styled-components"
],
"ignore": [
"src/components",
"src/containers",
"src/lib/hooks",
"src/lib/styles",
"src/lib/svg",
"src/slices",
"src/types",
"src/index.tsx",
"src/react-app-env.d.ts",
"src/setupTests.ts"
]
}

205
.gitignore vendored

@ -0,0 +1,205 @@
# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,linux,node
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,linux,node
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env*.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Storybook build outputs
.out
.storybook-out
storybook-static
# rollup.js default build output
dist/
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Temporary folders
tmp/
temp/
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,node
# Custom ignore files or folders
/lib

@ -0,0 +1,11 @@
src/
test/
.gitignore
.babelrc
.babelrc.js
tsconfig.json
public
build
desktop.ini
.DS_Store
.github

@ -1,2 +1,26 @@
# react-epub-viewer
Epub Viewer for React.js
Epub Viewer for React.js powered by [Epub.js](https://github.com/futurepress/epub.js/)
## EpubViewer
If you put ref as an argument, the module will register the viewer as an `iframe` in the ref. Also, there are some functions written in the ref by default.
- [x] **`handleKeyPress`** Function
- [x] **`onLocationChange`** Function
- [x] **`selectionChanged`** Function
## ReactViewer
- [x] **`onTocChange`** Event
- [x] **`onBookInfoChange`** Event
- [x] **`onPageChange`** Event
- [x] **`onSelection`** Event
- Fire with `mouseup` event
- [x] Custom Loading Component

@ -0,0 +1,92 @@
{
"name": "@altmshfkgudtjr/react-epub-viewer",
"version": "0.0.1",
"author": "NB",
"description": "Epub viewer for React.js powered by Epub.js",
"license": "MIT",
"keywords": [
"epub",
"viewer",
"React"
],
"main": "lib/modules/index.js",
"files": [
"lib",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/altmshfkgudtjr/react-epub-viewer"
},
"bugs": {
"url": "https://github.com/altmshfkgudtjr/react-epub-viewer/issues"
},
"scripts": {
"start": "react-scripts start",
"prebuild": "react-scripts test --watchAll=false --passWithNoTests",
"build": " react-scripts build",
"test": "react-scripts test",
"clean": "rimraf lib",
"precompile": "react-scripts test --watchAll=false --passWithNoTests && yarn clean",
"compile": "babel src --out-dir lib --extensions .ts,.tsx",
"postcompile": "cp -r ./src/types ./lib/types"
},
"dependencies": {
"@reduxjs/toolkit": "^1.5.0",
"epubjs": "^0.3.88",
"react-redux": "^7.2.2",
"throttle-debounce": "^3.0.1"
},
"devDependencies": {
"@babel/cli": "^7.13.14",
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-proposal-object-rest-spread": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.12",
"@babel/preset-env": "^7.13.12",
"@babel/preset-react": "^7.13.13",
"@babel/preset-typescript": "^7.13.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-redux": "^7.1.16",
"@types/styled-components": "^5.1.7",
"@types/throttle-debounce": "^2.1.0",
"babel-plugin-module-resolver": "4.0.0",
"babel-plugin-styled-components": "^1.12.0",
"install": "^0.13.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "^4.0.3",
"styled-components": "^5.2.3",
"typescript": "^4.2.3"
},
"peerDependencies": {
"react": ">=17.0.1",
"react-redux": ">=7.2.0",
"@reduxjs/toolkit": ">=1.5.0",
"throttle-debounce": ">=3.0.1"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"types": "dist/types/index.d.ts"
}

@ -0,0 +1,37 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700&display=swap');
html, body {
scroll-behavior: auto;
-webkit-overflow-scrolling: touch;
font-size: 16px;
height: 100%;
}
body {
padding: 0;
margin: 0;
}
button {
background: none;
border: none;
cursor: pointer;
}
a {
text-decoration: none;
}
* {
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: auto;
font-weight: 400;
-webkit-tap-highlight-color: rgba(0,0,0,0);
font-size: 1rem;
color: #333;
}
#root {
height : 100%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>React-Epub-Viewer Demo</title>
<meta
name="description"
content="React-Epub-Viewer Demo Page"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="icon" sizes="24x24" href="%PUBLIC_URL%/favicons/favicon_24x24.png" />
<link rel="icon" sizes="48x48" href="%PUBLIC_URL%/favicons/favicon_48x48.png" />
<link rel="icon" sizes="96x96" href="%PUBLIC_URL%/favicons/favicon_96x96.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicons/favicon_96x96.png" />
<link rel="stylesheet" type="text/css" href="%PUBLIC_URL%/css/global.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

@ -0,0 +1,15 @@
{
"short_name": "React-Epub-Viewer",
"name": "React-Epub-Viewer Demo",
"icons": [
{
"src": "favicons/favicon_96x96.png",
"sizes": "96x96 48x48 24x24",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

@ -0,0 +1,44 @@
import styled, { keyframes } from 'styled-components'
const LoadingView = () => {
return (
<LoadingWrapper>
<Wrapper>
<Content />
</Wrapper>
</LoadingWrapper>
);
}
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const LoadingWrapper = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
`;
const Wrapper = styled.div`
position: relative;
width: 100%;
height: 30px;
margin: 30px 0;
text-align: center;
`;
const Content = styled.div`
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid #ebf1ff;
border-radius: 50%;
border-top-color: #3972ff;
animation: ${spin} 1s ease-in-out infinite;
`;
export default LoadingView

@ -0,0 +1,90 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import media from 'lib/styles/media'
import palette from 'lib/styles/palette'
import animations from 'lib/styles/animations'
// types
import SnackbarType from 'types/snackbar'
const Snackbar = ({ onClick, text, type }: Props)=> {
return (
<Container onClick={onClick} type={type}>
<Content>
<Message>{text}</Message>
</Content>
</Container>
);
}
const Container = styled.div<{ type: SnackbarType }>`
position: relative;
width: 320px;
height: auto;
max-height: 800px;
background-color: ${({ type }) => {
if (type === 'SUCCESS') {
return palette.green4;
} else if (type === 'WARNING') {
return palette.orange4;
} else if (type === 'ERROR') {
return palette.red3;
} else if (type === 'INFO') {
return palette.blue4;
} else {
return palette.gray4;
}
}
};
box-shadow: ${styles.boxShadow.regular};
border-radius: 4px;
animation: ${animations.fadeInBottom} .3s cubic-bezier(0.25,0.1,0.25,1),
${animations.fadeOutBottom} .5s cubic-bezier(0.25,0.1,0.25,1) 3.6s;
cursor: pointer;
transition: .2s ${styles.transition};
${styles.noselect};
&:active {
transform: scale(.97, .97);
}
${media.small} {
border-radius: 0px;
width: 100%;
min-height: 1.8rem;
}
`;
const Content = styled.div`
position: relative;
display: flex;
width: 100%;
height: auto;
min-height: 2rem;
box-sizing: border-box;
padding: 12px 20px;
${media.small} {
min-height: 1.6rem;
line-height: 1.6rem;
}
`;
const Message = styled.div`
position: relative;
flex-grow: 1;
color: white;
font-size: 14px;
white-space: pre-wrap;
word-break: keep-all;
vertical-align: top;
`;
interface Props {
onClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>): void;
text: string;
type: SnackbarType;
}
export default Snackbar

@ -0,0 +1,15 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
const ViewerWrapper = styled.div`
position: relative;
width: 100vw;
height: 100vh;
overflow-x: hidden;
display: flex;
flex-direction: column;
${styles.scrollbar(0)};
`;
export default ViewerWrapper

@ -0,0 +1,72 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
const ColorItem = ({ name, color, onClick }: Props) => {
const onClickItem = (e: any) => {
onClick();
e.target.blur();
};
return (
<Wrapper onClick={onClickItem}>
<ColorWrapper>
<Color color={color} />
</ColorWrapper>
<Message>{name}</Message>
</Wrapper>
);
}
const Wrapper = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 32px;
border-radius: 6px;
cursor: pointer;
background-color: rgba(0,0,0,0);
transition: .1s ${styles.transition};
outline: none;
&:hover, &:focus {
background-color: ${palette.blue3};
& > span {
color: #fff;
}
}
`;
const ColorWrapper = styled.div`
width: 16px;
height: 16px;
border-radius: 16px;
background-color: #fff;
margin-right: 16px;
`;
const Color = styled.div<{ color: string }>`
width: 16px;
height: 16px;
border-radius: 16px;
background-color: ${({ color }) => color};
opacity: .4;
`;
const Message = styled.span`
width: 48px;
font-size: 14px;
color: #ddd;
line-height: 20px;
`;
interface Props {
name: string;
color: string;
onClick: () => void;
}
export default ColorItem

@ -0,0 +1,34 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
const Item = ({ text, onClick }: Props) => {
return <Container onClick={onClick}>{text}</Container>;
}
const Container = styled.button`
width: 100%;
height: 32px;
line-height: 32px;
text-align: center;
font-size: 14px;
color: #ccc;
transition: .1s ${styles.transition};
background-color: rgba(0,0,0,0);
border-radius: 6px;
cursor: pointer;
outline: none;
&:focus, &:hover {
color: #fff;
background-color: ${palette.red3};
}
`;
interface Props {
text: string;
onClick: (e?: any) => void;
}
export default Item

@ -0,0 +1,65 @@
import styled, { css } from 'styled-components'
// lib
import { contextmenuWidth } from 'lib/styles/viewerLayout'
import * as styles from 'lib/styles/styles'
import zIndex from 'lib/styles/zIndex'
const Wrapper = styled.div<Props>`
position: absolute;
left: ${({ x }) => window.innerWidth < x + contextmenuWidth
? `${window.innerWidth - contextmenuWidth}px`
: `${x}px`
};
top: ${({ y }) => window.innerHeight < y + 40
? `${window.innerHeight - 40}px`
: `${y}px`
};
width: ${({ width }) => width + 'px'};
height: ${({ height }) => height + 'px'};
box-sizing: border-box;
padding: ${({ width }) => width > 0
? `4px`
: ``
};
border-radius: 8px;
background-color: rgba(0,0,0,.8);
box-shadow: ${styles.boxShadow.regular};
z-index: ${zIndex.menu};
& > div:first-child {
height: 100%;
overflow-y: auto;
${styles.scrollbar(0)};
}
&::before {
display: ${({ width }) => width > 0
? `black`
: `none`
};
content: "";
position: absolute;
left: 80px;
${({ isReverse }) => isReverse
? css`bottom: -16px;`
: css`top: -16px;`
}
transform: translateX(-8px);
border: 8px solid rgba(0,0,0,0);
${({ isReverse }) => isReverse
? css`border-top: 8px solid rgba(0,0,0,.8);`
: css`border-bottom: 8px solid rgba(0,0,0,.8);`
}
z-index: 1;
}
`;
interface Props {
x: number;
y: number;
width: number;
height: number;
isReverse: boolean;
}
export default Wrapper

@ -0,0 +1,28 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
const Item = ({ text }: Props) => {
return (
<Container>
<span>{text}</span>
</Container>
);
}
const Container = styled.div`
height: 100%;
flex-basis: 100%;
display: flex;
align-items: center;
justify-content: center;
& > span {
color: ${palette.gray4};
}
`;
interface Props {
text: string;
}
export default Item;

@ -0,0 +1,56 @@
import styled from 'styled-components'
// icons
import { PrevIcon, NextIcon } from 'lib/svg'
// lib
import * as styles from 'lib/styles/styles'
const MoveBtn = ({ type, onClick }: Props) => {
const Icon = type === "PREV"
? PrevIcon
: NextIcon;
const msg = type === "PREV"
? "이전 페이지"
: "다음 페이지";
return (
<Container onClick={onClick} title={msg}>
<Content>
<Icon />
</Content>
</Container>
);
}
const Container = styled.button`
min-width: 60px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
&:focus, &:hover {
filter: invert(40%) sepia(85%) saturate(1256%) hue-rotate(210deg) brightness(113%) contrast(101%);
}
`;
const Content = styled.div`
display: flex;
align-items: center;
justify-content: center;
transition: .1s ${styles.transition};
& > svg {
width: 18px;
height: 18px;
}
`;
interface Props {
type: "PREV" | "NEXT"
onClick: () => void;
}
export default MoveBtn;

@ -0,0 +1,17 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
const Wrapper = styled.div`
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: ${palette.white};
`;
export default Wrapper

@ -0,0 +1,34 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
const ControlBtn = ({ message, onClick }: Props) => {
return <Btn onClick={() => onClick()}>{message}</Btn>;
}
const Btn = styled.button`
height: 100%;
padding: 0 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: .1s ${styles.transition};
outline: none;
&:focus, &:hover {
color: ${palette.blue3};
}
&:first-child {
margin-left: 8px;
}
`;
interface Props {
message: string;
onClick: (value?: boolean) => void;
}
export default ControlBtn

@ -0,0 +1,132 @@
import styled, { css } from 'styled-components'
// icons
import {
ScrollHorizontalIcon,
ScrollVerticalIcon,
BookCloseIcon,
BookOpenIcon
} from 'lib/svg'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
const ControlIconBtn = ({ type, alt, active, isSelected, onClick }: Props) => {
let HeaderIcon = null;
switch (type) {
case "ScrollVertical":
HeaderIcon = ScrollVerticalIcon;
break;
case "ScrollHorizontal":
HeaderIcon = ScrollHorizontalIcon;
break;
case "BookOpen":
HeaderIcon = BookOpenIcon;
break;
case "BookClose":
HeaderIcon = BookCloseIcon;
break;
}
const onClickBtn = () => {
if (active) onClick();
}
return (
<Btn
onClick={onClickBtn}
isSelected={isSelected}
active={active}
title={alt}
>
<Icon>
<HeaderIcon />
</Icon>
</Btn>
);
}
const Btn = styled.button<{ isSelected: boolean, active: boolean }>`
position: relative;
height: 100%;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 0;
transition: .3s ${styles.transition};
opacity: ${({active}) => active
? '1'
: '.3'
};
cursor: ${({active}) => active
? 'pointer'
: 'default'
};
outline: none;
&::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
z-index: -1;
border-radius: 30px;
width: ${({isSelected}) => isSelected
? '32px'
: '8px'
};
height: ${({isSelected}) => isSelected
? '32px'
: '8px'
};
opacity: ${({active, isSelected}) => active && isSelected
? '.7'
: '0'
};
transition: .1s ${styles.transition};
background-color: ${palette.blue1};
}
&:focus, &:hover {
${({active}) => active
? css`
&:before {
width: 32px;
height: 32px;
opacity: 1;
}
`
: ""
};
}
`;
const Icon = styled.div`
width: 16px;
height: 16px;
& > div, & > svg {
width: 16px;
height: 16px;
}
`;
type HeaderIcon =
"ScrollHorizontal" |
"ScrollVertical" |
"BookOpen" |
"BookClose";
interface Props {
type: HeaderIcon;
alt: string;
active: boolean;
isSelected: boolean;
onClick: () => void;
}
export default ControlIconBtn

@ -0,0 +1,34 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
import zIndex from 'lib/styles/zIndex'
const Layout = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 54px;
background-color: ${palette.white};
z-index: ${zIndex.header};
`;
export const AutoLayout = styled.div`
width: 100%;
flex-grow: 1;
display: flex;
justify-content: space-between;
border-bottom: 2px solid ${palette.gray1};
& > div {
display: flex;
align-items: center;
justify-content: flex-start;
}
& > div:last-child {
margin-right: 8px;
}
`;
export default Layout

@ -0,0 +1,20 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
const VerticalLine = ({ height=16 }: Props) => {
return <Bar height={height} />
}
const Bar = styled.div<{height: number}>`
width: 1px;
height: ${({height}) => height+'px'};
margin: 0 8px;
background-color: ${palette.gray2};
`;
interface Props {
height?: number
}
export default VerticalLine

@ -0,0 +1,16 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
import zIndex from 'lib/styles/zIndex'
const Wrapper = styled.div`
position: sticky;
width: 100%;
height: auto;
left: 0;
top: 0;
background-color: ${palette.white};
z-index: ${zIndex.header};
`;
export default Wrapper

@ -0,0 +1,11 @@
import styled from 'styled-components'
const Layout = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 24px 0;
`;
export default Layout

@ -0,0 +1,34 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
const SortedBtn = ({ text, isSelected, onClick }: Props) => {
return <Btn isSelected={isSelected} onClick={onClick}>{text}</Btn>;
}
const Btn = styled.button<{isSelected: boolean}>`
height: 20px;
line-height: 20px;
font-size: 14px;
cursor: pointer;
text-align: center;
margin-left: 16px;
color: ${({isSelected}) => isSelected
? palette.blue3
: palette.gray4
};
transition: .2s ${styles.transition};
&:hover {
color: ${palette.blue3};
}
`;
interface Props {
text: string;
isSelected: boolean;
onClick: () => void;
}
export default SortedBtn

@ -0,0 +1,10 @@
import styled from 'styled-components'
const SortedBtnWrapper = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 24px 24px 24px;
`;
export default SortedBtnWrapper

@ -0,0 +1,21 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
const PageNum = ({ num }: Props) => {
return <Content>{num}p</Content>
}
const Content = styled.span`
min-width: 32px;
margin-right: 16px;
font-size: 14px;
font-weight: 500;
color: ${palette.gray4};
`;
interface Props {
num: number;
}
export default PageNum

@ -0,0 +1,24 @@
import styled from 'styled-components'
const Post = styled.div<{ color: string }>`
position: relative;
font-size: 14px;
margin-top: 16px;
padding: 8px;
box-sizing: border-box;
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-color: ${({ color }) => color};
opacity: .3;
border-radius: 8px;
mix-blend-mode: multiply;
}
`;
export default Post

@ -0,0 +1,9 @@
import styled from 'styled-components'
const Title = styled.div`
font-size: 14px;
font-weight: 600;
color: inherit;
`;
export default Title

@ -0,0 +1,32 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
import * as styles from 'lib/styles/styles'
const Wrapper = styled.button`
padding: 16px 24px;
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
display: flex;
align-items: flex-start;
justify-content: flex-start;
background-color: ${palette.gray0};
transition: .1s ${styles.transition};
cursor: pointer;
outline: none;
text-align: left;
&:last-child {
box-shadow: 0 1px 4px rgb(0 0 0 / 20%);
}
& > div {
color: inherit;
flex-grow: 1;
}
&:hover > *, &:focus > * {
color: ${palette.blue3};
}
`;
export default Wrapper

@ -0,0 +1,50 @@
import styled from 'styled-components'
const BookInfo = ({src="", title ,publisher, author}: Props) => {
return (
<Container>
<BookImg src={src} alt={title} />
<BookContent>
<Title>{title}</Title>
<Info>{publisher}</Info>
<Info>{author}</Info>
</BookContent>
</Container>
);
}
const Container = styled.div`
display: flex;
padding: 24px;
`;
const BookImg = styled.img`
margin-right: 12px;
width: 44%;
min-width: 120px;
border-radius: 4px;
background: #eee;
`;
const BookContent = styled.div`
flex-grow: 1;
`;
const Title = styled.div`
font-weight: 500;
margin-bottom: 4px;
`;
const Info = styled.div`
font-size: 14px;
margin-bottom: 4px;
`;
interface Props {
src: string;
title: string;
publisher: string;
author: string;
}
export default BookInfo

@ -0,0 +1,42 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
const NavItem = ({ message, onClick }: Props) => {
return (
<Content onClick={onClick}>
<span>{message}</span>
</Content>
);
}
const Content = styled.button`
width: 100%;
height: 48px;
box-sizing: border-box;
padding: 12px 24px;
display: flex;
align-items: center;
cursor: pointer;
background-color: ${palette.gray0};
& > span {
transition: .2s ${styles.transition};
font-size: 14px;
}
&:hover {
& > span {
color: ${palette.blue3};
margin-left: 12px;
}
}
`;
interface Props {
message: string;
onClick: () => void;
}
export default NavItem

@ -0,0 +1,79 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import styled from 'styled-components'
// components
import OptionWrapper from 'components/option/OptionWrapper'
import OptionTitle from 'components/option/OptionTitle'
import DropdownValue from 'components/option/DropdownValue'
import DropdownItemWrapper from 'components/option/DropdownItemWrapper'
import DropdownItem from 'components/option/DropdownItem'
// types
import { BookFontFamily } from 'types/book'
const Dropdown = ({
title,
defaultValue,
valueList,
onSelect
}: Props) => {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState<boolean>(false);
const value = defaultValue === "Origin" ? "Original" : defaultValue;
/** Toggle dropdown */
const onToggle = useCallback(() => setVisible(!visible), [visible]);
/** Close dropdown */
const onClose = useCallback((e: any) => {
if (!ref || !ref.current) return;
if (![...e.path].includes(ref.current)) {
onToggle();
}
}, [ref, onToggle]);
const Items = valueList.map(font =>
<DropdownItem key={font} value={font} onClick={() => {
onSelect(font);
onToggle();
}} />
);
/** Register dropdown close event */
useEffect(() => {
if (visible) {
document.addEventListener('click', onClose);
} else {
document.removeEventListener('click', onClose);
}
return () => {
document.removeEventListener('click', onClose);
}
}, [visible, onClose]);
return (
<OptionWrapper>
<OptionTitle>{title}</OptionTitle>
<DropdownWrapper ref={ref}>
<DropdownValue value={value}
isDropdown={visible}
onClick={onToggle} />
<DropdownItemWrapper show={visible}>
{Items}
</DropdownItemWrapper>
</DropdownWrapper>
</OptionWrapper>
);
}
const DropdownWrapper = styled.div`
position: relative;
`;
interface Props {
title: string;
defaultValue: BookFontFamily;
valueList: BookFontFamily[];
onSelect: (font: BookFontFamily) => void;
}
export default Dropdown

@ -0,0 +1,33 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
const DropdownItem = ({ value, onClick }: Props) => {
const text = value === "Origin" ? "Original" : value;
return <Container onClick={onClick}>{text}</Container>;
}
const Container = styled.button`
width: 100%;
height: 40px;
box-sizing: border-box;
padding: 0 16px;
transition: .1s ${styles.transition};
font-size: 14px;
line-height: 40px;
cursor: pointer;
text-align: left;
outline: none;
&:focus, &:hover {
background-color: rgba(0,0,0,.05);
}
`;
interface Props {
value: string;
onClick: () => void;
}
export default DropdownItem

@ -0,0 +1,44 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
const DropdownItemWrapper = ({ show, children }: Props) => {
return <Container show={show}>{children}</Container>
}
const Container = styled.div<{show: boolean}>`
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-height: 200px;
overflow-y: auto;
box-sizing: border-box;
padding: 0 0 16px 0;
background-color: ${palette.white};
border: 2px solid ${palette.blue3};
z-index: 3;
border-radius: ${({show}) => show
? '0 0 20px 20px'
: '20px'
};
transition: .4s ${styles.transition};
opacity: ${({show}) => show
? '1'
: '0'
};
transform-origin: top;
transform: ${({show}) => show
? 'translateY(-2px) scaleY(1);'
: 'translateY(-40px) scaleY(0);'
};
${styles.scrollbar(6)}
`;
interface Props {
show: boolean;
children: React.ReactElement[];
}
export default DropdownItemWrapper

@ -0,0 +1,83 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
import palette from 'lib/styles/palette'
// icons
import { DownIcon } from 'lib/svg'
const DropdownValue = ({ value, isDropdown, onClick }: Props) => {
return (
<Container
onClick={onClick}
isDropdown={isDropdown}
title="Select font"
>
<Content>{value}</Content>
<Icon isDropdown={isDropdown}>
<DownIcon />
</Icon>
</Container>
);
}
const Container = styled.button<{isDropdown: boolean}>`
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
width: 100%;
height: 40px;
background-color: ${palette.white};
border: ${({isDropdown}) => isDropdown
? `2px solid ${palette.blue3}`
: `2px solid ${palette.gray2}`
};
border-radius: ${({isDropdown}) => isDropdown
? "20px 20px 0 0"
: "20px"
};
box-sizing: border-box;
padding: 0px 16px;
cursor: pointer;
z-index: 4;
transition: .1s ${styles.transition};
text-align: left;
outline: none;
&:hover, &:focus {
border: 2px solid ${palette.blue3};
& > div {
opacity: .8;
}
}
`;
const Content = styled.span`
flex-grow: 1;
margin-right: 16px;
font-size: 14px;
`;
const Icon = styled.div<{isDropdown: boolean}>`
display: flex;
width: 12px;
height: 12px;
opacity: .3;
& > svg {
transform-origin: center;
transform: ${({isDropdown}) => isDropdown
? 'rotate(180deg)'
: 'rotate(0deg)'
};
}
`;
interface Props {
value: string;
isDropdown: boolean;
onClick: (e: any) => void;
}
export default DropdownValue

@ -0,0 +1,8 @@
import styled from 'styled-components'
const Wrapper = styled.div`
box-sizing: border-box;
padding: 24px;
`;
export default Wrapper

@ -0,0 +1,9 @@
import styled from 'styled-components'
const OptionTitle = styled.div`
font-size: 14px;
font-weight: 500;
margin-bottom: 16px;
`;
export default OptionTitle

@ -0,0 +1,18 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
const OptionValue = styled.span<{ active: boolean }>`
display: inline-block;
font-size: 14px;
color: ${props => props.active
? palette.blue3
: palette.gray4
};
margin-right: 8px;
width: 40px;
text-align: right;
line-height: 20px;
`;
export default OptionValue

@ -0,0 +1,7 @@
import styled from 'styled-components'
const OptionWrapper = styled.div`
margin-bottom: 24px;
`;
export default OptionWrapper

@ -0,0 +1,56 @@
import styled from 'styled-components'
// components
import OptionWrapper from 'components/option/OptionWrapper'
import OptionTitle from 'components/option/OptionTitle'
import OptionValue from 'components/option/OptionValue'
import SliderValue from 'components/option/SliderValue'
const Slider = ({
active,
title,
minValue,
maxValue,
defaultValue,
step,
onChange
}: Props) => {
const onChangeValue = (e: any) => {
if (!active) return;
onChange(e);
}
return (
<OptionWrapper>
<OptionTitle>{title}</OptionTitle>
<SliderWrapper>
<OptionValue active={active}>{defaultValue}</OptionValue>
<SliderValue active={active}
minValue={minValue}
maxValue={maxValue}
defaultValue={defaultValue}
step={step}
onChange={onChangeValue} />
</SliderWrapper>
</OptionWrapper>
);
}
const SliderWrapper = styled.div`
display: flex;
align-items: center;
height: 20px;
`;
interface Props {
active: boolean;
title: string;
minValue: number;
maxValue: number;
defaultValue: number;
step: number;
onChange: (e: any) => void;
}
export default Slider

@ -0,0 +1,130 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
import * as styles from 'lib/styles/styles'
const SliderValue = ({
active,
minValue,
maxValue,
defaultValue,
step,
onChange
}: Props) => {
const percentage = Math.round((defaultValue - minValue) / (maxValue - minValue) * 100);
return (<>
<Icon>-</Icon>
<SliderWrapper>
<SliderBackground />
<SliderBar
active={active}
percentage={percentage}
/>
<Slider
type="range"
min={minValue}
max={maxValue}
defaultValue={defaultValue}
step={step}
onChange={onChange}
active={active}
disabled={!active} />
</SliderWrapper>
<Icon>+</Icon>
</>);
}
const SliderWrapper = styled.div`
flex-grow: 1;
display: flex;
position: relative;
width: auto;
`;
const SliderBackground = styled.div`
position: absolute;
width: 100%;
height: 6px;
background-color: #eee;
border-radius: 6px;
z-index: 0;
`;
const SliderBar = styled.div<{ percentage: number, active: boolean }>`
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: ${({ percentage }) => percentage + '%'};
height: 6px;
border-radius: 6px 0 0 6px;
background-color: ${props => props.active
? palette.blue4
: palette.gray2
};
z-index: 1;
`;
const Slider = styled.input<{ active: boolean }>`
appearance: none;
width: 100%;
height: 6px;
background-color: transparent;
border-radius: 6px;
margin: 0;
z-index: 2;
&::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 18px;
box-shadow: ${styles.boxShadow.regular};
background-color: white;
transition: .2s ${styles.transition};
transition-property: box-shadow, background-color;
border: ${props => props.active
? `2px solid ${palette.blue3}`
: `2px solid ${palette.gray4}`
};
cursor: ${props => props.active
? 'pointer'
: ''
};;
&:hover {
background-color: ${props => props.active
? palette.blue1
: ''
};
}
&:active {
box-shadow: none;
background-color: ${props => props.active
? palette.blue0
: ''
};
}
}
`;
const Icon = styled.span`
font-size: 12px;
font-weight: 500;
color: ${palette.gray4};
margin: 0 8px;
line-height: 20px;
`;
interface Props {
active: boolean;
minValue: number;
maxValue: number;
defaultValue: number;
step: number;
onChange: (e: any) => void;
}
export default SliderValue

@ -0,0 +1,39 @@
import styled from 'styled-components'
// lib
import * as styles from 'lib/styles/styles'
// icons
import { CloseIcon } from 'lib/svg'
const CloseBtn = ({ onClick }: Props) => {
return (
<Btn onClick={onClick}>
<Icon />
</Btn>
);
}
const Btn = styled.button`
position: relative;
height: 100%;
padding: 0 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: .1s ${styles.transition};
&:hover {
filter: invert(40%) sepia(85%) saturate(1256%) hue-rotate(210deg) brightness(113%) contrast(101%);
}
`;
const Icon = styled(CloseIcon)`
width: 18px;
height: 18px;
`;
interface Props {
onClick: () => void;
}
export default CloseBtn;

@ -0,0 +1,23 @@
import styled from 'styled-components'
// lib
import palette from 'lib/styles/palette'
const MenuEmpty = ({ text }: Props) => {
return <Content>{text}</Content>;
}
const Content = styled.div`
flex-grow: 1;
height: auto;
display: flex;
align-items: center;
justify-content: center;
color: ${palette.blue2};
font-size: 14px;
`;
interface Props {
text: string;
}
export default MenuEmpty

@ -0,0 +1,65 @@
import React from 'react'
import styled from 'styled-components'
// components
import CloseBtn from 'components/sideMenu/CloseBtn'
// lib
import * as styles from 'lib/styles/styles'
import zIndex from 'lib/styles/zIndex'
import palette from 'lib/styles/palette'
const Wrapper = ({ title, show, onClose, children }: Props, ref: any) => {
return (
<Container show={show} ref={ref}>
<Header>
<span>{title}</span>
<CloseBtn onClick={onClose} />
</Header>
{children}
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
position: fixed;
width: 340px;
height: 100vh;
top: 0;
right: 0;
z-index: ${zIndex.menu};
box-shadow: -4px 0 8px 0 rgba(0,0,0,.16);
background-color: ${palette.white};
border-radius: 16px 0 0 16px;
transform: ${({ show }: { show: boolean }) => show
? `translateX(0px) scale(1)`
: `translateX(420px) scale(.8)`
};
transition: .3s ${styles.transition};
overflow-y: auto;
${styles.scrollbar()};
`;
const Header = styled.div`
width: 100%;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 30px;
& > span {
padding-left: 24px;
font-size: 21px;
font-weight: 600;
}
`;
interface Props {
title: string;
show: boolean;
onClose: () => void;
children?: React.ReactNode;
}
export default React.forwardRef(Wrapper)

@ -0,0 +1,25 @@
// components
import Wrapper from 'components/footer/Wrapper'
import Item from 'components/footer/Item'
import MoveBtn from 'components/footer/MoveBtn'
const Footer = ({ title, nowPage, totalPage, onPageMove }: Props) => {
return (
<Wrapper>
<MoveBtn type="PREV" onClick={() => onPageMove("PREV")} />
<Item text={title} />
<Item text={`${nowPage} / ${totalPage}`} />
<MoveBtn type="NEXT" onClick={() => onPageMove("NEXT")} />
</Wrapper>
);
}
interface Props {
title: string;
nowPage: number;
totalPage: number;
onPageMove: (type: "PREV" | "NEXT") => void;
}
export default Footer

@ -0,0 +1,125 @@
import { useState } from 'react'
// components
import Wrapper from 'components/header/Wrapper'
import Layout, { AutoLayout } from 'components/header/Layout'
import ControlBtn from 'components/header/ControlBtn'
import ControlIconBtn from 'components/header/ControlIconBtn'
import VerticalLine from 'components/header/VerticalLine'
// types
import { BookOption } from 'types/book'
const Header = ({
title,
bookOption,
onNavToggle,
onOptionToggle,
onLearningToggle,
onBookOptionChange
}: Props) => {
const [isScrollHorizontal, setIsScrollHorizontal] = useState<boolean>(true);
const [viewType, setViewType] = useState<ViewType>({
active: true,
spread: true
});
/**
* Select view direction
* @param type Direction
*/
const onClickDirection = (type: "Horizontal" | "Vertical") => {
if (type === "Horizontal") {
setIsScrollHorizontal(true);
setViewType({ ...viewType, active: true });
onBookOptionChange({
...bookOption,
flow: "paginated"
});
} else {
setIsScrollHorizontal(false);
setViewType({ ...viewType, active: false });
onBookOptionChange({
...bookOption,
flow: "scrolled-doc"
});
}
}
/**
* Select isSpread
* @param isSpread Whether spread view
*/
const onClickViewType = (isSpread: boolean) => {
if (isSpread) {
setViewType({ ...viewType, spread: true });
onBookOptionChange({
...bookOption,
spread: "auto"
});
} else {
setViewType({ ...viewType, spread: false });
onBookOptionChange({
...bookOption,
spread: "none"
});
}
}
return (
<Wrapper>
<Layout>
<AutoLayout>
<div>
<ControlBtn message="Contents" onClick={onNavToggle} />
<ControlBtn message="Setting" onClick={onOptionToggle} />
<ControlBtn message="Highlights" onClick={onLearningToggle} />
</div>
<div>
<ControlIconBtn type="ScrollHorizontal"
alt="Horizontal View"
active={true}
isSelected={isScrollHorizontal}
onClick={() => onClickDirection("Horizontal")} />
<ControlIconBtn type="ScrollVertical"
alt="Vertical View"
active={true}
isSelected={!isScrollHorizontal}
onClick={() => onClickDirection("Vertical")} />
<VerticalLine />
<ControlIconBtn type="BookOpen"
alt="Two Page View"
active={viewType.active}
isSelected={viewType.spread}
onClick={() => onClickViewType(true)} />
<ControlIconBtn type="BookClose"
alt="One Page View"
active={viewType.active}
isSelected={!viewType.spread}
onClick={() => onClickViewType(false)} />
</div>
</AutoLayout>
</Layout>
</Wrapper>
);
}
interface Props {
title: string;
bookOption: BookOption;
onNavToggle: (value?: boolean) => void;
onOptionToggle: (value?: boolean) => void;
onLearningToggle: (value?: boolean) => void;
onBookOptionChange: (bookOption: BookOption) => void;
}
type ViewType = {
active: boolean,
spread: boolean
}
export default Header

@ -0,0 +1,218 @@
import { useState, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Provider } from 'react-redux'
// containers
import Viewer from 'modules/reactViewer/ReactViewer'
import Header from 'containers/Header'
import Footer from 'containers/Footer'
import Nav from 'containers/menu/Nav'
import Option from 'containers/menu/Option'
import Learning from 'containers/menu/Note'
import ContextMenu from 'containers/commons/ContextMenu'
import Snackbar from 'containers/commons/Snackbar'
// components
import ViewerWrapper from 'components/commons/ViewerWrapper'
import LoadingView from 'LoadingView'
// slices
import store from 'slices'
import { updateBook, updateCurrentPage, updateToc } from 'slices/book'
// hooks
import useMenu from 'lib/hooks/useMenu'
import useHighlight from 'lib/hooks/useHighlight'
// styles
import 'lib/styles/readerStyle.css'
import viewerLayout from 'lib/styles/viewerLayout'
// types
import { RootState } from 'slices'
import Book, { BookStyle, BookOption } from 'types/book'
import Page from 'types/page'
import Toc from 'types/toc'
const Reader = ({ url, loadingView }: Props) => {
const dispatch = useDispatch();
const book = useSelector<RootState, Book>(state => state.book.book);
const currentLocation = useSelector<RootState, Page>(state => state.book.currentLocation);
const viewerRef = useRef<any>(null);
const navRef = useRef<HTMLDivElement | null>(null);
const optionRef = useRef<HTMLDivElement | null>(null);
const learningRef = useRef<HTMLDivElement | null>(null);
const [isContextMenu, setIsContextMenu] = useState<boolean>(false);
const [bookStyle, setBookStyle] = useState<BookStyle>({
fontFamily: 'Origin',
fontSize: 18,
lineHeight: 1.4,
marginHorizontal: 15,
marginVertical: 5
});
const [bookOption, setBookOption] = useState<BookOption>({
flow: "paginated",
resizeOnOrientationChange: true,
spread: "auto"
});
const [navControl, onNavToggle] = useMenu(navRef, 300.);
const [optionControl, onOptionToggle, emitEvent] = useMenu(optionRef, 300);
const [learningControl, onLearningToggle] = useMenu(learningRef, 300);
const {
selection,
onSelection,
onClickHighlight,
onAddHighlight,
onRemoveHighlight,
onUpdateHighlight
} = useHighlight(viewerRef, setIsContextMenu, bookStyle, bookOption.flow);
/**
* Change Epub book information
* @param book Epub Book Info
*/
const onBookInfoChange = (book: Book) => dispatch(updateBook(book));
/**
* Change Epub location
* @param loc epubCFI or href
*/
const onLocationChange = (loc: string) => {
if (!viewerRef.current) return;
viewerRef.current.setLocation(loc);
}
/**
* Move page
* @param type Direction
*/
const onPageMove = (type: "PREV" | "NEXT") => {
const node = viewerRef.current;
if (!node || !node.prevPage || !node.nextPage) return;
type === "PREV" && node.prevPage();
type === "NEXT" && node.nextPage();
};
/**
* Set toc
* @param toc Table of Epub contents
*/
const onTocChange = (toc: Toc[]) => dispatch(updateToc(toc));
/**
* Set Epub viewer styles
* @param bokkStyle_ viewer style
*/
const onBookStyleChange = (bookStyle_: BookStyle) => setBookStyle(bookStyle_);
/**
* Set Epub viewer options
* @param bookOption_ viewer option
*/
const onBookOptionChange = (bookOption_: BookOption) => setBookOption(bookOption_);
/**
* Change current page
* @param page Epub page
*/
const onPageChange = (page: Page) => dispatch(updateCurrentPage(page));
/**
* ContextMenu on
* @param cfiRange CfiRange
*/
const onContextMenu = (cfiRange: string) => {
const result = onSelection(cfiRange);
setIsContextMenu(result);
}
/** ContextMenu off */
const onContextmMenuRemove = () => setIsContextMenu(false);
return (<>
<ViewerWrapper>
<Header
title={book.title}
bookOption={bookOption}
onNavToggle={onNavToggle}
onOptionToggle={onOptionToggle}
onLearningToggle={onLearningToggle}
onBookOptionChange={onBookOptionChange}
/>
<Viewer
url={url}
viewerLayout={viewerLayout}
viewerStyle={bookStyle}
viewerOption={bookOption}
onBookInfoChange={onBookInfoChange}
onPageChange={onPageChange}
onTocChange={onTocChange}
onSelection={onContextMenu}
loadingView={loadingView || <LoadingView />}
ref={viewerRef}
/>
<Footer
title={currentLocation.chapterName}
nowPage={currentLocation.currentPage}
totalPage={currentLocation.totalPage}
onPageMove={onPageMove}
/>
</ViewerWrapper>
<Nav
control={navControl}
onToggle={onNavToggle}
onLocation={onLocationChange}
ref={navRef}
/>
<Option
control={optionControl}
bookStyle={bookStyle}
bookFlow={bookOption.flow}
onToggle={onOptionToggle}
emitEvent={emitEvent}
onBookStyleChange={onBookStyleChange}
ref={optionRef}
/>
<Learning
control={learningControl}
onToggle={onLearningToggle}
onClickHighlight={onClickHighlight}
emitEvent={emitEvent}
viewerRef={viewerRef}
ref={learningRef}
/>
<ContextMenu
active={isContextMenu}
viewerRef={viewerRef}
selection={selection}
onAddHighlight={onAddHighlight}
onRemoveHighlight={onRemoveHighlight}
onUpdateHighlight={onUpdateHighlight}
onContextmMenuRemove={onContextmMenuRemove}
/>
<Snackbar />
</>);
}
const ReaderWrapper = ({ url, loadingView }: Props) => {
return (
<Provider store={store}>
<Reader url={url} loadingView={loadingView} />
</Provider>
);
}
interface Props {
url: string;
loadingView?: React.ReactNode;
}
export default ReaderWrapper

@ -0,0 +1,207 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSelector } from 'react-redux'
// components
import Wrapper from 'components/contextMenu/Wrapper'
import ColorItem from 'components/contextMenu/ColorItem'
import Item from 'components/contextMenu/Item'
// utils
import { getParagraphCfi } from 'lib/utils/commonUtil'
// slices
import { contextmenuWidth } from 'lib/styles/viewerLayout'
// types
import { RootState } from 'slices'
import Highlight, { Color } from 'types/highlight'
import Selection from 'types/selection'
const ContextMenu = ({
active,
viewerRef,
selection,
onAddHighlight,
onRemoveHighlight,
onUpdateHighlight,
onContextmMenuRemove
}: Props) => {
const highlights = useSelector<RootState, Highlight[]>(state => state.book.highlights);
const colorList = useSelector<RootState, Color[]>(state => state.book.colorList);
const menuRef = useRef<HTMLDivElement | null>(null);
const [highlight, setHighlight] = useState<Highlight | null>(null);
const [display, setDisplay] = useState<boolean>(false);
const [isEraseBtn, setIsEraseBtn] = useState<boolean>(false);
const [isReverse, setIsReverse] = useState<boolean>(false);
const [height, setHeight] = useState<number>(0);
const [y, setY] = useState<number>(selection.y);
const ColorList = colorList.map(
color => <ColorItem key={color.code}
name={color.name}
color={color.code}
onClick={selection.update
? () => onUpdateHighlight(highlight, color.code)
: () => onAddHighlight(color.code)
} />
);
/** Remove highlight */
const onRemoveHighlight_ = useCallback(() => {
if (!highlight) return;
onRemoveHighlight(highlight.key, highlight.cfiRange);
onContextmMenuRemove();
setIsEraseBtn(false);
}, [highlight, onRemoveHighlight, onContextmMenuRemove]);
/**
* Remove contextmenu
* @param e Mouse Event
* @param e.path
*/
const onRemove = useCallback(({ path }: any) => {
if (!menuRef.current) return;
if ([...path].includes(menuRef.current)) return;
onContextmMenuRemove();
}, [menuRef, onContextmMenuRemove]);
/**
* Arrow event
* @param e Keyboard Event
* @param e.key
*/
const onKeyPress = useCallback(({ key }: any) => {
key && key === "ArrowLeft" && onContextmMenuRemove();
key && key === "ArrowRight" && onContextmMenuRemove();
}, [onContextmMenuRemove]);
/** Check whether the menu button is visible */
useEffect(() => {
if (!active) setIsEraseBtn(false);
const paragraphCfi = getParagraphCfi(selection.cfiRange);
if (!paragraphCfi) return;
const filtered = highlights.filter(highlight => highlight.key === paragraphCfi + selection.cfiRange);
if (!filtered.length) return;
const highlight_ = filtered[0];
setHighlight(highlight_);
if (selection.update) {
setIsEraseBtn(true);
} else {
setIsEraseBtn(false);
}
}, [
active,
highlights,
selection.cfiRange,
selection.update
]);
/** Register contextmenu events */
useEffect(() => {
if (!viewerRef.current) return;
const iframe = document.querySelector('iframe');
const node = iframe && iframe.contentWindow && iframe.contentWindow.document;
const scrolledTarget = viewerRef.current.querySelector('div');
if (active) {
setDisplay(true);
scrolledTarget && scrolledTarget.addEventListener('scroll', onContextmMenuRemove);
node && node.addEventListener('mousedown', onRemove);
node && node.addEventListener('keyup', onKeyPress);
document.addEventListener('mousedown', onRemove);
document.addEventListener('keyup', onKeyPress);
} else {
setDisplay(false);
scrolledTarget && scrolledTarget.removeEventListener('scroll', onContextmMenuRemove);
node && node.removeEventListener('mousedown', onRemove);
node && node.removeEventListener('keyup', onKeyPress);
document.removeEventListener('mousedown', onRemove);
document.removeEventListener('keyup', onKeyPress);
};
return () => {
node && node.removeEventListener('mousedown', onContextmMenuRemove);
node && node.removeEventListener('keyup', onKeyPress);
document.removeEventListener('keyup', onKeyPress);
}
}, [
viewerRef,
active,
onRemove,
onContextmMenuRemove,
onKeyPress
]);
/** Set modified contextmenu height & whether menu is reverse */
useEffect(() => {
const menuPadding = 8;
const itemHeight = 32;
let itemCnt = ColorList.length;
if (isEraseBtn) itemCnt += 1;
const defaultHeight = itemCnt * itemHeight + menuPadding;
let y_ = selection.y;
const { innerHeight } = window;
if (selection.y + defaultHeight > innerHeight) {
y_ = selection.y - selection.height - (defaultHeight);
if (y_ < 0) {
setHeight(defaultHeight + y_ - 8);
y_ = 8;
} else {
setHeight(defaultHeight);
}
setIsReverse(true);
} else {
setHeight(defaultHeight);
setIsReverse(false);
}
setY(y_);
}, [
selection.y,
selection.height,
ColorList,
isEraseBtn,
setHeight
]);
return (<>
{display && <Wrapper x={selection.x}
y={y}
width={contextmenuWidth}
height={height}
isReverse={isReverse}
ref={menuRef}>
<div>
{ColorList}
{isEraseBtn && <Item text="Remove" onClick={onRemoveHighlight_} />}
</div>
</Wrapper>}
</>);
}
interface Props {
active: boolean;
viewerRef: any;
selection: Selection;
onAddHighlight: (color: string) => void;
onRemoveHighlight: (key: string, cfiRange: string) => void;
onUpdateHighlight: (highlight: Highlight | null, color: string) => void;
onContextmMenuRemove: () => void;
}
export default ContextMenu

@ -0,0 +1,56 @@
import styled from 'styled-components'
import { useSelector, useDispatch } from 'react-redux'
// components
import Snackbar from 'components/commons/Snackbar'
// lib
import zIndex from 'lib/styles/zIndex'
import media from 'lib/styles/media'
// slices
import { deleteSnackbar } from 'slices/snackbar'
// types
import { RootState } from 'slices'
import { SnackbarState } from 'slices/snackbar'
const SnackbarWrapper = ()=> {
const dispatch = useDispatch();
const snackbar = useSelector<RootState, SnackbarState>(state => state.snackbar);
const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
dispatch(deleteSnackbar());
};
return (
<Container>
{snackbar.text !== '' && <Snackbar onClick={onClick}
text={snackbar.text}
type={snackbar.type} />}
</Container>
);
}
const Container = styled.div`
position: fixed;
top: 40px;
right: 30px;
width: 320px;
height: auto;
z-index: ${zIndex.snackbar};
${media.small} {
width: 100%;
top: initial;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
`;
export default SnackbarWrapper

@ -0,0 +1,52 @@
import React from 'react'
import { useSelector } from 'react-redux'
// components
import Wrapper from 'components/sideMenu/Wrapper'
import BookInfo from 'components/nav/BookInfo'
import NavItem from 'components/nav/NavItem'
// types
import { RootState } from 'slices'
import Book from 'types/book'
import Toc from 'types/toc'
import { MenuControl } from 'lib/hooks/useMenu'
const Nav = ({ control, onToggle, onLocation }: Props, ref: any) => {
const book = useSelector<RootState, Book>(state => state.book.book);
const bookToc = useSelector<RootState, Toc[]>(state => state.book.toc);
/** Click nav item */
const onClickItem = (loc: string) => {
onLocation(loc);
onToggle();
}
const Tocs = bookToc.map((t, idx) =>
<NavItem key={idx}
message={t.label}
onClick={() => onClickItem(t.href)} />
);
return (<>
{control.display && <Wrapper title="Contents"
show={control.open}
onClose={onToggle}
ref={ref}>
<BookInfo
src={book.coverURL}
title={book.title}
publisher={book.publisher}
author={book.author}
/>
{Tocs}
</Wrapper>}
</>);
}
interface Props {
control: MenuControl;
onToggle: () => void;
onLocation: (loc: string) => void;
}
export default React.forwardRef(Nav)

@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
// containers
import Highlight from 'containers/menu/commons/Highlight'
// components
import Wrapper from 'components/sideMenu/Wrapper'
import LearningLayout from 'components/learning/Layout'
import SortedBtnWrapper from 'components/learning/SortedBtnWrapper'
import SortedBtn from 'components/learning/SortedBtn'
import MenuEmpty from 'components/sideMenu/MenuEmpty'
// types
import { RootState } from 'slices'
import HighlightType from 'types/highlight'
import { MenuControl } from 'lib/hooks/useMenu'
const Note = ({
control,
onToggle,
onClickHighlight,
emitEvent,
viewerRef
}: Props, ref: any) => {
const highlights = useSelector<RootState, HighlightType[]>(state => state.book.highlights);
const [sorted, setSorted] = useState<Sorted>("CART");
const [highlightList, setHighlightList] = useState<any[]>([]);
/**
* Select sort type
* @param type Viewed or Created
*/
const onSortedClick = (type: Sorted) => setSorted(type);
/** Set highlight contents */
useEffect(() => {
const sortedKey = sorted === "LOOK"
? "accessTime"
: "createTime";
const highlightList = highlights.slice().sort(function (a, b) {
return new Date(b[sortedKey]).getTime() - new Date(a[sortedKey]).getTime();
});
const Items = highlightList.map(h => <Highlight key={h.key}
highlight={h}
onClick={onClickHighlight}
emitEvent={emitEvent}
viewerRef={viewerRef} />
);
setHighlightList(Items);
}, [
viewerRef,
sorted,
highlights,
onClickHighlight,
emitEvent,
setHighlightList
]);
return (<>
{control.display && <Wrapper title="Highlight"
show={control.open}
onClose={onToggle}
ref={ref} >
<LearningLayout>
<SortedBtnWrapper>
<SortedBtn
text="viewed"
isSelected={sorted === "LOOK"}
onClick={() => onSortedClick("LOOK")}
/>
<SortedBtn
text="created"
isSelected={sorted === "CART"}
onClick={() => onSortedClick("CART")}
/>
</SortedBtnWrapper>
{highlightList.length > 0
? highlightList
: <MenuEmpty text="Empty highlights!" />
}
</LearningLayout>
</Wrapper>}
</>);
}
type Sorted = "LOOK" | "CART"
interface Props {
control: MenuControl;
onToggle: () => void;
onClickHighlight: (highlightNode: any) => void;
emitEvent: () => void;
viewerRef: any;
}
export default React.forwardRef(Note)

@ -0,0 +1,137 @@
import React from 'react'
import { useState, useEffect } from 'react'
// components
import Wrapper from 'components/sideMenu/Wrapper'
import OptionLayout from 'components/option/Layout'
import OptionDropdown from 'components/option/Dropdown'
import OptionSlider from 'components/option/Slider'
// types
import { BookStyle, BookFontFamily, BookFlow } from 'types/book'
import { MenuControl } from 'lib/hooks/useMenu'
const Option = ({
control,
bookStyle,
bookFlow,
onToggle,
emitEvent,
onBookStyleChange
}: Props, ref: any) => {
const [fontFamily, setFontFamily] = useState<BookFontFamily>(bookStyle.fontFamily);
const [fontSize, setFontSize] = useState<number>(bookStyle.fontSize);
const [lineHeight, setLineHeight] = useState<number>(bookStyle.lineHeight);
const [marginHorizontal, setMarginHorizontal] = useState<number>(bookStyle.marginHorizontal);
const [marginVertical, setMarginVertical] = useState<number>(bookStyle.marginVertical);
/** Change font family */
const onSelectFontFamily = (font: BookFontFamily) => setFontFamily(font);
/** Change font style and layout */
const onChangeSlider = (type: SliderType, e: any) => {
if (!e || !e.target) return;
switch (type) {
case "FontSize":
setFontSize(e.target.value);
break;
case "LineHeight":
setLineHeight(e.target.value);
break;
case "MarginHorizontal":
setMarginHorizontal(e.target.value);
break;
case "MarginVertical":
setMarginVertical(e.target.value);
break;
default:
break;
}
}
/* Save userdata */
// TODO Fix the infinite re-rendering issue, when inlcude `onBookStyleChange` to dependencies array.
/* eslint-disable */
useEffect(() => {
const timer = window.setTimeout(() => {
onBookStyleChange({
fontFamily,
fontSize,
lineHeight,
marginHorizontal,
marginVertical
});
}, 250);
return () => window.clearTimeout(timer);
}, [
fontFamily,
fontSize,
lineHeight,
marginHorizontal,
marginVertical
]);
/* eslint-enable */
/** Re-register close event, when after set */
useEffect(() => emitEvent(), [bookStyle, emitEvent]);
return (<>
{control.display && <Wrapper title="Setting"
show={control.open}
onClose={onToggle}
ref={ref}>
<OptionLayout>
<OptionDropdown title="Font"
defaultValue={fontFamily}
valueList={["Origin", "Roboto"]}
onSelect={onSelectFontFamily} />
<OptionSlider active={true}
title="Size"
minValue={8}
maxValue={36}
defaultValue={fontSize}
step={1}
onChange={(e) => onChangeSlider("FontSize", e)} />
<OptionSlider active={true}
title="Line height"
minValue={1}
maxValue={3}
defaultValue={lineHeight}
step={0.1}
onChange={(e) => onChangeSlider("LineHeight", e)} />
<OptionSlider active={true}
title="Horizontal margin"
minValue={0}
maxValue={100}
defaultValue={marginHorizontal}
step={1}
onChange={(e) => onChangeSlider("MarginHorizontal", e)} />
<OptionSlider active={bookFlow === "paginated"}
title="Vertical margin"
minValue={0}
maxValue={100}
defaultValue={marginVertical}
step={1}
onChange={(e) => onChangeSlider("MarginVertical", e)} />
</OptionLayout>
</Wrapper>}
</>);
}
interface Props {
control: MenuControl;
bookStyle: BookStyle;
bookFlow: BookFlow;
onToggle: () => void;
emitEvent: () => void;
onBookStyleChange: (bookStyle: BookStyle) => void;
}
type SliderType = "FontSize"
| "LineHeight"
| "MarginHorizontal"
| "MarginVertical";
export default React.forwardRef(Option)

@ -0,0 +1,71 @@
import { useDispatch } from 'react-redux'
// components
import Wrapper from 'components/learning/highlight/Wrapper'
import Title from 'components/learning/highlight/Title'
import Post from 'components/learning/highlight/Post'
import PageNum from 'components/learning/highlight/PageNum'
// slices
import { updateHighlight } from 'slices/book'
// utils
import { cfiRangeSpliter } from 'lib/utils/commonUtil'
// types
import HighlightType from 'types/highlight'
const Highlight = ({
highlight,
onClick,
emitEvent,
viewerRef
}: Props) => {
const dispatch = useDispatch();
/** 하이라이트 클릭 */
const onClickHighlight = () => {
if (!viewerRef.current) return;
const now = new Date().toISOString();
dispatch(updateHighlight({
...highlight,
accessTime: now
}));
const splitCfi = cfiRangeSpliter(highlight.cfiRange);
if (!splitCfi) return;
const { startCfi } = splitCfi;
viewerRef.current.setLocation(startCfi);
emitEvent();
const svgContainer = viewerRef.current.querySelector("svg");
if (!svgContainer) return;
const targetSvg = svgContainer.querySelector(`g[data-epubcfi="${highlight.cfiRange}"]`);
if (!targetSvg) return;
onClick(targetSvg.childNodes[0]);
}
return (
<Wrapper onClick={onClickHighlight}>
<PageNum num={highlight.pageNum} />
<div>
<Title>{highlight.chpaterName}</Title>
<Post color={highlight.color}>{highlight.content}</Post>
</div>
</Wrapper>
);
}
interface Props {
highlight: HighlightType;
onClick: (highlightNode: any) => void;
emitEvent: () => void;
viewerRef: any;
}
export default Highlight

@ -0,0 +1,6 @@
import ReactDOM from 'react-dom';
import Reader from 'containers/Reader'
const EPUB_URL = "files/Alices Adventures in Wonderland.epub";
ReactDOM.render(<Reader url={EPUB_URL} />, document.getElementById('root'));

@ -0,0 +1,261 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useSelector, useDispatch } from 'react-redux'
// slices
import { pushHighlight, popHighlight, updateHighlight } from 'slices/book'
import { newSnackbar } from 'slices/snackbar'
// utils
import {
getParagraphCfi,
clashCfiRange,
getSelectionPosition,
compareCfi,
cfiRangeSpliter,
getNodefromCfi
} from 'lib/utils/commonUtil'
// styles
import viewerLayout, { contextmenuWidth } from 'lib/styles/viewerLayout'
// types
import { RootState } from 'slices'
import { BookStyle, BookFlow } from 'types/book'
import Page from 'types/page'
import Selection from 'types/selection'
import Highlight from 'types/highlight'
/**
* Epub Reader의 Highlight hook
* - [selection]: selection의
* - [onSelection]: selection (Iframe에 selection .)
* - [onClickHighlight]: highlight
* - [onAddHighlight] highlight
* - [onRemoveHighlight]: highlight
* - [onUpdateHighlight]: highlight
*
* @param viewerRef Viewer에 useRef
* @param setIsContextMenu Contextmenu를 useState
*
* @example
* const [isContextMenu, setIsContextMenu] = useState<boolean>(false);
*
* const {
* selection,
* onSelection,
* onClickHighlight,
* onRemoveHighlight
* } = useSelection(viewerRef, setIsContextMenu);
*/
const useHighlight = (
viewerRef: any,
setIsContextMenu: React.Dispatch<React.SetStateAction<boolean>>,
bookStyle: BookStyle,
bookFlow: BookFlow
) => {
const dispatch = useDispatch();
const currentLocation = useSelector<RootState, Page>(state => state.book.currentLocation);
const highlights = useSelector<RootState, Highlight[]>(state => state.book.highlights);
const [selection, setSelection] = useState<Selection>({
update: false,
x: 0,
y: 0,
height: 0,
cfiRange: '',
content: ''
});
/**
* Selection
* @param cfiRange CfiRange
* @return Whether event is success
*/
const onSelection = useCallback((cfiRange: string): boolean => {
if (!viewerRef.current) return false;
const iframe = viewerRef.current.querySelector('iframe');
if (!iframe) return false;
const iframeWin = iframe.contentWindow;
if (!iframeWin) return false;
const filtered = highlights.filter(
highlight => clashCfiRange(highlight.cfiRange, cfiRange)
);
if (filtered.length > 0) {
dispatch(newSnackbar({
text: "There are already registered highlights in your selection.",
type: "WARNING"
}));
iframeWin.getSelection().removeAllRanges();
return false;
}
const position = getSelectionPosition(
viewerRef.current,
bookStyle,
bookFlow,
viewerLayout.MIN_VIEWER_WIDTH,
viewerLayout.MIN_VIEWER_HEIGHT,
viewerLayout.VIEWER_HEADER_HEIGHT,
contextmenuWidth
);
if (!position) return false;
const { x, y, height } = position;
const content = iframeWin.getSelection().toString().trim();
if (content.length === 0) return false;
setSelection({
update: false,
x,
y,
height,
cfiRange,
content
});
return true;
}, [
dispatch,
viewerRef,
highlights,
bookFlow,
bookStyle,
setSelection
]);
/** Highlight 클릭 이벤트 */
const onClickHighlight = useCallback((highlightNode: HTMLElement) => {
const targetNode = highlightNode.parentElement;
if (!targetNode) return;
const cfiRange = targetNode.dataset.epubcfi;
if (!cfiRange) return;
const { x, y, width, height } = targetNode.getBoundingClientRect();
setSelection({
update: true,
x: x + width / 2 - contextmenuWidth / 2,
y: y + height,
height,
cfiRange,
content: ""
});
}, [setSelection]);
/** 하이라이트 생성 함수 */
const onAddHighlight = useCallback((color: string) => {
const paragraphCfi = getParagraphCfi(selection.cfiRange);
if (!paragraphCfi) return;
const highlight: Highlight = {
key: paragraphCfi + selection.cfiRange,
accessTime: new Date().toISOString(),
createTime: new Date().toISOString(),
color,
paragraphCfi,
cfiRange: selection.cfiRange,
chpaterName: currentLocation.chapterName,
pageNum: currentLocation.currentPage,
content: selection.content
};
dispatch(pushHighlight(highlight));
setSelection({ ...selection, update: true });
}, [dispatch, selection, currentLocation]);
/**
*
* @param color Hex
*/
const onUpdateHighlight = useCallback((highlight: Highlight | null, color: string) => {
if (!highlight) return;
dispatch(updateHighlight({
...highlight,
color
}));
}, [dispatch]);
/**
*
* @param key
*/
const onRemoveHighlight = useCallback((key: string, cfiRange: string) => {
if (!viewerRef.current || !key) return;
dispatch(popHighlight(key));
dispatch(newSnackbar({
text: "The highlight removed successful!",
type: "INFO"
}));
viewerRef.current.offHighlight(cfiRange);
}, [dispatch, viewerRef]);
/**
*
* -
*/
useEffect(() => {
if (!viewerRef.current) return;
const iframe = viewerRef.current.querySelector('iframe');
if (!iframe) return;
const iframeWin = iframe.contentWindow;
if (!iframeWin) return;
highlights.forEach(highlight => {
const cfiRange = cfiRangeSpliter(highlight.cfiRange);
if (!cfiRange) return;
const { startCfi } = cfiRange;
if (compareCfi(currentLocation.startCfi, startCfi) < 1
&& compareCfi(currentLocation.endCfi, startCfi) > -1) {
const node = getNodefromCfi(highlight.paragraphCfi);
if (!node) return;
viewerRef.current.onHighlight(
highlight.cfiRange,
(e: any) => {
onClickHighlight(e.target);
setIsContextMenu(true);
},
highlight.color
);
iframeWin.getSelection().removeAllRanges();
}
});
}, [
dispatch,
viewerRef,
currentLocation,
highlights,
onRemoveHighlight,
onClickHighlight,
setIsContextMenu
]);
return {
selection,
onSelection,
onClickHighlight,
onAddHighlight,
onRemoveHighlight,
onUpdateHighlight
};
}
export default useHighlight;

@ -0,0 +1,124 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Epub Reader hook
* - control의 display를 Menu / .
* - control의 show를 Animaton .
* - Menu Close Event
*
* @param ref DOM을 useRef
* @param delay (ms)
*
* @example
* const Viewer = () => {
* const menuDelay = 300;
*
* const ref = useRef<HTMLDivElement | null>(null);
* const [control, onToggle] = usemenu(ref, menuDelay);
*
* return (<>
* <MenuOpenBtn onClick={onToggle} />
* <Menu control={control} onToggle={onToggle} ref={ref} />
* </>);
* }
*
* const Menu = ({ control, onToggle }, ref) => {
* return (<>
* {control.display && <MenuWrapper show={control.show}
* delay={menuDelay}
* ref={ref}>
* <CloseBtn onClick={onToggle} />
* </MenuWrapper>}
* </>);
* }
*
* const MenuWrapper = styled.div<{show: boolean; delay: boolean;}>`
* transform: ${({show}) => show
* ? "translateX(0px)"
* : "translateX(420px)"
* };
* transition: ${({delay}) => `${delay}ms ease`};
* `;
*/
function useMenu(ref: { current: any }, delay: number) {
const [eventSignal, setEventSignal] = useState<boolean>(true);
const [control, setControl] = useState<MenuControl>({
display: false,
open: false
});
/** Menu 토글 */
const onToggle = useCallback(() => {
let event: any = null;
window.clearTimeout(event);
if (!control.display) {
setControl({ display: true, open: false });
event = window.setTimeout(() => setControl({ display: true, open: true}), 0);
} else {
setControl({ display: true, open: false });
event = window.setTimeout(() => setControl({ display: false, open: false }), delay - 50);
}
}, [control.display, delay]);
/**
*
* - Event path에서 , .
*/
const onClose = useCallback((e: any) => {
if (!ref || !ref.current) return;
if (!e.path.includes(ref.current)) {
onToggle();
}
}, [ref, onToggle]);
/**
*
* - '보기설정' rendition이 ,
* - Iframe [About 250ms]
*/
const emitEvent = useCallback(() => {
window.setTimeout(() => setEventSignal(true), 300);
}, [setEventSignal]);
/**
*
* - Epub이 iframe으로 , iframe에도 Event를 .
*/
useEffect(() => {
if (!eventSignal && !control.display) return;
const epubIframe = document.querySelector('iframe');
if (control.display) {
document.addEventListener('click', onClose);
if (epubIframe && epubIframe.contentWindow) {
epubIframe.contentWindow.document.addEventListener('click', onClose);
}
} else {
document.removeEventListener('click', onClose);
if (epubIframe && epubIframe.contentWindow) {
epubIframe.contentWindow.document.removeEventListener('click', onClose);
}
}
setEventSignal(false);
return () => {
document.removeEventListener('click', onClose);
if (epubIframe && epubIframe.contentWindow) {
epubIframe.contentWindow.document.removeEventListener('click', onClose);
}
}
}, [control.display, onClose, eventSignal]);
return [control, onToggle, emitEvent] as const;
}
export type MenuControl = {
display: boolean;
open: boolean;
}
export default useMenu

@ -0,0 +1,62 @@
import { keyframes } from 'styled-components';
const fadeIn = keyframes`
0% { opacity: 0; }
100% { opacity: 1;}
`;
const fadeOut = keyframes`
0% { opacity: 1; }
100% { opacity: 0; }
`;
const fadeInTop = keyframes`
0% { opacity: 0; transform: translateY(-40px); }
100% { opacity: 1; transform: translateY(0); }
`;
const fadeOutTop = keyframes`
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-40px); }
`;
const fadeInBottom = keyframes`
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
`;
const fadeOutBottom = keyframes`
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(40px); }
`;
const fadeInLeft = keyframes`
0% { opacity: 0; transform: translateX(-40px); }
100% { opacity: 1; transform: translateX(0); }
`;
const fadeOutLeft = keyframes`
0% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(-40px); }
`;
const fadeInRight = keyframes`
0% { opacity: 0; transform: translateX(40px); }
100% { opacity: 1; transform: translateX(0); }
`;
const fadeOutRight = keyframes`
0% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(40px); }
`;
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const animations = {
fadeIn,
fadeOut,
fadeInTop,
fadeOutTop,
fadeInBottom,
fadeOutBottom,
fadeInLeft,
fadeOutLeft,
fadeInRight,
fadeOutRight,
spin
};
export default animations

@ -0,0 +1,31 @@
export const mediaQuery = (maxWidth: number): string => `
@media (max-width: ${maxWidth}px)
`;
export const mediaValue = {
xlarge: 1600,
large: 1440,
medium: 1200,
small: 702,
xsmall: 350
};
type Media = {
xlarge : string,
large : string,
medium : string,
small : string,
xsmall : string,
custom : (maxWidth: number) => string
}
const media: Media = {
xlarge : mediaQuery(mediaValue.xlarge),
large : mediaQuery(mediaValue.large),
medium : mediaQuery(mediaValue.medium),
small : mediaQuery(mediaValue.small),
xsmall : mediaQuery(mediaValue.xsmall),
custom : mediaQuery,
};
export default media;

@ -0,0 +1,79 @@
/*
:::: Color Varient ::::
*/
const palette = {
white: '#ffffff',
black: '#000000',
/* gray */
gray0: '#fafafa',
gray1: '#e7e7e7',
gray2: '#c3c3c3',
gray3: '#9e9e9e',
gray4: '#868686',
gray5: '#656565',
gray6: '#505050',
gray7: '#3c3c3c',
gray8: '#282828',
/* red */
red0: '#ffe8e8',
red1: '#ffd0d1',
red2: '#ff8a8b',
red3: '#ff4445',
red4: '#ff1517',
red5: '#bf1011',
red6: '#990d0e',
red7: '#73090a',
red8: '#4d0607',
/* orange */
orange0: '#fff0ec',
orange1: '#ffe2d9',
orange2: '#ffb6a0',
orange3: '#ff8966',
orange4: '#ff6c40',
orange5: '#bf5130',
orange6: '#994126',
orange7: '#73311d',
orange8: '#4d2013',
/* yellow */
yellow0: '#fffce6',
yellow1: '#fffacc',
yellow2: '#fff280',
yellow3: '#feea33',
yellow4: '#fee500',
yellow5: '#bfac00',
yellow6: '#988900',
yellow7: '#726700',
yellow8: '#4c4500',
/* green */
green0: '#e6f7e6',
green1: '#cdefcc',
green2: '#83d880',
green3: '#39c133',
green4: '#07b100',
green5: '#058500',
green6: '#046a00',
green7: '#035000',
green8: '#023500',
/* blue */
blue0: '#ebf1ff',
blue1: '#d7e3ff ',
blue2: '#9cb9ff ',
blue3: '#618eff ',
blue4: '#3972ff ',
blue5: '#2b56bf ',
blue6: '#224499 ',
blue7: '#1a3373 ',
blue8: '#11224d ',
/* purple */
purple0: '#f2ebff',
purple1: '#d7e3ff',
purple2: '#be9cff',
purple3: '#9761ff',
purple4: '#7d39ff',
purple5: '#5e2bbf',
purple6: '#4b2299',
purple7: '#381a73',
purple8: '#26114d',
};
export default palette

@ -0,0 +1,24 @@
a {
text-decoration: none;
}
input {
border: none;
background-color: rgba(0,0,0,0);
}
::selection {
background-color: #d4e9ff;
}
.epub-highlight {
fill-opacity: 0.3;
mix-blend-mode: multiply;
cursor: pointer;
pointer-events: fill;
transition: .1s ease-in-out;
}
.epub-highlight:hover {
fill-opacity: 0.4;
}

@ -0,0 +1,35 @@
export const noselect = (): string => `
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
`;
export const transition = (): string =>`
cubic-bezier(0.25, 0.1, 0.25, 1);
`;
export const boxShadow = {
light: "0px 4px 16px rgba(0, 0, 0, 0.04)",
regular: "0px 8px 12px rgba(0, 0, 0, 0.2)",
bold: "0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 8px 2px rgba(0,0,0,0.12), 0 5px 5px -3px rgba(0,0,0,0.2)"
};
export const scrollbar = (width=8) => `
&::-webkit-scrollbar {
width: ${width}px;
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 10px;
}
&::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
&::-webkit-scrollbar-track {
background: transparent;
}
`

@ -0,0 +1,14 @@
import ViewerLayout from 'types/viewerLayout'
/** Epub viewer layout size */
const viewerLayout: ViewerLayout = {
MIN_VIEWER_WIDTH: 600,
MIN_VIEWER_HEIGHT: 300,
VIEWER_HEADER_HEIGHT: 54,
VIEWER_FOOTER_HEIGHT: 60,
VIEWER_SIDEMENU_WIDTH: 0
}
export const contextmenuWidth = 160;
export default viewerLayout

@ -0,0 +1,13 @@
export const zIndexSet = (layer: number): string => `
${layer}
`;
const zIndex = {
header: zIndexSet(5),
menu: zIndexSet(10),
modal: zIndexSet(100),
snackbar: zIndexSet(400),
tooltip: zIndexSet(500)
};
export default zIndex;

@ -0,0 +1,36 @@
import * as React from "react";
function SvgBookClose(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".bookClose_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g
id="bookClose_svg__\uB808\uC774\uC5B4_2"
data-name="\uB808\uC774\uC5B4 2"
>
<g
id="bookClose_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<path
className="bookClose_svg__cls-3"
d="M37.12 3H10.88a3 3 0 00-.43 0A1.71 1.71 0 009.1 4.11a1.87 1.87 0 00-.1.59v29.18c0 1 0 .94 1.88 1.88l18 9a1.8 1.8 0 002.6-1.2 3 3 0 000-.31v-7.58h5.62A1.88 1.88 0 0039 33.8V4.87A1.88 1.88 0 0037.12 3z"
/>
<path
className="bookClose_svg__cls-3"
d="M31.5 35.67V14.84a2 2 0 00-1.1-1.78L10.45 3"
/>
</g>
</g>
</svg>
);
}
export default SvgBookClose;

@ -0,0 +1,32 @@
import * as React from "react";
function SvgBookOpen(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".bookOpen_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g
id="bookOpen_svg__\uB808\uC774\uC5B4_2"
data-name="\uB808\uC774\uC5B4 2"
>
<g
id="bookOpen_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<path
className="bookOpen_svg__cls-3"
d="M4 6.5a27.24 27.24 0 0120 1v34a27.24 27.24 0 00-20-1zM24 7.5a27.24 27.24 0 0120-1v34a27.24 27.24 0 00-20 1"
/>
</g>
</g>
</svg>
);
}
export default SvgBookOpen;

@ -0,0 +1,23 @@
import * as React from "react";
function SvgBookmark(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<g data-name="\uB808\uC774\uC5B4 2">
<g data-name="\uB808\uC774\uC5B4 1">
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<path
d="M10 5.07V44a1 1 0 001.71.73l10.88-11.29a1.94 1.94 0 012.82 0l10.88 11.25A1 1 0 0038 44V5.07A2 2 0 0036 3H12a2 2 0 00-2 2.07z"
stroke="#000"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
fill="none"
/>
</g>
</g>
</svg>
);
}
export default SvgBookmark;

@ -0,0 +1,22 @@
import * as React from "react";
function SvgBookmarker(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<g data-name="\uB808\uC774\uC5B4 2">
<g data-name="\uB808\uC774\uC5B4 1">
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<path
d="M42.93 12H4c-.93 0-1.39.92-.73 1.46l11.25 9.33a1.51 1.51 0 010 2.42L3.31 34.54c-.66.54-.2 1.46.69 1.46h38.93A1.92 1.92 0 0045 34.29V13.71A1.92 1.92 0 0042.93 12z"
stroke="#000"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</g>
</g>
</svg>
);
}
export default SvgBookmarker;

@ -0,0 +1,26 @@
import * as React from "react";
function SvgClose(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".close_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g id="close_svg__\uB808\uC774\uC5B4_2" data-name="\uB808\uC774\uC5B4 2">
<g
id="close_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<path className="close_svg__cls-3" d="M4 4l40 40M4 44L44 4" />
</g>
</g>
</svg>
);
}
export default SvgClose;

@ -0,0 +1,35 @@
import * as React from "react";
function SvgController(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".controller_svg__cls-3{fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:2px}"
}
</style>
</defs>
<g
id="controller_svg__\uB808\uC774\uC5B4_2"
data-name="\uB808\uC774\uC5B4 2"
>
<g
id="controller_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<circle className="controller_svg__cls-3" cx={39} cy={40} r={4} />
<circle className="controller_svg__cls-3" cx={24} cy={24} r={4} />
<path d="M20 24a4 4 0 01.55-2H4a2 2 0 000 4h16.55a4 4 0 01-.55-2zM44 22H27.45a3.91 3.91 0 010 4H44a2 2 0 000-4z" />
<circle className="controller_svg__cls-3" cx={9} cy={8} r={4} />
<path d="M44 6H12.45a3.91 3.91 0 010 4H44a2 2 0 000-4zM5 8a4 4 0 01.55-2H4a2 2 0 000 4h1.55A4 4 0 015 8z" />
<circle className="controller_svg__cls-3" cx={39} cy={40} r={4} />
<path d="M35 40a4 4 0 01.55-2H4a2 2 0 000 4h31.55a4 4 0 01-.55-2zM44 38h-1.55a3.91 3.91 0 010 4H44a2 2 0 000-4z" />
</g>
</g>
</svg>
);
}
export default SvgController;

@ -0,0 +1,22 @@
import * as React from "react";
function SvgDown(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<g data-name="\uB808\uC774\uC5B4 2">
<g data-name="\uB808\uC774\uC5B4 1">
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<path
d="M8.33 15.82l14.32 20.46a1.63 1.63 0 002.7 0l14.32-20.46A1.78 1.78 0 0038.32 13H9.68a1.78 1.78 0 00-1.35 2.82z"
stroke="#000"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</g>
</g>
</svg>
);
}
export default SvgDown;

@ -0,0 +1,29 @@
import * as React from "react";
function SvgLeft(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".left_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g id="left_svg__\uB808\uC774\uC5B4_2" data-name="\uB808\uC774\uC5B4 2">
<g
id="left_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<path
className="left_svg__cls-3"
d="M4 24L18.5 9.5M18.5 38.5L4 24M44 24H4"
/>
</g>
</g>
</svg>
);
}
export default SvgLeft;

@ -0,0 +1,18 @@
import * as React from "react";
function SvgMenu(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<g data-name="\uB808\uC774\uC5B4 2">
<g data-name="\uB808\uC774\uC5B4 1">
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<rect x={2} y={6} width={44} height={4} rx={2} />
<rect x={2} y={22} width={44} height={4} rx={2} />
<rect x={2} y={38} width={44} height={4} rx={2} />
</g>
</g>
</svg>
);
}
export default SvgMenu;

@ -0,0 +1,26 @@
import * as React from "react";
function SvgNext(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" {...props}>
<defs>
<style>
{
".next_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g id="next_svg__\uB808\uC774\uC5B4_2" data-name="\uB808\uC774\uC5B4 2">
<g
id="next_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h44v44H0z" opacity={0.3} />
<path className="next_svg__cls-3" d="M32 22L12 2M32 22L12 42" />
</g>
</g>
</svg>
);
}
export default SvgNext;

@ -0,0 +1,26 @@
import * as React from "react";
function SvgPrev(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" {...props}>
<defs>
<style>
{
".prev_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g id="prev_svg__\uB808\uC774\uC5B4_2" data-name="\uB808\uC774\uC5B4 2">
<g
id="prev_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h44v44H0z" opacity={0.3} />
<path className="prev_svg__cls-3" d="M12 22L32 2M12 22l20 20" />
</g>
</g>
</svg>
);
}
export default SvgPrev;

@ -0,0 +1,38 @@
import * as React from "react";
function SvgScrollHorizontal(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".scrollHorizontal_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g
id="scrollHorizontal_svg__\uB808\uC774\uC5B4_2"
data-name="\uB808\uC774\uC5B4 2"
>
<g
id="scrollHorizontal_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<circle
className="scrollHorizontal_svg__cls-3"
cx={24}
cy={24}
r={5}
/>
<path
className="scrollHorizontal_svg__cls-3"
d="M3 24l12-12M3 24l12 12M45 24L33 12M45 24L33 36"
/>
</g>
</g>
</svg>
);
}
export default SvgScrollHorizontal;

@ -0,0 +1,33 @@
import * as React from "react";
function SvgScrollVertical(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".scrollVertical_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g
id="scrollVertical_svg__\uB808\uC774\uC5B4_2"
data-name="\uB808\uC774\uC5B4 2"
>
<g
id="scrollVertical_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<circle className="scrollVertical_svg__cls-3" cx={24} cy={24} r={5} />
<path
className="scrollVertical_svg__cls-3"
d="M24 3l12 12M24 3L12 15M24 45l12-12M24 45L12 33"
/>
</g>
</g>
</svg>
);
}
export default SvgScrollVertical;

@ -0,0 +1,27 @@
import * as React from "react";
function SvgSearch(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
<defs>
<style>
{
".search_svg__cls-3{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px}"
}
</style>
</defs>
<g id="search_svg__\uB808\uC774\uC5B4_2" data-name="\uB808\uC774\uC5B4 2">
<g
id="search_svg__\uB808\uC774\uC5B4_1-2"
data-name="\uB808\uC774\uC5B4 1"
>
<path fill="none" d="M0 0h48v48H0z" opacity={0.2} />
<circle className="search_svg__cls-3" cx={17} cy={17} r={14} />
<path className="search_svg__cls-3" d="M44 44L27 27" />
</g>
</g>
</svg>
);
}
export default SvgSearch;

@ -0,0 +1,10 @@
import styled from 'styled-components'
const TempIcon = styled.div`
width: 32px;
height: 32px;
background-color: #ccc;
border-radius: 32px;
`;
export default TempIcon

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><path class="cls-3" d="M37.12,3H10.88a3,3,0,0,0-.43,0h0A1.71,1.71,0,0,0,9.1,4.11,1.87,1.87,0,0,0,9,4.7V33.88c0,1,0,.94,1.88,1.88l18,9a1.8,1.8,0,0,0,2.6-1.2,3,3,0,0,0,0-.31V35.67h5.62A1.88,1.88,0,0,0,39,33.8V4.87A1.88,1.88,0,0,0,37.12,3Z"/><path class="cls-3" d="M31.5,35.67V14.84a2,2,0,0,0-1.1-1.78L10.45,3"/></g></g></svg>

After

Width:  |  Height:  |  Size: 691 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><path class="cls-3" d="M4,6.5a27.24,27.24,0,0,1,20,1v34a27.24,27.24,0,0,0-20-1Z"/><path class="cls-3" d="M24,7.5a27.24,27.24,0,0,1,20-1v34a27.24,27.24,0,0,0-20,1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 546 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><path class="cls-3" d="M10,5.07V44a1,1,0,0,0,1.71.73L22.59,33.44a1.94,1.94,0,0,1,2.82,0L36.29,44.69A1,1,0,0,0,38,44V5.07A2,2,0,0,0,36,3H12A2,2,0,0,0,10,5.07Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 542 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><path class="cls-3" d="M42.93,12H4c-.93,0-1.39.92-.73,1.46l11.25,9.33a1.51,1.51,0,0,1,0,2.42L3.31,34.54C2.65,35.08,3.11,36,4,36H42.93A1.92,1.92,0,0,0,45,34.29V13.71A1.92,1.92,0,0,0,42.93,12Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 568 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><line class="cls-3" x1="4" y1="4" x2="44" y2="44"/><line class="cls-3" x1="4" y1="44" x2="44" y2="4"/></g></g></svg>

After

Width:  |  Height:  |  Size: 484 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-miterlimit:10;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><circle class="cls-3" cx="39" cy="40" r="4"/><circle class="cls-3" cx="24" cy="24" r="4"/><path d="M20,24a4,4,0,0,1,.55-2H4a2,2,0,0,0,0,4H20.55A4,4,0,0,1,20,24Z"/><path d="M44,22H27.45a3.91,3.91,0,0,1,0,4H44a2,2,0,0,0,0-4Z"/><circle class="cls-3" cx="9" cy="8" r="4"/><path d="M44,6H12.45a3.91,3.91,0,0,1,0,4H44a2,2,0,0,0,0-4Z"/><path d="M5,8a4,4,0,0,1,.55-2H4a2,2,0,0,0,0,4H5.55A4,4,0,0,1,5,8Z"/><circle class="cls-3" cx="39" cy="40" r="4"/><path d="M35,40a4,4,0,0,1,.55-2H4a2,2,0,0,0,0,4H35.55A4,4,0,0,1,35,40Z"/><path d="M44,38H42.45a3.91,3.91,0,0,1,0,4H44a2,2,0,0,0,0-4Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 937 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><path class="cls-3" d="M8.33,15.82,22.65,36.28a1.63,1.63,0,0,0,2.7,0L39.67,15.82A1.78,1.78,0,0,0,38.32,13H9.68A1.78,1.78,0,0,0,8.33,15.82Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 516 B

@ -0,0 +1,31 @@
import MenuIcon from './Menu'
import ControllerIcon from './Controller';
import PrevIcon from './Prev';
import NextIcon from './Next';
import BookCloseIcon from './BookClose';
import BookOpenIcon from './BookOpen';
import BookmarkIcon from './Bookmark';
import BookmarkerIcon from './Bookmarker';
import CloseIcon from './Close';
import DownIcon from './Down';
import LeftIcon from './Left';
import ScrollHorizontalIcon from './ScrollHorizontal';
import ScrollVerticalIcon from './ScrollVertical';
import SearchIcon from './Search';
export {
MenuIcon,
ControllerIcon,
PrevIcon,
NextIcon,
BookCloseIcon,
BookOpenIcon,
BookmarkIcon,
BookmarkerIcon,
CloseIcon,
DownIcon,
LeftIcon,
ScrollHorizontalIcon,
ScrollVerticalIcon,
SearchIcon
};

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><line class="cls-3" x1="4" y1="24" x2="18.5" y2="9.5"/><line class="cls-3" x1="18.5" y1="38.5" x2="4" y2="24"/><line class="cls-3" x1="44" y1="24" x2="4" y2="24"/></g></g></svg>

After

Width:  |  Height:  |  Size: 545 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2{fill:none;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><rect x="2" y="6" width="44" height="4" rx="2"/><rect x="2" y="22" width="44" height="4" rx="2"/><rect x="2" y="38" width="44" height="4" rx="2"/></g></g></svg>

After

Width:  |  Height:  |  Size: 441 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><defs><style>.cls-1{opacity:0.3;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="44" height="44"/></g><line class="cls-3" x1="32" y1="22" x2="12" y2="2"/><line class="cls-3" x1="32" y1="22" x2="12" y2="42"/></g></g></svg>

After

Width:  |  Height:  |  Size: 487 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><defs><style>.cls-1{opacity:0.3;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="44" height="44"/></g><line class="cls-3" x1="12" y1="22" x2="32" y2="2"/><line class="cls-3" x1="12" y1="22" x2="32" y2="42"/></g></g></svg>

After

Width:  |  Height:  |  Size: 487 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><circle class="cls-3" cx="24" cy="24" r="5"/><line class="cls-3" x1="3" y1="24" x2="15" y2="12"/><line class="cls-3" x1="3" y1="24" x2="15" y2="36"/><line class="cls-3" x1="45" y1="24" x2="33" y2="12"/><line class="cls-3" x1="45" y1="24" x2="33" y2="36"/></g></g></svg>

After

Width:  |  Height:  |  Size: 637 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><circle class="cls-3" cx="24" cy="24" r="5"/><line class="cls-3" x1="24" y1="3" x2="36" y2="15"/><line class="cls-3" x1="24" y1="3" x2="12" y2="15"/><line class="cls-3" x1="24" y1="45" x2="36" y2="33"/><line class="cls-3" x1="24" y1="45" x2="12" y2="33"/></g></g></svg>

After

Width:  |  Height:  |  Size: 637 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.cls-1{opacity:0.2;}.cls-2,.cls-3{fill:none;}.cls-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><g id="레이어_2" data-name="레이어 2"><g id="레이어_1-2" data-name="레이어 1"><g class="cls-1"><rect class="cls-2" width="48" height="48"/></g><circle class="cls-3" cx="17" cy="17" r="14"/><line class="cls-3" x1="44" y1="44" x2="27" y2="27"/></g></g></svg>

After

Width:  |  Height:  |  Size: 481 B

@ -0,0 +1,201 @@
import { EpubCFI } from 'epubjs'
// types
import { BookStyle, BookFlow } from 'types/book'
/**
* DateTime to `yyyy-mm-dd`
* @param {Date} time
*/
export const timeFormatter = (time: Date): string => {
const yyyy = time.getFullYear();
const mm = time.getMonth() + 1;
const dd = time.getDate();
const msg = `${yyyy}-${mm}-${dd}`;
return msg;
}
/**
* Comparison of two CFI sizes
* - -1 : CFI 1 < CFI 2
* - 0 : CFI 1 == CFI 2
* - 1 : CFI 1 > CFI 2
* @param cfi_1 CFI 1
* @param cfi_2 CFI 2
*/
export const compareCfi = (cfi_1: string, cfi_2: string): number => {
const epubcfi = new EpubCFI();
return epubcfi.compare(cfi_1, cfi_2);
};
/**
* Split CFI range into two starting CFI and ending CFI
* - null : Invalid CFIRange
* @param cfiRange CFIRange
*/
export const cfiRangeSpliter = (cfiRange: string) => {
const content = cfiRange.slice(8, -1);
const [origin, start, end] = content.split(',');
if (!origin || !start || !end) return null;
const startCfi = `epubcfi(${origin + start})`;
const endCfi = `epubcfi(${origin + end})`;
return { startCfi, endCfi }
}
/**
* Whether the two CFI ranges nested
* - true : Nested
* - false : Not nested
* - null : Invalid CFIRange
* @param cfiRange1 First CFIRange
* @param cfiRange2 Second CFIRange
*/
export const clashCfiRange = (baseCfiRange: string, targetCfiRange: string) => {
const splitCfi1 = cfiRangeSpliter(baseCfiRange);
const splitCfi2 = cfiRangeSpliter(targetCfiRange);
if (!splitCfi1 || !splitCfi2) return null;
const { startCfi: s1, endCfi: e1 } = splitCfi1;
const { startCfi: s2, endCfi: e2 } = splitCfi2;
if ((compareCfi(s2, s1) <= 0 && compareCfi(s1, e2) <= 0)
||(compareCfi(s2, e1) <= 0 && compareCfi(e1, e2) <= 0)
||(compareCfi(s1, s2) <= 0 && compareCfi(e2, e1) <= 0)) {
return true;
}
return false;
}
/**
* Extract paragraph CFI from CFIRange
* - null : Invalid CFIRange
* @param cfiRange CFIRange
*/
export const getParagraphCfi = (cfiRange: string) => {
if (!cfiRange) return;
const content = cfiRange.slice(8, -1);
const [origin, start, end] = content.split(',');
if (!origin || !start || !end) return null;
const cfi = `epubcfi(${origin})`;
return cfi;
}
/**
* Get specific DOM Element from CFI
* - ** Warning**: Other Iframe must not exist in the Reader page!
* @param cfi CFI
* @returns HTML Element or Null
*/
export const getNodefromCfi = (cfi: string): HTMLElement | null => {
const epubcfi = cfi.slice(8, -1);
/* Remove Id */
const pureCfi = epubcfi.replace(/\[.*?\]/gi, '');
/* Only CFI Base */
const splitCfi = pureCfi.split('!');
if (splitCfi.length < 1 || splitCfi[1] === "") {
return null;
}
/* Remove Body tag CFI */
const cfiPath: number[] = splitCfi[1].split('/')
.slice(2)
.map(x => Number(x));
const iframe = document.querySelector('iframe');
if (!iframe) return null;
const iframeBody = iframe.contentWindow && iframe.contentWindow.document.body;
if (!iframeBody) return null;
let component: HTMLElement | null = iframeBody;
/* Find Node based on CFI */
for (let idx of cfiPath) {
const childNodes: any = component && component.childNodes;
/* Bookmark / Highlight filtering.. */
const filtered = ([...childNodes].filter(n => !n.dataset
|| !n.dataset.bookmark
|| !n.dataset.highlight ));
component = filtered[idx - 1];
if (!component) {
component = null;
break;
}
}
return component;
}
/**
* Selection absolute location
* @param viewer viewerRef.current
* @param bookStyle bookStyle
* @param bookFlow book-flow
* @param MIN_VIEWER_WIDTH min viewer width
* @param MIN_VIEWER_HEIGHT min viewer height
* @param VIEWER_HEADER_HEIGHT viewer header height
* @param CONTEXTMENU_WIDTH contextmenu width
* @returns Contextmenu location
*/
export const getSelectionPosition = (
viewer: any,
bookStyle: BookStyle,
bookFlow: BookFlow,
MIN_VIEWER_WIDTH: number,
MIN_VIEWER_HEIGHT: number,
VIEWER_HEADER_HEIGHT: number,
CONTEXTMENU_WIDTH: number
): { x: number, y: number, height: number, width: number } | null => {
const {
innerWidth: windowWidth,
innerHeight: windowHeight
} = window;
const iframeWidth = viewer.offsetWidth;
const scrollTop = viewer.querySelector('div').scrollTop;
const iframe = viewer.querySelector('iframe');
const selection_ = iframe && iframe.contentWindow && iframe.contentWindow.getSelection();
if (!selection_ || selection_.rangeCount === 0) return null;
const range = selection_.getRangeAt(0);
const {
x: selectionX,
y: selectionY,
height: selectionHeight,
width: selectionWidth
} = range.getBoundingClientRect();
const marginLeft = ~~((windowWidth - MIN_VIEWER_WIDTH) / 100 * bookStyle.marginHorizontal / 2);
const marginTop = bookFlow === "scrolled-doc"
? 0
: ~~((windowHeight - VIEWER_HEADER_HEIGHT - MIN_VIEWER_HEIGHT) / 100 * bookStyle.marginVertical / 2);
const x = ~~(selectionX % iframeWidth + marginLeft + ( selectionWidth / 2 - CONTEXTMENU_WIDTH / 2));
const y = ~~(selectionY + selectionHeight + VIEWER_HEADER_HEIGHT + marginTop - scrollTop);
return {
x,
y,
height: selectionHeight,
width: selectionWidth
};
}

@ -0,0 +1,287 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Book, Rendition } from "epubjs";
// style
import EpubViewerWrapper from 'modules/epubViewer/style'
// types
import { EpubViewerProps } from 'types'
import Toc from 'types/toc'
import Loc from 'types/loc'
/**
* EpubViewer Module
* @class
* @param props
* @param props.url Epub path
* @param props.epubFileOptions Epub file option
* @param props.epubOptions Epub viewer option
* @param props.style Epub Wrapper style
* @param props.location Epub CFI or href
* @param props.bookChanged Run when book changed
* @param props.rendtionChanged Run when rendition changed
* @param props.pageChanged Run when page changed
* @param props.tocChanged Run when toc changed
* @param props.selectionChanged Run when selected
* @param props.loadingView Loading Component
* @param ref Viewer ref
*/
const EpubViewer = ({
url,
epubFileOptions,
epubOptions,
style,
location,
bookChanged,
rendtionChanged,
pageChanged,
tocChanged,
selectionChanged,
loadingView,
}: EpubViewerProps, ref: any) => {
const [isLoaded, setIsLoaded] = useState(false);
const [book, setBook] = useState<Book | null>(null);
const [rendition, setRendition] = useState<Rendition | null>(null);
const [toc, setToc] = useState<Toc[]>([]);
const currentCfi = useRef<string>('');
/**
* Init Book
* @method
* - Set book state
* - Set Epub path state
* - Set isLoaded state
* - Set toc state
*/
const initBook = () => {
if (book) {
book.destroy();
}
const book_ = new Book(url, epubFileOptions);
setBook(book_);
bookChanged && bookChanged(book_);
book_.loaded.navigation.then(({ toc }) => {
const toc_: Toc[] = toc.map(t => ({
label: t.label,
href: t.href
}));
setIsLoaded(true);
setToc(toc_);
tocChanged && tocChanged(toc_);
initRendition(book_);
});
// Init Location & Pagination
book_.ready.then(function() {
const stored = localStorage.getItem(book_.key() + '-locations');
if (stored) {
return book_.locations.load(stored);
} else {
return book_.locations.generate(1024);
}
}).then(() => {
localStorage.setItem(book_.key() + '-locations', book_.locations.save());
});
};
/**
* Init Rendition
* @method
* - Init iframe tag on DOM element
* - Set rendition
* - Set start page
*/
const initRendition = (book_: Book) => {
const node = ref.current;
if (!node || !book_) return;
const rendition_ = book_.renderTo(node, {
width: "100%",
height: "100%",
...epubOptions
});
setRendition(rendition_);
rendtionChanged && rendtionChanged(rendition_);
if(typeof location === "string") {
rendition_.display(location);
} else if(toc.length > 0 && toc[0].href) {
rendition_.display(toc[0].href);
} else {
rendition_.display();
}
}
/**
* Move page
* @method
* @param type direction
*/
const movePage = useCallback((type: "PREV" | "NEXT") => {
if (!rendition) return;
if (type === "PREV") rendition.prev();
else rendition.next();
}, [rendition]);
/**
* Move page by arrow key
* @method
* @param props Keyboard Event
* @param props.key
*/
const handleKeyPress = useCallback(({ key }: any) => {
key && key === "ArrowLeft" && movePage("PREV");
key && key === "ArrowRight" && movePage("NEXT");
}, [movePage]);
/**
* Run when location changed
* @method
* @param loc
* - Set location state
* - Run 'locationChanged()' through startCFI
*/
const onLocationChange = useCallback((loc: Loc) => {
const startCfi = loc && loc.start;
const endCfi = loc && loc.end;
const base = loc && loc.start.slice(8).split('!')[0];
if (!book) return;
const spineItem = book.spine.get(startCfi);
const navItem = book.navigation.get(spineItem.href);
const chapterName = navItem && navItem.label.trim();
const locations: any = book.locations;
const currentPage = locations.locationFromCfi(startCfi);
const totalPage = locations.total;
pageChanged && pageChanged({
chapterName,
currentPage,
totalPage,
startCfi,
endCfi,
base
});
currentCfi.current = startCfi;
}, [book, pageChanged]);
/**
* Highlight function
* @param cfiRange Selecton CFIRange
* @param color Highlight color
* @param callback Highlight callback function when click it
*/
const onHighlight = useCallback((
cfiRange: string,
callback: (e: any) => void,
color?: string
) => {
if (!rendition) return;
rendition.annotations.remove(cfiRange, 'highlight');
rendition.annotations.highlight(
cfiRange,
{},
callback,
"epub-highlight",
{ 'fill': color || '#fdf183' }
);
}, [rendition]);
/**
* Register viewer control function
* @method
* - REF.CURRENT.prevPage() : Move prev page
* - REF.CURRENT.nextPage() : Move next page
* - REF.CURRENT.getCurrentCfi() : Get current CFI
* - REF.CURRENT.onHighlight(): Set highlight
* - REF.CURRENT.offHighlight(): Remove specific highliht
* - REF.CURRENT.seLocation(): Move to specific cfi or href
*/
const registerGlobalFunc = useCallback(() => {
if (!ref.current) return;
if (movePage) {
ref.current.prevPage = () => movePage("PREV");
ref.current.nextPage = () => movePage("NEXT");
}
ref.current.getCurrentCfi = () => currentCfi.current;
if (onHighlight) {
ref.current.onHighlight = onHighlight;
}
if (rendition) {
ref.current.offHighlight = (cfiRange: string) => rendition.annotations.remove(cfiRange, 'highlight');
ref.current.setLocation = (location: string) => rendition.display(location);
}
}, [ref, rendition, movePage, onHighlight]);
/** Epub Init */
/* eslint-disable */
useEffect(() => {
if (!url) return;
initBook();
}, [url]);
/* eslint-enable */
/**
* Emit Viewer Event
* - Register move event
* - Register location changed event
* - Register selection event
*/
/* eslint-disable */
useEffect(() => {
if (!rendition) return;
// Emit global control function
registerGlobalFunc();
document.addEventListener('keyup', handleKeyPress, false);
rendition.on("keyup", handleKeyPress);
rendition.on("locationChanged", onLocationChange);
selectionChanged && rendition.on('selected', selectionChanged);
return (() => {
document.removeEventListener('keyup', handleKeyPress, false);
rendition.off("keyup", handleKeyPress);
rendition.off("locationChanged", onLocationChange);
selectionChanged && rendition.off('selected', selectionChanged);
});
}, [
rendition,
registerGlobalFunc,
handleKeyPress
]);
/* eslint-enable */
/** Display page by changed location */
useEffect(() => {
if (!location || !rendition) return;
rendition.display(location);
}, [location, rendition]);
return (<>
{!isLoaded && loadingView}
<EpubViewerWrapper ref={ref} style={style} />
</>);
}
export default React.forwardRef(EpubViewer)

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save