ソースを参照

Merge branch 'release/0.11.7'

Book Pauk 3 年 前
コミット
873a08fee1

+ 1 - 1
client/components/ExternalLibs/BookmarkSettings/BookmarkSettings.vue

@@ -11,7 +11,7 @@
                         Открыть выбранную закладку
                         Открыть выбранную закладку
                     </q-tooltip>
                     </q-tooltip>
                 </q-btn>
                 </q-btn>
-                <q-input ref="search" v-model="search" class="col" rounded outlined dense bg-color="white" placeholder="Найти">
+                <q-input ref="search" v-model="search" class="col" outlined dense bg-color="white" placeholder="Найти">
                     <template #append>
                     <template #append>
                         <q-icon v-if="search !== ''" name="la la-times" class="cursor-pointer" @click="resetSearch" />
                         <q-icon v-if="search !== ''" name="la la-times" class="cursor-pointer" @click="resetSearch" />
                     </template>
                     </template>

+ 12 - 11
client/components/ExternalLibs/ExternalLibs.vue

@@ -5,19 +5,19 @@
         </template>
         </template>
 
 
         <template #buttons>
         <template #buttons>
-            <span class="full-screen-button row justify-center items-center" @mousedown.stop @click="fullScreenToggle">
+            <span class="header-button row justify-center items-center" @mousedown.stop @click="fullScreenToggle">
                 <q-icon :name="(fullScreenActive ? 'la la-compress-arrows-alt': 'la la-expand-arrows-alt')" size="16px" />
                 <q-icon :name="(fullScreenActive ? 'la la-compress-arrows-alt': 'la la-expand-arrows-alt')" size="16px" />
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">На весь экран</q-tooltip>
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">На весь экран</q-tooltip>
             </span>
             </span>
-            <span class="full-screen-button row justify-center items-center" @mousedown.stop @click="changeScale(0.1)">
+            <span class="header-button row justify-center items-center" @mousedown.stop @click="changeScale(0.1)">
                 <q-icon name="la la-plus" size="16px" />
                 <q-icon name="la la-plus" size="16px" />
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Увеличить масштаб</q-tooltip>
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Увеличить масштаб</q-tooltip>
             </span>
             </span>
-            <span class="full-screen-button row justify-center items-center" @mousedown.stop @click="changeScale(-0.1)">
+            <span class="header-button row justify-center items-center" @mousedown.stop @click="changeScale(-0.1)">
                 <q-icon name="la la-minus" size="16px" />
                 <q-icon name="la la-minus" size="16px" />
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Уменьшить масштаб</q-tooltip>
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Уменьшить масштаб</q-tooltip>
             </span>
             </span>
-            <span class="full-screen-button row justify-center items-center" @mousedown.stop @click="showHelp">
+            <span class="header-button row justify-center items-center" @mousedown.stop @click="showHelp">
                 <q-icon name="la la-question-circle" size="16px" />
                 <q-icon name="la la-question-circle" size="16px" />
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Справка</q-tooltip>
                 <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Справка</q-tooltip>
             </span>
             </span>
@@ -32,7 +32,7 @@
                     :options="rootLinkOptions"
                     :options="rootLinkOptions"
                     style="width: 230px"
                     style="width: 230px"
                     dropdown-icon="la la-angle-down la-sm"
                     dropdown-icon="la la-angle-down la-sm"
-                    rounded outlined dense emit-value map-options display-value-sanitize options-sanitize
+                    outlined dense emit-value map-options display-value-sanitize options-sanitize
                     @popup-show="onSelectPopupShow" @popup-hide="onSelectPopupHide"
                     @popup-show="onSelectPopupShow" @popup-hide="onSelectPopupHide"
                 >
                 >
                     <template #prepend>
                     <template #prepend>
@@ -61,7 +61,7 @@
                     :options="selectedLinkOptions"
                     :options="selectedLinkOptions"
                     style="width: 50px"
                     style="width: 50px"
                     dropdown-icon="la la-angle-down la-sm"
                     dropdown-icon="la la-angle-down la-sm"
-                    rounded outlined dense emit-value map-options hide-selected display-value-sanitize options-sanitize
+                    outlined dense emit-value map-options hide-selected display-value-sanitize options-sanitize
                     @popup-show="onSelectPopupShow" @popup-hide="onSelectPopupHide"
                     @popup-show="onSelectPopupShow" @popup-hide="onSelectPopupHide"
                 >
                 >
                     <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
                     <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
@@ -73,7 +73,7 @@
                     ref="input"
                     ref="input"
                     v-model="bookUrl"
                     v-model="bookUrl"
                     class="col q-mr-sm"
                     class="col q-mr-sm"
-                    rounded outlined dense
+                    outlined dense
                     bg-color="white"
                     bg-color="white"
                     placeholder="Скопируйте сюда ссылку на книгу и нажмите 'Открыть'"
                     placeholder="Скопируйте сюда ссылку на книгу и нажмите 'Открыть'"
                     @focus="selectAllOnFocus" @keydown="bookUrlKeyDown"
                     @focus="selectAllOnFocus" @keydown="bookUrlKeyDown"
@@ -99,7 +99,7 @@
                     </template>
                     </template>
                 </q-input>
                 </q-input>
 
 
-                <q-btn :disabled="!bookUrl" rounded color="green-7" no-caps size="14px" @click="submitUrl">
+                <q-btn :disabled="!bookUrl" color="green-7" no-caps size="14px" @click="submitUrl">
                     Открыть
                     Открыть
                     <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
                     <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
                         Открыть в читалке
                         Открыть в читалке
@@ -894,14 +894,15 @@ export default vueComponent(ExternalLibs);
     background-color: #A0A0A0;
     background-color: #A0A0A0;
 }
 }
 
 
-.full-screen-button {
+.header-button {
     width: 30px;
     width: 30px;
     height: 30px;
     height: 30px;
     cursor: pointer;
     cursor: pointer;
 }
 }
 
 
-.full-screen-button:hover {
-    background-color: #69C05F;
+.header-button:hover {
+    color: white;
+    background-color: #39902F;
 }
 }
 
 
 .transparent-layout {
 .transparent-layout {

+ 111 - 24
client/components/Reader/ContentsPage/ContentsPage.vue

@@ -23,15 +23,15 @@
 
 
         <div class="q-mb-sm" />
         <div class="q-mb-sm" />
 
 
-        <div v-show="selectedTab == 'contents'" class="tab-panel">
+        <div v-show="selectedTab == 'contents'" ref="tabPanelContents" class="tab-panel">
             <div>
             <div>
                 <div v-for="item in contents" :key="item.key" class="column" style="width: 540px">
                 <div v-for="item in contents" :key="item.key" class="column" style="width: 540px">
-                    <div class="row q-px-sm no-wrap" :class="{'item': !item.isBookPos, 'item-book-pos': item.isBookPos}">
+                    <div :ref="`mainitem${item.key}`" class="row q-px-sm no-wrap" :class="{'item': !item.isBookPos, 'item-book-pos': item.isBookPos}">
                         <div v-if="item.list.length" class="row justify-center items-center expand-button clickable" @click="expandClick(item.key)">
                         <div v-if="item.list.length" class="row justify-center items-center expand-button clickable" @click="expandClick(item.key)">
-                            <q-icon name="la la-caret-right" class="icon" :class="{'expanded-icon': item.expanded}" color="green-8" size="20px" />
+                            <q-icon name="la la-caret-right" class="icon" :class="{'expanded-icon': item.expanded}" color="green-8" size="24px" />
                         </div>
                         </div>
                         <div v-else class="no-expand-button clickable" @click="setBookPos(item.offset)">
                         <div v-else class="no-expand-button clickable" @click="setBookPos(item.offset)">
-                            <q-icon name="la la-stop" class="icon" style="visibility: hidden" size="20px" />
+                            <q-icon name="la la-stop" class="icon" style="visibility: hidden" size="24px" />
                         </div>
                         </div>
                         <div class="col row clickable" @click="setBookPos(item.offset)">
                         <div class="col row clickable" @click="setBookPos(item.offset)">
                             <div :style="item.indentStyle"></div>
                             <div :style="item.indentStyle"></div>
@@ -42,8 +42,12 @@
                         </div>
                         </div>
                     </div>
                     </div>
                     
                     
-                    <div v-if="item.expanded" :ref="`subitem${item.key}`" class="subitems-transition">
-                        <div v-for="subitem in item.list" :key="subitem.key" class="row q-px-sm no-wrap" :class="{'subitem': !subitem.isBookPos, 'subitem-book-pos': subitem.isBookPos}">
+                    <div v-if="item.expanded" :ref="`subdiv${item.key}`" class="subitems-transition">
+                        <div 
+                            v-for="subitem in item.list" 
+                            :ref="`subitem${subitem.key}`" 
+                            :key="subitem.key" class="row q-px-sm no-wrap" :class="{'subitem': !subitem.isBookPos, 'subitem-book-pos': subitem.isBookPos}"
+                        >
                             <div class="col row clickable" @click="setBookPos(subitem.offset)">
                             <div class="col row clickable" @click="setBookPos(subitem.offset)">
                                 <div class="no-expand-button"></div>
                                 <div class="no-expand-button"></div>
                                 <div :style="subitem.indentStyle"></div>
                                 <div :style="subitem.indentStyle"></div>
@@ -61,10 +65,10 @@
             </div>
             </div>
         </div>
         </div>
 
 
-        <div v-show="selectedTab == 'images'" class="tab-panel">
+        <div v-show="selectedTab == 'images'" ref="tabPanelImages" class="tab-panel">
             <div>
             <div>
                 <div v-for="item in images" :key="item.key" class="column" style="width: 540px">
                 <div v-for="item in images" :key="item.key" class="column" style="width: 540px">
-                    <div class="row q-px-sm no-wrap" :class="{'item': !item.isBookPos, 'item-book-pos': item.isBookPos}">
+                    <div :ref="`image${item.key}`" class="row q-px-sm no-wrap" :class="{'item': !item.isBookPos, 'item-book-pos': item.isBookPos}">
                         <div class="col row clickable" @click="setBookPos(item.offset)">
                         <div class="col row clickable" @click="setBookPos(item.offset)">
                             <div class="image-thumb-box row justify-center items-center">
                             <div class="image-thumb-box row justify-center items-center">
                                 <div v-show="!imageLoaded[item.id]" class="image-thumb column justify-center">
                                 <div v-show="!imageLoaded[item.id]" class="image-thumb column justify-center">
@@ -124,7 +128,10 @@ const componentOptions = {
     watch: {
     watch: {
         bookPos() {
         bookPos() {
             this.updateBookPosSelection();
             this.updateBookPosSelection();
-        }
+        },
+        selectedTab() {
+            this.updateBookPosScrollTop();
+        },
     },
     },
 };
 };
 class ContentsPage {
 class ContentsPage {
@@ -282,31 +289,30 @@ class ContentsPage {
         if (!this.isVisible)
         if (!this.isVisible)
             return;
             return;
 
 
-        await utils.sleep(50);
+        await this.$nextTick();
         const bp = this.bookPos;
         const bp = this.bookPos;
         
         
         for (let i = 0; i < this.contents.length; i++) {
         for (let i = 0; i < this.contents.length; i++) {
             const item = this.contents[i];
             const item = this.contents[i];
             const nextOffset = (i < this.contents.length - 1 ? this.contents[i + 1].offset : this.parsed.textLength);
             const nextOffset = (i < this.contents.length - 1 ? this.contents[i + 1].offset : this.parsed.textLength);
 
 
+            if (bp >= item.offset && bp < nextOffset) {
+                item.isBookPos = true;
+            } else if (item.isBookPos) {
+                item.isBookPos = false;
+            }
+
             for (let j = 0; j < item.list.length; j++) {
             for (let j = 0; j < item.list.length; j++) {
                 const subitem = item.list[j];
                 const subitem = item.list[j];
                 const nextSubOffset = (j < item.list.length - 1 ? item.list[j + 1].offset : nextOffset);
                 const nextSubOffset = (j < item.list.length - 1 ? item.list[j + 1].offset : nextOffset);
 
 
                 if (bp >= subitem.offset && bp < nextSubOffset) {
                 if (bp >= subitem.offset && bp < nextSubOffset) {
                     subitem.isBookPos = true;
                     subitem.isBookPos = true;
-                    this.contents[i] = Object.assign(item, {list: item.list});
+                    this.updateBookPosScrollTop('contents', item, subitem, j);
                 } else if (subitem.isBookPos) {
                 } else if (subitem.isBookPos) {
                     subitem.isBookPos = false;
                     subitem.isBookPos = false;
-                    this.contents[i] = Object.assign(item, {list: item.list});
                 }
                 }
             }
             }
-
-            if (bp >= item.offset && bp < nextOffset) {
-                this.contents[i] = Object.assign(item, {isBookPos: true});
-            } else if (item.isBookPos) {
-                this.contents[i] = Object.assign(item, {isBookPos: false});
-            }
         }
         }
 
 
         for (let i = 0; i < this.images.length; i++) {
         for (let i = 0; i < this.images.length; i++) {
@@ -314,10 +320,91 @@ class ContentsPage {
             const nextOffset = (i < this.images.length - 1 ? this.images[i + 1].offset : this.parsed.textLength);
             const nextOffset = (i < this.images.length - 1 ? this.images[i + 1].offset : this.parsed.textLength);
 
 
             if (bp >= img.offset && bp < nextOffset) {
             if (bp >= img.offset && bp < nextOffset) {
-                this.images[i] = Object.assign(img, {isBookPos: true});
+                this.images[i].isBookPos = true;
             } else if (img.isBookPos) {
             } else if (img.isBookPos) {
-                this.images[i] = Object.assign(img, {isBookPos: false});
+                this.images[i].isBookPos = false;
+            }
+        }
+
+        this.updateBookPosScrollTop();
+    }
+
+    /*getOffsetTop(key) {
+        let el = this.getFirstElem(this.$refs[`mainitem${key}`]);
+        return (el ? el.offsetTop : 0);
+    }*/
+
+    async updateBookPosScrollTop() {
+        try {
+            await this.$nextTick();
+
+            if (this.selectedTab == 'contents') {
+                let item;
+                let subitem;
+                let i;
+
+                //ищем выделенные item
+                for(const _item of this.contents) {
+                    if (_item.isBookPos) {
+                        item = _item;
+                        for (let ii = 0; ii < item.list.length; ii++) {
+                            const _subitem = item.list[ii];
+                            if (_subitem.isBookPos) {
+                                subitem = _subitem;
+                                i = ii;
+                                break;
+                            }
+                        }
+                        break;
+                    }
+                }
+
+                if (!item)
+                    return;
+
+                //вычисляем и смещаем tabPanel.scrollTop
+                let el = this.getFirstElem(this.$refs[`mainitem${item.key}`]);
+                let elShift = 0;
+                if (subitem && item.expanded) {
+                    const subEl = this.getFirstElem(this.$refs[`subitem${subitem.key}`]);
+                    elShift = el.offsetHeight - subEl.offsetHeight*(i + 1);
+                } else {
+                    elShift = el.offsetHeight;
+                }
+
+                const tabPanel = this.$refs.tabPanelContents;
+                const halfH = tabPanel.clientHeight/2;
+                const newScrollTop = el.offsetTop - halfH - elShift;
+                if (newScrollTop < 20 + tabPanel.scrollTop - halfH || newScrollTop > -20 + tabPanel.scrollTop + halfH) 
+                    tabPanel.scrollTop = newScrollTop;
+            }
+
+            if (this.selectedTab == 'images') {
+                let item;
+
+                //ищем выделенные item
+                for(const _item of this.images) {
+                    if (_item.isBookPos) {
+                        item = _item;
+                        break;
+                    }
+                }
+
+                if (!item)
+                    return;
+
+                //вычисляем и смещаем tabPanel.scrollTop
+                let el = this.getFirstElem(this.$refs[`image${item.key}`]);
+
+                const tabPanel = this.$refs.tabPanelImages;
+                const halfH = tabPanel.clientHeight/2;
+                const newScrollTop = el.offsetTop - halfH - el.offsetHeight/2;
+
+                if (newScrollTop < 20 + tabPanel.scrollTop - halfH || newScrollTop > -20 + tabPanel.scrollTop + halfH) 
+                    tabPanel.scrollTop = newScrollTop;
             }
             }
+        } catch (e) {
+            console.error(e);
         }
         }
     }
     }
 
 
@@ -330,8 +417,8 @@ class ContentsPage {
         const expanded = !item.expanded;
         const expanded = !item.expanded;
 
 
         if (!expanded) {
         if (!expanded) {
-            let subitems = this.getFirstElem(this.$refs[`subitem${key}`]);
-            subitems.style.height = '0';
+            let subdiv = this.getFirstElem(this.$refs[`subdiv${key}`]);
+            subdiv.style.height = '0';
             await utils.sleep(200);
             await utils.sleep(200);
         }
         }
 
 
@@ -339,8 +426,8 @@ class ContentsPage {
 
 
         if (expanded) {
         if (expanded) {
             await this.$nextTick();
             await this.$nextTick();
-            let subitems = this.getFirstElem(this.$refs[`subitem${key}`]);
-            subitems.style.height = subitems.scrollHeight + 'px';
+            let subdiv = this.getFirstElem(this.$refs[`subdiv${key}`]);
+            subdiv.style.height = subdiv.scrollHeight + 'px';
         }
         }
     }
     }
 
 

+ 14 - 11
client/components/Reader/HelpPage/HelpPage.vue

@@ -5,13 +5,20 @@
         </template>
         </template>
 
 
         <div class="col column" style="min-width: 600px">
         <div class="col column" style="min-width: 600px">
-            <q-btn-toggle
-                v-model="selectedTab"
-                toggle-color="primary"
-                no-caps unelevated
-                :options="buttons"
-            />
-            <div class="separator"></div>
+            <div class="bg-grey-3 row">
+                <q-tabs
+                    v-model="selectedTab"
+                    active-color="black"
+                    active-bg-color="white"
+                    indicator-color="white"
+                    dense
+                    no-caps
+                    inline-label
+                    class="bg-grey-4 text-grey-7"
+                >
+                    <q-tab v-for="btn in buttons" :key="btn.value" :name="btn.value" :label="btn.label" />
+                </q-tabs>
+            </div>
 
 
             <keep-alive>
             <keep-alive>
                 <component :is="activePage" ref="page" class="col"></component>
                 <component :is="activePage" ref="page" class="col"></component>
@@ -93,8 +100,4 @@ export default vueComponent(HelpPage);
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.separator {
-    height: 1px;
-    background-color: #E0E0E0;
-}
 </style>
 </style>

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

@@ -1,5 +1,5 @@
 <template>
 <template>
-    <div v-show="visible" class="column justify-center items-center z-max" style="background-color: rgba(0, 0, 0, 0.8)">
+    <div v-show="visible" class="column justify-center items-center" style="background-color: rgba(0, 0, 0, 0.8); z-index: 100;">
         <div class="column justify-start items-center" style="height: 250px">
         <div class="column justify-start items-center" style="height: 250px">
             <q-circular-progress
             <q-circular-progress
                 show-value
                 show-value

+ 88 - 25
client/components/Reader/Reader.vue

@@ -141,6 +141,7 @@
                     @load-file="loadFile"
                     @load-file="loadFile"
                     @book-pos-changed="bookPosChanged"
                     @book-pos-changed="bookPosChanged"
                     @do-action="doAction"
                     @do-action="doAction"
+                    @hide-tool-bar="hideToolBar"
                 ></component>
                 ></component>
             </keep-alive>
             </keep-alive>
 
 
@@ -201,6 +202,7 @@ import miscApi from '../../api/misc';
 
 
 import {versionHistory} from './versionHistory';
 import {versionHistory} from './versionHistory';
 import * as utils from '../../share/utils';
 import * as utils from '../../share/utils';
+import LockQueue from '../../share/LockQueue';
 
 
 const componentOptions = {
 const componentOptions = {
     components: {
     components: {
@@ -313,6 +315,8 @@ class Reader {
         this.reader = this.$store.state.reader;
         this.reader = this.$store.state.reader;
         this.config = this.$store.state.config;
         this.config = this.$store.state.config;
 
 
+        this.lock = new LockQueue(100);
+
         this.$root.addEventHook('key', this.keyHook);
         this.$root.addEventHook('key', this.keyHook);
 
 
         this.lastActivePage = false;
         this.lastActivePage = false;
@@ -345,6 +349,13 @@ class Reader {
             this.debouncedSetRecentBook(newValue);
             this.debouncedSetRecentBook(newValue);
         }, 15000, {maxWait: 20000});
         }, 15000, {maxWait: 20000});
 
 
+        this.debouncedHideToolBar = _.debounce((event) => {
+            if (this.toolBarHideOnScroll && this.toolBarActive !== !!event.show) {
+                this.commit('reader/setToolBarActive', !!event.show);
+                this.$root.eventHook('resize');
+            }
+        }, 200);
+
         document.addEventListener('fullscreenchange', () => {
         document.addEventListener('fullscreenchange', () => {
             this.fullScreenActive = (document.fullscreenElement !== null);
             this.fullScreenActive = (document.fullscreenElement !== null);
         });
         });
@@ -402,6 +413,7 @@ class Reader {
         this.clickControlActive = this.clickControl;
         this.clickControlActive = this.clickControl;
         this.blinkCachedLoad = settings.blinkCachedLoad;
         this.blinkCachedLoad = settings.blinkCachedLoad;
         this.showToolButton = settings.showToolButton;
         this.showToolButton = settings.showToolButton;
+        this.toolBarHideOnScroll = settings.toolBarHideOnScroll;
         this.enableSitesFilter = settings.enableSitesFilter;
         this.enableSitesFilter = settings.enableSitesFilter;
         this.showNeedUpdateNotify = settings.showNeedUpdateNotify;
         this.showNeedUpdateNotify = settings.showNeedUpdateNotify;
         this.splitToPara = settings.splitToPara;
         this.splitToPara = settings.splitToPara;
@@ -662,6 +674,10 @@ class Reader {
         this.$root.eventHook('resize');
         this.$root.eventHook('resize');
     }
     }
 
 
+    hideToolBar(event) {
+        this.debouncedHideToolBar(event);
+    }
+
     fullScreenToggle() {
     fullScreenToggle() {
         this.fullScreenActive = !this.fullScreenActive;
         this.fullScreenActive = !this.fullScreenActive;
         if (this.fullScreenActive) {
         if (this.fullScreenActive) {
@@ -897,7 +913,7 @@ class Reader {
 
 
     refreshBook() {
     refreshBook() {
         const mrb = this.mostRecentBook();
         const mrb = this.mostRecentBook();
-        this.loadBook({url: mrb.url, uploadFileName: mrb.uploadFileName, force: true});
+        this.loadBook(Object.assign({}, mrb, {force: true}));
     }
     }
 
 
     undoAction() {
     undoAction() {
@@ -1051,7 +1067,7 @@ class Reader {
         return result;
         return result;
     }
     }
 
 
-    async loadBook(opts) {
+    async _loadBook(opts) {
         if (!opts || !opts.url) {
         if (!opts || !opts.url) {
             this.mostRecentBook();
             this.mostRecentBook();
             return;
             return;
@@ -1061,10 +1077,6 @@ class Reader {
 
 
         let url = encodeURI(decodeURI(opts.url));
         let url = encodeURI(decodeURI(opts.url));
 
 
-        //TODO: убрать конвертирование 'file://' после 06.2021
-        if (url.length == 71 && url.indexOf('file://') == 0)
-            url = url.replace(/^file/, 'disk');
-
         if ((url.indexOf('http://') != 0) && (url.indexOf('https://') != 0) &&
         if ((url.indexOf('http://') != 0) && (url.indexOf('https://') != 0) &&
             (url.indexOf('disk://') != 0))
             (url.indexOf('disk://') != 0))
             url = 'http://' + url;
             url = 'http://' + url;
@@ -1091,33 +1103,36 @@ class Reader {
             progress.show();
             progress.show();
             progress.setState({state: 'parse'});
             progress.setState({state: 'parse'});
 
 
-            // есть ли среди недавних
-            const key = bookManager.keyFromUrl(url);
-            let wasOpened = await bookManager.getRecentBook({key});
-            wasOpened = (wasOpened ? wasOpened : {});
-            const bookPos = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos);
-            const bookPosSeen = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen);
-            const uploadFileName = (opts.uploadFileName ? opts.uploadFileName : '');
+            // есть ли среди загруженных
+            let wasOpened = bookManager.findRecentByUrlAndPath(url, opts.path);
+            wasOpened = (wasOpened ? _.cloneDeep(wasOpened) : {});
+
+            wasOpened = Object.assign(wasOpened, {
+                path: (opts.path !== undefined ? opts.path : wasOpened.path),
+                bookPos: (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos),
+                bookPosSeen: (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen),
+                uploadFileName: (opts.uploadFileName ? opts.uploadFileName : wasOpened.uploadFileName),
+            });
 
 
             let book = null;
             let book = null;
 
 
             if (!opts.force) {
             if (!opts.force) {
                 // пытаемся загрузить и распарсить книгу в менеджере из локального кэша
                 // пытаемся загрузить и распарсить книгу в менеджере из локального кэша
-                const bookParsed = await bookManager.getBook({url, path: opts.path}, (prog) => {
+                const bookParsed = await bookManager.getBook(wasOpened, (prog) => {
                     progress.setState({progress: prog});
                     progress.setState({progress: prog});
                 });
                 });
 
 
                 // если есть в локальном кэше
                 // если есть в локальном кэше
                 if (bookParsed) {
                 if (bookParsed) {
-                    await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen}, bookParsed));
+                    await bookManager.setRecentBook(Object.assign(wasOpened, bookParsed));
                     this.mostRecentBook();
                     this.mostRecentBook();
-                    this.addAction(bookPos);
+                    this.addAction(wasOpened.bookPos);
                     this.loaderActive = false;
                     this.loaderActive = false;
                     progress.hide(); this.progressActive = false;
                     progress.hide(); this.progressActive = false;
                     this.blinkCachedLoadMessage();
                     this.blinkCachedLoadMessage();
 
 
                     this.checkBookPosPercent();
                     this.checkBookPosPercent();
-                    await this.activateClickMapPage();
+                    this.activateClickMapPage();//no await
                     return;
                     return;
                 }
                 }
 
 
@@ -1131,7 +1146,7 @@ class Reader {
                         });
                         });
                         book = Object.assign({}, wasOpened, {data: resp.data});
                         book = Object.assign({}, wasOpened, {data: resp.data});
                     } catch (e) {
                     } catch (e) {
-                        //молчим
+                        this.$root.notify.error('Конвертированный файл не найден на сервере.<br>Пробуем загрузить оригинал.', 'Ошибка загрузки');
                     }
                     }
                 }
                 }
             }
             }
@@ -1142,7 +1157,7 @@ class Reader {
             if (!book) {
             if (!book) {
                 book = await readerApi.loadBook({
                 book = await readerApi.loadBook({
                         url,
                         url,
-                        uploadFileName,
+                        uploadFileName: wasOpened.uploadFileName,
                         enableSitesFilter: this.enableSitesFilter,
                         enableSitesFilter: this.enableSitesFilter,
                         skipHtmlCheck: (this.splitToPara ? true : false),
                         skipHtmlCheck: (this.splitToPara ? true : false),
                         isText: (this.splitToPara ? true : false),
                         isText: (this.splitToPara ? true : false),
@@ -1159,14 +1174,44 @@ class Reader {
 
 
             // добавляем в bookManager
             // добавляем в bookManager
             progress.setState({state: 'parse', step: 5});
             progress.setState({state: 'parse', step: 5});
+
             const addedBook = await bookManager.addBook(book, (prog) => {
             const addedBook = await bookManager.addBook(book, (prog) => {
                 progress.setState({progress: prog});
                 progress.setState({progress: prog});
             });
             });
 
 
+            // sameBookKey
+            if (url.indexOf('disk://') == 0) {
+                //ищем такой файл в загруженных
+                let found = bookManager.findRecentBySameBookKey(wasOpened.uploadFileName);
+                found = (found ? _.cloneDeep(found) : found);
+
+                if (found) {
+                    if (wasOpened.sameBookKey != found.sameBookKey) {
+                        //спрашиваем, надо ли объединить файлы
+                        const askResult = bookManager.keysEqual(found.path, addedBook.path) || 
+                            await this.$root.stdDialog.askYesNo(`
+    Файл с именем "${wasOpened.uploadFileName}" уже есть в загруженных.
+    <br>Объединить позицию?`, 'Найдена похожая книга');
+                        if (askResult) {
+                            wasOpened.bookPos = found.bookPos;
+                            wasOpened.bookPosSeen = found.bookPosSeen;
+                            wasOpened.sameBookKey = found.sameBookKey;
+                        }
+                    }
+                } else {
+                    wasOpened.sameBookKey = wasOpened.uploadFileName;
+                }
+            } else {
+                wasOpened.sameBookKey = addedBook.url;
+            }
+
+            if (!bookManager.keysEqual(wasOpened.path, addedBook.path))
+                delete wasOpened.loadTime;
+
             // добавляем в историю
             // добавляем в историю
-            await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, uploadFileName}, addedBook));
+            await bookManager.setRecentBook(Object.assign(wasOpened, addedBook));
             this.mostRecentBook();
             this.mostRecentBook();
-            this.addAction(bookPos);
+            this.addAction(wasOpened.bookPos);
             this.updateRoute(true);
             this.updateRoute(true);
 
 
             this.loaderActive = false;
             this.loaderActive = false;
@@ -1177,11 +1222,11 @@ class Reader {
                 this.stopBlink = true;
                 this.stopBlink = true;
 
 
             this.checkBookPosPercent();
             this.checkBookPosPercent();
-            await this.activateClickMapPage();
+            this.activateClickMapPage();//no await
         } catch (e) {
         } catch (e) {
             progress.hide(); this.progressActive = false;
             progress.hide(); this.progressActive = false;
             this.loaderActive = true;
             this.loaderActive = true;
-            if (!this.showHelpOnErrorIfNeeded(e.message)) {
+            if (!this.showHelpOnErrorIfNeeded(url)) {
                 this.$root.stdDialog.alert(e.message, 'Ошибка', {color: 'negative'});
                 this.$root.stdDialog.alert(e.message, 'Ошибка', {color: 'negative'});
             }
             }
         } finally {
         } finally {
@@ -1189,7 +1234,16 @@ class Reader {
         }
         }
     }
     }
 
 
-    async loadFile(opts) {
+    async loadBook(opts) {
+        await this.lock.get();
+        try {
+            await this._loadBook(opts);
+        } finally {
+            this.lock.ret();
+        }
+    }
+
+    async _loadFile(opts) {
         this.progressActive = true;
         this.progressActive = true;
 
 
         await this.$nextTick();
         await this.$nextTick();
@@ -1205,7 +1259,7 @@ class Reader {
 
 
             progress.hide(); this.progressActive = false;
             progress.hide(); this.progressActive = false;
 
 
-            await this.loadBook({url, uploadFileName: opts.file.name, force: true});
+            await this._loadBook({url, uploadFileName: opts.file.name, force: true});
         } catch (e) {
         } catch (e) {
             progress.hide(); this.progressActive = false;
             progress.hide(); this.progressActive = false;
             this.loaderActive = true;
             this.loaderActive = true;
@@ -1213,6 +1267,15 @@ class Reader {
         }
         }
     }
     }
 
 
+    async loadFile(opts) {
+        await this.lock.get();
+        try {
+            await this._loadFile(opts);
+        } finally {
+            this.lock.ret();
+        }
+    }
+
     blinkCachedLoadMessage() {
     blinkCachedLoadMessage() {
         if (!this.blinkCachedLoad)
         if (!this.blinkCachedLoad)
             return;
             return;

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

@@ -54,7 +54,7 @@
 
 
                 <br><br>
                 <br><br>
                 <div class="row justify-center">
                 <div class="row justify-center">
-                    <!--q-btn class="q-px-sm" color="primary" dense no-caps rounded @click="openDonate">
+                    <!--q-btn class="q-px-sm" color="primary" dense no-caps @click="openDonate">
                         Помочь проекту
                         Помочь проекту
                     </q-btn-->
                     </q-btn-->
                 </div>
                 </div>

+ 500 - 227
client/components/Reader/RecentBooksPage/RecentBooksPage.vue

@@ -9,88 +9,153 @@
 
 
         <a ref="download" style="display: none;" target="_blank"></a>
         <a ref="download" style="display: none;" target="_blank"></a>
 
 
-        <q-table
-            class="recent-books-table col"
-            :rows="tableData"
-            row-key="key"
-            :columns="columns"
-            :pagination="pagination"
-            separator="cell"
-            hide-bottom
-            virtual-scroll
-            dense
-        > 
-            <template #header="props">
-                <q-tr :props="props">
-                    <q-th key="num" class="td-mp" style="width: 25px" :props="props">
-                        <span v-html="props.cols[0].label"></span>
-                    </q-th>
-                    <q-th key="date" class="td-mp break-word" style="width: 77px" :props="props">
-                        <span v-html="props.cols[1].label"></span>
-                    </q-th>
-                    <q-th key="desc" class="td-mp" style="width: 332px" :props="props" colspan="4">
-                        <q-input ref="input" v-model="search"
-                            outlined dense rounded style="position: absolute; top: 6px; left: 90px; width: 380px" bg-color="white"
-                            placeholder="Найти"                            
-                            @click.stop
-                        >
-                            <template #append>
-                                <q-icon v-if="search !== ''" name="la la-times" class="cursor-pointer" @click.stop="resetSearch" />
-                            </template>
-                        </q-input>
-                        <span v-html="props.cols[2].label"></span>
-                    </q-th>
-                </q-tr>
-            </template>
-
-            <template #body="props">
-                <q-tr :props="props">
-                    <q-td key="num" :props="props" class="td-mp" auto-width>
-                        <div class="break-word" style="width: 25px">
-                            {{ props.row.num }}
+        <div id="vs-container" ref="vsContainer" class="recent-books-scroll col">
+            <div ref="header" class="scroll-header row bg-blue-2">
+                <q-btn class="tool-button" round @click="showSameBookClick">
+                    <q-icon name="la la-caret-right" class="icon" :class="{'expanded-icon': showSameBook}" color="green-8" size="24px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        Показать/скрыть версии книг
+                    </q-tooltip>
+                </q-btn>
+
+                <q-btn class="tool-button" round @click="scrollToBegin">
+                    <q-icon name="la la-arrow-up" color="green-8" size="24px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        В начало списка
+                    </q-tooltip>
+                </q-btn>
+
+                <q-btn class="tool-button" round @click="scrollToEnd">
+                    <q-icon name="la la-arrow-down" color="green-8" size="24px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        В конец списка
+                    </q-tooltip>
+                </q-btn>
+
+                <q-btn class="tool-button" round @click="scrollToActiveBook">
+                    <q-icon name="la la-location-arrow" color="green-8" size="24px" />
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        На текущую книгу
+                    </q-tooltip>
+                </q-btn>
+
+                <q-select
+                    ref="sortMethod"
+                    v-model="sortMethod"
+                    class="q-ml-md q-mt-xs"
+                    :options="sortMethodOptions"
+                    style="width: 180px"
+                    bg-color="white"
+                    dropdown-icon="la la-angle-down la-sm"
+                    outlined dense emit-value map-options display-value-sanitize options-sanitize
+                    options-html display-value-html
+
+                    @update:model-value="sortMethodSelected"
+                >
+                    <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
+                        Метод сортировки
+                    </q-tooltip>
+
+                    <template #selected-item="scope">
+                        <div style="height: 28px; padding-top: 2px; overflow: hidden" v-html="scope.opt.label" />
+                    </template>
+                </q-select>
+
+                <q-input 
+                    ref="input"
+                    v-model="search"
+                    class="q-ml-sm q-mt-xs"
+                    outlined dense
+                    style="width: 180px"
+                    bg-color="white"
+                    placeholder="Найти"
+                    @click.stop
+                >
+                    <template #append>
+                        <q-icon v-if="search !== ''" name="la la-times" class="cursor-pointer" @click.stop="resetSearch" />
+                    </template>
+                </q-input>
+            </div>
+
+            <q-virtual-scroll
+                ref="virtualScroll"
+                v-slot="{ item, index }"
+                :items="tableData"
+                scroll-target="#vs-container"
+                virtual-scroll-item-size="80"
+                @virtual-scroll="onScroll"
+            >
+                <div class="table-row row" :class="{even: index % 2 > 0, 'active-book': item.active, 'active-parent-book': item.activeParent}">
+                    <div v-show="item.inGroup" class="row-part column justify-center items-center" style="width: 40px; border-right: 1px solid #cccccc">                        
+                        <q-icon name="la la-code-branch" size="24px" style="color: green" />
+                    </div>
+
+                    <div class="row-part column justify-center items-stretch" style="width: 80px">
+                        <div class="col row justify-center items-center clickable" @click="loadBook(item)">
+                            <q-icon name="la la-book" size="40px" style="color: #dddddd" />
                         </div>
                         </div>
-                    </q-td>
 
 
-                    <q-td key="date" auto-width :props="props" class="td-mp clickable" @click="loadBook(props.row.url)">
-                        <div class="break-word" style="width: 68px">
-                            {{ props.row.touchDate }}<br>
-                            {{ props.row.touchTime }}
+                        <div v-show="!showSameBook && item.group && item.group.length > 0" class="row justify-center" style="font-size: 70%">
+                            {{ (item.group ? item.group.length + 1 : 0) }} верси{{ wordEnding((item.group ? item.group.length + 1 : 0), 1) }}
                         </div>
                         </div>
-                    </q-td>
+                    </div>
 
 
-                    <q-td key="desc" auto-width :props="props" class="td-mp clickable" @click="loadBook(props.row.url)">
-                        <div class="break-word" style="width: 332px; font-size: 90%">
-                            <div style="color: green">
-                                {{ props.row.desc.author }}
+                    <div class="row-part column items-stretch clickable break-word" :style="{ 'width': (350 - 40*(+item.inGroup)) + 'px' }" style="font-size: 75%" @click="loadBook(item)">
+                        <div class="row" style="font-size: 80%">
+                            <div class="row justify-center row-info-top" style="width: 30px">
+                                {{ item.num }}
+                            </div>
+                            <div class="row justify-center row-info-top" style="width: 130px">
+                                Читался: {{ item.touchTime }}
+                            </div>
+                            <div class="row justify-center row-info-top" style="width: 138px">
+                                Загружен: {{ item.loadTime }}
+                            </div>
+                            <div class="row justify-center row-info-top" style="width: 1px">
                             </div>
                             </div>
-                            <div>{{ props.row.desc.title }}</div>
-                            <div class="read-bar" :style="`width: ${332*props.row.readPart}px`"></div>
                         </div>
                         </div>
-                    </q-td>
 
 
-                    <q-td key="links" :props="props" class="td-mp" auto-width>
-                        <div class="break-word" style="width: 75px; font-size: 90%">
-                            <a v-show="isUrl(props.row.url)" :href="props.row.url" target="_blank">Оригинал</a><br>
-                            <a :href="props.row.path" @click.prevent="downloadBook(props.row.path, props.row.fullTitle)">Скачать FB2</a>
+                        <div class="col q-mt-xs" :style="{ 'width': (340 - 40*(+item.inGroup)) + 'px' }">
+                            <div class="text-green-10" style="font-size: 105%">
+                                {{ item.desc.author }}
+                            </div>
+                            <div>{{ item.desc.title }}</div>
+                            <!--div>{{ item.path }}</div-->
                         </div>
                         </div>
-                    </q-td>
-
-                    <q-td key="close" :props="props" class="td-mp" auto-width>
-                        <div style="width: 38px">
-                            <q-btn
-                                dense
-                                style="width: 30px; height: 30px; padding: 7px 0 7px 0; margin-left: 4px"
-                                @click="handleDel(props.row.key)"
-                            >
-                                <q-icon class="la la-times" size="14px" />
-                            </q-btn>
+
+                        <div class="row q-mt-xs" style="font-size: 80%">
+                            <div class="row justify-center row-info-bottom" style="width: 60px">
+                                {{ item.desc.textLen }}
+                            </div>
+                            <div class="row justify-center row-info-bottom" style="width: 60px">
+                                {{ item.desc.perc }}
+                            </div>
+                            <div class="row justify-center row-info-bottom" style="width: 1px">
+                            </div>
+                        </div>
+
+                        <div class="read-bar" :style="`width: ${(340 - 40*(+item.inGroup))*item.readPart}px`"></div>
+                    </div>
+
+                    <div class="row-part column justify-center" style="width: 80px; font-size: 75%">
+                        <div>
+                            <a v-show="isUrl(item.url)" :href="item.url" target="_blank">Оригинал</a><br><br>
+                            <a :href="item.path" @click.prevent="downloadBook(item.path, item.fullTitle)">Скачать FB2</a>
                         </div>
                         </div>
-                    </q-td>
-                    <q-td key="last" :props="props" class="no-mp">
-                    </q-td>
-                </q-tr>
-            </template>
-        </q-table>
+                    </div>
+
+                    <div class="row-part column justify-center">
+                        <q-btn
+                            dense
+                            style="width: 30px; height: 30px; padding: 7px 0 7px 0; margin-left: 4px"
+                            @click="handleDel(item.key)"
+                        >
+                            <q-icon class="la la-times" size="14px" />
+                        </q-btn>
+                    </div>
+                </div>
+            </q-virtual-scroll>
+        </div>
     </Window>
     </Window>
 </template>
 </template>
 
 
@@ -99,9 +164,10 @@
 import vueComponent from '../../vueComponent.js';
 import vueComponent from '../../vueComponent.js';
 
 
 import path from 'path-browserify';
 import path from 'path-browserify';
-//import _ from 'lodash';
+import _ from 'lodash';
 
 
 import * as utils from '../../../share/utils';
 import * as utils from '../../../share/utils';
+import LockQueue from '../../../share/LockQueue';
 import Window from '../../share/Window.vue';
 import Window from '../../share/Window.vue';
 import bookManager from '../share/bookManager';
 import bookManager from '../share/bookManager';
 import readerApi from '../../../api/reader';
 import readerApi from '../../../api/reader';
@@ -111,9 +177,15 @@ const componentOptions = {
         Window,
         Window,
     },
     },
     watch: {
     watch: {
-        search: function() {
+        search() {
             this.updateTableData();
             this.updateTableData();
-        }
+        },
+        sortMethod() {
+            this.updateTableData();
+        },
+        settings() {
+            this.loadSettings();
+        },
     },
     },
 };
 };
 class RecentBooksPage {
 class RecentBooksPage {
@@ -122,52 +194,18 @@ class RecentBooksPage {
     loading = false;
     loading = false;
     search = '';
     search = '';
     tableData = [];
     tableData = [];
-    columns = [];
-    pagination = {};
+    sortMethod = '';
+    showSameBook = false;
 
 
     created() {
     created() {
-        this.firstInit = true;
-        this.pagination = {rowsPerPage: 0};
-
-        this.columns = [
-            {
-                name: 'num',
-                label: '#',
-                align: 'center',
-                sortable: true,
-                field: 'num',
-            },
-            {
-                name: 'date',
-                label: 'Время<br>просм.',
-                align: 'left',
-                field: 'touchDateTime',
-                sortable: true,
-                sort: (a, b, rowA, rowB) => rowA.touchDateTime - rowB.touchDateTime,
-            },
-            {
-                name: 'desc',
-                label: 'Название',
-                align: 'left',
-                field: 'descString',
-                sortable: true,
-            },
-            {
-                name: 'links',
-                label: '',
-                align: 'left',
-            },
-            {
-                name: 'close',
-                label: '',
-                align: 'left',
-            },
-            {
-                name: 'last',
-                label: '',
-                align: 'left',
-            },
-        ];
+        this.commit = this.$store.commit;
+
+        this.lastScrollTop1 = 0;
+        this.lastScrollTop2 = 0;
+
+        this.lock = new LockQueue(100);
+
+        this.loadSettings();
     }
     }
 
 
     init() {
     init() {
@@ -176,89 +214,186 @@ class RecentBooksPage {
         this.$nextTick(() => {
         this.$nextTick(() => {
             //this.$refs.input.focus();//плохо на планшетах
             //this.$refs.input.focus();//плохо на планшетах
         });
         });
-        (async() => {//подгрузка списка
-            if (this.initing)
-                return;
-            this.initing = true;
-
-            if (this.firstInit) {//для отзывчивости
-                await this.updateTableData(20);
-                this.firstInit = false;
-            }
-            await utils.sleep(50);
-            await this.updateTableData();
 
 
-            this.initing = false;
+        this.inited = true;
+
+        (async() => {
+            this.showBar();
+            await this.updateTableData();
+            await this.scrollToActiveBook();
         })();
         })();
     }
     }
 
 
-    async updateTableData(limit) {
-        while (this.updating) await utils.sleep(100);
-        this.updating = true;
-        let result = [];
-
-        this.loading = !!limit;
-        const sorted = bookManager.getSortedRecent();
-
-        let num = 0;
-        for (let i = 0; i < sorted.length; i++) {
-            const book = sorted[i];
-            if (book.deleted)
-                continue;
-
-            num++;
-            if (limit && result.length >= limit)
-                break;
-
-            let d = new Date();
-            d.setTime(book.touchTime);
-            const t = utils.formatDate(d).split(' ');
-
-            let readPart = 0;
-            let perc = '';
-            let textLen = '';
-            const p = (book.bookPosSeen ? book.bookPosSeen : (book.bookPos ? book.bookPos : 0));
-            if (book.textLength) {
-                readPart = p/book.textLength;
-                perc = ` [${(readPart*100).toFixed(2)}%]`;
-                textLen = ` ${Math.round(book.textLength/1000)}k`;
+    loadSettings() {
+        const settings = this.settings;
+        this.showSameBook = settings.recentShowSameBook;
+        this.sortMethod = settings.recentSortMethod || 'loadTimeDesc';
+    }
+
+    get settings() {
+        return this.$store.state.reader.settings;
+    }
+
+    async updateTableData() {
+        if (!this.inited)
+            return;
+
+        await this.lock.get();
+        try {
+            let result = [];
+
+            const sorted = bookManager.getSortedRecent();
+            const activeBook = bookManager.mostRecentBook();
+
+            //подготовка полей
+            for (const book of sorted) {
+                if (book.deleted)
+                    continue;
+
+                let d = new Date();
+                d.setTime(book.touchTime);
+                const touchTime = utils.formatDate(d);
+                const loadTimeRaw = (book.loadTime ? book.loadTime : 0);//book.addTime);
+                d.setTime(loadTimeRaw);
+                const loadTime = utils.formatDate(d);
+
+                let readPart = 0;
+                let perc = '';
+                let textLen = '';
+                const p = (book.bookPosSeen ? book.bookPosSeen : (book.bookPos ? book.bookPos : 0));
+                if (book.textLength) {
+                    readPart = p/book.textLength;
+                    perc = `${(readPart*100).toFixed(2)}%`;
+                    textLen = `${Math.floor(readPart*book.textLength/1000)}/${Math.floor(book.textLength/1000)}`;
+                }
+
+                const bt = utils.getBookTitle(book.fb2);
+
+                let title = bt.bookTitle;
+                title = (title ? `"${title}"`: '');
+                const author = (bt.author ? bt.author : (bt.bookTitle ? bt.bookTitle : (book.uploadFileName ? book.uploadFileName : book.url)));
+
+                result.push({
+                    touchTime,
+                    loadTime,
+                    desc: {
+                        author,
+                        title,
+                        perc,
+                        textLen,
+                    },
+                    readPart,
+                    url: book.url,
+                    path: book.path,
+                    fullTitle: bt.fullTitle,
+                    key: book.key,
+                    sameBookKey: book.sameBookKey,
+                    active: (activeBook.key == book.key),
+                    activeParent: false,
+                    inGroup: false,
+
+                    //для сортировки
+                    loadTimeRaw,
+                    touchTimeRaw: book.touchTime,
+                });
             }
             }
 
 
-            const bt = utils.getBookTitle(book.fb2);
-
-            let title = bt.bookTitle;
-            title = (title ? `"${title}"`: '');
-            const author = (bt.author ? bt.author : (bt.bookTitle ? bt.bookTitle : book.url));
-
-            result.push({
-                num,
-                touchDateTime: book.touchTime,
-                touchDate: t[0],
-                touchTime: t[1],
-                desc: {
-                    author,
-                    title: `${title}${perc}${textLen}`,
-                },
-                readPart,
-                descString: `${author}${title}${perc}${textLen}`,//для сортировки
-                url: book.url,
-                path: book.path,
-                fullTitle: bt.fullTitle,
-                key: book.key,
-            });
-        }
+            //нумерация
+            let num = 0;
 
 
-        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())
-        });
+            result.sort((a, b) => b.loadTimeRaw - a.loadTimeRaw);
+            for (const book of result) {
+                num++;
+                book.num = num;
+            }
+
+            //фильтрация
+            const search = this.search;
+            if (search) {
+                result = result.filter(item => {
+                    return !search ||
+                        item.touchTime.includes(search) ||
+                        item.loadTime.includes(search) ||
+                        item.desc.title.toLowerCase().includes(search.toLowerCase()) ||
+                        item.desc.author.toLowerCase().includes(search.toLowerCase())
+                });
+            }
+
+            //сортировка
+            switch (this.sortMethod) {
+                case 'loadTimeDesc':
+                    result.sort((a, b) => b.loadTimeRaw - a.loadTimeRaw);
+                    break;
+                case 'loadTimeAsc':
+                    result.sort((a, b) => a.loadTimeRaw - b.loadTimeRaw);
+                    break;
+                case 'touchTimeDesc':
+                    result.sort((a, b) => b.touchTimeRaw - a.touchTimeRaw);
+                    break;
+                case 'touchTimeAsc':
+                    result.sort((a, b) => a.touchTimeRaw - b.touchTimeRaw);
+                    break;
+                case 'authorDesc':
+                    result.sort((a, b) => b.desc.author.localeCompare(a.desc.author));
+                    break;
+                case 'authorAsc':
+                    result.sort((a, b) => a.desc.author.localeCompare(b.desc.author));
+                    break;
+                case 'titleDesc':
+                    result.sort((a, b) => b.desc.title.localeCompare(a.desc.title));
+                    break;
+                case 'titleAsc':
+                    result.sort((a, b) => a.desc.title.localeCompare(b.desc.title));
+                    break;
+            }
+
+            //группировка
+            const groups = {};
+            const parents = {};
+            let newResult = [];
+            for (const book of result) {
+                if (book.sameBookKey !== undefined) {
+                    if (!groups[book.sameBookKey]) {
+                        groups[book.sameBookKey] = [];
+                        parents[book.sameBookKey] = book;
+
+                        book.group = groups[book.sameBookKey];
+                        newResult.push(book);
+                    } else {
+                        book.inGroup = true;
+                        if (book.active)
+                            parents[book.sameBookKey].activeParent = true;
+
+                        groups[book.sameBookKey].push(book);
+                    }
+                } else {
+                    newResult.push(book);
+                }
+            }
+            result = newResult;
+
+            //showSameBook
+            if (this.showSameBook) {
+                newResult = [];
+                for (const book of result) {
+                    newResult.push(book);
+                    if (book.group) {
+                        for (const sameBook of book.group) {
+                            newResult.push(sameBook);
+                        }
+                    }
+                }
+
+                result = newResult;
+            }
+
+            //другие стадии
+            //.....
 
 
-        this.tableData = result;
-        this.updating = false;
+            this.tableData = result;
+        } finally {
+            this.lock.ret();
+        }
     }
     }
 
 
     resetSearch() {
     resetSearch() {
@@ -266,19 +401,22 @@ class RecentBooksPage {
         this.$refs.input.focus();
         this.$refs.input.focus();
     }
     }
 
 
-    wordEnding(num) {
-        const endings = ['', 'а', 'и', 'и', 'и', '', '', '', '', ''];
+    wordEnding(num, type = 0) {
+        const endings = [
+            ['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],
+            ['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й']
+        ];
         const deci = num % 100;
         const deci = num % 100;
         if (deci > 10 && deci < 20) {
         if (deci > 10 && deci < 20) {
-            return '';
+            return endings[type][0];
         } else {
         } else {
-            return endings[num % 10];
+            return endings[type][num % 10];
         }
         }
     }
     }
 
 
     get header() {
     get header() {
         const len = (this.tableData ? this.tableData.length : 0);
         const len = (this.tableData ? this.tableData.length : 0);
-        return `${(this.search ? 'Найдено' : 'Всего')} ${len} книг${this.wordEnding(len)}`;
+        return `${(this.search ? 'Найдено' : 'Всего')} ${len} файл${this.wordEnding(len)}`;
     }
     }
 
 
     async downloadBook(fb2path, fullTitle) {
     async downloadBook(fb2path, fullTitle) {
@@ -311,8 +449,8 @@ class RecentBooksPage {
             this.close();
             this.close();
     }
     }
 
 
-    loadBook(url) {
-        this.$emit('load-book', {url});
+    loadBook(row) {
+        this.$emit('load-book', {url: row.url, path: row.path});
         this.close();
         this.close();
     }
     }
 
 
@@ -323,6 +461,111 @@ class RecentBooksPage {
             return false;
             return false;
     }
     }
 
 
+    showBar() {
+        this.lastScrollTop1 = this.$refs.vsContainer.scrollTop;
+        this.$refs.header.style.position = 'sticky';
+        this.$refs.header.style.top = 0;
+    }
+
+    onScroll() {
+        const curScrollTop = this.$refs.vsContainer.scrollTop;
+
+        if (this.lockScroll) {
+            this.lastScrollTop1 = curScrollTop;
+            return;
+        }
+
+        if (curScrollTop - this.lastScrollTop1 > 100) {
+            this.$refs.header.style.top = `-${this.$refs.header.offsetHeight}px`;
+            this.$refs.header.style.transition = 'top 0.2s ease 0s';
+
+            this.lastScrollTop1 = curScrollTop;
+        } else if (curScrollTop - this.lastScrollTop2 < 0) {
+            this.$refs.header.style.position = 'sticky';
+            this.$refs.header.style.top = 0;
+
+            this.lastScrollTop1 = curScrollTop;
+        }
+
+        this.lastScrollTop2 = curScrollTop;
+    }
+
+    showSameBookClick() {
+        this.showSameBook = !this.showSameBook;
+
+        const newSettings = _.cloneDeep(this.settings);
+        newSettings.recentShowSameBook = this.showSameBook;
+        this.commit('reader/setSettings', newSettings);
+
+        this.updateTableData();
+    }
+
+    sortMethodSelected() {
+        const newSettings = _.cloneDeep(this.settings);
+        newSettings.recentSortMethod = this.sortMethod;
+        this.commit('reader/setSettings', newSettings);
+    }
+
+    async scrollToActiveBook() {
+        this.lockScroll = true;
+        try {
+            let activeIndex = -1;
+            let activeParentIndex = -1;
+            for (let i = 0; i < this.tableData.length; i++) {
+                const book = this.tableData[i];
+                if (book.active)
+                    activeIndex = i;
+                if (book.activeParent)
+                    activeParentIndex = i;
+
+                if (activeIndex >= 0 && activeParentIndex >= 0)
+                    break;
+            }
+
+            const index = (activeIndex >= 0 ? activeIndex : activeParentIndex);
+            if (index >= 0) {
+                this.$refs.virtualScroll.scrollTo(index, 'center');
+            }
+        } finally {
+            await utils.sleep(100);
+            this.lockScroll = false;
+        }
+    }
+
+    async scrollToBegin() {
+        this.lockScroll = true;
+        try {
+            this.$refs.virtualScroll.scrollTo(0, 'center');
+        } finally {
+            await utils.sleep(100);
+            this.lockScroll = false;
+        }
+    }
+
+    async scrollToEnd() {
+        this.lockScroll = true;
+        try {
+            this.$refs.virtualScroll.scrollTo(this.tableData.length, 'center');
+        } finally {
+            await utils.sleep(100);
+            this.lockScroll = false;
+        }
+    }
+
+
+    get sortMethodOptions() {
+        return [
+            {label: '<span style="font-size: 150%">&uarr;</span> Время загрузки', value: 'loadTimeDesc'},
+            {label: '<span style="font-size: 150%">&darr;</span> Время загрузки', value: 'loadTimeAsc'},
+            {label: '<span style="font-size: 150%">&uarr;</span> Время чтения', value: 'touchTimeDesc'},
+            {label: '<span style="font-size: 150%">&darr;</span> Время чтения', value: 'touchTimeAsc'},
+            {label: '<span style="font-size: 150%">&uarr;</span> Автор', value: 'authorDesc'},
+            {label: '<span style="font-size: 150%">&darr;</span> Автор', value: 'authorAsc'},
+            {label: '<span style="font-size: 150%">&uarr;</span> Название', value: 'titleDesc'},
+            {label: '<span style="font-size: 150%">&darr;</span> Название', value: 'titleAsc'},
+        ];
+    }
+
     close() {
     close() {
         this.$emit('recent-books-close');
         this.$emit('recent-books-close');
     }
     }
@@ -340,27 +583,32 @@ export default vueComponent(RecentBooksPage);
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.recent-books-table {
-    width: 600px;
+.recent-books-scroll {
+    width: 573px;
     overflow-y: auto;
     overflow-y: auto;
     overflow-x: hidden;
     overflow-x: hidden;
 }
 }
 
 
-.clickable {
-    cursor: pointer;
+.scroll-header {
+    height: 50px;
+    position: sticky;
+    z-index: 1;
+    top: 0;
+    border-bottom: 2px solid #aaaaaa;
+    padding-left: 5px;
 }
 }
 
 
-.td-mp {
-    margin: 0 !important;
-    padding: 4px 4px 4px 4px !important;
-    border-bottom: 1px solid #ddd;
+.table-row {
+    min-height: 80px;
+    border-bottom: 1px solid #cccccc;
 }
 }
 
 
-.no-mp {
-    margin: 0 !important;
-    padding: 0 !important;
-    border: 0;
-    border-left: 1px solid #ddd !important;
+.row-part {
+    padding: 4px 4px 4px 4px;
+}
+
+.clickable {
+    cursor: pointer;
 }
 }
 
 
 .break-word {
 .break-word {
@@ -373,22 +621,47 @@ export default vueComponent(RecentBooksPage);
 .read-bar {
 .read-bar {
     height: 3px;
     height: 3px;
     background-color: #aaaaaa;
     background-color: #aaaaaa;
+    margin-bottom: 2px;
 }
 }
-</style>
 
 
-<style>
-.recent-books-table .q-table__middle {
-    height: 100%;
-    overflow-x: hidden;
+.even {
+    background-color: #f2f2f2;
 }
 }
 
 
-.recent-books-table thead tr:first-child th {
-    position: sticky;
-    z-index: 1;
-    top: 0;
-    background-color: #c1f4cd;
+.active-book {
+    background-color: #b0f0b0 !important;
+}
+
+.active-parent-book {
+    background-color: #ffbbbb !important;
 }
 }
-.recent-books-table tr:nth-child(even) {
-    background-color: #f8f8f8;
+
+.icon {
+    transition: transform 0.2s;
+}
+
+.expanded-icon {
+    transform: rotate(90deg);
+}
+
+.row-info-top {
+    line-height: 110%;
+    border-left: 1px solid #cccccc;
+    border-bottom: 1px solid #cccccc;
+}
+
+.row-info-bottom {
+    line-height: 110%;
+    border: 1px solid #cccccc;
+    border-right: 0;
+}
+
+.tool-button {
+    min-width: 30px;
+    width: 30px;
+    min-height: 30px;
+    height: 30px;
+    margin: 10px 6px 0px 3px;
+    background-color: white;
 }
 }
 </style>
 </style>

+ 4 - 6
client/components/Reader/SearchPage/SearchPage.vue

@@ -8,12 +8,10 @@
             <span v-show="initStep">{{ initPercentage }}%</span>
             <span v-show="initStep">{{ initPercentage }}%</span>
 
 
             <div v-show="!initStep" class="input">
             <div v-show="!initStep" class="input">
-                <!--input ref="input"
-                    placeholder="что ищем"
-                    :value="needle" @input="needle = $event.target.value"/-->
-                <q-input ref="input" v-model="needle"
+                <q-input
+                    ref="input" v-model="needle"
                     class="col" outlined dense
                     class="col" outlined dense
-                    placeholder="что ищем"
+                    placeholder="Найти"
                     @keydown="inputKeyDown"         
                     @keydown="inputKeyDown"         
                 />
                 />
                 <div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">
                 <div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">
@@ -108,7 +106,7 @@ class SearchPage {
             this.parsed = parsed;
             this.parsed = parsed;
         }
         }
 
 
-        this.header = 'Найти';
+        this.header = 'Поиск в тексте';
         await this.$nextTick();
         await this.$nextTick();
         this.$refs.input.focus();
         this.$refs.input.focus();
         this.$refs.input.select();
         this.$refs.input.select();

+ 0 - 9
client/components/Reader/SettingsPage/ButtonsTab.inc

@@ -1,9 +0,0 @@
-<div class="part-header">Показывать кнопки панели</div>
-
-<div class="item row" v-for="item in toolButtons" :key="item.name" v-show="item.name != 'libs' || mode == 'liberama.top'">
-    <div class="label-3"></div>
-    <div class="col row">
-        <q-checkbox size="xs" v-model="showToolButton[item.name]" :label="rstore.readerActions[item.name]"
-        />
-    </div>
-</div>

+ 6 - 6
client/components/Reader/SettingsPage/SettingsPage.vue

@@ -1,5 +1,5 @@
 <template>
 <template>
-    <Window ref="window" height="95%" width="600px" @close="close">
+    <Window ref="window" width="600px" @close="close">
         <template #header>
         <template #header>
             Настройки
             Настройки
         </template>
         </template>
@@ -24,7 +24,7 @@
                     <div v-show="tabsScrollable" class="q-pt-lg" />
                     <div v-show="tabsScrollable" class="q-pt-lg" />
                     <q-tab class="tab" name="profiles" icon="la la-users" label="Профили" />
                     <q-tab class="tab" name="profiles" icon="la la-users" label="Профили" />
                     <q-tab class="tab" name="view" icon="la la-eye" label="Вид" />
                     <q-tab class="tab" name="view" icon="la la-eye" label="Вид" />
-                    <q-tab class="tab" name="buttons" icon="la la-grip-horizontal" label="Кнопки" />
+                    <q-tab class="tab" name="toolbar" icon="la la-grip-horizontal" label="Панель" />
                     <q-tab class="tab" name="keys" icon="la la-gamepad" label="Управление" />
                     <q-tab class="tab" name="keys" icon="la la-gamepad" label="Управление" />
                     <q-tab class="tab" name="pagemove" icon="la la-school" label="Листание" />
                     <q-tab class="tab" name="pagemove" icon="la la-school" label="Листание" />
                     <q-tab class="tab" name="convert" icon="la la-magic" label="Конвертир." />
                     <q-tab class="tab" name="convert" icon="la la-magic" label="Конвертир." />
@@ -82,8 +82,8 @@
                     </div>
                     </div>
                 </div>
                 </div>
                 <!-- Кнопки ---------------------------------------------------------------------->
                 <!-- Кнопки ---------------------------------------------------------------------->
-                <div v-if="selectedTab == 'buttons'" class="fit tab-panel">
-                    @@include('./ButtonsTab.inc');
+                <div v-if="selectedTab == 'toolbar'" class="fit tab-panel">
+                    @@include('./ToolBarTab.inc');
                 </div>
                 </div>
                 <!-- Управление ------------------------------------------------------------------>
                 <!-- Управление ------------------------------------------------------------------>
                 <div v-if="selectedTab == 'keys'" class="fit column">
                 <div v-if="selectedTab == 'keys'" class="fit column">
@@ -702,11 +702,11 @@ export default vueComponent(SettingsPage);
     margin-bottom: 5px;
     margin-bottom: 5px;
 }
 }
 
 
-.label-1, .label-7 {
+.label-1, .label-3, .label-7 {
     width: 75px;
     width: 75px;
 }
 }
 
 
-.label-2, .label-3, .label-4, .label-5 {
+.label-2, .label-4, .label-5 {
     width: 110px;
     width: 110px;
 }
 }
 
 

+ 18 - 0
client/components/Reader/SettingsPage/ToolBarTab.inc

@@ -0,0 +1,18 @@
+<div class="part-header">Отображение</div>
+
+<div class="item row no-wrap">
+    <div class="label-3"></div>
+    <q-checkbox size="xs" v-model="toolBarHideOnScroll" label="Скрывать/показывать панель при прокрутке" >
+        <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+            Скрывать/показывть панель при прокрутке текста вперед/назад
+        </q-tooltip>
+    </q-checkbox>
+</div>
+
+<div class="part-header">Показывать кнопки</div>
+
+<div class="item row no-wrap" v-for="item in toolButtons" :key="item.name" v-show="item.name != 'libs' || mode == 'liberama.top'">
+    <div class="label-3"></div>
+        <q-checkbox size="xs" v-model="showToolButton[item.name]" :label="rstore.readerActions[item.name]"
+        />
+</div>

+ 1 - 1
client/components/Reader/SettingsPage/UserHotKeys/UserHotKeys.vue

@@ -13,7 +13,7 @@
                     ref="input"
                     ref="input"
                     v-model="search"
                     v-model="search"
                     class="q-ml-sm col"
                     class="q-ml-sm col"
-                    outlined dense rounded
+                    outlined dense
                     bg-color="grey-4"
                     bg-color="grey-4"
                     placeholder="Найти"                    
                     placeholder="Найти"                    
                     @click.stop
                     @click.stop

+ 32 - 4
client/components/Reader/TextPage/TextPage.vue

@@ -66,7 +66,14 @@ const componentOptions = {
     watch: {
     watch: {
         bookPos: function() {
         bookPos: function() {
             this.$emit('book-pos-changed', {bookPos: this.bookPos, bookPosSeen: this.bookPosSeen});
             this.$emit('book-pos-changed', {bookPos: this.bookPos, bookPosSeen: this.bookPosSeen});
+
             this.draw();
             this.draw();
+
+            if (this.userBookPosChange) {
+                this.$emit('hide-tool-bar', {show: (this.bookPos == 0 || this.bookPos < this.prevBookPos)});
+                this.prevBookPos = this.bookPos;
+                this.userBookPosChange = false;
+            }
         },
         },
         bookPosSeen: function() {
         bookPosSeen: function() {
             this.$emit('book-pos-changed', {bookPos: this.bookPos, bookPosSeen: this.bookPosSeen});
             this.$emit('book-pos-changed', {bookPos: this.bookPos, bookPosSeen: this.bookPosSeen});
@@ -99,6 +106,8 @@ class TextPage {
     lastBook = null;
     lastBook = null;
     bookPos = 0;
     bookPos = 0;
     bookPosSeen = null;
     bookPosSeen = null;
+    prevBookPos = 0;
+    userBookPosChange = false;
 
 
     fontStyle = null;
     fontStyle = null;
     fontSize = null;
     fontSize = null;
@@ -155,7 +164,7 @@ class TextPage {
 
 
         this.$root.addEventHook('resize', async() => {
         this.$root.addEventHook('resize', async() => {
             this.$nextTick(this.onResize);
             this.$nextTick(this.onResize);
-            await utils.sleep(500);
+            await utils.sleep(200);
             this.$nextTick(this.onResize);
             this.$nextTick(this.onResize);
         });
         });
     }
     }
@@ -499,12 +508,25 @@ class TextPage {
     }
     }
 
 
     async onResize() {
     async onResize() {
+        if (this.resizing)
+            return;
+        
+        this.resizing = true;
         try {
         try {
+            const scrolled = this.doingScrolling;
+            if (scrolled)
+                await this.stopTextScrolling();
+
             this.calcDrawProps();
             this.calcDrawProps();
             this.setBackground();
             this.setBackground();
             this.draw();
             this.draw();
+
+            if (scrolled)
+                this.startTextScrolling();
         } catch (e) {
         } catch (e) {
             //
             //
+        } finally {
+            this.resizing = false;
         }
         }
     }
     }
 
 
@@ -652,7 +674,7 @@ class TextPage {
         }
         }
 
 
         if (this.book && this.bookPos > 0 && this.bookPos >= this.parsed.textLength) {
         if (this.book && this.bookPos > 0 && this.bookPos >= this.parsed.textLength) {
-            this.doEnd(true);
+            this.doEnd(true, false);
             return;
             return;
         }
         }
 
 
@@ -675,7 +697,7 @@ class TextPage {
         this.debouncedDrawPageDividerAndOrnament();
         this.debouncedDrawPageDividerAndOrnament();
 
 
         if (this.book && this.linesDown && this.linesDown.length < this.pageLineCount) {
         if (this.book && this.linesDown && this.linesDown.length < this.pageLineCount) {
-            this.doEnd(true);
+            this.doEnd(true, false);
             return;
             return;
         }
         }
     }
     }
@@ -911,12 +933,14 @@ class TextPage {
 
 
     doDown() {
     doDown() {
         if (this.linesDown && this.linesDown.length > this.pageLineCount && this.pageLineCount > 0) {
         if (this.linesDown && this.linesDown.length > this.pageLineCount && this.pageLineCount > 0) {
+            this.userBookPosChange = true;
             this.bookPos = this.linesDown[1].begin;
             this.bookPos = this.linesDown[1].begin;
         }
         }
     }
     }
 
 
     doUp() {
     doUp() {
         if (this.linesUp && this.linesUp.length > 1 && this.pageLineCount > 0) {
         if (this.linesUp && this.linesUp.length > 1 && this.pageLineCount > 0) {
+            this.userBookPosChange = true;
             this.bookPos = this.linesUp[1].begin;
             this.bookPos = this.linesUp[1].begin;
         }
         }
     }
     }
@@ -929,6 +953,7 @@ class TextPage {
             if (i >= 0 && this.linesDown.length >= 2*i + (this.keepLastToFirst ? 1 : 0)) {
             if (i >= 0 && this.linesDown.length >= 2*i + (this.keepLastToFirst ? 1 : 0)) {
                 this.currentAnimation = this.pageChangeAnimation;
                 this.currentAnimation = this.pageChangeAnimation;
                 this.pageChangeDirectionDown = true;
                 this.pageChangeDirectionDown = true;
+                this.userBookPosChange = true;
                 this.bookPos = this.linesDown[i].begin;
                 this.bookPos = this.linesDown[i].begin;
             } else 
             } else 
                 this.doEnd();
                 this.doEnd();
@@ -944,6 +969,7 @@ class TextPage {
             if (i >= 0 && this.linesUp.length > i) {
             if (i >= 0 && this.linesUp.length > i) {
                 this.currentAnimation = this.pageChangeAnimation;
                 this.currentAnimation = this.pageChangeAnimation;
                 this.pageChangeDirectionDown = false;
                 this.pageChangeDirectionDown = false;
+                this.userBookPosChange = true;
                 this.bookPos = this.linesUp[i].begin;
                 this.bookPos = this.linesUp[i].begin;
             }
             }
         }
         }
@@ -952,10 +978,11 @@ class TextPage {
     doHome() {
     doHome() {
         this.currentAnimation = this.pageChangeAnimation;
         this.currentAnimation = this.pageChangeAnimation;
         this.pageChangeDirectionDown = false;
         this.pageChangeDirectionDown = false;
+        this.userBookPosChange = true;
         this.bookPos = 0;
         this.bookPos = 0;
     }
     }
 
 
-    doEnd(noAni) {
+    doEnd(noAni, isUser = true) {
         if (this.parsed.para.length && this.pageLineCount > 0) {
         if (this.parsed.para.length && this.pageLineCount > 0) {
             let i = this.parsed.para.length - 1;
             let i = this.parsed.para.length - 1;
             let lastPos = this.parsed.para[i].offset + this.parsed.para[i].length - 1;
             let lastPos = this.parsed.para[i].offset + this.parsed.para[i].length - 1;
@@ -966,6 +993,7 @@ class TextPage {
                 if (!noAni)
                 if (!noAni)
                     this.currentAnimation = this.pageChangeAnimation;
                     this.currentAnimation = this.pageChangeAnimation;
                 this.pageChangeDirectionDown = true;
                 this.pageChangeDirectionDown = true;
+                this.userBookPosChange = isUser;
                 this.bookPos = lines[i].begin;
                 this.bookPos = lines[i].begin;
             }
             }
         }
         }

+ 103 - 18
client/components/Reader/share/bookManager.js

@@ -1,10 +1,12 @@
 import localForage from 'localforage';
 import localForage from 'localforage';
+import path from 'path-browserify';
 import _ from 'lodash';
 import _ from 'lodash';
 
 
 import * as utils from '../../../share/utils';
 import * as utils from '../../../share/utils';
 import BookParser from './BookParser';
 import BookParser from './BookParser';
 
 
 const maxDataSize = 500*1024*1024;//compressed bytes
 const maxDataSize = 500*1024*1024;//compressed bytes
+const maxRecentLength = 5000;
 
 
 //локальный кэш метаданных книг, ограничение maxDataSize
 //локальный кэш метаданных книг, ограничение maxDataSize
 const bmMetaStore = localForage.createInstance({
 const bmMetaStore = localForage.createInstance({
@@ -17,9 +19,6 @@ const bmDataStore = localForage.createInstance({
 });
 });
 
 
 //список недавно открытых книг
 //список недавно открытых книг
-const bmRecentStoreOld = localForage.createInstance({
-    name: 'bmRecentStore'
-});
 const bmRecentStoreNew = localForage.createInstance({
 const bmRecentStoreNew = localForage.createInstance({
     name: 'bmRecentStoreNew'
     name: 'bmRecentStoreNew'
 });
 });
@@ -39,7 +38,7 @@ class BookManager {
 
 
         this.saveRecentItem = _.debounce(() => {
         this.saveRecentItem = _.debounce(() => {
             bmRecentStoreNew.setItem('recent-item', this.recentItem);
             bmRecentStoreNew.setItem('recent-item', this.recentItem);
-            this.recentRev = (this.recentRev < 1000 ? this.recentRev + 1 : 1);
+            this.recentRev = (this.recentRev < maxRecentLength ? this.recentRev + 1 : 1);
             bmRecentStoreNew.setItem('rev', this.recentRev);
             bmRecentStoreNew.setItem('rev', this.recentRev);
         }, 200, {maxWait: 300});
         }, 200, {maxWait: 300});
 
 
@@ -54,6 +53,9 @@ class BookManager {
             if (this.recentItem)
             if (this.recentItem)
                 this.recent[this.recentItem.key] = this.recentItem;
                 this.recent[this.recentItem.key] = this.recentItem;
 
 
+            //конвертируем в новые ключи
+            await this.convertRecent();
+
             this.recentLastKey = await bmRecentStoreNew.getItem('recent-last-key');
             this.recentLastKey = await bmRecentStoreNew.getItem('recent-last-key');
             if (this.recentLastKey) {
             if (this.recentLastKey) {
                 const meta = await bmMetaStore.getItem(`bmMeta-${this.recentLastKey}`);
                 const meta = await bmMetaStore.getItem(`bmMeta-${this.recentLastKey}`);
@@ -70,6 +72,40 @@ class BookManager {
         this.loadStored();//no await
         this.loadStored();//no await
     }
     }
 
 
+    //TODO: убрать в 2025г
+    async convertRecent() {
+        const converted = await bmRecentStoreNew.getItem('recent-converted');
+
+        if (converted)
+            return;
+
+        const newRecent = {};
+        for (const book of Object.values(this.recent)) {
+
+            if (!book.path) {
+                continue;
+            }
+
+            const newKey = this.keyFromPath(book.path);
+
+            newRecent[newKey] = _.cloneDeep(book);
+            newRecent[newKey].key = newKey;
+            if (!newRecent[newKey].loadTime)
+                newRecent[newKey].loadTime = newRecent[newKey].addTime;
+        }
+
+        this.recent = newRecent;
+
+        //console.log(converted);
+        (async() => {
+            await utils.sleep(3000);
+            this.saveRecent();
+            this.emit('recent-changed');
+            this.emit('set-recent');
+            await bmRecentStoreNew.setItem('recent-converted', true);
+        })();
+    }
+
     //Ленивая асинхронная загрузка bmMetaStore
     //Ленивая асинхронная загрузка bmMetaStore
     async loadStored() {
     async loadStored() {
         //даем время для загрузки последней читаемой книги, чтобы не блокировать приложение
         //даем время для загрузки последней читаемой книги, чтобы не блокировать приложение
@@ -196,8 +232,8 @@ class BookManager {
 
 
     async addBook(newBook, callback) {        
     async addBook(newBook, callback) {        
         let meta = {url: newBook.url, path: newBook.path};
         let meta = {url: newBook.url, path: newBook.path};
-        meta.key = this.keyFromUrl(meta.url);
-        meta.addTime = Date.now();
+        meta.key = this.keyFromPath(meta.path);
+        meta.addTime = Date.now();//время добавления в кеш
 
 
         const cb = (perc) => {
         const cb = (perc) => {
             const p = Math.round(30*perc/100);
             const p = Math.round(30*perc/100);
@@ -232,10 +268,10 @@ class BookManager {
     async hasBookParsed(meta) {
     async hasBookParsed(meta) {
         if (!this.books) 
         if (!this.books) 
             return false;
             return false;
-        if (!meta.url)
+        if (!meta.path)
             return false;
             return false;
         if (!meta.key)
         if (!meta.key)
-            meta.key = this.keyFromUrl(meta.url);
+            meta.key = this.keyFromPath(meta.path);
 
 
         let book = this.books[meta.key];
         let book = this.books[meta.key];
 
 
@@ -250,8 +286,12 @@ class BookManager {
 
 
     async getBook(meta, callback) {
     async getBook(meta, callback) {
         let result = undefined;
         let result = undefined;
+
+        if (!meta.path)
+            return;
+
         if (!meta.key)
         if (!meta.key)
-            meta.key = this.keyFromUrl(meta.url);
+            meta.key = this.keyFromPath(meta.path);
 
 
         result = this.books[meta.key];
         result = this.books[meta.key];
 
 
@@ -261,11 +301,6 @@ class BookManager {
                 this.books[meta.key] = result;
                 this.books[meta.key] = result;
         }
         }
 
 
-        //Если файл на сервере изменился, считаем, что в кеше его нету
-        if (meta.path && result && meta.path != result.path) {
-            return;
-        }
-
         if (result && !result.parsed) {
         if (result && !result.parsed) {
             let data = await bmDataStore.getItem(`bmData-${meta.key}`);
             let data = await bmDataStore.getItem(`bmData-${meta.key}`);
             callback(5);
             callback(5);
@@ -325,10 +360,20 @@ class BookManager {
         return result;
         return result;
     }
     }
 
 
-    keyFromUrl(url) {
+    /*keyFromUrl(url) {
         return utils.stringToHex(url);
         return utils.stringToHex(url);
+    }*/
+
+    keyFromPath(bookPath) {
+        return path.basename(bookPath);
     }
     }
 
 
+    keysEqual(bookPath1, bookPath2) {
+        if (bookPath1 === undefined || bookPath2 === undefined)
+            return false;
+        
+        return (this.keyFromPath(bookPath1) === this.keyFromPath(bookPath2));
+    }
     //-- recent --------------------------------------------------------------
     //-- recent --------------------------------------------------------------
     async recentSetItem(item = null, skipCheck = false) {
     async recentSetItem(item = null, skipCheck = false) {
         const rev = await bmRecentStoreNew.getItem('rev');
         const rev = await bmRecentStoreNew.getItem('rev');
@@ -369,7 +414,10 @@ class BookManager {
 
 
     async setRecentBook(value) {
     async setRecentBook(value) {
         let result = this.metaOnly(value);
         let result = this.metaOnly(value);
-        result.touchTime = Date.now();
+        result.touchTime = Date.now();//время последнего чтения
+        if (!result.loadTime)
+            result.loadTime = Date.now();//время загрузки файла
+
         result.deleted = 0;
         result.deleted = 0;
 
 
         if (this.recent[result.key]) {
         if (this.recent[result.key]) {
@@ -401,7 +449,7 @@ class BookManager {
         const sorted = this.getSortedRecent();
         const sorted = this.getSortedRecent();
 
 
         let isDel = false;
         let isDel = false;
-        for (let i = 1000; i < sorted.length; i++) {
+        for (let i = maxRecentLength; i < sorted.length; i++) {
             delete this.recent[sorted[i].key];
             delete this.recent[sorted[i].key];
             isDel = true;
             isDel = true;
         }
         }
@@ -421,7 +469,7 @@ class BookManager {
 
 
         let max = 0;
         let max = 0;
         let result = null;
         let result = null;
-        for (let key in this.recent) {
+        for (const key in this.recent) {
             const book = this.recent[key];
             const book = this.recent[key];
             if (!book.deleted && book.touchTime > max) {
             if (!book.deleted && book.touchTime > max) {
                 max = book.touchTime;
                 max = book.touchTime;
@@ -452,6 +500,43 @@ class BookManager {
         return result;
         return result;
     }
     }
 
 
+    findRecentByUrlAndPath(url, bookPath) {
+        if (bookPath) {
+            const key = this.keyFromPath(bookPath);
+            const book = this.recent[key];
+            if (book && !book.deleted)
+                return book;
+        }
+
+        let max = 0;
+        let result = null;
+
+        for (const key in this.recent) {
+            const book = this.recent[key];
+            if (!book.deleted && book.url == url && book.loadTime > max) {
+                max = book.loadTime;
+                result = book;
+            }
+        }
+
+        return result;
+    }
+
+    findRecentBySameBookKey(sameKey) {
+        let max = 0;
+        let result = null;
+
+        for (const key in this.recent) {
+            const book = this.recent[key];
+            if (!book.deleted && book.sameBookKey == sameKey && book.loadTime > max) {
+                max = book.loadTime;
+                result = book;
+            }
+        }
+
+        return result;
+    }
+
     async setRecent(value) {
     async setRecent(value) {
         const mergedRecent = _.cloneDeep(this.recent);
         const mergedRecent = _.cloneDeep(this.recent);
 
 

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

@@ -1,4 +1,26 @@
 export const versionHistory = [
 export const versionHistory = [
+{
+    version: '0.11.7',
+    releaseDate: '2022-07-12',
+    showUntil: '2022-07-19',
+    content:
+`
+<ul>
+    <li>добавлено автосокрытие панели управления при листании, отключается в настройках</li>
+    <li>изменения в окне загруженных книг:</li>
+        <ul>
+            <li>добавлена группировка по версиям файла одной и той же книги</li>
+            <li>группировка происходит по имени загружаемого файла, либо по URL книги</li>
+            <li>добавлены различные методы сортировки списка загруженных книг</li>
+            <li>нумерация всегда осуществляется по времени загрузки</li>
+        </ul>
+    <li>незначительные общие изменения интерфейса, приведение к единому стилю</li>
+    <li>исправления багов</li>
+</ul>
+
+`
+},
+
 {
 {
     version: '0.11.6',
     version: '0.11.6',
     releaseDate: '2022-07-02',
     releaseDate: '2022-07-02',

+ 45 - 0
client/components/share/StdDialog.vue

@@ -55,6 +55,34 @@
             </div>
             </div>
         </div>
         </div>
 
 
+        <!--------------------------------------------------->
+        <div v-show="type == 'askYesNo'" class="bg-white no-wrap">
+            <div class="header row">
+                <div class="caption col row items-center q-ml-md">
+                    <q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
+                    <div v-html="caption"></div>
+                </div>
+                <div class="close-icon column justify-center items-center">
+                    <q-btn v-close-popup flat round dense>
+                        <q-icon name="la la-times" size="18px"></q-icon>
+                    </q-btn>
+                </div>
+            </div>
+
+            <div class="q-mx-md">
+                <div v-html="message"></div>
+            </div>
+
+            <div class="buttons row justify-end q-pa-md">
+                <q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
+                    Нет
+                </q-btn>
+                <q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
+                    Да
+                </q-btn>
+            </div>
+        </div>
+
         <!--------------------------------------------------->
         <!--------------------------------------------------->
         <div v-show="type == 'prompt'" class="bg-white no-wrap">
         <div v-show="type == 'prompt'" class="bg-white no-wrap">
             <div class="header row">
             <div class="header row">
@@ -262,6 +290,23 @@ class StdDialog {
         });
         });
     }
     }
 
 
+    askYesNo(message, caption, opts) {
+        return new Promise((resolve) => {
+            this.init(message, caption, opts);
+
+            this.hideTrigger = () => {
+                if (this.ok) {
+                    resolve(true);
+                } else {
+                    resolve(false);
+                }
+            };
+
+            this.type = 'askYesNo';
+            this.active = true;
+        });
+    }
+
     prompt(message, caption, opts) {
     prompt(message, caption, opts) {
         return new Promise((resolve) => {
         return new Promise((resolve) => {
             this.enableValidator = false;
             this.enableValidator = false;

+ 6 - 5
client/components/share/Window.vue

@@ -153,7 +153,7 @@ export default vueComponent(Window);
 }
 }
 
 
 .header {
 .header {
-    background: linear-gradient(to bottom right, green, #59B04F);
+    background: linear-gradient(to bottom right, #007000, #59B04F);
     align-items: center;
     align-items: center;
     height: 30px;
     height: 30px;
 }
 }
@@ -161,8 +161,8 @@ export default vueComponent(Window);
 .header-text {
 .header-text {
     margin-left: 10px;
     margin-left: 10px;
     margin-right: 10px;
     margin-right: 10px;
-    color: yellow;
-    text-shadow: 2px 1px 5px black, 2px 2px 5px black;
+    color: #FFFFA0;
+    text-shadow: 2px 2px 5px #005000, 2px 1px 5px #005000;
     overflow: hidden;
     overflow: hidden;
     white-space: nowrap;
     white-space: nowrap;
 }
 }
@@ -174,7 +174,8 @@ export default vueComponent(Window);
 }
 }
 
 
 .close-button:hover {
 .close-button:hover {
-    background-color: #69C05F;
+    color: white;
+    background-color: #FF3030;
 }
 }
 
 
-</style>
+</style>

+ 3 - 0
client/quasar.js

@@ -32,6 +32,8 @@ import {QPopupProxy} from 'quasar/src/components/popup-proxy';
 import {QDialog} from 'quasar/src/components/dialog';
 import {QDialog} from 'quasar/src/components/dialog';
 import {QChip} from 'quasar/src/components/chip';
 import {QChip} from 'quasar/src/components/chip';
 import {QTree} from 'quasar/src/components/tree';
 import {QTree} from 'quasar/src/components/tree';
+import {QVirtualScroll} from 'quasar/src/components/virtual-scroll';
+
 //import {QExpansionItem} from 'quasar/src/components/expansion-item';
 //import {QExpansionItem} from 'quasar/src/components/expansion-item';
 
 
 const components = {
 const components = {
@@ -62,6 +64,7 @@ const components = {
     QChip,
     QChip,
     QTree,
     QTree,
     //QExpansionItem,
     //QExpansionItem,
+    QVirtualScroll,
 };
 };
 
 
 //directives 
 //directives 

+ 53 - 0
client/share/LockQueue.js

@@ -0,0 +1,53 @@
+class LockQueue {
+    constructor(queueSize) {
+        this.queueSize = queueSize;
+        this.freed = true;
+        this.waitingQueue = [];
+    }
+
+    //async
+    get(take = true) {
+        return new Promise((resolve, reject) => {
+            if (this.freed) {
+                if (take)
+                    this.freed = false;
+                resolve();
+                return;
+            }
+
+            if (this.waitingQueue.length < this.queueSize) {
+                this.waitingQueue.push({resolve, reject});
+            } else {
+                reject(new Error('Lock queue is too long'));
+            }
+        });
+    }
+
+    ret() {
+        if (this.waitingQueue.length) {
+            this.waitingQueue.shift().resolve();
+        } else {
+            this.freed = true;
+        }
+    }
+
+    //async
+    wait() {
+        return this.get(false);
+    }
+
+    retAll() {
+        while (this.waitingQueue.length) {
+            this.waitingQueue.shift().resolve();
+        }
+    }
+
+    errAll(error = 'rejected') {
+        while (this.waitingQueue.length) {
+            this.waitingQueue.shift().reject(new Error(error));
+        }
+    }
+
+}
+
+export default LockQueue;

+ 5 - 1
client/store/modules/reader.js

@@ -21,7 +21,7 @@ const readerActions = {
     'offlineMode': 'Автономный режим (без интернета)',
     'offlineMode': 'Автономный режим (без интернета)',
     'contents': 'Оглавление/закладки',
     'contents': 'Оглавление/закладки',
     'libs': 'Сетевая библиотека',
     'libs': 'Сетевая библиотека',
-    'recentBooks': 'Открыть недавние',
+    'recentBooks': 'Показать загруженные',
     'switchToolbar': 'Показать/скрыть панель управления',
     'switchToolbar': 'Показать/скрыть панель управления',
     'donate': '',
     'donate': '',
     'bookBegin': 'В начало книги',
     'bookBegin': 'В начало книги',
@@ -185,8 +185,12 @@ const settingDefaults = {
 
 
     fontShifts: {},
     fontShifts: {},
     showToolButton: {},
     showToolButton: {},
+    toolBarHideOnScroll: true,
     userHotKeys: {},
     userHotKeys: {},
     userWallpapers: [],
     userWallpapers: [],
+
+    recentShowSameBook: false,
+    recentSortMethod: '',
 };
 };
 
 
 for (const font of fonts)
 for (const font of fonts)

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "Liberama",
   "name": "Liberama",
-  "version": "0.11.6",
+  "version": "0.11.7",
   "lockfileVersion": 2,
   "lockfileVersion": 2,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "Liberama",
       "name": "Liberama",
-      "version": "0.11.6",
+      "version": "0.11.7",
       "hasInstallScript": true,
       "hasInstallScript": true,
       "license": "CC0-1.0",
       "license": "CC0-1.0",
       "dependencies": {
       "dependencies": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "Liberama",
   "name": "Liberama",
-  "version": "0.11.6",
+  "version": "0.11.7",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "license": "CC0-1.0",
   "license": "CC0-1.0",
   "repository": "bookpauk/liberama",
   "repository": "bookpauk/liberama",

+ 1 - 1
server/core/Reader/BookConverter/textUtils.js

@@ -3,7 +3,7 @@ const chardet = require('chardet');
 function getEncoding(buf) {
 function getEncoding(buf) {
     let selected = getEncodingLite(buf);
     let selected = getEncodingLite(buf);
 
 
-    if (selected == 'ISO-8859-5') {
+    if (selected == 'ISO-8859-5' && buf.length > 10) {
         const charsetAll = chardet.analyse(buf.slice(0, 20000));
         const charsetAll = chardet.analyse(buf.slice(0, 20000));
         for (const charset of charsetAll) {
         for (const charset of charsetAll) {
             if (charset.name.indexOf('ISO-8859') < 0) {
             if (charset.name.indexOf('ISO-8859') < 0) {