فهرست منبع

Merge branch 'release/0.7.0'

Book Pauk 5 سال پیش
والد
کامیت
f66162efe7
28فایلهای تغییر یافته به همراه1932 افزوده شده و 1791 حذف شده
  1. 2 2
      build/linux.js
  2. 3 1
      build/webpack.prod.config.js
  3. 2 2
      build/win.js
  4. 8 29
      client/components/Reader/CopyTextPage/CopyTextPage.vue
  5. 13 13
      client/components/Reader/HelpPage/CommonHelpPage/CommonHelpPage.vue
  6. 22 44
      client/components/Reader/HelpPage/HelpPage.vue
  7. 0 290
      client/components/Reader/HistoryPage/HistoryPage.vue
  8. 1 1
      client/components/Reader/LoaderPage/LoaderPage.vue
  9. 21 34
      client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue
  10. 181 62
      client/components/Reader/Reader.vue
  11. 320 0
      client/components/Reader/RecentBooksPage/RecentBooksPage.vue
  12. 22 43
      client/components/Reader/SearchPage/SearchPage.vue
  13. 135 234
      client/components/Reader/ServerStorage/ServerStorage.vue
  14. 9 31
      client/components/Reader/SetPositionPage/SetPositionPage.vue
  15. 510 493
      client/components/Reader/SettingsPage/SettingsPage.vue
  16. 4 6
      client/components/Reader/TextPage/DrawHelper.js
  17. 7 7
      client/components/Reader/TextPage/TextPage.vue
  18. 7 4
      client/components/Reader/share/BookParser.js
  19. 98 155
      client/components/Reader/share/bookManager.js
  20. 16 0
      client/components/Reader/versionHistory.js
  21. 119 11
      client/components/share/Window.vue
  22. 1 1
      client/index.html.template
  23. 1 0
      client/main.js
  24. 4 0
      client/share/utils.js
  25. 8 2
      client/store/modules/reader.js
  26. 31 0
      docs/omnireader/omnireader
  27. 355 295
      package-lock.json
  28. 32 31
      package.json

+ 2 - 2
build/linux.js

@@ -24,8 +24,8 @@ async function main() {
     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`;
+    const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.1.0/node-v72-linux-x64.tar.gz';
+    const sqliteDecompressedFilename = `${tempDownloadDir}/node-v72-linux-x64/node_sqlite3.node`;
 
     if (!await fs.pathExists(sqliteDecompressedFilename)) {
         // Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку

+ 3 - 1
build/webpack.prod.config.js

@@ -9,6 +9,7 @@ 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 AppCachePlugin = require('appcache-webpack-plugin');
 
 const publicDir = path.resolve(__dirname, '../dist/tmp/public');
 const clientDir = path.resolve(__dirname, '../client');
@@ -53,6 +54,7 @@ module.exports = merge(baseWpConfig, {
             template: `${clientDir}/index.html.template`,
             filename: `${publicDir}/index.html`
         }),
-        new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
+        new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}]),
+        new AppCachePlugin({})
     ]
 });

+ 2 - 2
build/win.js

@@ -24,8 +24,8 @@ async function main() {
     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`;
+    const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.1.0/node-v72-win32-x64.tar.gz';
+    const sqliteDecompressedFilename = `${tempDownloadDir}/node-v72-win32-x64/node_sqlite3.node`;
 
     if (!await fs.pathExists(sqliteDecompressedFilename)) {
         // Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку

+ 8 - 29
client/components/Reader/CopyTextPage/CopyTextPage.vue

@@ -1,17 +1,13 @@
 <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>
+    <Window @close="close">
+        <template slot="header">
+            Скопировать текст
+        </template>
+
+        <div ref="text" class="text" tabindex="-1">
+            <div v-html="text"></div>
         </div>
-    </div>
+    </Window>
 </template>
 
 <script>
@@ -109,23 +105,6 @@ class CopyTextPage extends Vue {
 </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;

+ 13 - 13
client/components/Reader/HelpPage/CommonHelpPage/CommonHelpPage.vue

@@ -23,12 +23,19 @@
         <p>Поддерживаемые форматы: <b>fb2, fb2.zip, html, txt</b> и другие.</p>
 
         <p>Для автономной загрузки читалки (без интернета):<br>
-        В Google Chrome можно установить флаг <span class="clickable" @click="copyText('chrome://flags/#show-saved-copy')">chrome://flags/#show-saved-copy</span>
+        В Google Chrome можно установить флаг <span class="clickable" @click="copyText('chrome://flags/#show-saved-copy', 'Ссылка на флаг успешно скопирована в буфер обмена. Можно открыть ее в новой вкладке.')">chrome://flags/#show-saved-copy</span>
            в значение "Primary". В этом случае на стандартной странице "нет соединения" появится кнопка для автономной загрузки сайта из кэша.<br>
         В Mozilla Firefox в автономном режиме сайт загружается из кэша автоматически. Если этого не происходит, можно установить опцию
         "Веб-разработка" -> "Работать автономно".</p>
 
-        <div v-html="automationHtml"></div>
+        <div v-show="mode == 'omnireader'">
+            <p>Вы также можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
+                <br><span class="clickable" @click="copyText('javascript:location.href=\'https://omnireader.ru/?url=\'+location.href;', 'Код для адреса закладки успешно скопирован в буфер обмена')">
+                    <strong>javascript:location.href='https://omnireader.ru/?url='+location.href;</strong>
+                </span>
+                <br>Тогда, нажав на получившуюся кнопку на любой странице интернета, вы автоматически откроете ее в Omni Reader.
+            </p>
+        </div>
         <p>Связаться с разработчиком: <a href="mailto:bookpauk@gmail.com">bookpauk@gmail.com</a></p>
     </div>
 </template>
@@ -44,22 +51,15 @@ 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 '';
-        }
+    get mode() {
+        return this.$store.state.config.mode;
     }
 
-    async copyText(text) {
+    async copyText(text, mes) {
         const result = await copyTextToClipboard(text);
-        const msg = (result ? `Ссылка на флаг успешно скопирована в буфер обмена. Можно открыть ее в новой вкладке.` : 'Копирование не удалось');
+        const msg = (result ? mes : 'Копирование не удалось');
         if (result)
             this.$notify.success({message: msg});
         else

+ 22 - 44
client/components/Reader/HelpPage/HelpPage.vue

@@ -1,32 +1,27 @@
 <template>
-    <div ref="main" class="main" @click="close">
-        <div class="mainWindow" @click.stop>
-            <Window @close="close">
-                <template slot="header">
-                    Справка
-                </template>
+    <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="releases">
-                        <VersionHistoryPage></VersionHistoryPage>
-                    </el-tab-pane>
-                    <el-tab-pane label="Помочь проекту" name="donate">
-                        <DonateHelpPage></DonateHelpPage>
-                    </el-tab-pane>
-
-                </el-tabs>
-            </Window>
-        </div>
-    </div>
+        <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="releases">
+                <VersionHistoryPage></VersionHistoryPage>
+            </el-tab-pane>
+            <el-tab-pane label="Помочь проекту" name="donate">
+                <DonateHelpPage></DonateHelpPage>
+            </el-tab-pane>
+        </el-tabs>
+    </Window>
 </template>
 
 <script>
@@ -77,23 +72,6 @@ class HelpPage extends Vue {
 </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;

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

@@ -1,290 +0,0 @@
-<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"
-                    :row-key = "rowKey"
-                    >
-
-                    <el-table-column
-                        type="index"
-                        width="35px"
-                        >
-                    </el-table-column>
-                    <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"
-                                        ref="input"
-                                        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() {
-    }
-
-    init() {
-        this.updateTableData();
-        this.$nextTick(() => {
-            this.$refs.input.focus();
-        });
-    }
-
-    rowKey(row) {
-        return row.key;
-    }
-
-    updateTableData() {
-        let result = [];
-
-        const sorted = bookManager.getSortedRecent();
-        for (let i = 0; i < sorted.length; i++) {
-            const book = sorted[i];
-            if (book.deleted)
-                continue;
-
-            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 : {});
-
-            let title = fb2.bookTitle;
-            if (title)
-                title = `"${title}"`;
-            else
-                title = '';
-
-            let author = '';
-            if (fb2.author) {
-                const authorNames = fb2.author.map(a => _.compact([
-                    a.lastName,
-                    a.firstName,
-                    a.middleName
-                ]).join(' '));
-                author = authorNames.join(', ');
-            } else {
-                author = _.compact([
-                    fb2.lastName,
-                    fb2.firstName,
-                    fb2.middleName
-                ]).join(' ');
-            }
-            author = (author ? author : (fb2.bookTitle ? fb2.bookTitle : book.url));
-
-            result.push({
-                touchDateTime: book.touchTime,
-                touchDate: t[0],
-                touchTime: t[1],
-                desc: {
-                    title: `${title}${perc}${textLen}`,
-                    author,
-                },
-                url: book.url,
-                path: book.path,
-                key: book.key,
-            });
-            if (result.length >= 100)
-                break;
-        }
-
-        const search = this.search;
-        result = 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())
-        });
-
-        this.tableData = result;
-    }
-
-    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();
-
-        if (!bookManager.mostRecentBook())
-            this.close();
-    }
-
-    loadBook(url) {
-        this.$emit('load-book', {url});
-        this.close();
-    }
-
-    isUrl(url) {
-        if (url)
-            return (url.indexOf('file://') != 0);
-        else
-            return false;
-    }
-
-    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>

+ 1 - 1
client/components/Reader/LoaderPage/LoaderPage.vue

@@ -27,7 +27,7 @@
                 data-services="collections,vkontakte,facebook,odnoklassniki,twitter,telegram"
                 data-description="Чтение fb2-книг онлайн. Загрузка любой страницы интернета одним кликом, синхронизация между устройствами, удобное управление, регистрация не требуется."
                 data-title="Omni Reader - браузерная онлайн-читалка"
-                data-url="http://omnireader.ru">
+                data-url="https://omnireader.ru">
             </div>
             <div class="space"></div>
             <span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Отзывы о читалке</span>

+ 21 - 34
client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue

@@ -1,22 +1,19 @@
 <template>
-    <div ref="main" class="main" @click="close">
-        <div class="mainWindow" @click.stop>
-            <Window @close="close">
-                <template slot="header">
-                    Вставьте текст и нажмите
-                    <el-button size="mini" style="font-size: 120%; color: blue" @click="loadBuffer">Загрузить</el-button>
-
-                    или F2
-                </template>
-
-                <div>
-                    <el-input placeholder="Введите название текста" class="input" v-model="bookTitle"></el-input>
-                </div>
-                <hr/>
-                <textarea ref="textArea" class="text" @paste="calcTitle"></textarea>
-            </Window>
+    <Window @close="close">
+        <template slot="header">
+            <span style="position: relative; top: -3px">
+                Вставьте текст и нажмите
+                <span class="clickable" style="font-size: 150%; position: relative; top: 1px" @click="loadBuffer">загрузить</span>
+                или F2
+            </span>
+        </template>
+
+        <div>
+            <el-input placeholder="Введите название текста" class="input" v-model="bookTitle"></el-input>
         </div>
-    </div>
+        <hr/>
+        <textarea ref="textArea" class="text" @paste="calcTitle"></textarea>
+    </Window>
 </template>
 
 <script>
@@ -99,23 +96,6 @@ class PasteTextPage extends Vue {
 </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;
@@ -123,6 +103,7 @@ class PasteTextPage extends Vue {
     padding: 0 10px 0 10px;
     position: relative;
     font-size: 120%;
+    min-width: 400px;
 }
 
 .text:focus {
@@ -133,4 +114,10 @@ hr {
     margin: 0;
     padding: 0;
 }
+
+.clickable {
+    color: blue;
+    cursor: pointer;
+}
+
 </style>

+ 181 - 62
client/components/Reader/Reader.vue

@@ -1,7 +1,7 @@
 <template>
     <el-container>
         <el-header v-show="toolBarActive" height='50px'>
-            <div class="header">
+            <div ref="header" 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>
@@ -35,8 +35,8 @@
                         </el-button>
                     </el-tooltip>
                     <div class="space"></div>
-                    <el-tooltip v-show="showToolButton['history']" 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 v-show="showToolButton['recentBooks']" content="Открыть недавние" :open-delay="1000" effect="light">
+                        <el-button ref="recentBooks" class="tool-button" :class="buttonActiveClass('recentBooks')" @click="buttonClick('recentBooks')"><i class="el-icon-document"></i></el-button>
                     </el-tooltip>
                 </div>
 
@@ -69,7 +69,7 @@
                 @stop-text-search="stopTextSearch">
             </SearchPage>
             <CopyTextPage v-if="copyTextActive" ref="copyTextPage" @copy-text-toggle="copyTextToggle"></CopyTextPage>
-            <HistoryPage v-show="historyActive" ref="historyPage" @load-book="loadBook" @history-toggle="historyToggle"></HistoryPage>
+            <RecentBooksPage v-show="recentBooksActive" ref="recentBooksPage" @load-book="loadBook" @recent-books-toggle="recentBooksToggle"></RecentBooksPage>
             <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>
@@ -87,6 +87,87 @@
                 </span>
             </el-dialog>
 
+            <el-dialog
+                title="Внимание!"
+                :visible.sync="migrationVisible1"
+                width="90%">
+                <div>
+                    Появилась httpS-версия сайта по адресу <a href="https://omnireader.ru" target="_blank">https://omnireader.ru</a><br>
+                    Работа по httpS-протоколу, помимо безопасности соединения, позволяет воспользоваться всеми возможностями
+                    современных браузеров, а именно, применительно к нашему ресурсу:
+
+                    <ul>
+                        <li>возможность автономной работы с читалкой (без доступа к интернету), кеширование сайта через appcache</li>
+                        <li>безопасная передача на сервер данных о настройках и читаемых книгах при включенной синхронизации; все данные шифруются на стороне
+                            браузера ключом доступа и никто (в т.ч. администратор) не имеет возможности их прочитать
+                        <li>использование встроенных в JS функций шифрования и других</li>
+                    </ul>
+
+                    Для того, чтобы перейти на новую версию с сохранением настроек и читаемых книг необходимо синхронизировать обе читалки:
+                    <ul>
+                        <li>зайти в "Настройки"->"Профили" и поставить галочку "Включить синхронизацию с сервером"</li>
+                        <li>там же добавить профиль устройства с любым именем для синхронизации настроек<br>
+                            <span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
+                                после этого все данные будут автоматически сохранены на сервер
+                            </span>
+                        </li>
+                        <li>далее нажать на кнопку "Показать ключ доступа" и кликнуть по ссылке "Ссылка для ввода ключа"<br>
+                            <span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
+                                произойдет переход на https-версию читалки и откроется окно для ввода ключа
+                            </span><br>
+                            <span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
+                                подтвердив ввод ключа нажатием "OK", включив синхронизацию с сервером и выбрав профиль устройства, вы восстановите все ваши настройки в новой версии
+                            </span>
+                        </li>
+                    </ul>
+
+
+                    Старая http-версия сайта будет доступна до конца 2019 года.<br>
+                    Приносим извинения за доставленные неудобства.
+                </div>
+
+                <span slot="footer" class="dialog-footer">
+                    <el-button @click="migrationDialogDisable">Больше не показывать</el-button>
+                    <el-button @click="migrationDialogRemind">Напомнить позже</el-button>
+                </span>
+            </el-dialog>
+
+            <el-dialog
+                title="Внимание!"
+                :visible.sync="migrationVisible2"
+                width="90%">
+                <div>
+                    Информация для пользователей старой версии читалки по адресу <a href="http://omnireader.ru" target="_blank">http://omnireader.ru</a><br>
+                    Для того, чтобы перейти на новую httpS-версию с сохранением настроек и читаемых книг необходимо синхронизировать обе читалки:
+                    <ul>
+                        <li>перейти на старую версию ресурса <a href="http://omnireader.ru" target="_blank">http://omnireader.ru</a></li>
+                        <li>зайти в "Настройки"->"Профили" и поставить галочку "Включить синхронизацию с сервером"</li>
+                        <li>там же добавить профиль устройства с любым именем для синхронизации настроек<br>
+                            <span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
+                                после этого все данные будут автоматически сохранены на сервер
+                            </span>
+                        </li>
+                        <li>далее нажать на кнопку "Показать ключ доступа" и кликнуть по ссылке "Ссылка для ввода ключа"<br>
+                            <span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
+                                произойдет переход на https-версию читалки и откроется окно для ввода ключа
+                            </span><br>
+                            <span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
+                                подтвердив ввод ключа нажатием "OK", включив синхронизацию с сервером и выбрав профиль устройства, вы восстановите все ваши настройки в новой версии
+                            </span>
+                        </li>
+                    </ul>
+
+
+                    Старая http-версия сайта будет доступна до конца 2019 года.<br>
+                    Приносим извинения за доставленные неудобства.
+                </div>
+
+                <span slot="footer" class="dialog-footer">
+                    <el-button @click="migrationDialogDisable">Больше не показывать</el-button>
+                    <el-button @click="migrationDialogRemind">Напомнить позже</el-button>
+                </span>
+            </el-dialog>
+
         </el-main>
 
     </el-container>
@@ -106,7 +187,7 @@ 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 RecentBooksPage from './RecentBooksPage/RecentBooksPage.vue';
 import SettingsPage from './SettingsPage/SettingsPage.vue';
 import HelpPage from './HelpPage/HelpPage.vue';
 import ClickMapPage from './ClickMapPage/ClickMapPage.vue';
@@ -126,7 +207,7 @@ export default @Component({
         SetPositionPage,
         SearchPage,
         CopyTextPage,
-        HistoryPage,
+        RecentBooksPage,
         SettingsPage,
         HelpPage,
         ClickMapPage,
@@ -157,10 +238,12 @@ export default @Component({
             this.updateRoute();
         },
         loaderActive: function(newValue) {
-            const recent = this.mostRecentBook();
-            if (!newValue && !this.loading && recent && !bookManager.hasBookParsed(recent)) {
-                this.loadBook(recent);
-            }
+            (async() => {
+                const recent = this.mostRecentBook();
+                if (!newValue && !this.loading && recent && !await bookManager.hasBookParsed(recent)) {
+                    this.loadBook(recent);
+                }
+            })();
         },
     },
 })
@@ -173,7 +256,7 @@ class Reader extends Vue {
     setPositionActive = false;
     searchActive = false;
     copyTextActive = false;
-    historyActive = false;
+    recentBooksActive = false;
     settingsActive = false;
     helpActive = false;
     clickMapActive = false;
@@ -190,6 +273,8 @@ class Reader extends Vue {
 
     whatsNewVisible = false;
     whatsNewContent = '';
+    migrationVisible1 = false;
+    migrationVisible2 = false;
 
     created() {
         this.loading = true;
@@ -216,26 +301,25 @@ class Reader extends Vue {
             }
         }, 500);
 
-        this.debouncedSaveRecent = _.debounce(async() => {
-            const serverStorage = this.$refs.serverStorage;
-            while (!serverStorage.inited) await utils.sleep(1000);
-            await serverStorage.saveRecent();
-        }, 1000);
-
-        this.debouncedSaveRecentLast = _.debounce(async() => {
-            const serverStorage = this.$refs.serverStorage;
-            while (!serverStorage.inited) await utils.sleep(1000);
-            await serverStorage.saveRecentLast();
-        }, 1000);
-
         document.addEventListener('fullscreenchange', () => {
             this.fullScreenActive = (document.fullscreenElement !== null);
         });
 
         this.loadSettings();
+
+        //TODO: убрать в будущем
+        if (this.showToolButton['history']) {
+            const newShowToolButton = Object.assign({}, this.showToolButton);
+            newShowToolButton['recentBooks'] = true;
+            delete newShowToolButton['history'];
+            const newSettings = Object.assign({}, this.settings, { showToolButton: newShowToolButton });
+            this.commit('reader/setSettings', newSettings);
+        }
     }
 
     mounted() {
+        this.updateHeaderMinWidth();
+
         (async() => {
             await bookManager.init(this.settings);
             bookManager.addEventListener(this.bookManagerEvent);
@@ -252,7 +336,9 @@ class Reader extends Vue {
             this.checkActivateDonateHelpPage();
             this.loading = false;
 
+            await this.$refs.serverStorage.init();
             await this.showWhatsNew();
+            await this.showMigration();
         })();
     }
 
@@ -264,7 +350,16 @@ class Reader extends Vue {
         this.clickControl = settings.clickControl;
         this.blinkCachedLoad = settings.blinkCachedLoad;
         this.showWhatsNewDialog = settings.showWhatsNewDialog;
+        this.showMigrationDialog = settings.showMigrationDialog;
         this.showToolButton = settings.showToolButton;
+
+        this.updateHeaderMinWidth();
+    }
+
+    updateHeaderMinWidth() {
+        const showButtonCount = Object.values(this.showToolButton).reduce((a, b) => a + (b ? 1 : 0), 0);
+        if (this.$refs.header)
+            this.$refs.header.style.minWidth = 65*showButtonCount + 'px';
     }
 
     checkSetStorageAccessKey() {
@@ -320,6 +415,33 @@ class Reader extends Vue {
         }
     }
 
+    async showMigration() {
+        await utils.sleep(3000);
+        if (!this.settingsActive &&
+            this.mode == 'omnireader' && this.showMigrationDialog && this.migrationRemindDate != utils.formatDate(new Date(), 'coDate')) {
+            if (window.location.protocol == 'http:') {
+                this.migrationVisible1 = true;
+            } else if (window.location.protocol == 'https:') {
+                this.migrationVisible2 = true;
+            }
+        }
+    }
+
+    migrationDialogDisable() {
+        this.migrationVisible1 = false;
+        this.migrationVisible2 = false;
+        if (this.showMigrationDialog) {
+            const newSettings = Object.assign({}, this.settings, { showMigrationDialog: false });
+            this.commit('reader/setSettings', newSettings);
+        }
+    }
+
+    migrationDialogRemind() {
+        this.migrationVisible1 = false;
+        this.migrationVisible2 = false;
+        this.commit('reader/setMigrationRemindDate', utils.formatDate(new Date(), 'coDate'));
+    }
+
     openVersionHistory() {
         this.whatsNewVisible = false;
         this.versionHistoryToggle();
@@ -350,10 +472,14 @@ class Reader extends Vue {
         const pos = (recent && recent.bookPos && this.allowUrlParamBookPos ? `__p=${recent.bookPos}&` : '');
         const url = (recent ? `url=${recent.url}` : '');
         if (isNewRoute)
-            this.$router.push(`/reader?${pos}${url}`);
+            this.$router.push(`/reader?${pos}${url}`).catch(() => {});
         else
-            this.$router.replace(`/reader?${pos}${url}`);
+            this.$router.replace(`/reader?${pos}${url}`).catch(() => {});
+
+    }
 
+    get mode() {
+        return this.$store.state.config.mode;
     }
 
     get routeParamUrl() {
@@ -380,22 +506,13 @@ class Reader extends Vue {
     }
 
     async bookManagerEvent(eventName) {
-        const serverStorage = this.$refs.serverStorage;
-        if (eventName == 'load-meta-finish') {
-            serverStorage.init();
-            const result = await bookManager.cleanRecentBooks();
-            if (result)
-                this.debouncedSaveRecent();
-        }
-
-        if (eventName == 'recent-changed' || eventName == 'save-recent') {
-            if (this.historyActive) {
-                this.$refs.historyPage.updateTableData();
+        if (eventName == 'recent-changed') {
+            if (this.recentBooksActive) {
+                await this.$refs.recentBooksPage.updateTableData();
             }
 
             const oldBook = this.mostRecentBookReactive;
             const newBook = bookManager.mostRecentBook();
-
             if (oldBook && newBook) {
                 if (oldBook.key != newBook.key) {
                     this.loadingBook = true;
@@ -409,12 +526,6 @@ class Reader extends Vue {
                     this.bookPosChanged({bookPos: newBook.bookPos});
                 }
             }
-
-            if (eventName == 'recent-changed') {
-                this.debouncedSaveRecentLast();
-            } else {
-                this.debouncedSaveRecent();
-            }
         }
     }
 
@@ -436,6 +547,10 @@ class Reader extends Vue {
         return this.$store.state.reader.whatsNewContentHash;
     }
 
+    get migrationRemindDate() {
+        return this.$store.state.reader.migrationRemindDate;
+    }
+
     addAction(pos) {
         let a = this.actionList;
         if (!a.length || a[a.length - 1] != pos) {
@@ -476,7 +591,7 @@ class Reader extends Vue {
     closeAllTextPages() {
         this.setPositionActive = false;
         this.copyTextActive = false;
-        this.historyActive = false;
+        this.recentBooksActive = false;
         this.settingsActive = false;
         this.stopScrolling();
         this.stopSearch();
@@ -568,14 +683,14 @@ class Reader extends Vue {
         }
     }
 
-    historyToggle() {
-        this.historyActive = !this.historyActive;
-        if (this.historyActive) {
+    recentBooksToggle() {
+        this.recentBooksActive = !this.recentBooksActive;
+        if (this.recentBooksActive) {
             this.closeAllTextPages();
-            this.$refs.historyPage.init();
-            this.historyActive = true;
+            this.$refs.recentBooksPage.init();
+            this.recentBooksActive = true;
         } else {
-            this.historyActive = false;
+            this.recentBooksActive = false;
         }
     }
 
@@ -584,6 +699,10 @@ class Reader extends Vue {
         if (this.settingsActive) {
             this.closeAllTextPages();
             this.settingsActive = true;
+
+            this.$nextTick(() => {
+                this.$refs.settingsPage.init();
+            });
         } else {
             this.settingsActive = false;
         }
@@ -660,8 +779,8 @@ class Reader extends Vue {
             case 'copyText':
                 this.copyTextToggle();
                 break;
-            case 'history':
-                this.historyToggle();
+            case 'recentBooks':
+                this.recentBooksToggle();
                 break;
             case 'refresh':
                 this.refreshBook();
@@ -684,7 +803,7 @@ class Reader extends Vue {
             case 'scrolling':
             case 'search':
             case 'copyText':
-            case 'history':
+            case 'recentBooks':
             case 'settings':
                 if (this[`${button}Active`])
                     classResult = classActive;
@@ -702,7 +821,7 @@ class Reader extends Vue {
                 break;
         }
 
-        if (this.activePage == 'LoaderPage' || !this.mostRecentBook()) {
+        if (this.activePage == 'LoaderPage' || !this.mostRecentBookReactive) {
             switch (button) {
                 case 'undoAction':
                 case 'redoAction':
@@ -712,9 +831,9 @@ class Reader extends Vue {
                 case 'copyText':
                     classResult = classDisabled;
                     break;
-                case 'history':
+                case 'recentBooks':
                 case 'refresh':
-                    if (!this.mostRecentBook())
+                    if (!this.mostRecentBookReactive)
                         classResult = classDisabled;
                     break;
             }
@@ -759,7 +878,8 @@ class Reader extends Vue {
             //акивируем страницу с текстом
             this.$nextTick(async() => {
                 const last = this.mostRecentBookReactive;
-                const isParsed = bookManager.hasBookParsed(last);
+                const isParsed = await bookManager.hasBookParsed(last);
+
                 if (!isParsed) {
                     this.$root.$emit('set-app-title');
                     return;
@@ -793,7 +913,7 @@ class Reader extends Vue {
 
         // уже просматривается сейчас
         const lastBook = (this.$refs.page ? this.$refs.page.lastBook : null);
-        if (!opts.force && lastBook && lastBook.url == url && bookManager.hasBookParsed(lastBook)) {
+        if (!opts.force && lastBook && lastBook.url == url && await bookManager.hasBookParsed(lastBook)) {
             this.loaderActive = false;
             return;
         }
@@ -954,8 +1074,8 @@ class Reader extends Vue {
             if (!handled && this.settingsActive)
                 handled = this.$refs.settingsPage.keyHook(event);
 
-            if (!handled && this.historyActive)
-                handled = this.$refs.historyPage.keyHook(event);
+            if (!handled && this.recentBooksActive)
+                handled = this.$refs.recentBooksPage.keyHook(event);
 
             if (!handled && this.setPositionActive)
                 handled = this.$refs.setPositionPage.keyHook(event);
@@ -1005,7 +1125,7 @@ class Reader extends Vue {
                             this.refreshBook();
                             break;
                         case 'KeyX':
-                            this.historyToggle();
+                            this.recentBooksToggle();
                             event.preventDefault();
                             event.stopPropagation();
                             break;
@@ -1040,7 +1160,6 @@ class Reader extends Vue {
 .header {
     display: flex;
     justify-content: space-between;
-    min-width: 550px;
 }
 
 .el-main {

+ 320 - 0
client/components/Reader/RecentBooksPage/RecentBooksPage.vue

@@ -0,0 +1,320 @@
+<template>
+    <Window width="600px" ref="window" @close="close">
+        <template slot="header">
+            <span v-show="!loading">Последние {{tableData ? tableData.length : 0}} открытых книг</span>
+            <span v-show="loading"><i class="el-icon-loading" style="font-size: 25px"></i> <span style="position: relative; top: -4px">Список загружается</span></span>
+        </template>
+
+        <el-table
+            :data="tableData"
+            style="width: 570px"
+            size="mini"
+            height="1px"
+            stripe
+            border
+            :default-sort = "{prop: 'touchDateTime', order: 'descending'}"
+            :header-cell-style = "headerCellStyle"
+            :row-key = "rowKey"
+            >
+
+            <el-table-column
+                type="index"
+                width="35px"
+                >
+            </el-table-column>
+            <el-table-column
+                prop="touchDateTime"
+                min-width="85px"
+                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"
+                                ref="input"
+                                placeholder="Найти"
+                                style="margin: 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="280px"
+                    >
+                    <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="90px"
+                    >
+                    <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>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import Vue from 'vue';
+import Component from 'vue-class-component';
+import path from 'path';
+import _ from 'lodash';
+
+import * as utils 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 RecentBooksPage extends Vue {
+    loading = false;
+    search = null;
+    tableData = [];
+
+    created() {
+    }
+
+    init() {
+        this.$refs.window.init();
+
+        this.$nextTick(() => {
+            //this.$refs.input.focus();
+        });
+        (async() => {//отбражение подгрузки списка, иначе тормозит
+            if (this.initing)
+                return;
+            this.initing = true;
+
+            await this.updateTableData(3);
+            await utils.sleep(200);
+
+            if (bookManager.loaded) {
+                const t = Date.now();
+                await this.updateTableData(10);
+                if (bookManager.getSortedRecent().length > 10)
+                    await utils.sleep(10*(Date.now() - t));
+            } else {
+                let i = 0;
+                let j = 5;
+                while (i < 500 && !bookManager.loaded) {
+                    if (i % j == 0) {
+                        bookManager.sortedRecentCached = null;
+                        await this.updateTableData(100);
+                        j *= 2;
+                    }
+
+                    await utils.sleep(100);
+                    i++;
+                }
+            }
+            await this.updateTableData();
+            this.initing = false;
+        })();
+    }
+
+    rowKey(row) {
+        return row.key;
+    }
+
+    async updateTableData(limit) {
+        while (this.updating) await utils.sleep(100);
+        this.updating = true;
+        let result = [];
+
+        this.loading = !!limit;
+        const sorted = bookManager.getSortedRecent();
+
+        for (let i = 0; i < sorted.length; i++) {
+            const book = sorted[i];
+            if (book.deleted)
+                continue;
+
+            if (limit && result.length >= limit)
+                break;
+
+            let d = new Date();
+            d.setTime(book.touchTime);
+            const t = utils.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 : {});
+
+            let title = fb2.bookTitle;
+            if (title)
+                title = `"${title}"`;
+            else
+                title = '';
+
+            let author = '';
+            if (fb2.author) {
+                const authorNames = fb2.author.map(a => _.compact([
+                    a.lastName,
+                    a.firstName,
+                    a.middleName
+                ]).join(' '));
+                author = authorNames.join(', ');
+            } else {
+                author = _.compact([
+                    fb2.lastName,
+                    fb2.firstName,
+                    fb2.middleName
+                ]).join(' ');
+            }
+            author = (author ? author : (fb2.bookTitle ? fb2.bookTitle : book.url));
+
+            result.push({
+                touchDateTime: book.touchTime,
+                touchDate: t[0],
+                touchTime: t[1],
+                desc: {
+                    title: `${title}${perc}${textLen}`,
+                    author,
+                },
+                url: book.url,
+                path: book.path,
+                key: book.key,
+            });
+            if (result.length >= 100)
+                break;
+        }
+
+        const search = this.search;
+        result = 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())
+        });
+
+        /*for (let i = 0; i < result.length; i++) {
+            if (!_.isEqual(this.tableData[i], result[i])) {
+                this.$set(this.tableData, i, result[i]);
+                await utils.sleep(10);
+            }
+        }
+        if (this.tableData.length > result.length)
+            this.tableData.splice(result.length);*/
+        this.tableData = result;
+        this.updating = false;
+    }
+
+    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();
+
+        if (!bookManager.mostRecentBook())
+            this.close();
+    }
+
+    loadBook(url) {
+        this.$emit('load-book', {url});
+        this.close();
+    }
+
+    isUrl(url) {
+        if (url)
+            return (url.indexOf('file://') != 0);
+        else
+            return false;
+    }
+
+    close() {
+        this.$emit('recent-books-toggle');
+    }
+
+    keyHook(event) {
+        if (event.type == 'keydown' && event.code == 'Escape') {
+            this.close();
+        }
+        return true;
+    }
+}
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.desc {
+    cursor: pointer;
+}
+</style>

+ 22 - 43
client/components/Reader/SearchPage/SearchPage.vue

@@ -1,28 +1,24 @@
 <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>
+    <Window ref="window" height="125px" max-width="600px" :top-shift="-50" @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>
-    </div>
+    </Window>
 </template>
 
 <script>
@@ -61,6 +57,8 @@ class SearchPage extends Vue {
     }
 
     async init(parsed) {
+        this.$refs.window.init();
+
         if (this.parsed != parsed) {
             this.initStep = true;
             this.stopInit = false;
@@ -178,32 +176,13 @@ class SearchPage extends Vue {
 </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;
+    min-width: 430px;
 }
 
 .input {

+ 135 - 234
client/components/Reader/ServerStorage/ServerStorage.vue

@@ -13,8 +13,6 @@ import readerApi from '../../../api/reader';
 import * as utils from '../../../share/utils';
 import * as cryptoUtils from '../../../share/cryptoUtils';
 
-const maxSetTries = 5;
-
 export default @Component({
     watch: {
         serverSyncEnabled: function() {
@@ -46,11 +44,16 @@ class ServerStorage extends Vue {
             this.saveSettings();
         }, 500);
 
+        this.debouncedSaveRecent = _.debounce((itemKey) => {
+            this.saveRecent(itemKey);
+        }, 1000);
+
+        this.debouncedNotifySuccess = _.debounce(() => {
+            this.success('Данные синхронизированы с сервером');
+        }, 1000);
+
         this.oldProfiles = {};
         this.oldSettings = {};
-        this.oldRecent = {};
-        this.oldRecentLast = {};
-        this.oldRecentLastDiff = {};
     }
 
     async init() {
@@ -61,13 +64,18 @@ class ServerStorage extends Vue {
             } else {
                 await this.serverStorageKeyChanged();
             }
-            this.oldRecent = _.cloneDeep(bookManager.recent);
-            this.oldRecentLast = _.cloneDeep(bookManager.recentLast) || {};
+            bookManager.addEventListener(this.bookManagerEvent);
         } finally {
             this.inited = true;
         }
     }
 
+    async bookManagerEvent(eventName, itemKey) {
+        if (eventName == 'recent-changed') {
+            this.debouncedSaveRecent(itemKey);
+        }
+    }
+
     async generateNewServerStorageKey() {
         const key = utils.toBase58(utils.randomArray(32));
         this.commit('reader/setServerStorageKey', key);
@@ -146,10 +154,6 @@ class ServerStorage extends Vue {
         }
     }
 
-    notifySuccess() {
-        this.success('Данные синхронизированы с сервером');
-    }
-
     success(message) {
         if (this.showServerStorageMessages)
             this.$notify.success({message});
@@ -165,7 +169,7 @@ class ServerStorage extends Vue {
             this.$notify.error({message});
     }
 
-    async loadSettings(force) {
+    async loadSettings(force = false, doNotifySuccess = true) {
         if (!this.keyInited || !this.serverSyncEnabled || !this.currentProfile)
             return;
 
@@ -202,7 +206,8 @@ class ServerStorage extends Vue {
             this.commit('reader/setSettings', sets.data);
             this.commit('reader/setSettingsRev', {[setsId]: sets.rev});
 
-            this.notifySuccess();
+            if (doNotifySuccess)
+                this.debouncedNotifySuccess();
         } else {
             this.warning(`Неверный ответ сервера: ${sets.state}`);
         }
@@ -220,32 +225,18 @@ class ServerStorage extends Vue {
         try {
             const setsId = `settings-${this.currentProfile}`;
             let result = {state: ''};
-            let tries = 0;
-            while (result.state != 'success' && tries < maxSetTries) {
-                const oldRev = this.settingsRev[setsId] || 0;
-                try {
-                    result = await this.storageSet({[setsId]: {rev: oldRev + 1, data: this.settings}});
-                } catch(e) {
-                    this.savingSettings = false;
-                    this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
-                    return;
-                }
 
-                if (result.state == 'reject') {
-                    await this.loadSettings(true);
-                    const newSettings = utils.applyObjDiff(this.settings, diff);
-                    this.commit('reader/setSettings', newSettings);
-                }
-
-                tries++;
+            const oldRev = this.settingsRev[setsId] || 0;
+            try {
+                result = await this.storageSet({[setsId]: {rev: oldRev + 1, data: this.settings}});
+            } catch(e) {
+                this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
             }
 
-            if (tries >= maxSetTries) {
-                //отменять изменения не будем, просто предупредим
-                //this.commit('reader/setSettings', this.oldSettings);
-                console.error(result);
-                this.error('Не удалось отправить настройки на сервер. Данные не сохранены и могут быть перезаписаны.');
-            } else {
+            if (result.state == 'reject') {
+                await this.loadSettings(true, false);
+                this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
+            } else if (result.state == 'success') {
                 this.oldSettings = _.cloneDeep(this.settings);
                 this.commit('reader/setSettingsRev', {[setsId]: this.settingsRev[setsId] + 1});
             }
@@ -254,7 +245,7 @@ class ServerStorage extends Vue {
         }
     }
 
-    async loadProfiles(force) {
+    async loadProfiles(force = false, doNotifySuccess = true) {
         if (!this.keyInited || !this.serverSyncEnabled)
             return;
 
@@ -289,8 +280,10 @@ class ServerStorage extends Vue {
             this.oldProfiles = _.cloneDeep(prof.data);
             this.commit('reader/setProfiles', prof.data);
             this.commit('reader/setProfilesRev', prof.rev);
+            this.checkCurrentProfile();
 
-            this.notifySuccess();
+            if (doNotifySuccess)
+                this.debouncedNotifySuccess();
         } else {
             this.warning(`Неверный ответ сервера: ${prof.state}`);
         }
@@ -304,7 +297,7 @@ class ServerStorage extends Vue {
         if (utils.isEmptyObjDiff(diff))
             return;
 
-        //обнуляются профили во время разработки, подстраховка
+        //обнуляются профили во время разработки при hotReload, подстраховка
         if (!this.$store.state.reader.allowProfilesSave) {
             console.error('Сохранение профилей не санкционировано');
             return;
@@ -313,33 +306,16 @@ class ServerStorage extends Vue {
         this.savingProfiles = true;
         try {
             let result = {state: ''};
-            let tries = 0;
-            while (result.state != 'success' && tries < maxSetTries) {
-                try {
-                    result = await this.storageSet({profiles: {rev: this.profilesRev + 1, data: this.profiles}});
-                } catch(e) {
-                    this.savingProfiles = false;
-                    this.commit('reader/setProfiles', this.oldProfiles);
-                    this.checkCurrentProfile();
-                    this.error(`Ошибка соединения с сервером: (${e.message}). Изменения отменены.`);
-                    return;
-                }
-
-                if (result.state == 'reject') {
-                    await this.loadProfiles(true);
-                    const newProfiles = utils.applyObjDiff(this.profiles, diff);
-                    this.commit('reader/setProfiles', newProfiles);
-                }
-
-                tries++;
+            try {
+                result = await this.storageSet({profiles: {rev: this.profilesRev + 1, data: this.profiles}});
+            } catch(e) {
+                this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
             }
 
-            if (tries >= maxSetTries) {
-                this.commit('reader/setProfiles', this.oldProfiles);
-                this.checkCurrentProfile();
-                console.error(result);
-                this.error('Не удалось отправить данные на сервер. Изменения отменены.');
-            } else {
+            if (result.state == 'reject') {
+                await this.loadProfiles(true, false);
+                this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
+            } else if (result.state == 'success') {
                 this.oldProfiles = _.cloneDeep(this.profiles);
                 this.commit('reader/setProfilesRev', this.profilesRev + 1);        
             }
@@ -348,21 +324,19 @@ class ServerStorage extends Vue {
         }
     }
 
-    async loadRecent(force) {
+    async loadRecent(force = false, doNotifySuccess = true) {
         if (!this.keyInited || !this.serverSyncEnabled)
             return;
 
-        const oldRev = bookManager.recentRev;
-        const oldLastRev = bookManager.recentLastRev;
-        const oldLastDiffRev = bookManager.recentLastDiffRev;
+        const oldRecentRev = bookManager.recentRev;
+        const oldRecentDiffRev = bookManager.recentDiffRev;
         //проверим ревизию на сервере
         let revs = null;
         if (!force) {
             try {
-                revs = await this.storageCheck({recent: {}, recentLast: {}, recentLastDiff: {}});
-                if (revs.state == 'success' && revs.items.recent.rev == oldRev &&
-                    revs.items.recentLast.rev == oldLastRev &&
-                    revs.items.recentLastDiff.rev == oldLastDiffRev) {
+                revs = await this.storageCheck({recent: {}, recentDiff: {}});
+                if (revs.state == 'success' && revs.items.recent.rev == oldRecentRev &&
+                    revs.items.recentDiff.rev == oldRecentDiffRev) {
                     return;
                 }
             } catch(e) {
@@ -371,226 +345,153 @@ class ServerStorage extends Vue {
             }
         }
 
-        if (force || revs.items.recent.rev != oldRev) {
-            let recent = null;
+        let recent = null;
+        if (force || revs.items.recent.rev != oldRecentRev || revs.items.recentDiff.rev != oldRecentDiffRev) {
             try {
-                recent = await this.storageGet({recent: {}});
+                recent = await this.storageGet({recent: {}, recentDiff: {}});
             } catch(e) {
                 this.error(`Ошибка соединения с сервером: ${e.message}`);
                 return;
             }
 
             if (recent.state == 'success') {
+                let recentDiff = recent.items.recentDiff;
                 recent = recent.items.recent;
 
                 if (recent.rev == 0)
                     recent.data = {};
 
                 this.oldRecent = _.cloneDeep(recent.data);
-                await bookManager.setRecent(recent.data);
-                await bookManager.setRecentRev(recent.rev);
-            } else {
-                this.warning(`Неверный ответ сервера: ${recent.state}`);
-            }
-        }
-
-        if (force || revs.items.recentLast.rev != oldLastRev || revs.items.recentLastDiff.rev != oldLastDiffRev) {
-            let recentLast = null;
-            try {
-                recentLast = await this.storageGet({recentLast: {}, recentLastDiff: {}});
-            } catch(e) {
-                this.error(`Ошибка соединения с сервером: ${e.message}`);
-                return;
-            }
-
-            if (recentLast.state == 'success') {
-                const recentLastDiff = recentLast.items.recentLastDiff;
-                recentLast = recentLast.items.recentLast;
-
-                if (recentLast.rev == 0)
-                    recentLast.data = {};
-                if (recentLastDiff.rev == 0)
-                    recentLastDiff.data = {};
-
-                this.oldRecentLastDiff = _.cloneDeep(recentLastDiff.data);
-                this.oldRecentLast = _.cloneDeep(recentLast.data);
+                let newRecent = {};
+                if (recentDiff && recentDiff.data) {
+                    newRecent = utils.applyObjDiff(recent.data, recentDiff.data);
+                    this.recentDiff = _.cloneDeep(recentDiff.data);
+                    if (!utils.isObjDiff(this.recentDiff))
+                        this.recentDiff = null;
+                } else {
+                    newRecent = recent.data;
+                    this.recentDiff = null;
+                }
 
-                recentLast.data = utils.applyObjDiff(recentLast.data, recentLastDiff.data);
 
-                await bookManager.setRecentLast(recentLast.data);
-                await bookManager.setRecentLastRev(recentLast.rev);
-                await bookManager.setRecentLastDiffRev(recentLastDiff.rev);
+                if (!bookManager.loaded) {
+                    this.warning('Ожидание загрузки списка книг перед синхронизацией');
+                    while (!bookManager.loaded) await utils.sleep(100);
+                }
+                await bookManager.setRecent(newRecent);
+                await bookManager.setRecentRev(recent.rev);
+                await bookManager.setRecentDiffRev(recentDiff.rev);
             } else {
-                this.warning(`Неверный ответ сервера: ${recentLast.state}`);
+                this.warning(`Неверный ответ сервера: ${recent.state}`);
             }
         }
 
-        this.notifySuccess();
+        if (doNotifySuccess)
+            this.debouncedNotifySuccess();
     }
 
-    async saveRecent() {
+    async saveRecent(itemKey) {
         if (!this.keyInited || !this.serverSyncEnabled || this.savingRecent)
             return;
 
         const bm = bookManager;
 
-        const diff = utils.getObjDiff(this.oldRecent, bm.recent);
-        if (utils.isEmptyObjDiff(diff))
+        /*if (!bookManager.loaded) {
+            this.warning('Функции сохранения на сервер пока недоступны');
             return;
+        }*/
 
-        this.savingRecent = true;
-        try {
-            let result = {state: ''};
-            let tries = 0;
-            while (result.state != 'success' && tries < maxSetTries) {
-                try {
-                    result = await this.storageSet({recent: {rev: bm.recentRev + 1, data: bm.recent}});
-                } catch(e) {
-                    this.savingRecent = false;
-                    this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
-                    return;
-                }
+        //несколько замудреная инициализация oldRecent
+        if (!this.oldRecent) {
+            this.oldRecent = _.cloneDeep(bookManager.recent);
+        }
 
-                if (result.state == 'reject') {
-                    await this.loadRecent(true);
-                    //похоже это лишнее
-                    /*const newRecent = utils.applyObjDiff(bm.recent, diff);
-                    await bm.setRecent(newRecent);*/
-                }
+        if (bookManager.loaded && !this.oldRecentInited) {
+            this.oldRecent = _.cloneDeep(bookManager.recent);
+            this.oldRecentInited = true;
+        }
 
-                tries++;
+        //вычисляем дифф
+        let diff = null;
+        if (itemKey) {//ускоренное вычисления диффа
+            let itemDiff;
+            if (this.oldRecent[itemKey]) {
+                itemDiff = utils.getObjDiff({[itemKey]: (this.oldRecentInited ? this.oldRecent[itemKey] : {})}, {[itemKey]: bm.recent[itemKey]});
+            } else {
+                itemDiff = utils.getObjDiff({}, {[itemKey]: bm.recent[itemKey]});
             }
-
-            if (tries >= maxSetTries) {
-                console.error(result);
-                this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
+            if (this.recentDiff) {
+                diff = this.recentDiff;
+                if (itemDiff.change[itemKey])
+                    diff.change[itemKey] = itemDiff.change[itemKey];
+                if (itemDiff.add[itemKey])
+                    diff.add[itemKey] = itemDiff.add[itemKey];
             } else {
-                this.oldRecent = _.cloneDeep(bm.recent);
-                await bm.setRecentRev(bm.recentRev + 1);
-                await this.saveRecentLast(true);
+                diff = itemDiff;
             }
-        } finally {
-            this.savingRecent = false;
+        } else {//медленное вычисление диффа
+            if (this.oldRecentInited) {
+                diff = utils.getObjDiff(this.oldRecent, bm.recent);
+            } else
+                return;
         }
-    }
 
-    async saveRecentLast(force = false) {
-        if (!this.keyInited || !this.serverSyncEnabled || this.savingRecentLast)
+        if (utils.isEmptyObjDiff(diff))
             return;
 
-        const bm = bookManager;
-        let recentLast = bm.recentLast;
-        recentLast = (recentLast ? recentLast : {});
-        let lastRev = bm.recentLastRev;
+        //вычисление критерия сохранения целиком
+        let forceSaveRecent = JSON.stringify(diff).length > 2000;
+        if (!forceSaveRecent && itemKey) {
+            if (!this.sameKeyCount)
+                this.sameKeyCount = 0;
+            if (this.prevItemKey == itemKey)
+                this.sameKeyCount++;
 
-        const diff = utils.getObjDiff(this.oldRecentLast, recentLast);
-        if (utils.isEmptyObjDiff(diff))
-            return;
+            forceSaveRecent = this.sameKeyCount > 5 && (Object.keys(diff.change).length > 1);
 
-        if (this.oldRecentLast.key == recentLast.key && JSON.stringify(recentLast) > JSON.stringify(diff)) {
-            await this.saveRecentLastDiff(diff, force);
-            return;
+            this.sameKeyCount = (!forceSaveRecent ? this.sameKeyCount : 0);
+            this.prevItemKey = itemKey;
         }
 
-        this.savingRecentLast = true;
+        this.recentDiff = diff;
+        this.savingRecent = true;        
         try {
-            let result = {state: ''};
-            let tries = 0;
-            while (result.state != 'success' && tries < maxSetTries) {
-                if (force) {
-                    try {
-                        const revs = await this.storageCheck({recentLast: {}});
-                        if (revs.items.recentLast.rev)
-                            lastRev = revs.items.recentLast.rev;
-                    } catch(e) {
-                        this.error(`Ошибка соединения с сервером: ${e.message}`);
-                        return;
-                    }
-                }
+            if (forceSaveRecent) {//сохраняем recent целиком
+                let result = {state: ''};
 
                 try {
-                    result = await this.storageSet({recentLast: {rev: lastRev + 1, data: recentLast}}, force);
+                    result = await this.storageSet({recent: {rev: bm.recentRev + 1, data: bm.recent}, recentDiff: {rev: bm.recentDiffRev + 1, data: {}}});
                 } catch(e) {
-                    this.savingRecentLast = false;
-                    this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
-                    return;
+                    this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
                 }
 
                 if (result.state == 'reject') {
-                    await this.loadRecent(false);
-                    this.savingRecentLast = false;//!!!
-                    return;//!!!
-                }
-
-                tries++;
-            }
-
-            if (tries >= maxSetTries) {
-                console.error(result);
-                this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
-            } else {
-                this.oldRecentLast = _.cloneDeep(recentLast);
-                await bm.setRecentLastRev(lastRev + 1);
-                await this.saveRecentLastDiff({}, true);
-            }
-        } finally {
-            this.savingRecentLast = false;
-        }
-    }
-
-    async saveRecentLastDiff(diff, force = false) {
-        if (!this.keyInited || !this.serverSyncEnabled || this.savingRecentLastDiff)
-            return;
-
-        const bm = bookManager;
-        let lastRev = bm.recentLastDiffRev;
-
-        const d = utils.getObjDiff(this.oldRecentLastDiff, diff);
-        if (utils.isEmptyObjDiff(d))
-            return;
-
-        this.savingRecentLastDiff = true;
-        try {
-            let result = {state: ''};
-            let tries = 0;
-            while (result.state != 'success' && tries < maxSetTries) {
-                if (force) {
-                    try {
-                        const revs = await this.storageCheck({recentLastDiff: {}});
-                        if (revs.items.recentLastDiff.rev)
-                            lastRev = revs.items.recentLastDiff.rev;
-                    } catch(e) {
-                        this.error(`Ошибка соединения с сервером: ${e.message}`);
-                        return;
-                    }
+                    await this.loadRecent(true, false);
+                    this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
+                } else if (result.state == 'success') {
+                    this.oldRecent = _.cloneDeep(bm.recent);
+                    this.recentDiff = null;
+                    await bm.setRecentRev(bm.recentRev + 1);
+                    await bm.setRecentDiffRev(bm.recentDiffRev + 1);
                 }
+            } else {//сохраняем только дифф
+                let result = {state: ''};
 
                 try {
-                    result = await this.storageSet({recentLastDiff: {rev: lastRev + 1, data: diff}}, force);
+                    result = await this.storageSet({recentDiff: {rev: bm.recentDiffRev + 1, data: this.recentDiff}});
                 } catch(e) {
-                    this.savingRecentLastDiff = false;
-                    this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
-                    return;
+                    this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
                 }
 
                 if (result.state == 'reject') {
-                    await this.loadRecent(false);
-                    this.savingRecentLastDiff = false;
-                    return;
+                    await this.loadRecent(true, false);
+                    this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
+                } else if (result.state == 'success') {
+                    await bm.setRecentDiffRev(bm.recentDiffRev + 1);
                 }
-
-                tries++;
-            }
-
-            if (tries >= maxSetTries) {
-                console.error(result);
-                this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
-            } else {
-                this.oldRecentLastDiff = _.cloneDeep(diff);
-                await bm.setRecentLastDiffRev(lastRev + 1);
             }
         } finally {
-            this.savingRecentLastDiff = false;
+            this.savingRecent = false;
         }
     }
 

+ 9 - 31
client/components/Reader/SetPositionPage/SetPositionPage.vue

@@ -1,17 +1,13 @@
 <template>
-    <div ref="main" class="main" @click="close">
-        <div class="mainWindow" @click.stop>
-            <Window @close="close">
-                <template slot="header">
-                    Установить позицию
-                </template>
+    <Window ref="window" height="140px" max-width="600px" :top-shift="-50" @close="close">
+        <template slot="header">
+            Установить позицию
+        </template>
 
-                <div class="slider">
-                    <el-slider v-model="sliderValue" :max="sliderMax" :format-tooltip="formatTooltip"></el-slider>
-                </div>
-            </Window>
+        <div class="slider">
+            <el-slider v-model="sliderValue" :max="sliderMax" :format-tooltip="formatTooltip"></el-slider>
         </div>
-    </div>
+    </Window>
 </template>
 
 <script>
@@ -43,6 +39,8 @@ class SetPositionPage extends Vue {
     }
 
     init(sliderValue, sliderMax) {
+        this.$refs.window.init();
+        
         this.sliderMax = sliderMax;
         this.sliderValue = sliderValue;
         this.initialized = true;
@@ -70,26 +68,6 @@ class SetPositionPage extends Vue {
 </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;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 510 - 493
client/components/Reader/SettingsPage/SettingsPage.vue


+ 4 - 6
client/components/Reader/TextPage/DrawHelper.js

@@ -350,20 +350,18 @@ export default class DrawHelper {
         page2.style.background = backgroundColor;
 
         if (isDown) {
-            page2.style.transformOrigin = '10%';
+            page2.style.transformOrigin = '5%';
             await sleep(30);
 
-            page2.style.transformOrigin = '0%';
             page2.style.transition = `${duration}ms ease-in-out`;
-            page2.style.transform = `rotateY(-120deg)`;
+            page2.style.transform = `rotateY(-120deg) translateX(${this.w/4}px)`;
             await animation2Finish(duration);
         } else {
-            page2.style.transformOrigin = '90%';
+            page2.style.transformOrigin = '95%';
             await sleep(30);
 
-            page2.style.transformOrigin = '100%';
             page2.style.transition = `${duration}ms ease-in-out`;
-            page2.style.transform = `rotateY(120deg)`;
+            page2.style.transform = `rotateY(120deg) translateX(-${this.w/4}px)`;
             await animation2Finish(duration);
         }
 

+ 7 - 7
client/components/Reader/TextPage/TextPage.vue

@@ -131,7 +131,6 @@ class TextPage extends Vue {
         }, 10);
 
         this.$root.$on('resize', () => {this.$nextTick(this.onResize)});
-        this.mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
     }
 
     mounted() {
@@ -221,6 +220,7 @@ class TextPage extends Vue {
             this.parsed.showInlineImagesInCenter = this.showInlineImagesInCenter;
             this.parsed.imageHeightLines = this.imageHeightLines;
             this.parsed.imageFitWidth = this.imageFitWidth;
+            this.parsed.compactTextPerc = this.compactTextPerc;
         }
 
         //scrolling page
@@ -1006,7 +1006,7 @@ class TextPage extends Vue {
     }
 
     onTouchStart(event) {
-        if (!this.mobile)
+        if (!this.$isMobileDevice)
             return;
         this.endClickRepeat();
         if (event.touches.length == 1) {
@@ -1022,19 +1022,19 @@ class TextPage extends Vue {
     }
 
     onTouchEnd() {
-        if (!this.mobile)
+        if (!this.$isMobileDevice)
             return;
         this.endClickRepeat();
     }
 
     onTouchCancel() {
-        if (!this.mobile)
+        if (!this.$isMobileDevice)
             return;
         this.endClickRepeat();
     }
 
     onMouseDown(event) {
-        if (this.mobile)
+        if (this.$isMobileDevice)
             return;
         this.endClickRepeat();
         if (event.button == 0) {
@@ -1050,13 +1050,13 @@ class TextPage extends Vue {
     }
 
     onMouseUp() {
-        if (this.mobile)
+        if (this.$isMobileDevice)
             return;
         this.endClickRepeat();
     }
 
     onMouseWheel(event) {
-        if (this.mobile)
+        if (this.$isMobileDevice)
             return;
         if (event.deltaY > 0) {
             this.doDown();

+ 7 - 4
client/components/Reader/share/BookParser.js

@@ -179,7 +179,7 @@ export default class BookParser {
             if (tag == 'binary') {
                 let attrs = sax.getAttrsSync(tail);
                 binaryType = (attrs['content-type'] && attrs['content-type'].value ? attrs['content-type'].value : '');
-                if (binaryType == 'image/jpeg' || binaryType == 'image/png')
+                if (binaryType == 'image/jpeg' || binaryType == 'image/png' || binaryType == 'application/octet-stream')
                     binaryId = (attrs.id.value ? attrs.id.value : '');
             }
 
@@ -620,7 +620,8 @@ export default class BookParser {
             para.parsed.addEmptyParagraphs === this.addEmptyParagraphs &&
             para.parsed.showImages === this.showImages &&
             para.parsed.imageHeightLines === this.imageHeightLines &&
-            para.parsed.imageFitWidth === this.imageFitWidth
+            para.parsed.imageFitWidth === this.imageFitWidth &&
+            para.parsed.compactTextPerc === this.compactTextPerc
             )
             return para.parsed;
 
@@ -635,6 +636,7 @@ export default class BookParser {
             showImages: this.showImages,
             imageHeightLines: this.imageHeightLines,
             imageFitWidth: this.imageFitWidth,
+            compactTextPerc: this.compactTextPerc,
             visible: !(
                 (this.cutEmptyParagraphs && para.cut) ||
                 (para.addIndex > this.addEmptyParagraphs)
@@ -665,6 +667,7 @@ export default class BookParser {
         let style = {};
         let ofs = 0;//смещение от начала параграфа para.offset
         let imgW = 0;
+        const compactWidth = this.measureText('W', {})*this.compactTextPerc/100;
         // тут начинается самый замес, перенос по слогам и стилизация, а также изображения
         for (const part of parts) {
             style = part.style;
@@ -749,7 +752,7 @@ export default class BookParser {
                 p = (style.space ? p + parsed.p*style.space : p);
                 let w = this.measureText(str, style) + p;
                 let wordTail = word;
-                if (w > parsed.w && prevStr != '') {
+                if (w > parsed.w + compactWidth && prevStr != '') {
                     if (parsed.wordWrap) {//по слогам
                         let slogi = this.splitToSlogi(word);
 
@@ -762,7 +765,7 @@ export default class BookParser {
                             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) {
+                                if (ww <= parsed.w + compactWidth) {
                                     s += slog;
                                     ss += slog;
                                 } else 

+ 98 - 155
client/components/Reader/share/bookManager.js

@@ -18,49 +18,41 @@ const bmRecentStore = localForage.createInstance({
     name: 'bmRecentStore'
 });
 
-const bmCacheStore = localForage.createInstance({
-    name: 'bmCacheStore'
-});
-
 class BookManager {
     async init(settings) {
+        this.loaded = false;
         this.settings = settings;
 
         this.eventListeners = [];
+        this.books = {};
+        this.recent = {};
 
-        //bmCacheStore нужен только для ускорения загрузки читалки
-        this.booksCached = await bmCacheStore.getItem('books');
-        if (!this.booksCached)
-            this.booksCached = {};
-        this.recent = await bmCacheStore.getItem('recent');
-        this.recentLast = await bmCacheStore.getItem('recent-last');
-        if (this.recentLast)
+        this.recentLast = await bmRecentStore.getItem('recent-last');
+        if (this.recentLast) {
             this.recent[this.recentLast.key] = this.recentLast;
-        this.recentRev = await bmRecentStore.getItem('recent-rev') || 0;
-        this.recentLastRev = await bmRecentStore.getItem('recent-last-rev') || 0;
-        this.recentLastDiffRev = await bmRecentStore.getItem('recent-last-diff-rev') || 0;
-        this.books = Object.assign({}, this.booksCached);
-
-        this.recentChanged2 = true;
-
-        if (!this.books || !this.recent) {
-            this.books = {};
-            this.recent = {};
-            await this.loadMeta(true);
-        } else {
-            this.loadMeta(false);
+            const meta = await bmMetaStore.getItem(`bmMeta-${this.recentLast.key}`);
+            if (_.isObject(meta)) {
+                this.books[meta.key] = meta;
+            }
         }
+
+        this.recentRev = await bmRecentStore.getItem('recent-rev') || 0;
+        this.recentDiffRev = await bmRecentStore.getItem('recent-diff-rev') || 0;
+
+        this.recentChanged = true;
+
+        this.loadStored();//no await
     }
 
-    //долгая загрузка из хранилища,
-    //хранение в отдельных записях дает относительно
-    //нормальное поведение при нескольких вкладках с читалкой в браузере
-    async loadMeta(immediate) {
-        if (!immediate)
-            await utils.sleep(2000);
+    //Долгая асинхронная загрузка из хранилища.
+    //Хранение в отдельных записях дает относительно
+    //нормальное поведение при нескольких вкладках с читалкой в браузере.
+    async loadStored() {
+        //даем время для загрузки последней читаемой книги, чтобы не блокировать приложение
+        await utils.sleep(2000);
 
         let len = await bmMetaStore.length();
-        for (let i = 0; i < len; i++) {
+        for (let i = len - 1; i >= 0; i--) {
             const key = await bmMetaStore.key(i);
             const keySplit = key.split('-');
 
@@ -68,6 +60,7 @@ class BookManager {
                 let meta = await bmMetaStore.getItem(key);
 
                 if (_.isObject(meta)) {
+                    //уже может быть распарсена книга
                     const oldBook = this.books[meta.key];
                     this.books[meta.key] = meta;
 
@@ -80,22 +73,19 @@ class BookManager {
             }
         }
 
-        //"ленивая" загрузка
-        (async() => {
-            let key = null;
-            len = await bmRecentStore.length();
-            for (let i = 0; i < len; i++) {
-                key = await bmRecentStore.key(i);
-                if (key) {
-                    let r = await bmRecentStore.getItem(key);
-                    if (_.isObject(r) && r.key) {
-                        this.recent[r.key] = r;
-                    }
-                } else  {
-                    await bmRecentStore.removeItem(key);
+        let key = null;
+        len = await bmRecentStore.length();
+        for (let i = len - 1; i >= 0; i--) {
+            key = await bmRecentStore.key(i);
+            if (key) {
+                let r = await bmRecentStore.getItem(key);
+                if (_.isObject(r) && r.key) {
+                    this.recent[r.key] = r;
                 }
+            } else  {
+                await bmRecentStore.removeItem(key);
             }
-        })();
+        }
 
         //размножение для дебага
         /*if (key) {
@@ -106,17 +96,11 @@ class BookManager {
         }*/
         
         await this.cleanBooks();
+        await this.cleanRecentBooks();
 
-        //очистка позже
-        //await this.cleanRecentBooks();
-
-        this.booksCached = {};
-        for (const key in this.books) {
-            this.booksCached[key] = this.metaOnly(this.books[key]);
-        }
-        await bmCacheStore.setItem('books', this.booksCached);
-        await bmCacheStore.setItem('recent', this.recent);
-        this.emit('load-meta-finish');
+        this.recentChanged = true;
+        this.loaded = true;
+        this.emit('load-stored-finish');
     }
 
     async cleanBooks() {
@@ -136,7 +120,7 @@ class BookManager {
             }
 
             if (size > maxDataSize && toDel) {
-                await this._delBook(toDel);
+                await this.delBook(toDel);
             } else {
                 break;
             }
@@ -211,9 +195,7 @@ class BookManager {
         return inflator.result;
     }
 
-    async addBook(newBook, callback) {
-        if (!this.books) 
-            await this.init();
+    async addBook(newBook, callback) {        
         let meta = {url: newBook.url, path: newBook.path};
         meta.key = this.keyFromUrl(meta.url);
         meta.addTime = Date.now();
@@ -233,43 +215,53 @@ class BookManager {
 
         let data = newBook.data;
         if (result.dataCompressed) {
-            //data = utils.pako.deflate(data, {level: 9});
+            //data = utils.pako.deflate(data, {level: 5});
             data = await this.deflateWithProgress(data, cb2);
             result.dataCompressedLength = data.byteLength;
         }
         callback(95);
 
         this.books[meta.key] = result;
-        this.booksCached[meta.key] = this.metaOnly(result);
 
         await bmMetaStore.setItem(`bmMeta-${meta.key}`, this.metaOnly(result));
         await bmDataStore.setItem(`bmData-${meta.key}`, data);
-        await bmCacheStore.setItem('books', this.booksCached);
 
         callback(100);
         return result;
     }
 
-    hasBookParsed(meta) {
+    async 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];
+
+        if (!book && !this.loaded) {
+            book = await bmDataStore.getItem(`bmMeta-${meta.key}`);
+            if (book)
+                this.books[meta.key] = book;
+        }
+
         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 = await bmDataStore.getItem(`bmMeta-${meta.key}`);
+            if (result)
+                this.books[meta.key] = result;
+        }
+
         if (result && !result.parsed) {
             let data = await bmDataStore.getItem(`bmData-${meta.key}`);
             callback(5);
@@ -303,27 +295,14 @@ class BookManager {
         return result;
     }
 
-    async _delBook(meta) {
+    async delBook(meta) {
         await bmMetaStore.removeItem(`bmMeta-${meta.key}`);
         await bmDataStore.removeItem(`bmData-${meta.key}`);
 
         delete this.books[meta.key];
-        delete this.booksCached[meta.key];
-    }
-
-    async delBook(meta) {
-        if (!this.books) 
-            await this.init();
-
-        await this._delBook(meta);
-
-        await bmCacheStore.setItem('books', this.booksCached);
     }
 
     async parseBook(meta, data, callback) {
-        if (!this.books) 
-            await this.init();
-
         const parsed = new BookParser(this.settings);
 
         const parsedMeta = await parsed.parse(data, callback);
@@ -347,9 +326,8 @@ class BookManager {
         return utils.stringToHex(url);
     }
 
+    //-- recent --------------------------------------------------------------
     async setRecentBook(value) {
-        if (!this.recent) 
-            await this.init();
         const result = this.metaOnly(value);
         result.touchTime = Date.now();
         result.deleted = 0;
@@ -366,67 +344,57 @@ class BookManager {
 
         await bmRecentStore.setItem(result.key, result);
 
-        //кэшируем, аккуратно
-        let saveRecent = false;
-        if (!(this.recentLast && this.recentLast.key == result.key)) {
-            await bmCacheStore.setItem('recent', this.recent);
-            saveRecent = true;
-        }
         this.recentLast = result;
-        await bmCacheStore.setItem('recent-last', this.recentLast);
-
-        this.mostRecentCached = result;
-        this.recentChanged2 = true;
+        await bmRecentStore.setItem('recent-last', this.recentLast);
 
-        if (saveRecent)
-            this.emit('save-recent');
-        this.emit('recent-changed');
+        this.recentChanged = true;
+        this.emit('recent-changed', result.key);
         return result;
     }
 
     async getRecentBook(value) {
-        if (!this.recent) 
-            await this.init();
-        return this.recent[value.key];
+        let result = this.recent[value.key];
+        if (!result) {
+            result = await bmRecentStore.getItem(value.key);
+            this.recent[value.key] = result;
+        }
+        return result;
     }
 
     async delRecentBook(value) {
-        if (!this.recent) 
-            await this.init();
-
         this.recent[value.key].deleted = 1;
         await bmRecentStore.setItem(value.key, this.recent[value.key]);
-        await bmCacheStore.setItem('recent', this.recent);
-
-        this.mostRecentCached = null;
-        this.recentChanged2 = true;
 
-        this.emit('save-recent');
+        if (this.recentLast.key == value.key) {
+            this.recentLast = null;
+            await bmRecentStore.setItem('recent-last', this.recentLast);
+        }
+        this.emit('recent-changed', value.key);
     }
 
     async cleanRecentBooks() {
-        if (!this.recent) 
-            await this.init();
-
         const sorted = this.getSortedRecent();
 
         let isDel = false;
         for (let i = 1000; i < sorted.length; i++) {
             await bmRecentStore.removeItem(sorted[i].key);
             delete this.recent[sorted[i].key];
+            await bmRecentStore.removeItem(sorted[i].key);
             isDel = true;
         }
 
         this.sortedRecentCached = null;
-        await bmCacheStore.setItem('recent', this.recent);
 
+        if (isDel)
+            this.emit('recent-changed');
         return isDel;
     }
 
     mostRecentBook() {
-        if (this.mostRecentCached) {
-            return this.mostRecentCached;
+        if (this.recentLast) {
+            return this.recentLast;
         }
+        const oldRecentLast = this.recentLast;
 
         let max = 0;
         let result = null;
@@ -437,12 +405,16 @@ class BookManager {
                 result = book;
             }
         }
-        this.mostRecentCached = result;
+        this.recentLast = result;
+        bmRecentStore.setItem('recent-last', this.recentLast);//no await
+
+        if (this.recentLast !== oldRecentLast)
+            this.emit('recent-changed');
         return result;
     }
 
     getSortedRecent() {
-        if (!this.recentChanged2 && this.sortedRecentCached) {
+        if (!this.recentChanged && this.sortedRecentCached) {
             return this.sortedRecentCached;
         }
 
@@ -451,7 +423,7 @@ class BookManager {
         result.sort((a, b) => b.touchTime - a.touchTime);
 
         this.sortedRecentCached = result;
-        this.recentChanged2 = false;
+        this.recentChanged = false;
         return result;
     }
 
@@ -459,7 +431,6 @@ class BookManager {
         const mergedRecent = _.cloneDeep(this.recent);
 
         Object.assign(mergedRecent, value);
-        const newRecent = {};
         
         //"ленивое" обновление хранилища
         (async() => {
@@ -471,19 +442,12 @@ class BookManager {
             }
         })();
 
-        for (const rec of Object.values(mergedRecent)) {
-            if (rec.key) {
-                newRecent[rec.key] = rec;
-            }
-        }
-
-        this.recent = newRecent;
-        await bmCacheStore.setItem('recent', this.recent);
+        this.recent = mergedRecent;
 
         this.recentLast = null;
-        await bmCacheStore.setItem('recent-last', this.recentLast);
+        await bmRecentStore.setItem('recent-last', this.recentLast);
 
-        this.mostRecentCached = null;
+        this.recentChanged = true;
         this.emit('recent-changed');
     }
 
@@ -492,36 +456,11 @@ class BookManager {
         this.recentRev = value;
     }
 
-    async setRecentLast(value) {
-        if (!value.key)
-            value = null;
-
-        this.recentLast = value;
-        await bmCacheStore.setItem('recent-last', this.recentLast);
-        if (value && value.key) {
-            //гарантия переключения книги
-            const mostRecent = this.mostRecentBook();
-            if (mostRecent)
-                this.recent[mostRecent.key].touchTime = value.touchTime - 1;
-
-            this.recent[value.key] = value;
-            await bmRecentStore.setItem(value.key, value);
-            await bmCacheStore.setItem('recent', this.recent);
-        }
-
-        this.mostRecentCached = null;
-        this.emit('recent-changed');
-    }
-
-    async setRecentLastRev(value) {
-        await bmRecentStore.setItem('recent-last-rev', value);
-        this.recentLastRev = value;
+    async setRecentDiffRev(value) {
+        await bmRecentStore.setItem('recent-diff-rev', value);
+        this.recentDiffRev = value;
     }
 
-    async setRecentLastDiffRev(value) {
-        await bmRecentStore.setItem('recent-last-diff-rev', value);
-        this.recentLastDiffRev = value;
-    }
 
     addEventListener(listener) {
         if (this.eventListeners.indexOf(listener) < 0)
@@ -535,8 +474,12 @@ class BookManager {
     }
 
     emit(eventName, value) {
-        for (const listener of this.eventListeners)
-            listener(eventName, value);
+        if (this.eventListeners) {
+            for (const listener of this.eventListeners) {
+                //console.log(eventName);
+                listener(eventName, value);
+            }
+        }
     }
 
 }

+ 16 - 0
client/components/Reader/versionHistory.js

@@ -1,4 +1,20 @@
 export const versionHistory = [
+{
+    showUntil: '2019-10-01',
+    header: '0.7.0 (2019-09-07)',
+    content:
+`
+<ul>
+    <li>налажена работа https-версии сайта, рекомендуется плавный переход</li>
+    <li>добавлена возможность загрузки и работы https-версии читалки в оффлайн-режиме (при отсутствии интернета)</li>
+    <li>упрощение механизма серверной синхронизации с целью повышения надежности и избавления от багов</li>
+    <li>окна теперь можно перемещать за заголовок</li>
+    <li>немного улучшен внешний вид и управление на смартфонах</li>
+    <li>добавлен параметр "Компактность" в раздел "Вид"->"Текст" в настройках</li>
+</ul>
+`
+},
+
 {
     showUntil: '2019-07-20',
     header: '0.6.10 (2019-07-21)',

+ 119 - 11
client/components/share/Window.vue

@@ -1,10 +1,15 @@
 <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 ref="main" class="main" @click="close" @mouseup="onMouseUp" @mousemove="onMouseMove">
+        <div ref="windowBox" class="windowBox" @click.stop>
+            <div class="window">
+                <div ref="header" class="header" @mousedown.prevent.stop="onMouseDown"
+                    @touchstart.stop="onTouchStart" @touchend.stop="onTouchEnd" @touchmove.stop="onTouchMove">
+                    <span class="header-text"><slot name="header"></slot></span>
+                    <span class="close-button" @mousedown.stop @click="close"><i class="el-icon-close"></i></span>
+                </div>
+                <slot></slot>
+            </div>
         </div>
-        <slot></slot>
     </div>
 </template>
 
@@ -14,17 +19,116 @@ import Vue from 'vue';
 import Component from 'vue-class-component';
 
 export default @Component({
+    props: {
+        height: { type: String, default: '100%' },
+        width: { type: String, default: '100%' },
+        maxWidth: { type: String, default: '' },
+        topShift: { type: Number, default: 0 },
+    }
 })
 class Window extends Vue {
-    close() {
-        this.$emit('close');
+    init() {
+        this.$nextTick(() => {
+            this.$refs.windowBox.style.height = this.height;
+            this.$refs.windowBox.style.width = this.width;
+            if (this.maxWidth)
+                this.$refs.windowBox.style.maxWidth = this.maxWidth;
+
+            const left = (this.$refs.main.offsetWidth - this.$refs.windowBox.offsetWidth)/2;
+            const top = (this.$refs.main.offsetHeight - this.$refs.windowBox.offsetHeight)/2 + this.topShift;
+            this.$refs.windowBox.style.left = (left > 0 ? left : 0) + 'px';
+            this.$refs.windowBox.style.top = (top > 0 ? top : 0) + 'px';
+        });
+    }
+
+    onMouseDown(event) {
+        if (this.$isMobileDevice)
+            return;
+        if (event.button == 0) {
+            this.$refs.header.style.cursor = 'move';
+            this.startX = event.screenX;
+            this.startY = event.screenY;
+            this.moving = true;
+        }
+    }
+
+    onMouseUp(event) {
+        if (event.button == 0) {
+            this.$refs.header.style.cursor = 'default';
+            this.moving = false;
+        }
     }
 
+    onMouseMove(event) {
+        if (this.moving) {
+            const deltaX = event.screenX - this.startX;
+            const deltaY = event.screenY - this.startY;
+            this.startX = event.screenX;
+            this.startY = event.screenY;
+
+            this.$refs.windowBox.style.left = (this.$refs.windowBox.offsetLeft + deltaX) + 'px';
+            this.$refs.windowBox.style.top = (this.$refs.windowBox.offsetTop + deltaY) + 'px';
+        }
+    }
+
+    onTouchStart(event) {
+        if (!this.$isMobileDevice)
+            return;
+        if (event.touches.length == 1) {
+            const touch = event.touches[0];
+            this.$refs.header.style.cursor = 'move';
+            this.startX = touch.screenX;
+            this.startY = touch.screenY;
+            this.moving = true;
+        }
+    }
+
+    onTouchMove(event) {
+        if (!this.$isMobileDevice)
+            return;
+        if (event.touches.length == 1 && this.moving) {
+            const touch = event.touches[0];
+            const deltaX = touch.screenX - this.startX;
+            const deltaY = touch.screenY - this.startY;
+            this.startX = touch.screenX;
+            this.startY = touch.screenY;
+
+            this.$refs.windowBox.style.left = (this.$refs.windowBox.offsetLeft + deltaX) + 'px';
+            this.$refs.windowBox.style.top = (this.$refs.windowBox.offsetTop + deltaY) + 'px';
+        }
+    }
+
+    onTouchEnd() {
+        if (!this.$isMobileDevice)
+            return;
+        this.$refs.header.style.cursor = 'default';
+        this.moving = false;
+    }
+
+
+    close() {
+        if (!this.moving)
+            this.$emit('close');
+    }
 }
 //-----------------------------------------------------------------------------
 </script>
 
 <style scoped>
+.main {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    z-index: 50;
+}
+
+.windowBox {
+    position: absolute;
+    display: flex;
+    height: 100%;
+    width: 100%;
+}
+
 .window {
     flex: 1;
     display: flex;
@@ -39,9 +143,9 @@ class Window extends Vue {
 .header {
     display: flex;
     justify-content: flex-end;
-    background-color: #e5e7ea;
+    background-color: #59B04F;
     align-items: center;
-    height: 40px;
+    height: 30px;
 }
 
 .header-text {
@@ -54,8 +158,12 @@ class Window extends Vue {
     display: flex;
     justify-content: center;
     align-items: center;
-    width: 40px;
-    height: 40px;
+    width: 30px;
+    height: 30px;
     cursor: pointer;
 }
+
+.close-button:hover {
+    background-color: #69C05F;
+}
 </style>

+ 1 - 1
client/index.html.template

@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html>
+<html manifest="/app/manifest.appcache">
   <head>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width,initial-scale=1.0">

+ 1 - 0
client/main.js

@@ -6,6 +6,7 @@ import './element';
 
 import App from './components/App.vue';
 //Vue.config.productionTip = false;
+Vue.prototype.$isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
 
 new Vue({
     router,

+ 4 - 0
client/share/utils.js

@@ -129,6 +129,10 @@ export function getObjDiff(oldObj, newObj) {
     return result;
 }
 
+export function isObjDiff(diff) {
+    return (_.isObject(diff) && diff.__isDiff);
+}
+
 export function isEmptyObjDiff(diff) {
     return (!_.isObject(diff) || !diff.__isDiff ||
         (!Object.keys(diff.change).length &&

+ 8 - 2
client/store/modules/reader.js

@@ -8,7 +8,7 @@ const toolButtons = [
     {name: 'search',      show: true, text: 'Найти в тексте'},
     {name: 'copyText',    show: false, text: 'Скопировать текст со страницы'},
     {name: 'refresh',     show: true, text: 'Принудительно обновить книгу'},
-    {name: 'history',     show: true, text: 'Открыть недавние'},
+    {name: 'recentBooks', show: true, text: 'Открыть недавние'},
 ];
 
 const fonts = [
@@ -145,7 +145,7 @@ const settingDefaults = {
     fontName: 'ReaderDefault',
     webFontName: '',
     fontVertShift: 0,
-    textVertShift: -20,
+    textVertShift: 0,
 
     lineInterval: 3,// px, межстрочный интервал
     textAlignJustify: true,// выравнивание по ширине
@@ -176,10 +176,12 @@ const settingDefaults = {
     blinkCachedLoad: true,
     showImages: true,
     showInlineImagesInCenter: true,
+    compactTextPerc: 0,
     imageHeightLines: 100,
     imageFitWidth: true,
     showServerStorageMessages: true,
     showWhatsNewDialog: true,
+    showMigrationDialog: true,
 
     fontShifts: {},
     showToolButton: {},
@@ -201,6 +203,7 @@ const state = {
     profilesRev: 0,
     allowProfilesSave: false,//подстраховка для разработки
     whatsNewContentHash: '',
+    migrationRemindDate: '',
     currentProfile: '',
     settings: Object.assign({}, settingDefaults),
     settingsRev: {},
@@ -235,6 +238,9 @@ const mutations = {
     setWhatsNewContentHash(state, value) {
         state.whatsNewContentHash = value;
     },
+    setMigrationRemindDate(state, value) {
+        state.migrationRemindDate = value;
+    },
     setCurrentProfile(state, value) {
         state.currentProfile = value;
     },

+ 31 - 0
docs/omnireader/omnireader

@@ -1,3 +1,34 @@
+server {
+  listen 443 ssl; # managed by Certbot
+  ssl_certificate /etc/letsencrypt/live/omnireader.ru/fullchain.pem; # managed by Certbot
+  ssl_certificate_key /etc/letsencrypt/live/omnireader.ru/privkey.pem; # managed by Certbot
+  include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
+  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
+
+  server_name omnireader.ru;
+
+  client_max_body_size 50m;
+
+  gzip on;
+  gzip_min_length 1024;
+  gzip_proxied expired no-cache no-store private auth;
+  gzip_types *;
+
+  location /api {
+    proxy_pass http://localhost:44081;
+  }
+
+  location /tmp {
+    root /home/liberama/public;
+    add_header Content-Type text/xml;
+    add_header Content-Encoding gzip;
+  }
+
+  location / {
+    root /home/liberama/public;
+  }
+}
+
 server {
   listen 80;
   server_name omnireader.ru;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 355 - 295
package-lock.json


+ 32 - 31
package.json

@@ -1,6 +1,6 @@
 {
   "name": "Liberama",
-  "version": "0.6.10",
+  "version": "0.7.0",
   "engines": {
     "node": ">=10.0.0"
   },
@@ -19,69 +19,70 @@
   },
   "devDependencies": {
     "babel-core": "^6.22.1",
-    "babel-eslint": "^10.0.1",
+    "babel-eslint": "^10.0.3",
     "babel-loader": "^7.1.1",
     "babel-plugin-component": "^1.1.1",
     "babel-plugin-syntax-dynamic-import": "^6.18.0",
     "babel-plugin-transform-class-properties": "^6.24.1",
     "babel-plugin-transform-decorators-legacy": "^1.3.5",
     "babel-preset-env": "^1.3.2",
-    "clean-webpack-plugin": "^1.0.0",
+    "clean-webpack-plugin": "^1.0.1",
     "copy-webpack-plugin": "^4.6.0",
     "css-loader": "^1.0.0",
     "disable-output-webpack-plugin": "^1.0.1",
-    "element-theme-chalk": "^2.4.11",
-    "eslint": "^5.11.1",
-    "eslint-plugin-html": "^5.0.0",
+    "element-theme-chalk": "^2.12.0",
+    "eslint": "^5.16.0",
+    "eslint-plugin-html": "^5.0.5",
     "eslint-plugin-node": "^8.0.0",
-    "eslint-plugin-vue": "^5.0.0",
-    "event-hooks-webpack-plugin": "^2.1.1",
+    "eslint-plugin-vue": "^5.2.3",
+    "event-hooks-webpack-plugin": "^2.1.4",
     "file-loader": "^3.0.1",
     "html-webpack-plugin": "^3.2.0",
     "mini-css-extract-plugin": "^0.5.0",
     "null-loader": "^0.1.1",
-    "optimize-css-assets-webpack-plugin": "^5.0.1",
-    "pkg": "^4.3.7",
-    "terser-webpack-plugin": "^1.2.1",
+    "optimize-css-assets-webpack-plugin": "^5.0.3",
+    "pkg": "^4.4.0",
+    "terser-webpack-plugin": "^1.4.1",
     "url-loader": "^1.1.2",
     "vue-class-component": "^6.3.2",
-    "vue-loader": "^15.4.2",
+    "vue-loader": "^15.7.1",
     "vue-style-loader": "^4.1.2",
-    "vue-template-compiler": "^2.5.21",
-    "webpack": "^4.28.2",
-    "webpack-cli": "^3.1.2",
-    "webpack-dev-middleware": "^3.4.0",
-    "webpack-hot-middleware": "^2.24.3",
-    "webpack-merge": "^4.1.5"
+    "vue-template-compiler": "^2.6.10",
+    "webpack": "^4.39.3",
+    "webpack-cli": "^3.3.7",
+    "webpack-dev-middleware": "^3.7.1",
+    "webpack-hot-middleware": "^2.25.0",
+    "webpack-merge": "^4.2.2"
   },
   "dependencies": {
-    "axios": "^0.18.0",
-    "base-x": "^3.0.5",
+    "appcache-webpack-plugin": "^1.4.0",
+    "axios": "^0.18.1",
+    "base-x": "^3.0.6",
     "chardet": "^0.7.0",
-    "compression": "^1.7.3",
+    "compression": "^1.7.4",
     "decompress-zip": "^0.2.2",
-    "element-ui": "^2.4.11",
-    "express": "^4.16.4",
+    "element-ui": "^2.12.0",
+    "express": "^4.17.1",
     "fg-loadcss": "^2.1.0",
     "fs-extra": "^7.0.1",
-    "got": "^9.5.1",
+    "got": "^9.6.0",
     "he": "^1.2.0",
     "iconv-lite": "^0.4.24",
     "localforage": "^1.7.3",
-    "lodash": "^4.17.11",
+    "lodash": "^4.17.15",
     "minimist": "^1.2.0",
-    "multer": "^1.4.1",
+    "multer": "^1.4.2",
     "pako": "^1.0.10",
     "path-browserify": "^1.0.0",
-    "safe-buffer": "^5.1.2",
+    "safe-buffer": "^5.2.0",
     "sjcl": "^1.0.8",
     "sql-template-strings": "^2.2.2",
-    "sqlite": "^3.0.0",
+    "sqlite": "^3.0.3",
     "tar-fs": "^2.0.0",
     "unbzip2-stream": "^1.3.3",
-    "vue": "^2.5.21",
-    "vue-router": "^3.0.2",
-    "vuex": "^3.0.1",
+    "vue": "^2.6.10",
+    "vue-router": "^3.1.3",
+    "vuex": "^3.1.1",
     "vuex-persistedstate": "^2.5.4"
   }
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است