├── src ├── template │ ├── mimetype │ ├── fixed-layout-jp.css.js │ ├── container.xml.js │ ├── navigation-documents.xhtml.js │ ├── page_img.xhtml.js │ ├── page.xhtml.js │ └── standard.opf.js ├── react-app-env.d.ts ├── components │ ├── modal │ │ ├── index.css │ │ └── index.tsx │ ├── main.tsx │ ├── header.tsx │ └── icon.tsx ├── setupTests.ts ├── utils │ ├── deep-clone.js │ └── get-uuid.ts ├── reportWebVitals.ts ├── index.tsx ├── store │ ├── ui.ts │ ├── blobs.ts │ ├── contents.ts │ ├── book.ts │ └── main.ts ├── logo.svg └── index.css ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── README.md ├── .gitignore ├── tsconfig.json └── package.json /src/template/mimetype: -------------------------------------------------------------------------------- 1 | application/epub+zip -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wing-kai/epub-manga-creator/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wing-kai/epub-manga-creator/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wing-kai/epub-manga-creator/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/components/modal/index.css: -------------------------------------------------------------------------------- 1 | #modal { 2 | position: fixed; 3 | overflow: scroll; 4 | top: 0; 5 | left: 0; 6 | } -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/template/fixed-layout-jp.css.js: -------------------------------------------------------------------------------- 1 | const getText = () =>`@charset "UTF-8"; 2 | 3 | html, 4 | body { 5 | margin: 0; 6 | padding: 0; 7 | font-size: 0; 8 | } 9 | svg, img { 10 | margin: 0; 11 | padding: 0; 12 | } 13 | ` 14 | 15 | export default getText -------------------------------------------------------------------------------- /src/template/container.xml.js: -------------------------------------------------------------------------------- 1 | const getText = () => ` 2 | 6 | 7 | 11 | 12 | ` 13 | 14 | export default getText -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EPUB Manga Creator V2 2 | 3 | 就是个把一堆漫画图片打包成 epub 格式的 web gui 4 | 5 | 文件结构规范来自 [DIgital-Comic-Association](http://www.digital-comic.jp/) (デジタルコミック協議会) 6 | 7 | 🚀 [click here to use](https://wing-kai.github.io/epub-manga-creator) 8 | 9 | ## Develop 10 | 11 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/utils/deep-clone.js: -------------------------------------------------------------------------------- 1 | const deepClone = (target) => { 2 | if (target && typeof target === 'object') { 3 | const newObj = target instanceof Array ? [] : target instanceof Date ? new Date(+target) : {} 4 | for (const key in target) { 5 | const val = target[key] 6 | newObj[key] = deepClone(val) 7 | } 8 | return newObj 9 | } 10 | 11 | return target 12 | } 13 | 14 | export default deepClone 15 | -------------------------------------------------------------------------------- /src/utils/get-uuid.ts: -------------------------------------------------------------------------------- 1 | const generateRandomUUID = () => { 2 | const char = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" 3 | 4 | let uuid = "" 5 | let i = 36 6 | 7 | while (i-- > 0) { 8 | if (i === 27 || i === 22 || i === 17 || i === 12) { 9 | uuid = uuid + "-" 10 | } else { 11 | uuid = uuid + String(char[Math.ceil(Math.random() * 35)]) 12 | } 13 | } 14 | 15 | return uuid 16 | } 17 | 18 | export default generateRandomUUID -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Manga Creator", 3 | "name": "EPUB Manga Creator", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/template/navigation-documents.xhtml.js: -------------------------------------------------------------------------------- 1 | const getText = () => ` 2 | 3 | 4 | 5 | Navigation 6 | 7 | 8 | 14 | 19 | 20 | 21 | ` 22 | 23 | export default getText -------------------------------------------------------------------------------- /src/template/page_img.xhtml.js: -------------------------------------------------------------------------------- 1 | const getText = () => ` 2 | 3 | 8 | 9 | 10 | {{title}} 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | ` 21 | 22 | export default getText -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx", 23 | "experimentalDecorators": true 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/template/page.xhtml.js: -------------------------------------------------------------------------------- 1 | const getText = () => ` 2 | 3 | 8 | 9 | 10 | {{title}} 11 | 12 | 13 | 14 | 15 |
16 | 19 | {{image}} 20 | 21 |
22 | 23 | ` 24 | 25 | export default getText -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import reportWebVitals from './reportWebVitals'; 4 | import 'components/icon' 5 | import './index.css' 6 | 7 | import Header from 'components/header' 8 | import Main from 'components/main' 9 | import Modal, { ModalBackDrop } from 'components/modal' 10 | 11 | function renderComponent(reactComponent: React.ReactElement) { 12 | const container = document.createElement('div') 13 | const Portal = () => { 14 | return ReactDOM.createPortal(reactComponent, document.body) 15 | } 16 | ReactDOM.render(, container) 17 | } 18 | 19 | renderComponent(
) 20 | renderComponent(
) 21 | renderComponent() 22 | renderComponent() 23 | 24 | // If you want to start measuring performance in your app, pass a function 25 | // to log results (for example: reportWebVitals(console.log)) 26 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 27 | reportWebVitals(); 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "epub-manga-creator-v2", 3 | "version": "2.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^26.0.15", 7 | "@types/node": "^12.0.0", 8 | "@types/react": "^17.0.0", 9 | "@types/react-dom": "^17.0.0", 10 | "clone": "^2.1.2", 11 | "jszip": "^3.6.0", 12 | "mobx": "^6.3.0", 13 | "mobx-react": "^7.1.0", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-scripts": "4.0.3", 17 | "react-use": "^17.2.4", 18 | "typescript": "^4.1.2", 19 | "web-vitals": "^1.0.1" 20 | }, 21 | "homepage": "https://wing-kai.github.io/epub-manga-creator", 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/store/ui.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, observable, action } from "mobx" 2 | 3 | class Store { 4 | @observable modalBookVisible = false 5 | @observable modalContentVisible = false 6 | @observable modalPageVisible = false 7 | @observable maxCardBoxCountInOneRow = 0 8 | @observable selectedPageIndex: number | null = null 9 | @observable fileName = `` 10 | 11 | firstImport = true 12 | 13 | constructor() { 14 | makeAutoObservable(this) 15 | } 16 | 17 | @action 18 | toggleBookVisible(fileName?: string) { 19 | this.modalBookVisible = !this.modalBookVisible 20 | if (fileName && this.firstImport) { 21 | this.fileName = fileName 22 | } 23 | } 24 | 25 | @action 26 | firstUploaded() { 27 | this.firstImport = false 28 | } 29 | 30 | @action 31 | toggleContentVisible() { 32 | this.modalContentVisible = !this.modalContentVisible 33 | } 34 | 35 | @action 36 | togglePageVisible() { 37 | this.modalPageVisible = !this.modalPageVisible 38 | } 39 | 40 | @action 41 | hideModal() { 42 | Object.assign(this, { 43 | modalBookVisible: false, 44 | modalPageVisible: false, 45 | }) 46 | } 47 | 48 | @action 49 | selectPageIndex(index: number | null) { 50 | if (index === null) { 51 | this.selectedPageIndex = null 52 | } else if (this.selectedPageIndex === index) { 53 | this.selectedPageIndex = null 54 | } else { 55 | this.selectedPageIndex = index 56 | } 57 | } 58 | } 59 | 60 | export default Store -------------------------------------------------------------------------------- /src/template/standard.opf.js: -------------------------------------------------------------------------------- 1 | const getText = () => ` 2 | 13 | 14 | 15 | 16 | 17 | {{title}} 18 | 19 | 20 | 21 | 22 | 23 | {{subject}} 24 | 25 | 26 | {{publisher}} 27 | 28 | 29 | 30 | ja 31 | 32 | 33 | urn:uuid:{{uuid}} 34 | 35 | 36 | {{createTime}} 37 | 38 | 39 | pre-paginated 40 | {{spread}} 41 | 42 | 43 | true 44 | false 45 | 1.1 46 | 47 | 48 | 49 | 50 | width={{width}}, height={{height}} 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ` 77 | 78 | export default getText -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 26 | 27 | 28 | Epub Manga Creator 29 | 30 | 31 | 32 | 33 | 36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/blobs.ts: -------------------------------------------------------------------------------- 1 | import { action, makeAutoObservable, observable, runInAction } from "mobx" 2 | 3 | export declare namespace StoreBlobs { 4 | export interface ImageBlob { 5 | blob: Blob 6 | blobURL: string 7 | thumbnailURL: string 8 | originImage: HTMLImageElement 9 | } 10 | } 11 | 12 | const getImageWithBlobURL = (blobURL: string): Promise => new Promise((resolve, reject) => { 13 | const image = new Image() 14 | image.onerror = (e) => reject(e) 15 | image.onload = (e) => { 16 | resolve(image) 17 | } 18 | image.src = blobURL 19 | }) 20 | 21 | const formatBlobItem = async (blob: Blob) => { 22 | const blobURL = URL.createObjectURL(blob) 23 | 24 | const originImage = await getImageWithBlobURL(blobURL) 25 | 26 | const canvas = document.createElement("canvas") 27 | if (originImage.width < originImage.height) { 28 | canvas.width = originImage.width / originImage.height * 200 29 | canvas.height = 200 30 | } else if (originImage.width > originImage.height) { 31 | canvas.width = 200 32 | canvas.height = originImage.height / originImage.width * 200 33 | } else { 34 | canvas.width = 200 35 | canvas.height = 200 36 | } 37 | 38 | const context = canvas.getContext('2d') as CanvasRenderingContext2D 39 | context.imageSmoothingQuality = 'high' 40 | context.drawImage(originImage, 0, 0, canvas.width, canvas.height) 41 | 42 | const thumbnailBlob = await new Promise( 43 | (resolve, reject) => canvas.toBlob( 44 | blob => blob ? resolve(blob) : reject() 45 | ) 46 | ) 47 | 48 | const item: StoreBlobs.ImageBlob = { 49 | blob, 50 | blobURL, 51 | thumbnailURL: URL.createObjectURL(thumbnailBlob), 52 | originImage 53 | } 54 | 55 | return item 56 | } 57 | 58 | class Store { 59 | @observable blobs: ({[id: string]: StoreBlobs.ImageBlob}) = {} 60 | 61 | constructor() { 62 | makeAutoObservable(this) 63 | } 64 | 65 | @action 66 | async push(blobs: Blob[], uuids: string[]) { 67 | let formatBlobs: StoreBlobs.ImageBlob[] = [] 68 | 69 | try { 70 | formatBlobs = await Promise.all(blobs.map(formatBlobItem)) 71 | } catch (err) { 72 | console.error(err) 73 | alert('错误\nError') 74 | return 75 | } 76 | 77 | runInAction(() => { 78 | const blobs: ({[id: string]: StoreBlobs.ImageBlob}) = {} 79 | 80 | uuids.forEach((uuid, index) => { 81 | blobs[uuid] = formatBlobs[index] 82 | }) 83 | 84 | // Object.assign(this.blobs, blobs) 85 | this.blobs = { 86 | ...this.blobs, 87 | ...blobs 88 | } 89 | }) 90 | } 91 | } 92 | 93 | export { Store } 94 | export default new Store() -------------------------------------------------------------------------------- /src/store/contents.ts: -------------------------------------------------------------------------------- 1 | import { action, makeAutoObservable, observable, toJS } from "mobx" 2 | 3 | interface ContentItem { 4 | pageIndex: number | null 5 | title: string 6 | } 7 | 8 | interface IndexMap { [pageIndex: string]: number } 9 | 10 | const cloneState = function(this: Store) { 11 | return { 12 | list: toJS(this.list), 13 | indexMap: toJS(this.indexMap) 14 | } 15 | } 16 | 17 | class Store { 18 | @observable list: ContentItem[] = [{ 19 | pageIndex: 0, 20 | title: '表紙' 21 | }] 22 | @observable indexMap: IndexMap = { 23 | 0: 0 24 | } 25 | @observable savedSets: { title: string; list: ContentItem[] }[] = [] 26 | 27 | constructor() { 28 | makeAutoObservable(this) 29 | } 30 | 31 | @action 32 | removeTitle(listIndex: number) { 33 | const { list, indexMap } = cloneState.call(this) 34 | 35 | if (list.length === 1) { 36 | return 37 | } 38 | 39 | const item = list[listIndex] 40 | 41 | list.splice(listIndex, 1) 42 | if (item.pageIndex && (item.pageIndex in indexMap)) { 43 | delete indexMap[item.pageIndex] 44 | } 45 | 46 | this.list = list 47 | this.indexMap = indexMap 48 | } 49 | 50 | @action 51 | updateList(contentItems: ContentItem[]) { 52 | const indexMap: IndexMap = {} 53 | 54 | contentItems.forEach((item, index) => { 55 | if (item.pageIndex !== null) { 56 | indexMap[item.pageIndex] = index 57 | } 58 | }) 59 | 60 | this.list = contentItems 61 | this.indexMap = indexMap 62 | } 63 | 64 | @action 65 | setPageIndexToTitle(listIndex: number, pageIndex: number) { 66 | const { list, indexMap } = cloneState.call(this) 67 | 68 | if (indexMap[pageIndex] && indexMap[pageIndex] === listIndex) { 69 | return 70 | } 71 | 72 | const newIndexMap: IndexMap = {} 73 | const originListIndex = pageIndex in indexMap ? indexMap[pageIndex] : null 74 | 75 | if (originListIndex !== null) { 76 | list[originListIndex].pageIndex = null 77 | } 78 | 79 | list[listIndex].pageIndex = pageIndex 80 | 81 | list.forEach((contentItem: ContentItem, index: number) => { 82 | if (contentItem.pageIndex !== null) { 83 | newIndexMap[contentItem.pageIndex] = index 84 | } 85 | }) 86 | 87 | this.list = list 88 | this.indexMap = newIndexMap 89 | } 90 | 91 | @action 92 | removePageIndex(listIndex: number) { 93 | const { list, indexMap } = cloneState.call(this) 94 | 95 | const item = list[listIndex] 96 | list[listIndex].pageIndex = null 97 | delete indexMap[item.pageIndex as number] 98 | 99 | this.list = list 100 | this.indexMap = indexMap 101 | } 102 | 103 | @action 104 | saveSet(title: string) { 105 | this.savedSets.push({ 106 | title, 107 | list: toJS(this.list) 108 | }) 109 | } 110 | 111 | @action 112 | removeSet(index: number) { 113 | const savedSets = toJS(this.savedSets) 114 | savedSets.splice(index, 1) 115 | this.savedSets = savedSets 116 | } 117 | } 118 | 119 | export default Store -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --card-box-width: 282px; 3 | --card-box-margin: 7px; 4 | } 5 | 6 | html, body, #app { 7 | width: 100%; 8 | height: 100%; 9 | overflow: hidden; 10 | } 11 | 12 | body { 13 | display: flex; 14 | } 15 | 16 | .icon { 17 | width: 20px; 18 | height: 20px; 19 | fill: #eee; 20 | } 21 | 22 | #nav { 23 | padding: 10px; 24 | box-sizing: border-box; 25 | display: flex; 26 | flex-direction: column; 27 | justify-content: flex-start; 28 | } 29 | 30 | #nav .btn { 31 | width: 50px; 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | } 36 | 37 | #nav .nav-item + .nav-item { 38 | margin-top: 10px; 39 | } 40 | 41 | #nav .nav-item.dropdown { 42 | cursor: pointer; 43 | } 44 | 45 | #nav .nav-item.dropdown:hover > .dropdown-menu { 46 | display: block; 47 | } 48 | 49 | #main { 50 | flex: 1; 51 | height: auto; 52 | overflow: auto; 53 | box-sizing: border-box; 54 | display: flex; 55 | flex-direction: column; 56 | } 57 | 58 | #main .main-input-upload { 59 | width: 400px; 60 | height: 300px; 61 | margin: auto; 62 | font-size: 50px; 63 | display: flex; 64 | justify-content: center; 65 | align-items: center; 66 | } 67 | 68 | #main .row { 69 | width: 100%; 70 | } 71 | 72 | #main .row.page-row { 73 | margin: 0 0 20px 0; 74 | } 75 | 76 | #main .card-group { 77 | width: var(--card-box-width); 78 | margin: 0 var(--card-box-margin); 79 | padding: 0; 80 | overflow: hidden; 81 | } 82 | 83 | #main > .alert { 84 | width: 300px; 85 | margin: auto; 86 | } 87 | 88 | #main .card { 89 | overflow: hidden; 90 | height: 200px; 91 | position: relative; 92 | } 93 | 94 | #main .card .card-image { 95 | width: 100%; 96 | height: 100%; 97 | display: flex; 98 | } 99 | 100 | #main .card .icon { 101 | position: absolute; 102 | right: 0; 103 | top: -4px; 104 | z-index: 1; 105 | fill: var(--bs-primary); 106 | width: 50px; 107 | height: 50px; 108 | } 109 | 110 | #main .card .page-num { 111 | position: absolute; 112 | bottom: 2px; 113 | left: 50%; 114 | transform: translateX(-50%); 115 | padding: 3px 4px; 116 | background-color: rgba(0, 0, 0, .5); 117 | color: #fff; 118 | font-size: 14px; 119 | text-align: center; 120 | line-height: 1; 121 | border-radius: 4px; 122 | } 123 | 124 | #main .card .card-image .spinner-border { 125 | margin: auto; 126 | } 127 | 128 | #main .card .card-body, 129 | #main .card .btn-group { 130 | padding: 0; 131 | } 132 | 133 | #main .card .btn-group { 134 | position: absolute; 135 | bottom: 7px; 136 | left: 50%; 137 | transform: translateX(-50%); 138 | opacity: 0; 139 | } 140 | 141 | #main .card:hover .btn-group { 142 | opacity: 1; 143 | } 144 | 145 | #main .card-group .btn-group .btn { 146 | padding: 0; 147 | display: flex; 148 | align-items: center; 149 | justify-content: center; 150 | width: 32px; 151 | height: 32px; 152 | line-height: 1; 153 | } 154 | 155 | #main .author-info > * { 156 | width: auto; 157 | } 158 | 159 | #main .author-info .ghbtns { 160 | width: 130px; 161 | border: none; 162 | } -------------------------------------------------------------------------------- /src/store/book.ts: -------------------------------------------------------------------------------- 1 | import { action, makeAutoObservable, observable, toJS } from "mobx" 2 | import uuid from 'utils/get-uuid' 3 | 4 | export declare namespace StoreBook { 5 | export interface PageItem { 6 | index: number, 7 | blobID: string 8 | sticky: 'auto' | 'left' | 'right', 9 | blank: boolean 10 | } 11 | 12 | export interface BookInfoSet { 13 | bookTitle: string 14 | bookAuthors: string[] 15 | bookSubject: string 16 | bookPublisher: string 17 | } 18 | } 19 | 20 | type BookPageProperty = 'bookID' | 'bookTitle' | 'bookAuthors' | 'bookSubject' | 'bookPublisher' | 'pageSize' | 'pagePosition' | 'pageShow' | 'pageFit' | 'pageBackgroundColor' | 'pageDirection' | 'coverPosition' | 'imgTag' 21 | 22 | class Store { 23 | @observable bookID: string = uuid() 24 | @observable bookTitle: string = '' 25 | @observable bookAuthors: string[] = [''] 26 | @observable bookSubject: string = '' 27 | @observable bookPublisher: string = '' 28 | 29 | @observable pageSize: [number, number] = [250, 353] 30 | @observable pagePosition: ('center' | 'between') = 'between' 31 | @observable pageShow: ('two' | 'one') = 'two' 32 | @observable pageFit: ('stretch' | 'fit' | 'fill') = 'stretch' 33 | @observable pageBackgroundColor: ('white' | 'black') = 'white' 34 | @observable pageDirection: ('right' | 'left') = 'right' 35 | @observable coverPosition: ('first-page' | 'alone') = 'first-page' 36 | @observable imgTag: ('svg' | 'img') = 'svg' 37 | 38 | @observable pages: StoreBook.PageItem[] = [] 39 | @observable savedSets: StoreBook.BookInfoSet[] = [] 40 | 41 | constructor() { 42 | makeAutoObservable(this) 43 | } 44 | 45 | @action 46 | pushNewPage(blobUUIDs: string[]) { 47 | const newPages: StoreBook.PageItem[] = blobUUIDs.map((uuid, index) => ({ 48 | index, 49 | blobID: uuid, 50 | sticky: 'auto', 51 | blank: false 52 | })) 53 | 54 | const pages = toJS(this.pages) 55 | this.pages = [...pages, ...newPages] 56 | } 57 | 58 | splitPage(index: number, blobUUIDs: string[]) { 59 | const pages = toJS(this.pages) 60 | 61 | const newPageItem1: StoreBook.PageItem = { 62 | index, 63 | blobID: blobUUIDs[0], 64 | sticky: 'auto', 65 | blank: false 66 | } 67 | const newPageItem2: StoreBook.PageItem = { 68 | index: -1, 69 | blobID: blobUUIDs[1], 70 | sticky: 'auto', 71 | blank: false 72 | } 73 | 74 | pages.splice(index, 1, newPageItem1, newPageItem2) 75 | 76 | this.pages = pages 77 | } 78 | 79 | @action 80 | updateBookPageProperty(key: BookPageProperty, value: any) { 81 | Object.assign(this, { 82 | [key]: value 83 | }) 84 | } 85 | 86 | @action 87 | removePage(index: number) { 88 | const pages = toJS(this.pages) 89 | pages.splice(index, 1) 90 | this.pages = pages 91 | } 92 | 93 | @action 94 | switchIndex(index: number, targetIndex: number) { 95 | let pages: (StoreBook.PageItem | null)[] = toJS(this.pages) 96 | const pageItem = pages[index] 97 | const maxIndex = pages.length - 1 98 | 99 | pages[index] = null 100 | const part1 = pages.slice(0, targetIndex) 101 | const part2 = pages.slice(targetIndex, maxIndex + 1) 102 | pages = [...part1, pageItem, ...part2].filter(item => item) 103 | 104 | this.pages = pages as StoreBook.PageItem[] 105 | } 106 | 107 | @action 108 | updatePageItemIndex() { 109 | const pages = toJS(this.pages) 110 | 111 | this.pages = pages.map((pageItem: StoreBook.PageItem, i: number) => { 112 | pageItem.index = i 113 | return pageItem 114 | }) 115 | } 116 | 117 | @action 118 | insertBlankPage(index: number) { 119 | const pages = toJS(this.pages) 120 | const maxIndex = pages.length - 1 121 | 122 | const newPageItem: StoreBook.PageItem = { 123 | index: -1, 124 | blobID: '', 125 | sticky: 'auto', 126 | blank: true 127 | } 128 | 129 | if (index >= maxIndex) { 130 | pages.push(newPageItem) 131 | } else { 132 | const currentIndex = index < 0 ? 0 : index 133 | const pageItem = pages[currentIndex] 134 | pages.splice(currentIndex, 1, newPageItem, pageItem) 135 | } 136 | 137 | this.pages = pages 138 | } 139 | 140 | @action 141 | saveBookInfoToSet() { 142 | const newSet = { 143 | bookTitle: this.bookTitle, 144 | bookAuthors: this.bookAuthors, 145 | bookSubject: this.bookSubject, 146 | bookPublisher: this.bookPublisher, 147 | } 148 | 149 | this.savedSets.push(newSet) 150 | } 151 | 152 | @action 153 | removeBookInfoSet(index: number) { 154 | const savedSets = toJS(this.savedSets) 155 | savedSets.splice(index, 1) 156 | this.savedSets = savedSets 157 | } 158 | 159 | @action 160 | applySet(index: number) { 161 | this.bookTitle = this.savedSets[index].bookTitle 162 | this.bookAuthors = this.savedSets[index].bookAuthors 163 | this.bookSubject = this.savedSets[index].bookSubject 164 | this.bookPublisher = this.savedSets[index].bookPublisher 165 | } 166 | } 167 | 168 | export default Store -------------------------------------------------------------------------------- /src/components/main.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react' 2 | import React, { useCallback, useEffect, useRef, useState } from 'react' 3 | import { useMount } from 'react-use' 4 | import storeMain from 'store/main' 5 | import storeBlobs, { StoreBlobs } from 'store/blobs' 6 | import Icon from './icon' 7 | 8 | const THIS_YEAR = (new Date()).getFullYear() 9 | 10 | const PageCard = observer(function(props: { 11 | pageItemIndex: number | null 12 | blobItem?: StoreBlobs.ImageBlob | null 13 | pagePosition: 'center' | 'left' | 'right' 14 | blank: boolean 15 | }) { 16 | const storeUI = React.useContext(React.createContext(storeMain.ui)) 17 | const storeBook = React.useContext(React.createContext(storeMain.book)) 18 | const storeContent = React.useContext(React.createContext(storeMain.contents)) 19 | 20 | const onClickImage = useCallback(() => { 21 | storeMain.ui.selectPageIndex(props.pageItemIndex) 22 | }, [props.pageItemIndex]) 23 | 24 | if (props.pageItemIndex === null) { 25 | return ( 26 |
27 | ) 28 | } 29 | 30 | let preserveAspectRatio = 'none' 31 | 32 | if (storeBook.pageFit !== 'stretch') { 33 | preserveAspectRatio = props.pagePosition === 'center' 34 | ? 'xMidYMid ' 35 | : props.pagePosition === 'left' 36 | ? 'xMinYMid ' 37 | : 'xMaxYMid ' 38 | 39 | if (storeBook.pageFit === 'fit') { 40 | preserveAspectRatio += 'meet' 41 | } else { // props.imageFit === 'fill' 42 | preserveAspectRatio += 'slice' 43 | } 44 | } 45 | 46 | const imageFocus = props.pageItemIndex !== null && (storeUI.selectedPageIndex === props.pageItemIndex) 47 | 48 | return ( 49 |
50 | { 51 | props.pageItemIndex in storeContent.indexMap 52 | ? 53 | : null 54 | } 55 | { 56 | (props.blobItem || (!props.blobItem && props.blank)) ? ( 57 | 61 | 62 | { 63 | props.blobItem ? ( 64 | 70 | ) : null 71 | } 72 | { 73 | !imageFocus ? null : 74 | 75 | } 76 | 77 | ) : ( 78 |
79 |
80 |
81 | ) 82 | } 83 | { 84 | props.pageItemIndex === null 85 | ? null 86 | :
{props.pageItemIndex + 1}
87 | } 88 |
89 | ) 90 | }) 91 | 92 | const DoublePageCard = observer(function(props: { 93 | pages: [number | null, number | null] 94 | }) { 95 | const storeBook = React.useContext(React.createContext(storeMain.book)) 96 | 97 | const leftSidePageIndex = storeBook.pageDirection === 'right' ? props.pages[1] : props.pages[0] 98 | const rightSidePageIndex = storeBook.pageDirection === 'right' ? props.pages[0] : props.pages[1] 99 | const leftSidePage = leftSidePageIndex === null ? null : storeBook.pages[leftSidePageIndex] 100 | const rightSidePage = rightSidePageIndex === null ? null : storeBook.pages[rightSidePageIndex] 101 | const coverPosition = storeBook.coverPosition === 'alone' ? 1 : 0 102 | 103 | return ( 104 |
105 | 111 | 117 |
118 | ) 119 | }) 120 | 121 | const computedStyle = getComputedStyle(document.documentElement) 122 | const CARD_BOX_WIDTH = +computedStyle.getPropertyValue('--card-box-width').slice(0, -2) 123 | const CARD_BOX_MARGIN = +computedStyle.getPropertyValue('--card-box-margin').slice(0, -2) 124 | 125 | const Main = function() { 126 | const mainRef = useRef(null) 127 | const storeBook = React.useContext(React.createContext(storeMain.book)) 128 | // const storeUI = React.useContext(React.createContext(storeMain.ui)) 129 | const [showPages, setShowPages] = useState<[any, any][][]>([]) 130 | // const [maxCardBoxCountInOneRow, setMaxCardBoxCountInOneRow] = useState(0) 131 | 132 | const pageResizeCallback = useCallback(() => { 133 | const pageWidth = mainRef.current?.clientWidth 134 | if (!pageWidth) { 135 | return 136 | } 137 | 138 | const boxCountInOneRow = Math.floor(pageWidth / (CARD_BOX_WIDTH + CARD_BOX_MARGIN * 2)) 139 | const rowCount = Math.ceil((1 + storeBook.pages.length) / boxCountInOneRow / 2) 140 | 141 | // if (maxCardBoxCountInOneRow === boxCountInOneRow) { 142 | // return 143 | // } 144 | 145 | if (storeBook.pages.length === 0) { 146 | // setMaxCardBoxCountInOneRow(boxCountInOneRow) 147 | setShowPages([]) 148 | return 149 | } 150 | 151 | let i = 0 152 | let j = 0 153 | let x = -1 154 | 155 | const pages: [any, any][][] = [] 156 | const len = storeBook.pages.length 157 | 158 | while(i++ < rowCount) { 159 | const r: [number | null, number | null][] = [] 160 | while(j++ < boxCountInOneRow) { 161 | r.push([ 162 | storeBook.coverPosition === 'first-page' 163 | ? x === -1 ? null : ++x < len ? x : null 164 | : ++x < len ? x : null, 165 | ++x < len ? x : null 166 | ]) 167 | } 168 | j = 0 169 | pages.push(storeBook.pageDirection === 'right' ? r.reverse() : r) 170 | } 171 | 172 | // setMaxCardBoxCountInOneRow(boxCountInOneRow) 173 | setShowPages(pages) 174 | }, [storeBook.coverPosition, storeBook.pageDirection, storeBook.pages.length]) 175 | 176 | const onClickImport = useCallback(() => { 177 | document.getElementById('input-upload')?.click() 178 | }, []) 179 | 180 | useEffect(() => { 181 | pageResizeCallback() 182 | }, [storeBook.pages, pageResizeCallback]) 183 | 184 | useMount(() => { 185 | pageResizeCallback() 186 | // TODO: 需要throttle优化 187 | window.addEventListener('resize', pageResizeCallback) 188 | }) 189 | 190 | return ( 191 |
192 | { 193 | showPages.length === 0 ? ( 194 | process.env.NODE_ENV === 'development' 195 | ?
Import
196 | :
ready 🚀
197 | ) : ( 198 | showPages.map((row, i) => ( 199 |
200 | { 201 | row.map((pages, j) => ()) 202 | } 203 |
204 | )) 205 | ) 206 | } 207 |
208 |
{THIS_YEAR} wing-kai@Github
209 |