@ -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" |
||||
] |
||||
} |
@ -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%; |
||||
} |
After Width: | Height: | Size: 475 B |
After Width: | Height: | Size: 981 B |
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 |
After Width: | Height: | Size: 691 B |
After Width: | Height: | Size: 546 B |
After Width: | Height: | Size: 542 B |
After Width: | Height: | Size: 568 B |
After Width: | Height: | Size: 484 B |
After Width: | Height: | Size: 937 B |
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 |
||||
}; |
After Width: | Height: | Size: 545 B |
After Width: | Height: | Size: 441 B |
After Width: | Height: | Size: 487 B |
After Width: | Height: | Size: 487 B |
After Width: | Height: | Size: 637 B |
After Width: | Height: | Size: 637 B |
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) |