├── 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 |
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 |
77 | ) : (
78 |
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 |
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 |
218 |
219 |
220 | )
221 | }
222 |
223 | export default observer(Main)
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip'
2 | import { observer } from 'mobx-react'
3 | import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'
4 | import Icon from 'components/icon'
5 | import storeMain from 'store/main'
6 | // import storeBlobs from 'store/blobs'
7 |
8 | type SupportType = 'image' | 'zip' | 'epub'
9 |
10 | const PageControl = observer(function(props: { pageIndex: number | null }) {
11 | const onUseImageSizeToPage = useCallback(() => {
12 | const pageItem = storeMain.book.pages[props.pageIndex as number]
13 | const image = storeMain.blobs.blobs[pageItem.blobID].originImage
14 | storeMain.book.updateBookPageProperty('pageSize', [
15 | image.width,
16 | image.height
17 | ])
18 | }, [props.pageIndex])
19 |
20 | const onChangePageIndex = useCallback(() => {
21 | const max = storeMain.book.pages.length
22 | const inputValue = window.prompt(`new page index (1 - ${max}):`)
23 | let num = parseInt(inputValue || '')
24 |
25 | if (isNaN(num) || num < 1) {
26 | return
27 | } else if (num > max) {
28 | num = max
29 | }
30 |
31 | storeMain.replacePageIndex(props.pageIndex as number, num - 1)
32 | }, [props.pageIndex])
33 |
34 | const onSetContentq = useCallback((e: React.MouseEvent) => {
35 | storeMain.contents.setPageIndexToTitle(
36 | +(e.currentTarget.dataset.index as string),
37 | props.pageIndex as number
38 | )
39 | }, [props.pageIndex])
40 |
41 | const onSplitPage = useCallback(() => {
42 | storeMain.splitPage(props.pageIndex as number)
43 | }, [props.pageIndex])
44 |
45 | const onRemovePage = useCallback(() => {
46 | const res = window.confirm(`remove page index ${props.pageIndex as number + 1}?`)
47 | res && storeMain.removePage(props.pageIndex as number)
48 | }, [props.pageIndex])
49 |
50 | if (props.pageIndex === null) {
51 | return (
52 | <>
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | >
69 | )
70 | }
71 |
72 | const blankPage = storeMain.book.pages[props.pageIndex].blank
73 |
74 | return (
75 | <>
76 |
77 |
80 |
81 |
82 |
85 |
86 |
87 |
88 |
89 | {
90 | storeMain.contents.list.map((contentItem, index) =>
91 | -
92 |
97 | {contentItem.title}
98 |
99 |
100 | )
101 | }
102 |
103 |
104 |
105 |
108 |
109 |
110 |
113 |
114 | >
115 | )
116 | })
117 |
118 | const AcceptMap = {
119 | image: 'image/jpeg,image/png,image/webp,image/avif',
120 | zip: 'application/zip',
121 | epub: 'application/epub+zip',
122 | }
123 |
124 | const MultipleAttrMap = {
125 | image: true,
126 | zip: false,
127 | epub: false,
128 | }
129 |
130 | const blobToFile = (theBlob: Blob, fileName:string): File => {
131 | var b: any = theBlob
132 | //A Blob() is almost a File() - it's just missing the two properties below which we will add
133 | b.lastModifiedDate = new Date()
134 | b.name = fileName
135 |
136 | //Cast to a File() type
137 | return theBlob as File
138 | }
139 |
140 | const Header = function() {
141 | const store = React.useContext(React.createContext(storeMain.ui))
142 | const inputRef = useRef(null)
143 | const [inputType, setInputType] = useState('zip') // 修改这个可以改默认格式
144 |
145 | const onClickToggleBookVisible = useCallback(() => {
146 | store.toggleBookVisible()
147 | }, [store])
148 | const onClickToggleContentVisible = useCallback(() => {
149 | store.toggleContentVisible()
150 | }, [store])
151 | const onClickTogglePageVisible = useCallback(() => {
152 | store.togglePageVisible()
153 | }, [store])
154 |
155 | const onClickImport = useCallback((e: React.MouseEvent) => {
156 | const newType = e.currentTarget.dataset.type as SupportType
157 |
158 | if (newType === inputType) {
159 | inputRef.current?.click()
160 | } else {
161 | setInputType(newType)
162 | }
163 | }, [inputType])
164 |
165 | const handleGetFile = useCallback(async () => {
166 | const input: HTMLInputElement = inputRef.current as HTMLInputElement
167 |
168 | // TODO
169 | // if (inputType === 'epub') {
170 | // console.log(input.files?.[0])
171 | // return
172 | // }
173 |
174 | if (inputType === 'zip' && (input?.files?.[0])) {
175 | const fileName = input.files[0].name
176 | JSZip.loadAsync(input.files[0]).then(zipContent => {
177 | const zipFiles = Object.keys(zipContent.files).sort().map((filename) => zipContent.files[filename])
178 | const promises: Promise[] = zipFiles.map(zipItem => {
179 | if (zipItem.dir) {
180 | return Promise.resolve(null)
181 | }
182 |
183 | return new Promise(resolve => {
184 | zipItem.async('uint8array').then(uint8Array => {
185 | // 检查 MIME type 的方法参考自:
186 | // https://stackoverflow.com/questions/18299806/how-to-check-file-mime-type-with-javascript-before-upload
187 | const header = Array.from(new Uint8Array(uint8Array).subarray(0, 4)).map(item => item.toString(16)).join('')
188 | let mimeType = null
189 |
190 | switch (header) {
191 | case '89504e47':
192 | mimeType = 'image/png'
193 | break
194 | case '52494646':
195 | mimeType = 'image/webp'
196 | break
197 | case 'ffd8ffe0':
198 | case 'ffd8ffe1':
199 | case 'ffd8ffe2':
200 | case 'ffd8ffe3':
201 | case 'ffd8ffe8':
202 | mimeType = 'image/jpeg'
203 | break
204 | case '00020': // todo: 不确定是不是这个
205 | mimeType = 'image/avif'
206 | break
207 | default:
208 | break
209 | }
210 |
211 | if (mimeType) {
212 | const b = new Blob([uint8Array], { type: mimeType })
213 | resolve(
214 | blobToFile(
215 | b,
216 | zipItem.name.replace(/^.+\/(.+)$/, '$1')
217 | )
218 | )
219 | } else {
220 | resolve(null)
221 | }
222 | })
223 | })
224 | })
225 |
226 | return Promise.all(promises) as Promise
227 | }).then((files: (File | null)[]) => {
228 | storeMain.importPageFromImages(files.filter(b => b !== null) as File[])
229 | if (storeMain.ui.firstImport) {
230 | storeMain.ui.toggleBookVisible(fileName)
231 | }
232 | })
233 | return
234 | }
235 |
236 | // inputType === jpg png webp
237 | storeMain.importPageFromImages(Array.from(input.files as FileList))
238 | }, [inputType])
239 |
240 | const onClickInsertBlankPage = useCallback(() => {
241 | const max = storeMain.book.pages.length
242 | const inputValue = window.prompt(`page index (1 - ${max}):`)
243 | let num = parseInt(inputValue || '')
244 |
245 | if (isNaN(num) || num < 1) {
246 | return
247 | } else if (num > max) {
248 | num = max
249 | }
250 |
251 | storeMain.insertBlankPage(num - 1)
252 | }, [])
253 |
254 | const onClickGenerate = useCallback(() => {
255 | storeMain.generateBook()
256 | }, [])
257 |
258 | useLayoutEffect(() => {
259 | setTimeout(() => {
260 | inputRef.current?.click()
261 | }, 0)
262 | }, [inputType])
263 |
264 | return (
265 |
318 | )
319 | }
320 |
321 | export default observer(Header)
--------------------------------------------------------------------------------
/src/components/icon.tsx:
--------------------------------------------------------------------------------
1 | const iconString = ``
33 |
34 | document.body.insertAdjacentHTML('afterbegin', iconString)
35 |
36 | type IconProps = ({ name: string; className?: string })
37 |
38 | const Icon = function(props: IconProps) {
39 | return (
40 |
43 | )
44 | }
45 |
46 | export default Icon
--------------------------------------------------------------------------------
/src/store/main.ts:
--------------------------------------------------------------------------------
1 | import { action, autorun, makeAutoObservable, observable, toJS } from "mobx"
2 | import uuid from 'utils/get-uuid'
3 | import Book from 'store/book'
4 | import Ui from 'store/ui'
5 | import Contents from 'store/contents'
6 | import storeBlobs, { Store as Blobs } from 'store/blobs'
7 | import JSZip from "jszip"
8 |
9 | import 'template/mimetype'
10 | import getTemplateContainerXml from 'template/container.xml'
11 | import getTemplatePageXhtml from 'template/page.xhtml'
12 | import getTemplatePageImgXhtml from 'template/page_img.xhtml'
13 | import getTemplateFixedLayoutJpCss from 'template/fixed-layout-jp.css'
14 | import getTemplateStandardOpf from 'template/standard.opf'
15 | import getTemplateNavigationDocumentsXhtml from 'template/navigation-documents.xhtml'
16 |
17 | const htmlToEscape = (str: string): string => {
18 | // eslint-disable-next-line no-control-regex
19 | const reg = /"|&|'|\\!|<|>|[\x00-\x20]|[\x7F-\xFF]|[\u0100-\u2700]/g
20 |
21 | return str.replace(reg, ($0) => {
22 | let c: any = $0.charCodeAt(0)
23 | let r = ['']
24 |
25 | c = (c === 0x20) ? 0xA0 : c
26 | r.push(c)
27 | r.push(';')
28 | return r.join('')
29 | })
30 | }
31 |
32 | const getNumberStr = (num: number, zeroCount: number): string => {
33 | let str = String(num)
34 | let i = zeroCount - str.length
35 | while (i-- > 0) {
36 | str = '0' + str
37 | }
38 | return str
39 | }
40 |
41 | class Store {
42 | @observable ui: Ui
43 | @observable book: Book
44 | @observable contents: Contents
45 | @observable blobs: Blobs
46 |
47 | constructor() {
48 | this.ui = new Ui()
49 | this.book = new Book()
50 | this.contents = new Contents()
51 | this.blobs = storeBlobs
52 |
53 | makeAutoObservable(this)
54 |
55 | try {
56 | const bookSets = JSON.parse(localStorage.getItem('EPUB_CREATOR_SAVED_SETS_BOOK') || '[]')
57 | const contentSets = JSON.parse(localStorage.getItem('EPUB_CREATOR_SAVED_SETS_CONTENTS') || '[]')
58 |
59 | this.book.savedSets = bookSets
60 | this.contents.savedSets = contentSets
61 | } catch {
62 | // do nothing
63 | }
64 | }
65 |
66 | @action
67 | importPageFromImages(fileList: File[]) {
68 | const uuids = fileList.map(() => uuid())
69 |
70 | this.book.pushNewPage(uuids)
71 | this.blobs.push(fileList, uuids)
72 | }
73 |
74 | @action
75 | replacePageIndex(index: number, targetIndex: number) {
76 | this.book.switchIndex(index, index > targetIndex ? targetIndex : targetIndex + 1)
77 | this.ui.selectPageIndex(targetIndex)
78 |
79 | const newIndexMap: Contents['indexMap'] = {}
80 | const list = toJS(this.contents.list)
81 |
82 | this.book.pages.forEach((pageItem, index) => {
83 | if (pageItem.index in this.contents.indexMap) {
84 | const listIndex = this.contents.indexMap[pageItem.index]
85 | newIndexMap[index] = listIndex
86 | list[listIndex].pageIndex = index
87 | }
88 | })
89 |
90 | this.contents.updateList(list)
91 | this.book.updatePageItemIndex()
92 | }
93 |
94 | @action
95 | async splitPage(index: number) {
96 | const pageItem = this.book.pages[index]
97 | const blobItem = this.blobs.blobs[pageItem.blobID]
98 | const uuids = [uuid(), uuid()]
99 | const mime = blobItem.blob.type
100 |
101 | const w1 = blobItem.originImage.width >> 1
102 | const w2 = blobItem.originImage.width - w1
103 |
104 | const canvas1 = document.createElement('canvas')
105 | const canvas2 = document.createElement('canvas')
106 |
107 | canvas1.width = w1
108 | canvas1.height = blobItem.originImage.height
109 |
110 | canvas2.width = w2
111 | canvas2.height = blobItem.originImage.height
112 |
113 | const ctx1 = canvas1.getContext('2d')
114 | const ctx2 = canvas2.getContext('2d')
115 |
116 | ctx1?.drawImage(blobItem.originImage, 0, 0)
117 | ctx2?.drawImage(blobItem.originImage, 0 - w1, 0)
118 |
119 | const blobs = await Promise.all([
120 | new Promise((resolve, reject) =>
121 | canvas1.toBlob(
122 | (blob) => blob ? resolve(blob) : reject(),
123 | mime, 1
124 | )
125 | ),
126 | new Promise((resolve, reject) =>
127 | canvas2.toBlob(
128 | (blob) => blob ? resolve(blob) : reject(),
129 | mime, 1
130 | )
131 | ),
132 | ])
133 |
134 | this.book.splitPage(index, uuids)
135 | this.book.updatePageItemIndex()
136 | this.blobs.push(blobs, this.book.pageDirection === 'left' ? uuids : uuids.reverse())
137 |
138 | const list = toJS(this.contents.list)
139 | list.forEach(contentItem => {
140 | if (contentItem.pageIndex === null) {
141 | return
142 | }
143 | if (contentItem.pageIndex > index) {
144 | contentItem.pageIndex++
145 | }
146 | })
147 |
148 | this.contents.updateList(list)
149 | }
150 |
151 | @action
152 | insertBlankPage(index: number) {
153 | this.book.insertBlankPage(index)
154 | this.book.updatePageItemIndex()
155 |
156 | const selectedPageIndex = this.ui.selectedPageIndex
157 | if (selectedPageIndex && (selectedPageIndex >= index)) {
158 | this.ui.selectPageIndex(selectedPageIndex + 1)
159 | }
160 |
161 | const list = toJS(this.contents.list)
162 | list.forEach(contentItem => {
163 | if (contentItem.pageIndex === null) {
164 | return
165 | }
166 | if (contentItem.pageIndex > index) {
167 | contentItem.pageIndex++
168 | }
169 | })
170 |
171 | this.contents.updateList(list)
172 | }
173 |
174 | @action
175 | removePage(index: number) {
176 | this.book.removePage(index)
177 | this.ui.selectPageIndex(null)
178 |
179 | const list = toJS(this.contents.list)
180 | list.forEach((contentItem) => {
181 | if (contentItem.pageIndex === null) {
182 | return
183 | }
184 | if (contentItem.pageIndex === index) {
185 | contentItem.pageIndex = null
186 | return
187 | }
188 | if (contentItem.pageIndex > index) {
189 | contentItem.pageIndex--
190 | }
191 | })
192 |
193 | this.contents.updateList(list)
194 | }
195 |
196 | @action
197 | generateBook() {
198 | let templateContainerXml = getTemplateContainerXml()
199 | let templatePageXhtml = getTemplatePageXhtml()
200 | let templatePageImgXhtml = getTemplatePageImgXhtml()
201 | let templateFixedLayoutJpCss = getTemplateFixedLayoutJpCss()
202 | let templateStandardOpf = getTemplateStandardOpf()
203 | let templateNavigationDocumentsXhtml = getTemplateNavigationDocumentsXhtml()
204 |
205 | const Zip = new JSZip()
206 |
207 | Zip.folder('META-INF')
208 | Zip.folder('OEBPS/image')
209 | Zip.folder('OEBPS/text')
210 | Zip.folder('OEBPS/style')
211 |
212 | templateNavigationDocumentsXhtml = templateNavigationDocumentsXhtml.replace(
213 | '',
214 | Object.keys(this.contents.indexMap).map((pageIndex) => {
215 | const listIndex = this.contents.indexMap[pageIndex]
216 | const contentItem = this.contents.list[listIndex]
217 | const title = htmlToEscape(contentItem.title)
218 |
219 | if (pageIndex === '0') {
220 | return `${title}`
221 | }
222 |
223 | return `${title}`
224 | }).join('\n')
225 | )
226 |
227 | let imageItemStr: string[] = []
228 | let pageItemStr: string[] = []
229 | let itemRefStr: string[] = []
230 | let spread = this.book.coverPosition === 'first-page'
231 | ? this.book.pageDirection
232 | : this.book.pageDirection === 'left'
233 | ? 'right'
234 | : 'left'
235 |
236 | this.book.pages.forEach((pageItem, i) => {
237 | const numStr = i === 0 ? 'cover' : getNumberStr(i - 1, 4)
238 | const imageFileName = (i === 0 ? '' : 'i_') + numStr
239 |
240 | if (pageItem.blank) {
241 | pageItemStr.push(` `)
242 | } else {
243 | const mimeType = this.blobs.blobs[pageItem.blobID].blob.type // image/xxxxx
244 | pageItemStr.push(` `)
245 | imageItemStr.push(` `)
246 | }
247 |
248 | if (i !== 0) {
249 | itemRefStr.push(``)
250 | spread = spread === 'left' ? 'right' : 'left'
251 | }
252 | })
253 |
254 | if (this.book.coverPosition === 'alone') {
255 | pageItemStr.splice(0, 1)
256 | } else { // this.book.coverPosition === 'first-page'
257 | itemRefStr.unshift(``)
258 | }
259 |
260 | const viewPortWidth = this.book.pageSize[0] + ''
261 | const viewPortHeight = this.book.pageSize[1] + ''
262 | const fitMode = this.book.pageFit
263 | const bookTitle = htmlToEscape(this.book.bookTitle.trim())
264 |
265 | if (this.book.imgTag === 'svg') {
266 | this.book.pages.forEach((pageItem, i) => {
267 | const numStr = i === 0 ? 'cover' : getNumberStr(i - 1, 4)
268 | const blob = this.blobs.blobs[pageItem.blobID].blob
269 | const mimeType = blob.type.slice(6)
270 | const imageFileName = (i === 0 ? '' : 'i_') + numStr + '.' + mimeType
271 |
272 | if (pageItem.blank) {
273 | Zip.file(
274 | `OEBPS/text/p_${numStr}.xhtml`,
275 | templatePageXhtml
276 | .replace('{{title}}', bookTitle)
277 | .replace(new RegExp('{{width}}', 'gm'), viewPortWidth)
278 | .replace(new RegExp('{{height}}', 'gm'), viewPortHeight)
279 | .replace('{{image}}', '')
280 | )
281 | return
282 | }
283 |
284 | let par = 'none'
285 | if (fitMode !== 'stretch') {
286 | par = this.book.pagePosition === 'center'
287 | ? 'xMidYMid '
288 | : this.book.pageDirection === 'left'
289 | ? (i + 1) % 2 === 1 ? 'xMaxYMid ' : 'xMinYMid '
290 | : (i + 1) % 2 === 1 ? 'xMinYMid ' : 'xMaxYMid '
291 |
292 | if (fitMode === 'fit') {
293 | par += 'meet'
294 | } else { // props.imageFit === 'fill'
295 | par += 'slice'
296 | }
297 | }
298 |
299 | Zip.file(
300 | `OEBPS/text/p_${numStr}.xhtml`,
301 | templatePageXhtml
302 | .replace('{{title}}', bookTitle)
303 | .replace(new RegExp('{{width}}', 'gm'), viewPortWidth)
304 | .replace(new RegExp('{{height}}', 'gm'), viewPortHeight)
305 | .replace('{{image}}', ``)
306 | )
307 |
308 | Zip.file(`OEBPS/image/${imageFileName}`, blob)
309 | })
310 | } else { // this.book.imgTag === 'img'
311 | this.book.pages.forEach((pageItem, i) => {
312 | const numStr = i === 0 ? 'cover' : getNumberStr(i - 1, 4)
313 | const blob = this.blobs.blobs[pageItem.blobID].blob
314 | const mimeType = blob.type.slice(6)
315 | const imageFileName = (i === 0 ? '' : 'i_') + numStr + '.' + mimeType
316 |
317 | if (pageItem.blank) {
318 | Zip.file(
319 | `OEBPS/text/p_${numStr}.xhtml`,
320 | templatePageImgXhtml
321 | .replace('{{title}}', bookTitle)
322 | .replace(new RegExp('{{width}}', 'gm'), viewPortWidth)
323 | .replace(new RegExp('{{height}}', 'gm'), viewPortHeight)
324 | .replace(`
`, '')
325 | )
326 | return
327 | }
328 |
329 | let imgStyle = 'object-fit:fill'
330 | if (fitMode !== 'stretch') {
331 | imgStyle = 'object-position:'
332 |
333 | imgStyle += (
334 | this.book.pagePosition === 'center'
335 | ? 'center;'
336 | : this.book.pageDirection === 'left'
337 | ? (i + 1) % 2 === 1 ? 'right;' : 'left;'
338 | : (i + 1) % 2 === 1 ? 'left;' : 'right;'
339 | )
340 |
341 | if (fitMode === 'fit') {
342 | imgStyle += 'object-fit:contain'
343 | } else { // props.imageFit === 'fill'
344 | imgStyle += 'object-fit:cover'
345 | }
346 | }
347 |
348 | Zip.file(
349 | `OEBPS/text/p_${numStr}.xhtml`,
350 | templatePageImgXhtml
351 | .replace('{{title}}', bookTitle)
352 | .replace(new RegExp('{{width}}', 'gm'), viewPortWidth)
353 | .replace(new RegExp('{{height}}', 'gm'), viewPortHeight)
354 | .replace('{{imageSource}}', `../image/${imageFileName}`)
355 | .replace('{{style}}', imgStyle)
356 | )
357 |
358 | Zip.file(`OEBPS/image/${imageFileName}`, blob)
359 | })
360 | }
361 |
362 | let authorsStr = this.book.bookAuthors.map((name, i) => {
363 | return [
364 | `${htmlToEscape(name)}`,
365 | `aut`,
366 | ``,
367 | `${i + 1}`
368 | ].join('\n')
369 | }).join('\n')
370 |
371 | templateStandardOpf = templateStandardOpf
372 | .replace('{{uuid}}', this.book.bookID)
373 | .replace('{{title}}', bookTitle)
374 | .replace('', authorsStr)
375 | .replace('{{subject}}', htmlToEscape(this.book.bookSubject))
376 | .replace('{{publisher}}', htmlToEscape(this.book.bookPublisher))
377 | .replace('{{spread}}', this.book.pageShow === 'one' ? 'none' : 'landscape')
378 | .replace('{{createTime}}', new Date().toISOString())
379 | .replace(new RegExp('{{width}}', 'gm'), viewPortWidth)
380 | .replace(new RegExp('{{height}}', 'gm'), viewPortHeight)
381 | .replace('', imageItemStr.join('\n'))
382 | .replace('', pageItemStr.join('\n'))
383 | .replace('', itemRefStr.join('\n'))
384 | .replace('{{direction}}', this.book.pageDirection === 'right' ? ' page-progression-direction="rtl"' : '')
385 |
386 | Zip.file('mimetype', 'application/epub+zip')
387 | Zip.file('META-INF/container.xml', templateContainerXml)
388 | Zip.file('OEBPS/style/fixed-layout-jp.css', templateFixedLayoutJpCss)
389 | Zip.file('OEBPS/navigation-documents.xhtml', templateNavigationDocumentsXhtml)
390 | Zip.file('OEBPS/standard.opf', templateStandardOpf)
391 |
392 | Zip.generateAsync({
393 | type: 'blob',
394 | mimeType: 'application/epub+zip'
395 | }).then(blob => {
396 | const anchor = document.createElement('a')
397 | const objectURL = window.URL.createObjectURL(blob)
398 | anchor.download = this.book.bookTitle.trim() + '.epub'
399 | anchor.href = objectURL
400 | anchor.click()
401 | window.URL.revokeObjectURL(objectURL)
402 | })
403 | }
404 | }
405 |
406 | const store = new Store()
407 |
408 | autorun(() => {
409 | localStorage.setItem('EPUB_CREATOR_SAVED_SETS_BOOK', JSON.stringify(toJS(store.book.savedSets)))
410 | localStorage.setItem('EPUB_CREATOR_SAVED_SETS_CONTENTS', JSON.stringify(toJS(store.contents.savedSets)))
411 | })
412 |
413 | export default store
--------------------------------------------------------------------------------
/src/components/modal/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FormEvent, useCallback, useEffect, useState } from 'react'
2 | import storeMain from 'store/main'
3 | import { observer } from 'mobx-react'
4 | import { toJS } from 'mobx'
5 | import deepClone from 'utils/deep-clone'
6 | import Icon from 'components/icon'
7 |
8 | import 'components/modal/index.css'
9 |
10 | const PAGE_SIZE: { [name: string]: () => [number, number] } = {
11 | 'B4': () => [1250, 1765],
12 | 'B5': () => [880, 1250],
13 | 'A4': () => [1050, 1485],
14 | 'A5': () => [1480, 2100],
15 | 'CG 16:9': () => [1600, 900],
16 | 'CG 16:10': () => [1600, 1000],
17 | }
18 |
19 | const KeywordPicker = function(props: { keywords: string[], onClick: (str: string) => void }) {
20 | const onClick = useCallback((e: FormEvent) => {
21 | const index = e.currentTarget.dataset.index as string
22 |
23 | props.onClick(props.keywords[+index])
24 | }, [props])
25 |
26 | return (
27 | <>
28 | {
29 | props.keywords.map((str, index) =>
30 |
37 | )
38 | }
39 | >
40 | )
41 | }
42 |
43 | const ModalBook = observer(function() {
44 | const storeUI = React.useContext(React.createContext(storeMain.ui))
45 | const storeBook = React.useContext(React.createContext(storeMain.book))
46 | const setsSeletRef = React.useRef(null)
47 |
48 | const [fileName, setFileName] = useState('')
49 | const [keywords, setKeywords] = useState([])
50 | const [selectedSetIndex, setSelectedSetIndex] = useState(-1)
51 |
52 | const keywordsLength = keywords.length
53 |
54 | const onClickModal = useCallback((e) => {
55 | e.stopPropagation()
56 | return
57 | }, [])
58 | const onClickClose = useCallback(() => {
59 | storeUI.toggleBookVisible()
60 | }, [storeUI])
61 |
62 | const onClickAnalyze = useCallback(() => {
63 | const reg = [
64 | /\[.*?\(.*\)\]/, // [xxx(xxx)]
65 | /\[.*?\]\s?\[.*?\]/, // [xxx][xxx]
66 | /\[.*?\]/, // [xxx]
67 | /\(.*?\)/, // (xxx)
68 | /\([^[\]()]*?\)|\[[^[\]()]*?\]/g,
69 | ]
70 |
71 | let suffix0 = reg[0].exec(fileName)
72 | let suffix1 = reg[1].exec(fileName)
73 | let suffix2 = reg[2].exec(fileName)
74 | let suffix3 = reg[3].exec(fileName)
75 |
76 | if (suffix0) {
77 | let suffix = suffix0[0]
78 | let author = Array.from(suffix.match(/\(.*?\)/g) || []).pop()
79 | const i = fileName.indexOf(suffix[0])
80 | const titleAndPrefix = fileName.slice(i === 0 ? suffix.length : i + suffix.length).trim()
81 |
82 | storeBook.updateBookPageProperty('bookTitle', titleAndPrefix)
83 | storeBook.updateBookPageProperty('bookAuthors', [author?.slice(1, -1)?.trim() || ''])
84 | setKeywords(Array.from(fileName.match(reg[4]) || []).map(str => str.slice(1, -1)))
85 | return
86 | }
87 |
88 | if (suffix1) {
89 | let suffix = suffix1[0]
90 | let author = Array.from(suffix.match(/\[.*?\]/g) || []).pop()
91 | const i = fileName.indexOf(suffix[0])
92 | const titleAndPrefix = fileName.slice(i === 0 ? suffix.length : i + suffix.length).trim()
93 |
94 | storeBook.updateBookPageProperty('bookTitle', titleAndPrefix)
95 | storeBook.updateBookPageProperty('bookAuthors', [author?.slice(1, -1)?.trim() || ''])
96 | setKeywords(Array.from(fileName.match(reg[4]) || []).map(str => str.slice(1, -1)))
97 | return
98 | }
99 |
100 | if (suffix2) {
101 | let suffix = suffix2[0]
102 | let author = suffix.slice(1, -1)
103 | const i = fileName.indexOf(suffix[0])
104 | const titleAndPrefix = fileName.slice(i === 0 ? suffix.length : i + suffix.length).trim()
105 |
106 | storeBook.updateBookPageProperty('bookTitle', titleAndPrefix)
107 | storeBook.updateBookPageProperty('bookAuthors', [author])
108 | setKeywords(Array.from(fileName.match(reg[4]) || []).map(str => str.slice(1, -1)))
109 | return
110 | }
111 |
112 | if (suffix3) {
113 | let suffix = suffix3[0]
114 | let author = suffix.slice(1, -1)
115 | const i = fileName.indexOf(suffix[0])
116 | const titleAndPrefix = fileName.slice(i === 0 ? suffix.length : i + suffix.length).trim()
117 |
118 | storeBook.updateBookPageProperty('bookTitle', titleAndPrefix)
119 | storeBook.updateBookPageProperty('bookAuthors', [author])
120 | setKeywords(Array.from(fileName.match(reg[4]) || []).map(str => str.slice(1, -1)))
121 | return
122 | }
123 |
124 | storeBook.updateBookPageProperty('bookTitle', fileName)
125 | setKeywords(Array.from(fileName.match(reg[4]) || []).map(str => str.slice(1, -1)))
126 | }, [storeBook, fileName])
127 |
128 | const onChangeFileName = useCallback((e: FormEvent) => {
129 | console.log(e.currentTarget.value)
130 | setFileName(e.currentTarget.value)
131 | }, [])
132 | const onChangeBookID = useCallback((e: FormEvent) => {
133 | const eventTarget = e.currentTarget as HTMLInputElement
134 | storeBook.updateBookPageProperty('bookID', eventTarget.value)
135 | }, [storeBook])
136 | const onChangeBookTitle = useCallback((e: FormEvent) => {
137 | storeBook.updateBookPageProperty('bookTitle', e.currentTarget.value)
138 | }, [storeBook])
139 | const onAddAuthor = useCallback((e: FormEvent) => {
140 | const eventTarget = e.currentTarget as HTMLInputElement
141 | const index = eventTarget.dataset.index as string
142 | const newValue = [...toJS(storeBook.bookAuthors)]
143 | newValue.splice(+index, 1, newValue[+index], '')
144 | storeBook.updateBookPageProperty('bookAuthors', newValue)
145 | }, [storeBook])
146 | const onRemoveAuthor = useCallback((e: FormEvent) => {
147 | const eventTarget = e.currentTarget as HTMLInputElement
148 | const index = eventTarget.dataset.index as string
149 | const newValue = [...toJS(storeBook.bookAuthors)]
150 | newValue.splice(+index, 1)
151 | storeBook.updateBookPageProperty('bookAuthors', newValue)
152 | }, [storeBook])
153 | const onChangeBookAuthors = useCallback((e: FormEvent) => {
154 | const eventTarget = e.currentTarget as HTMLInputElement
155 | const index = eventTarget.dataset.index as string
156 | const newValue = [...toJS(storeBook.bookAuthors)]
157 | newValue.splice(+index, 1, eventTarget.value)
158 | storeBook.updateBookPageProperty('bookAuthors', newValue)
159 | }, [storeBook])
160 | const onChangeBookSubject = useCallback((e: FormEvent) => {
161 | const eventTarget = e.currentTarget as HTMLInputElement
162 | storeBook.updateBookPageProperty('bookSubject', eventTarget.value)
163 | }, [storeBook])
164 | const onChangeBookPublisher = useCallback((e: FormEvent) => {
165 | const eventTarget = e.currentTarget as HTMLInputElement
166 | storeBook.updateBookPageProperty('bookPublisher', eventTarget.value)
167 | }, [storeBook])
168 |
169 | const onChangeTitleFromPicker = useCallback((value: string) => {
170 | storeBook.updateBookPageProperty('bookTitle', value)
171 | }, [storeBook])
172 | const onChangeAuthorsFromPicker = useCallback((value: string) => {
173 | const authors = toJS(storeBook.bookAuthors)
174 |
175 | if (authors.slice(-1)[0] === '') {
176 | authors[authors.length - 1] = value
177 | } else {
178 | authors.push(value)
179 | }
180 |
181 | storeBook.updateBookPageProperty('bookAuthors', authors)
182 | }, [storeBook])
183 | const onChangePublisherFromPicker = useCallback((value: string) => {
184 | storeBook.updateBookPageProperty('bookPublisher', value)
185 | }, [storeBook])
186 |
187 | const onClickModalBody = useCallback(() => {
188 | setSelectedSetIndex(-1)
189 | }, [])
190 |
191 | const onClickSaveSet = useCallback(() => {
192 | storeBook.saveBookInfoToSet()
193 | setSelectedSetIndex(-1)
194 | setTimeout(() => {
195 | if (setsSeletRef.current) {
196 | setsSeletRef.current.value = '-1'
197 | }
198 | }, 0)
199 | }, [storeBook])
200 |
201 | const onClickRemoveSet = useCallback(() => {
202 | storeBook.removeBookInfoSet(selectedSetIndex)
203 | setSelectedSetIndex(-1)
204 | setTimeout(() => {
205 | if (setsSeletRef.current) {
206 | setsSeletRef.current.value = '-1'
207 | }
208 | }, 0)
209 | }, [selectedSetIndex, storeBook])
210 |
211 | const onApplySet = useCallback((e: React.ChangeEvent) => {
212 | const index = +e.currentTarget.value
213 | setSelectedSetIndex(index)
214 | storeBook.applySet(index)
215 | }, [storeBook])
216 |
217 | useEffect(() => {
218 | setFileName(storeUI.fileName)
219 | }, [storeUI.fileName])
220 |
221 | useEffect(() => {
222 | if (fileName && storeUI.firstImport) {
223 | onClickAnalyze()
224 | storeUI.firstUploaded()
225 | }
226 | }, [fileName, onClickAnalyze, storeUI])
227 |
228 | useEffect(() => {
229 | if (selectedSetIndex !== -1) {
230 | return
231 | }
232 | setTimeout(() => {
233 | if (setsSeletRef.current) {
234 | setsSeletRef.current.value = selectedSetIndex + ''
235 | }
236 | }, 0)
237 | })
238 |
239 | return (
240 |
241 |
242 |
243 |
Book
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 | {
281 | storeBook.bookAuthors.map((name: string, index: number) => (
282 |
283 |
284 |
292 |
301 |
302 | ))
303 | }
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
356 |
357 |
358 |
359 |
360 | )
361 | })
362 |
363 | const ModalContents = observer(function() {
364 | const store = React.useContext(React.createContext(storeMain.ui))
365 | const setsSeletRef = React.useRef(null)
366 | const [plainMode, setPlainMode] = useState(false)
367 | const [tempList, setTempList] = useState([])
368 | const [textAreaInput, setTextAreaInput] = useState('')
369 | const [selectedSetIndex, setSelectedSetIndex] = useState(-1)
370 | const maxIndex = tempList.length - 1
371 |
372 | const onClickModal = useCallback((e) => {
373 | e.stopPropagation()
374 | return
375 | }, [])
376 |
377 | const onClickClose = useCallback(() => {
378 | store.toggleContentVisible()
379 | }, [store])
380 |
381 | const togglePlainMode = useCallback(() => {
382 | if (plainMode) {
383 | const list: typeof storeMain.contents.list = []
384 | const items = textAreaInput.split('\n')
385 | items.forEach(item => {
386 | const [pageIndex, ...title] = item.split('. ')
387 |
388 | if (!isNaN(pageIndex as any) && title.length) {
389 | list.push({
390 | pageIndex: Math.abs(+pageIndex - 1),
391 | title: title.join('')
392 | })
393 | } else {
394 | list.push({
395 | pageIndex: 998,
396 | title: pageIndex
397 | })
398 | }
399 | })
400 |
401 | setTempList(list)
402 | } else {
403 | let value = tempList.map(contentItem => {
404 | return ((contentItem.pageIndex || 0) + 1) + '. ' + contentItem.title
405 | }).join('\n')
406 |
407 | setTextAreaInput(value)
408 | }
409 |
410 | setPlainMode(!plainMode)
411 | }, [tempList, plainMode, textAreaInput])
412 |
413 | const onInputPageIndex = useCallback((e: FormEvent) => {
414 | const listIndex = +(e.currentTarget.dataset.index as string)
415 | const value = e.currentTarget.value as string
416 | const newList = deepClone(tempList) as typeof storeMain.contents.list
417 | const item = newList[listIndex]
418 | item.pageIndex = (+value - 1) || 0
419 | setTempList(newList)
420 | }, [tempList])
421 |
422 | const onInputTitle = useCallback((e: FormEvent) => {
423 | const listIndex = +(e.currentTarget.dataset.index as string)
424 | const value = e.currentTarget.value as string
425 | const newList = deepClone(tempList) as typeof storeMain.contents.list
426 | const item = newList[listIndex]
427 | item.title = value
428 | setTempList(newList)
429 | }, [tempList])
430 |
431 | const onClickAdd = useCallback((e: FormEvent) => {
432 | const listIndex = +(e.currentTarget.dataset.index as string)
433 | const list = deepClone(tempList) as typeof storeMain.contents.list
434 | const item = list[listIndex]
435 | list.splice(listIndex, 1, item, {
436 | pageIndex: 0,
437 | title: ''
438 | })
439 | setTempList(list)
440 | }, [tempList])
441 |
442 | const onClickRemove = useCallback((e: FormEvent) => {
443 | const listIndex = +(e.currentTarget.dataset.index as string)
444 | const list = deepClone(tempList) as typeof storeMain.contents.list
445 | list.splice(listIndex, 1)
446 | setTempList(list)
447 | }, [tempList])
448 |
449 | const onSortList = useCallback(() => {
450 | const list = deepClone(tempList) as typeof storeMain.contents.list
451 |
452 | list.sort((a, b) => {
453 | if (isNaN(a.pageIndex as number)) {
454 | return 1
455 | }
456 | if (isNaN(b.pageIndex as number)) {
457 | return 1
458 | }
459 | return (a.pageIndex as number) - (b.pageIndex as number)
460 | })
461 |
462 | setTempList(list)
463 | }, [tempList])
464 |
465 | const onFocusNumberInput = useCallback((e: FormEvent) => {
466 | e.currentTarget.select()
467 | }, [])
468 |
469 | const onTextareaInput = useCallback((e: FormEvent) => {
470 | setTextAreaInput(e.currentTarget.value)
471 | }, [])
472 |
473 | const onSave = useCallback(() => {
474 | storeMain.contents.updateList(tempList)
475 | store.toggleContentVisible()
476 | }, [store, tempList])
477 |
478 | const onClickSaveSet = useCallback(() => {
479 | storeMain.contents.saveSet(storeMain.book.bookTitle)
480 | setSelectedSetIndex(-1)
481 | setTimeout(() => {
482 | if (setsSeletRef.current) {
483 | setsSeletRef.current.value = '-1'
484 | }
485 | }, 0)
486 | }, [])
487 |
488 | const onClickRemoveSet = useCallback(() => {
489 | storeMain.contents.removeSet(selectedSetIndex)
490 | setSelectedSetIndex(-1)
491 | setTimeout(() => {
492 | if (setsSeletRef.current) {
493 | setsSeletRef.current.value = '-1'
494 | }
495 | }, 0)
496 | }, [selectedSetIndex])
497 |
498 | const onApplySet = useCallback((e: React.ChangeEvent) => {
499 | const index = +e.currentTarget.value
500 | setSelectedSetIndex(index)
501 | setTempList(toJS(storeMain.contents.savedSets[index].list))
502 | }, [])
503 |
504 | const onClickModalBody = useCallback(() => {
505 | setSelectedSetIndex(-1)
506 | }, [])
507 |
508 | useEffect(() => {
509 | if (selectedSetIndex !== -1) {
510 | return
511 | }
512 | setTimeout(() => {
513 | if (setsSeletRef.current) {
514 | setsSeletRef.current.value = selectedSetIndex + ''
515 | }
516 | }, 0)
517 | })
518 |
519 | useEffect(() => {
520 | if (store.modalContentVisible) {
521 | setTempList(toJS(storeMain.contents.list))
522 | }
523 | }, [store.modalContentVisible])
524 |
525 | return (
526 |
527 |
528 |
529 |
Content
530 |
531 |
532 |
533 | {
534 | plainMode ? (
535 |
544 | ) : (
545 | <>
546 |
547 |
548 |
index
549 |
550 |
551 |
title
552 |
553 |
554 |
555 | {
556 | tempList.map((contentItem, index) => (
557 |
558 |
559 |
567 |
568 |
569 |
570 |
571 |
572 |
573 |
576 |
579 |
580 |
581 |
582 | ))
583 | }
584 | >
585 | )
586 | }
587 |
588 | {
589 | plainMode ? null : (
590 |
591 |
592 |
599 |
600 |
601 | )
602 | }
603 |
604 |
607 |
610 |
613 |
614 |
615 |
616 | )
617 | })
618 |
619 | const ButtonRadio = function(props: { value: any; current: any; label: string; onClick: (val: any) => void }) {
620 | const onClick = useCallback(() => {
621 | props.onClick(props.value)
622 | }, [props])
623 |
624 | const className = props.value === props.current
625 | ? 'btn btn-sm btn-primary me-2'
626 | : 'btn btn-sm btn-outline-primary me-2'
627 |
628 | return (
629 |
634 | )
635 | }
636 |
637 | const ModalPage = observer(function() {
638 | const store = React.useContext(React.createContext(storeMain.ui))
639 | const storeBook = React.useContext(React.createContext(storeMain.book))
640 |
641 | const onClickModal = useCallback((e) => {
642 | e.stopPropagation()
643 | return
644 | }, [])
645 |
646 | const onClickClose = useCallback(() => {
647 | store.togglePageVisible()
648 | }, [store])
649 |
650 | const onChangePageWidth = useCallback((e: FormEvent) => {
651 | storeBook.updateBookPageProperty('pageSize', [
652 | +e.currentTarget.value || 1,
653 | storeBook.pageSize[1]
654 | ])
655 | }, [storeBook])
656 | const onChangePageHeight = useCallback((e: FormEvent) => {
657 | storeBook.updateBookPageProperty('pageSize', [
658 | storeBook.pageSize[0],
659 | +e.currentTarget.value || 1
660 | ])
661 | }, [storeBook])
662 | const onSwitchPageSize = useCallback(() => {
663 | storeBook.updateBookPageProperty('pageSize', [
664 | storeBook.pageSize[1],
665 | storeBook.pageSize[0]
666 | ])
667 | }, [storeBook])
668 | const onChangeSizeWithDefaultValue = useCallback((key: string) => {
669 | let func = PAGE_SIZE[key]
670 | storeBook.updateBookPageProperty('pageSize', func())
671 | }, [storeBook])
672 | const onChangePagePosition = useCallback((value) => {
673 | storeBook.updateBookPageProperty('pagePosition', value)
674 | }, [storeBook])
675 | const onChangePageShow = useCallback((value) => {
676 | storeBook.updateBookPageProperty('pageShow', value)
677 | }, [storeBook])
678 | const onChangePageFit = useCallback((value) => {
679 | storeBook.updateBookPageProperty('pageFit', value)
680 | }, [storeBook])
681 | // const onChangePageBackgroundColor = useCallback((value) => {
682 | // storeBook.updateBookPageProperty('pageBackgroundColor', value)
683 | // }, [storeBook])
684 | const onChangePageDirection = useCallback((value) => {
685 | storeBook.updateBookPageProperty('pageDirection', value)
686 | }, [storeBook])
687 | const onChangeCoverPosition = useCallback((value) => {
688 | storeBook.updateBookPageProperty('coverPosition', value)
689 | }, [storeBook])
690 | const onChangeImageTag = useCallback((value) => {
691 | storeBook.updateBookPageProperty('imgTag', value)
692 | }, [storeBook])
693 |
694 | return (
695 |
696 |
697 |
698 |
Page
699 |
700 |
701 |
702 |
703 |
704 |
705 |
706 |
707 |
708 | w
709 |
717 |
718 |
719 |
720 |
723 |
724 |
725 |
726 | h
727 |
735 |
736 |
737 |
738 |
739 |
740 |
741 |
742 |
743 |
744 |
745 |
746 |
747 |
748 |
749 |
750 |
751 |
752 |
753 |
754 |
755 |
756 |
757 |
758 |
759 |
760 |
761 |
762 |
763 |
764 |
765 |
766 |
767 |
768 |
769 |
770 |
771 |
772 |
773 | {
774 | //
775 | //
776 | //
777 | //
778 | //
779 | //
780 | //
781 | }
782 |
783 |
784 |
785 |
786 |
787 |
788 |
789 |
790 |
791 |
792 |
793 |
794 |
795 |
796 |
797 |
798 |
799 |
800 |
801 |
802 |
803 |
804 |
805 |
806 | )
807 | })
808 |
809 | const Modal = function() {
810 | const store = React.useContext(React.createContext(storeMain.ui))
811 |
812 | const modalVisible = store.modalBookVisible || store.modalContentVisible || store.modalPageVisible
813 |
814 | const onClickClose = useCallback(() => {
815 | store.hideModal()
816 | }, [store])
817 |
818 | return modalVisible ? (
819 |
820 | {
821 | !store.modalBookVisible ? null :
822 | }
823 | {
824 | !store.modalContentVisible ? null :
825 | }
826 | {
827 | !store.modalPageVisible ? null :
828 | }
829 |
830 | ) : null
831 | }
832 |
833 | const ModalBackDrop = observer(function() {
834 | const store = React.useContext(React.createContext(storeMain.ui))
835 | const modalVisible = store.modalBookVisible || store.modalContentVisible || store.modalPageVisible
836 |
837 | return modalVisible
838 | ?
839 | : null
840 | })
841 |
842 | export { ModalBackDrop }
843 | export default observer(Modal)
--------------------------------------------------------------------------------