@ -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 |
# 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) |