Browse Source

Merge branch 'release/0.1.0'

Book Pauk 6 years ago
parent
commit
55216f21b2
100 changed files with 6740 additions and 48 deletions
  1. 45 0
      .eslintrc
  2. 0 48
      .eslintrc.js
  3. 5 0
      .gitignore
  4. 3 0
      README.md
  5. 74 0
      build/linux.js
  6. 66 0
      build/webpack.base.config.js
  7. 43 0
      build/webpack.dev.config.js
  8. 50 0
      build/webpack.prod.config.js
  9. 64 0
      build/win.js
  10. 14 0
      client/api/misc.js
  11. 109 0
      client/api/reader.js
  12. BIN
      client/assets/apple-touch-icon-precomposed.png
  13. BIN
      client/assets/apple-touch-icon.png
  14. BIN
      client/assets/favicon.ico
  15. 328 0
      client/components/App.vue
  16. 20 0
      client/components/CardIndex/Book/Book.vue
  17. 20 0
      client/components/CardIndex/Card/Card.vue
  18. 76 0
      client/components/CardIndex/CardIndex.vue
  19. 20 0
      client/components/CardIndex/History/History.vue
  20. 20 0
      client/components/CardIndex/Search/Search.vue
  21. 20 0
      client/components/Help/Help.vue
  22. 20 0
      client/components/Income/Income.vue
  23. 20 0
      client/components/NotFound404/NotFound404.vue
  24. 80 0
      client/components/Reader/ClickMapPage/ClickMapPage.vue
  25. 148 0
      client/components/Reader/CopyTextPage/CopyTextPage.vue
  26. 66 0
      client/components/Reader/HelpPage/CommonHelpPage/CommonHelpPage.vue
  27. 110 0
      client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue
  28. BIN
      client/components/Reader/HelpPage/DonateHelpPage/assets/bitcoin.png
  29. BIN
      client/components/Reader/HelpPage/DonateHelpPage/assets/litecoin.png
  30. BIN
      client/components/Reader/HelpPage/DonateHelpPage/assets/monero.png
  31. BIN
      client/components/Reader/HelpPage/DonateHelpPage/assets/yandex.png
  32. 100 0
      client/components/Reader/HelpPage/HelpPage.vue
  33. 54 0
      client/components/Reader/HelpPage/HotkeysHelpPage/HotkeysHelpPage.vue
  34. 58 0
      client/components/Reader/HelpPage/MouseHelpPage/MouseHelpPage.vue
  35. 251 0
      client/components/Reader/HistoryPage/HistoryPage.vue
  36. 160 0
      client/components/Reader/LoaderPage/LoaderPage.vue
  37. 99 0
      client/components/Reader/ProgressPage/ProgressPage.vue
  38. 901 0
      client/components/Reader/Reader.vue
  39. 230 0
      client/components/Reader/SearchPage/SearchPage.vue
  40. 96 0
      client/components/Reader/SetPositionPage/SetPositionPage.vue
  41. 455 0
      client/components/Reader/SettingsPage/SettingsPage.vue
  42. 98 0
      client/components/Reader/TextPage/DrawHelper.js
  43. 1165 0
      client/components/Reader/TextPage/TextPage.vue
  44. BIN
      client/components/Reader/TextPage/images/paper1.jpg
  45. BIN
      client/components/Reader/TextPage/images/paper2.jpg
  46. BIN
      client/components/Reader/TextPage/images/paper3.jpg
  47. BIN
      client/components/Reader/TextPage/images/paper4.jpg
  48. BIN
      client/components/Reader/TextPage/images/paper5.jpg
  49. BIN
      client/components/Reader/TextPage/images/paper6.jpg
  50. BIN
      client/components/Reader/TextPage/images/paper7.jpg
  51. BIN
      client/components/Reader/TextPage/images/paper8.jpg
  52. BIN
      client/components/Reader/TextPage/images/paper9.jpg
  53. 636 0
      client/components/Reader/share/BookParser.js
  54. 234 0
      client/components/Reader/share/bookManager.js
  55. 13 0
      client/components/Reader/share/clickMap.js
  56. 70 0
      client/components/Reader/share/restoreOldSettings.js
  57. 20 0
      client/components/Settings/Settings.vue
  58. 20 0
      client/components/Sources/Sources.vue
  59. BIN
      client/components/fonts/arimo.woff2
  60. BIN
      client/components/fonts/avrile.ttf
  61. BIN
      client/components/fonts/avrile.woff
  62. BIN
      client/components/fonts/geo_1.ttf
  63. BIN
      client/components/fonts/geo_1.woff
  64. BIN
      client/components/fonts/open-sans.ttf
  65. BIN
      client/components/fonts/open-sans.woff
  66. BIN
      client/components/fonts/reader-default.ttf
  67. BIN
      client/components/fonts/reader-default.woff
  68. BIN
      client/components/fonts/roboto.ttf
  69. BIN
      client/components/fonts/roboto.woff
  70. BIN
      client/components/fonts/rubik.woff2
  71. 61 0
      client/components/share/Window.vue
  72. 122 0
      client/element.js
  73. 11 0
      client/index.html.template
  74. 14 0
      client/main.js
  75. 67 0
      client/router.js
  76. 65 0
      client/share/utils.js
  77. 22 0
      client/store/index.js
  78. 39 0
      client/store/modules/config.js
  79. 202 0
      client/store/modules/reader.js
  80. 25 0
      client/store/modules/uistate.js
  81. 25 0
      client/store/root.js
  82. 1 0
      client/theme/alert.css
  83. 1 0
      client/theme/aside.css
  84. 0 0
      client/theme/autocomplete.css
  85. 1 0
      client/theme/badge.css
  86. 0 0
      client/theme/base.css
  87. 0 0
      client/theme/breadcrumb-item.css
  88. 1 0
      client/theme/breadcrumb.css
  89. 0 0
      client/theme/button-group.css
  90. 0 0
      client/theme/button.css
  91. 1 0
      client/theme/card.css
  92. 1 0
      client/theme/carousel-item.css
  93. 0 0
      client/theme/carousel.css
  94. 0 0
      client/theme/cascader.css
  95. 0 0
      client/theme/checkbox-button.css
  96. 0 0
      client/theme/checkbox-group.css
  97. 0 0
      client/theme/checkbox.css
  98. 0 0
      client/theme/col.css
  99. 0 0
      client/theme/collapse-item.css
  100. 0 0
      client/theme/collapse.css

+ 45 - 0
.eslintrc

@@ -0,0 +1,45 @@
+{
+  "parserOptions": {
+    "parser": "babel-eslint"
+  },
+  "extends": [
+    "eslint:recommended",
+    "plugin:vue/essential"
+  ],
+  "plugins": [
+    "vue",
+    "html",
+    "node"
+  ],
+  "env": {
+    "browser": true,
+    "node": true
+  },
+  "globals": {
+    "LM_OK": false,
+    "LM_INFO": false,
+    "LM_WARN": false,
+    "LM_ERR": false,
+    "LM_FATAL": false,
+    "LM_TOTAL": false
+  },
+  "rules": {
+    "strict": 0,
+    "indent": [0, 4, {
+      "SwitchCase": 1
+    }],
+    "space-before-function-paren": [2, "never"],
+    "valid-jsdoc": [2, {
+      "requireReturn": false,
+      "prefer": {
+        "returns": "return"
+      }
+    }],
+    "require-jsdoc": 0,
+    "max-len": [1, 200, 4, {
+      "ignoreComments": true,
+      "ignoreUrls": true
+    }],
+    "no-console": off
+  }
+}

+ 0 - 48
.eslintrc.js

@@ -1,48 +0,0 @@
-module.exports = {
-    root: true,
-    parserOptions: {
-        parser: 'babel-eslint',
-        sourceType: 'module'
-    },
-    env: {
-        browser: true,
-    },
-    extends: [
-        'plugin:vue/essential',
-        'standard'
-    ],
-    plugins: [
-        'html',
-        'standard',
-        'vue'
-    ],
-    rules: {
-        'generator-star-spacing': 'off',
-        'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
-        'indent': [ 'error', 4, { 'SwitchCase': 1 } ],
-        'brace-style': [ 'error', '1tbs' ],
-        'semi': [ 'error', 'always' ],
-        'no-console': 'error',
-        'comma-dangle': [ 'error', {
-            'arrays': 'never',
-            'objects': 'always-multiline',
-            'imports': 'never',
-            'exports': 'never',
-            'functions': 'never'
-        }],
-        'no-multiple-empty-lines': [ 'error', { 'max': 2, 'maxBOF': 1 }],
-        'no-undef': 'error',
-        'space-in-parens': ['error', 'never'],
-        'space-before-function-paren': [
-            'error',
-            'always'
-        ],
-        'quotes': ['error', 'single'],
-        'space-before-blocks': [
-            'error',
-            'always'
-        ],
-        'no-empty': 'error',
-        'no-duplicate-imports': 'error'
-    }
-}

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+/node_modules
+/server/data
+/server/public
+/server/ipfs
+/dist

+ 3 - 0
README.md

@@ -0,0 +1,3 @@
+# Liberama
+
+Свободный обмен книгами в формате fb2

+ 74 - 0
build/linux.js

@@ -0,0 +1,74 @@
+const fs = require('fs-extra');
+const path = require('path');
+const util = require('util');
+const stream = require('stream');
+const pipeline = util.promisify(stream.pipeline);
+
+const got = require('got');
+const decompress = require('decompress');
+const decompressTargz = require('decompress-targz');
+
+const distDir = path.resolve(__dirname, '../dist');
+const publicDir = `${distDir}/tmp/public`;
+const outDir = `${distDir}/linux`;
+
+const tempDownloadDir = `${distDir}/tmp/download`;
+
+async function main() {
+    await fs.emptyDir(outDir);
+    // перемещаем public на место
+    if (await fs.pathExists(publicDir))
+        await fs.move(publicDir, `${outDir}/public`);
+
+    await fs.ensureDir(tempDownloadDir);
+
+    //sqlite3
+    const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.0.4/node-v64-linux-x64.tar.gz';
+    const sqliteDecompressedFilename = `${tempDownloadDir}/node-v64-linux-x64/node_sqlite3.node`;
+
+    if (!await fs.pathExists(sqliteDecompressedFilename)) {
+        // Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
+        await pipeline(got.stream(sqliteRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
+        console.log(`done downloading ${sqliteRemoteUrl}`);
+
+        //распаковываем
+        await decompress(`${tempDownloadDir}/sqlite.tar.gz`, `${tempDownloadDir}`, {
+            plugins: [
+                decompressTargz()
+            ]
+        });
+        console.log('files decompressed');
+    }
+    // копируем в дистрибутив
+    await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
+    console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
+
+    //ipfs
+    const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs`;
+    if (!await fs.pathExists(ipfsDecompressedFilename)) {
+        // Скачиваем ipfs
+        const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_linux-amd64.tar.gz';
+
+        await pipeline(got.stream(ipfsRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/ipfs.tar.gz`));
+        console.log(`done downloading ${ipfsRemoteUrl}`);
+
+        //распаковываем
+        await decompress(`${tempDownloadDir}/ipfs.tar.gz`, `${tempDownloadDir}`, {
+            plugins: [
+                decompressTargz()
+            ]
+        });
+        console.log('files decompressed');
+    }
+
+    // копируем в дистрибутив
+    await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs`);
+    console.log(`copied ${tempDownloadDir}/go-ipfs/ipfs to ${outDir}/ipfs`);
+    //для development
+    const devIpfsFile = path.resolve(__dirname, '../server/ipfs');
+    if (!await fs.pathExists(devIpfsFile)) {
+        await fs.copy(ipfsDecompressedFilename, devIpfsFile);
+    }
+}
+
+main();

+ 66 - 0
build/webpack.base.config.js

@@ -0,0 +1,66 @@
+const path = require('path');
+//const webpack = require('webpack');
+const VueLoaderPlugin = require('vue-loader/lib/plugin');
+
+const clientDir = path.resolve(__dirname, '../client');
+
+module.exports = {
+    entry: [`${clientDir}/main.js`],
+    output: {
+        publicPath: '/app/',
+    },
+
+    module: {
+        rules: [
+            {
+                test: /\.vue$/,
+                loader: "vue-loader"
+            },
+            {
+                test: /\.js$/,
+                loader: 'babel-loader',
+                exclude: /node_modules/,
+                query: {
+                    plugins: [
+                        'syntax-dynamic-import',
+                        'transform-decorators-legacy',
+                        'transform-class-properties',
+//                        ["component", { "libraryName": "element-ui", "styleLibraryName": `~${clientDir}/theme` } ]
+                    ]
+                }
+            },
+            {
+                test: /\.gif$/,
+                loader: "url-loader",
+                options: {
+                    name: "images/[name]-[hash:6].[ext]"
+                }
+            },
+            {
+                test: /\.png$/,
+                loader: "url-loader",
+                options: {
+                    name: "images/[name]-[hash:6].[ext]"
+                }
+            },
+            {
+                test: /\.jpg$/,
+                loader: "file-loader",
+                options: {
+                    name: "images/[name]-[hash:6].[ext]"
+                }
+            },
+            {
+                test: /\.(ttf|eot|woff|woff2)$/,
+                loader: "file-loader",
+                options: {
+                    name: "fonts/[name]-[hash:6].[ext]"
+                }
+            },
+        ]
+    },
+
+    plugins: [
+        new VueLoaderPlugin(),
+    ]
+};

+ 43 - 0
build/webpack.dev.config.js

@@ -0,0 +1,43 @@
+const path = require('path');
+const webpack = require('webpack');
+
+const merge = require('webpack-merge');
+const baseWpConfig = require('./webpack.base.config');
+
+baseWpConfig.entry.unshift('webpack-hot-middleware/client');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const CopyWebpackPlugin = require('copy-webpack-plugin');
+
+const publicDir = path.resolve(__dirname, '../server/public');
+const clientDir = path.resolve(__dirname, '../client');
+
+module.exports = merge(baseWpConfig, {
+    mode: 'development',
+    devtool: "#inline-source-map",
+    output: {
+        path: `${publicDir}/app`,
+        filename: 'bundle.js'
+    },
+
+    module: {
+        rules: [
+            {
+                test: /\.css$/,
+                use: [
+                  'vue-style-loader',
+                  'css-loader'
+                ]
+            },
+        ]
+    },
+
+    plugins: [
+        new webpack.HotModuleReplacementPlugin(),
+        new webpack.NoEmitOnErrorsPlugin(),
+        new HtmlWebpackPlugin({
+            template: `${clientDir}/index.html.template`,
+            filename: `${publicDir}/index.html`
+        }),
+        new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
+    ]
+});

+ 50 - 0
build/webpack.prod.config.js

@@ -0,0 +1,50 @@
+const path = require('path');
+//const webpack = require('webpack');
+
+const merge = require('webpack-merge');
+const baseWpConfig = require('./webpack.base.config');
+const TerserPlugin = require('terser-webpack-plugin');
+const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const CleanWebpackPlugin = require('clean-webpack-plugin');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const CopyWebpackPlugin = require('copy-webpack-plugin');
+
+const publicDir = path.resolve(__dirname, '../dist/tmp/public');
+const clientDir = path.resolve(__dirname, '../client');
+
+module.exports = merge(baseWpConfig, {
+    mode: 'production',
+    output: {
+        path: `${publicDir}/app_new`,
+        filename: 'bundle.[contenthash].js'
+    },
+    module: {
+        rules: [
+            {
+                test: /\.css$/,
+                use: [
+                  MiniCssExtractPlugin.loader,
+                  'css-loader'
+                ]
+            }
+        ]
+    },
+    optimization: {
+        minimizer: [
+            new TerserPlugin(),
+            new OptimizeCSSAssetsPlugin()
+        ]
+    },
+    plugins: [
+        new CleanWebpackPlugin([publicDir], {root: path.resolve(__dirname, '..')}),
+        new MiniCssExtractPlugin({
+            filename: "[name].[contenthash].css"
+        }),
+        new HtmlWebpackPlugin({
+            template: `${clientDir}/index.html.template`,
+            filename: `${publicDir}/index.html`
+        }),
+        new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
+    ]
+});

+ 64 - 0
build/win.js

@@ -0,0 +1,64 @@
+const fs = require('fs-extra');
+const path = require('path');
+const util = require('util');
+const stream = require('stream');
+const pipeline = util.promisify(stream.pipeline);
+
+const got = require('got');
+const decompress = require('decompress');
+const decompressTargz = require('decompress-targz');
+
+const distDir = path.resolve(__dirname, '../dist');
+const publicDir = `${distDir}/tmp/public`;
+const outDir = `${distDir}/win`;
+
+const tempDownloadDir = `${distDir}/tmp/download`;
+
+async function main() {
+    await fs.emptyDir(outDir);
+    // перемещаем public на место
+    if (await fs.pathExists(publicDir))
+        await fs.move(publicDir, `${outDir}/public`);
+
+    await fs.ensureDir(tempDownloadDir);
+
+    //sqlite3
+    const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.0.4/node-v64-win32-x64.tar.gz';
+    const sqliteDecompressedFilename = `${tempDownloadDir}/node-v64-win32-x64/node_sqlite3.node`;
+
+    if (!await fs.pathExists(sqliteDecompressedFilename)) {
+        // Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
+        await pipeline(got.stream(sqliteRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
+        console.log(`done downloading ${sqliteRemoteUrl}`);
+
+        //распаковываем
+        await decompress(`${tempDownloadDir}/sqlite.tar.gz`, `${tempDownloadDir}`, {
+            plugins: [
+                decompressTargz()
+            ]
+        });
+        console.log('files decompressed');
+    }
+    // копируем в дистрибутив
+    await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
+    console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
+
+    //ipfs
+    const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs.exe`;
+    if (!await fs.pathExists(ipfsDecompressedFilename)) {
+        // Скачиваем ipfs
+        const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_windows-amd64.zip';
+
+        await pipeline(got.stream(ipfsRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/ipfs.zip`));
+        console.log(`done downloading ${ipfsRemoteUrl}`);
+
+        //распаковываем
+        await decompress(`${tempDownloadDir}/ipfs.zip`, `${tempDownloadDir}`);
+        console.log('files decompressed');
+    }
+    // копируем в дистрибутив
+    await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs.exe`);
+    console.log(`copied ${ipfsDecompressedFilename} to ${outDir}/ipfs.exe`);
+}
+
+main();

+ 14 - 0
client/api/misc.js

@@ -0,0 +1,14 @@
+import axios from 'axios';
+
+const api = axios.create({
+  baseURL: '/api'
+});
+
+class Misc {
+    async loadConfig() {
+        const response = await api.post('/config', {params: ['name', 'version', 'mode']});
+        return response.data;
+    }
+}
+
+export default new Misc();

+ 109 - 0
client/api/reader.js

@@ -0,0 +1,109 @@
+import axios from 'axios';
+import {sleep} from '../share/utils';
+
+const maxFileUploadSize = 50*1024*1024;
+const api = axios.create({
+  baseURL: '/api/reader'
+});
+
+const workerApi = axios.create({
+  baseURL: '/api/worker'
+});
+
+class Reader {
+    async loadBook(url, callback) {
+        const refreshPause = 200;
+        if (!callback) callback = () => {};
+
+        let response = await api.post('/load-book', {type: 'url', url});
+
+        const workerId = response.data.workerId;
+        if (!workerId)
+            throw new Error('Неверный ответ api');
+
+        callback({totalSteps: 4});
+
+        let i = 0;
+        while (1) {// eslint-disable-line no-constant-condition
+            callback(response.data);
+
+            if (response.data.state == 'finish') {//воркер закончил работу, можно скачивать кешированный на сервере файл
+                callback({step: 4});
+                const book = await this.loadCachedBook(response.data.path, callback);
+                return Object.assign({}, response.data, {data: book.data});
+            }
+            if (response.data.state == 'error') {
+                let errMes = response.data.error;
+                if (errMes.indexOf('getaddrinfo') >= 0 || 
+                    errMes.indexOf('ECONNRESET') >= 0 ||
+                    errMes.indexOf('EINVAL') >= 0 ||
+                    errMes.indexOf('404') >= 0)
+                    errMes = `Ресурс не найден по адресу: ${response.data.url}`;
+                throw new Error(errMes);
+            }
+            if (i > 0)
+                await sleep(refreshPause);
+
+            i++;
+            if (i > 30*1000/refreshPause) {//30 сек ждем телодвижений воркера
+                throw new Error('Слишком долгое время ожидания');
+            }
+            //проверка воркера
+            const prevProgress = response.data.progress;
+            response = await workerApi.post('/get-state', {workerId});
+            i = (prevProgress != response.data.progress ? 1 : i);
+        }
+    }
+
+    async loadCachedBook(url, callback){
+        const response = await axios.head(url);
+
+        let estSize = 1000000;
+        if (response.headers['content-length']) {
+            estSize = response.headers['content-length'];
+        }
+
+        const options = {
+            onDownloadProgress: progress => {
+                while (progress.loaded > estSize) estSize *= 1.5;
+
+                if (callback)
+                    callback({state: 'loading', progress: Math.round((progress.loaded*100)/estSize)});
+            }
+        }
+        //загрузка
+        return await axios.get(url, options);
+    }
+
+    async uploadFile(file, callback) {
+        if (file.size > maxFileUploadSize)
+            throw new Error(`Размер файла превышает ${maxFileUploadSize} байт`);
+
+        let formData = new FormData();
+        formData.append('file', file);
+
+        const options = {
+            headers: {
+              'Content-Type': 'multipart/form-data'
+            },
+            onUploadProgress: progress => {
+                const total = (progress.total ? progress.total : progress.loaded + 200000);
+                if (callback)
+                    callback({state: 'upload', progress: Math.round((progress.loaded*100)/total)});
+            }
+
+        };
+
+        let response = await api.post('/upload-file', formData, options);
+        if (response.data.state == 'error')
+            throw new Error(response.data.error);
+
+        const url = response.data.url;
+        if (!url) 
+            throw new Error('Неверный ответ api');
+
+        return url;
+    }
+}
+
+export default new Reader();

BIN
client/assets/apple-touch-icon-precomposed.png


BIN
client/assets/apple-touch-icon.png


BIN
client/assets/favicon.ico


+ 328 - 0
client/components/App.vue

@@ -0,0 +1,328 @@
+<template>
+    <el-container>
+        <el-aside v-if="showAsideBar" :width="asideWidth">
+            <div class="app-name"><span v-html="appName"></span></div>
+            <el-button class="el-button-collapse" @click="toggleCollapse" :icon="buttonCollapseIcon"></el-button>
+            <el-menu class="el-menu-vertical" :default-active="rootRoute" :collapse="isCollapse" router>
+              <el-menu-item index="/cardindex">
+                <i class="el-icon-search"></i>
+                <span :class="itemTitleClass('/cardindex')" slot="title">{{ this.itemRuText['/cardindex'] }}</span>
+              </el-menu-item>
+              <el-menu-item index="/reader">
+                <i class="el-icon-tickets"></i>
+                <span :class="itemTitleClass('/reader')" slot="title">{{ this.itemRuText['/reader'] }}</span>
+              </el-menu-item>
+              <el-menu-item index="/forum" disabled>
+                <i class="el-icon-message"></i>
+                <span :class="itemTitleClass('/forum')" slot="title">{{ this.itemRuText['/forum'] }}</span>
+              </el-menu-item>
+              <el-menu-item index="/income">
+                <i class="el-icon-upload"></i>
+                <span :class="itemTitleClass('/income')" slot="title">{{ this.itemRuText['/income'] }}</span>
+              </el-menu-item>
+              <el-menu-item index="/sources">
+                <i class="el-icon-menu"></i>
+                <span :class="itemTitleClass('/sources')" slot="title">{{ this.itemRuText['/sources'] }}</span>
+              </el-menu-item>
+              <el-menu-item index="/settings">
+                <i class="el-icon-setting"></i>
+                <span :class="itemTitleClass('/settings')" slot="title">{{ this.itemRuText['/settings'] }}</span>
+              </el-menu-item>
+              <el-menu-item index="/help">
+                <i class="el-icon-question"></i>
+                <span :class="itemTitleClass('/help')" slot="title">{{ this.itemRuText['/help'] }}</span>
+              </el-menu-item>
+            </el-menu>
+        </el-aside>
+
+        <el-main v-if="showMain" :style="{padding: (isReaderActive ? 0 : '5px')}">
+            <keep-alive>
+                <router-view></router-view>
+            </keep-alive>
+        </el-main>
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+    watch: {
+        rootRoute: function() {
+            this.setAppTitle();
+            this.redirectIfNeeded();
+        },
+        mode: function() {
+            this.redirectIfNeeded();
+        }
+    },
+
+})
+class App extends Vue {
+    itemRuText = {
+        '/cardindex': 'Картотека',
+        '/reader': 'Читалка',
+        '/forum': 'Форум-чат',
+        '/income': 'Поступления',
+        '/sources': 'Источники',
+        '/settings': 'Параметры',
+        '/help': 'Справка',
+    }
+    created() {
+        this.commit = this.$store.commit;
+        this.dispatch = this.$store.dispatch;
+        this.state = this.$store.state;
+        this.uistate = this.$store.state.uistate;
+        this.config = this.$store.state.config;
+
+        // set-app-title
+        this.$root.$on('set-app-title', this.setAppTitle);
+
+        //global keyHooks
+        this.keyHooks = [];
+        this.keyHook = (event) => {
+            for (const hook of this.keyHooks)
+                hook(event);
+        }
+
+        this.$root.addKeyHook = (hook) => {
+            if (this.keyHooks.indexOf(hook) < 0)
+                this.keyHooks.push(hook);
+        }
+
+        this.$root.removeKeyHook = (hook) => {
+            const i = this.keyHooks.indexOf(hook);
+            if (i >= 0)
+                this.keyHooks.splice(i, 1);
+        }
+
+        document.addEventListener('keyup', (event) => {
+            this.keyHook(event);
+        });        
+        document.addEventListener('keydown', (event) => {
+            this.keyHook(event);
+        });        
+        window.addEventListener('resize', () => {
+            this.$root.$emit('resize');
+        });
+    }
+
+    mounted() {
+        this.dispatch('config/loadConfig');
+        this.$watch('apiError', function(newError) {
+            if (newError) {
+                this.$notify.error({
+                    title: 'Ошибка API',
+                    dangerouslyUseHTMLString: true,
+                    message: newError.response.config.url + '<br>' + newError.response.statusText
+                });
+            }
+        });
+    }
+
+    toggleCollapse() {
+        this.commit('uistate/setAsideBarCollapse', !this.uistate.asideBarCollapse);
+        this.$root.$emit('resize');
+    }
+
+    get isCollapse() {
+        return this.uistate.asideBarCollapse;
+    }
+
+    get asideWidth() {
+        if (this.uistate.asideBarCollapse) {
+            return '64px';
+        } else {
+            return '170px';
+        }
+    }
+
+    get buttonCollapseIcon() {
+        if (this.uistate.asideBarCollapse) {
+            return 'el-icon-d-arrow-right';
+        } else {
+            return 'el-icon-d-arrow-left';
+        }
+    }
+
+    get appName() {
+        if (this.isCollapse)
+            return '<br><br>';
+        else
+            return `${this.config.name} <br>v${this.config.version}`;
+    }
+
+    get apiError() {
+        return this.state.apiError;
+    }
+
+    get rootRoute() {
+        const m = this.$route.path.match(/^(\/[^/]*).*$/i);
+        this.$root.rootRoute = (m ? m[1] : this.$route.path);
+
+        return this.$root.rootRoute;
+    }
+
+    setAppTitle(title) {
+        if (!title) {
+            if (this.mode == 'omnireader') {
+                document.title = `Omni Reader - всегда с вами`;
+            } else if (this.config && this.mode !== null) {
+                document.title = `${this.config.name} - ${this.itemRuText[this.$root.rootRoute]}`;
+            }
+        } else {
+            document.title = title;
+        }
+    }
+
+    itemTitleClass(path) {
+        return (this.rootRoute == path ? {'bold-font': true} : {});
+    }
+
+    get mode() {
+        return this.$store.state.config.mode;
+    }
+
+    get showAsideBar() {
+        return (this.mode !== null && this.mode != 'reader' && this.mode != 'omnireader');
+    }
+
+    get isReaderActive() {
+        return this.rootRoute == '/reader';
+    }
+
+    get showMain() {
+        return (this.showAsideBar || this.isReaderActive);
+    }
+
+    redirectIfNeeded() {
+        if ((this.mode == 'reader' || this.mode == 'omnireader') && (this.rootRoute != '/reader')) {
+            //старый url
+            const search = window.location.search.substr(1);
+            const url = search.split('url=')[1] || '';
+            if (url) {
+                window.location = `/#/reader?url=${url}`;
+            } else {
+                this.$router.replace('/reader');
+            }
+        }
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.app-name {
+    margin-left: 10px;
+    margin-top: 10px;
+    text-align: center;
+    line-height: 140%;
+    font-weight: bold;
+}
+
+.bold-font {
+    font-weight: bold;
+}
+
+.el-container {
+    height: 100%;
+}
+
+.el-aside {
+    line-height: 1;
+    background-color: #ccc;
+    color: #000;
+}
+
+.el-main {
+    padding: 0;
+    background-color: #E6EDF4;
+    color: #000;
+}
+
+.el-menu-vertical:not(.el-menu--collapse) {
+    background-color: inherit;
+    color: inherit;
+    text-align: left;
+    width: 100%;
+    border: 0;
+}
+
+.el-menu--collapse {
+    background-color: inherit;
+    color: inherit;
+    border: 0;
+}
+
+.el-button-collapse, .el-button-collapse:focus, .el-button-collapse:active, .el-button-collapse:hover {
+    background-color: inherit;
+    color: inherit;
+    margin-top: 5px;
+    width: 100%;
+    height: 64px;
+    border: 0;
+}
+.el-menu-item {
+    font-size: 85%;
+}
+</style>
+
+<style>
+body, html, #app {
+    margin: 0;
+    padding: 0;
+    height: 100%;
+    font: normal 12pt ReaderDefault;
+}
+
+.el-tabs__content {
+    flex: 1;
+    padding: 0 !important;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+}
+
+@font-face {
+  font-family: 'ReaderDefault';
+  src: url('fonts/reader-default.woff') format('woff'),
+       url('fonts/reader-default.ttf') format('truetype');
+}
+
+@font-face {
+  font-family: 'OpenSans';
+  src: url('fonts/open-sans.woff') format('woff'),
+       url('fonts/open-sans.ttf') format('truetype');
+}
+
+@font-face {
+  font-family: 'Roboto';
+  src: url('fonts/roboto.woff') format('woff'),
+       url('fonts/roboto.ttf') format('truetype');
+}
+
+@font-face {
+  font-family: 'Rubik';
+  src: url('fonts/rubik.woff2') format('woff2');
+}
+
+@font-face {
+  font-family: 'Avrile';
+  src: url('fonts/avrile.woff') format('woff'),
+       url('fonts/avrile.ttf') format('truetype');
+}
+
+@font-face {
+  font-family: 'Arimo';
+  src: url('fonts/arimo.woff2') format('woff2');
+}
+
+@font-face {
+  font-family: 'GEO_1';
+  src: url('fonts/geo_1.woff') format('woff'),
+       url('fonts/geo_1.ttf') format('truetype');
+}
+
+</style>

+ 20 - 0
client/components/CardIndex/Book/Book.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел Book в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Book extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 20 - 0
client/components/CardIndex/Card/Card.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел Card в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Card extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 76 - 0
client/components/CardIndex/CardIndex.vue

@@ -0,0 +1,76 @@
+<template>
+    <el-container direction="vertical">
+        <el-tabs type="border-card" style="height: 100%;" v-model="selectedTab">
+            <el-tab-pane label="Поиск"></el-tab-pane>
+            <el-tab-pane label="Автор"></el-tab-pane>
+            <el-tab-pane label="Книга"></el-tab-pane>
+            <el-tab-pane label="История"></el-tab-pane>
+            <keep-alive>
+                <router-view></router-view>
+            </keep-alive>
+        </el-tabs>
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import _ from 'lodash';
+
+const rootRoute = '/cardindex';
+const tab2Route = [
+    '/cardindex/search',
+    '/cardindex/card',
+    '/cardindex/book',
+    '/cardindex/history',
+];
+let lastActiveTab = null;
+
+export default @Component({
+    watch: {
+        selectedTab: function(newValue, oldValue) {
+            lastActiveTab = newValue;
+            this.setRouteByTab(newValue);
+        },
+        curRoute: function(newValue, oldValue) {
+            this.setTabByRoute(newValue);
+        },
+    },
+})
+class CardIndex extends Vue {
+    selectedTab = null;
+
+    mounted() {
+        this.setTabByRoute(this.curRoute);
+    }
+
+    setTabByRoute(route) {
+        const t = _.indexOf(tab2Route, route);
+        if (t >= 0) {
+            if (t !== this.selectedTab)
+                this.selectedTab = t.toString();
+        } else {
+            if (route == rootRoute && lastActiveTab !== null)
+                this.setRouteByTab(lastActiveTab);
+        }
+    }
+
+    setRouteByTab(tab) {
+        const t = Number(tab);
+        if (tab2Route[t] !== this.curRoute) {
+            this.$router.replace(tab2Route[t]);
+        }
+    }
+
+    get curRoute() {
+        const m = this.$route.path.match(/^(\/[^\/]*\/[^\/]*).*$/i);
+        return (m ? m[1] : this.$route.path);
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+</style>

+ 20 - 0
client/components/CardIndex/History/History.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел History в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class History extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 20 - 0
client/components/CardIndex/Search/Search.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел Search в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Search extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 20 - 0
client/components/Help/Help.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел Help в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Help extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 20 - 0
client/components/Income/Income.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел Income в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Income extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 20 - 0
client/components/NotFound404/NotFound404.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Страница не найдена
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class NotFound404 extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 80 - 0
client/components/Reader/ClickMapPage/ClickMapPage.vue

@@ -0,0 +1,80 @@
+<template>
+    <div ref="page" class="map-page">
+        <div class="content" v-html="mapHtml"></div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+import {sleep} from '../../../share/utils';
+import {clickMap, clickMapText} from '../share/clickMap';
+
+export default @Component({
+})
+class ClickMapPage extends Vue {
+    fontSize = '200%';
+
+    created() {
+    }
+
+    get mapHtml() {
+        let result = '<div style="flex: 1; display: flex;">';
+
+        let px = 0;
+        for (const x in clickMap) {
+            let div = `<div style="display: flex; flex-direction: column; width: ${x - px}%;">`;
+
+            let py = 0;
+            for (const y in clickMap[x]) {
+                const text = clickMapText[clickMap[x][y]].split(' ');
+                let divText = '';
+                for (const t of text)
+                    divText += `<span>${t}</span>`;
+                div += `<div style="display: flex; flex-direction: column; justify-content: center; align-items: center; ` +
+                    `height: ${y - py}%; border: 1px solid white; font-size: ${this.fontSize}; line-height: 100%;">${divText}</div>`;
+                py = y;
+            }
+
+            div += '</div>';
+            px = x;
+            result += div;
+        }
+
+        result += '</div>';
+        return result;
+    }
+
+    async slowDisappear() {
+        const page = this.$refs.page;
+        page.style.animation = 'click-map-disappear 5s ease-in 1';
+        await sleep(5000);
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.map-page {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 19;
+    background-color: rgba(0, 0, 0, 0.9);
+    color: white;
+    display: flex;
+}
+
+.content {
+    flex: 1;
+    display: flex;
+}
+</style>
+<style>
+@keyframes click-map-disappear {
+    0%   { opacity: 0.9; }
+    100% { opacity: 0; }
+}
+</style>

+ 148 - 0
client/components/Reader/CopyTextPage/CopyTextPage.vue

@@ -0,0 +1,148 @@
+<template>
+    <div ref="main" class="main" @click="close">
+        <div class="mainWindow" @click.stop>
+            <Window @close="close">
+                <template slot="header">
+                    Скопировать текст
+                </template>
+
+                <div ref="text" class="text" tabindex="-1">
+                    <div v-html="text"></div>
+                </div>
+            </Window>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+import Window from '../../share/Window.vue';
+import {sleep} from '../../../share/utils';
+
+export default @Component({
+    components: {
+        Window,
+    },
+})
+class CopyTextPage extends Vue {
+    text = null;
+    initStep = null;
+    initPercentage = 0;
+
+    created() {
+        this.commit = this.$store.commit;
+        this.reader = this.$store.state.reader;
+    }
+
+    async init(bookPos, parsed, copyFullText) {
+        this.text = 'Загрузка';
+        await this.$nextTick();
+
+        const paraIndex = parsed.findParaIndex(bookPos || 0);
+        this.initStep = true;
+        this.stopInit = false;
+
+        let nextPerc = 0;
+        let text = '';
+        let cut = '';
+        let from = 0;
+        let to = parsed.para.length;
+        if (!copyFullText) {
+            from = paraIndex - 100;
+            from = (from < 0 ? 0 : from);
+            to = paraIndex + 100;
+            to = (to > parsed.para.length ? parsed.para.length : to);
+            cut = '<p>..... Текст вырезан. Если хотите скопировать больше, поставьте в настройках галочку "Загружать весь текст"';
+        }
+
+        if (from > 0)
+            text += cut;
+        for (let i = from; i < to; i++) {
+            const p = parsed.para[i];
+            const parts = parsed.splitToStyle(p.text);
+            if (this.stopInit)
+                return;
+
+            text += `<p id="p${i}" class="copyPara">`;
+            for (const part of parts)
+                text += part.text;
+
+            const perc = Math.round(i/parsed.para.length*100);
+
+            if (perc > nextPerc) {
+                this.initPercentage = perc;
+                await sleep(1);
+                nextPerc = perc + 10;
+            }
+        }
+        if (to < parsed.para.length)
+            text += cut;
+
+        this.text = text;
+        this.initStep = false;
+
+        await this.$nextTick();
+        this.$refs.text.focus();
+
+        const p = document.getElementById('p' + paraIndex);
+        if (p) {
+            this.$refs.text.scrollTop = p.offsetTop;
+        }
+    }
+
+    close() {
+        this.stopInit = true;
+        this.$emit('copy-text-toggle');
+    }
+
+    keyHook(event) {
+        if (event.type == 'keydown' && (event.code == 'Escape')) {
+            this.close();
+        }
+        return true;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.main {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 40;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+}
+
+.mainWindow {
+    width: 100%;
+    height: 100%;
+    display: flex;
+}
+
+.text {
+    flex: 1;
+    overflow-wrap: anywhere;
+    overflow-y: auto;
+    padding: 0 10px 0 10px;
+    position: relative;
+    font-size: 120%;
+}
+
+.text:focus {
+    outline: none;
+}
+</style>
+<style>
+.copyPara {
+    margin: 0;
+    padding: 0;
+    text-indent: 30px;
+}
+</style>

+ 66 - 0
client/components/Reader/HelpPage/CommonHelpPage/CommonHelpPage.vue

@@ -0,0 +1,66 @@
+<template>
+    <div class="page">
+        <h4>Возможности читалки:</h4>
+        <ul>
+            <li>загрузка любой страницы интернета</li>
+            <li>изменение цвета фона, текста, размер и тип шрифта и прочее</li>
+            <li>установка и запоминание текущей позиции и настроек в браузере (в будущем планируется сохранение и на сервер)</li>
+            <li>кэширование файлов книг на клиенте и на сервере</li>
+            <li>открытие книг с локального диска</li>
+            <li>плавный скроллинг текста</li>
+            <li>анимация перелистывания (скоро)</li>
+            <li>поиск по тексту и копирование фрагмента</li>
+            <li>запоминание недавних книг, скачивание книги из читалки в формате fb2</li>
+            <li>управление кликом и с клавиатуры</li>
+            <li>подключение к интернету не обязательно для чтения книги после ее загрузки</li>
+            <li>регистрация не требуется</li>
+            <li>поддерживаемые браузеры: Google Chrome, Mozilla Firefox последних версий</li>
+        </ul>
+
+        <p>В качестве URL можно задавать html-страничку с книгой, либо прямую ссылку 
+        на файл из онлайн-библиотеки (например, скопировав адрес ссылки или кнопки "скачать fb2").</p>
+        <p>Поддерживаемые форматы: <strong>html, txt, fb2, fb2.zip</strong></p>
+
+        <div v-html="automationHtml"></div>
+        <p>Связаться с разработчиком: <a href="mailto:bookpauk@gmail.com">bookpauk@gmail.com</a></p>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class CommonHelpPage extends Vue {
+    created() {
+        this.config = this.$store.state.config;
+    }
+
+    get automationHtml() {
+        if (this.config.mode == 'omnireader') {
+            return `<p>Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
+                    <br><strong>javascript:location.href='http://omnireader.ru/?url='+location.href;</strong>
+                    <br>Тогда, нажав на получившуюся кнопку на любой странице интернета, вы автоматически откроете ее в Omni Reader.</p>`;
+        } else {
+            return '';
+        }
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.page {
+    flex: 1;
+    padding: 15px;
+    overflow-y: auto;
+    font-size: 120%;
+    line-height: 130%;
+}
+
+h4 {
+    margin: 0;
+}
+</style>

+ 110 - 0
client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue

@@ -0,0 +1,110 @@
+<template>
+    <div class="page">
+        <div class="box">
+            <p class="p">Проект существует исключительно на личном энтузиазме.</p>
+            <p class="p">Чтобы энтузиазма было побольше, вы можете пожертвовать на развитие проекта любую сумму:</p>
+            <div class="address">
+                <img class="logo" src="./assets/yandex.png">
+                <el-button class="button" @click="donateYandexMoney">Пожертвовать</el-button><br>
+                <div class="para">{{ yandexAddress }}</div>
+            </div>
+
+            <div class="address">                
+                <img class="logo" src="./assets/bitcoin.png">
+                <el-button class="button" @click="copyAddress(bitcoinAddress, 'Bitcoin')">Скопировать</el-button><br>
+                <div class="para">{{ bitcoinAddress }}</div>
+            </div>
+
+            <div class="address">                
+                <img class="logo" src="./assets/litecoin.png">
+                <el-button class="button" @click="copyAddress(litecoinAddress, 'Litecoin')">Скопировать</el-button><br>
+                <div class="para">{{ litecoinAddress }}</div>
+            </div>
+
+            <div class="address">                
+                <img class="logo" src="./assets/monero.png">
+                <el-button class="button" @click="copyAddress(moneroAddress, 'Monero')">Скопировать</el-button><br>
+                <div class="para">{{ moneroAddress }}</div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import {copyTextToClipboard} from '../../../../share/utils';
+
+export default @Component({
+})
+class DonateHelpPage extends Vue {
+    yandexAddress = '410018702323056';
+    bitcoinAddress = '3EbgZ7MK1UVaN38Gty5DCBtS4PknM4Ut85';
+    litecoinAddress = 'MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ';
+    moneroAddress = '8BQPnvHcPSHM5gMQsmuypDgx9NNsYqwXKfDDuswEyF2Q2ewQSfd2pkK6ydH2wmMyq2JViZvy9DQ35hLMx7g72mFWNJTPtnz';
+
+    created() {
+    }
+
+    donateYandexMoney() {
+        window.open(`https://money.yandex.ru/to/${this.yandexAddress}`, '_blank');
+    }
+
+    async copyAddress(address, prefix) {
+        const result = await copyTextToClipboard(address);
+        const msg = (result ? `${prefix}-адрес ${address} успешно скопирован в буфер обмена` : 'Копирование не удалось');
+        if (result)
+            this.$notify.success({message: msg});
+        else
+            this.$notify.error({message: msg});
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.page {
+    flex: 1;
+    padding: 15px;
+    overflow-y: auto;
+    font-size: 120%;
+    line-height: 130%;
+    display: flex;
+}
+
+.p {
+    margin: 0;
+    padding: 0;
+    text-indent: 20px;
+}
+
+.box {
+    flex: 1;
+    max-width: 550px;
+    overflow-wrap: break-word;
+}
+
+h5 {
+    margin: 0;
+}
+
+.address {
+    padding-top: 10px;
+    margin-top: 20px;
+}
+
+.para {
+    margin: 10px 10px 10px 40px;
+}
+
+.button {
+    margin-left: 10px;
+}
+
+.logo {
+    width: 130px;
+    position: relative;
+    top: 10px;
+}
+</style>

BIN
client/components/Reader/HelpPage/DonateHelpPage/assets/bitcoin.png


BIN
client/components/Reader/HelpPage/DonateHelpPage/assets/litecoin.png


BIN
client/components/Reader/HelpPage/DonateHelpPage/assets/monero.png


BIN
client/components/Reader/HelpPage/DonateHelpPage/assets/yandex.png


+ 100 - 0
client/components/Reader/HelpPage/HelpPage.vue

@@ -0,0 +1,100 @@
+<template>
+    <div ref="main" class="main" @click="close">
+        <div class="mainWindow" @click.stop>
+            <Window @close="close">
+                <template slot="header">
+                    Справка
+                </template>
+
+                <el-tabs type="border-card" v-model="selectedTab">
+                    <el-tab-pane class="tab" label="Общее">
+                        <CommonHelpPage></CommonHelpPage>
+                    </el-tab-pane>
+                    <el-tab-pane label="Клавиатура">
+                        <HotkeysHelpPage></HotkeysHelpPage>
+                    </el-tab-pane>
+                    <el-tab-pane label="Мышь/тачпад">
+                        <MouseHelpPage></MouseHelpPage>
+                    </el-tab-pane>
+                    <el-tab-pane label="Помочь проекту" name="donate">
+                        <DonateHelpPage></DonateHelpPage>
+                    </el-tab-pane>
+
+                </el-tabs>
+            </Window>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+import Window from '../../share/Window.vue';
+import CommonHelpPage from './CommonHelpPage/CommonHelpPage.vue';
+import HotkeysHelpPage from './HotkeysHelpPage/HotkeysHelpPage.vue';
+import MouseHelpPage from './MouseHelpPage/MouseHelpPage.vue';
+import DonateHelpPage from './DonateHelpPage/DonateHelpPage.vue';
+
+export default @Component({
+    components: {
+        Window,
+        CommonHelpPage,
+        HotkeysHelpPage,
+        MouseHelpPage,
+        DonateHelpPage,
+    },
+})
+class HelpPage extends Vue {
+    selectedTab = null;
+
+    close() {
+        this.$emit('help-toggle');
+    }
+
+    activateDonateHelpPage() {
+        this.selectedTab = 'donate';
+    }
+
+    keyHook(event) {
+        if (event.type == 'keydown' && (event.code == 'Escape')) {
+            this.close();
+        }
+        return true;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.main {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 40;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+}
+
+.mainWindow {
+    width: 100%;
+    height: 100%;
+    display: flex;
+}
+
+.el-tabs {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+}
+
+.el-tab-pane {
+    flex: 1;
+    display: flex;
+    overflow: hidden;
+}
+</style>

+ 54 - 0
client/components/Reader/HelpPage/HotkeysHelpPage/HotkeysHelpPage.vue

@@ -0,0 +1,54 @@
+<template>
+    <div class="page">
+        <h4>Управление с помощью горячих клавиш:</h4>
+        <ul>
+            <li><b>F1, H</b> - открыть справку</li>
+            <li><b>Escape</b> - показать/скрыть страницу загрузки</li>
+            <li><b>Tab</b> - показать/скрыть панель управления</li>
+            <li><b>PageUp, Left, Shift+Space, Backspace</b> - страницу назад</li>
+            <li><b>PageDown, Right, Space</b> - страницу вперед</li>
+            <li><b>Home</b> - в начало книги</li>
+            <li><b>End</b> - в конец книги</li>
+            <li><b>Up</b> - строчку назад</li>
+            <li><b>Down</b> - строчку вперёд</li>
+            <li><b>A, Shift+A</b> - изменить размер шрифта</li>
+            <li><b>Enter, F, F11, ` (апостроф)</b> - вкл./выкл. полный экран</li>
+            <li><b>Z</b> - вкл./выкл. плавный скроллинг текста</li>
+            <li><b>Shift+Down/Shift+Up</b> - увеличить/уменьшить скорость скроллинга
+            <li><b>P</b> - установить страницу</li>
+            <li><b>Ctrl+F</b> - найти в тексте</li>            
+            <li><b>Ctrl+C</b> - скопировать текст со страницы</li>            
+            <li><b>R</b> - принудительно обновить книгу в обход кэша</li>
+            <li><b>X</b> - открыть недавние</li>
+            <li><b>S</b> - открыть окно настроек</li>
+        </ul>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class HotkeysHelpPage extends Vue {
+    created() {
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.page {
+    flex: 1;
+    padding: 15px;
+    overflow-y: auto;
+    font-size: 120%;
+    line-height: 130%;
+}
+
+h4 {
+    margin: 0;
+}
+</style>

+ 58 - 0
client/components/Reader/HelpPage/MouseHelpPage/MouseHelpPage.vue

@@ -0,0 +1,58 @@
+<template>
+    <div class="page">
+        <h4>Управление с помощью мыши/тачпада:</h4>
+        <ul>
+            <li><b>ЛКМ/ТАЧ</b> по экрану в одну из областей - активация действия:</li>
+                <div class="click-map-page">
+                    <ClickMapPage ref="clickMapPage"></ClickMapPage>
+                </div>
+            <li><b>ПКМ</b> - показать/скрыть панель управления</li>
+            <li><b>СКМ</b> - вкл./выкл. плавный скроллинг текста</li>
+        </ul>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+import ClickMapPage from '../../ClickMapPage/ClickMapPage.vue';
+
+export default @Component({
+    components: {
+        ClickMapPage,
+    },
+})
+class MouseHelpPage extends Vue {
+    created() {
+    }
+
+    mounted() {
+        this.$refs.clickMapPage.$el.style.fontSize = '50%';
+        this.$refs.clickMapPage.$el.style.backgroundColor = '#478355';
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.page {
+    flex: 1;
+    padding: 15px;
+    overflow-y: auto;
+    font-size: 120%;
+    line-height: 130%;
+}
+
+h4 {
+    margin: 0;
+}
+
+.click-map-page {
+    position: relative;
+    width: 400px;
+    height: 400px;
+    margin: 10px 0 10px 0;
+}
+</style>

+ 251 - 0
client/components/Reader/HistoryPage/HistoryPage.vue

@@ -0,0 +1,251 @@
+<template>
+    <div ref="main" class="main" @click="close">
+        <div class="mainWindow" @click.stop>
+            <Window @close="close">
+                <template slot="header">
+                    Последние 100 открытых книг
+                </template>
+
+                <el-table
+                    :data="tableData"
+                    style="width: 100%"
+                    size="mini"
+                    height="1px"
+                    stripe
+                    border
+                    :default-sort = "{prop: 'touchDateTime', order: 'descending'}"
+                    :header-cell-style = "headerCellStyle"
+                    >
+
+                    <el-table-column
+                        prop="touchDateTime"
+                        min-width="90px"
+                        sortable
+                        >
+                        <template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
+                            <span style="font-size: 90%">Время<br>просм.</span>
+                        </template>
+                        <template slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
+                            <div class="desc" @click="loadBook(scope.row.url)">
+                                {{ scope.row.touchDate }}<br>
+                                {{ scope.row.touchTime }}
+                            </div>
+                        </template>
+                    </el-table-column>
+
+                    <el-table-column
+                        >
+                        <template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
+                            <!--el-input ref="input"
+                                :value="search" @input="search = $event"
+                                size="mini"
+                                style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 10px"
+                                placeholder="Найти"/-->
+                                <div class="el-input el-input--mini">
+                                    <input class="el-input__inner"
+                                        placeholder="Найти"
+                                        style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 20px; padding: 0 10px 0 10px"
+                                        :value="search" @input="search = $event.target.value"
+                                    />
+                                </div>
+                        </template>
+
+                        <el-table-column
+                            min-width="300px"
+                            >
+                            <template slot-scope="scope">
+                                <div class="desc" @click="loadBook(scope.row.url)">
+                                    <span style="color: green">{{ scope.row.desc.author }}</span><br>
+                                    <span>{{ scope.row.desc.title }}</span>
+                                </div>
+                            </template>
+                        </el-table-column>
+
+                        <el-table-column
+                            min-width="100px"
+                            >
+                            <template slot-scope="scope">
+                                <a v-show="isUrl(scope.row.url)" :href="scope.row.url" target="_blank">Оригинал</a><br>
+                                <a :href="scope.row.path" :download="getFileNameFromPath(scope.row.path)">Скачать FB2</a>
+                            </template>
+                        </el-table-column>
+
+                        <el-table-column
+                            width="60px"
+                            >
+                            <template slot-scope="scope">
+                                <el-button
+                                    size="mini"
+                                    style="width: 30px; padding: 7px 0 7px 0; margin-left: 4px"
+                                    @click="handleDel(scope.row.key)"><i class="el-icon-close"></i>
+                                </el-button>
+                            </template>
+                        </el-table-column>
+
+                    </el-table-column>
+
+                </el-table>
+            </Window>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import path from 'path';
+import _ from 'lodash';
+
+import {formatDate} from '../../../share/utils';
+import Window from '../../share/Window.vue';
+import bookManager from '../share/bookManager';
+
+export default @Component({
+    components: {
+        Window,
+    },
+    watch: {
+        search: function() {
+            this.updateTableData();
+        }
+    },
+})
+class HistoryPage extends Vue {
+    search = null;
+    tableData = null;
+
+    created() {
+    }
+
+    mounted() {
+        this.updateTableData();
+        this.mostRecentBook = bookManager.mostRecentBook();
+    }
+
+    updateTableData() {
+        let result = [];
+
+        for (let bookKey in bookManager.recent) {
+            const book = bookManager.recent[bookKey];
+            let d = new Date();
+            d.setTime(book.touchTime);
+            const t = formatDate(d).split(' ');
+
+            let perc = '';
+            let textLen = '';
+            const p = (book.bookPosSeen ? book.bookPosSeen : (book.bookPos ? book.bookPos : 0));
+            if (book.textLength) {
+                perc = ` [${((p/book.textLength)*100).toFixed(2)}%]`;
+                textLen = ` ${Math.round(book.textLength/1000)}k`;
+            }
+
+            const fb2 = (book.fb2 ? book.fb2 : {});
+            result.push({
+                touchDateTime: book.touchTime,
+                touchDate: t[0],
+                touchTime: t[1],
+                desc: {
+                    title: `"${fb2.bookTitle}"${perc}${textLen}`,
+                    author: _.compact([
+                        fb2.lastName,
+                        fb2.firstName,
+                        fb2.middleName
+                    ]).join(' '),
+                },
+                url: book.url,
+                path: book.path,
+                key: book.key,
+            });
+        }
+
+        const search = this.search;
+        this.tableData = result.filter(item => {
+            return !search ||
+                item.touchTime.includes(search) ||
+                item.touchDate.includes(search) ||
+                item.desc.title.toLowerCase().includes(search.toLowerCase()) ||
+                item.desc.author.toLowerCase().includes(search.toLowerCase())
+        });
+    }
+
+    headerCellStyle(cell) {
+        let result = {margin: 0, padding: 0};
+        if (cell.columnIndex > 0) {
+            result['border-bottom'] = 0;
+        }
+        if (cell.rowIndex > 0) {
+            result.height = '0px';
+            result['border-right'] = 0;
+        }
+        return result;
+    }
+
+    getFileNameFromPath(fb2Path) {
+        return path.basename(fb2Path).substr(0, 10) + '.fb2';
+    }
+
+    openOriginal(url) {
+        window.open(url, '_blank');
+    }
+
+    openFb2(path) {
+        window.open(path, '_blank');
+    }
+
+    async handleDel(key) {
+        await bookManager.delRecentBook({key});
+        this.updateTableData();
+
+        const newRecent = bookManager.mostRecentBook();
+        if (this.mostRecentBook != newRecent)
+            this.$emit('load-book', newRecent);
+
+        this.mostRecentBook = newRecent;
+        if (!this.mostRecentBook)
+            this.close();
+    }
+
+    loadBook(url) {
+        this.$emit('load-book', {url});
+        this.close();
+    }
+
+    isUrl(url) {
+        return (url.indexOf('file://') != 0);
+    }
+
+    close() {
+        this.$emit('history-toggle');
+    }
+
+    keyHook(event) {
+        if (event.type == 'keydown' && event.code == 'Escape') {
+            this.close();
+        }
+        return true;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.main {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 50;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
+.mainWindow {
+    height: 100%;
+    display: flex;
+}
+
+.desc {
+    cursor: pointer;
+}
+</style>

+ 160 - 0
client/components/Reader/LoaderPage/LoaderPage.vue

@@ -0,0 +1,160 @@
+<template>
+    <div ref="main" class="main">
+        <div class="part">
+            <span class="greeting bold-font">{{ title }}</span>
+            <span class="greeting">Добро пожаловать!</span>
+            <span class="greeting">Поддерживаются форматы: fb2, fb2.zip, html, txt</span>
+        </div>
+        <div class="part center">
+            <el-input ref="input" placeholder="URL книги" v-model="bookUrl">
+                <el-button slot="append" icon="el-icon-check" @click="submitUrl"></el-button>
+            </el-input>
+            <div class="space"></div>
+            <input type="file" id="file" ref="file" @change="loadFile" style='display: none;'/>
+            <el-button size="mini" @click="loadFileClick">
+                Загрузить файл с диска
+            </el-button>
+            <div class="space"></div>
+            <span v-if="config.mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Комментарии</span>
+        </div>
+        <div class="part bottom">
+            <span class="bottom-span clickable" @click="openHelp">Справка</span>
+            <span class="bottom-span clickable" @click="openDonate">Помочь проекту</span>
+            <span class="bottom-span">{{ version }}</span>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class LoaderPage extends Vue {
+    bookUrl = null;
+    loadPercent = 0;
+
+    created() {
+        this.commit = this.$store.commit;
+        this.config = this.$store.state.config;
+    }
+
+    mounted() {
+        this.progress = this.$refs.progress;
+    }
+
+    activated() {
+        this.$refs.input.focus();
+    }
+
+    get title() {
+        if (this.config.mode == 'omnireader')
+            return 'Omni Reader - браузерная онлайн-читалка.';
+        return 'Универсальная читалка книг и ресурсов интернета.';
+
+    }
+
+    get version() {
+        return `v${this.config.version}`;
+    }
+
+    submitUrl() {
+        if (this.bookUrl) {
+            this.$emit('load-book', {url: this.bookUrl});
+            this.bookUrl = '';
+        }
+    }
+
+    loadFileClick() {
+        this.$refs.file.click();
+    }
+
+    loadFile() {
+        const file = this.$refs.file.files[0];
+        if (file)
+            this.$emit('load-file', {file});
+    }
+
+    openHelp() {
+        this.$emit('help-toggle');
+    }
+
+    openDonate() {
+        this.$emit('donate-toggle');
+    }
+    
+    openComments() {
+        window.open('http://samlib.ru/comment/b/bookpauk/bookpauk_reader', '_blank');
+    }
+
+    keyHook(event) {
+        //недостатки сторонних ui
+        const input = this.$refs.input.$refs.input;
+        if (document.activeElement === input && event.type == 'keydown' && event.code == 'Enter') {
+            this.submitUrl();
+        }
+
+        if (event.type == 'keydown' && (event.code == 'F1' || (document.activeElement !== input && event.code == 'KeyH'))) {
+            this.$emit('help-toggle');
+            event.preventDefault();
+            event.stopPropagation();
+            return true;
+        }
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+<style scoped>
+.main {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+}
+
+.part {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+}
+
+.greeting {
+    font-size: 130%;
+    line-height: 170%;
+}
+
+.bold-font {
+    font-weight: bold;
+}
+
+.clickable {
+    color: blue;
+    text-decoration: underline;
+    cursor: pointer;
+}
+
+.center {
+    justify-content: flex-start;
+    padding: 0 10px 0 10px;
+}
+
+.bottom {
+    justify-content: flex-end;
+}
+
+.bottom-span {
+    font-size: 70%;
+    margin-bottom: 10px;
+}
+
+.el-input {
+    max-width: 700px;
+}
+
+.space {
+    height: 20px;
+}
+</style>

+ 99 - 0
client/components/Reader/ProgressPage/ProgressPage.vue

@@ -0,0 +1,99 @@
+<template>
+    <div v-show="visible" class="main">
+        <div class="center">
+            <el-progress type="circle" :width="100" :stroke-width="6" color="#0F9900" :percentage="percentage"></el-progress>
+            <p class="text">{{ text }}</p>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+const ruMessage = {
+    'start': ' ',
+    'finish': ' ',
+    'error': ' ',
+    'download': 'скачивание',
+    'decompress': 'распаковка',
+    'convert': 'конвертирование',
+    'loading': 'загрузка',
+    'parse': 'обработка',
+    'upload': 'отправка',
+};
+
+export default @Component({
+})
+class ProgressPage extends Vue {
+    text = '';
+    totalSteps = 1;
+    step = 1;
+    progress = 0;
+    visible = false;
+
+    show() {
+        this.$el.style.width = this.$parent.$el.offsetWidth + 'px';
+        this.$el.style.height = this.$parent.$el.offsetHeight + 'px';
+        this.text = '';
+        this.totalSteps = 1;
+        this.step = 1;
+        this.progress = 0;
+
+        this.visible = true;
+    }
+
+    hide() {
+        this.visible = false;
+    }
+
+    setState(state) {
+        if (state.state)
+            this.text = (ruMessage[state.state] ? ruMessage[state.state] : state.state);
+        this.step = (state.step ? state.step : this.step);
+        this.totalSteps = (state.totalSteps > this.totalSteps ? state.totalSteps : this.totalSteps);
+        this.progress = state.progress || 0;
+    }
+
+    get percentage() {
+        let circle = document.querySelector('path[class="el-progress-circle__path"]');
+        if (circle)
+            circle.style.transition = '';
+        return Math.round(((this.step - 1)/this.totalSteps + this.progress/(100*this.totalSteps))*100);
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+<style scoped>
+.main {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+
+    z-index: 100;
+    background-color: rgba(0, 0, 0, 0.8);
+
+    position: absolute;
+}
+.center {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: center;
+
+    color: white;
+    height: 300px;
+}
+
+.text {
+    color: yellow;
+}
+
+</style>
+<style>
+.el-progress__text {
+    color: lightgreen;
+}
+</style>

+ 901 - 0
client/components/Reader/Reader.vue

@@ -0,0 +1,901 @@
+<template>
+    <el-container>
+        <el-header v-show="toolBarActive" height='50px'>
+            <div class="header">
+                <el-tooltip content="Загрузить книгу" :open-delay="1000" effect="light">
+                    <el-button ref="loader" class="tool-button" :class="buttonActiveClass('loader')" @click="buttonClick('loader')"><i class="el-icon-back"></i></el-button>
+                </el-tooltip>
+
+                <div>
+                    <el-tooltip content="Действие назад" :open-delay="1000" effect="light">
+                        <el-button ref="undoAction" class="tool-button" :class="buttonActiveClass('undoAction')" @click="buttonClick('undoAction')" ><i class="el-icon-arrow-left"></i></el-button>
+                    </el-tooltip>
+                    <el-tooltip content="Действие вперед" :open-delay="1000" effect="light">
+                        <el-button ref="redoAction" class="tool-button" :class="buttonActiveClass('redoAction')" @click="buttonClick('redoAction')" ><i class="el-icon-arrow-right"></i></el-button>
+                    </el-tooltip>
+                    <div class="space"></div>
+                    <el-tooltip content="На весь экран" :open-delay="1000" effect="light">
+                        <el-button ref="fullScreen" class="tool-button" :class="buttonActiveClass('fullScreen')" @click="buttonClick('fullScreen')"><i class="el-icon-rank"></i></el-button>
+                    </el-tooltip>
+                    <el-tooltip content="Плавный скроллинг" :open-delay="1000" effect="light">
+                        <el-button ref="scrolling" class="tool-button" :class="buttonActiveClass('scrolling')" @click="buttonClick('scrolling')"><i class="el-icon-sort"></i></el-button>
+                    </el-tooltip>
+                    <el-tooltip content="Перелистнуть" :open-delay="1000" effect="light">
+                        <el-button ref="setPosition" class="tool-button" :class="buttonActiveClass('setPosition')" @click="buttonClick('setPosition')"><i class="el-icon-d-arrow-right"></i></el-button>
+                    </el-tooltip>
+                    <el-tooltip content="Найти в тексте" :open-delay="1000" effect="light">
+                        <el-button ref="search" class="tool-button" :class="buttonActiveClass('search')" @click="buttonClick('search')"><i class="el-icon-search"></i></el-button>
+                    </el-tooltip>
+                    <el-tooltip content="Скопировать текст со страницы" :open-delay="1000" effect="light">
+                        <el-button ref="copyText" class="tool-button" :class="buttonActiveClass('copyText')" @click="buttonClick('copyText')"><i class="el-icon-edit-outline"></i></el-button>
+                    </el-tooltip>
+                    <el-tooltip content="Принудительно обновить книгу в обход кэша" :open-delay="1000" effect="light">
+                        <el-button ref="refresh" class="tool-button" :class="buttonActiveClass('refresh')" @click="buttonClick('refresh')">
+                            <i class="el-icon-refresh" :class="{clear: !showRefreshIcon}"></i>
+                        </el-button>
+                    </el-tooltip>
+                    <div class="space"></div>
+                    <el-tooltip content="Открыть недавние" :open-delay="1000" effect="light">
+                        <el-button ref="history" class="tool-button" :class="buttonActiveClass('history')" @click="buttonClick('history')"><i class="el-icon-document"></i></el-button>
+                    </el-tooltip>
+                </div>
+
+                <el-tooltip content="Настроить" :open-delay="1000" effect="light">
+                    <el-button ref="settings" class="tool-button" :class="buttonActiveClass('settings')" @click="buttonClick('settings')"><i class="el-icon-setting"></i></el-button>            
+                </el-tooltip>
+            </div>
+        </el-header>
+
+        <el-main>
+            <keep-alive>
+                <component ref="page" :is="activePage"
+                    @load-book="loadBook"
+                    @load-file="loadFile"
+                    @book-pos-changed="bookPosChanged"
+                    @tool-bar-toggle="toolBarToggle"
+                    @full-screen-toogle="fullScreenToggle"
+                    @stop-scrolling="stopScrolling"
+                    @scrolling-toggle="scrollingToggle"
+                    @help-toggle="helpToggle"
+                    @donate-toggle="donateToggle"
+                ></component>
+            </keep-alive>
+
+            <SetPositionPage v-if="setPositionActive" ref="setPositionPage" @set-position-toggle="setPositionToggle" @book-pos-changed="bookPosChanged"></SetPositionPage>
+            <SearchPage v-show="searchActive" ref="searchPage" 
+                @search-toggle="searchToggle" 
+                @book-pos-changed="bookPosChanged"
+                @start-text-search="startTextSearch"
+                @stop-text-search="stopTextSearch">
+            </SearchPage>
+            <CopyTextPage v-if="copyTextActive" ref="copyTextPage" @copy-text-toggle="copyTextToggle"></CopyTextPage>            
+            <HistoryPage v-if="historyActive" ref="historyPage" @load-book="loadBook" @history-toggle="historyToggle"></HistoryPage>
+            <SettingsPage v-if="settingsActive" ref="settingsPage" @settings-toggle="settingsToggle"></SettingsPage>
+            <HelpPage v-if="helpActive" ref="helpPage" @help-toggle="helpToggle"></HelpPage>
+            <ClickMapPage v-show="clickMapActive" ref="clickMapPage"></ClickMapPage>
+        </el-main>
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import LoaderPage from './LoaderPage/LoaderPage.vue';
+import TextPage from './TextPage/TextPage.vue';
+import ProgressPage from './ProgressPage/ProgressPage.vue';
+
+import SetPositionPage from './SetPositionPage/SetPositionPage.vue';
+import SearchPage from './SearchPage/SearchPage.vue';
+import CopyTextPage from './CopyTextPage/CopyTextPage.vue';
+import HistoryPage from './HistoryPage/HistoryPage.vue';
+import SettingsPage from './SettingsPage/SettingsPage.vue';
+import HelpPage from './HelpPage/HelpPage.vue';
+import ClickMapPage from './ClickMapPage/ClickMapPage.vue';
+
+import bookManager from './share/bookManager';
+import readerApi from '../../api/reader';
+import _ from 'lodash';
+import {sleep} from '../../share/utils';
+import restoreOldSettings from './share/restoreOldSettings';
+
+export default @Component({
+    components: {
+        LoaderPage,
+        TextPage,
+        ProgressPage,
+
+        SetPositionPage,
+        SearchPage,
+        CopyTextPage,
+        HistoryPage,
+        SettingsPage,
+        HelpPage,
+        ClickMapPage,
+    },
+    watch: {
+        bookPos: function(newValue) {
+            if (newValue !== undefined && this.activePage == 'TextPage') {
+                const textPage = this.$refs.page;
+                if (textPage.bookPos != newValue) {
+                    textPage.bookPos = newValue;
+                }
+                this.debouncedSetRecentBook(newValue);
+            }
+        },
+        routeParamPos: function(newValue) {
+            if (newValue !== undefined && newValue != this.bookPos) {
+                this.bookPos = newValue;
+            }
+        },
+        routeParamUrl: function(newValue) {
+            if (newValue !== '' && newValue !== this.mostRecentBook().url) {
+                this.loadBook({url: newValue, bookPos: this.routeParamPos});
+            }
+        },
+        settings: function(newValue) {
+            this.allowUrlParamBookPos = newValue.allowUrlParamBookPos;
+            this.copyFullText = newValue.copyFullText;
+            this.showClickMapPage = newValue.showClickMapPage;
+            this.updateRoute();
+        },
+    },
+})
+class Reader extends Vue {
+    loaderActive = false;
+    progressActive = false;
+    fullScreenActive = false;
+
+    scrollingActive = false;
+    setPositionActive = false;
+    searchActive = false;
+    copyTextActive = false;
+    historyActive = false;
+    settingsActive = false;
+    helpActive = false;
+    clickMapActive = false;
+
+    bookPos = null;
+    allowUrlParamBookPos = false;
+    showRefreshIcon = true;
+    mostRecentBookReactive = null;
+
+    actionList = [];
+    actionCur = -1;
+
+    created() {
+        this.loading = true;
+        this.commit = this.$store.commit;
+        this.dispatch = this.$store.dispatch;
+        this.reader = this.$store.state.reader;
+
+        this.$root.addKeyHook(this.keyHook);
+
+        this.lastActivePage = false;
+
+        this.debouncedUpdateRoute = _.debounce(() => {
+            this.updateRoute();
+        }, 1000);
+
+        this.debouncedSetRecentBook = _.debounce(async(newValue) => {
+            const recent = this.mostRecentBook();
+            if (recent && recent.bookPos != newValue) {
+                await bookManager.setRecentBook(Object.assign({}, recent, {bookPos: newValue, bookPosSeen: this.bookPosSeen}));
+
+                if (this.actionCur < 0 || (this.actionCur >= 0 && this.actionList[this.actionCur] != newValue))
+                    this.addAction(newValue);
+            }
+        }, 500);
+
+        document.addEventListener('fullscreenchange', () => {
+            this.fullScreenActive = (document.fullscreenElement !== null);
+        });
+
+        this.allowUrlParamBookPos = this.settings.allowUrlParamBookPos;
+        this.copyFullText = this.settings.copyFullText;
+        this.showClickMapPage = this.settings.showClickMapPage;
+    }
+
+    mounted() {
+        (async() => {
+            await bookManager.init();
+            await restoreOldSettings(this.settings, bookManager, this.commit);
+
+            if (this.$root.rootRoute == '/reader') {
+                if (this.routeParamUrl) {
+                    this.loadBook({url: this.routeParamUrl, bookPos: this.routeParamPos});
+                } else if (this.mostRecentBook()) {
+                    this.loadBook({url: this.mostRecentBook().url});
+                } else {
+                    this.loaderActive = true;
+                }
+            }
+            this.loading = false;
+        })();
+    }
+
+    get routeParamPos() {
+        let result = undefined;
+        const q = this.$route.query;
+        if (q['__p']) {
+            result = q['__p'];
+            if (Array.isArray(result))
+                result = result[0];
+        }
+        
+        return (result ? parseInt(result, 10) || 0 : result);
+    }
+
+    updateRoute(isNewRoute) {
+        const recent = this.mostRecentBook();
+        const pos = (recent && recent.bookPos && this.allowUrlParamBookPos ? `__p=${recent.bookPos}&` : '');
+        const url = (recent ? `url=${recent.url}` : '');
+        if (isNewRoute)
+            this.$router.push(`/reader?${pos}${url}`);
+        else
+            this.$router.replace(`/reader?${pos}${url}`);
+
+    }
+
+    get routeParamUrl() {
+        let result = '';
+        const path = this.$route.fullPath;
+        const i = path.indexOf('url=');
+        if (i >= 0) {
+            result = path.substr(i + 4);
+        }
+        
+        return decodeURIComponent(result);
+    }
+
+    bookPosChanged(event) {
+        if (event.bookPosSeen !== undefined)
+            this.bookPosSeen = event.bookPosSeen;
+        this.bookPos = event.bookPos;
+        this.debouncedUpdateRoute();
+    }
+
+    get toolBarActive() {
+        return this.reader.toolBarActive;
+    }
+
+    mostRecentBook() {
+        const result = bookManager.mostRecentBook();
+        this.mostRecentBookReactive = result;
+        return result;
+    }
+
+    get settings() {
+        return this.$store.state.reader.settings;
+    }
+
+    addAction(pos) {
+        let a = this.actionList;
+        if (!a.length || a[a.length - 1] != pos) {
+            a.push(pos);
+            if (a.length > 20)
+                a.shift();
+            this.actionCur = a.length - 1;
+        }
+    }
+
+    toolBarToggle() {
+        this.commit('reader/setToolBarActive', !this.toolBarActive);
+        this.$root.$emit('resize');
+    }
+
+    fullScreenToggle() {
+        this.fullScreenActive = !this.fullScreenActive;
+        if (this.fullScreenActive) {
+            const element = document.documentElement;
+            if (element.requestFullscreen) {
+                element.requestFullscreen();
+            } else if (element.webkitrequestFullscreen) {
+                element.webkitRequestFullscreen();
+            } else if (element.mozRequestFullscreen) {
+                element.mozRequestFullScreen();
+            }
+        } else {
+            if (document.cancelFullScreen) {
+                document.cancelFullScreen();
+            } else if (document.mozCancelFullScreen) {
+                document.mozCancelFullScreen();
+            } else if (document.webkitCancelFullScreen) {
+                document.webkitCancelFullScreen();
+            }
+        }
+    }
+
+    closeAllTextPages() {
+        this.setPositionActive = false;
+        this.copyTextActive = false;
+        this.historyActive = false;
+        this.settingsActive = false;
+        this.stopScrolling();
+        this.stopSearch();
+        this.helpActive = false;
+    }
+
+    loaderToggle() {
+        this.loaderActive = !this.loaderActive;
+        if (this.loaderActive) {
+            this.closeAllTextPages();
+        }
+    }
+
+    setPositionToggle() {
+        this.setPositionActive = !this.setPositionActive;
+        if (this.setPositionActive && this.activePage == 'TextPage' && this.mostRecentBook()) {
+            this.closeAllTextPages();
+            this.setPositionActive = true;
+
+            this.$nextTick(() => {
+                this.$refs.setPositionPage.sliderMax = this.mostRecentBook().textLength - 1;
+                this.$refs.setPositionPage.sliderValue = this.mostRecentBook().bookPos;
+            });
+        } else {
+            this.setPositionActive = false;
+        }
+    }
+
+    stopScrolling() {
+        if (this.scrollingActive)
+            this.scrollingToggle();
+    }
+
+    scrollingToggle() {
+        this.scrollingActive = !this.scrollingActive;
+        if (this.activePage == 'TextPage') {
+            const page = this.$refs.page;
+            if (this.scrollingActive) {
+                page.startTextScrolling();
+            } else {
+                page.stopTextScrolling();
+            }
+        }
+    }
+
+    stopSearch() {
+        if (this.searchActive)
+            this.searchToggle();
+    }
+
+    startTextSearch(opts) {
+        if (this.activePage == 'TextPage')
+            this.$refs.page.startSearch(opts.needle);
+    }
+
+    stopTextSearch() {
+        if (this.activePage == 'TextPage')
+            this.$refs.page.stopSearch();
+    }
+
+    searchToggle() {
+        this.searchActive = !this.searchActive;
+        const page = this.$refs.page;
+        if (this.searchActive && this.activePage == 'TextPage' && page.parsed) {
+            this.closeAllTextPages();
+            this.searchActive = true;
+
+            this.$nextTick(() => {
+                this.$refs.searchPage.init(page.parsed);
+            });
+        } else {
+            this.stopTextSearch();
+            this.searchActive = false;
+        }
+    }
+
+    copyTextToggle() {
+        this.copyTextActive = !this.copyTextActive;
+        const page = this.$refs.page;
+        if (this.copyTextActive && this.activePage == 'TextPage' && page.parsed) {
+            this.closeAllTextPages();
+            this.copyTextActive = true;
+
+            this.$nextTick(() => {
+                this.$refs.copyTextPage.init(this.mostRecentBook().bookPos, page.parsed, this.copyFullText);
+            });
+        } else {
+            this.copyTextActive = false;
+        }
+    }
+
+    historyToggle() {
+        this.historyActive = !this.historyActive;
+        if (this.historyActive) {
+            this.closeAllTextPages();
+            this.historyActive = true;
+        } else {
+            this.historyActive = false;
+        }
+    }
+
+    settingsToggle() {
+        this.settingsActive = !this.settingsActive;
+        if (this.settingsActive) {
+            this.closeAllTextPages();
+            this.settingsActive = true;
+        } else {
+            this.settingsActive = false;
+        }
+    }
+
+    helpToggle() {
+        this.helpActive = !this.helpActive;
+        if (this.helpActive) {
+            this.closeAllTextPages();
+            this.helpActive = true;
+        }
+    }
+
+    donateToggle() {
+        this.helpToggle();
+        if (this.helpActive) {
+            this.$nextTick(() => {
+                this.$refs.helpPage.activateDonateHelpPage();
+            });
+        }
+    }
+
+    buttonClick(button) {
+        const activeClass = this.buttonActiveClass(button);
+        if (!activeClass['tool-button-disabled'])
+            switch (button) {
+                case 'loader':
+                    this.loaderToggle();
+                    break;
+                case 'undoAction':
+                    if (this.actionCur > 0) {
+                        this.actionCur--;
+                        this.bookPosChanged({bookPos: this.actionList[this.actionCur]});
+                    }
+                    break;
+                case 'redoAction':
+                    if (this.actionCur < this.actionList.length - 1) {
+                        this.actionCur++;
+                        this.bookPosChanged({bookPos: this.actionList[this.actionCur]});
+                    }
+                    break;
+                case 'fullScreen':
+                    this.fullScreenToggle();
+                    break;
+                case 'setPosition':
+                    this.setPositionToggle();
+                    break;
+                case 'scrolling':
+                    this.scrollingToggle();
+                    break;
+                case 'search':
+                    this.searchToggle();
+                    break;
+                case 'copyText':
+                    this.copyTextToggle();
+                    break;
+                case 'history':
+                    this.historyToggle();
+                    break;
+                case 'refresh':
+                    if (this.mostRecentBook()) {
+                        this.loadBook({url: this.mostRecentBook().url, force: true});
+                    }
+                    break;
+                case 'settings':
+                    this.settingsToggle();
+                    break;
+            }
+        this.$refs[button].$el.blur();
+    }
+
+    buttonActiveClass(button) {
+        const classActive = { 'tool-button-active': true, 'tool-button-active:hover': true };
+        const classDisabled = { 'tool-button-disabled': true, 'tool-button-disabled:hover': true };
+        let classResult = {};
+
+        switch (button) {
+            case 'loader':
+            case 'fullScreen':
+            case 'setPosition':
+            case 'scrolling':
+            case 'search':
+            case 'copyText':
+            case 'history':
+            case 'settings':
+                if (this[`${button}Active`])
+                    classResult = classActive;
+                break;
+        }
+
+        switch (button) {
+            case 'undoAction':
+                if (this.actionCur <= 0)
+                    classResult = classDisabled;
+                break;
+            case 'redoAction':
+                if (this.actionCur == this.actionList.length - 1)
+                    classResult = classDisabled;
+                break;
+        }
+
+        if (this.activePage == 'LoaderPage' || !this.mostRecentBook()) {
+            switch (button) {
+                case 'undoAction':
+                case 'redoAction':
+                case 'setPosition':
+                case 'scrolling':
+                case 'search':
+                case 'copyText':
+                    classResult = classDisabled;
+                    break;
+                case 'history':
+                case 'refresh':
+                    if (!this.mostRecentBook())
+                        classResult = classDisabled;
+                    break;
+            }
+        }
+
+        return classResult;
+    }
+
+    async acivateClickMapPage() {
+        if (this.showClickMapPage && !this.clickMapActive) {
+            this.clickMapActive = true;
+            await this.$refs.clickMapPage.slowDisappear();
+            this.clickMapActive = false;
+        }
+    }
+
+    get activePage() {
+        let result = '';
+
+        if (this.progressActive)
+            result = 'ProgressPage';
+        else if (this.loaderActive)
+            result = 'LoaderPage';
+        else if (this.mostRecentBookReactive)
+            result = 'TextPage';
+
+        if (!result && !this.loading) {
+            this.loaderActive = true;
+            result = 'LoaderPage';
+        }
+
+        if (result != 'TextPage') {
+            this.$root.$emit('set-app-title');
+        }
+
+        if (this.lastActivePage != result && result == 'TextPage') {
+            //акивируем страницу с текстом
+            this.$nextTick(async() => {
+                const last = this.mostRecentBookReactive;
+                const isParsed = bookManager.hasBookParsed(last);
+                if (!isParsed) {
+                    this.$root.$emit('set-app-title');
+                    return;
+                }
+
+                this.updateRoute();
+                const textPage = this.$refs.page;
+                if (textPage.showBook) {
+                    textPage.lastBook = last;
+                    textPage.bookPos = (last.bookPos !== undefined ? last.bookPos : 0);
+
+                    textPage.showBook();
+                }
+            });
+        }
+
+        this.lastActivePage = result;
+        return result;
+    }
+
+    loadBook(opts) {
+        if (!opts) {
+            this.mostRecentBook();
+            return;
+        }
+
+        // уже просматривается сейчас
+        const lastBook = (this.$refs.page ? this.$refs.page.lastBook : null);
+        if (!opts.force && lastBook && lastBook.url == opts.url && bookManager.hasBookParsed(lastBook)) {
+            this.loaderActive = false;
+            return;
+        }
+
+        this.progressActive = true;
+        this.$nextTick(async() => {
+            const progress = this.$refs.page;
+
+            this.actionList = [];
+            this.actionCur = -1;
+
+            try {
+                progress.show();
+                progress.setState({state: 'parse'});
+
+                // есть ли среди недавних
+                const key = bookManager.keyFromUrl(opts.url);
+                let wasOpened = await bookManager.getRecentBook({key});
+                wasOpened = (wasOpened ? wasOpened : {});
+                const bookPos = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos);
+                const bookPosSeen = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen);
+                const bookPosPercent = wasOpened.bookPosPercent;
+
+                let book = null;
+
+                if (!opts.force) {
+                    // пытаемся загрузить и распарсить книгу в менеджере из локального кэша
+                    const bookParsed = await bookManager.getBook({url: opts.url}, (prog) => {
+                        progress.setState({progress: prog});
+                    });
+
+                    // если есть в локальном кэше
+                    if (bookParsed) {
+                        await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, bookManager.metaOnly(bookParsed)));
+                        this.mostRecentBook();
+                        this.addAction(bookPos);
+                        this.loaderActive = false;
+                        progress.hide(); this.progressActive = false;
+                        this.blinkCachedLoadMessage();
+
+                        await this.acivateClickMapPage();
+                        return;
+                    }
+
+                    // иначе идем на сервер
+                    // пытаемся загрузить готовый файл с сервера
+                    if (wasOpened.path) {
+                        try {
+                            const resp = await readerApi.loadCachedBook(wasOpened.path, (state) => {
+                                progress.setState(state);
+                            });
+                            book = Object.assign({}, wasOpened, {data: resp.data});
+                        } catch (e) {
+                            //молчим
+                        }
+                    }
+                }
+
+                progress.setState({totalSteps: 5});
+
+                // не удалось, скачиваем книгу полностью с конвертацией
+                let loadCached = true;
+                if (!book) {
+                    book = await readerApi.loadBook(opts.url, (state) => {
+                        progress.setState(state);
+                    });
+                    loadCached = false;
+                }
+
+                // добавляем в bookManager
+                progress.setState({state: 'parse', step: 5});
+                const addedBook = await bookManager.addBook(book, (prog) => {
+                    progress.setState({progress: prog});
+                });
+
+                // добавляем в историю
+                await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, bookManager.metaOnly(addedBook)));
+                this.mostRecentBook();
+                this.addAction(bookPos);
+                this.updateRoute(true);
+
+                this.loaderActive = false;
+                progress.hide(); this.progressActive = false;
+                if (loadCached) {
+                    this.blinkCachedLoadMessage();
+                } else
+                    this.stopBlink = true;
+
+                await this.acivateClickMapPage();
+            } catch (e) {
+                progress.hide(); this.progressActive = false;
+                this.loaderActive = true;
+                this.$alert(e.message, 'Ошибка', {type: 'error'});
+            }
+        });
+    }
+
+    loadFile(opts) {
+        this.progressActive = true;
+        this.$nextTick(async() => {
+            const progress = this.$refs.page;
+            try {
+                progress.show();
+                progress.setState({state: 'upload'});
+
+                const url = await readerApi.uploadFile(opts.file, (state) => {
+                    progress.setState(state);
+                });
+
+                progress.hide(); this.progressActive = false;
+
+                this.loadBook({url});
+            } catch (e) {
+                progress.hide(); this.progressActive = false;
+                this.loaderActive = true;
+                this.$alert(e.message, 'Ошибка', {type: 'error'});
+            }
+        });
+    }
+
+    blinkCachedLoadMessage() {
+        this.blinkCount = 30;
+        if (!this.inBlink) {
+            this.inBlink = true;
+            this.stopBlink = false;
+            this.$nextTick(async() => {
+                let page = this.$refs.page;
+                while (this.blinkCount) {
+                    this.showRefreshIcon = !this.showRefreshIcon;
+                    if (page.blinkCachedLoadMessage)
+                        page.blinkCachedLoadMessage(this.showRefreshIcon);
+                    await sleep(500);
+                    if (this.stopBlink)
+                        break;
+                    this.blinkCount--;
+                    page = this.$refs.page;
+                }
+                this.showRefreshIcon = true;
+                this.inBlink = false;
+                if (page.blinkCachedLoadMessage)
+                    page.blinkCachedLoadMessage('finish');
+            });
+        }
+    }
+
+    keyHook(event) {
+        if (this.$root.rootRoute == '/reader') {
+            let handled = false;
+            if (!handled && this.helpActive)
+                handled = this.$refs.helpPage.keyHook(event);
+
+            if (!handled && this.settingsActive)
+                handled = this.$refs.settingsPage.keyHook(event);
+
+            if (!handled && this.historyActive)
+                handled = this.$refs.historyPage.keyHook(event);
+
+            if (!handled && this.setPositionActive)
+                handled = this.$refs.setPositionPage.keyHook(event);
+
+            if (!handled && this.searchActive)
+                handled = this.$refs.searchPage.keyHook(event);
+
+            if (!handled && this.copyTextActive)
+                handled = this.$refs.copyTextPage.keyHook(event);
+
+            if (!handled && this.$refs.page && this.$refs.page.keyHook)
+                handled = this.$refs.page.keyHook(event);
+
+            if (!handled && event.type == 'keydown') {
+                if (event.code == 'Escape')
+                    this.loaderToggle();
+
+                if (this.activePage == 'TextPage') {
+                    switch (event.code) {
+                        case 'KeyH':
+                        case 'F1':
+                            this.helpToggle();
+                            event.preventDefault();
+                            event.stopPropagation();
+                            break;
+                        case 'KeyP':
+                            this.setPositionToggle();
+                            break;
+                        case 'KeyF':
+                            if (event.ctrlKey) {
+                                this.searchToggle();
+                                event.preventDefault();
+                                event.stopPropagation();
+                            }
+                            break;
+                        case 'KeyC':
+                            if (event.ctrlKey) {
+                                this.copyTextToggle();
+                                event.preventDefault();
+                                event.stopPropagation();
+                            }
+                            break;
+                        case 'KeyZ':
+                            this.scrollingToggle();
+                            break;
+                        case 'KeyX':
+                            this.historyToggle();
+                            break;
+                        case 'KeyS':
+                            this.settingsToggle();
+                            break;
+                    }
+                }
+            }
+        }
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.el-container {
+    padding: 0;
+    margin: 0;
+    height: 100%;
+}
+
+.el-header {
+    padding-left: 5px;
+    padding-right: 5px;
+    background-color: #1B695F;
+    color: #000;
+    overflow-x: auto;
+    overflow-y: hidden;
+}
+  
+.header {
+    display: flex;
+    justify-content: space-between;
+    min-width: 550px;
+}
+
+.el-main {
+    position: relative;
+    display: flex;
+    padding: 0;
+    margin: 0;
+    background-color: #EBE2C9;
+    color: #000;
+}
+
+.tool-button {
+    margin: 0 2px 0 2px;
+    padding: 0;
+    color: #3E843E;
+    background-color: #E6EDF4;
+    margin-top: 5px;
+    height: 38px;
+    width: 38px;
+    border: 0;
+    box-shadow: 3px 3px 5px black;
+}
+
+.tool-button:hover {
+    background-color: white;
+}
+
+.tool-button-active {
+    box-shadow: 0 0 0;
+    color: white;
+    background-color: #8AB45F;
+    position: relative;
+    top: 1px;
+    left: 1px;
+}
+
+.tool-button-active:hover {
+    color: white;
+    background-color: #81C581;
+}
+
+.tool-button-disabled {
+    color: lightgray;
+    background-color: gray;
+}
+
+.tool-button-disabled:hover {
+    color: lightgray;
+    background-color: gray;
+}
+
+i {
+    font-size: 200%;
+}
+
+.space {
+    width: 10px;
+    display: inline-block;
+}
+
+.clear {
+    color: rgba(0,0,0,0);
+}
+</style>

+ 230 - 0
client/components/Reader/SearchPage/SearchPage.vue

@@ -0,0 +1,230 @@
+<template>
+    <div ref="main" class="main" @click="close">
+        <div class="mainWindow" @click.stop>
+            <Window @close="close">
+                <template slot="header">
+                    {{ header }}
+                </template>
+
+                <div class="content">
+                    <span v-show="initStep">{{ initPercentage }}%</span>
+
+                    <div v-show="!initStep" class="input">
+                        <input ref="input" class="el-input__inner"
+                            placeholder="что ищем"
+                            :value="needle" @input="needle = $event.target.value"/>
+                        <div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">{{ foundText }}</div>
+                    </div>
+                    <el-button-group v-show="!initStep" class="button-group">
+                        <el-button @click="showNext"><i class="el-icon-arrow-down"></i></el-button>
+                        <el-button @click="showPrev"><i class="el-icon-arrow-up"></i></el-button>
+                    </el-button-group>
+                </div>
+            </Window>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+import Window from '../../share/Window.vue';
+import {sleep} from '../../../share/utils';
+
+export default @Component({
+    components: {
+        Window,
+    },
+    watch: {
+        needle: function() {
+            this.find();
+
+        },
+        foundText: function(newValue) {
+            this.$refs.input.style.paddingRight = (10 + newValue.length*12) + 'px';
+        },
+    },
+})
+class SearchPage extends Vue {
+    header = null;
+    initStep = null;
+    initPercentage = 0;
+    needle = null;
+    foundList = [];
+    foundCur = -1;
+
+    created() {
+        this.commit = this.$store.commit;
+        this.reader = this.$store.state.reader;
+    }
+
+    async init(parsed) {
+        if (this.parsed != parsed) {
+            this.initStep = true;
+            this.stopInit = false;
+            this.header = 'Подготовка';
+            await this.$nextTick();
+            await sleep(10);
+
+            let nextPerc = 0;
+            let text = '';
+            for (let i = 0; i < parsed.para.length; i++) {
+                const p = parsed.para[i];
+                const parts = parsed.splitToStyle(p.text);
+                if (this.stopInit)
+                    return;
+
+                for (const part of parts)
+                    text += part.text;
+
+                const perc = Math.round(i/parsed.para.length*100);
+
+                if (perc > nextPerc) {
+                    this.initPercentage = perc;
+                    await sleep(1);
+                    nextPerc = perc + 10;
+                }
+            }            
+            this.text = text.toLowerCase();
+            this.initStep = false;
+            this.needle = '';
+            this.foundList = [];
+            this.foundCur = -1;
+            this.parsed = parsed;
+        }
+
+        this.header = 'Найти';
+        await this.$nextTick();
+        this.$refs.input.focus();
+        this.$refs.input.select();
+    }
+
+    get foundText() {
+        if (this.foundList.length && this.foundCur >= 0)
+            return `${this.foundCur + 1}/${this.foundList.length}`;
+        else
+            return '';
+    }
+
+    find() {
+        let foundList = [];
+        if (this.needle) {
+            const needle = this.needle.toLowerCase();
+            let i = 0;
+            while (i < this.text.length) {
+                const found = this.text.indexOf(needle, i);
+                if (found >= 0)
+                    foundList.push(found);
+                i = (found >= 0 ? found + 1 : this.text.length);
+            }
+        }
+        this.foundList = foundList;
+        this.foundCur = -1;
+        this.showNext();
+    }
+
+    showNext() {
+        const next = this.foundCur + 1;
+        if (next < this.foundList.length)
+            this.foundCur = next;
+        else
+            this.foundCur = (this.foundList.length ? 0 : -1);
+
+        if (this.foundCur >= 0) {
+            this.$emit('start-text-search', {needle: this.needle.toLowerCase()});
+            this.$emit('book-pos-changed', {bookPos: this.foundList[this.foundCur]});
+        } else {
+            this.$emit('stop-text-search');
+        }
+        this.$refs.input.focus();
+    }
+
+    showPrev() {
+        const prev = this.foundCur - 1;
+        if (prev >= 0)
+            this.foundCur = prev;
+        else
+            this.foundCur = this.foundList.length - 1;
+
+        if (this.foundCur >= 0) {
+            this.$emit('start-text-search', {needle: this.needle.toLowerCase()});
+            this.$emit('book-pos-changed', {bookPos: this.foundList[this.foundCur]});
+        } else {
+            this.$emit('stop-text-search');
+        }
+        this.$refs.input.focus();
+    }
+
+    close() {
+        this.stopInit = true;
+        this.$emit('search-toggle');
+    }
+
+    keyHook(event) {
+        //недостатки сторонних ui
+        if (document.activeElement === this.$refs.input && event.type == 'keydown' && event.key == 'Enter') {
+            this.showNext();
+        }
+
+        if (event.type == 'keydown' && (event.code == 'Escape')) {
+            this.close();
+        }
+        return true;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.main {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 40;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+}
+
+.mainWindow {
+    width: 100%;
+    max-width: 500px;
+    height: 125px;
+    display: flex;
+    position: relative;
+    top: -50px;
+}
+
+.content {
+    flex: 1;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: 10px;
+}
+
+.input {
+    display: flex;
+    margin: 0;
+    padding: 0;
+    width: 100%;
+    position: relative;
+}
+
+.button-group {
+    width: 150px;
+    margin: 0;
+    padding: 0;
+}
+
+.el-button {
+    padding: 9px 17px 9px 17px;
+}
+
+i {
+    font-size: 20px;
+}
+</style>

+ 96 - 0
client/components/Reader/SetPositionPage/SetPositionPage.vue

@@ -0,0 +1,96 @@
+<template>
+    <div ref="main" class="main" @click="close">
+        <div class="mainWindow" @click.stop>
+            <Window @close="close">
+                <template slot="header">
+                    Установить позицию
+                </template>
+
+                <div class="slider">
+                    <el-slider v-model="sliderValue" :max="sliderMax" :format-tooltip="formatTooltip"></el-slider>
+                </div>
+            </Window>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import _ from 'lodash';
+
+import Window from '../../share/Window.vue';
+
+export default @Component({
+    components: {
+        Window,
+    },
+    watch: {
+        sliderValue: function(newValue) {
+            this.$emit('book-pos-changed', {bookPos: newValue});
+        },
+    },
+})
+class SetPositionPage extends Vue {
+    sliderValue = null;
+    sliderMax = null;
+
+    created() {
+        this.commit = this.$store.commit;
+        this.reader = this.$store.state.reader;
+    }
+
+    formatTooltip(val) {
+        if (this.sliderMax)
+            return (val/this.sliderMax*100).toFixed(2) + '%';
+        else
+            return 0;
+    }
+
+    close() {
+        this.$emit('set-position-toggle');
+    }
+
+    keyHook(event) {
+        if (event.type == 'keydown' && (event.code == 'Escape' || event.code == 'KeyP')) {
+            this.close();
+        }
+        return true;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.main {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 40;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+}
+
+.mainWindow {
+    width: 100%;
+    max-width: 600px;
+    height: 140px;
+    display: flex;
+    position: relative;
+    top: -50px;
+}
+
+.slider {
+    margin: 20px;
+    background-color: #efefef;
+    border-radius: 15px;
+}
+
+.el-slider {
+    margin-right: 20px;
+    margin-left: 20px;
+}
+</style>

+ 455 - 0
client/components/Reader/SettingsPage/SettingsPage.vue

@@ -0,0 +1,455 @@
+<template>
+    <div ref="main" class="main" @click="close">
+        <div class="mainWindow" @click.stop>
+            <Window @close="close">
+                <template slot="header">
+                    Настройки
+                </template>
+
+                <el-tabs type="border-card" tab-position="left" v-model="selectedTab">
+                    <!--------------------------------------------------------------------------->
+                    <el-tab-pane label="Вид">
+
+                        <el-form :model="form" size="small" label-width="120px" @submit.native.prevent>
+                            <div class="partHeader">Цвет</div>
+
+                            <el-form-item label="Текст">
+                                <el-col :span="12">
+                                    <el-color-picker v-model="textColor" color-format="hex" :predefine="predefineTextColors"></el-color-picker>
+                                    <span class="color-picked"><b>{{ textColor }}</b></span>
+                                </el-col>
+                                <el-col :span="5">
+                                    <span style="position: relative; top: 20px;">Обои:</span>
+                                </el-col>
+                            </el-form-item>
+
+                            <el-form-item label="Фон">
+                                <el-col :span="12">
+                                    <el-color-picker v-model="backgroundColor" color-format="hex" :predefine="predefineBackgroundColors" :disabled="wallpaper != ''"></el-color-picker>
+                                    <span v-show="wallpaper == ''" class="color-picked"><b>{{ backgroundColor }}</b></span>
+                                </el-col>
+
+                                <el-col :span="11">
+                                    <el-select v-model="wallpaper">
+                                        <el-option label="Нет" value=""></el-option>
+                                        <el-option label="1" value="paper1"></el-option>
+                                        <el-option label="2" value="paper2"></el-option>
+                                        <el-option label="3" value="paper3"></el-option>
+                                        <el-option label="4" value="paper4"></el-option>
+                                        <el-option label="5" value="paper5"></el-option>
+                                        <el-option label="6" value="paper6"></el-option>
+                                        <el-option label="7" value="paper7"></el-option>
+                                        <el-option label="8" value="paper8"></el-option>
+                                        <el-option label="9" value="paper9"></el-option>
+                                    </el-select>
+                                </el-col>
+                            </el-form-item>
+                        </el-form>
+
+                        <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
+                            <div class="partHeader">Шрифт</div>
+
+                            <el-form-item label="Локальный/веб">
+                                <el-col :span="11">
+                                    <el-select v-model="fontName" placeholder="Шрифт" :disabled="webFontName != ''">
+                                        <el-option v-for="item in fonts"
+                                            :key="item.name"
+                                            :label="item.label"
+                                            :value="item.name">
+                                        </el-option>
+                                    </el-select>
+                                </el-col>
+                                <el-col :span="1">
+                                    &nbsp;
+                                </el-col>
+                                <el-col :span="11">
+                                    <el-tooltip :open-delay="500" effect="light" placement="top">
+                                        <template slot="content">
+                                            Веб шрифты дают большое разнообразие,<br>
+                                            однако есть шанс, что шрифт будет загружаться<br>
+                                            очень медленно или вовсе не загрузится
+                                        </template>
+                                        <el-select v-model="webFontName">
+                                            <el-option label="Нет" value=""></el-option>
+                                            <el-option v-for="item in webFonts"
+                                                :key="item.name"
+                                                :value="item.name">
+                                            </el-option>
+                                        </el-select>
+                                    </el-tooltip>
+                                </el-col>
+                            </el-form-item>
+                            <el-form-item label="Размер">
+                                <el-col :span="17">
+                                    <el-input-number v-model="fontSize" :min="5" :max="100"></el-input-number>
+                                </el-col>
+                                <el-col :span="1">
+                                    <a href="https://fonts.google.com/?subset=cyrillic" target="_blank">Примеры</a>
+                                </el-col>
+                            </el-form-item>
+                            <el-form-item label="Сдвиг">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Сдвиг шрифта по вертикали в процентах от размера.<br>
+                                        Отрицательное значение сдвигает вверх, положительное -<br>
+                                        вниз. Значение зависит от метрики шрифта.
+                                    </template>
+                                    <el-input-number v-model="vertShift" :min="-100" :max="100"></el-input-number>
+                                </el-tooltip>
+                            </el-form-item>
+
+                            <el-form-item label="Стиль">
+                                <el-col :span="8">
+                                    <el-checkbox v-model="fontBold">Жирный</el-checkbox>
+                                </el-col>
+                                <el-col :span="8">
+                                    <el-checkbox v-model="fontItalic">Курсив</el-checkbox>
+                                </el-col>
+                            </el-form-item>
+                        </el-form>
+
+                        <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
+                            <div class="partHeader">Текст</div>
+
+                            <el-form-item label="Интервал">
+                                <el-input-number v-model="lineInterval" :min="0" :max="100"></el-input-number>
+                            </el-form-item>
+                            <el-form-item label="Параграф">
+                                <el-input-number v-model="p" :min="0" :max="200"></el-input-number>
+                            </el-form-item>
+                            <el-form-item label="Отступ">
+                                <el-col :span="11">
+                                    <el-tooltip :open-delay="500" effect="light">
+                                        <template slot="content">
+                                            Слева/справа
+                                        </template>
+                                        <el-input-number v-model="indentLR" :min="0" :max="200"></el-input-number>
+                                    </el-tooltip>
+                                </el-col>
+                                <el-col :span="1">
+                                    &nbsp;
+                                </el-col>
+                                <el-col :span="11">
+                                    <el-tooltip :open-delay="500" effect="light">
+                                        <template slot="content">
+                                            Сверху/снизу
+                                        </template>
+                                        <el-input-number v-model="indentTB" :min="0" :max="200"></el-input-number>
+                                    </el-tooltip>
+                                </el-col>
+                            </el-form-item>
+                            <el-form-item label="Сдвиг">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Сдвиг текста по вертикали в процентах от размера шрифта.<br>
+                                        Отрицательное значение сдвигает вверх, положительное -<br>
+                                        вниз.
+                                    </template>
+                                    <el-input-number v-model="textVertShift" :min="-100" :max="100"></el-input-number>
+                                </el-tooltip>
+                            </el-form-item>
+                            <el-form-item label="Скроллинг">
+                                <el-col :span="11">
+                                    <el-tooltip :open-delay="500" effect="light">
+                                        <template slot="content">
+                                            Замедление скроллинга в миллисекундах.<br>
+                                            Определяет время, за которое текст<br>
+                                            прокручивается на одну строку.
+                                        </template>
+                                        <el-input-number v-model="scrollingDelay" :min="1" :max="10000"></el-input-number>
+                                    </el-tooltip>
+                                </el-col>
+                                <el-col :span="1">
+                                    &nbsp;
+                                </el-col>
+                                <el-col :span="11">
+                                    <el-tooltip :open-delay="500" effect="light" placement="top">
+                                        <template slot="content">
+                                            Вид скроллинга: линейный,<br>
+                                            ускорение-замедление и пр.
+                                        </template>
+
+                                        <el-select v-model="scrollingType">
+                                            <el-option value="linear"></el-option>
+                                            <el-option value="ease"></el-option>
+                                            <el-option value="ease-in"></el-option>
+                                            <el-option value="ease-out"></el-option>
+                                            <el-option value="ease-in-out"></el-option>
+                                        </el-select>
+                                    </el-tooltip>
+                                </el-col>
+
+                            </el-form-item>
+                            <el-form-item label="Выравнивание">
+                                <el-checkbox v-model="textAlignJustify">По ширине</el-checkbox>
+                                <el-checkbox v-model="wordWrap">Перенос по слогам</el-checkbox>
+                            </el-form-item>
+                        </el-form>
+
+                        <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
+                            <div class="partHeader">Строка статуса</div>
+
+                            <el-form-item label="Статус">
+                                <el-checkbox v-model="showStatusBar">Показывать</el-checkbox>
+                                <el-checkbox v-model="statusBarTop" :disabled="!showStatusBar">Вверху/внизу</el-checkbox>
+                            </el-form-item>
+                            <el-form-item label="Высота">
+                                <el-input-number v-model="statusBarHeight" :min="5" :max="50" :disabled="!showStatusBar"></el-input-number>
+                            </el-form-item>
+                            <el-form-item label="Прозрачность">
+                                <el-input-number v-model="statusBarColorAlpha" :min="0" :max="1" :precision="2" :step="0.1" :disabled="!showStatusBar"></el-input-number>
+                            </el-form-item>
+                        </el-form>
+                    </el-tab-pane>
+
+                    <!--------------------------------------------------------------------------->
+                    <el-tab-pane label="Листание">
+                        <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
+                            <div class="partHeader">Анимация</div>
+
+                            <el-form-item label="Вид">
+                                не готово
+                            </el-form-item>
+
+                            <el-form-item label="Скорость">
+                                не готово
+                            </el-form-item>
+                        </el-form>
+
+                        <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
+                            <div class="partHeader">Другое</div>
+
+                            <el-form-item label="Страница">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Переносить последнюю строку страницы<br>
+                                        в начало следующей при листании
+                                    </template>
+                                    <el-checkbox v-model="keepLastToFirst">Переносить последнюю строку</el-checkbox>
+                                </el-tooltip>
+                            </el-form-item>
+                        </el-form>
+                        
+                    </el-tab-pane>
+                    <!--------------------------------------------------------------------------->
+                    <el-tab-pane label="Прочее">
+                        <el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
+                            <el-form-item label="Подсказка">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Показывать или нет подсказку при каждой загрузке книги
+                                    </template>
+                                    <el-checkbox v-model="showClickMapPage">Показывать области управления кликом</el-checkbox>
+                                </el-tooltip>
+                            </el-form-item>                            
+                            <el-form-item label="URL">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Добавление параметра "__p" в строке браузера<br>
+                                        позволяет передавать ссылку на книгу в читалке<br>
+                                        без потери текущей позиции. Однако в этом случае<br>
+                                        при листании забивается история браузера, т.к. на<br>
+                                        каждое изменение позиции происходит смена URL.
+                                    </template>
+                                    <el-checkbox v-model="allowUrlParamBookPos">Добавлять параметр "__p"</el-checkbox>
+                                </el-tooltip>
+                            </el-form-item>
+                            <el-form-item label="Парсинг">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Включение этой опции позволяет делать предварительную<br>
+                                        обработку текста в ленивом режиме сразу после загрузки<br>
+                                        книги. Это может повысить отзывчивость читалки, но<br>
+                                        нагружает процессор каждый раз при открытии книги.
+                                    </template>
+                                    <el-checkbox v-model="lazyParseEnabled">Предварительная обработка текста</el-checkbox>
+                                </el-tooltip>
+                            </el-form-item>
+                            <el-form-item label="Копирование">
+                                <el-tooltip :open-delay="500" effect="light">
+                                    <template slot="content">
+                                        Загружать весь текст в окно<br>
+                                        копирования текста со страницы
+                                    </template>
+                                    <el-checkbox v-model="copyFullText">Загружать весь текст</el-checkbox>
+                                </el-tooltip>
+                            </el-form-item>
+
+                        </el-form>
+                        
+                    </el-tab-pane>
+
+                </el-tabs>
+            </Window>
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+import Window from '../../share/Window.vue';
+import rstore from '../../../store/modules/reader';
+
+export default @Component({
+    components: {
+        Window,
+    },
+    data: function() {
+        return Object.assign({}, rstore.settingDefaults);
+    },
+    watch: {
+        form: function(newValue) {
+            this.commit('reader/setSettings', newValue);
+        },
+        fontBold: function(newValue) {
+            this.fontWeight = (newValue ? 'bold' : '');
+        },
+        fontItalic: function(newValue) {
+            this.fontStyle = (newValue ? 'italic' : '');
+        },
+        vertShift: function(newValue) {
+            const font = (this.webFontName ? this.webFontName : this.fontName);
+            this.fontShifts = Object.assign({}, this.fontShifts, {[font]: newValue});
+            this.fontVertShift = newValue;
+        },
+        fontName: function(newValue) {
+            const font = (this.webFontName ? this.webFontName : newValue);
+            this.vertShift = this.fontShifts[font] || 0;
+        },
+        webFontName: function(newValue) {
+            const font = (newValue ? newValue : this.fontName);
+            this.vertShift = this.fontShifts[font] || 0;
+        },
+    },
+})
+class SettingsPage extends Vue {
+    selectedTab = null;
+    form = {};
+    fontBold = false;
+    fontItalic = false;
+    vertShift = 0;
+
+    webFonts = [];
+    fonts = [];
+
+    created() {
+        this.commit = this.$store.commit;
+        this.reader = this.$store.state.reader;
+
+        this.form = Object.assign({}, this.settings);
+        for (let prop in rstore.settingDefaults) {
+            this[prop] = this.form[prop];
+            this.$watch(prop, (newValue) => {
+                this.form = Object.assign({}, this.form, {[prop]: newValue});
+            });
+        }
+        this.fontBold = (this.fontWeight == 'bold');
+        this.fontItalic = (this.fontStyle == 'italic');
+
+        this.fonts = rstore.fonts;
+        this.webFonts = rstore.webFonts;
+        const font = (this.webFontName ? this.webFontName : this.fontName);
+        this.vertShift = this.fontShifts[font] || 0;
+    }
+
+    get settings() {
+        return this.$store.state.reader.settings;
+    }
+
+    get predefineTextColors() {
+        return [
+          '#ffffff',
+          '#000000',
+          '#202020',
+          '#323232',
+          '#aaaaaa',
+          '#00c0c0',
+        ];
+    }
+
+    get predefineBackgroundColors() {
+        return [
+          '#ffffff',
+          '#000000',
+          '#202020',
+          '#ebe2c9',
+          '#909080',
+          '#808080',
+          '#c8c8c8',
+          '#478355',
+          '#a6caf0',
+        ];
+    }
+
+    close() {
+        this.$emit('settings-toggle');
+    }
+
+    keyHook(event) {
+        if (event.type == 'keydown' && event.code == 'Escape') {
+            this.close();
+        }
+        return true;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.main {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 60;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+}
+
+.mainWindow {
+    height: 70%;
+    display: flex;
+    position: relative;
+}
+
+.el-form {
+    border-top: 2px solid #bbbbbb;
+    margin-bottom: 5px;
+}
+
+.el-form-item {
+    padding: 0;
+    margin: 0;
+    margin-bottom: 5px;
+}
+
+.color-picked {
+    margin-left: 10px;
+    position: relative;
+    top: -11px;
+}
+
+.partHeader {
+    font-weight: bold;
+    margin-bottom: 5px;
+}
+
+.el-tabs {
+    flex: 1;
+    display: flex;
+}
+
+.el-tab-pane {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    width: 420px;
+    overflow-y: auto;
+    padding: 15px;
+}
+
+</style>

+ 98 - 0
client/components/Reader/TextPage/DrawHelper.js

@@ -0,0 +1,98 @@
+export default class DrawHelper {
+    fontBySize(size) {
+        return `${size}px ${this.fontName}`;
+    }
+
+    drawPercentBar(x, y, w, h, font, fontSize, bookPos, textLength) {
+        const pad = 3;
+        const fh = h - 2*pad;
+        const fh2 = fh/2;
+
+        const t1 = `${Math.floor((bookPos + 1)/1000)}k/${Math.floor(textLength/1000)}k`;
+        const w1 = this.measureTextFont(t1, font) + fh2;
+        const read = (bookPos + 1)/textLength;
+        const t2 = `${(read*100).toFixed(2)}%`;
+        const w2 = this.measureTextFont(t2, font);
+        let w3 = w - w1 - w2;
+
+        let out = '';
+        if (w1 + w2 <= w)
+            out += this.fillTextShift(t1, x, y, font, fontSize);
+        
+        if (w1 + w2 + w3 <= w && w3 > (10 + fh2)) {
+            const barWidth = w - w1 - w2 - fh2;
+            out += this.strokeRect(x + w1, y + pad, barWidth, fh - 2, this.statusBarColor);
+            out += this.fillRect(x + w1 + 2, y + pad + 2, (barWidth - 4)*read, fh - 6, this.statusBarColor);
+        }
+
+        if (w1 <= w)
+            out += this.fillTextShift(t2, x + w1 + w3, y, font, fontSize);
+
+        return out;
+    }
+
+    drawStatusBar(statusBarTop, statusBarHeight, bookPos, textLength, title) {
+
+        let out = `<div class="layout" style="` + 
+            `width: ${this.realWidth}px; height: ${statusBarHeight}px; ` + 
+            `color: ${this.statusBarColor}">`;
+
+        const fontSize = statusBarHeight*0.75;
+        const font = 'bold ' + this.fontBySize(fontSize);
+
+        out += this.fillRect(0, (statusBarTop ? statusBarHeight : 0), this.realWidth, 1, this.statusBarColor);
+
+        const date = new Date();
+        const time = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
+        const timeW = this.measureTextFont(time, font);
+        out += this.fillTextShift(time, this.realWidth - timeW - fontSize, 2, font, fontSize);
+
+        out += this.fillTextShift(this.fittingString(title, this.realWidth/2 - fontSize - 3, font), fontSize, 2, font, fontSize);
+
+        out += this.drawPercentBar(this.realWidth/2, 2, this.realWidth/2 - timeW - 2*fontSize, statusBarHeight, font, fontSize, bookPos, textLength);
+        
+        out += '</div>';
+        return out;
+    }
+
+    statusBarClickable(statusBarTop, statusBarHeight) {
+        return `<div class="layout" style="position: absolute; ` + 
+            `left: 0px; top: ${statusBarTop ? 1 : this.realHeight - statusBarHeight + 1}px; ` +
+            `width: ${this.realWidth/2}px; height: ${statusBarHeight}px; cursor: pointer"></div>`;
+    }
+
+    fittingString(str, maxWidth, font) {
+        let w = this.measureTextFont(str, font);
+        const ellipsis = '…';
+        const ellipsisWidth = this.measureTextFont(ellipsis, font);
+        if (w <= maxWidth || w <= ellipsisWidth) {
+            return str;
+        } else {
+            let len = str.length;
+            while (w >= maxWidth - ellipsisWidth && len-- > 0) {
+                str = str.substring(0, len);
+                w = this.measureTextFont(str, font);
+            }
+            return str + ellipsis;
+        }
+    }
+
+    fillTextShift(text, x, y, font, size, css) {
+        return this.fillText(text, x, y + size*this.fontShift, font, css);        
+    }
+
+    fillText(text, x, y, font, css) {
+        css = (css ? css : '');
+        return `<div style="position: absolute; white-space: pre; left: ${x}px; top: ${y}px; font: ${font}; ${css}">${text}</div>`;
+    }
+
+    fillRect(x, y, w, h, color) {
+        return `<div style="position: absolute; left: ${x}px; top: ${y}px; ` +
+            `width: ${w}px; height: ${h}px; background-color: ${color}"></div>`; 
+    }
+
+    strokeRect(x, y, w, h, color) {
+        return `<div style="position: absolute; left: ${x}px; top: ${y}px; ` +
+            `width: ${w}px; height: ${h}px; box-sizing: border-box; border: 1px solid ${color}"></div>`; 
+    }
+}

+ 1165 - 0
client/components/Reader/TextPage/TextPage.vue

@@ -0,0 +1,1165 @@
+<template>
+    <div ref="main" class="main">
+        <div class="layout back">
+            <div v-html="background"></div>
+            <!-- img -->
+        </div>
+        <div ref="scrollBox1" class="layout" style="overflow: hidden">
+            <div ref="scrollingPage" class="layout" @transitionend="onScrollingTransitionEnd">
+                <div v-html="page1"></div>
+            </div>
+        </div>
+        <div ref="scrollBox2" class="layout" style="overflow: hidden">
+            <div v-html="page2"></div>
+        </div>
+        <div v-show="showStatusBar" ref="statusBar" class="layout">
+            <div v-html="statusBar"></div>
+        </div>
+        <div ref="layoutEvents" class="layout events" @mousedown.prevent.stop="onMouseDown" @mouseup.prevent.stop="onMouseUp"
+            @wheel.prevent.stop="onMouseWheel"
+            @touchstart.stop="onTouchStart" @touchend.stop="onTouchEnd" @touchcancel.prevent.stop="onTouchCancel"
+            oncontextmenu="return false;">
+            <div v-show="showStatusBar" v-html="statusBarClickable" @mousedown.prevent.stop @touchstart.stop
+                @click.prevent.stop="onStatusBarClick"></div>
+            <div v-show="fontsLoading" ref="fontsLoading"></div>
+        </div>
+        <!-- невидимым делать нельзя, вовремя не подгружаютя шрифты -->
+        <canvas ref="offscreenCanvas" class="layout" style="width: 0px; height: 0px"></canvas>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import {loadCSS} from 'fg-loadcss';
+import _ from 'lodash';
+import {sleep} from '../../../share/utils';
+
+import bookManager from '../share/bookManager';
+import DrawHelper from './DrawHelper';
+import rstore from '../../../store/modules/reader';
+import {clickMap} from '../share/clickMap';
+
+const minLayoutWidth = 100;
+
+export default @Component({
+    watch: {
+        bookPos: function(newValue) {
+            this.$emit('book-pos-changed', {bookPos: newValue, bookPosSeen: this.bookPosSeen});
+            this.draw();
+        },
+        settings: function() {
+            this.debouncedLoadSettings();
+        },
+        toggleLayout: function() {
+            this.updateLayout();
+        },
+    },
+})
+class TextPage extends Vue {
+    toggleLayout = false;
+    showStatusBar = false;
+    background = null;
+    page1 = null;
+    page2 = null;
+    statusBar = null;
+    statusBarClickable = null;
+    fontsLoading = null;
+
+    lastBook = null;
+    bookPos = 0;
+
+    fontStyle = null;
+    fontSize = null;
+    fontName = null;
+
+    meta = null;
+
+    created() {
+        this.drawHelper = new DrawHelper();
+
+        this.commit = this.$store.commit;
+        this.dispatch = this.$store.dispatch;
+        this.config = this.$store.state.config;
+        this.reader = this.$store.state.reader;
+
+        this.debouncedStartClickRepeat = _.debounce((x, y) => {
+            this.startClickRepeat(x, y);
+        }, 800);
+
+        this.debouncedPrepareNextPage = _.debounce(() => {
+            this.prepareNextPage();
+        }, 100);
+
+        this.debouncedDrawStatusBar = _.throttle(() => {
+            this.drawStatusBar();
+        }, 60);        
+
+        this.debouncedLoadSettings = _.debounce(() => {
+            this.loadSettings();
+        }, 50);
+
+        this.debouncedUpdatePage = _.debounce((lines) => {
+            this.toggleLayout = !this.toggleLayout;
+
+            if (this.toggleLayout)
+                this.page1 = this.drawPage(lines);
+            else
+                this.page2 = this.drawPage(lines);
+
+            this.doPageTransition();
+        }, 10);
+
+        this.$root.$on('resize', () => {this.$nextTick(this.onResize)});
+        this.mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
+    }
+
+    mounted() {
+        this.context = this.$refs.offscreenCanvas.getContext('2d');
+    }
+
+    hex2rgba(hex, alpha = 1) {
+        const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
+        return `rgba(${r},${g},${b},${alpha})`;
+    }
+
+    calcDrawProps() {
+        //preloaded fonts
+        this.fontList = [`12px ${this.fontName}`];
+
+        //widths
+        this.realWidth = this.$refs.main.clientWidth;
+        this.realHeight = this.$refs.main.clientHeight;
+
+        this.$refs.layoutEvents.style.width = this.realWidth + 'px';
+        this.$refs.layoutEvents.style.height = this.realHeight + 'px';
+
+        this.w = this.realWidth - 2*this.indentLR;
+        this.h = this.realHeight - (this.showStatusBar ? this.statusBarHeight : 0) - 2*this.indentTB;
+        this.lineHeight = this.fontSize + this.lineInterval;
+        this.pageLineCount = 1 + Math.floor((this.h - this.fontSize)/this.lineHeight);
+
+        if (this.parsed) {
+            this.parsed.p = this.p;
+            this.parsed.w = this.w;// px, ширина текста
+            this.parsed.font = this.font;
+            this.parsed.wordWrap = this.wordWrap;
+            let t = '';
+            while (this.measureText(t, {}) < this.w) t += 'Щ';
+            this.parsed.maxWordLength = t.length - 1;
+            this.parsed.measureText = this.measureText;
+        }
+
+        //сообщение "Загрузка шрифтов..."
+        const flText = 'Загрузка шрифта...';
+        this.$refs.fontsLoading.innerHTML = flText;
+        const fontsLoadingStyle = this.$refs.fontsLoading.style;
+        fontsLoadingStyle.position = 'absolute';
+        fontsLoadingStyle.fontSize = this.fontSize + 'px';
+        fontsLoadingStyle.top = (this.realHeight/2 - 2*this.fontSize) + 'px';
+        fontsLoadingStyle.left = (this.realWidth - this.measureText(flText, {}))/2 + 'px';
+
+        //stuff
+        this.statusBarColor = this.hex2rgba(this.textColor || '#000000', this.statusBarColorAlpha);
+        this.currentTransition = '';
+        this.pageChangeDirectionDown = true;
+        this.fontShift = this.fontVertShift/100;
+        this.textShift = this.textVertShift/100 + this.fontShift;
+
+        //drawHelper
+        this.drawHelper.realWidth = this.realWidth;
+        this.drawHelper.realHeight = this.realHeight;
+
+        this.drawHelper.backgroundColor = this.backgroundColor;
+        this.drawHelper.statusBarColor = this.statusBarColor;
+        this.drawHelper.fontName = this.fontName;
+        this.drawHelper.fontShift = this.fontShift;
+        this.drawHelper.measureText = this.measureText;
+        this.drawHelper.measureTextFont = this.measureTextFont;
+
+        this.$refs.statusBar.style.left = '0px';
+        this.$refs.statusBar.style.top = (this.statusBarTop ? 1 : this.realHeight - this.statusBarHeight) + 'px';
+
+        this.statusBarClickable = this.drawHelper.statusBarClickable(this.statusBarTop, this.statusBarHeight);
+
+        //scrolling page
+        const pageDelta = this.h - (this.pageLineCount*this.lineHeight - this.lineInterval);
+        let y = this.indentTB + pageDelta/2;
+        if (this.showStatusBar)
+            y += this.statusBarHeight*(this.statusBarTop ? 1 : 0);
+        const page1 = this.$refs.scrollBox1;
+        const page2 = this.$refs.scrollBox2;
+        page1.style.width = this.w + 'px';
+        page2.style.width = this.w + 'px';
+        page1.style.height = (this.h - pageDelta) + 'px';
+        page2.style.height = (this.h - pageDelta) + 'px';
+        page1.style.top = y + 'px';
+        page2.style.top = y + 'px';
+        page1.style.left = this.indentLR + 'px';
+        page2.style.left = this.indentLR + 'px';
+    }
+
+    measureText(text, style) {// eslint-disable-line no-unused-vars
+        this.context.font = this.fontByStyle(style);
+        return this.context.measureText(text).width;
+    }
+
+    measureTextFont(text, font) {// eslint-disable-line no-unused-vars
+        this.context.font = font;
+        return this.context.measureText(text).width;
+    }
+
+    async checkLoadedFonts() {
+        let loaded = await Promise.all(this.fontList.map(font => document.fonts.check(font)));
+        if (loaded.some(r => !r)) {
+            loaded = await Promise.all(this.fontList.map(font => document.fonts.load(font)));
+            if (loaded.some(r => !r.length))
+                throw new Error('some font not loaded');
+        }
+    }
+
+    async loadFonts() {
+        this.fontsLoading = true;
+
+        if (!this.fontsLoaded)
+            this.fontsLoaded = {};
+        //загрузка дин.шрифта
+        const loaded = this.fontsLoaded[this.fontCssUrl];
+        if (this.fontCssUrl && !loaded) {
+            loadCSS(this.fontCssUrl);
+            this.fontsLoaded[this.fontCssUrl] = 1;
+        }
+
+        const waitingTime = 10*1000;
+        const delay = 100;
+        let i = 0;
+        //ждем шрифты
+        while (i < waitingTime/delay) {
+            i++;
+            try {
+                await this.checkLoadedFonts();
+                i = waitingTime;
+            } catch (e) {
+                await sleep(delay);
+            }
+        }
+        if (i !== waitingTime) {
+            this.$notify.error({
+                title: 'Ошибка загрузки',
+                message: 'Некоторые шрифты не удалось загрузить'
+            });
+        }
+
+        this.fontsLoading = false;
+    }
+
+    getSettings() {
+        const settings = this.settings;
+
+        for (let prop in rstore.settingDefaults) {
+            this[prop] = settings[prop];
+        }
+
+        const wf = this.webFontName;
+        const i = _.findIndex(rstore.webFonts, ['name', wf]);
+        if (wf && i >= 0) {
+            this.fontName = wf;
+            this.fontCssUrl = rstore.webFonts[i].css;
+            this.fontVertShift = settings.fontShifts[wf] || 0;
+        }
+    }
+
+    async calcPropsAndLoadFonts(omitLoadFonts) {
+        this.calcDrawProps();
+        this.setBackground();
+
+        if (!omitLoadFonts)
+            await this.loadFonts();
+
+        this.draw();
+
+        // шрифты хрен знает когда подгружаются, поэтому
+        const parsed = this.parsed;
+        if (!parsed.force) {
+            let i = 0;
+            parsed.force = true;
+            while (i < 10) {
+                await sleep(1000);
+                if (this.parsed != parsed)
+                    break;
+                this.draw();
+                i++;
+            }
+            parsed.force = false;
+        }
+    }
+
+    loadSettings() {
+        (async() => {
+            let fontName = this.fontName;
+            this.getSettings();
+            await this.calcPropsAndLoadFonts(fontName == this.fontName);
+        })();
+    }
+
+    showBook() {
+        this.$refs.main.focus();
+
+        this.toggleLayout = false;
+        this.updateLayout();
+        this.book = null;
+        this.meta = null;
+        this.fb2 = null;
+        this.parsed = null;
+
+        this.linesUp = null;
+        this.linesDown = null;
+        this.searching = false;
+
+        this.statusBarMessage = '';
+
+        this.getSettings();
+        this.calcDrawProps();
+        this.draw();// пока не загрузили, очистим канвас
+
+        if (this.lastBook) {
+            (async() => {
+                //подождем ленивый парсинг
+                this.stopLazyParse = true;
+                while (this.doingLazyParse) await sleep(10);
+
+                const isParsed = await bookManager.hasBookParsed(this.lastBook);
+                if (!isParsed) {
+                    return;
+                }
+
+                this.book = await bookManager.getBook(this.lastBook);
+                this.meta = bookManager.metaOnly(this.book);
+                this.fb2 = this.meta.fb2;
+
+                const authorName = _.compact([
+                    this.fb2.lastName,
+                    this.fb2.firstName,
+                    this.fb2.middleName
+                ]).join(' ');
+                this.title = _.compact([
+                    authorName,
+                    this.fb2.bookTitle
+                ]).join(' - ');
+
+                this.$root.$emit('set-app-title', this.title);
+
+                this.parsed = this.book.parsed;
+
+                this.page1 = null;
+                this.page2 = null;
+                this.statusBar = null;
+                await this.stopTextScrolling();
+
+                this.calcPropsAndLoadFonts();
+
+                this.refreshTime();
+                if (this.lazyParseEnabled)
+                    this.lazyParsePara();
+            })();
+        }
+    }
+
+    updateLayout() {
+        if (this.toggleLayout) {
+            this.$refs.scrollBox1.style.visibility = 'visible';
+            this.$refs.scrollBox2.style.visibility = 'hidden';
+        } else {
+            this.$refs.scrollBox1.style.visibility = 'hidden';
+            this.$refs.scrollBox2.style.visibility = 'visible';
+        }
+    }
+
+    setBackground() {
+        this.background = `<div class="layout ${this.wallpaper}" style="width: ${this.realWidth}px; height: ${this.realHeight}px;` + 
+            ` background-color: ${this.backgroundColor}"></div>`;
+    }
+
+    async onResize() {
+        /*this.page1 = null;
+        this.page2 = null;
+        this.statusBar = null;*/
+
+        this.calcDrawProps();
+        this.setBackground();
+        this.draw();
+    }
+
+    get settings() {
+        return this.$store.state.reader.settings;
+    }
+
+    get font() {
+        return `${this.fontStyle} ${this.fontWeight} ${this.fontSize}px ${this.fontName}`;
+    }
+
+    fontByStyle(style) {
+        return `${style.italic ? 'italic' : this.fontStyle} ${style.bold ? 'bold' : this.fontWeight} ${this.fontSize}px ${this.fontName}`;
+    }
+
+    onScrollingTransitionEnd() {
+        if (this.resolveTransitionFinish)
+            this.resolveTransitionFinish();
+    }
+
+    startSearch(needle) {
+        this.needle = '';
+        const words = needle.split(' ');
+        for (const word of words) {
+            if (word != '') {
+                this.needle = word;
+                break;
+            }
+        }
+
+        this.searching = true;
+        this.draw();
+    }
+
+    stopSearch() {
+        this.searching = false;
+        this.draw();
+    }
+
+    async startTextScrolling() {
+        if (this.doingScrolling || !this.book || !this.parsed.textLength || !this.linesDown || this.pageLineCount < 1 ||
+            this.linesDown.length <= this.pageLineCount) {
+            this.$emit('stop-scrolling');
+            return;
+        }
+
+        this.stopScrolling = false;
+        this.doingScrolling = true;
+
+        const transitionFinish = (timeout) => {
+            return new Promise(async(resolve) => {
+                this.resolveTransitionFinish = resolve;
+                let wait = timeout/100;
+                while (wait > 0 && !this.stopScrolling) {
+                    wait--;
+                    await sleep(100);
+                }
+                resolve();
+            });
+        };
+
+        if (!this.toggleLayout)
+            this.page1 = this.page2;
+        this.toggleLayout = true;
+        await this.$nextTick();
+        await sleep(50);
+
+        this.cachedPos = -1;
+        const page = this.$refs.scrollingPage;
+        let i = 0;
+        while (!this.stopScrolling) {
+                page.style.transition = `${this.scrollingDelay}ms ${this.scrollingType}`;
+                page.style.transform = `translateY(-${this.lineHeight}px)`;
+
+                if (i > 0) {
+                    this.doDown();
+                    if (this.linesDown.length <= this.pageLineCount + 1) {
+                        this.stopScrolling = true;
+                    }
+                }
+                await transitionFinish(this.scrollingDelay + 201);
+                page.style.transition = '';
+                page.style.transform = 'none';
+                page.offsetHeight;
+                i++;
+        }
+        this.resolveTransitionFinish = null;
+        this.doingScrolling = false;
+        this.$emit('stop-scrolling');
+    }
+
+    async stopTextScrolling() {
+        this.stopScrolling = true;
+
+        const page = this.$refs.scrollingPage;
+        page.style.transition = '';
+        page.style.transform = 'none';
+        page.offsetHeight;
+
+        while (this.doingScrolling) await sleep(10);
+    }
+
+    draw() {
+        if (this.doingScrolling) {
+            if (this.cachedPos == this.bookPos) {
+                this.linesDown = this.linesCached.linesDown;
+                this.linesUp = this.linesCached.linesUp;
+                this.page1 = this.pageCached;
+            } else {
+                const lines = this.getLines(this.bookPos);
+                this.linesDown = lines.linesDown;
+                this.linesUp = lines.linesUp;
+                this.page1 = this.drawPage(lines.linesDown);
+            }
+
+            //caching next
+            if (this.cachedPageTimer)
+                clearTimeout(this.cachedPageTimer);
+            this.cachedPageTimer = setTimeout(() => {
+                if (this.linesDown && this.linesDown.length > this.pageLineCount && this.pageLineCount > 0) {
+                    this.cachedPos = this.linesDown[1].begin;
+                    this.linesCached = this.getLines(this.cachedPos);
+                    this.pageCached = this.drawPage(this.linesCached.linesDown);
+                }
+                this.cachedPageTimer = null;
+            }, 20);
+
+            this.debouncedDrawStatusBar();
+            return;
+        }
+
+        if (this.w < minLayoutWidth) {
+            this.page1 = null;
+            this.page2 = null;
+            this.statusBar = null;
+            return;
+        }
+
+        if (this.book && this.bookPos > 0 && this.bookPos >= this.parsed.textLength) {
+            this.doEnd();
+            return;
+        }
+
+
+        if (this.pageChangeDirectionDown && this.pagePrepared && this.bookPos == this.bookPosPrepared) {
+            this.toggleLayout = !this.toggleLayout;
+            this.linesDown = this.linesDownNext;
+            this.linesUp = this.linesUpNext;
+            this.doPageTransition();
+        } else {
+            const lines = this.getLines(this.bookPos);
+            this.linesDown = lines.linesDown;
+            this.linesUp = lines.linesUp;
+
+            /*if (this.toggleLayout)
+                this.page1 = this.drawPage(lines.linesDown);
+            else
+                this.page2 = this.drawPage(lines.linesDown);*/
+            
+            this.debouncedUpdatePage(lines.linesDown);
+        }
+
+        this.pagePrepared = false;
+        this.debouncedPrepareNextPage();
+        this.debouncedDrawStatusBar();
+
+        if (this.book && this.linesDown && this.linesDown.length < this.pageLineCount)
+            this.doEnd();
+    }
+
+    doPageTransition() {
+        if (this.currentTransition) {
+            //this.currentTransition
+            //this.pageChangeTransitionSpeed
+            //this.pageChangeDirectionDown  
+            
+            //curr to next transition
+            //пока заглушка
+        }
+
+        this.currentTransition = '';
+        this.pageChangeDirectionDown = false;//true только если PgDown
+    }
+
+    getLines(bookPos) {
+        if (!this.parsed || this.pageLineCount < 1)
+            return {};
+
+        return {
+            linesDown: this.parsed.getLines(bookPos, 2*this.pageLineCount),
+            linesUp: this.parsed.getLines(bookPos, -2*this.pageLineCount)
+        };
+    }
+
+    drawPage(lines) {
+        if (!this.lastBook || this.pageLineCount < 1 || !this.book || !lines || !this.parsed.textLength)
+            return '';
+
+        const spaceWidth = this.measureText(' ', {});
+
+        let out = `<div class="layout" style="width: ${this.realWidth}px; height: ${this.realHeight}px;` + 
+            ` color: ${this.textColor}">`;
+
+        let len = lines.length;
+        len = (len > this.pageLineCount + 1 ? this.pageLineCount + 1 : len);
+
+        let y = this.fontSize*this.textShift;
+
+        for (let i = 0; i < len; i++) {
+            const line = lines[i];
+            /* line:
+            {
+                begin: Number,
+                end: Number,
+                first: Boolean,
+                last: Boolean,
+                parts: array of {
+                    style: {bold: Boolean, italic: Boolean, center: Boolean}
+                    text: String,
+                }
+            }*/
+
+            let indent = line.first ? this.p : 0;
+
+            let lineText = '';
+            let center = false;
+            let centerStyle = {};
+            for (const part of line.parts) {
+                lineText += part.text;
+                center = center || part.style.center;
+                if (part.style.center)
+                    centerStyle = part.style;
+            }
+
+            let filled = false;
+            // если выравнивание по ширине включено
+            if (this.textAlignJustify && !line.last && !center) {
+                const words = lineText.split(' ');
+
+                if (words.length > 1) {
+                    const spaceCount = words.length - 1;
+
+                    const space = (this.w - line.width + spaceWidth*spaceCount)/spaceCount;
+
+                    let x = indent;
+                    for (const part of line.parts) {
+                        const font = this.fontByStyle(part.style);
+                        let partWords = part.text.split(' ');
+
+                        for (let j = 0; j < partWords.length; j++) {
+                            let f = font;
+                            let style = part.style;
+                            let word = partWords[j];
+                            if (i == 0 && this.searching && word.toLowerCase().indexOf(this.needle) >= 0) {
+                                style = Object.assign({}, part.style, {bold: true});
+                                f = this.fontByStyle(style);
+                            }
+                            out += this.drawHelper.fillText(word, x, y, f);
+                            x += this.measureText(word, style) + (j < partWords.length - 1 ? space : 0);
+                        }
+                    }
+                    filled = true;
+                }
+            }
+
+            // просто выводим текст
+            if (!filled) {
+                let x = indent;
+                x = (center ? (this.w - this.measureText(lineText, centerStyle))/2 : x);
+                for (const part of line.parts) {
+                    let font = this.fontByStyle(part.style);
+
+                    if (i == 0 && this.searching) {//для поиска, разбивка по словам
+                        let partWords = part.text.split(' ');
+                        for (let j = 0; j < partWords.length; j++) {
+                            let f = font;
+                            let style = part.style;
+                            let word = partWords[j];
+                            if (word.toLowerCase().indexOf(this.needle) >= 0) {
+                                style = Object.assign({}, part.style, {bold: true});
+                                f = this.fontByStyle(style);
+                            }
+                            out += this.drawHelper.fillText(word, x, y, f);
+                            x += this.measureText(word, style) + (j < partWords.length - 1 ? spaceWidth : 0);
+                        }
+                    } else {
+                        out += this.drawHelper.fillText(part.text, x, y, font);
+                        x += this.measureText(part.text, part.style);
+                    }
+                }
+            }
+            y += this.lineHeight;
+        }
+
+        out += '</div>';
+        return out;
+    }
+
+    drawStatusBar(message) {
+        if (this.w < minLayoutWidth) {
+            this.statusBar = null;
+            return;
+        }
+
+        if (this.showStatusBar && this.linesDown && this.pageLineCount > 0) {
+            const lines = this.linesDown;
+            let i = this.pageLineCount;
+            if (this.keepLastToFirst)
+                i--;
+            i = (i > lines.length - 1 ? lines.length - 1 : i);
+            if (i >= 0) {
+                if (!message)
+                    message = this.statusBarMessage;
+                if (!message)
+                    message = this.title;
+                this.statusBar = this.drawHelper.drawStatusBar(this.statusBarTop, this.statusBarHeight, 
+                    lines[i].end, this.parsed.textLength, message);
+                this.bookPosSeen = lines[i].end;
+            }
+        } else {
+            this.statusBar = '';
+        }
+    }
+
+    blinkCachedLoadMessage(state) {
+        if (state === 'finish') {
+            this.statusBarMessage = '';
+        } else if (state) {
+            this.statusBarMessage = 'Книга загружена из кэша';
+        } else {
+            this.statusBarMessage = ' ';
+        }
+        this.drawStatusBar();
+    }
+
+    async lazyParsePara() {
+        if (!this.parsed || this.doingLazyParse)
+            return;
+        this.doingLazyParse = true;
+        let j = 0;
+        let k = 0;
+        let prevPerc = 0;
+        this.stopLazyParse = false;
+        for (let i = 0; i < this.parsed.para.length; i++) {
+            j++;
+            if (j > 1) {
+                await sleep(1);
+                j = 0;
+            }
+            if (this.stopLazyParse)
+                break;
+            this.parsed.parsePara(i);
+            k++;
+            if (k > 100) {
+                let perc = Math.round(i/this.parsed.para.length*100);
+                if (perc != prevPerc)
+                    this.drawStatusBar(`Обработка текста ${perc}%`);
+                prevPerc = perc;
+                k = 0;
+            }
+        }
+        this.drawStatusBar();
+        this.doingLazyParse = false;
+    }
+
+    async refreshTime() {
+        if (!this.timeRefreshing) {
+            this.timeRefreshing = true;
+            await sleep(60*1000);
+
+            if (this.book && this.parsed.textLength) {
+                this.debouncedDrawStatusBar();
+            }
+            this.timeRefreshing = false;
+            this.refreshTime();
+        }
+    }
+
+    prepareNextPage() {
+        // подготовка следующей страницы заранее        
+        if (!this.book || !this.parsed.textLength || !this.linesDown || this.pageLineCount < 1)
+            return;
+        
+        let i = this.pageLineCount;
+        if (this.keepLastToFirst)
+            i--;
+        if (i >= 0 && this.linesDown.length > i) {
+            this.bookPosPrepared = this.linesDown[i].begin;
+            
+            const lines = this.getLines(this.bookPosPrepared);
+            this.linesDownNext = lines.linesDown;
+            this.linesUpNext =  lines.linesUp;
+
+            if (this.toggleLayout)
+                this.page2 = this.drawPage(lines.linesDown);//наоборот
+            else
+                this.page1 = this.drawPage(lines.linesDown);
+
+            this.pagePrepared = true;
+        }
+    }
+
+    doDown() {
+        if (this.linesDown && this.linesDown.length > this.pageLineCount && this.pageLineCount > 0) {
+            this.bookPos = this.linesDown[1].begin;
+        }
+    }
+
+    doUp() {
+        if (this.linesUp && this.linesUp.length > 1 && this.pageLineCount > 0) {
+            this.bookPos = this.linesUp[1].begin;
+        }
+    }
+
+    doPageDown() {
+        if (this.linesDown && this.pageLineCount > 0) {
+            let i = this.pageLineCount;
+            if (this.keepLastToFirst)
+                i--;
+            if (i >= 0 && this.linesDown.length >= 2*i) {
+                this.currentTransition = this.pageChangeTransition;
+                this.pageChangeDirectionDown = true;
+                this.bookPos = this.linesDown[i].begin;
+            } else 
+                this.doEnd();
+        }
+    }
+
+    doPageUp() {
+        if (this.linesUp && this.pageLineCount > 0) {
+            let i = this.pageLineCount;
+            if (this.keepLastToFirst)
+                i--;
+            i = (i > this.linesUp.length - 1 ? this.linesUp.length - 1 : i);
+            if (i >= 0 && this.linesUp.length > i) {
+                this.currentTransition = this.pageChangeTransition;
+                this.pageChangeDirectionDown = false;
+                this.bookPos = this.linesUp[i].begin;
+            }
+        }
+    }
+
+    doHome() {
+        this.bookPos = 0;
+    }
+
+    doEnd() {
+        if (this.parsed.para.length && this.pageLineCount > 0) {
+            let i = this.parsed.para.length - 1;
+            let lastPos = this.parsed.para[i].offset + this.parsed.para[i].length - 1;
+            const lines = this.parsed.getLines(lastPos, -this.pageLineCount);
+            if (lines) {
+                i = this.pageLineCount - 1;
+                i = (i > lines.length - 1 ? lines.length - 1 : i);
+                this.bookPos = lines[i].begin;
+            }
+        }
+    }
+
+    doToolBarToggle() {
+        this.$emit('tool-bar-toggle');
+    }
+
+    async doFontSizeInc() {
+        if (!this.settingsChanging) {
+            this.settingsChanging = true;
+            const newSize = (this.settings.fontSize + 1 < 100 ? this.settings.fontSize + 1 : 100);
+            const newSettings = Object.assign({}, this.settings, {fontSize: newSize});
+            this.commit('reader/setSettings', newSettings);
+            await sleep(50);
+            this.settingsChanging = false;
+        }
+    }
+
+    async doFontSizeDec() {
+        if (!this.settingsChanging) {
+            this.settingsChanging = true;
+            const newSize = (this.settings.fontSize - 1 > 5 ? this.settings.fontSize - 1 : 5);
+            const newSettings = Object.assign({}, this.settings, {fontSize: newSize});
+            this.commit('reader/setSettings', newSettings);
+            await sleep(50);
+            this.settingsChanging = false;
+        }
+    }
+
+    async doScrollingSpeedUp() {
+        if (!this.settingsChanging) {
+            this.settingsChanging = true;
+            const newDelay = (this.settings.scrollingDelay - 50 > 1 ? this.settings.scrollingDelay - 50 : 1);
+            const newSettings = Object.assign({}, this.settings, {scrollingDelay: newDelay});
+            this.commit('reader/setSettings', newSettings);
+            await sleep(50);
+            this.settingsChanging = false;
+        }
+    }
+
+    async doScrollingSpeedDown() {
+        if (!this.settingsChanging) {
+            this.settingsChanging = true;
+            const newDelay = (this.settings.scrollingDelay + 50 < 10000 ? this.settings.scrollingDelay + 50 : 10000);
+            const newSettings = Object.assign({}, this.settings, {scrollingDelay: newDelay});
+            this.commit('reader/setSettings', newSettings);
+            await sleep(50);
+            this.settingsChanging = false;
+        }
+    }
+
+    keyHook(event) {
+        let result = false;
+        if (event.type == 'keydown' && !event.ctrlKey && !event.altKey) {
+            result = true;
+            switch (event.code) {
+                case 'ArrowDown':
+                    if (event.shiftKey)
+                        this.doScrollingSpeedUp();
+                    else
+                        this.doDown();
+                    break;
+                case 'ArrowUp':
+                    if (event.shiftKey)
+                        this.doScrollingSpeedDown();
+                    else
+                        this.doUp();
+                    break;
+                case 'PageDown':
+                case 'ArrowRight':
+                    this.doPageDown();
+                    break;
+                case 'Space':
+                    if (event.shiftKey)
+                        this.doPageUp();
+                    else
+                        this.doPageDown();
+                    break;
+                case 'PageUp':
+                case 'ArrowLeft':
+                case 'Backspace':
+                    this.doPageUp();
+                    break;
+                case 'Home':
+                    this.doHome();
+                    break;
+                case 'End':
+                    this.doEnd();
+                    break;
+                case 'KeyA':
+                    if (event.shiftKey)
+                        this.doFontSizeDec();
+                    else
+                        this.doFontSizeInc();
+                    break;
+                case 'Enter':
+                case 'Backquote'://`
+                case 'KeyF':
+                    this.$emit('full-screen-toogle');
+                    break;
+                case 'Tab':
+                    this.doToolBarToggle();
+                    event.preventDefault();
+                    event.stopPropagation();
+                    break;
+                default:
+                    result = false;
+                    break;
+            }
+        }
+        return result;
+    }
+
+    async startClickRepeat(pointX, pointY) {
+        this.repX = pointX;
+        this.repY = pointY;
+
+        if (!this.repInit && this.repDoing) {
+            this.repInit = true;
+            let delay = 400;
+            while (this.repDoing) {
+                this.handleClick(pointX, pointY);
+                await sleep(delay);
+                if (delay > 15)
+                    delay *= 0.8;
+            }
+            this.repInit = false;
+        }
+    }
+
+    endClickRepeat() {
+        this.repDoing = false;
+    }
+
+    onTouchStart(event) {
+        if (!this.mobile)
+            return;
+        this.endClickRepeat();
+        if (event.touches.length == 1) {
+            const touch = event.touches[0];
+            const rect = event.target.getBoundingClientRect();
+            const x = touch.pageX - rect.left;
+            const y = touch.pageY - rect.top;
+            if (this.handleClick(x, y)) {
+                this.repDoing = true;
+                this.debouncedStartClickRepeat(x, y);
+            }
+        }
+    }
+
+    onTouchEnd() {
+        if (!this.mobile)
+            return;
+        this.endClickRepeat();
+    }
+
+    onTouchCancel() {
+        if (!this.mobile)
+            return;
+        this.endClickRepeat();
+    }
+
+    onMouseDown(event) {
+        if (this.mobile)
+            return;
+        this.endClickRepeat();
+        if (event.button == 0) {
+            if (this.handleClick(event.offsetX, event.offsetY)) {
+                this.repDoing = true;
+                this.debouncedStartClickRepeat(event.offsetX, event.offsetY);
+            }
+        } else if (event.button == 1) {
+            this.$emit('scrolling-toggle');
+        } else if (event.button == 2) {
+            this.doToolBarToggle();
+        }
+    }
+
+    onMouseUp() {
+        if (this.mobile)
+            return;
+        this.endClickRepeat();
+    }
+
+    onMouseWheel(event) {
+        if (this.mobile)
+            return;
+        if (event.deltaY > 0) {
+            this.doDown();
+        } else if (event.deltaY < 0) {
+            this.doUp();
+        }
+    }
+
+    onStatusBarClick() {
+        const url = this.meta.url;
+        if (url && url.indexOf('file://') != 0) {
+            window.open(url, '_blank');
+        } else {
+            this.$alert('Оригинал недоступен, т.к. файл книги был загружен с локального диска', '', {type: 'warning'});
+        }
+    }
+
+    handleClick(pointX, pointY) {
+        const w = pointX/this.realWidth*100;
+        const h = pointY/this.realHeight*100;
+
+        let action = '';
+        loops: {
+            for (const x in clickMap) {
+                for (const y in clickMap[x]) {
+                    if (w < x && h < y) {
+                        action = clickMap[x][y];
+                        break loops;
+                    }
+                }
+            }
+        }
+
+        switch (action) {
+            case 'Down' ://Down
+                this.doDown();
+                break;
+            case 'Up' ://Up
+                this.doUp();
+                break;
+            case 'PgDown' ://PgDown
+                this.doPageDown();
+                break;
+            case 'PgUp' ://PgUp
+                this.doPageUp();
+                break;
+            case 'Menu' :
+                this.doToolBarToggle();
+                break;
+            default :
+                // Nothing
+        }
+
+        return (action && action != 'Menu');
+   }
+
+}
+//-----------------------------------------------------------------------------
+</script>
+<style scoped>
+.main {
+    flex: 1;
+    margin: 0;
+    padding: 0;
+    overflow: hidden;
+    position: relative;
+    min-width: 200px;
+}
+
+.layout {
+    margin: 0;
+    padding: 0;
+    position: absolute;
+    z-index: 10;
+}
+
+.back {
+    z-index: 5;
+}
+
+.events {
+    z-index: 20;
+    background-color: rgba(0,0,0,0);
+}
+
+</style>
+
+<style>
+.paper1 {
+    background: url("images/paper1.jpg") center;
+    background-size: cover;
+}
+
+.paper2 {
+    background: url("images/paper2.jpg") center;
+    background-size: cover;
+}
+
+.paper3 {
+    background: url("images/paper3.jpg") center;
+    background-size: cover;
+}
+
+.paper4 {
+    background: url("images/paper4.jpg") center;
+    background-size: cover;
+}
+
+.paper5 {
+    background: url("images/paper5.jpg") center;
+    background-size: cover;
+}
+
+.paper6 {
+    background: url("images/paper6.jpg") center;
+    background-size: cover;
+}
+
+.paper7 {
+    background: url("images/paper7.jpg") center;
+    background-size: cover;
+}
+
+.paper8 {
+    background: url("images/paper8.jpg") center;
+    background-size: cover;
+}
+
+.paper9 {
+    background: url("images/paper9.jpg");
+}
+
+</style>

BIN
client/components/Reader/TextPage/images/paper1.jpg


BIN
client/components/Reader/TextPage/images/paper2.jpg


BIN
client/components/Reader/TextPage/images/paper3.jpg


BIN
client/components/Reader/TextPage/images/paper4.jpg


BIN
client/components/Reader/TextPage/images/paper5.jpg


BIN
client/components/Reader/TextPage/images/paper6.jpg


BIN
client/components/Reader/TextPage/images/paper7.jpg


BIN
client/components/Reader/TextPage/images/paper8.jpg


BIN
client/components/Reader/TextPage/images/paper9.jpg


+ 636 - 0
client/components/Reader/share/BookParser.js

@@ -0,0 +1,636 @@
+import he from 'he';
+import sax from '../../../../server/core/BookConverter/sax';
+import {sleep} from '../../../share/utils';
+
+export default class BookParser {
+    constructor() {
+        // defaults
+        this.p = 30;// px, отступ параграфа
+        this.w = 300;// px, ширина страницы
+        this.wordWrap = false;// перенос по слогам
+
+        //заглушка
+        this.measureText = (text, style) => {// eslint-disable-line no-unused-vars
+            return text.length*20;
+        };
+    }
+
+    async parse(data, callback) {
+        if (!callback)
+            callback = () => {};
+        callback(0);
+
+        if (data.indexOf('<FictionBook') < 0) {            
+            throw new Error('Неверный формат файла');
+        }
+
+        //defaults
+        let fb2 = {
+            firstName: '',
+            middleName: '',
+            lastName: '',
+            bookTitle: '',
+        };
+
+        let path = '';
+        let tag = '';
+        let center = false;
+        let bold = false;
+        let italic = false;
+
+        let paraIndex = -1;
+        let paraOffset = 0;
+        let para = []; /*array of
+            {
+                index: Number,
+                offset: Number, //сумма всех length до этого параграфа
+                length: Number, //длина text без тегов
+                text: String //текст параграфа (или title или epigraph и т.д) с вложенными тегами
+            }
+        */
+        const newParagraph = (text, len) => {
+            paraIndex++;
+            let p = {
+                index: paraIndex,
+                offset: paraOffset,
+                length: len,
+                text: text,
+            };
+
+            para[paraIndex] = p;
+            paraOffset += p.length;
+        };
+
+        const growParagraph = (text, len) => {
+            if (paraIndex < 0) {
+                newParagraph(text, len);
+                return;
+            }
+
+            let p = para[paraIndex];
+            if (p) {
+                paraOffset -= p.length;
+                if (p.length == 1 && p.text[0] == ' ' && len > 0) {
+                    p.length = 0;
+                    p.text = p.text.substr(1);
+                }
+                p.length += len;
+                p.text += text;
+            } else {
+                p = {
+                    index: paraIndex,
+                    offset: paraOffset,
+                    length: len,
+                    text: text
+                };
+            }
+
+            para[paraIndex] = p;
+            paraOffset += p.length;
+        };
+
+        const onStartNode = (elemName) => {// eslint-disable-line no-unused-vars
+            if (elemName == '?xml')
+                return;
+
+            tag = elemName;
+            path += '/' + elemName;
+
+            if ((tag == 'p' || tag == 'empty-line' || tag == 'v') && path.indexOf('/fictionbook/body/section') == 0) {
+                newParagraph(' ', 1);
+            }
+
+            if (tag == 'emphasis' || tag == 'strong') {
+                growParagraph(`<${tag}>`, 0);
+            }
+
+            if (tag == 'title') {
+                newParagraph(' ', 1);
+                bold = true;
+                center = true;
+            }
+
+            if (tag == 'subtitle') {
+                newParagraph(' ', 1);
+                bold = true;
+            }
+
+            if (tag == 'epigraph') {
+                italic = true;
+            }
+
+            if (tag == 'stanza') {
+                newParagraph(' ', 1);
+            }
+        };
+
+        const onEndNode = (elemName) => {// eslint-disable-line no-unused-vars
+            if (tag == elemName) {
+                if (tag == 'emphasis' || tag == 'strong') {
+                    growParagraph(`</${tag}>`, 0);
+                }
+
+                if (tag == 'title') {
+                    bold = false;
+                    center = false;
+                }
+
+                if (tag == 'subtitle') {
+                    bold = false;
+                }
+
+                if (tag == 'epigraph') {
+                    italic = false;
+                }
+
+                if (tag == 'stanza') {
+                    newParagraph(' ', 1);
+                }
+
+                path = path.substr(0, path.length - tag.length - 1);
+                let i = path.lastIndexOf('/');
+                if (i >= 0) {
+                    tag = path.substr(i + 1);
+                } else {
+                    tag = path;
+                }
+            }
+        };
+
+        const onTextNode = (text) => {// eslint-disable-line no-unused-vars
+            text = he.decode(text);
+            text = text.replace(/>/g, '&gt;');
+            text = text.replace(/</g, '&lt;');
+
+            if (text != ' ' && text.trim() == '')
+                text = text.trim();
+
+            if (text == '')
+                return;
+
+            text = text.replace(/[\t\n\r]/g, ' ');
+
+            switch (path) {
+                case '/fictionbook/description/title-info/author/first-name':
+                    fb2.firstName = text;
+                    break;
+                case '/fictionbook/description/title-info/author/middle-name':
+                    fb2.middleName = text;
+                    break;
+                case '/fictionbook/description/title-info/author/last-name':
+                    fb2.lastName = text;
+                    break;
+                case '/fictionbook/description/title-info/genre':
+                    fb2.genre = text;
+                    break;
+                case '/fictionbook/description/title-info/date':
+                    fb2.date = text;
+                    break;
+                case '/fictionbook/description/title-info/book-title':
+                    fb2.bookTitle = text;
+                    break;
+                case '/fictionbook/description/title-info/id':
+                    fb2.id = text;
+                    break;
+            }
+
+            if (path.indexOf('/fictionbook/description/title-info/annotation') == 0) {
+                if (!fb2.annotation)
+                    fb2.annotation = '';
+                if (tag != 'annotation')
+                    fb2.annotation += `<${tag}>${text}</${tag}>`;
+                else
+                    fb2.annotation += text;
+            }
+
+            let tOpen = (center ? '<center>' : '');
+            tOpen += (bold ? '<strong>' : '');
+            tOpen += (italic ? '<emphasis>' : '');
+            let tClose = (center ? '</center>' : '');
+            tClose += (bold ? '</strong>' : '');
+            tClose += (italic ? '</emphasis>' : '');
+
+            if (path.indexOf('/fictionbook/body/title') == 0) {
+                newParagraph(`${tOpen}${text}${tClose}`, text.length, true);
+            }
+
+            if (path.indexOf('/fictionbook/body/section') == 0) {
+                switch (tag) {
+                    case 'p':
+                        growParagraph(`${tOpen}${text}${tClose}`, text.length);
+                        break;
+                    default:
+                        growParagraph(`${tOpen}${text}${tClose}`, text.length);
+                }
+            }
+        };
+
+        const onProgress = async(prog) => {
+            await sleep(1);
+            callback(prog);
+        };
+
+        await sax.parse(data, {
+            onStartNode, onEndNode, onTextNode, onProgress
+        });
+
+        this.fb2 = fb2;
+        this.para = para;
+
+        this.textLength = paraOffset;
+
+        callback(100);
+        await sleep(10);
+
+        return {fb2};
+    }
+
+    findParaIndex(bookPos) {
+        let result = undefined;
+        //дихотомия
+        let first = 0;
+        let last = this.para.length - 1;
+        while (first < last) {
+            let mid = first + Math.floor((last - first)/2);
+            if (bookPos <= this.para[mid].offset + this.para[mid].length - 1)
+                last = mid;
+            else
+                first = mid + 1;
+        }
+
+        if (last >= 0) {
+            const ofs = this.para[last].offset;
+            if (bookPos >= ofs && bookPos < ofs + this.para[last].length)
+                result = last; 
+        }
+
+        return result;
+    }
+
+    splitToStyle(s) {
+        let result = [];/*array of {
+            style: {bold: Boolean, italic: Boolean, center: Boolean},
+            text: String,
+        }*/
+        let style = {};
+
+        const onTextNode = async(text) => {// eslint-disable-line no-unused-vars
+            result.push({
+                style: Object.assign({}, style),
+                text: text
+            });
+        };
+
+        const onStartNode = async(elemName) => {// eslint-disable-line no-unused-vars
+            switch (elemName) {
+                case 'strong':
+                    style.bold = true;
+                    break;
+                case 'emphasis':
+                    style.italic = true;
+                    break;
+                case 'center':
+                    style.center = true;
+                    break;
+            }
+        };
+
+        const onEndNode = async(elemName) => {// eslint-disable-line no-unused-vars
+            switch (elemName) {
+                case 'strong':
+                    style.bold = false;
+                    break;
+                case 'emphasis':
+                    style.italic = false;
+                    break;
+                case 'center':
+                    style.center = false;
+                    break;
+            }
+        };
+
+        sax.parseSync(s, {
+            onStartNode, onEndNode, onTextNode
+        });
+
+
+        //длинные слова (или белиберду без пробелов) тоже разобьем
+        const maxWordLength = this.maxWordLength;
+        const parts = result;
+        result = [];
+        for (const part of parts) {
+            let p = part;
+            let i = 0;
+            let spaceIndex = -1;
+            while (i < p.text.length) {
+                if (p.text[i] == ' ')
+                    spaceIndex = i;
+
+                if (i - spaceIndex >= maxWordLength && i < p.text.length - 1 && 
+                    this.measureText(p.text.substr(spaceIndex + 1, i - spaceIndex), p.style) >= this.w - this.p) {
+                    result.push({style: p.style, text: p.text.substr(0, i + 1)});
+                    p = {style: p.style, text: p.text.substr(i + 1)};
+                    spaceIndex = -1;
+                    i = -1;
+                }
+                i++;
+            }
+            result.push(p);
+        }
+
+        return result;
+    }
+
+    splitToSlogi(word) {
+        let result = [];
+
+        const glas = new Set(['а', 'А', 'о', 'О', 'и', 'И', 'е', 'Е', 'ё', 'Ё', 'э', 'Э', 'ы', 'Ы', 'у', 'У', 'ю', 'Ю', 'я', 'Я']);
+        const soglas = new Set([
+            'б', 'в', 'г', 'д', 'ж', 'з', 'й', 'к', 'л', 'м', 'н', 'п', 'р', 'с', 'т', 'ф', 'х', 'ц', 'ч', 'ш', 'щ',
+            'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'Й', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', 'Ч', 'Ц', 'Ш', 'Щ'
+        ]);
+        const znak = new Set(['ь', 'Ь', 'ъ', 'Ъ', 'й', 'Й']);
+        const alpha = new Set([...glas, ...soglas, ...znak]);
+
+        let slog = '';
+        let slogLen = 0;
+        const len = word.length;
+        word += '   ';
+        for (let i = 0; i < len; i++) {
+            slog += word[i];
+            if (alpha.has(word[i]))
+                slogLen++;
+
+            if (slogLen > 1 && i < len - 2 && (
+                    //гласная, а следом не 2 согласные буквы
+                    (glas.has(word[i]) && !(soglas.has(word[i + 1]) && 
+                        soglas.has(word[i + 2])) && alpha.has(word[i + 1]) && alpha.has(word[i + 2])
+                    ) ||
+                    //предыдущая не согласная буква, текущая согласная, а следом согласная и согласная|гласная буквы
+                    (alpha.has(word[i - 1]) && !soglas.has(word[i - 1]) && 
+                        soglas.has(word[i]) && soglas.has(word[i + 1]) && 
+                        (glas.has(word[i + 2]) || soglas.has(word[i + 2])) && 
+                        alpha.has(word[i + 1]) && alpha.has(word[i + 2])
+                    ) ||
+                    //мягкий или твердый знак или Й
+                    (znak.has(word[i]) && alpha.has(word[i + 1]) && alpha.has(word[i + 2])) ||
+                    (word[i] == '-')
+                ) &&
+                //нельзя оставлять окончания на ь, ъ, й
+                !(znak.has(word[i + 2]) && !alpha.has(word[i + 3]))
+
+                ) {
+                result.push(slog);
+                slog = '';
+                slogLen = 0;
+            }
+        }
+        if (slog)
+            result.push(slog);
+
+        return result;
+    }
+
+    parsePara(paraIndex) {
+        const para = this.para[paraIndex];
+
+        if (!this.force &&
+            para.parsed && 
+            para.parsed.w === this.w &&
+            para.parsed.p === this.p &&
+            para.parsed.wordWrap === this.wordWrap &&
+            para.parsed.maxWordLength === this.maxWordLength &&
+            para.parsed.font === this.font
+            )
+            return para.parsed;
+
+        const parsed = {
+            w: this.w,
+            p: this.p,
+            wordWrap: this.wordWrap,
+            maxWordLength: this.maxWordLength,
+            font: this.font,
+        };
+
+
+        const lines = []; /* array of
+        {
+            begin: Number,
+            end: Number,
+            first: Boolean,
+            last: Boolean,
+            parts: array of {
+                style: {bold: Boolean, italic: Boolean, center: Boolean},
+                text: String,
+            }
+        }*/
+        let parts = this.splitToStyle(para.text);
+
+        let line = {begin: para.offset, parts: []};
+        let partText = '';//накапливаемый кусок со стилем
+
+        let str = '';//измеряемая строка
+        let prevStr = '';//строка без крайнего слова
+        let prevW = 0;
+        let j = 0;//номер строки
+        let style = {};
+        let ofs = 0;//смещение от начала параграфа para.offset
+
+        // тут начинается самый замес, перенос по слогам и стилизация
+        for (const part of parts) {
+            const words = part.text.split(' ');
+            style = part.style;
+
+            let sp1 = '';
+            let sp2 = '';
+            for (let i = 0; i < words.length; i++) {
+                const word = words[i];
+                ofs += word.length + (i < words.length - 1 ? 1 : 0);
+
+                if (word == '' && i > 0 && i < words.length - 1)
+                    continue;
+
+                str += sp1 + word;
+
+                let p = (j == 0 ? parsed.p : 0);
+                let w = this.measureText(str, style) + p;
+                let wordTail = word;
+                if (w > parsed.w && prevStr != '') {
+                    if (parsed.wordWrap) {//по слогам
+                        let slogi = this.splitToSlogi(word);
+
+                        if (slogi.length > 1) {
+                            let s = prevStr + sp1;
+                            let ss = sp1;
+
+                            let pw;
+                            const slogiLen = slogi.length;
+                            for (let k = 0; k < slogiLen - 1; k++) {
+                                let slog = slogi[0];
+                                let ww = this.measureText(s + slog + (slog[slog.length - 1] == '-' ? '' : '-'), style) + p;
+                                if (ww <= parsed.w) {
+                                    s += slog;
+                                    ss += slog;
+                                } else 
+                                    break;
+                                pw = ww;
+                                slogi.shift();
+                            }
+
+                            if (pw) {
+                                prevW = pw;
+                                partText += ss + (ss[ss.length - 1] == '-' ? '' : '-');
+                                wordTail = slogi.join('');
+                            }
+                        }
+                    }
+
+                    if (partText != '')
+                        line.parts.push({style, text: partText});
+
+                    if (line.parts.length) {//корявенько, коррекция при переносе, отрефакторить не вышло
+                        let t = line.parts[line.parts.length - 1].text;
+                        if (t[t.length - 1] == ' ') {
+                            line.parts[line.parts.length - 1].text = t.trimRight();
+                            prevW -= this.measureText(' ', style);
+                        }
+                    }
+
+                    line.end = para.offset + ofs - wordTail.length - 1 - (i < words.length - 1 ? 1 : 0);
+                    if (line.end - line.begin < 0)
+                        console.error(`Parse error, empty line in paragraph ${paraIndex}`);
+
+                    line.width = prevW;
+                    line.first = (j == 0);
+                    line.last = false;
+                    lines.push(line);
+
+                    line = {begin: line.end + 1, parts: []};
+                    partText = '';
+                    sp2 = '';
+                    str = wordTail;
+                    j++;
+                }
+
+                prevStr = str;
+                partText += sp2 + wordTail;
+                sp1 = ' ';
+                sp2 = ' ';
+                prevW = w;
+            }
+
+            if (partText != '')
+                line.parts.push({style, text: partText});
+            partText = '';
+        }
+
+        if (line.parts.length) {//корявенько, коррекция при переносе
+            let t = line.parts[line.parts.length - 1].text;
+            if (t[t.length - 1] == ' ') {
+                line.parts[line.parts.length - 1].text = t.trimRight();
+                prevW -= this.measureText(' ', style);
+            }
+
+            line.end = para.offset + para.length - 1;
+            if (line.end - line.begin < 0)
+                console.error(`Parse error, empty line in paragraph ${paraIndex}`);
+
+            line.width = prevW;
+            line.first = (j == 0);
+            line.last = true;
+            lines.push(line);
+        } else {//подстраховка
+            if (lines.length) {
+                line = lines[lines.length - 1];
+                const end = para.offset + para.length - 1;
+                if (line.end != end)
+                    console.error(`Parse error, wrong end in paragraph ${paraIndex}`);
+                line.end = end;
+            }
+        }
+
+        parsed.lines = lines;
+        para.parsed = parsed;
+
+        return parsed;
+    }
+
+    findLineIndex(bookPos, lines) {
+        let result = undefined;
+
+        //дихотомия
+        let first = 0;
+        let last = lines.length - 1;
+        while (first < last) {
+            let mid = first + Math.floor((last - first)/2);
+            if (bookPos <= lines[mid].end)
+                last = mid;
+            else
+                first = mid + 1;
+        }
+
+        if (last >= 0) {
+            if (bookPos >= lines[last].begin && bookPos <= lines[last].end)
+                result = last; 
+        }
+
+        return result;
+    }
+
+    getLines(bookPos, n) {
+        let result = [];
+        let paraIndex = this.findParaIndex(bookPos);
+
+        if (paraIndex === undefined)
+            return result;
+        
+        if (n > 0) {
+            let parsed = this.parsePara(paraIndex);
+            let i = this.findLineIndex(bookPos, parsed.lines);
+            if (i === undefined)
+                return result;
+
+            while (n > 0) {
+                result.push(parsed.lines[i]);
+                i++;
+
+                if (i >= parsed.lines.length) {
+                    paraIndex++;
+                    if (paraIndex < this.para.length)
+                        parsed = this.parsePara(paraIndex);
+                    else
+                        return result;
+                    i = 0;
+                }
+
+                n--;
+            }
+        } else if (n < 0) {
+            n = -n;
+            let parsed = this.parsePara(paraIndex);
+            let i = this.findLineIndex(bookPos, parsed.lines);
+            if (i === undefined)
+                return result;
+
+            while (n > 0) {
+                result.push(parsed.lines[i]);
+                i--;
+
+                if (i < 0) {
+                    paraIndex--;
+                    if (paraIndex >= 0)
+                        parsed = this.parsePara(paraIndex);
+                    else
+                        return result;
+                    i = parsed.lines.length - 1;
+                }
+                
+                n--;
+            }
+        }
+
+        if (!result.length)
+            result = null;
+        return result;
+    }
+}

+ 234 - 0
client/components/Reader/share/bookManager.js

@@ -0,0 +1,234 @@
+import localForage from 'localforage';
+
+import * as utils from '../../../share/utils';
+import BookParser from './BookParser';
+
+const maxDataSize = 500*1024*1024;//chars, not bytes
+
+const bmMetaStore = localForage.createInstance({
+    name: 'bmMetaStore'
+});
+
+const bmDataStore = localForage.createInstance({
+    name: 'bmDataStore'
+});
+
+const bmRecentStore = localForage.createInstance({
+    name: 'bmRecentStore'
+});
+
+class BookManager {
+    async init() {
+        this.books = {};
+        this.recent = {};
+        this.recentChanged = true;
+
+        let len = await bmMetaStore.length();
+        for (let i = 0; i < len; i++) {
+            const key = await bmMetaStore.key(i);
+            const keySplit = key.split('-');
+
+            if (keySplit.length == 2 && keySplit[0] == 'bmMeta') {
+                let meta = await bmMetaStore.getItem(key);
+
+                this.books[meta.key] = meta;
+            }
+        }
+
+        len = await bmRecentStore.length();
+        for (let i = 0; i < len; i++) {
+            const key = await bmRecentStore.key(i);
+            let r = await bmRecentStore.getItem(key);
+            this.recent[r.key] = r;
+        }
+
+        await this.cleanBooks();
+    }
+
+    async cleanBooks() {
+        while (1) {// eslint-disable-line no-constant-condition
+            let size = 0;
+            let min = Date.now();
+            let toDel = null;
+            for (let key in this.books) {
+                let book = this.books[key];
+                size += (book.length ? book.length : 0);
+
+                if (book.addTime < min) {
+                    toDel = book;
+                    min = book.addTime;
+                }
+            }
+
+            if (size > maxDataSize && toDel) {
+                await this.delBook(toDel);
+            } else {
+                break;
+            }
+        }
+    }
+
+    async addBook(newBook, callback) {
+        if (!this.books) 
+            await this.init();
+        let meta = {url: newBook.url, path: newBook.path};
+        meta.key = this.keyFromUrl(meta.url);
+        meta.addTime = Date.now();
+
+        const result = await this.parseBook(meta, newBook.data, callback);
+
+        this.books[meta.key] = result;
+
+        await bmMetaStore.setItem(`bmMeta-${meta.key}`, this.metaOnly(result));
+        await bmDataStore.setItem(`bmData-${meta.key}`, result.data);
+
+        return result;
+    }
+
+    hasBookParsed(meta) {
+        if (!this.books) 
+            return false;
+        if (!meta.url)
+            return false;
+        if (!meta.key)
+            meta.key = this.keyFromUrl(meta.url);
+        let book = this.books[meta.key];
+        return !!(book && book.parsed);
+    }
+
+    async getBook(meta, callback) {
+        if (!this.books) 
+            await this.init();
+        let result = undefined;
+        if (!meta.key)
+            meta.key = this.keyFromUrl(meta.url);
+        result = this.books[meta.key];
+
+        if (result && !result.data) {
+            result.data = await bmDataStore.getItem(`bmData-${meta.key}`);
+            this.books[meta.key] = result;
+        }
+
+        if (result && !result.parsed) {
+            result = await this.parseBook(result, result.data, callback);
+            this.books[meta.key] = result;
+        }
+
+        return result;
+    }
+
+    async delBook(meta) {
+        if (!this.books) 
+            await this.init();
+
+        await bmMetaStore.removeItem(`bmMeta-${meta.key}`);
+        await bmDataStore.removeItem(`bmData-${meta.key}`);
+
+        delete this.books[meta.key];
+    }
+
+    async parseBook(meta, data, callback) {
+        if (!this.books) 
+            await this.init();
+        const parsed = new BookParser();
+
+        const parsedMeta = await parsed.parse(data, callback);
+        const result = Object.assign({}, meta, parsedMeta, {
+            length: data.length,
+            textLength: parsed.textLength,
+            data,
+            parsed
+        });
+
+        return result;
+    }
+
+    metaOnly(book) {
+        let result = Object.assign({}, book);
+        delete result.data;
+        delete result.parsed;
+        return result;
+    }
+
+    keyFromUrl(url) {
+        return utils.stringToHex(url);
+    }
+
+    async setRecentBook(value, noTouch) {
+        if (!this.recent) 
+            await this.init();
+        const result = Object.assign({}, value);
+        if (!noTouch)
+            Object.assign(result, {touchTime: Date.now()});
+
+        if (result.textLength && !result.bookPos && result.bookPosPercent)
+            result.bookPos = Math.round(result.bookPosPercent*result.textLength);
+
+        this.recent[result.key] = result;
+
+        await bmRecentStore.setItem(result.key, result);
+        await this.cleanRecentBooks();
+
+        this.recentChanged = true;
+        return result;
+    }
+
+    async getRecentBook(value) {
+        if (!this.recent) 
+            await this.init();
+        return this.recent[value.key];
+    }
+
+    async delRecentBook(value) {
+        if (!this.recent) 
+            await this.init();
+
+        await bmRecentStore.removeItem(value.key);
+        delete this.recent[value.key];
+        this.recentChanged = true;
+    }
+
+    async cleanRecentBooks() {
+        if (!this.recent) 
+            await this.init();
+
+        if (Object.keys(this.recent).length > 100) {
+            let min = Date.now();
+            let found = null;
+            for (let key in this.recent) {
+                const book = this.recent[key];
+                if (book.touchTime < min) {
+                    min = book.touchTime;
+                    found = book;
+                }
+            }
+
+            if (found) {
+                await this.delRecentBook(found);
+                await this.cleanRecentBooks();
+            }
+        }
+    }
+
+    mostRecentBook() {
+        if (!this.recentChanged && this.mostRecentCached) {
+            return this.mostRecentCached;
+        }
+
+        let max = 0;
+        let result = null;
+        for (let key in this.recent) {
+            const book = this.recent[key];
+            if (book.touchTime > max) {
+                max = book.touchTime;
+                result = book;
+            }
+        }
+        this.mostRecentCached = result;
+        this.recentChanged = false;
+        return result;
+    }
+
+}
+
+export default new BookManager();

+ 13 - 0
client/components/Reader/share/clickMap.js

@@ -0,0 +1,13 @@
+export const clickMap = {
+    33: {30: 'PgUp', 100: 'PgDown'},
+    67: {30: 'Up', 70: 'Menu', 100: 'Down'},
+    100: {30: 'PgUp', 100: 'PgDown'}
+};
+
+export const clickMapText = {
+    'PgUp': 'Страницу назад',
+    'PgDown': 'Страницу вперед',
+    'Up': 'Строку назад',
+    'Down': 'Строку вперед',
+    'Menu': 'Показать или скрыть панель',
+};

+ 70 - 0
client/components/Reader/share/restoreOldSettings.js

@@ -0,0 +1,70 @@
+export default async function restoreOldSettings(settings, bookManager, commit) {
+    const oldSets = localStorage['colorSetting'];
+    let isOld = false;
+    for (let i = 0; i < localStorage.length; i++) {
+        let key = unescape(localStorage.key(i));
+        if (key.indexOf('bpr-book-') == 0)
+            isOld = true;
+    }
+
+    if (isOld || oldSets) {
+        let newSettings = null;
+        if (oldSets) {
+            const [textColor, backgroundColor, lineStep, , , statusBarHeight, scInt] = unescape(oldSets).split('|');
+
+            const fontSize = Math.round(lineStep*0.8);
+            const scrollingDelay = fontSize*scInt;
+
+            newSettings = Object.assign({}, settings, {
+                textColor,
+                backgroundColor,
+                fontSize,
+                statusBarHeight: statusBarHeight*1,
+                scrollingDelay,
+            });
+        }
+
+        for (let i = 0; i < localStorage.length; i++) {
+            let key = localStorage.key(i);
+            if (key.indexOf('bpr-') == 0) {
+                let v = unescape(localStorage[key]);
+                key = unescape(key);
+
+                if (key.lastIndexOf('=timestamp') == key.length - 10) {
+                    continue;
+                }
+
+                if (key.indexOf('bpr-book-') == 0) {
+                    const url = key.substr(9);
+                    const [scrollTop, scrollHeight, ] = v.split('|');
+
+                    const bookPosPercent = scrollTop*1/(scrollHeight*1 + 1);
+                    const title = unescape(localStorage[`bpr-title-${escape(url)}`]);
+                    const author = unescape(localStorage[`bpr-author-${escape(url)}`]);
+                    const time = unescape(localStorage[`bpr-book-${escape(url)}=timestamp`]).split(';')[0];
+                    const touchTime = Date.parse(time);
+
+                    const bookKey = bookManager.keyFromUrl(url);
+                    const recent = await bookManager.getRecentBook({key: bookKey});
+
+                    if (!recent) {
+                        await bookManager.setRecentBook({
+                            key: bookKey,
+                            touchTime,
+                            bookPosPercent,
+                            url,
+                            fb2: {
+                                bookTitle: title,
+                                lastName: author,
+                            }
+                        }, true);
+                    }
+                }
+            }
+        }
+
+        localStorage.clear();
+        if (oldSets)
+            commit('reader/setSettings', newSettings);
+    }
+}

+ 20 - 0
client/components/Settings/Settings.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел Settings в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Settings extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

+ 20 - 0
client/components/Sources/Sources.vue

@@ -0,0 +1,20 @@
+<template>
+    <el-container>
+        Раздел Sources в разработке
+    </el-container>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Sources extends Vue {
+    created() {
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>

BIN
client/components/fonts/arimo.woff2


BIN
client/components/fonts/avrile.ttf


BIN
client/components/fonts/avrile.woff


BIN
client/components/fonts/geo_1.ttf


BIN
client/components/fonts/geo_1.woff


BIN
client/components/fonts/open-sans.ttf


BIN
client/components/fonts/open-sans.woff


BIN
client/components/fonts/reader-default.ttf


BIN
client/components/fonts/reader-default.woff


BIN
client/components/fonts/roboto.ttf


BIN
client/components/fonts/roboto.woff


BIN
client/components/fonts/rubik.woff2


+ 61 - 0
client/components/share/Window.vue

@@ -0,0 +1,61 @@
+<template>
+    <div class="window">
+        <div class="header">
+            <span class="header-text"><slot name="header"></slot></span>
+            <span class="close-button" @click="close"><i class="el-icon-close"></i></span>
+        </div>
+        <slot></slot>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+
+export default @Component({
+})
+class Window extends Vue {
+    close() {
+        this.$emit('close');
+    }
+
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.window {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    margin: 10px;
+    background-color: #ffffff;
+    border: 3px double black;
+    border-radius: 4px;
+    box-shadow: 3px 3px 5px black;
+}
+
+.header {
+    display: flex;
+    justify-content: flex-end;
+    background-color: #e5e7ea;
+    align-items: center;
+    height: 40px;
+}
+
+.header-text {
+    flex: 1;
+    margin-left: 10px;
+    margin-right: 10px;
+}
+
+.close-button {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 40px;
+    height: 40px;
+    cursor: pointer;
+}
+</style>

+ 122 - 0
client/element.js

@@ -0,0 +1,122 @@
+import Vue from 'vue';
+
+/*
+import ElementUI from 'element-ui';
+import './theme/index.css';
+import locale from 'element-ui/lib/locale/lang/ru-RU';
+
+Vue.use(ElementUI, { locale });
+*/
+
+//------------------------------------------------------
+//import './theme/index.css';
+
+import './theme/icon.css';
+import './theme/tooltip.css';
+
+import ElMenu from 'element-ui/lib/menu'; 
+import './theme/menu.css';
+
+import ElMenuItem from 'element-ui/lib/menu-item';
+import './theme/menu-item.css';
+
+import ElButton from 'element-ui/lib/button';
+import './theme/button.css';
+
+import ElButtonGroup from 'element-ui/lib/button-group';
+import './theme/button-group.css';
+
+import ElCheckbox from 'element-ui/lib/checkbox';
+import './theme/checkbox.css';
+
+import ElTabs from 'element-ui/lib/tabs';
+import './theme/tabs.css';
+
+import ElTabPane from 'element-ui/lib/tab-pane';
+import './theme/tab-pane.css';
+
+import ElTooltip from 'element-ui/lib/tooltip';
+import './theme/tooltip.css';
+
+import ElCol from 'element-ui/lib/col';
+import './theme/col.css';
+
+import ElContainer from 'element-ui/lib/container';
+import './theme/container.css';
+
+import ElAside from 'element-ui/lib/aside';
+import './theme/aside.css';
+
+import ElHeader from 'element-ui/lib/header';
+import './theme/header.css';
+
+import ElMain from 'element-ui/lib/main';
+import './theme/main.css';
+
+import ElInput from 'element-ui/lib/input';
+import './theme/input.css';
+
+import ElInputNumber from 'element-ui/lib/input-number';
+import './theme/input-number.css';
+
+import ElSelect from 'element-ui/lib/select';
+import './theme/select.css';
+
+import ElOption from 'element-ui/lib/option';
+import './theme/option.css';
+
+import ElTable from 'element-ui/lib/table';
+import './theme/table.css';
+
+import ElTableColumn from 'element-ui/lib/table-column';
+import './theme/table-column.css';
+
+import ElProgress from 'element-ui/lib/progress';
+import './theme/progress.css';
+
+import ElSlider from 'element-ui/lib/slider';
+import './theme/slider.css';
+
+import ElForm from 'element-ui/lib/form';
+import './theme/form.css';
+
+import ElFormItem from 'element-ui/lib/form-item';
+import './theme/form-item.css';
+
+import ElColorPicker from 'element-ui/lib/color-picker';
+import './theme/color-picker.css';
+
+import Notification from 'element-ui/lib/notification';
+import './theme/notification.css';
+
+import Loading from 'element-ui/lib/loading';
+import './theme/loading.css';
+
+import MessageBox from 'element-ui/lib/message-box';
+import './theme/message-box.css';
+
+const components = {
+    ElMenu, ElMenuItem, ElButton, ElButtonGroup, ElCheckbox, ElTabs, ElTabPane, ElTooltip,
+    ElCol, ElContainer, ElAside, ElMain, ElHeader,
+    ElInput, ElInputNumber, ElSelect, ElOption, ElTable, ElTableColumn,
+    ElProgress, ElSlider, ElForm, ElFormItem,
+    ElColorPicker,
+};
+
+for (let name in components) {
+    Vue.component(name, components[name]);
+}
+
+//Vue.use(Loading.directive);
+
+Vue.prototype.$loading = Loading.service;
+Vue.prototype.$msgbox = MessageBox;
+Vue.prototype.$alert = MessageBox.alert;
+Vue.prototype.$confirm = MessageBox.confirm;
+Vue.prototype.$prompt = MessageBox.prompt;
+Vue.prototype.$notify = Notification;
+//Vue.prototype.$message = Message;
+
+import lang from 'element-ui/lib/locale/lang/ru-RU';
+import locale from 'element-ui/lib/locale';
+locale.use(lang);

+ 11 - 0
client/index.html.template

@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <title></title>
+  </head>
+  <body>
+    <div id="app"></div>
+  </body>
+</html>

+ 14 - 0
client/main.js

@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import App from './components/App.vue';
+
+import router from './router';
+import store from './store';
+import './element';
+
+//Vue.config.productionTip = false;
+
+new Vue({
+    router,
+    store,
+    render: h => h(App),
+}).$mount('#app');

+ 67 - 0
client/router.js

@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import _ from 'lodash';
+
+import App from './components/App.vue';
+
+const CardIndex = () => import('./components/CardIndex/CardIndex.vue');
+const Search = () => import('./components/CardIndex/Search/Search.vue');
+const Card = () => import('./components/CardIndex/Card/Card.vue');
+const Book = () => import('./components/CardIndex/Book/Book.vue');
+const History = () => import('./components/CardIndex/History/History.vue');
+
+const Reader = () => import('./components/Reader/Reader.vue');
+//const Forum = () => import('./components/Forum/Forum.vue');
+const Income = () => import('./components/Income/Income.vue');
+const Sources = () => import('./components/Sources/Sources.vue');
+const Settings = () => import('./components/Settings/Settings.vue');
+const Help = () => import('./components/Help/Help.vue');
+const NotFound404 = () => import('./components/NotFound404/NotFound404.vue');
+
+const myRoutes = [
+    ['/', null, null, '/cardindex'],
+    ['/cardindex', CardIndex ],
+    ['/cardindex~search', Search ],
+    ['/cardindex~card', Card ],
+    ['/cardindex~card/:authorId', Card ],
+    ['/cardindex~book', Book ],
+    ['/cardindex~book/:bookId', Book ],
+    ['/cardindex~history', History ],
+
+    ['/reader', Reader ],
+    ['/income', Income ],
+    ['/sources', Sources ],
+    ['/settings', Settings ],
+    ['/help', Help ],
+    ['*', null, null, '/cardindex' ],
+];
+
+let routes = {};
+
+for (let route of myRoutes) {
+    const [path, component, name, redirect] = route;
+    let cleanRoute = _.pickBy({path, component, name, redirect}, _.identity);
+    
+    let parts = cleanRoute.path.split('~');
+    let f = routes;
+    for (let part of parts) {
+        const curRoute = _.assign({}, cleanRoute, { path: part });
+
+        if (!f.children)
+            f.children = [];
+        let r = f.children;
+
+        f = _.find(r, {path: part});
+        if (!f) {
+            r.push(curRoute);
+            f = curRoute;
+        }
+    }
+}
+routes = routes.children;
+
+Vue.use(VueRouter);
+
+export default new VueRouter({
+    routes
+});

+ 65 - 0
client/share/utils.js

@@ -0,0 +1,65 @@
+export function sleep(ms) {
+    return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+export function stringToHex(str) {
+    let result = '';
+    for (let i = 0; i < str.length; i++) {
+        result += str.charCodeAt(i).toString(16);
+    }
+    return result;
+}
+
+export function hexToString(str) {
+    let result = '';
+    for (let i = 0; i < str.length; i += 2) {
+        result += String.fromCharCode(parseInt(str.substr(i, 2), 16));
+    }
+    return result;
+}
+
+export function formatDate(d, format) {
+    if (!format)
+        format = 'normal';
+
+    switch (format) {
+        case 'normal':
+            return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()} ` + 
+                `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
+    }
+    
+}
+
+export function fallbackCopyTextToClipboard(text) {
+    let textArea = document.createElement('textarea');
+    textArea.value = text;
+    document.body.appendChild(textArea);
+    textArea.focus();
+    textArea.select();
+
+    let result = false;
+    try {
+         result = document.execCommand('copy');
+    } catch (e) {
+        //
+    }
+
+    document.body.removeChild(textArea);
+    return result;
+}
+
+export async function copyTextToClipboard(text) {
+    if (!navigator.clipboard) {
+        return fallbackCopyTextToClipboard(text);
+    }
+
+    let result = false;
+    try {
+        await navigator.clipboard.writeText(text);
+        result = true;
+    } catch (e) {
+        //
+    }
+
+    return result;
+}

+ 22 - 0
client/store/index.js

@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createPersistedState from 'vuex-persistedstate';
+
+import root from './root.js';
+import uistate from './modules/uistate';
+import config from './modules/config';
+import reader from './modules/reader';
+
+Vue.use(Vuex);
+
+const debug = process.env.NODE_ENV !== 'production';
+
+export default new Vuex.Store(Object.assign({}, root, {
+  modules: {
+    uistate,
+    config,
+    reader,
+  },
+  strict: debug,
+  plugins: [createPersistedState()]
+}));

+ 39 - 0
client/store/modules/config.js

@@ -0,0 +1,39 @@
+import miscApi from '../../api/misc';
+// initial state
+const state = {
+    name: null,
+    version: null,
+    mode: null,
+};
+
+// getters
+const getters = {};
+
+// actions
+const actions = {
+    async loadConfig({ commit, state }) {
+        commit('setApiError', null, { root: true });
+        commit('setConfig', {});
+        try {
+            const config = await miscApi.loadConfig();
+            commit('setConfig', config);
+        } catch (e) {
+            commit('setApiError', e, { root: true });
+        }
+    },
+};
+
+// mutations
+const mutations = {
+    setConfig(state, value) {
+        Object.assign(state, value);
+    },
+};
+
+export default {
+    namespaced: true,
+    state,
+    getters,
+    actions,
+    mutations
+};

+ 202 - 0
client/store/modules/reader.js

@@ -0,0 +1,202 @@
+import Vue from 'vue';
+
+const fonts = [
+    {name: 'ReaderDefault', label: 'По-умолчанию', fontVertShift: 0},
+    {name: 'GEO_1', label: 'BPG Arial', fontVertShift: 10},
+    {name: 'Arimo', fontVertShift: 0},
+    {name: 'Avrile', fontVertShift: -10},
+    {name: 'OpenSans', fontVertShift: -5},
+    {name: 'Roboto', fontVertShift: 10},
+    {name: 'Rubik', fontVertShift: 0},
+];
+
+const webFonts = [
+    {css: 'https://fonts.googleapis.com/css?family=Alegreya', name: 'Alegreya', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Alegreya+Sans', name: 'Alegreya Sans', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Alegreya+SC', name: 'Alegreya SC', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Alice', name: 'Alice', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Amatic+SC', name: 'Amatic SC', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Andika', name: 'Andika', fontVertShift: -35},
+    {css: 'https://fonts.googleapis.com/css?family=Anonymous+Pro', name: 'Anonymous Pro', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Arsenal', name: 'Arsenal', fontVertShift: 0},
+
+    {css: 'https://fonts.googleapis.com/css?family=Bad+Script', name: 'Bad Script', fontVertShift: -30},
+
+    {css: 'https://fonts.googleapis.com/css?family=Caveat', name: 'Caveat', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Comfortaa', name: 'Comfortaa', fontVertShift: 10},
+    {css: 'https://fonts.googleapis.com/css?family=Cormorant', name: 'Cormorant', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Cormorant+Garamond', name: 'Cormorant Garamond', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Cormorant+Infant', name: 'Cormorant Infant', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Cormorant+Unicase', name: 'Cormorant Unicase', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Cousine', name: 'Cousine', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Cuprum', name: 'Cuprum', fontVertShift: 5},
+
+    {css: 'https://fonts.googleapis.com/css?family=Didact+Gothic', name: 'Didact Gothic', fontVertShift: -10},
+
+    {css: 'https://fonts.googleapis.com/css?family=EB+Garamond', name: 'EB Garamond', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=El+Messiri', name: 'El Messiri', fontVertShift: -5},
+
+    {css: 'https://fonts.googleapis.com/css?family=Fira+Mono', name: 'Fira Mono', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Fira+Sans', name: 'Fira Sans', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Fira+Sans+Condensed', name: 'Fira Sans Condensed', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Fira+Sans+Extra+Condensed', name: 'Fira Sans Extra Condensed', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Forum', name: 'Forum', fontVertShift: 5},
+
+    {css: 'https://fonts.googleapis.com/css?family=Gabriela', name: 'Gabriela', fontVertShift: 5},
+
+    {css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Mono', name: 'IBM Plex Mono', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Sans', name: 'IBM Plex Sans', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Serif', name: 'IBM Plex Serif', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Istok+Web', name: 'Istok Web', fontVertShift: -5},
+
+    {css: 'https://fonts.googleapis.com/css?family=Jura', name: 'Jura', fontVertShift: 0},
+
+    {css: 'https://fonts.googleapis.com/css?family=Kelly+Slab', name: 'Kelly Slab', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Kosugi', name: 'Kosugi', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Kosugi+Maru', name: 'Kosugi Maru', fontVertShift: 10},
+    {css: 'https://fonts.googleapis.com/css?family=Kurale', name: 'Kurale', fontVertShift: -15},
+
+    {css: 'https://fonts.googleapis.com/css?family=Ledger', name: 'Ledger', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Lobster', name: 'Lobster', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Lora', name: 'Lora', fontVertShift: 0},
+
+    {css: 'https://fonts.googleapis.com/css?family=Marck+Script', name: 'Marck Script', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Marmelad', name: 'Marmelad', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Merriweather', name: 'Merriweather', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Montserrat', name: 'Montserrat', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Montserrat+Alternates', name: 'Montserrat Alternates', fontVertShift: 0},
+
+    {css: 'https://fonts.googleapis.com/css?family=Neucha', name: 'Neucha', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Noto+Sans', name: 'Noto Sans', fontVertShift: -10},
+    {css: 'https://fonts.googleapis.com/css?family=Noto+Sans+SC', name: 'Noto Sans SC', fontVertShift: -15},
+    {css: 'https://fonts.googleapis.com/css?family=Noto+Serif', name: 'Noto Serif', fontVertShift: -10},
+    {css: 'https://fonts.googleapis.com/css?family=Noto+Serif+TC', name: 'Noto Serif TC', fontVertShift: -15},
+    
+    {css: 'https://fonts.googleapis.com/css?family=Old+Standard+TT', name: 'Old Standard TT', fontVertShift: 15},
+    {css: 'https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300', name: 'Open Sans Condensed', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Oranienbaum', name: 'Oranienbaum', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Oswald', name: 'Oswald', fontVertShift: -20},
+
+    {css: 'https://fonts.googleapis.com/css?family=Pacifico', name: 'Pacifico', fontVertShift: -35},
+    {css: 'https://fonts.googleapis.com/css?family=Pangolin', name: 'Pangolin', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Pattaya', name: 'Pattaya', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Philosopher', name: 'Philosopher', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Play', name: 'Play', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Playfair+Display', name: 'Playfair Display', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Playfair+Display+SC', name: 'Playfair Display SC', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Podkova', name: 'Podkova', fontVertShift: 10},
+    {css: 'https://fonts.googleapis.com/css?family=Poiret+One', name: 'Poiret One', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Prata', name: 'Prata', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Prosto+One', name: 'Prosto One', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=PT+Mono', name: 'PT Mono', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=PT+Sans', name: 'PT Sans', fontVertShift: -10},
+    {css: 'https://fonts.googleapis.com/css?family=PT+Sans+Caption', name: 'PT Sans Caption', fontVertShift: -10},
+    {css: 'https://fonts.googleapis.com/css?family=PT+Sans+Narrow', name: 'PT Sans Narrow', fontVertShift: -10},
+    {css: 'https://fonts.googleapis.com/css?family=PT+Serif', name: 'PT Serif', fontVertShift: -10},
+    {css: 'https://fonts.googleapis.com/css?family=PT+Serif+Caption', name: 'PT Serif Caption', fontVertShift: -10},
+
+    {css: 'https://fonts.googleapis.com/css?family=Roboto+Condensed', name: 'Roboto Condensed', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Roboto+Mono', name: 'Roboto Mono', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Roboto+Slab', name: 'Roboto Slab', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Ruslan+Display', name: 'Ruslan Display', fontVertShift: 20},
+    {css: 'https://fonts.googleapis.com/css?family=Russo+One', name: 'Russo One', fontVertShift: 5},
+
+    {css: 'https://fonts.googleapis.com/css?family=Sawarabi+Gothic', name: 'Sawarabi Gothic', fontVertShift: -15},
+    {css: 'https://fonts.googleapis.com/css?family=Scada', name: 'Scada', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Seymour+One', name: 'Seymour One', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Source+Sans+Pro', name: 'Source Sans Pro', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Spectral', name: 'Spectral', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Stalinist+One', name: 'Stalinist One', fontVertShift: 0},
+
+    {css: 'https://fonts.googleapis.com/css?family=Tinos', name: 'Tinos', fontVertShift: 5},
+    {css: 'https://fonts.googleapis.com/css?family=Tenor+Sans', name: 'Tenor Sans', fontVertShift: 5},
+
+    {css: 'https://fonts.googleapis.com/css?family=Underdog', name: 'Underdog', fontVertShift: 10},
+    {css: 'https://fonts.googleapis.com/css?family=Ubuntu+Mono', name: 'Ubuntu Mono', fontVertShift: 0},
+    {css: 'https://fonts.googleapis.com/css?family=Ubuntu+Condensed', name: 'Ubuntu Condensed', fontVertShift: -5},
+
+    {css: 'https://fonts.googleapis.com/css?family=Vollkorn', name: 'Vollkorn', fontVertShift: -5},
+    {css: 'https://fonts.googleapis.com/css?family=Vollkorn+SC', name: 'Vollkorn SC', fontVertShift: 0},
+
+    {css: 'https://fonts.googleapis.com/css?family=Yanone+Kaffeesatz', name: 'Yanone Kaffeesatz', fontVertShift: 20},
+    {css: 'https://fonts.googleapis.com/css?family=Yeseva+One', name: 'Yeseva One', fontVertShift: 10},
+
+
+];
+
+const settingDefaults = {
+        textColor: '#000000',
+        backgroundColor: '#EBE2C9',
+        wallpaper: '',
+        fontStyle: '',// 'italic'
+        fontWeight: '',// 'bold'
+        fontSize: 20,// px
+        fontName: 'ReaderDefault',
+        webFontName: '',
+        fontVertShift: 0,
+        textVertShift: -20,
+
+        lineInterval: 3,// px, межстрочный интервал
+        textAlignJustify: true,// выравнивание по ширине
+        p: 25,// px, отступ параграфа
+        indentLR: 15,// px, отступ всего текста слева и справа
+        indentTB: 0,// px, отступ всего текста сверху и снизу
+        wordWrap: true,//перенос по слогам
+        keepLastToFirst: true,// перенос последней строки в первую при листании
+
+        showStatusBar: true,
+        statusBarTop: false,// top, bottom
+        statusBarHeight: 19,// px
+        statusBarColorAlpha: 0.4,
+
+        scrollingDelay: 3000,// замедление, ms
+        scrollingType: 'ease-in-out', //linear, ease, ease-in, ease-out, ease-in-out
+
+        pageChangeTransition: '',// '' - нет, downShift, rightShift, thaw - протаивание, blink - мерцание
+        pageChangeTransitionSpeed: 50, //0-100%
+
+        allowUrlParamBookPos: false,
+        lazyParseEnabled: false,
+        copyFullText: false,
+        showClickMapPage: true,
+        fontShifts: {},
+};
+
+for (const font of fonts)
+    settingDefaults.fontShifts[font.name] = font.fontVertShift;
+for (const font of webFonts)
+    settingDefaults.fontShifts[font.name] = font.fontVertShift;
+
+// initial state
+const state = {
+    toolBarActive: true,
+    settings: Object.assign({}, settingDefaults),
+};
+
+// getters
+const getters = {};
+
+// actions
+const actions = {};
+
+// mutations
+const mutations = {
+    setToolBarActive(state, value) {
+        state.toolBarActive = value;
+    },
+    setSettings(state, value) {
+        state.settings = Object.assign({}, state.settings, value);
+    }
+};
+
+export default {
+    fonts,
+    webFonts,
+    settingDefaults,
+
+    namespaced: true,
+    state,
+    getters,
+    actions,
+    mutations
+};

+ 25 - 0
client/store/modules/uistate.js

@@ -0,0 +1,25 @@
+// initial state
+const state = {
+    asideBarCollapse: false,
+};
+
+// getters
+const getters = {};
+
+// actions
+const actions = {};
+
+// mutations
+const mutations = {
+    setAsideBarCollapse(state, value) {
+        state.asideBarCollapse = value;
+    },
+};
+
+export default {
+    namespaced: true,
+    state,
+    getters,
+    actions,
+    mutations
+};

+ 25 - 0
client/store/root.js

@@ -0,0 +1,25 @@
+// initial state
+const state = {
+    apiError: null,
+};
+
+// getters
+const getters = {};
+
+// actions
+const actions = {};
+
+// mutations
+const mutations = {
+    setApiError(state, value) {
+        state.apiError = value;
+    },
+};
+
+export default {
+    namespaced: true,
+    state,
+    getters,
+    actions,
+    mutations
+};

+ 1 - 0
client/theme/alert.css

@@ -0,0 +1 @@
+.el-alert{width:100%;padding:8px 16px;margin:0;-webkit-box-sizing:border-box;box-sizing:border-box;border-radius:4px;position:relative;background-color:#fff;overflow:hidden;opacity:1;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-transition:opacity .2s;transition:opacity .2s}.el-alert.is-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.el-alert--success{background-color:#f0f9eb;color:#67c23a}.el-alert--success .el-alert__description{color:#67c23a}.el-alert--info{background-color:#f4f4f5;color:#909399}.el-alert--info .el-alert__description{color:#909399}.el-alert--warning{background-color:#fdf6ec;color:#e6a23c}.el-alert--warning .el-alert__description{color:#e6a23c}.el-alert--error{background-color:#fef0f0;color:#f56c6c}.el-alert--error .el-alert__description{color:#f56c6c}.el-alert__content{display:table-cell;padding:0 8px}.el-alert__icon{font-size:16px;width:16px}.el-alert__icon.is-big{font-size:28px;width:28px}.el-alert__title{font-size:13px;line-height:18px}.el-alert__title.is-bold{font-weight:700}.el-alert .el-alert__description{font-size:12px;margin:5px 0 0}.el-alert__closebtn{font-size:12px;color:#c0c4cc;opacity:1;position:absolute;top:12px;right:15px;cursor:pointer}.el-alert__closebtn.is-customed{font-style:normal;font-size:13px;top:9px}.el-alert-fade-enter,.el-alert-fade-leave-active{opacity:0}

+ 1 - 0
client/theme/aside.css

@@ -0,0 +1 @@
+.el-aside{overflow:auto;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-flex-negative:0;flex-shrink:0}

File diff suppressed because it is too large
+ 0 - 0
client/theme/autocomplete.css


+ 1 - 0
client/theme/badge.css

@@ -0,0 +1 @@
+.el-badge{position:relative;vertical-align:middle;display:inline-block}.el-badge__content{background-color:#f56c6c;border-radius:10px;color:#fff;display:inline-block;font-size:12px;height:18px;line-height:18px;padding:0 6px;text-align:center;white-space:nowrap;border:1px solid #fff}.el-badge__content.is-fixed{position:absolute;top:0;right:10px;-webkit-transform:translateY(-50%) translateX(100%);transform:translateY(-50%) translateX(100%)}.el-badge__content.is-fixed.is-dot{right:5px}.el-badge__content.is-dot{height:8px;width:8px;padding:0;right:0;border-radius:50%}.el-badge__content--primary{background-color:#00468F}.el-badge__content--success{background-color:#67c23a}.el-badge__content--warning{background-color:#e6a23c}.el-badge__content--info{background-color:#909399}.el-badge__content--danger{background-color:#f56c6c}

File diff suppressed because it is too large
+ 0 - 0
client/theme/base.css


+ 0 - 0
client/theme/breadcrumb-item.css


+ 1 - 0
client/theme/breadcrumb.css

@@ -0,0 +1 @@
+.el-breadcrumb{font-size:14px;line-height:1}.el-breadcrumb::after,.el-breadcrumb::before{display:table;content:""}.el-breadcrumb::after{clear:both}.el-breadcrumb__separator{margin:0 9px;font-weight:700;color:#c0c4cc}.el-breadcrumb__separator[class*=icon]{margin:0 6px;font-weight:400}.el-breadcrumb__item{float:left}.el-breadcrumb__inner{color:#606266}.el-breadcrumb__inner a,.el-breadcrumb__inner.is-link{font-weight:700;text-decoration:none;-webkit-transition:color .2s cubic-bezier(.645,.045,.355,1);transition:color .2s cubic-bezier(.645,.045,.355,1);color:#303133}.el-breadcrumb__inner a:hover,.el-breadcrumb__inner.is-link:hover{color:#00468F;cursor:pointer}.el-breadcrumb__item:last-child .el-breadcrumb__inner,.el-breadcrumb__item:last-child .el-breadcrumb__inner a,.el-breadcrumb__item:last-child .el-breadcrumb__inner a:hover,.el-breadcrumb__item:last-child .el-breadcrumb__inner:hover{font-weight:400;color:#606266;cursor:text}.el-breadcrumb__item:last-child .el-breadcrumb__separator{display:none}

+ 0 - 0
client/theme/button-group.css


File diff suppressed because it is too large
+ 0 - 0
client/theme/button.css


+ 1 - 0
client/theme/card.css

@@ -0,0 +1 @@
+.el-card{border-radius:4px;border:1px solid #ebeef5;background-color:#fff;overflow:hidden;color:#303133;-webkit-transition:.3s;transition:.3s}.el-card.is-always-shadow,.el-card.is-hover-shadow:focus,.el-card.is-hover-shadow:hover{-webkit-box-shadow:0 2px 12px 0 rgba(0,0,0,.1);box-shadow:0 2px 12px 0 rgba(0,0,0,.1)}.el-card__header{padding:18px 20px;border-bottom:1px solid #ebeef5;-webkit-box-sizing:border-box;box-sizing:border-box}.el-card__body{padding:20px}

+ 1 - 0
client/theme/carousel-item.css

@@ -0,0 +1 @@
+.el-carousel__item,.el-carousel__mask{position:absolute;height:100%;top:0;left:0}.el-carousel__item{width:100%;display:inline-block;overflow:hidden;z-index:0}.el-carousel__item.is-active{z-index:2}.el-carousel__item.is-animating{-webkit-transition:-webkit-transform .4s ease-in-out;transition:-webkit-transform .4s ease-in-out;transition:transform .4s ease-in-out;transition:transform .4s ease-in-out,-webkit-transform .4s ease-in-out}.el-carousel__item--card{width:50%;-webkit-transition:-webkit-transform .4s ease-in-out;transition:-webkit-transform .4s ease-in-out;transition:transform .4s ease-in-out;transition:transform .4s ease-in-out,-webkit-transform .4s ease-in-out}.el-carousel__item--card.is-in-stage{cursor:pointer;z-index:1}.el-carousel__item--card.is-in-stage.is-hover .el-carousel__mask,.el-carousel__item--card.is-in-stage:hover .el-carousel__mask{opacity:.12}.el-carousel__item--card.is-active{z-index:2}.el-carousel__mask{width:100%;background-color:#fff;opacity:.24;-webkit-transition:.2s;transition:.2s}

File diff suppressed because it is too large
+ 0 - 0
client/theme/carousel.css


File diff suppressed because it is too large
+ 0 - 0
client/theme/cascader.css


+ 0 - 0
client/theme/checkbox-button.css


+ 0 - 0
client/theme/checkbox-group.css


File diff suppressed because it is too large
+ 0 - 0
client/theme/checkbox.css


File diff suppressed because it is too large
+ 0 - 0
client/theme/col.css


+ 0 - 0
client/theme/collapse-item.css


File diff suppressed because it is too large
+ 0 - 0
client/theme/collapse.css


Some files were not shown because too many files changed in this diff