├── demo-ui.png ├── assets ├── img │ ├── loader.gif │ ├── favicon.png │ ├── favicon.svg │ ├── search-clear.svg │ ├── bookmark.svg │ ├── bookmarked.svg │ ├── next.svg │ ├── prev.svg │ ├── open-book.svg │ ├── menu.svg │ ├── bookmarks.svg │ ├── upload.svg │ ├── resize-full.svg │ ├── resize-small.svg │ ├── info.svg │ ├── annotations.svg │ ├── settings.svg │ ├── toc.svg │ └── search.svg ├── font │ ├── fontello.eot │ ├── fontello.ttf │ ├── fontello.woff │ └── fontello.svg └── css │ ├── toolbar.css │ ├── common.css │ ├── notedlg.css │ ├── main.css │ └── sidebar.css ├── .gitignore ├── .editorconfig ├── docs └── keybindings.md ├── index.html ├── LICENSE ├── package.json ├── src ├── utils.js ├── sidebar │ ├── metadata.js │ ├── search.js │ ├── toc.js │ ├── annotations.js │ ├── bookmarks.js │ └── settings.js ├── notedlg.js ├── storage.js ├── sidebar.js ├── content.js ├── toolbar.js ├── reader.js ├── strings.js └── ui.js ├── webpack.config.cjs └── README.md /demo-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intity/epubreader-js/HEAD/demo-ui.png -------------------------------------------------------------------------------- /assets/img/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intity/epubreader-js/HEAD/assets/img/loader.gif -------------------------------------------------------------------------------- /assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intity/epubreader-js/HEAD/assets/img/favicon.png -------------------------------------------------------------------------------- /assets/font/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intity/epubreader-js/HEAD/assets/font/fontello.eot -------------------------------------------------------------------------------- /assets/font/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intity/epubreader-js/HEAD/assets/font/fontello.ttf -------------------------------------------------------------------------------- /assets/font/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intity/epubreader-js/HEAD/assets/font/fontello.woff -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | 3 | # Dependency directories 4 | node_modules/ 5 | 6 | # Visual Studio Code 7 | .vscode 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.{js,ts,cjs,css,html}] 10 | charset = utf-8 11 | indent_style = tab 12 | indent_size = 4 13 | 14 | [*.{js,ts}] 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /assets/img/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/keybindings.md: -------------------------------------------------------------------------------- 1 | # Keybindings 2 | 3 | The **epubreader-js** application supports the following keybindings. 4 | 5 | ## Font size 6 | 7 | | Key | Description | 8 | | ------------ | -------------------------- | 9 | | + | Increasing font size | 10 | | - | Decrease font size | 11 | | 0 | Reset font size to default | 12 | 13 | > The font size increases/decreases as a percentage in steps of 2 units. 14 | 15 | ## Navigation 16 | 17 | | Key | Description | 18 | | --------------------- | ------------------- | 19 | | ArrowLeft | Go to previous page | 20 | | ArrowRight | Go to next page | -------------------------------------------------------------------------------- /assets/css/toolbar.css: -------------------------------------------------------------------------------- 1 | #toolbar { 2 | height: 58px; 3 | margin: 0; 4 | padding: 0; 5 | z-index: 10; 6 | display: grid; 7 | position: relative; 8 | } 9 | 10 | #toolbar .menu-1 { 11 | display: flex; 12 | grid-column: 1; 13 | justify-content: left; 14 | } 15 | 16 | #toolbar .menu-2 { 17 | display: flex; 18 | grid-column: 2; 19 | justify-content: right; 20 | } 21 | 22 | #toolbar .box { 23 | width: 58px; 24 | height: 36px; 25 | } 26 | 27 | #toolbar #btn-p.box, 28 | #toolbar #btn-n.box { 29 | display: none; 30 | } 31 | 32 | /** 33 | * iPhone 5 : 320 x 568 34 | */ 35 | @media 36 | only screen and (width: 320px) and (orientation: portrait), 37 | only screen and (height: 320px) and (orientation: landscape) { 38 | #toolbar { 39 | height: 52px; 40 | } 41 | 42 | #toolbar .box { 43 | width: 52px; 44 | height: 30px; 45 | } 46 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 futurepress 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/img/search-clear.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "epubreader-js", 3 | "version": "0.3.4", 4 | "homepage": "https://github.com/intity/epubreader-js", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/intity/epubreader-js.git" 8 | }, 9 | "directories": { 10 | "dist": "dist", 11 | "docs": "docs" 12 | }, 13 | "description": "epub reader", 14 | "bugs": { 15 | "url": "https://github.com/intity/epubreader-js/issues" 16 | }, 17 | "main": "src/reader.js", 18 | "type": "module", 19 | "scripts": { 20 | "build": "webpack --mode=production --progress", 21 | "serve": "webpack serve", 22 | "minify": "webpack --mode=production --optimization-minimize --progress", 23 | "prepare": "npm run build && npm run minify" 24 | }, 25 | "author": "fchasen@gmail.com", 26 | "license": "MIT", 27 | "contributors": [ 28 | { 29 | "name": "vite", 30 | "email": "nuget@list.ru" 31 | } 32 | ], 33 | "devDependencies": { 34 | "copy-webpack-plugin": "^8.1.1", 35 | "webpack": "^5.99.9", 36 | "webpack-cli": "^6.0.1", 37 | "webpack-concat-files-plugin": "^0.5.2", 38 | "webpack-dev-server": "^5.2.2" 39 | }, 40 | "dependencies": { 41 | "epubjs": "^0.3.93", 42 | "js-md5": "^0.7.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /assets/img/bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /assets/img/bookmarked.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /assets/img/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 39 | -------------------------------------------------------------------------------- /assets/img/prev.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 39 | -------------------------------------------------------------------------------- /assets/img/open-book.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 22 | 23 | 25 | 26 | 28 | image/svg+xml 29 | 31 | 32 | 33 | 34 | 38 | 42 | 43 | -------------------------------------------------------------------------------- /assets/img/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 42 | 43 | -------------------------------------------------------------------------------- /assets/img/bookmarks.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 39 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const d = (obj, prop) => obj ? obj[prop] : undefined 2 | 3 | const q = (src, dst, ext, prop) => { 4 | let val 5 | if (typeof dst[prop] === "boolean") { 6 | switch (prop) { 7 | case "annotations": 8 | case "bookmarks": 9 | val = dst[prop] ? src[prop] : dst[prop] 10 | break; 11 | default: 12 | val = dst[prop] 13 | break; 14 | } 15 | } else if (prop === "arrows") { 16 | val = dst[prop] 17 | } else { 18 | val = d(ext, prop) === undefined ? src[prop] : dst[prop] 19 | } 20 | return val 21 | } 22 | 23 | export const extend = (src, dst, ext) => { 24 | for (let prop in src) { 25 | if (prop === "bookPath") { 26 | continue 27 | } else if (dst[prop] instanceof Array) { 28 | dst[prop] = ext ? (src[prop] ? src[prop] : dst[prop]) : src[prop] 29 | } else if (dst[prop] instanceof Object) { 30 | extend(src[prop], dst[prop], d(ext, prop)) // recursive call 31 | } else { 32 | dst[prop] = ext ? q(src, dst, ext, prop) : src[prop] 33 | } 34 | } 35 | } 36 | 37 | export const uuid = () => { 38 | let d = new Date().getTime() 39 | const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { 40 | let r = (d + Math.random() * 16) % 16 | 0 41 | d = Math.floor(d / 16) 42 | return (c === "x" ? r : (r & 0x7 | 0x8)).toString(16) 43 | }) 44 | return uuid 45 | } 46 | 47 | export const detectMobile = () => { 48 | const matches = [ 49 | /Android/i, 50 | /BlackBerry/i, 51 | /iPhone/i, 52 | /iPad/i, 53 | /iPod/i, 54 | /Windows Phone/i, 55 | /webOS/i 56 | ] 57 | return matches.some((i) => navigator.userAgent.match(i)) 58 | } -------------------------------------------------------------------------------- /src/sidebar/metadata.js: -------------------------------------------------------------------------------- 1 | import { UIBox, UIDiv, UIItem, UIList, UIPanel, UIText } from "../ui.js"; 2 | 3 | export class MetadataPanel extends UIPanel { 4 | 5 | constructor(reader) { 6 | 7 | super(); 8 | const container = new UIDiv().setClass("list-container"); 9 | const strings = reader.strings; 10 | const labels = {}; 11 | const key = "sidebar/metadata"; 12 | const label = new UIText(strings.get(key)).setClass("label"); 13 | this.add(new UIBox(label).addClass("header")); 14 | labels[key] = label; 15 | 16 | this.items = new UIList(); 17 | this.setId("metadata"); 18 | this.add(container); 19 | 20 | const init = (prop, meta) => { 21 | if (meta[prop] === undefined || 22 | meta[prop] === null || (typeof meta[prop] === "string" && meta[prop].length === 0)) { 23 | return; 24 | } 25 | const item = new UIItem(); 26 | const label = new UIText().setClass("label"); 27 | const value = new UIText().setClass("value"); 28 | label.setValue(strings.get(key + "/" + prop).toUpperCase()); 29 | if (prop === "description") { 30 | value.dom.innerHTML = meta[prop]; 31 | } else { 32 | value.setValue(meta[prop]); 33 | } 34 | labels[key + "/" + prop] = label; 35 | item.add([label, value]); 36 | this.items.add(item); 37 | } 38 | 39 | //-- events --// 40 | 41 | reader.on("metadata", (meta) => { 42 | 43 | this.items.clear(); 44 | container.clear(); 45 | container.add(this.items); 46 | document.title = meta.title; 47 | for (const prop in meta) { 48 | init(prop, meta); 49 | } 50 | }); 51 | 52 | reader.on("languagechanged", (value) => { 53 | 54 | for (const prop in labels) { 55 | let text; 56 | if (prop === key) { 57 | text = strings.get(prop); 58 | } else { 59 | text = strings.get(prop).toUpperCase(); 60 | } 61 | labels[prop].setValue(text); 62 | } 63 | }); 64 | } 65 | } -------------------------------------------------------------------------------- /assets/img/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 42 | 43 | -------------------------------------------------------------------------------- /assets/img/resize-full.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 42 | 46 | 47 | -------------------------------------------------------------------------------- /assets/img/resize-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 42 | 46 | 47 | -------------------------------------------------------------------------------- /src/sidebar/search.js: -------------------------------------------------------------------------------- 1 | import { UIPanel, UIDiv, UIInput, UILink, UIList, UIItem, UIBox } from "../ui.js"; 2 | 3 | export class SearchPanel extends UIPanel { 4 | 5 | constructor(reader) { 6 | 7 | super(); 8 | const container = new UIDiv().setClass("list-container"); 9 | const strings = reader.strings; 10 | 11 | let searchQuery = undefined; 12 | const search = new UIInput("search").setId("nav-q"); 13 | search.dom.placeholder = strings.get("sidebar/search/placeholder"); 14 | search.dom.onsearch = () => { 15 | 16 | const value = search.getValue(); 17 | 18 | if (value.length === 0) { 19 | this.items.clear(); 20 | } else if (searchQuery !== value) { 21 | this.items.clear(); 22 | this.doSearch(value).then(results => { 23 | 24 | results.forEach(data => { 25 | this.set(data); 26 | }); 27 | }); 28 | } 29 | searchQuery = value; 30 | }; 31 | 32 | this.setId("search"); 33 | this.items = new UIList(); 34 | container.add(this.items); 35 | this.add([new UIBox(search), container]); 36 | this.reader = reader; 37 | this.selector = undefined; 38 | // 39 | // improvement of the highlighting of keywords is required... 40 | // 41 | } 42 | 43 | /** 44 | * Searching the entire book 45 | * @param {*} q Query keyword 46 | * @returns The search result array. 47 | */ 48 | async doSearch(q) { 49 | 50 | const book = this.reader.book; 51 | const results = await Promise.all( 52 | book.spine.spineItems.map(item => item.load(book.load.bind(book)) 53 | .then(item.find.bind(item, q)).finally(item.unload.bind(item)))); 54 | return await Promise.resolve([].concat.apply([], results)); 55 | } 56 | 57 | set(data) { 58 | 59 | const link = new UILink("#" + data.cfi, data.excerpt); 60 | const item = new UIItem(); 61 | link.dom.onclick = () => { 62 | 63 | if (this.selector && this.selector !== item) 64 | this.selector.unselect(); 65 | 66 | item.select(); 67 | this.selector = item; 68 | this.reader.rendition.display(data.cfi); 69 | return false; 70 | }; 71 | item.add(link); 72 | this.items.add(item); 73 | } 74 | } -------------------------------------------------------------------------------- /assets/img/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const CopyPlugin = require("copy-webpack-plugin") 3 | 4 | const config = { 5 | mode: "development", 6 | entry: { 7 | epubreader: "./src/reader.js" 8 | }, 9 | output: { 10 | path: path.resolve(__dirname, "dist"), 11 | libraryTarget: "module" 12 | }, 13 | externals: { 14 | "epubjs": "epubjs" 15 | }, 16 | optimization: { 17 | usedExports: false 18 | }, 19 | devServer: { 20 | static: { 21 | directory: path.join(__dirname, "dist") 22 | }, 23 | hot: false, 24 | liveReload: true, 25 | compress: true, 26 | port: 8080 27 | }, 28 | experiments: { 29 | outputModule: true 30 | }, 31 | plugins: [ 32 | new CopyPlugin({ 33 | patterns: [ 34 | { 35 | from: "node_modules/jszip/dist/jszip.min.js", 36 | to: "js/libs/jszip.min.js", 37 | toType: "file", 38 | force: true 39 | }, 40 | { 41 | from: "node_modules/js-md5/build/md5.min.js", 42 | to: "js/libs/md5.min.js", 43 | toType: "file", 44 | force: true 45 | }, 46 | { 47 | from: "node_modules/epubjs/dist/epub.min.js", 48 | to: "js/libs/epub.min.js", 49 | toType: "file", 50 | force: true 51 | }, 52 | { 53 | from: "assets", 54 | to: "assets", 55 | toType: "dir", 56 | force: true 57 | }, 58 | { 59 | from: "index.html", 60 | to: "index.html", 61 | toType: "file", 62 | force: true, 63 | transform: (content, absoluteFrom) => { 64 | return content.toString().replace(/dist\//g, "") 65 | } 66 | } 67 | ] 68 | }) 69 | ], 70 | performance: { 71 | hints: false 72 | } 73 | } 74 | 75 | module.exports = (env, args) => { 76 | 77 | config.devtool = env.WEBPACK_SERVE ? "eval-source-map" : "source-map" 78 | 79 | if (args.optimizationMinimize) { 80 | config.output.filename = "js/[name].min.js" 81 | config.output.sourceMapFilename = "js/[name].min.js.map" 82 | config.optimization.minimize = true 83 | } else { 84 | config.output.filename = "js/[name].js" 85 | config.output.sourceMapFilename = "js/[name].js.map" 86 | config.optimization.minimize = false 87 | } 88 | 89 | return config; 90 | } -------------------------------------------------------------------------------- /src/notedlg.js: -------------------------------------------------------------------------------- 1 | import { uuid } from "./utils.js"; 2 | import { UIBox, UIDiv, UIInput, UILabel } from "./ui.js"; 3 | 4 | export class NoteDlg { 5 | 6 | constructor(reader) { 7 | 8 | const container = new UIDiv().setId("notedlg"); 9 | const strings = reader.strings; 10 | const keys = [ 11 | "notedlg/label", 12 | "notedlg/add" 13 | ]; 14 | const label = new UILabel(strings.get(keys[0]), "note-input"); 15 | const textBox = new UIInput("text", "").setId("note-input"); 16 | textBox.dom.oninput = (e) => { 17 | 18 | this.update(); 19 | e.preventDefault(); 20 | }; 21 | 22 | const addBtn = new UIInput("button", strings.get(keys[1])); 23 | addBtn.dom.disabled = true; 24 | addBtn.dom.onclick = (e) => { 25 | 26 | const note = { 27 | cfi: this.cfi, 28 | date: new Date(), 29 | text: textBox.getValue(), 30 | uuid: uuid() 31 | }; 32 | this.range = undefined; 33 | reader.settings.annotations.push(note); 34 | reader.emit("noteadded", note); 35 | container.removeAttribute("class"); 36 | e.preventDefault(); 37 | addBtn.dom.blur(); 38 | }; 39 | 40 | this.update = () => { 41 | 42 | addBtn.dom.disabled = !(this.range && textBox.getValue().length > 0); 43 | }; 44 | 45 | container.add(new UIBox([label, textBox, addBtn]).addClass("control")); 46 | document.body.appendChild(container.dom); 47 | 48 | //-- events --// 49 | 50 | reader.on("selected", (cfi, contents) => { 51 | 52 | this.cfi = cfi; 53 | this.range = contents.range(cfi); 54 | this.update(); 55 | container.setClass("open"); 56 | textBox.setValue(""); 57 | }); 58 | 59 | reader.on("unselected", () => { 60 | 61 | this.range = undefined; 62 | this.update(); 63 | container.removeAttribute("class"); 64 | }); 65 | 66 | reader.on("languagechanged", (value) => { 67 | 68 | label.setTextContent(strings.get(keys[0])); 69 | addBtn.setValue(strings.get(keys[1])); 70 | }); 71 | } 72 | } -------------------------------------------------------------------------------- /src/sidebar/toc.js: -------------------------------------------------------------------------------- 1 | import { UIPanel, UIDiv, UIItem, UIList, UILink, UISpan, UIText, UIBox } from "../ui.js"; 2 | 3 | export class TocPanel extends UIPanel { 4 | 5 | constructor(reader) { 6 | 7 | super(); 8 | const container = new UIDiv().setClass("list-container"); 9 | const strings = reader.strings; 10 | const keys = [ 11 | "sidebar/contents" 12 | ]; 13 | const label = new UIText(strings.get(keys[0])).setClass("label"); 14 | this.reader = reader; 15 | this.selector = undefined; // save reference to selected tree item 16 | this.setId("contents"); 17 | this.add(new UIBox(label).addClass("header")); 18 | 19 | //-- events --// 20 | 21 | reader.on("navigation", (toc) => { 22 | 23 | container.clear(); 24 | container.add(this.generateToc(toc)); 25 | this.add(container); 26 | }); 27 | 28 | reader.on("languagechanged", (value) => { 29 | 30 | label.setValue(strings.get(keys[0])); 31 | }); 32 | } 33 | 34 | generateToc(toc, parent) { 35 | 36 | const list = new UIList(parent); 37 | 38 | toc.forEach((chapter) => { 39 | 40 | const link = new UILink(chapter.href, chapter.label); 41 | const item = new UIItem(list).setId(chapter.id); 42 | const ibtn = new UISpan(); 43 | 44 | link.dom.onclick = (e) => { 45 | 46 | if (this.selector && this.selector !== item) 47 | this.selector.unselect(); 48 | 49 | item.select(); 50 | this.selector = item; 51 | this.reader.settings.sectionId = chapter.id; 52 | this.reader.rendition.display(chapter.href); 53 | e.preventDefault(); 54 | }; 55 | item.add([ibtn, link]); 56 | this.reader.navItems[chapter.href] = { 57 | id: chapter.id, 58 | label: chapter.label 59 | }; 60 | 61 | if (this.reader.settings.sectionId === chapter.id) { 62 | list.expand(); 63 | item.select(); 64 | this.selector = item; 65 | } 66 | 67 | if (chapter.subitems && chapter.subitems.length > 0) { 68 | 69 | const subItems = this.generateToc(chapter.subitems, item); 70 | ibtn.setClass("toggle-collapsed"); 71 | ibtn.dom.onclick = () => { 72 | 73 | if (subItems.expanded) { 74 | subItems.collaps(); 75 | ibtn.setClass("toggle-collapsed"); 76 | } else { 77 | subItems.expand(); 78 | ibtn.setClass("toggle-expanded"); 79 | } 80 | return false; 81 | }; 82 | item.add(subItems); 83 | } 84 | 85 | list.add(item); 86 | }); 87 | 88 | return list; 89 | } 90 | } -------------------------------------------------------------------------------- /src/storage.js: -------------------------------------------------------------------------------- 1 | export class Storage { 2 | 3 | constructor() { 4 | 5 | this.name = "epubreader-js"; 6 | this.version = 1.0; 7 | this.db; 8 | this.indexedDB = window.indexedDB || 9 | window.webkitIndexedDB || 10 | window.mozIndexedDB || 11 | window.OIndexedDB || 12 | window.msIndexedDB; 13 | 14 | if (this.indexedDB === undefined) { 15 | 16 | console.error("The IndexedDB API not available in your browser."); 17 | } 18 | } 19 | 20 | init(callback) { 21 | 22 | if (this.indexedDB === undefined) { 23 | callback(); 24 | return; 25 | } 26 | 27 | const time = Date.now(); 28 | const onerror = (e) => console.error("IndexedDB", e); 29 | const request = indexedDB.open(this.name, this.version); 30 | request.onupgradeneeded = (e) => { 31 | 32 | const db = e.target.result; 33 | if (db.objectStoreNames.contains("entries") === false) { 34 | db.createObjectStore("entries"); 35 | } 36 | } 37 | 38 | request.onsuccess = (e) => { 39 | 40 | this.db = e.target.result; 41 | this.db.onerror = onerror; 42 | callback(); 43 | console.log(`storage.init: ${Date.now() - time} ms`); 44 | } 45 | 46 | request.onerror = onerror; 47 | } 48 | 49 | get(callback) { 50 | 51 | if (this.db === undefined) { 52 | callback(); 53 | return; 54 | } 55 | 56 | const time = Date.now(); 57 | const transaction = this.db.transaction(["entries"], "readwrite"); 58 | const objectStore = transaction.objectStore("entries"); 59 | const request = objectStore.get(0); 60 | request.onsuccess = (e) => { 61 | 62 | callback(e.target.result); 63 | console.log(`storage.get: ${Date.now() - time} ms`); 64 | } 65 | } 66 | 67 | set(data, callback) { 68 | 69 | if (this.db === undefined) { 70 | callback(); 71 | return; 72 | } 73 | 74 | const time = Date.now(); 75 | const transaction = this.db.transaction(["entries"], "readwrite"); 76 | const objectStore = transaction.objectStore("entries"); 77 | const request = objectStore.put(data, 0); 78 | request.onsuccess = () => { 79 | 80 | callback(); 81 | console.log(`storage.set: ${Date.now() - time} ms`); 82 | } 83 | } 84 | 85 | clear() { 86 | 87 | if (this.db === undefined) { 88 | return; 89 | } 90 | 91 | const time = Date.now(); 92 | const transaction = this.db.transaction(["entries"], "readwrite"); 93 | const objectStore = transaction.objectStore("entries"); 94 | const request = objectStore.clear(); 95 | request.onsuccess = () => { 96 | 97 | console.log(`storage.clear: ${Date.now() - time} ms`); 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /assets/img/annotations.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 21 | 22 | 24 | 28 | 29 | 30 | 32 | 33 | 35 | image/svg+xml 36 | 38 | 39 | 40 | 41 | 45 | 49 | 53 | 57 | 58 | -------------------------------------------------------------------------------- /src/sidebar.js: -------------------------------------------------------------------------------- 1 | import { UITabbedPanel, UIInput, UIDiv } from "./ui.js"; 2 | import { TocPanel } from "./sidebar/toc.js"; 3 | import { BookmarksPanel } from "./sidebar/bookmarks.js"; 4 | import { AnnotationsPanel } from "./sidebar/annotations.js"; 5 | import { SearchPanel } from "./sidebar/search.js"; 6 | import { SettingsPanel } from "./sidebar/settings.js"; 7 | import { MetadataPanel } from "./sidebar/metadata.js"; 8 | 9 | export class Sidebar { 10 | 11 | constructor(reader) { 12 | 13 | const strings = reader.strings; 14 | const controls = reader.settings; 15 | const keys = [ 16 | "sidebar/close", 17 | "sidebar/contents", 18 | "sidebar/bookmarks", 19 | "sidebar/annotations", 20 | "sidebar/search", 21 | "sidebar/settings", 22 | "sidebar/metadata" 23 | ]; 24 | 25 | const container = new UITabbedPanel("vertical").setId("sidebar"); 26 | 27 | const openerBox = new UIDiv().setId("btn-p").addClass("box"); 28 | const openerBtn = new UIInput("button"); 29 | openerBtn.setTitle(strings.get(keys[0])); 30 | openerBtn.dom.onclick = (e) => { 31 | 32 | reader.emit("sidebaropener", false); 33 | e.preventDefault(); 34 | openerBtn.dom.blur(); 35 | }; 36 | openerBox.add(openerBtn); 37 | container.addMenu(openerBox); 38 | 39 | container.addTab("btn-t", strings.get(keys[1]), new TocPanel(reader)); 40 | if (controls.bookmarks) { 41 | container.addTab("btn-d", strings.get(keys[2]), new BookmarksPanel(reader)); 42 | } 43 | if (controls.annotations) { 44 | container.addTab("btn-a", strings.get(keys[3]), new AnnotationsPanel(reader)); 45 | } 46 | container.addTab("btn-s", strings.get(keys[4]), new SearchPanel(reader)); 47 | container.addTab("btn-c", strings.get(keys[5]), new SettingsPanel(reader)); 48 | container.addTab("btn-i", strings.get(keys[6]), new MetadataPanel(reader)); 49 | container.select("btn-t"); 50 | 51 | document.body.appendChild(container.dom); 52 | 53 | //-- events --// 54 | 55 | reader.on("sidebaropener", (value) => { 56 | 57 | if (value) { 58 | container.setClass("open"); 59 | } else { 60 | container.removeAttribute("class"); 61 | } 62 | }); 63 | 64 | reader.on("languagechanged", (value) => { 65 | 66 | openerBtn.setTitle(strings.get(keys[0])); 67 | container.setLabel("btn-t", strings.get(keys[1])); 68 | if (controls.bookmarks) { 69 | container.setLabel("btn-d", strings.get(keys[2])); 70 | } 71 | if (controls.annotations) { 72 | container.setLabel("btn-a", strings.get(keys[3])); 73 | } 74 | container.setLabel("btn-s", strings.get(keys[4])); 75 | container.setLabel("btn-c", strings.get(keys[5])); 76 | container.setLabel("btn-i", strings.get(keys[6])); 77 | }); 78 | } 79 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # epubreader-js 2 | 3 | ![UI](demo-ui.png) 4 | 5 | ## About the Reader 6 | 7 | The **epubreader-js** application is based on the [epub.js](https://github.com/futurepress/epub.js) library and is a fork of the [epubjs-reader](https://github.com/futurepress/epubjs-reader) repository. 8 | 9 | ## Getting Started 10 | 11 | Open up [epubreader-js](https://intity.github.io/epubreader-js/) in a browser. 12 | 13 | You can change the ePub it opens by passing a link to `bookPath` in the url: 14 | 15 | `?bookPath=https://s3.amazonaws.com/epubjs/books/alice.epub` 16 | 17 | ## Running Locally 18 | 19 | Install [node.js](https://nodejs.org/en/) 20 | 21 | Then install the project dependences with npm 22 | 23 | ```javascript 24 | npm install 25 | ``` 26 | 27 | You can run the reader locally with the command 28 | 29 | ```javascript 30 | npm run serve 31 | ``` 32 | 33 | Builds are concatenated and minified using [webpack](https://github.com/webpack/webpack) 34 | 35 | To generate a new build run 36 | 37 | ```javascript 38 | npm run build 39 | ``` 40 | 41 | or rebuilding all *.js files 42 | 43 | ```javascript 44 | npm run prepare 45 | ``` 46 | 47 | ## Pre-configuration 48 | 49 | The **epubreader-js** application settings is a JavaScript object that you pass as an argument to the `Reader` constructor. You can make preliminary settings in the file [index.html](dist/index.html). For example, this is what the default `Reader` initialization looks like: 50 | 51 | ```html 52 | 58 | ``` 59 | 60 | Let's say we want to disable the `openbook` feature, which is designed to open an epub file on a personal computer. This can be useful for integrating a public library into your site. Let's do this: 61 | 62 | ```html 63 | 68 | ``` 69 | 70 | > Note that the `{{bookPath}}` replacement token is used to define the `url` string variable. This simple solution will allow you to set up a route to pass the target URL. 71 | 72 | ## Features 73 | 74 | The **epubreader-js** application supports the following features: 75 | 76 | - Initial support for mobile devices 77 | - Saving settings in the browser’s local storage 78 | - Opening a book file from the device’s file system 79 | - Bookmarks 80 | - Annotations 81 | - Search by sections of the book 82 | - Output epub metadata 83 | - [Keybindings](docs/keybindings.md) 84 | -------------------------------------------------------------------------------- /assets/css/common.css: -------------------------------------------------------------------------------- 1 | #sidebar, #notedlg { 2 | display: grid; 3 | overflow: hidden; 4 | position: absolute; 5 | box-shadow: none; 6 | font-family: Roboto, 'Segoe UI', Tahoma, sans-serif; 7 | background-color: #fff; 8 | } 9 | 10 | .box { 11 | border: 0; 12 | padding: 11px 0; 13 | display: grid; 14 | min-height: 36px; 15 | } 16 | 17 | [id^="btn-"].box input { 18 | width: 36px; 19 | height: 36px; 20 | border: 0; 21 | margin: 0 11px; 22 | cursor: pointer; 23 | padding: 0; 24 | outline: none; 25 | opacity: 0.5; 26 | background-size: 24px 24px; 27 | background-color: transparent; 28 | background-repeat: no-repeat; 29 | background-position: center; 30 | } 31 | 32 | [id^="btn-"].box input:hover { 33 | opacity: 0.7; 34 | background-color: #F2F3F4; 35 | border-radius: 18px; 36 | } 37 | 38 | [id^="btn-"].box.selected input { 39 | opacity: 0.7; 40 | background-color: #F2F3F4; 41 | border-radius: 18px; 42 | } 43 | 44 | [id^="btn-"].box input[type=file]::file-selector-button, 45 | [id^="btn-"].box input[type=file]::-webkit-file-upload-button { 46 | content: none; 47 | visibility: hidden; 48 | } 49 | 50 | #btn-m.box input { 51 | background-image: url("../img/menu.svg"); 52 | } 53 | 54 | #btn-p.box input { 55 | background-image: url("../img/prev.svg"); 56 | } 57 | 58 | #btn-n.box input { 59 | background-image: url("../img/next.svg"); 60 | } 61 | 62 | #btn-o.box input { 63 | background-image: url("../img/open-book.svg"); 64 | } 65 | 66 | #btn-b.box input { 67 | background-image: url("../img/bookmark.svg"); 68 | } 69 | 70 | #btn-b.box.bookmarked input { 71 | background-image: url("../img/bookmarked.svg"); 72 | } 73 | 74 | #btn-d.box input { 75 | background-image: url("../img/bookmarks.svg"); 76 | } 77 | 78 | #btn-f.box input { 79 | background-image: url("../img/resize-full.svg"); 80 | } 81 | 82 | #btn-f.box.resize-small input { 83 | background-image: url("../img/resize-small.svg"); 84 | } 85 | 86 | #btn-t.box input { 87 | background-image: url("../img/toc.svg"); 88 | } 89 | 90 | #btn-a.box input { 91 | background-image: url("../img/annotations.svg"); 92 | } 93 | 94 | #btn-s.box input { 95 | background-image: url("../img/search.svg"); 96 | } 97 | 98 | #btn-c.box input { 99 | background-image: url("../img/settings.svg"); 100 | } 101 | 102 | #btn-i.box input { 103 | background-image: url("../img/info.svg"); 104 | } 105 | 106 | /** 107 | * iPhone 5 : 320 x 568 108 | */ 109 | @media 110 | only screen and (width: 320px) and (orientation: portrait), 111 | only screen and (height: 320px) and (orientation: landscape) { 112 | .box { 113 | min-height: 30px; 114 | } 115 | 116 | [id^="btn-"].box input { 117 | width: 30px; 118 | height: 30px; 119 | } 120 | } -------------------------------------------------------------------------------- /assets/img/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /assets/img/toc.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 59 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | import { UIDiv, UISpan } from "./ui.js"; 2 | 3 | export class Content { 4 | 5 | constructor(reader) { 6 | 7 | const settings = reader.settings; 8 | const container = new UIDiv().setId("content"); 9 | 10 | let prev; 11 | if (settings.arrows === "content") { 12 | 13 | prev = new UIDiv().setId("prev").setClass("arrow"); 14 | prev.dom.onclick = (e) => { 15 | 16 | reader.emit("prev"); 17 | e.preventDefault(); 18 | }; 19 | prev.add(new UISpan("<")); 20 | container.add(prev); 21 | } 22 | 23 | const viewer = new UIDiv().setId("viewer"); 24 | container.add(viewer); 25 | 26 | let next; 27 | if (settings.arrows === "content") { 28 | next = new UIDiv().setId("next").setClass("arrow"); 29 | next.dom.onclick = (e) => { 30 | 31 | reader.emit("next"); 32 | e.preventDefault(); 33 | }; 34 | next.add(new UISpan(">")); 35 | container.add(next); 36 | } 37 | 38 | const loader = new UIDiv().setId("loader"); 39 | const divider = new UIDiv().setId("divider"); 40 | const overlay = new UIDiv().setId("overlay"); 41 | overlay.dom.onclick = (e) => { 42 | reader.emit("sidebaropener", false); 43 | e.preventDefault(); 44 | }; 45 | 46 | container.add([loader, divider, overlay]); 47 | document.body.appendChild(container.dom); 48 | 49 | //-- events --// 50 | 51 | reader.on("bookready", (cfg) => { 52 | 53 | viewer.setClass(cfg.flow); 54 | loader.dom.style.display = "block"; 55 | }); 56 | 57 | reader.on("bookloaded", () => { 58 | 59 | loader.dom.style.display = "none"; 60 | }); 61 | 62 | reader.on("layout", (props) => { 63 | 64 | if (props.spread && props.width > props.spreadWidth) { 65 | divider.dom.style.display = "block"; 66 | } else { 67 | divider.dom.style.display = "none"; 68 | } 69 | }); 70 | 71 | reader.on("flowchanged", (value) => { 72 | 73 | viewer.setClass(value); 74 | }); 75 | 76 | reader.on("relocated", (location) => { 77 | 78 | if (settings.arrows === "content") { 79 | if (location.atStart) { 80 | prev.addClass("disabled"); 81 | } else { 82 | prev.removeClass("disabled"); 83 | } 84 | if (location.atEnd) { 85 | next.addClass("disabled"); 86 | } else { 87 | next.removeClass("disabled"); 88 | } 89 | } 90 | }); 91 | 92 | reader.on("prev", () => { 93 | 94 | if (settings.arrows === "content") { 95 | prev.addClass("active"); 96 | setTimeout(() => { prev.removeClass("active"); }, 100); 97 | } 98 | }); 99 | 100 | reader.on("next", () => { 101 | 102 | if (settings.arrows === "content") { 103 | next.addClass("active"); 104 | setTimeout(() => { next.removeClass("active"); }, 100); 105 | } 106 | }); 107 | 108 | reader.on("sidebaropener", (value) => { 109 | 110 | overlay.dom.style.display = value ? "block" : "none"; 111 | }); 112 | 113 | reader.on("viewercleanup", () => { 114 | 115 | viewer.clear(); 116 | }); 117 | } 118 | } -------------------------------------------------------------------------------- /src/sidebar/annotations.js: -------------------------------------------------------------------------------- 1 | import { UIPanel, UIDiv, UITextArea, UIInput, UILink, UIList, UIItem, UISpan, UIText, UIBox } from "../ui.js"; 2 | 3 | export class AnnotationsPanel extends UIPanel { 4 | 5 | constructor(reader) { 6 | 7 | super(); 8 | const container = new UIDiv().setClass("list-container"); 9 | const strings = reader.strings; 10 | const keys = [ 11 | "sidebar/annotations", 12 | "sidebar/annotations/clear" 13 | ]; 14 | const headerLabel = new UIText(strings.get(keys[0])).setClass("label"); 15 | const clearBtn = new UIInput("button", strings.get(keys[1])); 16 | clearBtn.dom.onclick = (e) => { 17 | 18 | this.clearNotes(); 19 | e.preventDefault(); 20 | }; 21 | this.add(new UIBox([headerLabel, clearBtn]).addClass("header")); 22 | this.selector = undefined; 23 | this.notes = new UIList(); 24 | container.add(this.notes); 25 | this.setId("annotations"); 26 | this.add(container); 27 | this.reader = reader; 28 | this.update = () => { 29 | 30 | clearBtn.dom.disabled = reader.settings.annotations.length === 0; 31 | }; 32 | 33 | //-- events --// 34 | 35 | reader.on("bookready", (cfg) => { 36 | 37 | cfg.annotations.forEach((note) => { 38 | 39 | this.set(note); 40 | }); 41 | this.update(); 42 | }); 43 | 44 | reader.on("noteadded", (note) => { 45 | 46 | this.set(note); 47 | this.update(); 48 | }); 49 | 50 | reader.on("languagechanged", (value) => { 51 | 52 | headerLabel.setValue(strings.get(keys[0])); 53 | clearBtn.setValue(strings.get(keys[1])); 54 | }); 55 | } 56 | 57 | set(note) { 58 | 59 | const link = new UILink("#" + note.cfi, note.text); 60 | const item = new UIItem().setId("note-" + note.uuid); 61 | const btnr = new UISpan().setClass("btn-remove"); 62 | const call = () => { }; 63 | 64 | link.dom.onclick = (e) => { 65 | 66 | if (this.selector && this.selector !== item) { 67 | this.selector.unselect(); 68 | } 69 | item.select(); 70 | this.selector = item; 71 | this.reader.rendition.display(note.cfi); 72 | e.preventDefault(); 73 | }; 74 | 75 | btnr.dom.onclick = (e) => { 76 | 77 | this.removeNote(note); 78 | e.preventDefault(); 79 | }; 80 | 81 | item.add([link, btnr]); 82 | this.notes.add(item); 83 | this.reader.rendition.annotations.add( 84 | "highlight", note.cfi, {}, call, "note-highlight", {}); 85 | this.update(); 86 | } 87 | 88 | removeNote(note) { 89 | 90 | const index = this.reader.settings.annotations.indexOf(note); 91 | if (index === -1) 92 | return; 93 | 94 | this.notes.remove(index); 95 | this.reader.settings.annotations.splice(index, 1); 96 | this.reader.rendition.annotations.remove(note.cfi, "highlight"); 97 | this.update(); 98 | } 99 | 100 | clearNotes() { 101 | 102 | this.reader.settings.annotations.forEach(note => { 103 | this.reader.rendition.annotations.remove(note.cfi, "highlight"); 104 | }); 105 | this.notes.clear(); 106 | this.reader.settings.annotations = []; 107 | this.update(); 108 | } 109 | } -------------------------------------------------------------------------------- /assets/css/notedlg.css: -------------------------------------------------------------------------------- 1 | #notedlg { 2 | top: 0; 3 | width: 100%; 4 | height: 58px; 5 | z-index: 20; 6 | font-size: small; 7 | transform: translateY(-58px); 8 | transition: 0.25s; 9 | justify-items: center; 10 | } 11 | 12 | #notedlg.open { 13 | transform: translateY(0); 14 | box-shadow: 0 0 16px rgba(0, 0, 0, .28); 15 | } 16 | 17 | #notedlg .box { 18 | min-width: 390px; 19 | } 20 | 21 | #notedlg .box.control { 22 | display: grid; 23 | align-items: center; 24 | grid-template: 36px / 80px auto 80px; 25 | } 26 | 27 | #notedlg .box label { 28 | text-align: center; 29 | margin-left: 11px; 30 | font-weight: 500; 31 | } 32 | 33 | #notedlg .box input[type="text"] { 34 | margin: 0 11px; 35 | padding: 3px; 36 | border-width: 1px; 37 | border-style: solid; 38 | border-radius: 3px; 39 | outline-color: #1A73E8 !important; 40 | } 41 | 42 | #notedlg .box input[type="button"] { 43 | color: #fff !important; 44 | margin-right: 11px; 45 | height: 36px; 46 | border: 0; 47 | border-radius: 4px; 48 | background-color: #1A73E8 !important; 49 | font-weight: bold !important; 50 | } 51 | 52 | #notedlg .box input[type=button]:hover { 53 | box-shadow: 0 0 2px rgba(0,0,0,.28); 54 | background-color: #1B66C9 !important; 55 | } 56 | 57 | #notedlg .box input[type="button"]:disabled { 58 | cursor: auto; 59 | box-shadow: none; 60 | background-color: #BDC3C7 !important; 61 | } 62 | 63 | /** 64 | * iPhone 5 : 320 x 568 65 | */ 66 | @media 67 | only screen and (width: 320px) and (orientation: portrait), 68 | only screen and (height: 320px) and (orientation: landscape) { 69 | #notedlg { 70 | height: 52px; 71 | } 72 | 73 | #notedlg .box { 74 | min-width: 320px; 75 | } 76 | 77 | #notedlg .box.control { 78 | grid-template: 30px / 80px 160px 80px; 79 | } 80 | 81 | #notedlg .box input[type="button"] { 82 | height: 30px; 83 | } 84 | } 85 | 86 | /** 87 | * iPhone 6/7/8 : 375 x 667 88 | * iPhone X : 375 x 812 89 | */ 90 | @media 91 | only screen and (width: 375px) and (orientation: portrait), 92 | only screen and (height: 375px) and (orientation: landscape) { 93 | #notedlg .box { 94 | min-width: 375px; 95 | } 96 | } 97 | 98 | /** 99 | * Pixel 7 : 412 x 915 100 | */ 101 | @media 102 | only screen and (width: 412px) and (orientation: portrait), 103 | only screen and (height: 412px) and (orientation: landscape) { 104 | #notedlg .box { 105 | min-width: 412px; 106 | } 107 | } 108 | 109 | /** 110 | * iPhone 6/7/8 Plus : 414 x 736 111 | */ 112 | @media 113 | only screen and (width: 414px) and (orientation: portrait), 114 | only screen and (height: 414px) and (orientation: landscape) { 115 | #notedlg .box { 116 | min-width: 414px; 117 | } 118 | } 119 | 120 | /** 121 | * iPhone 14/15 Pro Max : 430 x 932 122 | */ 123 | @media 124 | only screen and (width: 430px) and (orientation: portrait), 125 | only screen and (height: 430px) and (orientation: landscape) { 126 | #notedlg .box { 127 | min-width: 430px; 128 | } 129 | } -------------------------------------------------------------------------------- /src/sidebar/bookmarks.js: -------------------------------------------------------------------------------- 1 | import { UIPanel, UIDiv, UIRow, UIInput, UILink, UIList, UIItem, UIText, UIBox, UISpan } from "../ui.js"; 2 | 3 | export class BookmarksPanel extends UIPanel { 4 | 5 | constructor(reader) { 6 | 7 | super(); 8 | const container = new UIDiv().setClass("list-container"); 9 | const strings = reader.strings; 10 | const keys = [ 11 | "sidebar/bookmarks", 12 | "sidebar/bookmarks/clear" 13 | ]; 14 | const headerLabel = new UIText(strings.get(keys[0])).setClass("label"); 15 | const clearBtn = new UIInput("button", strings.get(keys[1])); 16 | clearBtn.dom.onclick = (e) => { 17 | 18 | this.clearBookmarks(); 19 | reader.emit("bookmarked", false); 20 | e.preventDefault(); 21 | }; 22 | this.add(new UIBox([headerLabel, clearBtn]).addClass("header")); 23 | this.selector = undefined; 24 | this.bookmarks = new UIList(); 25 | container.add(this.bookmarks); 26 | this.setId("bookmarks"); 27 | this.add(container); 28 | this.reader = reader; 29 | 30 | const update = () => { 31 | 32 | clearBtn.dom.disabled = reader.settings.bookmarks.length === 0; 33 | }; 34 | 35 | //-- events --// 36 | 37 | reader.on("displayed", (renderer, cfg) => { 38 | 39 | cfg.bookmarks.forEach((cfi) => { 40 | 41 | this.setBookmark(cfi); 42 | }); 43 | update(); 44 | }); 45 | 46 | reader.on("relocated", (location) => { 47 | 48 | this.locationCfi = location.start.cfi; // save location cfi 49 | }); 50 | 51 | reader.on("bookmarked", (boolean, cfi) => { 52 | 53 | if (boolean) { 54 | this.appendBookmark(); 55 | } else { 56 | this.removeBookmark(cfi); 57 | } 58 | update(); 59 | }); 60 | 61 | reader.on("languagechanged", (value) => { 62 | 63 | headerLabel.setValue(strings.get(keys[0])); 64 | clearBtn.setValue(strings.get(keys[1])); 65 | }); 66 | } 67 | 68 | appendBookmark() { 69 | 70 | const cfi = this.locationCfi; 71 | if (this.reader.isBookmarked(cfi) > -1) { 72 | return; 73 | } 74 | this.setBookmark(cfi); 75 | this.reader.settings.bookmarks.push(cfi); 76 | } 77 | 78 | removeBookmark(cfi) { 79 | 80 | const _cfi = cfi || this.locationCfi; 81 | const index = this.reader.isBookmarked(_cfi); 82 | if (index === -1) { 83 | return; 84 | } 85 | this.bookmarks.remove(index); 86 | this.reader.settings.bookmarks.splice(index, 1); 87 | } 88 | 89 | clearBookmarks() { 90 | 91 | this.bookmarks.clear(); 92 | this.reader.settings.bookmarks = []; 93 | } 94 | 95 | setBookmark(cfi) { 96 | 97 | const link = new UILink(); 98 | const item = new UIItem(); 99 | const btnr = new UISpan().setClass("btn-remove"); 100 | const navItem = this.reader.navItemFromCfi(cfi); 101 | let idref; 102 | let label; 103 | 104 | if (navItem === undefined) { 105 | const spineItem = this.reader.book.spine.get(cfi); 106 | idref = spineItem.idref; 107 | label = spineItem.idref 108 | } else { 109 | idref = navItem.id; 110 | label = navItem.label; 111 | } 112 | 113 | link.setHref("#" + cfi); 114 | link.dom.onclick = (e) => { 115 | 116 | if (this.selector && this.selector !== item) { 117 | this.selector.unselect(); 118 | } 119 | item.select(); 120 | this.selector = item; 121 | this.reader.rendition.display(cfi); 122 | e.preventDefault(); 123 | }; 124 | link.setTextContent(label); 125 | 126 | btnr.dom.onclick = (e) => { 127 | 128 | this.reader.emit("bookmarked", false, cfi); 129 | e.preventDefault(); 130 | }; 131 | 132 | item.add([link, btnr]); 133 | item.setId(idref); 134 | this.bookmarks.add(item); 135 | } 136 | } -------------------------------------------------------------------------------- /assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import url("common.css"); 2 | @import url("toolbar.css"); 3 | @import url("sidebar.css"); 4 | @import url("notedlg.css"); 5 | 6 | @font-face { 7 | font-family: 'fontello'; 8 | src: url('../font/fontello.eot?60518104'); 9 | src: url('../font/fontello.eot?60518104#iefix') format('embedded-opentype'), 10 | url('../font/fontello.woff?60518104') format('woff'), 11 | url('../font/fontello.ttf?60518104') format('truetype'), 12 | url('../font/fontello.svg?60518104#fontello') format('svg'); 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | padding: 0; 20 | overflow: hidden; 21 | } 22 | 23 | #content { 24 | top: 0; 25 | right: 0; 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | position: absolute; 30 | overflow: hidden; 31 | background: #fff; 32 | align-items: center; 33 | } 34 | 35 | #viewer { 36 | color: inherit; 37 | margin: 0 auto; 38 | display: flex; 39 | z-index: 2; 40 | overflow: hidden; 41 | align-items: center; 42 | vertical-align: middle; 43 | } 44 | 45 | #viewer.paginated { 46 | width: calc(100% - 116px); 47 | height: calc(100% - 116px); 48 | } 49 | 50 | #viewer.scrolled { 51 | top: 58px; 52 | width: calc(100% - 116px); 53 | height: calc(100% - 58px); 54 | position: sticky; 55 | } 56 | 57 | #viewer .epub-view { 58 | width: auto !important; 59 | } 60 | 61 | #viewer iframe { 62 | border: none; 63 | } 64 | 65 | #prev { 66 | text-align: start; 67 | } 68 | 69 | #next { 70 | text-align: end; 71 | } 72 | 73 | .arrow { 74 | flex: 1; 75 | padding: 0 10px; 76 | } 77 | 78 | .arrow:disabled, 79 | .arrow.disabled { 80 | visibility: hidden; 81 | } 82 | 83 | .arrow span { 84 | color: #E2E2E2; 85 | cursor: pointer; 86 | overflow: hidden; 87 | font-size: 64px; 88 | font-family: arial, sans-serif; 89 | font-weight: bold; 90 | vertical-align: middle; 91 | 92 | user-select: none; 93 | -webkit-user-select: none; 94 | -moz-user-select: none; 95 | -ms-user-select: none; 96 | } 97 | 98 | .arrow span:hover { 99 | color: #777; 100 | } 101 | 102 | .arrow:active > span, 103 | .arrow.active > span { 104 | color: #000; 105 | } 106 | 107 | #divider { 108 | left: 50%; 109 | height: calc(100% - 116px); 110 | margin: 0; 111 | padding: 0; 112 | display: none; 113 | position: absolute; 114 | border-left: 1px dotted #777; 115 | } 116 | 117 | #overlay { 118 | top: 0; 119 | left: 0; 120 | width: 100%; 121 | height: 100%; 122 | z-index: 10; 123 | display: none; 124 | position: fixed; 125 | background: rgba(0, 0, 0, 0.4); 126 | transition: all 0.25s; 127 | } 128 | 129 | #loader { 130 | top: calc(50% - 32px); 131 | left: calc(50% - 32px); 132 | width: 64px; 133 | height: 64px; 134 | position: absolute; 135 | background-size: 64px; 136 | background-image: url("../img/loader.gif"); 137 | background-repeat: no-repeat; 138 | background-position: center; 139 | } 140 | 141 | /** 142 | * iPhone 5 : 320 x 568 143 | */ 144 | @media 145 | only screen and (max-width: 320px) and (orientation: portrait), 146 | only screen and (max-width: 568px) and (orientation: landscape) { 147 | #viewer.scrolled { 148 | height: calc(100% - 52px); 149 | } 150 | 151 | .arrow span { 152 | font-size: 32px; 153 | } 154 | } 155 | 156 | /** 157 | * iPhone 5 : 320 x 568 158 | * iPhone 6/7/8 : 375 x 667 159 | * iPhone 11 Pro : 375 x 812 160 | * Pixel 7 : 412 x 915 161 | * iPhone 6/7/8 Plus : 414 x 736 162 | * iPhone 11 : 414 x 896 163 | * iPhone 12/14 : 390 x 844 164 | * iPhone 14/15 Pro Max : 430 x 932 165 | */ 166 | @media 167 | only screen and (min-width: 320px) and (max-width: 430px) and (orientation: portrait), 168 | only screen and (min-width: 568px) and (max-width: 932px) and (orientation: landscape) { 169 | #viewer.paginated { 170 | width: calc(100% - 32px); 171 | } 172 | 173 | #viewer.scrolled { 174 | width: 100%; 175 | } 176 | 177 | .arrow span { 178 | font-size: 40px; 179 | } 180 | } 181 | 182 | /** 183 | * iPad Mini : 768 x 1024 184 | * iPad 10.2 : 810 x 1080 185 | * iPad Air (2020) : 820 x 1180 186 | * iPad Air : 834 x 1112 187 | * iPad Pro 11 : 834 x 1194 188 | * iPad Pro 12.9 : 1024 x 1366 189 | */ 190 | @media 191 | only screen and (min-width: 768px) and (max-width: 1024px) and (orientation: portrait), 192 | only screen and (min-width: 1024px) and (max-width: 1366px) and (orientation: landscape) { 193 | #viewer.paginated { 194 | width: calc(100% - 116px); 195 | } 196 | 197 | #viewer.scrolled { 198 | width: 100%; 199 | } 200 | 201 | .arrow span { 202 | font-size: 50px; 203 | } 204 | } -------------------------------------------------------------------------------- /assets/img/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 30 | 34 | 38 | 39 | -------------------------------------------------------------------------------- /src/sidebar/settings.js: -------------------------------------------------------------------------------- 1 | import { UIPanel, UIRow, UISelect, UIInput, UILabel, UINumber, UIText, UIBox, UIDiv } from "../ui.js"; 2 | 3 | export class SettingsPanel extends UIPanel { 4 | 5 | constructor(reader) { 6 | 7 | super(); 8 | super.setId("settings"); 9 | 10 | const strings = reader.strings; 11 | const keys = [ 12 | "sidebar/settings", 13 | "sidebar/settings/language", 14 | "sidebar/settings/fontsize", 15 | "sidebar/settings/flow", 16 | "sidebar/settings/spread", 17 | "sidebar/settings/spread/minwidth" 18 | ]; 19 | const headerLabel = new UIText(strings.get(keys[0])).setClass("label"); 20 | this.add(new UIBox(headerLabel).addClass("header")); 21 | 22 | const languageLabel = new UILabel(strings.get(keys[1]), "language-ui"); 23 | const languageRow = new UIRow(); 24 | const language = new UISelect().setOptions({ 25 | en: "English", 26 | fr: "French", 27 | ja: "Japanese", 28 | ru: "Russian", 29 | zh: "Chinese" 30 | }); 31 | language.dom.onchange = (e) => { 32 | 33 | reader.emit("languagechanged", e.target.value); 34 | }; 35 | language.setId("language-ui"); 36 | languageRow.add(languageLabel); 37 | languageRow.add(language); 38 | 39 | const fontSizeLabel = new UILabel(strings.get(keys[2]), "fontsize"); 40 | const fontSizeRow = new UIRow(); 41 | const fontSize = new UINumber(100, 1); 42 | fontSize.dom.onchange = (e) => { 43 | 44 | reader.emit("styleschanged", { 45 | fontSize: parseInt(e.target.value) 46 | }); 47 | }; 48 | fontSize.setId("fontsize") 49 | fontSizeRow.add(fontSizeLabel); 50 | fontSizeRow.add(fontSize); 51 | 52 | //-- flow configure --// 53 | 54 | const flowLabel = new UILabel(strings.get(keys[3]), "flow"); 55 | const flowRow = new UIRow(); 56 | const flow = new UISelect().setOptions({ 57 | paginated: "Paginated", 58 | scrolled: "Scrolled" 59 | }); 60 | flow.dom.onchange = (e) => { 61 | 62 | reader.emit("flowchanged", e.target.value); 63 | 64 | if (e.target.value === "scrolled") { 65 | reader.emit("spreadchanged", { 66 | mod: "none", 67 | min: undefined 68 | }); 69 | } else { 70 | reader.emit("spreadchanged", { 71 | mod: undefined, 72 | min: undefined 73 | }); 74 | } 75 | }; 76 | flow.setId("flow"); 77 | flowRow.add(flowLabel); 78 | flowRow.add(flow); 79 | 80 | //-- spdead configure --// 81 | 82 | const minSpreadWidth = new UINumber(800, 1); 83 | const spreadLabel = new UILabel(strings.get(keys[4]), "spread"); 84 | const spreadRow = new UIRow(); 85 | const spread = new UISelect().setOptions({ 86 | none: "None", 87 | auto: "Auto" 88 | }); 89 | spread.dom.onchange = (e) => { 90 | 91 | reader.emit("spreadchanged", { 92 | mod: e.target.value, 93 | min: undefined 94 | }); 95 | minSpreadWidth.dom.disabled = e.target.value === "none"; 96 | }; 97 | spread.setId("spread"); 98 | 99 | spreadRow.add(spreadLabel); 100 | spreadRow.add(spread); 101 | 102 | const minSpreadWidthLabel = new UILabel(strings.get(keys[5]), "min-spread-width"); 103 | const minSpreadWidthRow = new UIRow(); 104 | minSpreadWidth.dom.onchange = (e) => { 105 | 106 | reader.emit("spreadchanged", { 107 | mod: undefined, 108 | min: parseInt(e.target.value) 109 | }); 110 | }; 111 | minSpreadWidth.setId("min-spread-width"); 112 | minSpreadWidthRow.add(minSpreadWidthLabel); 113 | minSpreadWidthRow.add(minSpreadWidth); 114 | 115 | //-- pagination --// 116 | 117 | const paginationStr = strings.get("sidebar/settings/pagination"); 118 | const paginationRow = new UIRow(); 119 | const pagination = new UIInput("checkbox", false, paginationStr[1]); 120 | pagination.setId("pagination"); 121 | pagination.dom.onclick = (e) => { 122 | 123 | // not implemented 124 | }; 125 | 126 | paginationRow.add(new UILabel(paginationStr[0], "pagination")); 127 | paginationRow.add(pagination); 128 | 129 | this.add(new UIBox([ 130 | languageRow, 131 | fontSizeRow, 132 | flowRow, 133 | spreadRow, 134 | minSpreadWidthRow, 135 | //paginationRow 136 | ])); 137 | 138 | //-- events --// 139 | 140 | reader.on("bookready", (cfg) => { 141 | 142 | language.setValue(cfg.language); 143 | fontSize.setValue(cfg.styles.fontSize); 144 | flow.setValue(cfg.flow); 145 | spread.setValue(cfg.spread.mod); 146 | minSpreadWidth.setValue(cfg.spread.min); 147 | minSpreadWidth.dom.disabled = cfg.spread.mod === "none"; 148 | }); 149 | 150 | reader.on("layout", (props) => { 151 | 152 | if (props.flow === "scrolled") { 153 | spread.setValue("none"); 154 | spread.dom.disabled = true; 155 | minSpreadWidth.dom.disabled = true; 156 | } else { 157 | spread.dom.disabled = false; 158 | } 159 | }); 160 | 161 | reader.on("languagechanged", (value) => { 162 | 163 | headerLabel.setTextContent(strings.get(keys[0])); 164 | languageLabel.setTextContent(strings.get(keys[1])); 165 | fontSizeLabel.setTextContent(strings.get(keys[2])); 166 | flowLabel.setTextContent(strings.get(keys[3])); 167 | spreadLabel.setTextContent(strings.get(keys[4])); 168 | minSpreadWidthLabel.setTextContent(strings.get(keys[5])); 169 | }); 170 | } 171 | } -------------------------------------------------------------------------------- /src/toolbar.js: -------------------------------------------------------------------------------- 1 | import { UIDiv, UIInput } from "./ui.js"; 2 | 3 | export class Toolbar { 4 | 5 | constructor(reader) { 6 | 7 | const strings = reader.strings; 8 | const settings = reader.settings; 9 | 10 | const container = new UIDiv().setId("toolbar"); 11 | const keys = [ 12 | "toolbar/sidebar", 13 | "toolbar/prev", 14 | "toolbar/next", 15 | "toolbar/openbook", 16 | "toolbar/openbook/error", 17 | "toolbar/bookmark", 18 | "toolbar/fullscreen" 19 | ]; 20 | const menu1 = new UIDiv().setClass("menu-1"); 21 | const openerBox = new UIDiv().setId("btn-m").setClass("box"); 22 | const openerBtn = new UIInput("button"); 23 | openerBtn.dom.title = strings.get(keys[0]); 24 | openerBtn.dom.onclick = (e) => { 25 | 26 | reader.emit("sidebaropener", true); 27 | openerBtn.dom.blur(); 28 | e.preventDefault(); 29 | }; 30 | openerBox.add(openerBtn); 31 | menu1.add(openerBox); 32 | 33 | let prevBox, prevBtn; 34 | let nextBox, nextBtn; 35 | if (settings.arrows === "toolbar") { 36 | prevBox = new UIDiv().setId("btn-p").setClass("box"); 37 | prevBtn = new UIInput("button"); 38 | prevBtn.setTitle(strings.get(keys[1])); 39 | prevBtn.dom.onclick = (e) => { 40 | 41 | reader.emit("prev"); 42 | e.preventDefault(); 43 | prevBtn.dom.blur(); 44 | }; 45 | prevBox.add(prevBtn); 46 | menu1.add(prevBox); 47 | 48 | nextBox = new UIDiv().setId("btn-n").setClass("box"); 49 | nextBtn = new UIInput("button"); 50 | nextBtn.dom.title = strings.get(keys[2]); 51 | nextBtn.dom.onclick = (e) => { 52 | 53 | reader.emit("next"); 54 | e.preventDefault(); 55 | nextBtn.dom.blur(); 56 | }; 57 | nextBox.add(nextBtn); 58 | menu1.add(nextBox); 59 | } 60 | 61 | const menu2 = new UIDiv().setClass("menu-2"); 62 | let openbookBtn; 63 | if (settings.openbook) { 64 | const onload = (e) => { 65 | 66 | reader.storage.clear(); 67 | reader.storage.set(e.target.result, () => { 68 | reader.unload(); 69 | reader.init(e.target.result); 70 | const url = new URL(window.location.origin); 71 | window.history.pushState({}, "", url); 72 | }); 73 | }; 74 | const onerror = (e) => { 75 | console.error(e); 76 | }; 77 | const openbookBox = new UIDiv().setId("btn-o").setClass("box"); 78 | openbookBtn = new UIInput("file"); 79 | openbookBtn.dom.title = strings.get(keys[3]); 80 | openbookBtn.dom.accept = "application/epub+zip"; 81 | openbookBtn.dom.onchange = (e) => { 82 | 83 | if (e.target.files.length === 0) 84 | return; 85 | 86 | if (window.FileReader) { 87 | 88 | const fr = new FileReader(); 89 | fr.onload = onload; 90 | fr.readAsArrayBuffer(e.target.files[0]); 91 | fr.onerror = onerror; 92 | } else { 93 | alert(strings.get(keys[4])); 94 | } 95 | 96 | }; 97 | openbookBtn.dom.onclick = (e) => { 98 | 99 | openbookBtn.dom.blur(); 100 | }; 101 | openbookBox.add(openbookBtn); 102 | menu2.add(openbookBox); 103 | } 104 | 105 | let bookmarkBox, bookmarkBtn; 106 | if (settings.bookmarks) { 107 | bookmarkBox = new UIDiv().setId("btn-b").setClass("box"); 108 | bookmarkBtn = new UIInput("button"); 109 | bookmarkBtn.setTitle(strings.get(keys[5])); 110 | bookmarkBtn.dom.onclick = (e) => { 111 | 112 | const cfi = this.locationCfi; 113 | const val = reader.isBookmarked(cfi) === -1; 114 | reader.emit("bookmarked", val); 115 | e.preventDefault(); 116 | bookmarkBtn.dom.blur(); 117 | }; 118 | bookmarkBox.add(bookmarkBtn); 119 | menu2.add(bookmarkBox); 120 | } 121 | 122 | let fullscreenBtn; 123 | if (settings.fullscreen) { 124 | 125 | const fullscreenBox = new UIDiv().setId("btn-f").setClass("box"); 126 | fullscreenBtn = new UIInput("button"); 127 | fullscreenBtn.setTitle(strings.get(keys[6])); 128 | fullscreenBtn.dom.onclick = (e) => { 129 | 130 | this.toggleFullScreen(); 131 | e.preventDefault(); 132 | }; 133 | 134 | document.onkeydown = (e) => { 135 | 136 | if (e.key === "F11") { 137 | e.preventDefault(); 138 | this.toggleFullScreen(); 139 | } 140 | }; 141 | 142 | document.onfullscreenchange = (e) => { 143 | 144 | const w = window.screen.width === e.target.clientWidth; 145 | const h = window.screen.height === e.target.clientHeight; 146 | 147 | if (w && h) { 148 | fullscreenBox.addClass("resize-small"); 149 | } else { 150 | fullscreenBox.removeClass("resize-small"); 151 | } 152 | }; 153 | fullscreenBox.add(fullscreenBtn); 154 | menu2.add(fullscreenBox); 155 | } 156 | 157 | container.add([menu1, menu2]); 158 | document.body.appendChild(container.dom); 159 | 160 | //-- events --// 161 | 162 | reader.on("relocated", (location) => { 163 | 164 | if (settings.bookmarks) { 165 | const cfi = location.start.cfi; 166 | const val = reader.isBookmarked(cfi) === -1; 167 | if (val) { 168 | bookmarkBox.removeClass("bookmarked"); 169 | } else { 170 | bookmarkBox.addClass("bookmarked"); 171 | } 172 | this.locationCfi = cfi; // save location cfi 173 | } 174 | if (settings.arrows === "toolbar") { 175 | prevBox.dom.style.display = location.atStart ? "none" : "block"; 176 | nextBox.dom.style.display = location.atEnd ? "none" : "block"; 177 | } 178 | }); 179 | 180 | reader.on("bookmarked", (boolean) => { 181 | 182 | if (boolean) { 183 | bookmarkBox.addClass("bookmarked"); 184 | } else { 185 | bookmarkBox.removeClass("bookmarked"); 186 | } 187 | }); 188 | 189 | reader.on("languagechanged", (value) => { 190 | 191 | openerBtn.setTitle(strings.get(keys[0])); 192 | 193 | if (settings.arrows === "toolbar") { 194 | prevBtn.setTitle(strings.get(keys[1])); 195 | nextBtn.setTitle(strings.get(keys[2])); 196 | } 197 | if (settings.openbook) { 198 | openbookBtn.setTitle(strings.get(keys[3])); 199 | } 200 | if (settings.bookmarks) { 201 | bookmarkBtn.setTitle(strings.get(keys[5])); 202 | } 203 | if (settings.fullscreen) { 204 | fullscreenBtn.setTitle(strings.get(keys[6])); 205 | } 206 | }); 207 | } 208 | 209 | toggleFullScreen() { 210 | 211 | document.activeElement.blur(); 212 | 213 | if (document.fullscreenElement === null) { 214 | document.documentElement.requestFullscreen(); 215 | } else if (document.exitFullscreen) { 216 | document.exitFullscreen(); 217 | } 218 | } 219 | } -------------------------------------------------------------------------------- /assets/font/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2013 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /assets/css/sidebar.css: -------------------------------------------------------------------------------- 1 | #sidebar { 2 | top: 0; 3 | width: 390px; 4 | height: 100vh; 5 | z-index: 12; 6 | transform: translateX(-390px); 7 | transition: 0.25s; 8 | grid-template-rows: 58px; 9 | } 10 | 11 | #sidebar.open { 12 | transform: translateX(0); 13 | box-shadow: 0 0 16px rgba(0,0,0,.28); 14 | } 15 | 16 | #sidebar .menu { 17 | grid-row: 1; 18 | grid-column: 1 / 3; 19 | height: 58px; 20 | border: 0; 21 | border-bottom: 1px solid #BDC3C7; 22 | position: sticky; 23 | } 24 | 25 | #sidebar .list-container { 26 | height: calc(100% - 73px); 27 | padding: 15px 0 0 0; 28 | overflow: auto; 29 | } 30 | 31 | /* sidebar tabs */ 32 | 33 | #sidebar .tabs { 34 | grid-column: 1; 35 | grid-row: 2; 36 | width: 58px; 37 | } 38 | 39 | #sidebar .tab-selector { 40 | width: 58px; 41 | height: 36px; 42 | margin: 11px 0; 43 | position: absolute; 44 | transition: transform 0.5s; 45 | border-left: 2px solid #1A73E8 !important; 46 | } 47 | 48 | /* sidebar panels */ 49 | 50 | #sidebar .panels { 51 | width: 332px; 52 | margin: 0; 53 | padding: 0; 54 | grid-column: 2; 55 | grid-row: 1 / 3; 56 | overflow: hidden; 57 | } 58 | 59 | #sidebar .panel { 60 | height: 100vh; 61 | margin: 0; 62 | padding: 0; 63 | position: sticky; 64 | font-size: small; 65 | } 66 | 67 | #sidebar .panel > .box { 68 | width: 100%; 69 | align-content: center; 70 | } 71 | 72 | #sidebar .panel .box.header span.label { 73 | color: #5F6368; 74 | font-size: 18px; 75 | font-style: normal; 76 | margin: 0 11px; 77 | } 78 | 79 | #sidebar .panel .box.header { 80 | align-items: center; 81 | grid-template: 36px / auto 116px; 82 | } 83 | 84 | #sidebar .panel input[class^="btn-"] { 85 | height: 21px; 86 | } 87 | 88 | #sidebar .panel input[type=button] { 89 | color: #fff !important; 90 | cursor: pointer; 91 | margin: 0 11px; 92 | padding: 0; 93 | height: 36px; 94 | border: 0; 95 | border-radius: 4px; 96 | background-color: #1A73E8 !important; 97 | font-weight: bold !important; 98 | } 99 | 100 | #sidebar .panel input[type=button]:hover { 101 | box-shadow: 0 0 2px rgba(0,0,0,.28); 102 | background-color: #1B66C9 !important; 103 | } 104 | 105 | #sidebar .panel input[type=button]:disabled { 106 | cursor: auto; 107 | box-shadow: none; 108 | background-color: #BDC3C7 !important; 109 | } 110 | 111 | #sidebar .panel ul { 112 | margin: 0; 113 | padding-right: 11px; 114 | } 115 | 116 | #sidebar .panel li { 117 | width: calc(100% - 2px); 118 | margin-left: 2px; 119 | list-style: none; 120 | font-weight: 500; 121 | } 122 | 123 | #sidebar .panel li:hover { 124 | background-color: #F2F3F4 !important; 125 | } 126 | 127 | #sidebar .panel li.selected { 128 | margin-left: 0px; 129 | border-left: 2px solid #1A73E8 !important; 130 | background-color: #F2F3F4 !important; 131 | } 132 | 133 | /* #sidebar .panel li.selected a { 134 | color: #797D7F !important; 135 | } 136 | 137 | #sidebar .panel li a { 138 | color: #515A5A; 139 | display: inline-block; 140 | font-weight: bold; 141 | text-decoration: none; 142 | } 143 | 144 | #sidebar .panel li a:hover { 145 | color: #797D7F !important; 146 | } */ 147 | 148 | #sidebar .panel li #item-box { 149 | display: grid; 150 | padding: 6px 0; 151 | align-items: baseline; 152 | } 153 | 154 | #sidebar .panel li #item-box span { 155 | margin: 0; 156 | padding: 0; 157 | cursor: pointer; 158 | text-align: center; 159 | } 160 | 161 | #sidebar .panel li #item-box span.btn-remove { 162 | background-color: #BDC3C7 !important; 163 | } 164 | 165 | #sidebar .panel li #item-box span.btn-remove::before { 166 | content: 'X'; 167 | } 168 | 169 | #sidebar .panel li #item-box span[class^="toggle-"]::before { 170 | color: #000 !important; 171 | opacity: 0.8; 172 | 173 | user-select: none; 174 | -webkit-user-select: none; 175 | -moz-user-select: none; 176 | -ms-user-select: none; 177 | } 178 | 179 | #sidebar .panel li > ul { 180 | width: calc(100% - 24px); 181 | padding: 0 0 0 24px; 182 | display: none; 183 | } 184 | 185 | #sidebar .row { 186 | margin: 0; 187 | padding: 6px 11px 0px 11px; 188 | display: flex; 189 | min-height: 23px; 190 | align-items: baseline; 191 | vertical-align: middle; 192 | } 193 | 194 | #sidebar .row label { 195 | flex: 1; 196 | padding: 1px; 197 | font-weight: 500; 198 | white-space: nowrap; 199 | text-overflow: ellipsis; 200 | overflow: hidden; 201 | } 202 | 203 | #sidebar .row select { 204 | flex: 1; 205 | width: 100%; 206 | padding: 2px; 207 | border-width: 1px; 208 | border-style: solid; 209 | border-radius: 3px; 210 | outline-color: #1A73E8 !important; 211 | } 212 | 213 | #sidebar .row input[type="checkbox"] { 214 | width: 16px; 215 | height: 16px; 216 | margin: 0; 217 | cursor: pointer; 218 | border-width: 1px; 219 | border-style: solid; 220 | border-radius: 2px; 221 | 222 | appearance: none; 223 | -webkit-appearance: none; 224 | -moz-appearance: none; 225 | } 226 | 227 | #sidebar .row input[type="checkbox"]:checked { 228 | appearance: auto; 229 | -webkit-appearance: auto; 230 | -moz-appearance: auto; 231 | } 232 | 233 | #sidebar .row input[type="number"] { 234 | flex: 0.9867; 235 | width: 100%; 236 | padding: 3px; 237 | border-width: 1px; 238 | border-style: solid; 239 | border-radius: 3px; 240 | outline-color: #1A73E8 !important; 241 | } 242 | 243 | /* toc panel */ 244 | 245 | #contents.panel ul { 246 | padding-left: 0; 247 | } 248 | 249 | #contents.panel li { 250 | padding: 0; 251 | } 252 | 253 | #contents.panel li #item-box { 254 | grid-template: auto / 24px auto; 255 | } 256 | 257 | #contents.panel li a { 258 | width: calc(100% - 24px); 259 | } 260 | 261 | #contents.panel li #item-box span.toggle-collapsed::before { 262 | content: '\002B'; 263 | } 264 | 265 | #contents.panel #item-box span.toggle-expanded::before { 266 | content: '\2212'; 267 | } 268 | 269 | /* bookmarks panel */ 270 | 271 | #bookmarks.panel ul { 272 | padding-left: 0px; 273 | } 274 | 275 | #bookmarks.panel li #item-box { 276 | grid-template: auto / auto 22px; 277 | padding: 6px; 278 | } 279 | 280 | /* search panel */ 281 | 282 | #search.panel ul { 283 | padding-left: 0px; 284 | } 285 | 286 | #search.panel li #item-box { 287 | padding: 6px; 288 | } 289 | 290 | #search.panel input[type="search"] { 291 | width: calc(100% - 22px); 292 | height: 36px; 293 | border-color: #BDC3C7; 294 | border-width: 1px; 295 | border-style: solid; 296 | border-radius: 18px; 297 | margin: 0 11px; 298 | padding: 8px 12px; 299 | outline-color: #1A73E8; 300 | } 301 | 302 | /* annotations panel */ 303 | 304 | #annotations.panel ul { 305 | padding-left: 0px; 306 | } 307 | 308 | #annotations.panel li #item-box { 309 | grid-template: auto / auto 22px; 310 | padding: 6px; 311 | } 312 | 313 | #annotations.panel li > a { 314 | width: calc(100% - 38px); 315 | margin: 0 6px; 316 | } 317 | 318 | /* metadata panel */ 319 | 320 | #metadata.panel ul { 321 | padding-left: 0px; 322 | } 323 | 324 | #metadata.panel li #item-box { 325 | padding: 6px; 326 | justify-items: left; 327 | } 328 | 329 | #metadata.panel li #item-box span { 330 | cursor: auto; 331 | } 332 | 333 | #metadata.panel li #item-box span.label { 334 | font-weight: bold; 335 | } 336 | 337 | #metadata.panel li #item-box span.value { 338 | text-align: left; 339 | } 340 | 341 | /** 342 | * iPhone 5 : 320 x 568 343 | * iPhone 6/7/8 : 375 x 667 344 | * iPhone 11 : 375 x 812 345 | */ 346 | @media 347 | only screen and (height: 320px) and (orientation: landscape), 348 | only screen and (height: 375px) and (orientation: landscape) { 349 | #sidebar .tabs { 350 | overflow: auto; 351 | } 352 | } 353 | 354 | /** 355 | * iPhone 5 : 320 x 568 356 | */ 357 | @media 358 | only screen and (width: 320px) and (orientation: portrait), 359 | only screen and (height: 320px) and (orientation: landscape) { 360 | #sidebar { 361 | width: 320px; 362 | transform: translateX(-320px); 363 | grid-template-rows: 52px; 364 | } 365 | 366 | #sidebar .menu { 367 | height: 52px; 368 | } 369 | 370 | #sidebar .tabs { 371 | width: 52px; 372 | } 373 | 374 | #sidebar .panels { 375 | width: 268px; 376 | } 377 | 378 | #sidebar .panel .box.header { 379 | grid-template: 30px / auto 116px; 380 | } 381 | 382 | #sidebar .panel .box.header span.label { 383 | font-size: 16px; 384 | } 385 | 386 | #sidebar .panel .box.header input[type="button"] { 387 | height: 30px; 388 | } 389 | 390 | #sidebar .panel .box input[type="search"] { 391 | height: 30px; 392 | padding: 6px 10px; 393 | } 394 | 395 | #sidebar .tab-selector { 396 | display: none; 397 | } 398 | 399 | #sidebar .list-container { 400 | height: calc(100% - 67px); 401 | } 402 | } 403 | 404 | /** 405 | * iPhone 6/7/8 : 375 x 667 406 | * iPhone 11 : 375 x 812 407 | */ 408 | @media 409 | only screen and (width: 375px) and (orientation: portrait), 410 | only screen and (height: 375px) and (orientation: landscape) { 411 | #sidebar { 412 | width: 375px; 413 | transform: translateX(-375px); 414 | } 415 | 416 | #sidebar .panels { 417 | width: 317px; 418 | } 419 | 420 | #sidebar .tab-selector { 421 | display: none; 422 | } 423 | } 424 | 425 | /** 426 | * Pixel 7 : 412 x 915 427 | */ 428 | @media 429 | only screen and (width: 412px) and (orientation: portrait), 430 | only screen and (height: 412px) and (orientation: landscape) { 431 | #sidebar { 432 | width: 412px; 433 | transform: translateX(-412px); 434 | } 435 | 436 | #sidebar .panels { 437 | width: 354px; 438 | } 439 | } 440 | 441 | /** 442 | * iPhone 6/7/8 Plus : 414 x 736 443 | */ 444 | @media 445 | only screen and (width: 414px) and (orientation: portrait), 446 | only screen and (height: 414px) and (orientation: landscape) { 447 | #sidebar { 448 | width: 414px; 449 | transform: translateX(-414px); 450 | } 451 | 452 | #sidebar .panels { 453 | width: 356px; 454 | } 455 | } 456 | 457 | /** 458 | * iPhone 14/15 Pro Max : 430 x 932 459 | */ 460 | @media 461 | only screen and (width: 430px) and (orientation: portrait), 462 | only screen and (height: 430px) and (orientation: landscape) { 463 | #sidebar { 464 | width: 430px; 465 | transform: translateX(-430px); 466 | } 467 | 468 | #sidebar .panels { 469 | width: 372px; 470 | } 471 | } -------------------------------------------------------------------------------- /src/reader.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from "event-emitter"; 2 | 3 | import { extend, detectMobile } from "./utils.js"; 4 | import { Storage } from "./storage.js"; 5 | import { Strings } from "./strings.js"; 6 | import { Toolbar } from "./toolbar.js"; 7 | import { Content } from "./content.js"; 8 | import { Sidebar } from "./sidebar.js"; 9 | import { NoteDlg } from "./notedlg.js"; 10 | 11 | export class Reader { 12 | 13 | constructor(bookPath, settings) { 14 | 15 | const preinit = (data) => { 16 | const url = new URL(window.location); 17 | let path = bookPath; 18 | if (settings && !settings.openbook) { 19 | path = bookPath; 20 | if (data) this.storage.clear(); 21 | } else if (data && url.search.length === 0) { 22 | path = data; 23 | } 24 | this.cfgInit(path, settings); 25 | this.strings = new Strings(this); 26 | this.toolbar = new Toolbar(this); 27 | this.content = new Content(this); 28 | this.sidebar = new Sidebar(this); 29 | if (this.settings.annotations) { 30 | this.notedlg = new NoteDlg(this); 31 | } 32 | this.init(); 33 | } 34 | 35 | this.settings = undefined; 36 | this.isMobile = detectMobile(); 37 | this.storage = new Storage(); 38 | const openbook = settings && settings.openbook; 39 | 40 | if (this.storage.indexedDB && (!settings || openbook)) { 41 | this.storage.init(() => this.storage.get((data) => preinit(data))); 42 | } else { 43 | preinit(); 44 | } 45 | 46 | window.onbeforeunload = this.unload.bind(this); 47 | window.onhashchange = this.hashChanged.bind(this); 48 | window.onkeydown = this.keyboardHandler.bind(this); 49 | window.onwheel = (e) => { 50 | if (e.ctrlKey) { 51 | e.preventDefault(); 52 | } 53 | }; 54 | } 55 | 56 | /** 57 | * Initialize book. 58 | * @param {*} bookPath 59 | * @param {*} settings 60 | */ 61 | init(bookPath, settings) { 62 | 63 | this.emit("viewercleanup"); 64 | this.navItems = {}; 65 | 66 | if (arguments.length > 0) { 67 | 68 | this.cfgInit(bookPath, settings); 69 | } 70 | 71 | this.book = ePub(this.settings.bookPath); 72 | this.rendition = this.book.renderTo("viewer", { 73 | manager: this.settings.manager, 74 | flow: this.settings.flow, 75 | spread: this.settings.spread.mod, 76 | minSpreadWidth: this.settings.spread.min, 77 | width: "100%", 78 | height: "100%", 79 | snap: true 80 | }); 81 | 82 | const cfi = this.settings.previousLocationCfi; 83 | if (cfi) { 84 | this.displayed = this.rendition.display(cfi); 85 | } else { 86 | this.displayed = this.rendition.display(); 87 | } 88 | 89 | this.displayed.then((renderer) => { 90 | this.emit("displayed", renderer, this.settings); 91 | }); 92 | 93 | this.book.ready.then(() => { 94 | this.emit("bookready", this.settings); 95 | }).then(() => { 96 | this.emit("bookloaded"); 97 | }); 98 | 99 | this.book.loaded.metadata.then((meta) => { 100 | this.emit("metadata", meta); 101 | }); 102 | 103 | this.book.loaded.navigation.then((toc) => { 104 | this.emit("navigation", toc); 105 | }); 106 | 107 | this.rendition.on("click", (e) => { 108 | const selection = e.view.document.getSelection(); 109 | if (selection.type !== "Range") { 110 | this.emit("unselected"); 111 | } 112 | }); 113 | 114 | this.rendition.on("layout", (props) => { 115 | this.emit("layout", props); 116 | }); 117 | 118 | this.rendition.on("selected", (cfiRange, contents) => { 119 | this.setLocation(cfiRange); 120 | this.emit("selected", cfiRange, contents); 121 | }); 122 | 123 | this.rendition.on("relocated", (location) => { 124 | this.setLocation(location.start.cfi); 125 | this.emit("relocated", location); 126 | }); 127 | 128 | this.rendition.on("keydown", this.keyboardHandler.bind(this)); 129 | 130 | this.on("prev", () => { 131 | if (this.book.package.metadata.direction === "rtl") { 132 | this.rendition.next(); 133 | } else { 134 | this.rendition.prev(); 135 | } 136 | }); 137 | 138 | this.on("next", () => { 139 | if (this.book.package.metadata.direction === "rtl") { 140 | this.rendition.prev(); 141 | } else { 142 | this.rendition.next(); 143 | } 144 | }); 145 | 146 | this.on("languagechanged", (value) => { 147 | this.settings.language = value; 148 | }); 149 | 150 | this.on("flowchanged", (value) => { 151 | this.settings.flow = value; 152 | this.rendition.flow(value); 153 | }); 154 | 155 | this.on("spreadchanged", (value) => { 156 | const mod = value.mod || this.settings.spread.mod; 157 | const min = value.min || this.settings.spread.min; 158 | this.settings.spread.mod = mod; 159 | this.settings.spread.min = min; 160 | this.rendition.spread(mod, min); 161 | }); 162 | 163 | this.on("styleschanged", (value) => { 164 | const fontSize = value.fontSize; 165 | this.settings.styles.fontSize = fontSize; 166 | this.rendition.themes.fontSize(fontSize + "%"); 167 | }); 168 | } 169 | 170 | /* ------------------------------- Common ------------------------------- */ 171 | 172 | navItemFromCfi(cfi) { 173 | 174 | // This feature was added to solve the problem of duplicate titles in 175 | // bookmarks. But this still has no solution because when reloading the 176 | // reader, rendition cannot get the range from the previously saved CFI. 177 | const range = this.rendition.getRange(cfi); 178 | const idref = range ? range.startContainer.parentNode.id : undefined; 179 | const location = this.rendition.currentLocation(); 180 | const href = location.start.href; 181 | return this.navItems[href + "#" + idref] || this.navItems[href]; 182 | } 183 | 184 | /* ------------------------------ Bookmarks ----------------------------- */ 185 | 186 | /** 187 | * Verifying the current page in bookmarks. 188 | * @param {*} cfi 189 | * @returns The index of the bookmark if it exists, or -1 otherwise. 190 | */ 191 | isBookmarked(cfi) { 192 | 193 | return this.settings.bookmarks.indexOf(cfi); 194 | } 195 | 196 | /* ----------------------------- Annotations ---------------------------- */ 197 | 198 | isAnnotated(note) { 199 | 200 | return this.settings.annotations.indexOf(note); 201 | } 202 | 203 | /* ------------------------------ Settings ------------------------------ */ 204 | 205 | /** 206 | * Initialize book settings. 207 | * @param {any} bookPath 208 | * @param {any} settings 209 | */ 210 | cfgInit(bookPath, settings) { 211 | 212 | this.entryKey = md5(bookPath).toString(); 213 | this.settings = { 214 | bookPath: bookPath, 215 | arrows: this.isMobile ? "none" : "content", // none | content | toolbar 216 | manager: this.isMobile ? "continuous" : "default", 217 | restore: true, 218 | history: true, 219 | openbook: this.storage.indexedDB ? true : false, 220 | language: "en", 221 | sectionId: undefined, 222 | bookmarks: [], // array | false 223 | annotations: [], // array | false 224 | flow: "paginated", // paginated | scrolled 225 | spread: { 226 | mod: "auto", // auto | none 227 | min: 800 228 | }, 229 | styles: { 230 | fontSize: 100 231 | }, 232 | pagination: undefined, // not implemented 233 | fullscreen: document.fullscreenEnabled 234 | }; 235 | 236 | extend(settings || {}, this.settings); 237 | 238 | if (this.settings.restore) { 239 | this.applySavedSettings(settings || {}); 240 | } else { 241 | this.removeSavedSettings(); 242 | } 243 | } 244 | 245 | /** 246 | * Checks if the book setting can be retrieved from localStorage. 247 | * @returns true if the book key exists, or false otherwise. 248 | */ 249 | isSaved() { 250 | 251 | return localStorage && localStorage.getItem(this.entryKey) !== null; 252 | } 253 | 254 | /** 255 | * Removing the current book settings from local storage. 256 | * @returns true if the book settings were deleted successfully, or false 257 | * otherwise. 258 | */ 259 | removeSavedSettings() { 260 | 261 | if (!this.isSaved()) 262 | return false; 263 | 264 | localStorage.removeItem(this.entryKey); 265 | return true; 266 | } 267 | 268 | /** 269 | * Applies saved settings from local storage. 270 | * @param {*} external External settings 271 | * @returns True if the settings were applied successfully, false otherwise. 272 | */ 273 | applySavedSettings(external) { 274 | 275 | if (!this.isSaved()) 276 | return false; 277 | 278 | let stored; 279 | try { 280 | stored = JSON.parse(localStorage.getItem(this.entryKey)); 281 | } catch (e) { 282 | console.exception(e); 283 | } 284 | 285 | if (stored) { 286 | extend(stored, this.settings, external); 287 | return true; 288 | } else { 289 | return false; 290 | } 291 | } 292 | 293 | /** 294 | * Saving the current book settings in local storage. 295 | */ 296 | saveSettings() { 297 | 298 | this.settings.previousLocationCfi = this.rendition.location.start.cfi; 299 | const cfg = Object.assign({}, this.settings); 300 | delete cfg.arrows; 301 | delete cfg.manager; 302 | delete cfg.history; 303 | delete cfg.restore; 304 | delete cfg.openbook; 305 | delete cfg.pagination; 306 | delete cfg.fullscreen; 307 | localStorage.setItem(this.entryKey, JSON.stringify(cfg)); 308 | } 309 | 310 | setLocation(cfi) { 311 | 312 | const baseUrl = this.book.archived ? undefined : this.book.url; 313 | const url = new URL(window.location, baseUrl); 314 | url.hash = "#" + cfi; 315 | 316 | // Update the History Location 317 | if (this.settings.history && window.location.hash !== url.hash) { 318 | // Add CFI fragment to the history 319 | window.history.pushState({}, "", url); 320 | this.currentLocationCfi = cfi; 321 | } 322 | } 323 | 324 | //-- event handlers --// 325 | 326 | unload() { 327 | 328 | if (this.settings.restore && localStorage) { 329 | this.saveSettings(); 330 | } 331 | } 332 | 333 | hashChanged() { 334 | 335 | const hash = window.location.hash.slice(1); 336 | this.rendition.display(hash); 337 | } 338 | 339 | keyboardHandler(e) { 340 | 341 | const step = 2; 342 | let value = this.settings.styles.fontSize; 343 | 344 | switch (e.key) { 345 | 346 | case "=": 347 | case "+": 348 | value += step; 349 | this.emit("styleschanged", { fontSize: value }); 350 | break; 351 | case "-": 352 | value -= step; 353 | this.emit("styleschanged", { fontSize: value }); 354 | break; 355 | case "0": 356 | value = 100; 357 | this.emit("styleschanged", { fontSize: value }); 358 | break; 359 | case "ArrowLeft": 360 | this.emit("prev"); 361 | break; 362 | case "ArrowRight": 363 | this.emit("next"); 364 | break; 365 | } 366 | } 367 | } 368 | 369 | EventEmitter(Reader.prototype); -------------------------------------------------------------------------------- /src/strings.js: -------------------------------------------------------------------------------- 1 | export class Strings { 2 | 3 | constructor(reader) { 4 | 5 | this.language = reader.settings.language || "en"; 6 | this.values = { 7 | en: { 8 | "toolbar/sidebar": "Sidebar", 9 | "toolbar/prev": "Previous page", 10 | "toolbar/next": "Next page", 11 | "toolbar/openbook": "Open book", 12 | "toolbar/openbook/error": "Your browser does not support the required features.\nPlease use a modern browser such as Google Chrome, or Mozilla Firefox.", 13 | "toolbar/bookmark": "Add this page to bookmarks", 14 | "toolbar/fullscreen": "Fullscreen", 15 | 16 | "sidebar/close": "Close Sidebar", 17 | "sidebar/contents": "Contents", 18 | "sidebar/bookmarks": "Bookmarks", 19 | "sidebar/bookmarks/add": "Add", 20 | "sidebar/bookmarks/remove": "Remove", 21 | "sidebar/bookmarks/clear": "Clear", 22 | "sidebar/annotations": "Annotations", 23 | "sidebar/annotations/add": "Add", 24 | "sidebar/annotations/remove": "Remove", 25 | "sidebar/annotations/clear": "Clear", 26 | "sidebar/annotations/anchor": "Anchor", 27 | "sidebar/annotations/cancel": "Cancel", 28 | "sidebar/search": "Search", 29 | "sidebar/search/placeholder": "Search", 30 | "sidebar/settings": "Settings", 31 | "sidebar/settings/language": "Language", 32 | "sidebar/settings/fontsize": "Font size (%)", 33 | "sidebar/settings/flow": "Flow", 34 | "sidebar/settings/pagination": ["Pagination", "Generate pagination"], 35 | "sidebar/settings/spread": "Spread", 36 | "sidebar/settings/spread/minwidth": "Minimum spread width", 37 | "sidebar/metadata": "Metadata", 38 | "sidebar/metadata/title": "Title", 39 | "sidebar/metadata/creator": "Creator", 40 | "sidebar/metadata/description": "Description", 41 | "sidebar/metadata/pubdate": "Pubdate", 42 | "sidebar/metadata/publisher": "Publisher", 43 | "sidebar/metadata/identifier": "Identifier", 44 | "sidebar/metadata/language": "Language", 45 | "sidebar/metadata/rights": "Rights", 46 | "sidebar/metadata/modified_date": "Modified date", 47 | "sidebar/metadata/layout": "Layout", // rendition:layout 48 | "sidebar/metadata/flow": "Flow", // rendition:flow 49 | "sidebar/metadata/spread": "Spread", // rendition:spread 50 | "sidebar/metadata/direction": "Direction", // page-progression-direction 51 | 52 | "notedlg/label": "Note", 53 | "notedlg/add": "Add" 54 | }, 55 | fr: { 56 | "toolbar/sidebar": "Barre latérale", 57 | "toolbar/prev": "???", 58 | "toolbar/next": "???", 59 | "toolbar/openbook": "Ouvrir un livre local", 60 | "toolbar/openbook/error": "Votre navigateur ne prend pas en charge les fonctions nécessaires.\nVeuillez utiliser un navigateur moderne tel que Google Chrome ou Mozilla Firefox.", 61 | "toolbar/bookmark": "Insérer un marque page ici", 62 | "toolbar/fullscreen": "Plein écran", 63 | 64 | "sidebar/close": "???", 65 | "sidebar/contents": "Sommaire", 66 | "sidebar/bookmarks": "Marque-pages", 67 | "sidebar/bookmarks/add": "Ajouter", 68 | "sidebar/bookmarks/remove": "Retirer", 69 | "sidebar/bookmarks/clear": "Tout enlever", 70 | "sidebar/annotations": "Annotations", 71 | "sidebar/annotations/add": "Ajouter", 72 | "sidebar/annotations/remove": "Retirer", 73 | "sidebar/annotations/clear": "Tout enlever", 74 | "sidebar/annotations/anchor": "Ancre", 75 | "sidebar/annotations/cancel": "Annuler", 76 | "sidebar/search": "Rechercher", 77 | "sidebar/search/placeholder": "rechercher", 78 | "sidebar/settings": "Réglages", 79 | "sidebar/settings/language": "Langue", 80 | "sidebar/settings/fontsize": "???", 81 | "sidebar/settings/flow": "???", 82 | "sidebar/settings/pagination": ["Pagination", "Établir une pagination"], 83 | "sidebar/settings/spread": "???", 84 | "sidebar/settings/spread/minwidth": "???", 85 | "sidebar/metadata": "???", 86 | "sidebar/metadata/title": "???", 87 | "sidebar/metadata/creator": "???", 88 | "sidebar/metadata/description": "???", 89 | "sidebar/metadata/pubdate": "???", 90 | "sidebar/metadata/publisher": "???", 91 | "sidebar/metadata/identifier": "???", 92 | "sidebar/metadata/language": "Langue", 93 | "sidebar/metadata/rights": "???", 94 | "sidebar/metadata/modified_date": "???", 95 | "sidebar/metadata/layout": "???", 96 | "sidebar/metadata/flow": "???", 97 | "sidebar/metadata/spread": "???", 98 | "sidebar/metadata/direction": "???", 99 | 100 | "notedlg/label": "???", 101 | "notedlg/add": "Ajouter" 102 | }, 103 | ja: { 104 | "toolbar/sidebar": "サイドバー", 105 | "toolbar/prev": "???", 106 | "toolbar/next": "???", 107 | "toolbar/openbook": "本を開く", 108 | "toolbar/openbook/error": "ご利用のブラウザは必要な機能をサポートしていません。\nGoogle Chrome、Mozilla Firefox、その他のモダンなブラウザでご利用ください。", 109 | "toolbar/bookmark": "このページに栞を設定する", 110 | "toolbar/fullscreen": "フルスクリーン", 111 | 112 | "sidebar/close": "???", 113 | "sidebar/contents": "目次", 114 | "sidebar/bookmarks": "栞", 115 | "sidebar/bookmarks/add": "追加", 116 | "sidebar/bookmarks/remove": "削除", 117 | "sidebar/bookmarks/clear": "クリア", 118 | "sidebar/annotations": "注釈", 119 | "sidebar/annotations/add": "追加", 120 | "sidebar/bookmarks/remove": "削除", 121 | "sidebar/annotations/clear": "クリア", 122 | "sidebar/annotations/anchor": "アンカー", 123 | "sidebar/annotations/cancel": "キャンセル", 124 | "sidebar/search": "検索", 125 | "sidebar/search/placeholder": "検索", 126 | "sidebar/settings": "設定", 127 | "sidebar/settings/language": "表示言語", 128 | "sidebar/settings/fontsize": "???", 129 | "sidebar/settings/flow": "???", 130 | "sidebar/settings/pagination": ["ページネーション", "ページネーションを生成します。"], 131 | "sidebar/settings/spread": "???", 132 | "sidebar/settings/spread/minwidth": "???", 133 | "sidebar/metadata": "???", 134 | "sidebar/metadata/title": "???", 135 | "sidebar/metadata/creator": "???", 136 | "sidebar/metadata/description": "???", 137 | "sidebar/metadata/pubdate": "???", 138 | "sidebar/metadata/publisher": "???", 139 | "sidebar/metadata/identifier": "???", 140 | "sidebar/metadata/language": "表示言語", 141 | "sidebar/metadata/rights": "???", 142 | "sidebar/metadata/modified_date": "???", 143 | "sidebar/metadata/layout": "???", 144 | "sidebar/metadata/flow": "???", 145 | "sidebar/metadata/spread": "???", 146 | "sidebar/metadata/direction": "???", 147 | 148 | "notedlg/label": "???", 149 | "notedlg/add": "追加" 150 | }, 151 | ru: { 152 | "toolbar/sidebar": "Боковая панель", 153 | "toolbar/prev": "Предыдущая страница", 154 | "toolbar/next": "Следущая страница", 155 | "toolbar/openbook": "Открыть книгу", 156 | "toolbar/openbook/error": "Ваш браузер не поддерживает необходимые функции.\nПожалуйста, используйте современный браузер, такой как Google Chrome или Mozilla Firefox.", 157 | "toolbar/bookmark": "Добавить эту страницу в закладки", 158 | "toolbar/fullscreen": "Полноэкранный режим", 159 | 160 | "sidebar/close": "Закрыть боковую панель", 161 | "sidebar/contents": "Содержание", 162 | "sidebar/bookmarks": "Закладки", 163 | "sidebar/bookmarks/add": "Добавить", 164 | "sidebar/bookmarks/remove": "Удалить", 165 | "sidebar/bookmarks/clear": "Очистить", 166 | "sidebar/annotations": "Аннотации", 167 | "sidebar/annotations/add": "Добавить", 168 | "sidebar/annotations/remove": "Удалить", 169 | "sidebar/annotations/clear": "Очистить", 170 | "sidebar/annotations/anchor": "Метка", 171 | "sidebar/annotations/cancel": "Отмена", 172 | "sidebar/search": "Поиск", 173 | "sidebar/search/placeholder": "Поиск", 174 | "sidebar/settings": "Настройки", 175 | "sidebar/settings/language": "Язык", 176 | "sidebar/settings/fontsize": "Размер шрифта", 177 | "sidebar/settings/flow": "Поток", 178 | "sidebar/settings/pagination": ["Нумерация страниц", "Генерировать нумерацию страниц"], 179 | "sidebar/settings/spread": "Разворот", 180 | "sidebar/settings/spread/minwidth": "Мин. ширина колонки", 181 | "sidebar/metadata": "Метаданные", 182 | "sidebar/metadata/title": "Заголовок", 183 | "sidebar/metadata/creator": "Автор", 184 | "sidebar/metadata/description": "Описание", 185 | "sidebar/metadata/pubdate": "Дата публикации", 186 | "sidebar/metadata/publisher": "Издатель", 187 | "sidebar/metadata/identifier": "Идентификатор", 188 | "sidebar/metadata/language": "Язык", 189 | "sidebar/metadata/rights": "Лицензия", 190 | "sidebar/metadata/modified_date": "Дата изменения", 191 | "sidebar/metadata/layout": "Макет", 192 | "sidebar/metadata/flow": "Поток", 193 | "sidebar/metadata/spread": "Разворот", 194 | "sidebar/metadata/direction": "Направление", 195 | 196 | "notedlg/label": "Заметка", 197 | "notedlg/add": "Добавить" 198 | }, 199 | zh: { 200 | "toolbar/sidebar": "侧边栏", 201 | "toolbar/prev": "上一页", 202 | "toolbar/next": "下一页", 203 | "toolbar/openbook": "打开书籍", 204 | "toolbar/openbook/error": "您的浏览器不支持所需功能。\n请使用现代浏览器如谷歌Chrome或火狐Firefox。", 205 | "toolbar/bookmark": "加为书签", 206 | "toolbar/fullscreen": "全屏", 207 | 208 | "sidebar/close": "关闭侧边栏", 209 | "sidebar/contents": "目录", 210 | "sidebar/bookmarks": "书签", 211 | "sidebar/bookmarks/add": "添加", 212 | "sidebar/bookmarks/remove": "移除", 213 | "sidebar/bookmarks/clear": "清空", 214 | "sidebar/annotations": "注解", 215 | "sidebar/annotations/add": "添加", 216 | "sidebar/annotations/remove": "移除", 217 | "sidebar/annotations/clear": "清空", 218 | "sidebar/annotations/anchor": "锚定", 219 | "sidebar/annotations/cancel": "取消", 220 | 221 | "sidebar/search": "搜索", 222 | "sidebar/search/placeholder": "搜索", 223 | "sidebar/settings": "设置", 224 | "sidebar/settings/language": "语言", 225 | "sidebar/settings/fontsize": "字体大小 (%)", 226 | "sidebar/settings/flow": "流模式", // Scrolled = "滚动模式" 227 | "sidebar/settings/pagination": ["分页模式", "生成分页"], 228 | "sidebar/settings/spread": "双页布局", 229 | "sidebar/settings/spread/minwidth": "最小双页宽度", 230 | "sidebar/metadata": "元数据", 231 | "sidebar/metadata/title": "标题", 232 | "sidebar/metadata/creator": "作者", 233 | "sidebar/metadata/description": "描述", 234 | "sidebar/metadata/pubdate": "出版日期", 235 | "sidebar/metadata/publisher": "出版商", 236 | "sidebar/metadata/identifier": "标识符", 237 | "sidebar/metadata/language": "语言", 238 | "sidebar/metadata/rights": "版权", 239 | "sidebar/metadata/modified_date": "修改日期", 240 | "sidebar/metadata/layout": "布局", // rendition:layout 241 | "sidebar/metadata/flow": "流模式", // rendition:flow 242 | "sidebar/metadata/spread": "双页布局", // rendition:spread 243 | "sidebar/metadata/direction": "阅读方向", // page-progression-direction 244 | 245 | "notedlg/label": "笔记", 246 | "notedlg/add": "添加" 247 | }, 248 | }; 249 | 250 | reader.on("languagechanged", (value) => { 251 | this.language = value; 252 | }); 253 | } 254 | 255 | get(key) { return this.values[this.language][key] || "???"; } 256 | } -------------------------------------------------------------------------------- /src/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author mrdoob https://github.com/mrdoob/ui.js 3 | */ 4 | 5 | const ERROR_MSG = "is not an instance of UIElement."; 6 | 7 | /** 8 | * UIElement 9 | * @param {string} tag 10 | */ 11 | export class UIElement { 12 | 13 | constructor(tag) { 14 | 15 | this.dom = document.createElement(tag); 16 | } 17 | 18 | add() { 19 | 20 | for (let i = 0; i < arguments.length; i++) { 21 | 22 | const argument = arguments[i]; 23 | 24 | if (argument instanceof UIElement) { 25 | 26 | this.dom.appendChild(argument.dom); 27 | 28 | } else if (Array.isArray(argument)) { 29 | 30 | for (let j = 0; j < argument.length; j++) { 31 | 32 | const element = argument[j]; 33 | 34 | if (element instanceof UIElement) { 35 | 36 | this.dom.appendChild(element.dom); 37 | } else { 38 | 39 | console.error("UIElement:", element, ERROR_MSG); 40 | } 41 | } 42 | } else { 43 | 44 | console.error("UIElement:", argument, ERROR_MSG); 45 | } 46 | } 47 | return this; 48 | } 49 | 50 | remove() { 51 | 52 | for (let i = 0; i < arguments.length; i++) { 53 | 54 | const argument = arguments[i]; 55 | 56 | if (argument instanceof UIElement) { 57 | 58 | this.dom.removeChild(argument.dom); 59 | 60 | } else if (Number.isInteger(argument)) { 61 | 62 | this.dom.removeChild(this.dom.childNodes[argument]); 63 | } else { 64 | 65 | console.error("UIElement:", argument, ERROR_MSG); 66 | } 67 | } 68 | return this; 69 | } 70 | 71 | clear() { 72 | 73 | while (this.dom.children.length) { 74 | 75 | this.dom.removeChild(this.dom.lastChild); 76 | } 77 | return this; 78 | } 79 | 80 | setId(id) { 81 | 82 | this.dom.id = id; 83 | return this; 84 | } 85 | 86 | getId() { 87 | 88 | return this.dom.id; 89 | } 90 | 91 | removeAttribute(name) { 92 | 93 | this.dom.removeAttribute(name); 94 | return this; 95 | } 96 | 97 | setClass(name) { 98 | 99 | this.dom.className = name; 100 | return this; 101 | } 102 | 103 | addClass(name) { 104 | 105 | this.dom.classList.add(name); 106 | return this; 107 | } 108 | 109 | removeClass(name) { 110 | 111 | this.dom.classList.remove(name); 112 | return this; 113 | } 114 | 115 | setStyle(key, value) { 116 | 117 | this.dom.style[key] = value; 118 | return this; 119 | } 120 | 121 | getTitle() { 122 | 123 | return this.dom.title; 124 | } 125 | 126 | setTitle(title) { 127 | 128 | if (this.dom.title !== title && title) 129 | this.dom.title = title; 130 | return this; 131 | } 132 | 133 | getTextContent() { 134 | 135 | return this.dom.textContent; 136 | } 137 | 138 | setTextContent(text) { 139 | 140 | if (this.dom.textContent !== text && text) 141 | this.dom.textContent = text; 142 | return this; 143 | } 144 | 145 | getBoundingClientRect() { 146 | 147 | return this.dom.getBoundingClientRect(); 148 | } 149 | } 150 | 151 | /** 152 | * UISpan 153 | * @param {string} text 154 | */ 155 | export class UISpan extends UIElement { 156 | 157 | constructor(text) { 158 | 159 | super("span"); 160 | this.setTextContent(text); 161 | } 162 | } 163 | 164 | /** 165 | * UIDiv 166 | */ 167 | export class UIDiv extends UIElement { 168 | 169 | constructor() { 170 | 171 | super("div"); 172 | } 173 | } 174 | 175 | /** 176 | * UIRow 177 | */ 178 | export class UIRow extends UIDiv { 179 | 180 | constructor() { 181 | 182 | super(); 183 | 184 | this.dom.className = "row"; 185 | } 186 | } 187 | 188 | /** 189 | * UIPanel 190 | */ 191 | export class UIPanel extends UIDiv { 192 | 193 | constructor() { 194 | 195 | super(); 196 | 197 | this.dom.className = "panel"; 198 | } 199 | } 200 | 201 | /** 202 | * UILabel 203 | * @param {string} text 204 | * @param {string} id 205 | */ 206 | export class UILabel extends UIElement { 207 | 208 | constructor(text, id) { 209 | 210 | super("label"); 211 | 212 | this.dom.textContent = text; 213 | if (id) this.dom.htmlFor = id; 214 | } 215 | } 216 | 217 | /** 218 | * UILink 219 | * @param {string} href 220 | * @param {string} text 221 | */ 222 | export class UILink extends UIElement { 223 | 224 | constructor(href, text) { 225 | 226 | super("a"); 227 | 228 | this.dom.href = href || "#"; 229 | this.dom.textContent = text || ""; 230 | } 231 | 232 | setHref(url) { 233 | 234 | this.dom.href = url; 235 | return this; 236 | } 237 | } 238 | 239 | /** 240 | * UIText 241 | * @param {string} text 242 | */ 243 | export class UIText extends UISpan { 244 | 245 | constructor(text) { 246 | 247 | super(); 248 | 249 | this.dom.textContent = text; 250 | } 251 | 252 | getValue() { 253 | 254 | return this.dom.textContent; 255 | } 256 | 257 | setValue(text) { 258 | 259 | this.dom.textContent = text; 260 | return this; 261 | } 262 | } 263 | 264 | /** 265 | * UITextArea 266 | */ 267 | export class UITextArea extends UIElement { 268 | 269 | constructor() { 270 | 271 | super("textarea"); 272 | 273 | this.dom.spellcheck = false; 274 | this.dom.onkeydown = (e) => { 275 | 276 | e.stopPropagation(); 277 | }; 278 | } 279 | 280 | getValue() { 281 | 282 | return this.dom.value; 283 | } 284 | 285 | setValue(value) { 286 | 287 | this.dom.value = value; 288 | return this; 289 | } 290 | } 291 | 292 | /** 293 | * UISelect 294 | */ 295 | export class UISelect extends UIElement { 296 | 297 | constructor() { 298 | 299 | super("select"); 300 | } 301 | 302 | setMultiple(boolean) { 303 | 304 | this.dom.multiple = boolean || false; 305 | return this; 306 | } 307 | 308 | setOptions(options) { 309 | 310 | const selected = this.dom.value; 311 | this.clear(); 312 | 313 | for (const key in options) { 314 | 315 | const option = document.createElement("option"); 316 | option.value = key; 317 | option.text = options[key]; 318 | this.dom.appendChild(option); 319 | } 320 | this.dom.value = selected; 321 | return this; 322 | } 323 | 324 | getValue() { 325 | 326 | return this.dom.value; 327 | } 328 | 329 | setValue(value) { 330 | 331 | value = String(value); 332 | 333 | if (this.dom.value !== value) 334 | this.dom.value = value; 335 | return this; 336 | } 337 | } 338 | 339 | /** 340 | * UIInput 341 | * @param {*} type 342 | * @param {*} value 343 | * @param {*} title 344 | */ 345 | export class UIInput extends UIElement { 346 | 347 | constructor(type, value, title) { 348 | 349 | super("input"); 350 | 351 | this.dom.type = type; 352 | this.dom.onkeydown = (e) => { 353 | 354 | e.stopPropagation(); 355 | }; 356 | this.setValue(value); 357 | this.setTitle(title); 358 | } 359 | 360 | getName() { 361 | 362 | return this.dom.name; 363 | } 364 | 365 | setName(name) { 366 | 367 | this.dom.name = name; 368 | return this; 369 | } 370 | 371 | getType() { 372 | 373 | return this.dom.type; 374 | } 375 | 376 | setType(type) { 377 | 378 | this.dom.type = type; 379 | return this; 380 | } 381 | 382 | getValue() { 383 | 384 | return this.dom.value; 385 | } 386 | 387 | setValue(value) { 388 | 389 | if (this.dom.value !== value && value !== undefined) 390 | this.dom.value = value; 391 | return this; 392 | } 393 | } 394 | 395 | /** 396 | * UIColor 397 | */ 398 | export class UIColor extends UIElement { 399 | 400 | constructor() { 401 | 402 | super("input"); 403 | 404 | try { 405 | 406 | this.dom.type = "color"; 407 | this.dom.value = "#ffffff"; 408 | 409 | } catch (e) { 410 | 411 | console.exception(e); 412 | } 413 | } 414 | 415 | getValue() { 416 | 417 | return this.dom.value; 418 | } 419 | 420 | getHexValue() { 421 | 422 | return parseInt(this.dom.value.substr(1), 16); 423 | } 424 | 425 | setValue(value) { 426 | 427 | this.dom.value = value; 428 | return this; 429 | } 430 | 431 | setHexValue(hex) { 432 | 433 | this.dom.value = "#" + ("000000" + hex.toString(16)).slice(-6); 434 | return this; 435 | } 436 | } 437 | 438 | /** 439 | * UINumber 440 | * @param {number} value 441 | * @param {number} step 442 | * @param {number} min 443 | * @param {number} max 444 | * @param {number} precision 445 | */ 446 | export class UINumber extends UIElement { 447 | 448 | constructor(value, step, min, max, precision) { 449 | 450 | super("input"); 451 | 452 | this.dom.type = "number"; 453 | this.dom.step = step || 1; 454 | this.dom.onkeydown = (e) => { 455 | 456 | e.stopPropagation(); 457 | }; 458 | this.value = value || 0; 459 | this.min = min || -Infinity; 460 | this.max = max || +Infinity; 461 | this.precision = precision || 0; 462 | this.setValue(value); 463 | this.dom.onchange = (e) => { 464 | 465 | this.setValue(this.value); 466 | }; 467 | } 468 | 469 | getName() { 470 | 471 | return this.dom.name; 472 | } 473 | 474 | setName(name) { 475 | 476 | this.dom.name = name; 477 | return this; 478 | } 479 | 480 | setPrecision(precision) { 481 | 482 | this.precision = precision; 483 | this.setValue(this.value); 484 | return this; 485 | } 486 | 487 | setRange(min, max) { 488 | 489 | this.min = min; 490 | this.max = max; 491 | this.dom.min = min; 492 | this.dom.max = max; 493 | return this; 494 | } 495 | 496 | setStep(step) { 497 | 498 | this.dom.step = step; 499 | return this; 500 | } 501 | 502 | getValue() { 503 | 504 | return parseFloat(this.dom.value); 505 | } 506 | 507 | setValue(value) { 508 | 509 | if (value !== undefined) { 510 | value = parseFloat(value); 511 | 512 | if (value < this.min) 513 | value = this.min; 514 | if (value > this.max) 515 | value = this.max; 516 | 517 | this.value = value; 518 | this.dom.value = value.toFixed(this.precision); 519 | } 520 | return this; 521 | } 522 | } 523 | 524 | /** 525 | * UIBreak 526 | */ 527 | export class UIBreak extends UIElement { 528 | 529 | constructor() { 530 | 531 | super("br"); 532 | } 533 | } 534 | 535 | /** 536 | * UIHorizontalRule 537 | */ 538 | export class UIHorizontalRule extends UIElement { 539 | 540 | constructor() { 541 | 542 | super("hr"); 543 | } 544 | } 545 | 546 | /** 547 | * UIProgress 548 | * @param {*} value 549 | */ 550 | export class UIProgress extends UIElement { 551 | 552 | constructor(value) { 553 | 554 | super("progress"); 555 | 556 | this.dom.value = value; 557 | } 558 | 559 | setValue(value) { 560 | 561 | this.dom.value = value; 562 | return this; 563 | } 564 | } 565 | 566 | /** 567 | * UITabbedPanel 568 | * @param {string} align (horizontal | vertical) 569 | */ 570 | export class UITabbedPanel extends UIDiv { 571 | 572 | constructor(align) { 573 | 574 | super(); 575 | 576 | this.align = align || "horizontal"; 577 | this.tabs = []; 578 | this.panels = []; 579 | this.selector = new UISpan().setClass("tab-selector"); 580 | this.menuDiv = new UIDiv().setClass("menu"); 581 | this.tabsDiv = new UIDiv().setClass("tabs"); 582 | this.tabsDiv.add(this.selector); 583 | this.panelsDiv = new UIDiv().setClass("panels"); 584 | this.selected = ""; 585 | this.add(this.menuDiv); 586 | this.add(this.tabsDiv); 587 | this.add(this.panelsDiv); 588 | } 589 | 590 | addMenu(items) { 591 | this.menuDiv.add(items); 592 | } 593 | 594 | addTab(id, label, items) { 595 | 596 | const tab = new UITab(label, this); 597 | tab.setId(id); 598 | tab.setClass("box"); 599 | this.tabs.push(tab); 600 | this.tabsDiv.add(tab); 601 | 602 | const panel = new UIDiv(); 603 | panel.setId(id); 604 | panel.add(items); 605 | this.panels.push(panel); 606 | this.panelsDiv.add(panel); 607 | this.select(id); 608 | } 609 | 610 | select(id) { 611 | 612 | for (let tab of this.tabs) { 613 | if (tab.dom.id === id) { 614 | tab.addClass("selected"); 615 | this.transformSelector(tab); 616 | } else if (tab.dom.id === this.selected) { 617 | tab.removeClass("selected"); 618 | } 619 | } 620 | 621 | for (let panel of this.panels) { 622 | if (panel.dom.id === id) { 623 | panel.dom.style.display = "block"; 624 | } else if (panel.dom.id === this.selected) { 625 | panel.dom.style.display = "none"; 626 | } 627 | } 628 | 629 | this.selected = id; 630 | return this; 631 | } 632 | 633 | setLabel(id, text) { 634 | 635 | for (let tab of this.tabs) { 636 | if (tab.dom.id === id) { 637 | tab.setTitle(text); 638 | break; 639 | } 640 | } 641 | } 642 | 643 | transformSelector(tab) { 644 | 645 | let size; 646 | const rect = tab.getBoundingClientRect(); 647 | if (this.align === "horizontal") { 648 | size = rect.width * this.tabs.indexOf(tab); 649 | this.selector.dom.style.transform = `translateX(${size}px)`; 650 | } else { 651 | size = rect.height * this.tabs.indexOf(tab); 652 | this.selector.dom.style.transform = `translateY(${size}px)`; 653 | } 654 | } 655 | } 656 | 657 | /** 658 | * UITab 659 | * @param {string} text 660 | * @param {UITabbedPanel} parent 661 | */ 662 | export class UITab extends UIDiv { 663 | 664 | constructor(text, parent) { 665 | 666 | super(); 667 | this.button = new UIInput("button"); 668 | this.button.dom.title = text; 669 | this.dom.onclick = (e) => { 670 | 671 | parent.select(this.dom.id); 672 | e.preventDefault(); 673 | }; 674 | this.add(this.button); 675 | } 676 | } 677 | 678 | /** 679 | * UIList 680 | * @param {UIItem} parent 681 | */ 682 | export class UIList extends UIElement { 683 | 684 | constructor(parent) { 685 | 686 | super("ul"); 687 | this.parent = parent && parent.parent; // LI->UL 688 | this.expanded = false; 689 | } 690 | 691 | expand() { 692 | 693 | this.expanded = true; 694 | this.dom.style.display = "block"; 695 | if (this.parent) 696 | this.parent.expand(); 697 | return this; 698 | } 699 | 700 | collaps() { 701 | 702 | this.expanded = false; 703 | this.dom.style.display = "none"; 704 | return this; 705 | } 706 | } 707 | 708 | /** 709 | * UIItem 710 | * @param {UIList} parent 711 | */ 712 | export class UIItem extends UIElement { 713 | 714 | constructor(parent) { 715 | 716 | super("li"); 717 | this.parent = parent; // UL 718 | this.selected = false; 719 | } 720 | 721 | add() { 722 | let len = 0; 723 | const box = new UIDiv().setId("item-box"); 724 | for (let i = 0; i < arguments.length; i++) { 725 | const argument = arguments[i]; 726 | if (argument instanceof UIList) { 727 | super.add(argument); 728 | } else { 729 | box.add(argument); 730 | len++; 731 | } 732 | } 733 | if (len) super.add(box); 734 | return this; 735 | } 736 | 737 | select() { 738 | 739 | this.selected = true; 740 | this.setClass("selected"); 741 | return this; 742 | } 743 | 744 | unselect() { 745 | 746 | this.selected = false; 747 | this.removeAttribute("class"); 748 | return this; 749 | } 750 | } 751 | 752 | /** 753 | * UIBox 754 | * @param {UIElement} items 755 | */ 756 | export class UIBox extends UIElement { 757 | 758 | constructor(items) { 759 | 760 | super("div"); 761 | this.setClass("box"); 762 | this.add(items); 763 | } 764 | } --------------------------------------------------------------------------------