Procházet zdrojové kódy

Merge branch 'release/0.10.0'

Book Pauk před 4 roky
rodič
revize
2b4b9f24a1
40 změnil soubory, kde provedl 1385 přidání a 785 odebrání
  1. 1 2
      client/api/misc.js
  2. 3 6
      client/api/reader.js
  3. 1 183
      client/api/webSocketConnection.js
  4. 8 0
      client/components/App.vue
  5. 7 7
      client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue
  6. binární
      client/components/Reader/HelpPage/DonateHelpPage/assets/yandex.png
  7. binární
      client/components/Reader/HelpPage/DonateHelpPage/assets/yoomoney.png
  8. 1 1
      client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue
  9. 13 1
      client/components/Reader/Reader.vue
  10. 20 4
      client/components/Reader/RecentBooksPage/RecentBooksPage.vue
  11. 108 7
      client/components/Reader/SettingsPage/SettingsPage.vue
  12. 5 0
      client/components/Reader/SettingsPage/include/ViewTab.inc
  13. 43 6
      client/components/Reader/SettingsPage/include/ViewTab/Color.inc
  14. 124 0
      client/components/Reader/SettingsPage/include/ViewTab/Mode.inc
  15. 36 8
      client/components/Reader/SettingsPage/include/ViewTab/Status.inc
  16. 1 18
      client/components/Reader/SettingsPage/include/ViewTab/Text.inc
  17. 151 117
      client/components/Reader/TextPage/DrawHelper.js
  18. 93 0
      client/components/Reader/TextPage/TextPage.css
  19. 145 107
      client/components/Reader/TextPage/TextPage.vue
  20. binární
      client/components/Reader/TextPage/images/paper10.png
  21. binární
      client/components/Reader/TextPage/images/paper11.png
  22. binární
      client/components/Reader/TextPage/images/paper12.png
  23. binární
      client/components/Reader/TextPage/images/paper13.png
  24. binární
      client/components/Reader/TextPage/images/paper14.png
  25. binární
      client/components/Reader/TextPage/images/paper15.png
  26. binární
      client/components/Reader/TextPage/images/paper16.png
  27. binární
      client/components/Reader/TextPage/images/paper17.png
  28. 236 164
      client/components/Reader/share/BookParser.js
  29. 27 0
      client/components/Reader/share/wallpaperStorage.js
  30. 14 0
      client/components/Reader/versionHistory.js
  31. 4 2
      client/quasar.js
  32. 22 0
      client/share/dynamicCss.js
  33. 1 0
      client/store/modules/fonts/fonts.json
  34. 13 0
      client/store/modules/fonts/fonts2list.js
  35. 30 117
      client/store/modules/reader.js
  36. 3 3
      package.json
  37. 7 1
      server/controllers/WebSocketController.js
  38. 237 0
      server/core/WebSocketConnection.js
  39. 2 4
      server/db/ConnManager.js
  40. 29 27
      server/db/SqliteConnectionPool.js

+ 1 - 2
client/api/misc.js

@@ -13,8 +13,7 @@ class Misc {
         ]};
 
         try {
-            await wsc.open();
-            const config = await wsc.message(wsc.send(Object.assign({action: 'get-config'}, query)));
+            const config = await wsc.message(await wsc.send(Object.assign({action: 'get-config'}, query)));
             if (config.error)
                 throw new Error(config.error);
             return config;

+ 3 - 6
client/api/reader.js

@@ -19,8 +19,7 @@ class Reader {
 
         let response = {};
         try {
-            await wsc.open();
-            const requestId = wsc.send({action: 'worker-get-state-finish', workerId});
+            const requestId = await wsc.send({action: 'worker-get-state-finish', workerId});
 
             let prevResponse = false;
             while (1) {// eslint-disable-line no-constant-condition
@@ -124,8 +123,7 @@ class Reader {
             let response = null
             
             try {
-                await wsc.open();
-                response = await wsc.message(wsc.send({action: 'reader-restore-cached-file', path: url}));
+                response = await wsc.message(await wsc.send({action: 'reader-restore-cached-file', path: url}));
             } catch (e) {
                 console.error(e);
                 //если с WebSocket проблема, работаем по http
@@ -210,8 +208,7 @@ class Reader {
     async storage(request) {
         let response = null;
         try {
-            await wsc.open();
-            response = await wsc.message(wsc.send({action: 'reader-storage', body: request}));
+            response = await wsc.message(await wsc.send({action: 'reader-storage', body: request}));
         } catch (e) {
             console.error(e);
             //если с WebSocket проблема, работаем по http

+ 1 - 183
client/api/webSocketConnection.js

@@ -1,185 +1,3 @@
-import * as utils from '../share/utils';
-
-const cleanPeriod = 60*1000;//1 минута
-
-class WebSocketConnection {
-    //messageLifeTime в минутах (cleanPeriod)
-    constructor(messageLifeTime = 5) {
-        this.ws = null;
-        this.timer = null;
-        this.listeners = [];
-        this.messageQueue = [];
-        this.messageLifeTime = messageLifeTime;
-        this.requestId = 0;
-
-        this.connecting = false;
-    }
-
-    addListener(listener) {
-        if (this.listeners.indexOf(listener) < 0)
-            this.listeners.push(Object.assign({regTime: Date.now()}, listener));
-    }
-
-    //рассылаем сообщение и удаляем те обработчики, которые его получили
-    emit(mes, isError) {
-        const len = this.listeners.length;
-        if (len > 0) {
-            let newListeners = [];
-            for (const listener of this.listeners) {
-                let emitted = false;
-                if (isError) {
-                    if (listener.onError)
-                        listener.onError(mes);
-                    emitted = true;
-                } else {
-                    if (listener.onMessage) {
-                        if (listener.requestId) {
-                            if (listener.requestId === mes.requestId) {
-                                listener.onMessage(mes);
-                                emitted = true;
-                            }
-                        } else {
-                            listener.onMessage(mes);
-                            emitted = true;
-                        }
-                    } else {
-                        emitted = true;
-                    }
-                }
-
-                if (!emitted)
-                    newListeners.push(listener);
-            }
-            this.listeners = newListeners;
-        }
-        
-        return this.listeners.length != len;
-    }
-
-    open(url) {
-        return new Promise((resolve, reject) => { (async() => {
-            //Ожидаем окончания процесса подключения, если open уже был вызван
-            let i = 0;
-            while (this.connecting && i < 200) {//10 сек
-                await utils.sleep(50);
-                i++;
-            }
-            if (i >= 200)
-                this.connecting = false;
-
-            //проверим подключение, и если нет, то подключимся заново
-            if (this.ws && this.ws.readyState == WebSocket.OPEN) {
-                resolve(this.ws);
-            } else {
-                this.connecting = true;
-                const protocol = (window.location.protocol == 'https:' ? 'wss:' : 'ws:');
-
-                url = url || `${protocol}//${window.location.host}/ws`;
-                
-                this.ws = new WebSocket(url);
-
-                if (this.timer) {
-                    clearTimeout(this.timer);
-                }
-                this.timer = setTimeout(() => { this.periodicClean(); }, cleanPeriod);
-
-                this.ws.onopen = (e) => {
-                    this.connecting = false;
-                    resolve(e);
-                };
-
-                this.ws.onmessage = (e) => {
-                    try {
-                        const mes = JSON.parse(e.data);
-                        this.messageQueue.push({regTime: Date.now(), mes});
-
-                        let newMessageQueue = [];
-                        for (const message of this.messageQueue) {
-                            if (!this.emit(message.mes)) {
-                                newMessageQueue.push(message);
-                            }
-                        }
-
-                        this.messageQueue = newMessageQueue;
-                    } catch (e) {
-                        this.emit(e.message, true);
-                    }
-                };
-
-                this.ws.onerror = (e) => {
-                    this.emit(e.message, true);
-                    if (this.connecting) {
-                        this.connecting = false;
-                        reject(e);
-                    }
-                };
-            }
-        })() });
-    }
-
-    //timeout в минутах (cleanPeriod)
-    message(requestId, timeout = 2) {
-        return new Promise((resolve, reject) => {
-            this.addListener({
-                requestId,
-                timeout,
-                onMessage: (mes) => {
-                    resolve(mes);
-                },
-                onError: (e) => {
-                    reject(e);
-                }
-            });
-        });
-    }
-
-    send(req) {
-        if (this.ws && this.ws.readyState == WebSocket.OPEN) {
-            const requestId = ++this.requestId;
-            this.ws.send(JSON.stringify(Object.assign({requestId}, req)));
-            return requestId;
-        } else {
-            throw new Error('WebSocket connection is not ready');
-        }
-    }
-
-    close() {
-        if (this.ws && this.ws.readyState == WebSocket.OPEN) {
-            this.ws.close();
-        }
-    }
-
-    periodicClean() {
-        try {
-            this.timer = null;
-
-            const now = Date.now();
-            //чистка listeners
-            let newListeners = [];
-            for (const listener of this.listeners) {
-                if (now - listener.regTime < listener.timeout*cleanPeriod - 50) {
-                    newListeners.push(listener);
-                } else {
-                    if (listener.onError)
-                        listener.onError('Время ожидания ответа истекло');
-                }
-            }
-            this.listeners = newListeners;
-
-            //чистка messageQueue
-            let newMessageQueue = [];
-            for (const message of this.messageQueue) {
-                if (now - message.regTime < this.messageLifeTime*cleanPeriod - 50) {
-                    newMessageQueue.push(message);
-                }
-            }
-            this.messageQueue = newMessageQueue;
-        } finally {
-            if (this.ws.readyState == WebSocket.OPEN) {
-                this.timer = setTimeout(() => { this.periodicClean(); }, cleanPeriod);
-            }
-        }
-    }
-}
+import WebSocketConnection from '../../server/core/WebSocketConnection';
 
 export default new WebSocketConnection();

+ 8 - 0
client/components/App.vue

@@ -270,6 +270,14 @@ body, html, #app {
     animation: rotating 2s linear infinite;
 }
 
+@keyframes rotating { 
+    from { 
+        transform: rotate(0deg); 
+    } to { 
+        transform: rotate(360deg); 
+    }
+}
+
 .notify-button-icon {
     font-size: 16px !important;
 }

+ 7 - 7
client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue

@@ -3,10 +3,10 @@
         <div class="box">
             <p class="p">Вы можете пожертвовать на развитие проекта любую сумму:</p>
             <div class="address">
-                <img class="logo" src="./assets/yandex.png">
-                <q-btn class="q-ml-sm q-px-sm" dense no-caps @click="donateYandexMoney">Пожертвовать</q-btn><br>
-                <div class="para">{{ yandexAddress }}
-                    <q-icon class="copy-icon" name="la la-copy" @click="copyAddress(yandexAddress, 'Яндекс кошелек')">
+                <img class="logo" src="./assets/yoomoney.png">
+                <q-btn class="q-ml-sm q-px-sm" dense no-caps @click="donateYooMoney">Пожертвовать</q-btn><br>
+                <div class="para">{{ yooAddress }}
+                    <q-icon class="copy-icon" name="la la-copy" @click="copyAddress(yooAddress, 'Кошелёк ЮMoney')">
                         <q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">Скопировать</q-tooltip>                    
                     </q-icon>
                 </div>
@@ -60,7 +60,7 @@ import {copyTextToClipboard} from '../../../../share/utils';
 export default @Component({
 })
 class DonateHelpPage extends Vue {
-    yandexAddress = '410018702323056';
+    yooAddress = '410018702323056';
     paypalAddress = 'bookpauk@gmail.com';
     bitcoinAddress = '3EbgZ7MK1UVaN38Gty5DCBtS4PknM4Ut85';
     litecoinAddress = 'MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ';
@@ -69,8 +69,8 @@ class DonateHelpPage extends Vue {
     created() {
     }
 
-    donateYandexMoney() {
-        window.open(`https://money.yandex.ru/to/${this.yandexAddress}`, '_blank');
+    donateYooMoney() {
+        window.open(`https://yoomoney.ru/to/${this.yooAddress}`, '_blank');
     }
 
     async copyAddress(address, prefix) {

binární
client/components/Reader/HelpPage/DonateHelpPage/assets/yandex.png


binární
client/components/Reader/HelpPage/DonateHelpPage/assets/yoomoney.png


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

@@ -68,7 +68,7 @@ class PasteTextPage extends Vue {
     }
 
     loadBuffer() {
-        this.$emit('load-buffer', {buffer: `<buffer><cut-title>${utils.escapeXml(this.bookTitle)}</cut-title>${this.$refs.textArea.value}</buffer>`});
+        this.$emit('load-buffer', {buffer: `<buffer><fb2-title>${utils.escapeXml(this.bookTitle)}</fb2-title>${utils.escapeXml(this.$refs.textArea.value)}</buffer>`});
         this.close();
     }
 

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

@@ -194,6 +194,10 @@ export default @Component({
                 }
             })();
         },
+        dualPageMode(newValue) {
+            if (newValue)
+                this.stopScrolling();
+        },
     },
 })
 class Reader extends Vue {
@@ -227,6 +231,7 @@ class Reader extends Vue {
     whatsNewVisible = false;
     whatsNewContent = '';
     donationVisible = false;
+    dualPageMode = false;
 
     created() {
         this.rstore = rstore;
@@ -321,6 +326,7 @@ class Reader extends Vue {
         this.djvuQuality = settings.djvuQuality;
         this.pdfAsText = settings.pdfAsText;
         this.pdfQuality = settings.pdfQuality;
+        this.dualPageMode = settings.dualPageMode;
 
         this.readerActionByKeyCode = utils.userHotKeysObjectSwap(settings.userHotKeys);
         this.$root.readerActionByKeyEvent = (event) => {
@@ -778,7 +784,6 @@ class Reader extends Vue {
             case 'loader':
             case 'fullScreen':
             case 'setPosition':
-            case 'scrolling':
             case 'search':
             case 'copyText':
             case 'convOptions':
@@ -794,6 +799,13 @@ class Reader extends Vue {
                     classResult = classActive;
                 }
                 break;
+            case 'scrolling':
+                if (this.progressActive || this.dualPageMode) {
+                    classResult = classDisabled;
+                } else if (this[`${action}Active`]) {
+                    classResult = classActive;
+                }
+                break;
             case 'undoAction':
                 if (this.actionCur <= 0)
                     classResult = classDisabled;

+ 20 - 4
client/components/Reader/RecentBooksPage/RecentBooksPage.vue

@@ -27,8 +27,11 @@
                             placeholder="Найти"
                             v-model="search"
                             @click.stop
-                        />
-
+                        >
+                            <template v-slot: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>
@@ -53,6 +56,7 @@
                         <div class="break-word" style="width: 332px; font-size: 90%">
                             <div style="color: green">{{ props.row.desc.author }}</div>
                             <div>{{ props.row.desc.title }}</div>
+                            <div class="read-bar" :style="`width: ${332*props.row.readPart}px`"></div>
                         </div>
                     </q-td>
 
@@ -106,7 +110,7 @@ export default @Component({
 })
 class RecentBooksPage extends Vue {
     loading = false;
-    search = null;
+    search = '';
     tableData = [];
     columns = [];
     pagination = {};
@@ -200,11 +204,13 @@ class RecentBooksPage extends Vue {
             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) {
-                perc = ` [${((p/book.textLength)*100).toFixed(2)}%]`;
+                readPart = p/book.textLength;
+                perc = ` [${(readPart*100).toFixed(2)}%]`;
                 textLen = ` ${Math.round(book.textLength/1000)}k`;
             }
 
@@ -223,6 +229,7 @@ class RecentBooksPage extends Vue {
                     author,
                     title: `${title}${perc}${textLen}`,
                 },
+                readPart,
                 descString: `${author}${title}${perc}${textLen}`,//для сортировки
                 url: book.url,
                 path: book.path,
@@ -244,6 +251,11 @@ class RecentBooksPage extends Vue {
         this.updating = false;
     }
 
+    resetSearch() {
+        this.search = '';
+        this.$refs.input.focus();
+    }
+
     wordEnding(num) {
         const endings = ['', 'а', 'и', 'и', 'и', '', '', '', '', ''];
         const deci = num % 100;
@@ -346,6 +358,10 @@ class RecentBooksPage extends Vue {
     white-space: normal;
 }
 
+.read-bar {
+    height: 3px;
+    background-color: #aaaaaa;
+}
 </style>
 
 <style>

+ 108 - 7
client/components/Reader/SettingsPage/SettingsPage.vue

@@ -82,6 +82,7 @@ import * as utils from '../../../share/utils';
 import Window from '../../share/Window.vue';
 import NumInput from '../../share/NumInput.vue';
 import UserHotKeys from './UserHotKeys/UserHotKeys.vue';
+import wallpaperStorage from '../share/wallpaperStorage';
 
 import rstore from '../../../store/modules/reader';
 import defPalette from './defPalette';
@@ -113,7 +114,7 @@ export default @Component({
         },
         vertShift: function(newValue) {
             const font = (this.webFontName ? this.webFontName : this.fontName);
-            if (this.fontShifts[font] != newValue) {
+            if (this.fontShifts[font] != newValue || this.fontVertShift != newValue) {
                 this.fontShifts = Object.assign({}, this.fontShifts, {[font]: newValue});
                 this.fontVertShift = newValue;
             }
@@ -130,6 +131,10 @@ export default @Component({
             if (newValue != '' && this.pageChangeAnimation == 'flip')
                 this.pageChangeAnimation = '';
         },
+        dualPageMode(newValue) {
+            if (newValue && this.pageChangeAnimation == 'flip' || this.pageChangeAnimation == 'rightShift')
+                this.pageChangeAnimation = '';
+        },
         textColor: function(newValue) {
             this.textColorFiltered = newValue;
         },
@@ -144,11 +149,25 @@ export default @Component({
             if (hex.test(newValue))
                 this.backgroundColor = newValue;
         },
+        dualDivColor(newValue) {
+            this.dualDivColorFiltered = newValue;
+        },
+        dualDivColorFiltered(newValue) {
+            if (hex.test(newValue))
+                this.dualDivColor = newValue;
+        },
+        statusBarColor(newValue) {
+            this.statusBarColorFiltered = newValue;
+        },
+        statusBarColorFiltered(newValue) {
+            if (hex.test(newValue))
+                this.statusBarColor = newValue;
+        },
     },
 })
 class SettingsPage extends Vue {
     selectedTab = 'profiles';
-    selectedViewTab = 'color';
+    selectedViewTab = 'mode';
     selectedKeysTab = 'mouse';
     form = {};
     fontBold = false;
@@ -157,6 +176,7 @@ class SettingsPage extends Vue {
     tabsScrollable = false;
     textColorFiltered = '';
     bgColorFiltered = '';
+    dualDivColorFiltered = '';
 
     webFonts = [];
     fonts = [];
@@ -217,6 +237,8 @@ class SettingsPage extends Vue {
         this.vertShift = this.fontShifts[font] || 0;
         this.textColorFiltered = this.textColor;
         this.bgColorFiltered = this.backgroundColor;
+        this.dualDivColorFiltered = this.dualDivColor;
+        this.statusBarColorFiltered = this.statusBarColor;
     }
 
     get mode() {
@@ -256,9 +278,15 @@ class SettingsPage extends Vue {
 
     get wallpaperOptions() {
         let result = [{label: 'Нет', value: ''}];
-        for (let i = 1; i < 10; i++) {
+
+        for (const wp of this.userWallpapers) {
+            result.push({label: wp.label, value: wp.cssClass});
+        }
+
+        for (let i = 1; i <= 17; i++) {
             result.push({label: i, value: `paper${i}`});
         }
+
         return result;
     }
 
@@ -282,13 +310,15 @@ class SettingsPage extends Vue {
         let result = [
             {label: 'Нет', value: ''},
             {label: 'Вверх-вниз', value: 'downShift'},
-            {label: 'Вправо-влево', value: 'rightShift'},
+            (!this.dualPageMode ? {label: 'Вправо-влево', value: 'rightShift'} : null),
             {label: 'Протаивание', value: 'thaw'},
             {label: 'Мерцание', value: 'blink'},
             {label: 'Вращение', value: 'rotate'},
-        ];
-        if (this.wallpaper == '')
-            result.push({label: 'Листание', value: 'flip'});
+            (this.wallpaper == '' && !this.dualPageMode ? {label: 'Листание', value: 'flip'} : null),
+        ];        
+
+        result = result.filter(v => v);
+
         return result;
     }
 
@@ -351,6 +381,12 @@ class SettingsPage extends Vue {
             case 'bg':
                 result += `background-color: ${this.backgroundColor};`
                 break;
+            case 'div':
+                result += `background-color: ${this.dualDivColor};`
+                break;
+            case 'statusbar':
+                result += `background-color: ${this.statusBarColor};`
+                break;
         }
         return result;
     }
@@ -517,6 +553,71 @@ class SettingsPage extends Vue {
 
     }
 
+    loadWallpaperFileClick() {
+        this.$refs.file.click();
+    }
+
+    loadWallpaperFile() {
+        const file = this.$refs.file.files[0];        
+        if (file.size > 10*1024*1024) {
+            this.$root.stdDialog.alert('Файл обоев не должен превышать в размере 10Mb', 'Ошибка');
+            return;
+        }
+
+        if (file.type != 'image/png' && file.type != 'image/jpeg') {
+            this.$root.stdDialog.alert('Файл обоев должен иметь тип PNG или JPEG', 'Ошибка');
+            return;
+        }
+
+        if (this.userWallpapers.length >= 100) {
+            this.$root.stdDialog.alert('Превышено максимальное количество пользовательских обоев.', 'Ошибка');
+            return;
+        }
+
+        this.$refs.file.value = '';
+        if (file) {
+            const reader = new FileReader();
+
+            reader.onload = (e) => {
+                const newUserWallpapers = _.cloneDeep(this.userWallpapers);
+                let n = 0;
+                for (const wp of newUserWallpapers) {
+                    const newN = parseInt(wp.label.replace(/\D/g, ''), 10);
+                    if (newN > n)
+                        n = newN;
+                }
+                n++;
+
+                const cssClass = `user-paper${n}`;
+                newUserWallpapers.push({label: `#${n}`, cssClass});
+                (async() => {
+                    await wallpaperStorage.setData(cssClass, e.target.result);
+
+                    this.userWallpapers = newUserWallpapers;
+                    this.wallpaper = cssClass;
+                })();
+            }
+
+            reader.readAsDataURL(file);
+        }
+    }
+
+    async delWallpaper() {
+        if (this.wallpaper.indexOf('user-paper') == 0) {
+            const newUserWallpapers = [];
+            for (const wp of this.userWallpapers) {
+                if (wp.cssClass != this.wallpaper) {
+                    newUserWallpapers.push(wp);
+                }
+            }
+
+            await wallpaperStorage.removeData(this.wallpaper);
+
+            this.userWallpapers = newUserWallpapers;
+            this.wallpaper = '';
+        }
+    }
+
     keyHook(event) {
         if (!this.$root.stdDialog.active && event.type == 'keydown' && event.key == 'Escape') {
             this.close();

+ 5 - 0
client/components/Reader/SettingsPage/include/ViewTab.inc

@@ -7,6 +7,7 @@
     no-caps
     class="no-mp bg-grey-4 text-grey-7"
 >
+    <q-tab name="mode" label="Режим" />
     <q-tab name="color" label="Цвет" />
     <q-tab name="font" label="Шрифт" />
     <q-tab name="text" label="Текст" />
@@ -16,6 +17,10 @@
 <div class="q-mb-sm"/>
 
 <div class="col tab-panel">
+    <div v-if="selectedViewTab == 'mode'">
+        @@include('./ViewTab/Mode.inc');
+    </div>
+
     <div v-if="selectedViewTab == 'color'">
         @@include('./ViewTab/Color.inc');
     </div>

+ 43 - 6
client/components/Reader/SettingsPage/include/ViewTab/Color.inc

@@ -22,8 +22,6 @@
                 </q-icon>
             </template>
         </q-input>
-
-        <span class="col" style="position: relative; top: 35px; left: 15px;">Обои:</span>
     </div>
 </div>
 
@@ -36,7 +34,6 @@
             v-model="bgColorFiltered"
             :rules="['hexColor']"
             style="max-width: 150px"
-            :disable="wallpaper != ''"
         >
             <template v-slot:prepend>
                 <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="colorPanStyle('bg')">
@@ -48,11 +45,51 @@
                 </q-icon>
             </template>
         </q-input>
+    </div>
+</div>
 
-        <div class="q-px-sm"/>
-        <q-select class="col" v-model="wallpaper" :options="wallpaperOptions"
+<div class="q-mt-md"/>
+<div class="item row">
+    <div class="label-2">Обои</div>
+    <div class="col row items-center">
+        <q-select class="col-left no-mp" v-model="wallpaper" :options="wallpaperOptions"
             dropdown-icon="la la-angle-down la-sm"
             outlined dense emit-value map-options
-        />
+        >
+            <template v-slot:selected-item="scope">
+                <div >{{ scope.opt.label }}</div>
+                <div v-show="scope.opt.value" class="q-ml-sm" :class="scope.opt.value" style="width: 50px; height: 30px;"></div>
+            </template>
+
+            <template v-slot:option="scope">
+                <q-item
+                    v-bind="scope.itemProps"
+                    v-on="scope.itemEvents"
+                >
+                    <q-item-section>
+                        <q-item-label v-html="scope.opt.label" />
+                    </q-item-section>
+                    <q-item-section v-show="scope.opt.value" :class="scope.opt.value" style="min-width: 70px; min-height: 50px"/>
+                </q-item>
+            </template>
+        </q-select>
+
+        <div class="q-px-xs"/>
+        <q-btn class="q-ml-sm" round dense color="blue" icon="la la-plus" @click.stop="loadWallpaperFileClick">
+            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Добавить файл обоев</q-tooltip>
+        </q-btn>
+        <q-btn class="q-ml-sm" round dense color="blue" icon="la la-minus" @click.stop="delWallpaper" :disable="wallpaper.indexOf('user-paper') != 0">
+            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Удалить выбранные обои</q-tooltip>
+        </q-btn>
     </div>
 </div>
+
+<div class="q-mt-sm"/>
+<div class="item row">
+    <div class="label-2"></div>
+    <div class="col row items-center">
+        <q-checkbox v-model="wallpaperIgnoreStatusBar" size="xs" label="Не включать строку статуса в обои" />
+    </div>
+</div>
+
+<input type="file" ref="file" @change="loadWallpaperFile" style='display: none;'/>

+ 124 - 0
client/components/Reader/SettingsPage/include/ViewTab/Mode.inc

@@ -0,0 +1,124 @@
+<!---------------------------------------------->
+<div class="hidden part-header">Режим</div>
+
+<div class="item row">
+    <div class="label-2"></div>
+    <div class="col row">
+        <q-checkbox v-model="dualPageMode" size="xs" label="Двухстраничный режим" />
+    </div>
+</div>
+
+<div class="part-header">Страницы</div>
+<div class="item row">
+    <div class="label-2">Отступ границ</div>
+    <div class="col row">
+        <NumInput class="col-left" v-model="indentLR" :min="0" :max="2000">
+            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                Слева/справа от края экрана
+            </q-tooltip>
+        </NumInput>
+        <div class="q-px-sm"/>
+        <NumInput class="col" v-model="indentTB" :min="0" :max="2000">
+            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                Сверху/снизу от края экрана
+            </q-tooltip>
+        </NumInput>
+    </div>
+</div>
+
+<div v-show="dualPageMode" class="item row">
+    <div class="label-2">Отступ внутри</div>
+    <div class="col row">
+        <NumInput class="col-left" v-model="dualIndentLR" :min="0" :max="2000">
+            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                Слева/справа внутри страницы
+            </q-tooltip>
+        </NumInput>
+    </div>
+</div>
+
+<div v-show="dualPageMode">
+    <div class="part-header">Разделитель</div>
+
+    <div class="item row no-wrap">
+        <div class="label-2">Цвет</div>
+        <div class="col-left row">
+            <q-input class="col-left no-mp"
+                outlined dense
+                v-model="dualDivColorFiltered"
+                :rules="['hexColor']"
+                style="max-width: 150px"
+                :disable="dualDivColorAsText"
+            >
+                <template v-slot:prepend>
+                    <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="colorPanStyle('div')">
+                        <q-popup-proxy anchor="bottom middle" self="top middle">
+                            <div>
+                                <q-color v-model="dualDivColor"
+                                    no-header default-view="palette" :palette="predefineTextColors"
+                                />
+                            </div>
+                        </q-popup-proxy>
+                    </q-icon>
+                </template>
+            </q-input>
+        </div>
+        
+        <div class="q-px-xs"/>
+        <q-checkbox v-model="dualDivColorAsText" size="xs" label="Как у текста" />
+    </div>
+
+    <div class="item row">
+        <div class="label-2">Прозрачность</div>
+        <div class="col row">
+            <NumInput class="col-left" v-model="dualDivColorAlpha" :min="0" :max="1" :digits="2" :step="0.1"/>
+        </div>
+    </div>
+
+    <div class="item row">
+        <div class="label-2">Ширина (px)</div>
+        <div class="col row">
+            <NumInput class="col-left" v-model="dualDivWidth" :min="0" :max="100">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Ширина разделителя
+                </q-tooltip>
+            </NumInput>
+        </div>
+    </div>
+
+    <div class="item row">
+        <div class="label-2">Высота (%)</div>
+        <div class="col row">
+            <NumInput class="col-left" v-model="dualDivHeight" :min="0" :max="100">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Высота разделителя
+                </q-tooltip>
+            </NumInput>
+        </div>
+    </div>
+
+    <div class="item row">
+        <div class="label-2">Пунктир</div>
+        <div class="col row">
+            <NumInput class="col-left" v-model="dualDivStrokeFill" :min="0" :max="2000">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Заполнение пунктира
+                </q-tooltip>
+            </NumInput>
+            <div class="q-px-sm"/>
+            <NumInput class="col" v-model="dualDivStrokeGap" :min="0" :max="2000">
+                <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
+                    Промежуток пунктира
+                </q-tooltip>
+            </NumInput>
+        </div>
+    </div>
+
+    <div class="item row">
+        <div class="label-2">Ширина тени</div>
+        <div class="col row">
+            <NumInput class="col-left" v-model="dualDivShadowWidth" :min="0" :max="100"/>
+        </div>
+    </div>    
+
+</div>

+ 36 - 8
client/components/Reader/SettingsPage/include/ViewTab/Status.inc

@@ -5,25 +5,53 @@
     <div class="label-2">Статус</div>
     <div class="col row">
         <q-checkbox v-model="showStatusBar" size="xs" label="Показывать" />
-        <q-checkbox class="q-ml-sm" v-model="statusBarTop" size="xs" :disable="!showStatusBar" label="Вверху/внизу" />
+        <q-checkbox v-show="showStatusBar" class="q-ml-sm" v-model="statusBarTop" size="xs" label="Вверху/внизу" />
     </div>
 </div>
 
-<div class="item row">
-    <div class="label-2">Высота</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="statusBarHeight" :min="5" :max="100" :disable="!showStatusBar"/>
+<div v-show="showStatusBar" class="item row no-wrap">
+    <div class="label-2">Цвет</div>
+    <div class="col-left row">
+        <q-input class="col-left no-mp"
+            outlined dense
+            v-model="statusBarColorFiltered"
+            :rules="['hexColor']"
+            style="max-width: 150px"
+            :disable="statusBarColorAsText"
+        >
+            <template v-slot:prepend>
+                <q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="colorPanStyle('statusbar')">
+                    <q-popup-proxy anchor="bottom middle" self="top middle">
+                        <div>
+                            <q-color v-model="statusBarColor"
+                                no-header default-view="palette" :palette="predefineTextColors"
+                            />
+                        </div>
+                    </q-popup-proxy>
+                </q-icon>
+            </template>
+        </q-input>
     </div>
+    
+    <div class="q-px-xs"/>
+    <q-checkbox v-model="statusBarColorAsText" size="xs" label="Как у текста"/>
 </div>
 
-<div class="item row">
+<div v-show="showStatusBar" class="item row">
     <div class="label-2">Прозрачность</div>
     <div class="col row">
-        <NumInput class="col-left" v-model="statusBarColorAlpha" :min="0" :max="1" :digits="2" :step="0.1" :disable="!showStatusBar"/>
+        <NumInput class="col-left" v-model="statusBarColorAlpha" :min="0" :max="1" :digits="2" :step="0.1"/>
     </div>
 </div>
 
-<div class="item row">
+<div v-show="showStatusBar" class="item row">
+    <div class="label-2">Высота</div>
+    <div class="col row">
+        <NumInput class="col-left" v-model="statusBarHeight" :min="5" :max="100"/>
+    </div>
+</div>
+
+<div v-show="showStatusBar" class="item row">
     <div class="label-2"></div>
     <div class="col row">
         <q-checkbox v-model="statusBarClickOpen" size="xs" label="Открывать оригинал по клику">

+ 1 - 18
client/components/Reader/SettingsPage/include/ViewTab/Text.inc

@@ -15,23 +15,6 @@
     </div>
 </div>
 
-<div class="item row">
-    <div class="label-2">Отступ</div>
-    <div class="col row">
-        <NumInput class="col-left" v-model="indentLR" :min="0" :max="2000">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Слева/справа
-            </q-tooltip>
-        </NumInput>
-        <div class="q-px-sm"/>
-        <NumInput class="col" v-model="indentTB" :min="0" :max="2000">
-            <q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
-                Сверху/снизу
-            </q-tooltip>
-        </NumInput>
-    </div>
-</div>
-
 <div class="item row">
     <div class="label-2">Сдвиг</div>
     <div class="col row">
@@ -123,7 +106,7 @@
 <div class="item row">
     <div class="label-2"></div>
     <div class="col row">
-        <q-checkbox v-model="imageFitWidth" :disable="!showImages" size="xs" label="Ширина не более размера экрана" />
+        <q-checkbox v-model="imageFitWidth" size="xs" label="Ширина не более размера страницы" :disable="!showImages || dualPageMode"/>
     </div>
 </div>
 

+ 151 - 117
client/components/Reader/TextPage/DrawHelper.js

@@ -19,6 +19,109 @@ export default class DrawHelper {
         return this.context.measureText(text).width;
     }
 
+    drawLine(line, lineIndex, baseLineIndex, sel, imageDrawn) {
+        /* line:
+        {
+            begin: Number,
+            end: Number,
+            first: Boolean,
+            last: Boolean,
+            parts: array of {
+                style: {bold: Boolean, italic: Boolean, center: Boolean},
+                image: {local: Boolean, inline: Boolean, id: String, imageLine: Number, lineCount: Number, paraIndex: Number},
+                text: String,
+            }            
+        }*/
+
+        let out = '<div>';
+
+        let lineText = '';
+        let center = false;
+        let space = 0;
+        let j = 0;
+        //формируем строку
+        for (const part of line.parts) {
+            let tOpen = '';
+            tOpen += (part.style.bold ? '<b>' : '');
+            tOpen += (part.style.italic ? '<i>' : '');
+            tOpen += (part.style.sup ? '<span style="vertical-align: baseline; position: relative; line-height: 0; top: -0.3em">' : '');
+            tOpen += (part.style.sub ? '<span style="vertical-align: baseline; position: relative; line-height: 0; top: 0.3em">' : '');
+            let tClose = '';
+            tClose += (part.style.sub ? '</span>' : '');
+            tClose += (part.style.sup ? '</span>' : '');
+            tClose += (part.style.italic ? '</i>' : '');
+            tClose += (part.style.bold ? '</b>' : '');
+
+            let text = '';
+            if (lineIndex == 0 && this.searching) {
+                for (let k = 0; k < part.text.length; k++) {
+                    text += (sel.has(j) ? `<ins>${part.text[k]}</ins>` : part.text[k]);
+                    j++;
+                }
+            } else
+                text = part.text;
+
+            if (text && text.trim() == '')
+                text = `<span style="white-space: pre">${text}</span>`;
+
+            lineText += `${tOpen}${text}${tClose}`;
+
+            center = center || part.style.center;
+            space = (part.style.space > space ? part.style.space : space);
+
+            //избражения
+            //image: {local: Boolean, inline: Boolean, id: String, imageLine: Number, lineCount: Number, paraIndex: Number, w: Number, h: Number},
+            const img = part.image;
+            if (img && img.id && !img.inline && !imageDrawn.has(img.paraIndex)) {
+                const bin = this.parsed.binary[img.id];
+                if (bin) {
+                    let resize = '';                        
+                    if (bin.h > img.h) {
+                        resize = `height: ${img.h}px`;
+                    }
+
+                    const left = (this.w - img.w)/2;
+                    const top = ((img.lineCount*this.lineHeight - img.h)/2) + (lineIndex - baseLineIndex - img.imageLine)*this.lineHeight;
+                    if (img.local) {
+                        lineText += `<img src="data:${bin.type};base64,${bin.data}" style="position: absolute; left: ${left}px; top: ${top}px; ${resize}"/>`;
+                    } else {
+                        lineText += `<img src="${img.id}" style="position: absolute; left: ${left}px; top: ${top}px; ${resize}"/>`;
+                    }
+                }
+                imageDrawn.add(img.paraIndex);
+            }
+
+            if (img && img.id && img.inline) {
+                if (img.local) {
+                    const bin = this.parsed.binary[img.id];
+                    if (bin) {
+                        let resize = '';
+                        if (bin.h > this.fontSize) {
+                            resize = `height: ${this.fontSize - 3}px`;
+                        }
+                        lineText += `<img src="data:${bin.type};base64,${bin.data}" style="${resize}"/>`;
+                    }
+                } else {
+                    //
+                }
+            }
+        }
+
+        const centerStyle = (center ? `text-align: center; text-align-last: center; width: ${this.w}px` : '')
+        if ((line.first || space) && !center) {
+            let p = (line.first ? this.p : 0);
+            p = (space ? p + this.p*space : p);
+            lineText = `<span style="display: inline-block; margin-left: ${p}px"></span>${lineText}`;
+        }
+
+        if (line.last || center)
+            lineText = `<span style="display: inline-block; ${centerStyle}">${lineText}</span>`;
+
+        out += lineText + '</div>';
+
+        return out;
+    }
+
     drawPage(lines, isScrolling) {
         if (!this.lastBook || this.pageLineCount < 1 || !this.book || !lines || !this.parsed.textLength)
             return '';
@@ -26,134 +129,65 @@ export default class DrawHelper {
         const font = this.fontByStyle({});
         const justify = (this.textAlignJustify ? 'text-align: justify; text-align-last: justify;' : '');
 
-        let out = `<div style="width: ${this.w}px; height: ${this.h + (isScrolling ? this.lineHeight : 0)}px;` + 
+        const boxH = this.h + (isScrolling ? this.lineHeight : 0);
+        let out = `<div class="row no-wrap" style="width: ${this.boxW}px; height: ${boxH}px;` + 
             ` position: absolute; top: ${this.fontSize*this.textShift}px; color: ${this.textColor}; font: ${font}; ${justify}` +
             ` line-height: ${this.lineHeight}px; white-space: nowrap;">`;
 
-        let imageDrawn = new Set();
+        let imageDrawn1 = new Set();
+        let imageDrawn2 = new Set();
         let len = lines.length;
         const lineCount = this.pageLineCount + (isScrolling ? 1 : 0);
         len = (len > lineCount ? lineCount : len);
 
-        for (let i = 0; i < len; i++) {
-            const line = lines[i];
-            /* line:
-            {
-                begin: Number,
-                end: Number,
-                first: Boolean,
-                last: Boolean,
-                parts: array of {
-                    style: {bold: Boolean, italic: Boolean, center: Boolean},
-                    image: {local: Boolean, inline: Boolean, id: String, imageLine: Number, lineCount: Number, paraIndex: Number},
-                    text: String,
-                }
-            }*/
-            let sel = new Set();
-            //поиск
-            if (i == 0 && this.searching) {
-                let pureText = '';
-                for (const part of line.parts) {
-                    pureText += part.text;
-                }
-
-                pureText = pureText.toLowerCase();
-                let j = 0;
-                while (1) {// eslint-disable-line no-constant-condition
-                    j = pureText.indexOf(this.needle, j);
-                    if (j >= 0) {
-                        for (let k = 0; k < this.needle.length; k++) {
-                            sel.add(j + k);
-                        }
-                    } else
-                        break;
-                    j++;
-                }
+        //поиск
+        let sel = new Set();
+        if (len > 0 && this.searching) {
+            const line = lines[0];
+            let pureText = '';
+            for (const part of line.parts) {
+                pureText += part.text;
             }
 
-            let lineText = '';
-            let center = false;
-            let space = 0;
+            pureText = pureText.toLowerCase();
             let j = 0;
-            //формируем строку
-            for (const part of line.parts) {
-                let tOpen = '';
-                tOpen += (part.style.bold ? '<b>' : '');
-                tOpen += (part.style.italic ? '<i>' : '');
-                tOpen += (part.style.sup ? '<span style="vertical-align: baseline; position: relative; line-height: 0; top: -0.3em">' : '');
-                tOpen += (part.style.sub ? '<span style="vertical-align: baseline; position: relative; line-height: 0; top: 0.3em">' : '');
-                let tClose = '';
-                tClose += (part.style.sub ? '</span>' : '');
-                tClose += (part.style.sup ? '</span>' : '');
-                tClose += (part.style.italic ? '</i>' : '');
-                tClose += (part.style.bold ? '</b>' : '');
-
-                let text = '';
-                if (i == 0 && this.searching) {
-                    for (let k = 0; k < part.text.length; k++) {
-                        text += (sel.has(j) ? `<ins>${part.text[k]}</ins>` : part.text[k]);
-                        j++;
+            while (1) {// eslint-disable-line no-constant-condition
+                j = pureText.indexOf(this.needle, j);
+                if (j >= 0) {
+                    for (let k = 0; k < this.needle.length; k++) {
+                        sel.add(j + k);
                     }
                 } else
-                    text = part.text;
-
-                if (text && text.trim() == '')
-                    text = `<span style="white-space: pre">${text}</span>`;
-
-                lineText += `${tOpen}${text}${tClose}`;
-
-                center = center || part.style.center;
-                space = (part.style.space > space ? part.style.space : space);
-
-                //избражения
-                //image: {local: Boolean, inline: Boolean, id: String, imageLine: Number, lineCount: Number, paraIndex: Number, w: Number, h: Number},
-                const img = part.image;
-                if (img && img.id && !img.inline && !imageDrawn.has(img.paraIndex)) {
-                    const bin = this.parsed.binary[img.id];
-                    if (bin) {
-                        let resize = '';                        
-                        if (bin.h > img.h) {
-                            resize = `height: ${img.h}px`;
-                        }
-
-                        const left = (this.w - img.w)/2;
-                        const top = ((img.lineCount*this.lineHeight - img.h)/2) + (i - img.imageLine)*this.lineHeight;
-                        if (img.local) {
-                            lineText += `<img src="data:${bin.type};base64,${bin.data}" style="position: absolute; left: ${left}px; top: ${top}px; ${resize}"/>`;
-                        } else {
-                            lineText += `<img src="${img.id}" style="position: absolute; left: ${left}px; top: ${top}px; ${resize}"/>`;
-                        }
-                    }
-                    imageDrawn.add(img.paraIndex);
-                }
-
-                if (img && img.id && img.inline) {
-                    if (img.local) {
-                        const bin = this.parsed.binary[img.id];
-                        if (bin) {
-                            let resize = '';
-                            if (bin.h > this.fontSize) {
-                                resize = `height: ${this.fontSize - 3}px`;
-                            }
-                            lineText += `<img src="data:${bin.type};base64,${bin.data}" style="${resize}"/>`;
-                        }
-                    } else {
-                        //
-                    }
-                }
+                    break;
+                j++;
             }
+        }
 
-            const centerStyle = (center ? `text-align: center; text-align-last: center; width: ${this.w}px` : '')
-            if ((line.first || space) && !center) {
-                let p = (line.first ? this.p : 0);
-                p = (space ? p + this.p*space : p);
-                lineText = `<span style="display: inline-block; margin-left: ${p}px"></span>${lineText}`;
+        //отрисовка строк
+        if (!this.dualPageMode) {
+            out += `<div class="fit">`;
+            for (let i = 0; i < len; i++) {
+                out += this.drawLine(lines[i], i, 0, sel, imageDrawn1);
             }
+            out += `</div>`;
+        } else {
+            //левая страница
+            out += `<div style="width: ${this.w}px; margin-left: ${this.dualIndentLR}px; position: relative;">`;
+            const l2 = (this.pageRowsCount > len ? len : this.pageRowsCount);
+            for (let i = 0; i < l2; i++) {
+                out += this.drawLine(lines[i], i, 0, sel, imageDrawn1);
+            }
+            out += '</div>';
 
-            if (line.last || center)
-                lineText = `<span style="display: inline-block; ${centerStyle}">${lineText}</span>`;
+            //разделитель
+            out += `<div style="width: ${this.dualIndentLR*2}px;"></div>`;
 
-            out += (i > 0 ? '<br>' : '') + lineText;
+            //правая страница
+            out += `<div style="width: ${this.w}px; margin-right: ${this.dualIndentLR}px; position: relative;">`;
+            for (let i = l2; i < len; i++) {
+                out += this.drawLine(lines[i], i, l2, sel, imageDrawn2);
+            }
+            out += '</div>';
         }
 
         out += '</div>';
@@ -179,8 +213,8 @@ export default class DrawHelper {
         
         if (w1 + w2 + w3 <= w && w3 > (10 + fh2)) {
             const barWidth = w - w1 - w2 - fh2;
-            out += this.strokeRect(x + w1, y + pad, barWidth, fh - 2, this.statusBarColor);
-            out += this.fillRect(x + w1 + 2, y + pad + 2, (barWidth - 4)*read, fh - 6, this.statusBarColor);
+            out += this.strokeRect(x + w1, y + pad, barWidth, fh - 2, this.statusBarRgbaColor);
+            out += this.fillRect(x + w1 + 2, y + pad + 2, (barWidth - 4)*read, fh - 6, this.statusBarRgbaColor);
         }
 
         if (w1 <= w)
@@ -193,12 +227,12 @@ export default class DrawHelper {
 
         let out = `<div class="layout" style="` + 
             `width: ${this.realWidth}px; height: ${statusBarHeight}px; ` + 
-            `color: ${this.statusBarColor}">`;
+            `color: ${this.statusBarRgbaColor}">`;
 
         const fontSize = statusBarHeight*0.75;
         const font = 'bold ' + this.fontBySize(fontSize);
 
-        out += this.fillRect(0, (statusBarTop ? statusBarHeight : 0), this.realWidth, 1, this.statusBarColor);
+        out += this.fillRect(0, (statusBarTop ? statusBarHeight : 0), this.realWidth, 1, this.statusBarRgbaColor);
 
         const date = new Date();
         const time = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
@@ -207,7 +241,7 @@ export default class DrawHelper {
 
         out += this.fillTextShift(this.fittingString(title, this.realWidth/2 - fontSize - 3, font), fontSize, 2, font, fontSize);
 
-        out += this.drawPercentBar(this.realWidth/2, 2, this.realWidth/2 - timeW - 2*fontSize, statusBarHeight, font, fontSize, bookPos, textLength, imageNum, imageLength);
+        out += this.drawPercentBar(this.realWidth/2 + fontSize, 2, this.realWidth/2 - timeW - 3*fontSize, statusBarHeight, font, fontSize, bookPos, textLength, imageNum, imageLength);
         
         out += '</div>';
         return out;
@@ -274,7 +308,7 @@ export default class DrawHelper {
     }
 
     async doPageAnimationRightShift(page1, page2, duration, isDown, animation1Finish) {
-        const s = this.w + this.fontSize;
+        const s = this.boxW + this.fontSize;
 
         if (isDown) {
             page1.style.transform = `translateX(${s}px)`;

+ 93 - 0
client/components/Reader/TextPage/TextPage.css

@@ -0,0 +1,93 @@
+@keyframes page1-animation-thaw {
+    0%   { opacity: 0; }
+    100% { opacity: 1; }
+}
+
+@keyframes page2-animation-thaw {
+    0%   { opacity: 1; }
+    100% { opacity: 0; }
+}
+
+.paper1 {
+    background: url("images/paper1.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper2 {
+    background: url("images/paper2.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper3 {
+    background: url("images/paper3.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper4 {
+    background: url("images/paper4.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper5 {
+    background: url("images/paper5.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper6 {
+    background: url("images/paper6.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper7 {
+    background: url("images/paper7.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper8 {
+    background: url("images/paper8.jpg") center;
+    background-size: 100% 100%;
+}
+
+.paper9 {
+    background: url("images/paper9.jpg");
+}
+
+.paper10 {
+    background: url("images/paper10.png") center;
+    background-size: 100% 100%;
+}
+
+.paper11 {
+    background: url("images/paper11.png") center;
+    background-size: 100% 100%;
+}
+
+.paper12 {
+    background: url("images/paper12.png") center;
+    background-size: 100% 100%;
+}
+
+.paper13 {
+    background: url("images/paper13.png") center;
+    background-size: 100% 100%;
+}
+
+.paper14 {
+    background: url("images/paper14.png") center;
+    background-size: 100% 100%;
+}
+
+.paper15 {
+    background: url("images/paper15.png") center;
+    background-size: 100% 100%;
+}
+
+.paper16 {
+    background: url("images/paper16.png") center;
+    background-size: 100% 100%;
+}
+
+.paper17 {
+    background: url("images/paper17.png") center;
+    background-size: 100% 100%;
+}

+ 145 - 107
client/components/Reader/TextPage/TextPage.vue

@@ -1,8 +1,8 @@
 <template>
     <div ref="main" class="main">
         <div class="layout back" @wheel.prevent.stop="onMouseWheel">
-            <div v-html="background"></div>
-            <!-- img -->
+            <div class="absolute" v-html="background"></div>
+            <div class="absolute" v-html="pageDivider"></div>
         </div>
         <div ref="scrollBox1" class="layout over-hidden" @wheel.prevent.stop="onMouseWheel">
             <div ref="scrollingPage1" class="layout over-hidden" @transitionend="onPage1TransitionEnd" @animationend="onPage1AnimationEnd">
@@ -27,7 +27,7 @@
         <div v-show="!clickControl && showStatusBar && statusBarClickOpen" class="layout" v-html="statusBarClickable" @mousedown.prevent.stop @touchstart.stop
             @click.prevent.stop="onStatusBarClick">
         </div>
-        <!-- невидимым делать нельзя, вовремя не подгружаютя шрифты -->
+        <!-- невидимым делать нельзя (display: none), вовремя не подгружаютя шрифты -->
         <canvas ref="offscreenCanvas" class="layout" style="visibility: hidden"></canvas>
         <div ref="measureWidth" style="position: absolute; visibility: hidden"></div>
     </div>
@@ -40,8 +40,13 @@ import Component from 'vue-class-component';
 import {loadCSS} from 'fg-loadcss';
 import _ from 'lodash';
 
+import './TextPage.css';
+
 import * as utils from '../../../share/utils';
+import dynamicCss from '../../../share/dynamicCss';
+
 import bookManager from '../share/bookManager';
+import wallpaperStorage from '../share/wallpaperStorage';
 import DrawHelper from './DrawHelper';
 import rstore from '../../../store/modules/reader';
 import {clickMap} from '../share/clickMap';
@@ -74,6 +79,7 @@ class TextPage extends Vue {
     clickControl = true;
 
     background = null;
+    pageDivider = null;
     page1 = null;
     page2 = null;
     statusBar = null;
@@ -110,7 +116,11 @@ class TextPage extends Vue {
 
         this.debouncedDrawStatusBar = _.throttle(() => {
             this.drawStatusBar();
-        }, 60);        
+        }, 60);
+
+        this.debouncedDrawPageDividerAndOrnament = _.throttle(() => {
+            this.drawPageDividerAndOrnament();
+        }, 65);
 
         this.debouncedLoadSettings = _.debounce(() => {
             this.loadSettings();
@@ -161,14 +171,16 @@ class TextPage extends Vue {
         this.$refs.layoutEvents.style.width = this.realWidth + 'px';
         this.$refs.layoutEvents.style.height = this.realHeight + 'px';
 
-        this.w = this.realWidth - 2*this.indentLR;
+        const dual = (this.dualPageMode ? 2 : 1);
+        this.boxW = this.realWidth - 2*this.indentLR;
+        this.w = this.boxW/dual - (this.dualPageMode ? 2*this.dualIndentLR : 0);
+
         this.scrollHeight = this.realHeight - (this.showStatusBar ? this.statusBarHeight : 0);
         this.h = this.scrollHeight - 2*this.indentTB;
-        this.lineHeight = this.fontSize + this.lineInterval;
-        this.pageLineCount = 1 + Math.floor((this.h - this.lineHeight + this.lineInterval/2)/this.lineHeight);
 
-        this.$refs.scrollingPage1.style.width = this.w + 'px';
-        this.$refs.scrollingPage2.style.width = this.w + 'px';
+        this.lineHeight = this.fontSize + this.lineInterval;
+        this.pageRowsCount = 1 + Math.floor((this.h - this.lineHeight + this.lineInterval/2)/this.lineHeight);
+        this.pageLineCount = (this.dualPageMode ? this.pageRowsCount*2 : this.pageRowsCount)
 
         //stuff
         this.currentAnimation = '';
@@ -180,7 +192,10 @@ class TextPage extends Vue {
         this.$refs.statusBar.style.left = '0px';
         this.$refs.statusBar.style.top = (this.statusBarTop ? 1 : this.realHeight - this.statusBarHeight) + 'px';
 
-        this.statusBarColor = this.hex2rgba(this.textColor || '#000000', this.statusBarColorAlpha);
+        const sbColor = (this.statusBarColorAsText ? this.textColor : this.statusBarColor);
+        this.statusBarRgbaColor = this.hex2rgba(sbColor || '#000000', this.statusBarColorAlpha);
+        const ddColor = (this.dualDivColorAsText ? this.textColor : this.dualDivColor);
+        this.dualDivRgbaColor = this.hex2rgba(ddColor || '#000000', this.dualDivColorAlpha);
 
         //drawHelper
         this.drawHelper.realWidth = this.realWidth;
@@ -188,10 +203,20 @@ class TextPage extends Vue {
         this.drawHelper.lastBook = this.lastBook;
         this.drawHelper.book = this.book;
         this.drawHelper.parsed = this.parsed;
+        this.drawHelper.pageRowsCount = this.pageRowsCount;
         this.drawHelper.pageLineCount = this.pageLineCount;
 
+        this.drawHelper.dualPageMode = this.dualPageMode;
+        this.drawHelper.dualIndentLR = this.dualIndentLR;
+        /*this.drawHelper.dualDivWidth = this.dualDivWidth;
+        this.drawHelper.dualDivHeight = this.dualDivHeight;
+        this.drawHelper.dualDivRgbaColor = this.dualDivRgbaColor;
+        this.drawHelper.dualDivStrokeFill = this.dualDivStrokeFill;
+        this.drawHelper.dualDivStrokeGap = this.dualDivStrokeGap;
+        this.drawHelper.dualDivShadowWidth = this.dualDivShadowWidth;*/
+
         this.drawHelper.backgroundColor = this.backgroundColor;
-        this.drawHelper.statusBarColor = this.statusBarColor;
+        this.drawHelper.statusBarRgbaColor = this.statusBarRgbaColor;
         this.drawHelper.fontStyle = this.fontStyle;
         this.drawHelper.fontWeight = this.fontWeight;
         this.drawHelper.fontSize = this.fontSize;
@@ -200,6 +225,7 @@ class TextPage extends Vue {
         this.drawHelper.textColor = this.textColor;
         this.drawHelper.textShift = this.textShift;
         this.drawHelper.p = this.p;
+        this.drawHelper.boxW = this.boxW;
         this.drawHelper.w = this.w;
         this.drawHelper.h = this.h;
         this.drawHelper.indentLR = this.indentLR;
@@ -226,34 +252,57 @@ class TextPage extends Vue {
         //statusBar
         this.statusBarClickable = this.drawHelper.statusBarClickable(this.statusBarTop, this.statusBarHeight);
 
+        //wallpaper css, асинхронно
+        (async() => {
+            const wallpaperDataLength = await wallpaperStorage.getLength();
+            if (wallpaperDataLength !== this.wallpaperDataLength) {//оптимизация
+                this.wallpaperDataLength = wallpaperDataLength;
+
+                let newCss = '';
+                for (const wp of this.userWallpapers) {
+                    const data = await wallpaperStorage.getData(wp.cssClass);
+
+                    if (!data) {
+                        //здесь будем восстанавливать данные с сервера
+                    }
+
+                    if (data) {
+                        newCss += `.${wp.cssClass} {background: url(${data}) center; background-size: 100% 100%;}`;                
+                    }
+                }
+                dynamicCss.replace('wallpapers', newCss);
+            }
+        })();
+
         //parsed
         if (this.parsed) {
-            this.parsed.p = this.p;
-            this.parsed.w = this.w;// px, ширина текста
-            this.parsed.font = this.font;
-            this.parsed.fontSize = this.fontSize;
-            this.parsed.wordWrap = this.wordWrap;
-            this.parsed.cutEmptyParagraphs = this.cutEmptyParagraphs;
-            this.parsed.addEmptyParagraphs = this.addEmptyParagraphs;
-            let t = wideLetter;
-            if (!this.drawHelper.measureText(t, {}))
+            let wideLine = wideLetter;
+            if (!this.drawHelper.measureText(wideLine, {}))
                 throw new Error('Ошибка measureText');
-            while (this.drawHelper.measureText(t, {}) < this.w) t += wideLetter;
-            this.parsed.maxWordLength = t.length - 1;
-            this.parsed.measureText = this.drawHelper.measureText.bind(this.drawHelper);
-            this.parsed.lineHeight = this.lineHeight;
-            this.parsed.showImages = this.showImages;
-            this.parsed.showInlineImagesInCenter = this.showInlineImagesInCenter;
-            this.parsed.imageHeightLines = this.imageHeightLines;
-            this.parsed.imageFitWidth = this.imageFitWidth;
-            this.parsed.compactTextPerc = this.compactTextPerc;
-
-            this.parsed.testText = 'Это тестовый текст. Его ширина выдается системой неверно некоторое время.';
-            this.parsed.testWidth = this.drawHelper.measureText(this.parsed.testText, {});
+            while (this.drawHelper.measureText(wideLine, {}) < this.w) wideLine += wideLetter;
+
+            this.parsed.setSettings({
+                p: this.p,
+                w: this.w,
+                font: this.font,
+                fontSize: this.fontSize,
+                wordWrap: this.wordWrap,
+                cutEmptyParagraphs: this.cutEmptyParagraphs,
+                addEmptyParagraphs: this.addEmptyParagraphs,
+                maxWordLength: wideLine.length - 1,
+                lineHeight: this.lineHeight,
+                showImages: this.showImages,
+                showInlineImagesInCenter: this.showInlineImagesInCenter,
+                imageHeightLines: this.imageHeightLines,
+                imageFitWidth: this.imageFitWidth,
+                compactTextPerc: this.compactTextPerc,
+                testWidth: 0,
+                measureText: this.drawHelper.measureText.bind(this.drawHelper),
+            });
         }
 
         //scrolling page
-        const pageSpace = this.scrollHeight - this.pageLineCount*this.lineHeight;
+        const pageSpace = this.scrollHeight - this.pageRowsCount*this.lineHeight;
         let top = pageSpace/2;
         if (this.showStatusBar)
             top += this.statusBarHeight*(this.statusBarTop ? 1 : 0);
@@ -262,14 +311,14 @@ class TextPage extends Vue {
         
         page1.perspective = page2.perspective = '3072px';
 
-        page1.width = page2.width = this.w + this.indentLR + 'px';
+        page1.width = page2.width = this.boxW + this.indentLR + 'px';
         page1.height = page2.height = this.scrollHeight - (pageSpace > 0 ? pageSpace : 0) + 'px';
         page1.top = page2.top = top + 'px';
         page1.left = page2.left = this.indentLR + 'px';
 
         page1 = this.$refs.scrollingPage1.style;
         page2 = this.$refs.scrollingPage2.style;
-        page1.width = page2.width = this.w + this.indentLR + 'px';
+        page1.width = page2.width = this.boxW + this.indentLR + 'px';
         page1.height = page2.height = this.scrollHeight + this.lineHeight + 'px';
     }
 
@@ -333,20 +382,36 @@ class TextPage extends Vue {
         if (!omitLoadFonts)
             await this.loadFonts();
 
-        this.draw();
-
-        // ширина шрифта некоторое время выдается неверно, поэтому
-        if (!omitLoadFonts) {
-            const parsed = this.parsed;
-
-            let i = 0;
-            const t = this.parsed.testText;
-            while (i++ < 50 && this.parsed === parsed && this.drawHelper.measureText(t, {}) === this.parsed.testWidth)
+        if (omitLoadFonts) {
+            this.draw();
+        } else {
+            // ширина шрифта некоторое время выдается неверно,
+            // не удалось событийно отловить этот момент, поэтому костыль
+            while (this.checkingFont) {
+                this.stopCheckingFont = true;
                 await utils.sleep(100);
+            }
 
-            if (this.parsed === parsed) {
-                this.parsed.testWidth = this.drawHelper.measureText(t, {});
-                this.draw();
+            this.checkingFont = true;
+            this.stopCheckingFont = false;
+            try {
+                const parsed = this.parsed;
+
+                let i = 0;
+                const t = 'Это тестовый текст. Его ширина выдается системой неправильно некоторое время.';
+                let twprev = 0;
+                //5 секунд проверяем изменения шрифта
+                while (!this.stopCheckingFont && i++ < 50 && this.parsed === parsed) {
+                    const tw = this.drawHelper.measureText(t, {});
+                    if (tw !== twprev) {
+                        this.parsed.setSettings({testWidth: tw});
+                        this.draw();
+                        twprev = tw;
+                    }
+                    await utils.sleep(100);
+                }
+            } finally {
+                this.checkingFont = false;
             }
         }
     }
@@ -430,8 +495,18 @@ class TextPage extends Vue {
     }
 
     setBackground() {
-        this.background = `<div class="layout ${this.wallpaper}" style="width: ${this.realWidth}px; height: ${this.realHeight}px;` + 
-            ` background-color: ${this.backgroundColor}"></div>`;
+        if (this.wallpaperIgnoreStatusBar) {
+            this.background = `<div class="layout" style="width: ${this.realWidth}px; height: ${this.realHeight}px;` +
+                ` background-color: ${this.backgroundColor}">` +
+                `<div class="layout ${this.wallpaper}" style="width: ${this.realWidth}px; height: ${this.scrollHeight}px; ` +
+                    `top: ${(this.showStatusBar && this.statusBarTop ? this.statusBarHeight + 1 : 0)}px; position: relative;">` +
+                `</div>` +
+            `</div>`;
+        } else {
+            this.background = `<div class="layout ${this.wallpaper}" style="width: ${this.realWidth}px; height: ${this.realHeight}px;` +
+                ` background-color: ${this.backgroundColor}"></div>`;
+        }
+
     }
 
     async onResize() {
@@ -490,7 +565,7 @@ class TextPage extends Vue {
 
     async startTextScrolling() {
         if (this.doingScrolling || !this.book || !this.parsed.textLength || !this.linesDown || this.pageLineCount < 1 ||
-            this.linesDown.length <= this.pageLineCount) {
+            this.linesDown.length <= this.pageLineCount || this.dualPageMode) {
             this.doStopScrolling();
             return;
         }
@@ -608,6 +683,7 @@ class TextPage extends Vue {
         if (!this.pageChangeAnimation)
             this.debouncedPrepareNextPage();
         this.debouncedDrawStatusBar();
+        this.debouncedDrawPageDividerAndOrnament();
 
         if (this.book && this.linesDown && this.linesDown.length < this.pageLineCount) {
             this.doEnd(true);
@@ -747,6 +823,25 @@ class TextPage extends Vue {
         }
     }
 
+    drawPageDividerAndOrnament() {
+        if (this.dualPageMode) {
+            this.pageDivider = `<div class="layout" style="width: ${this.realWidth}px; height: ${this.scrollHeight}px; ` + 
+                `top: ${(this.showStatusBar && this.statusBarTop ? this.statusBarHeight + 1 : 0)}px; position: relative;">` +
+                `<div class="fit row justify-center items-center no-wrap">` +
+                    `<div style="height: ${Math.round(this.scrollHeight*this.dualDivHeight/100)}px; width: ${this.dualDivWidth}px; ` +
+                        `box-shadow: 0 0 ${this.dualDivShadowWidth}px ${this.dualDivRgbaColor}; ` + 
+                        `background-image: url(&quot;data:image/svg+xml;utf8,<svg width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'>` +
+                            `<line x1='${this.dualDivWidth/2}' y1='0' x2='${this.dualDivWidth/2}' y2='100%' stroke='${this.dualDivRgbaColor}' ` +
+                                `stroke-width='${this.dualDivWidth}' stroke-dasharray='${this.dualDivStrokeFill} ${this.dualDivStrokeGap}'/>` +
+                        `</svg>&quot;);">` +
+                    `</div>` +
+                `</div>` +
+            `</div>`;
+        } else {
+            this.pageDivider = null;
+        }
+    }
+
     blinkCachedLoadMessage(state) {
         if (state === 'finish') {
             this.statusBarMessage = '';
@@ -1161,60 +1256,3 @@ class TextPage extends Vue {
 }
 
 </style>
-
-<style>
-.paper1 {
-    background: url("images/paper1.jpg") center;
-    background-size: cover;
-}
-
-.paper2 {
-    background: url("images/paper2.jpg") center;
-    background-size: cover;
-}
-
-.paper3 {
-    background: url("images/paper3.jpg") center;
-    background-size: cover;
-}
-
-.paper4 {
-    background: url("images/paper4.jpg") center;
-    background-size: cover;
-}
-
-.paper5 {
-    background: url("images/paper5.jpg") center;
-    background-size: cover;
-}
-
-.paper6 {
-    background: url("images/paper6.jpg") center;
-    background-size: cover;
-}
-
-.paper7 {
-    background: url("images/paper7.jpg") center;
-    background-size: cover;
-}
-
-.paper8 {
-    background: url("images/paper8.jpg") center;
-    background-size: cover;
-}
-
-.paper9 {
-    background: url("images/paper9.jpg");
-}
-
-@keyframes page1-animation-thaw {
-    0%   { opacity: 0; }
-    100% { opacity: 1; }
-}
-
-@keyframes page2-animation-thaw {
-    0%   { opacity: 1; }
-    100% { opacity: 0; }
-}
-
-</style>

binární
client/components/Reader/TextPage/images/paper10.png


binární
client/components/Reader/TextPage/images/paper11.png


binární
client/components/Reader/TextPage/images/paper12.png


binární
client/components/Reader/TextPage/images/paper13.png


binární
client/components/Reader/TextPage/images/paper14.png


binární
client/components/Reader/TextPage/images/paper15.png


binární
client/components/Reader/TextPage/images/paper16.png


binární
client/components/Reader/TextPage/images/paper17.png


+ 236 - 164
client/components/Reader/share/BookParser.js

@@ -4,23 +4,55 @@ import * as utils from '../../../share/utils';
 
 const maxImageLineCount = 100;
 
-export default class BookParser {
-    constructor(settings) {
-        if (settings) {
-            this.showInlineImagesInCenter = settings.showInlineImagesInCenter;
-        }
+// defaults
+const defaultSettings = {
+    p: 30,  //px, отступ параграфа
+    w: 500, //px, ширина страницы
+
+    font: '', //css описание шрифта
+    fontSize: 20, //px, размер шрифта
+    wordWrap: false, //перенос по слогам
+    cutEmptyParagraphs: false, //убирать пустые параграфы
+    addEmptyParagraphs: 0, //добавлять n пустых параграфов перед непустым
+    maxWordLength: 500, //px, максимальная длина слова без пробелов
+    lineHeight: 26, //px, высота строки
+    showImages: true, //показыввать изображения
+    showInlineImagesInCenter: true, //выносить изображения в центр, работает на этапе первичного парсинга (parse)
+    imageHeightLines: 100, //кол-во строк, максимальная высота изображения
+    imageFitWidth: true, //ширина изображения не более ширины страницы
+    dualPageMode: false, //двухстраничный режим
+    compactTextPerc: 0, //проценты, степень компактности текста
+    testWidth: 0, //ширина тестовой строки, пересчитывается извне при изменении шрифта браузером
+    isTesting: false, //тестовый режим
+
+    //заглушка, измеритель ширины текста
+    measureText: (text, style) => {// eslint-disable-line no-unused-vars
+        return text.length*20;
+    },
+};
+
+//for splitToSlogi()
+const glas = new Set(['а', 'А', 'о', 'О', 'и', 'И', 'е', 'Е', 'ё', 'Ё', 'э', 'Э', 'ы', 'Ы', 'у', 'У', 'ю', 'Ю', 'я', 'Я']);
+const soglas = new Set([
+    'б', 'в', 'г', 'д', 'ж', 'з', 'й', 'к', 'л', 'м', 'н', 'п', 'р', 'с', 'т', 'ф', 'х', 'ц', 'ч', 'ш', 'щ',
+    'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'Й', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', 'Ч', 'Ц', 'Ш', 'Щ'
+]);
+const znak = new Set(['ь', 'Ь', 'ъ', 'Ъ', 'й', 'Й']);
+const alpha = new Set([...glas, ...soglas, ...znak]);
 
-        // defaults
-        this.p = 30;// px, отступ параграфа
-        this.w = 300;// px, ширина страницы
-        this.wordWrap = false;// перенос по слогам
+export default class BookParser {
+    constructor(settings = {}) {
+        this.sets = {};
 
-        //заглушка
-        this.measureText = (text, style) => {// eslint-disable-line no-unused-vars
-            return text.length*20;
-        };
+        this.setSettings(defaultSettings);
+        this.setSettings(settings);
     }
 
+    setSettings(settings = {}) {
+        this.sets = Object.assign({}, this.sets, settings);
+        this.measureText = this.sets.measureText;
+    }
+    
     async parse(data, callback) {
         if (!callback)
             callback = () => {};
@@ -76,6 +108,7 @@ export default class BookParser {
         */
         const getImageDimensions = (binaryId, binaryType, data) => {
             return new Promise ((resolve, reject) => { (async() => {
+                data = data.replace(/[\n\r\s]/g, '');
                 const i = new Image();
                 let resolved = false;
                 i.onload = () => {
@@ -120,14 +153,59 @@ export default class BookParser {
             })().catch(reject); });
         };
 
-        const newParagraph = (text, len, addIndex) => {
+        const correctCurrentPara = () => {
+            //коррекция текущего параграфа
+            if (paraIndex >= 0) {
+                const prevParaIndex = paraIndex;
+                let p = para[paraIndex];
+                paraOffset -= p.length;
+                //добавление пустых (addEmptyParagraphs) параграфов перед текущим непустым
+                if (p.text.trim() != '') {
+                    for (let i = 0; i < 2; i++) {
+                        para[paraIndex] = {
+                            index: paraIndex,
+                            offset: paraOffset,
+                            length: 1,
+                            text: ' ',
+                            addIndex: i + 1,
+                        };
+                        paraIndex++;
+                        paraOffset++;
+                    }
+
+                    if (curTitle.paraIndex == prevParaIndex)
+                        curTitle.paraIndex = paraIndex;
+                    if (curSubtitle.paraIndex == prevParaIndex)
+                        curSubtitle.paraIndex = paraIndex;
+                }
+
+                //уберем пробелы с концов параграфа, минимум 1 пробел должен быть у пустого параграфа
+                let newParaText = p.text.trim();
+                newParaText = (newParaText.length ? newParaText : ' ');
+                const ldiff = p.text.length - newParaText.length;
+                if (ldiff != 0) {
+                    p.text = newParaText;
+                    p.length -= ldiff;
+                }
+
+                p.index = paraIndex;
+                p.offset = paraOffset;
+                para[paraIndex] = p;
+                paraOffset += p.length;
+            }
+        };
+
+        const newParagraph = (text = '', len = 0) => {
+            correctCurrentPara();
+
+            //новый параграф
             paraIndex++;
             let p = {
                 index: paraIndex,
                 offset: paraOffset,
                 length: len,
                 text: text,
-                addIndex: (addIndex ? addIndex : 0),
+                addIndex: 0,
             };
 
             if (inSubtitle) {
@@ -137,53 +215,26 @@ export default class BookParser {
             }
 
             para[paraIndex] = p;
-            paraOffset += p.length;
+            paraOffset += len;
         };
 
         const growParagraph = (text, len) => {
             if (paraIndex < 0) {
-                newParagraph(' ', 1);
+                newParagraph();
                 growParagraph(text, len);
                 return;
             }
 
-            const prevParaIndex = paraIndex;
-            let p = para[paraIndex];
-            paraOffset -= p.length;
-            //добавление пустых (addEmptyParagraphs) параграфов перед текущим
-            if (p.length == 1 && p.text[0] == ' ' && len > 0) {
-                paraIndex--;
-                for (let i = 0; i < 2; i++) {
-                    newParagraph(' ', 1, i + 1);
-                }
-
-                paraIndex++;
-                p.index = paraIndex;
-                p.offset = paraOffset;
-                para[paraIndex] = p;
-
-                if (curTitle.paraIndex == prevParaIndex)
-                    curTitle.paraIndex = paraIndex;
-                if (curSubtitle.paraIndex == prevParaIndex)
-                    curSubtitle.paraIndex = paraIndex;
-
-                //уберем начальный пробел
-                p.length = 0;
-                p.text = p.text.substr(1);
-            }
-
-            p.length += len;
-            p.text += text;
-
-            
             if (inSubtitle) {
                 curSubtitle.title += text;
             } else if (inTitle) {
                 curTitle.title += text;
             }
 
-            para[paraIndex] = p;
-            paraOffset += p.length;
+            const p = para[paraIndex];
+            p.length += len;
+            p.text += text;
+            paraOffset += len;
         };
 
         const onStartNode = (elemName, tail) => {// eslint-disable-line no-unused-vars
@@ -196,8 +247,8 @@ export default class BookParser {
             if (tag == 'binary') {
                 let attrs = sax.getAttrsSync(tail);
                 binaryType = (attrs['content-type'] && attrs['content-type'].value ? attrs['content-type'].value : '');
-                binaryType = (binaryType == 'image/jpg' ? 'image/jpeg' : binaryType);
-                if (binaryType == 'image/jpeg' || binaryType == 'image/png' || binaryType == 'application/octet-stream')
+                binaryType = (binaryType == 'image/jpg' || binaryType == 'application/octet-stream' ? 'image/jpeg' : binaryType);
+                if (binaryType == 'image/jpeg' || binaryType == 'image/png')
                     binaryId = (attrs.id.value ? attrs.id.value : '');
             }
 
@@ -210,19 +261,23 @@ export default class BookParser {
                     if (href[0] == '#') {//local
                         imageNum++;
 
-                        if (inPara && !this.showInlineImagesInCenter && !center)
+                        if (inPara && !this.sets.showInlineImagesInCenter && !center)
                             growParagraph(`<image-inline href="${href}" num="${imageNum}"></image-inline>`, 0);
                         else
                             newParagraph(`<image href="${href}" num="${imageNum}">${' '.repeat(maxImageLineCount)}</image>`, maxImageLineCount);
 
                         this.images.push({paraIndex, num: imageNum, id, local, alt});
 
-                        if (inPara && this.showInlineImagesInCenter)
-                            newParagraph(' ', 1);
+                        if (inPara && this.sets.showInlineImagesInCenter)
+                            newParagraph();
                     } else {//external
                         imageNum++;
 
-                        dimPromises.push(getExternalImageDimensions(href));
+                        if (!this.sets.isTesting) {
+                            dimPromises.push(getExternalImageDimensions(href));
+                        } else {
+                            dimPromises.push(this.sets.getExternalImageDimensions(this, href));
+                        }
                         newParagraph(`<image href="${href}" num="${imageNum}">${' '.repeat(maxImageLineCount)}</image>`, maxImageLineCount);
 
                         this.images.push({paraIndex, num: imageNum, id, local, alt});
@@ -264,25 +319,25 @@ export default class BookParser {
                             newParagraph(`<emphasis><space w="1">${a}</space></emphasis>`, a.length);
                         });
                         if (ann.length)
-                            newParagraph(' ', 1);
+                            newParagraph();
                     }
 
                     if (isFirstBody && fb2.sequence && fb2.sequence.length) {
                         const bt = utils.getBookTitle(fb2);
                         if (bt.sequence) {
                             newParagraph(bt.sequence, bt.sequence.length);
-                            newParagraph(' ', 1);
+                            newParagraph();
                         }
                     }
 
                     if (!isFirstBody)
-                        newParagraph(' ', 1);
+                        newParagraph();
                     isFirstBody = false;
                     bodyIndex++;
                 }
 
                 if (tag == 'title') {
-                    newParagraph(' ', 1);
+                    newParagraph();
                     isFirstTitlePara = true;
                     bold = true;
                     center = true;
@@ -294,7 +349,7 @@ export default class BookParser {
 
                 if (tag == 'section') {
                     if (!isFirstSection)
-                        newParagraph(' ', 1);
+                        newParagraph();
                     isFirstSection = false;
                     sectionLevel++;
                 }
@@ -305,7 +360,7 @@ export default class BookParser {
 
                 if ((tag == 'p' || tag == 'empty-line' || tag == 'v')) {
                     if (!(tag == 'p' && isFirstTitlePara))
-                        newParagraph(' ', 1);
+                        newParagraph();
                     if (tag == 'p') {
                         inPara = true;
                         isFirstTitlePara = false;
@@ -313,7 +368,7 @@ export default class BookParser {
                 }
 
                 if (tag == 'subtitle') {
-                    newParagraph(' ', 1);
+                    newParagraph();
                     isFirstTitlePara = true;
                     bold = true;
                     center = true;
@@ -334,11 +389,12 @@ export default class BookParser {
                 }
 
                 if (tag == 'poem') {
-                    newParagraph(' ', 1);
+                    newParagraph();
                 }
 
                 if (tag == 'text-author') {
-                    newParagraph(' ', 1);
+                    newParagraph();
+                    bold = true;
                     space += 1;
                 }
             }
@@ -380,15 +436,15 @@ export default class BookParser {
                     if (tag == 'epigraph' || tag == 'annotation') {
                         italic = false;
                         space -= 1;
-                        if (tag == 'annotation')
-                            newParagraph(' ', 1);
+                        newParagraph();
                     }
 
                     if (tag == 'stanza') {
-                        newParagraph(' ', 1);
+                        newParagraph();
                     }
 
                     if (tag == 'text-author') {
+                        bold = false;
                         space -= 1;
                     }
                 }
@@ -405,17 +461,14 @@ export default class BookParser {
 
         const onTextNode = (text) => {// eslint-disable-line no-unused-vars
             text = he.decode(text);
-            text = text.replace(/>/g, '&gt;');
-            text = text.replace(/</g, '&lt;');
+            text = text.replace(/>/g, '&gt;').replace(/</g, '&lt;').replace(/[\t\n\r\xa0]/g, ' ');
 
             if (text && text.trim() == '')
-                text = (text.indexOf(' ') >= 0 ? ' ' : '');
+                text = ' ';
 
             if (!text)
                 return;
 
-            text = text.replace(/[\t\n\r\xa0]/g, ' ');
-
             const authorLength = (fb2.author && fb2.author.length ? fb2.author.length : 0);
             switch (path) {
                 case '/fictionbook/description/title-info/author/first-name':
@@ -453,24 +506,31 @@ export default class BookParser {
                     fb2.annotation += text;
             }
 
-            let tOpen = (center ? '<center>' : '');
-            tOpen += (bold ? '<strong>' : '');
-            tOpen += (italic ? '<emphasis>' : '');
-            tOpen += (space ? `<space w="${space}">` : '');
-            let tClose = (space ? '</space>' : '');
-            tClose += (italic ? '</emphasis>' : '');
-            tClose += (bold ? '</strong>' : '');
-            tClose += (center ? '</center>' : '');
+            if (binaryId) {
+                if (!this.sets.isTesting) {
+                    dimPromises.push(getImageDimensions(binaryId, binaryType, text));
+                } else {
+                    dimPromises.push(this.sets.getImageDimensions(this, binaryId, binaryType, text));
+                }
+            }
 
             if (path.indexOf('/fictionbook/body/title') == 0 ||
                 path.indexOf('/fictionbook/body/section') == 0 ||
                 path.indexOf('/fictionbook/body/epigraph') == 0
                 ) {
-                growParagraph(`${tOpen}${text}${tClose}`, text.length);
-            }
-
-            if (binaryId) {
-                dimPromises.push(getImageDimensions(binaryId, binaryType, text));
+                let tOpen = (center ? '<center>' : '');
+                tOpen += (bold ? '<strong>' : '');
+                tOpen += (italic ? '<emphasis>' : '');
+                tOpen += (space ? `<space w="${space}">` : '');
+                let tClose = (space ? '</space>' : '');
+                tClose += (italic ? '</emphasis>' : '');
+                tClose += (bold ? '</strong>' : '');
+                tClose += (center ? '</center>' : '');
+
+                if (text != ' ')
+                    growParagraph(`${tOpen}${text}${tClose}`, text.length);
+                else
+                    growParagraph(' ', 1);
             }
         };
 
@@ -482,6 +542,7 @@ export default class BookParser {
         await sax.parse(data, {
             onStartNode, onEndNode, onTextNode, onProgress
         });
+        correctCurrentPara();
 
         if (dimPromises.length) {
             try {
@@ -542,9 +603,19 @@ export default class BookParser {
         let style = {};
         let image = {};
 
+        //оптимизация по памяти
+        const copyStyle = (s) => {
+            const r = {};
+            for (const prop in s) {
+                if (s[prop])
+                    r[prop] = s[prop];
+            }
+            return r;
+        };
+
         const onTextNode = async(text) => {// eslint-disable-line no-unused-vars
             result.push({
-                style: Object.assign({}, style),
+                style: copyStyle(style),
                 image,
                 text
             });
@@ -589,7 +660,7 @@ export default class BookParser {
                         img.inline = true;
                         img.num = (attrs.num && attrs.num.value ? attrs.num.value : 0);
                         result.push({
-                            style: Object.assign({}, style),
+                            style: copyStyle(style),
                             image: img,
                             text: ''
                         });
@@ -632,7 +703,7 @@ export default class BookParser {
         });
 
         //длинные слова (или белиберду без пробелов) тоже разобьем
-        const maxWordLength = this.maxWordLength;
+        const maxWordLength = this.sets.maxWordLength;
         const parts = result;
         result = [];
         for (const part of parts) {
@@ -645,7 +716,7 @@ export default class BookParser {
                         spaceIndex = i;
 
                     if (i - spaceIndex >= maxWordLength && i < p.text.length - 1 && 
-                        this.measureText(p.text.substr(spaceIndex + 1, i - spaceIndex), p.style) >= this.w - this.p) {
+                        this.measureText(p.text.substr(spaceIndex + 1, i - spaceIndex), p.style) >= this.sets.w - this.sets.p) {
                         result.push({style: p.style, image: p.image, text: p.text.substr(0, i + 1)});
                         p = {style: p.style, image: p.image, text: p.text.substr(i + 1)};
                         spaceIndex = -1;
@@ -663,86 +734,87 @@ export default class BookParser {
     splitToSlogi(word) {
         let result = [];
 
-        const glas = new Set(['а', 'А', 'о', 'О', 'и', 'И', 'е', 'Е', 'ё', 'Ё', 'э', 'Э', 'ы', 'Ы', 'у', 'У', 'ю', 'Ю', 'я', 'Я']);
-        const soglas = new Set([
-            'б', 'в', 'г', 'д', 'ж', 'з', 'й', 'к', 'л', 'м', 'н', 'п', 'р', 'с', 'т', 'ф', 'х', 'ц', 'ч', 'ш', 'щ',
-            'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'Й', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', 'Ч', 'Ц', 'Ш', 'Щ'
-        ]);
-        const znak = new Set(['ь', 'Ь', 'ъ', 'Ъ', 'й', 'Й']);
-        const alpha = new Set([...glas, ...soglas, ...znak]);
-
-        let slog = '';
-        let slogLen = 0;
         const len = word.length;
-        word += '   ';
-        for (let i = 0; i < len; i++) {
-            slog += word[i];
-            if (alpha.has(word[i]))
-                slogLen++;
-
-            if (slogLen > 1 && i < len - 2 && (
-                    //гласная, а следом не 2 согласные буквы
-                    (glas.has(word[i]) && !(soglas.has(word[i + 1]) && 
-                        soglas.has(word[i + 2])) && alpha.has(word[i + 1]) && alpha.has(word[i + 2])
-                    ) ||
-                    //предыдущая не согласная буква, текущая согласная, а следом согласная и согласная|гласная буквы
-                    (alpha.has(word[i - 1]) && !soglas.has(word[i - 1]) && 
-                        soglas.has(word[i]) && soglas.has(word[i + 1]) && 
-                        (glas.has(word[i + 2]) || soglas.has(word[i + 2])) && 
-                        alpha.has(word[i + 1]) && alpha.has(word[i + 2])
-                    ) ||
-                    //мягкий или твердый знак или Й
-                    (znak.has(word[i]) && alpha.has(word[i + 1]) && alpha.has(word[i + 2])) ||
-                    (word[i] == '-')
-                ) &&
-                //нельзя оставлять окончания на ь, ъ, й
-                !(znak.has(word[i + 2]) && !alpha.has(word[i + 3]))
-
-                ) {
-                result.push(slog);
-                slog = '';
-                slogLen = 0;
+        if (len > 3) {
+            let slog = '';
+            let slogLen = 0;
+            word += '   ';
+            for (let i = 0; i < len; i++) {
+                slog += word[i];
+                if (alpha.has(word[i]))
+                    slogLen++;
+
+                if (slogLen > 1 && i < len - 2 && (
+                        //гласная, а следом не 2 согласные буквы
+                        (glas.has(word[i]) && !( soglas.has(word[i + 1]) && soglas.has(word[i + 2]) ) &&
+                            alpha.has(word[i + 1]) && alpha.has(word[i + 2])
+                        ) ||
+                        //предыдущая не согласная буква, текущая согласная, а следом согласная и согласная|гласная буквы
+                        (alpha.has(word[i - 1]) && !soglas.has(word[i - 1]) && soglas.has(word[i]) && soglas.has(word[i + 1]) && 
+                            ( glas.has(word[i + 2]) || soglas.has(word[i + 2]) ) && 
+                            alpha.has(word[i + 1]) && alpha.has(word[i + 2])
+                        ) ||
+                        //мягкий или твердый знак или Й
+                        (znak.has(word[i]) && alpha.has(word[i + 1]) && alpha.has(word[i + 2])) ||
+                        (word[i] == '-')
+                    ) &&
+                    //нельзя оставлять окончания на ь, ъ, й
+                    !(znak.has(word[i + 2]) && !alpha.has(word[i + 3]))
+
+                    ) {
+                    result.push(slog);
+                    slog = '';
+                    slogLen = 0;
+                }
             }
+            if (slog)
+                result.push(slog);
+        } else {
+            result.push(word);            
         }
-        if (slog)
-            result.push(slog);
 
         return result;
     }
 
     parsePara(paraIndex) {
         const para = this.para[paraIndex];
+        const s = this.sets;
 
+        //перераспарсиваем только при изменении одного из параметров
         if (!this.force &&
             para.parsed && 
-            para.parsed.testWidth === this.testWidth &&
-            para.parsed.w === this.w &&
-            para.parsed.p === this.p &&
-            para.parsed.wordWrap === this.wordWrap &&
-            para.parsed.maxWordLength === this.maxWordLength &&
-            para.parsed.font === this.font &&
-            para.parsed.cutEmptyParagraphs === this.cutEmptyParagraphs &&
-            para.parsed.addEmptyParagraphs === this.addEmptyParagraphs &&
-            para.parsed.showImages === this.showImages &&
-            para.parsed.imageHeightLines === this.imageHeightLines &&
-            para.parsed.imageFitWidth === this.imageFitWidth &&
-            para.parsed.compactTextPerc === this.compactTextPerc
+            para.parsed.p === s.p &&
+            para.parsed.w === s.w &&
+            para.parsed.font === s.font &&
+            para.parsed.fontSize === s.fontSize &&
+            para.parsed.wordWrap === s.wordWrap &&
+            para.parsed.cutEmptyParagraphs === s.cutEmptyParagraphs &&
+            para.parsed.addEmptyParagraphs === s.addEmptyParagraphs &&
+            para.parsed.maxWordLength === s.maxWordLength &&
+            para.parsed.lineHeight === s.lineHeight &&
+            para.parsed.showImages === s.showImages &&
+            para.parsed.imageHeightLines === s.imageHeightLines &&
+            para.parsed.imageFitWidth === (s.imageFitWidth || s.dualPageMode) &&
+            para.parsed.compactTextPerc === s.compactTextPerc &&
+            para.parsed.testWidth === s.testWidth
             )
             return para.parsed;
 
         const parsed = {
-            testWidth: this.testWidth,
-            w: this.w,
-            p: this.p,
-            wordWrap: this.wordWrap,
-            maxWordLength: this.maxWordLength,
-            font: this.font,
-            cutEmptyParagraphs: this.cutEmptyParagraphs,
-            addEmptyParagraphs: this.addEmptyParagraphs,
-            showImages: this.showImages,
-            imageHeightLines: this.imageHeightLines,
-            imageFitWidth: this.imageFitWidth,
-            compactTextPerc: this.compactTextPerc,
+            p: s.p,
+            w: s.w,
+            font: s.font,
+            fontSize: s.fontSize,
+            wordWrap: s.wordWrap,
+            cutEmptyParagraphs: s.cutEmptyParagraphs,
+            addEmptyParagraphs: s.addEmptyParagraphs,
+            maxWordLength: s.maxWordLength,
+            lineHeight: s.lineHeight,
+            showImages: s.showImages,
+            imageHeightLines: s.imageHeightLines,
+            imageFitWidth: (s.imageFitWidth || s.dualPageMode),
+            compactTextPerc: s.compactTextPerc,
+            testWidth: s.testWidth,
             visible: true, //вычисляется позже
         };
 
@@ -774,7 +846,7 @@ export default class BookParser {
         let ofs = 0;//смещение от начала параграфа para.offset
         let imgW = 0;
         let imageInPara = false;
-        const compactWidth = this.measureText('W', {})*this.compactTextPerc/100;
+        const compactWidth = this.measureText('W', {})*parsed.compactTextPerc/100;
         // тут начинается самый замес, перенос по слогам и стилизация, а также изображения
         for (const part of parts) {
             style = part.style;
@@ -787,14 +859,14 @@ export default class BookParser {
                 if (!bin)
                     bin = {h: 1, w: 1};
 
-                let lineCount = this.imageHeightLines;
-                let c = Math.ceil(bin.h/this.lineHeight);
+                let lineCount = parsed.imageHeightLines;
+                let c = Math.ceil(bin.h/parsed.lineHeight);
 
-                const maxH = lineCount*this.lineHeight;
+                const maxH = lineCount*parsed.lineHeight;
                 let maxH2 = maxH;
-                if (this.imageFitWidth && bin.w > this.w) {
-                    maxH2 = bin.h*this.w/bin.w;
-                    c = Math.ceil(maxH2/this.lineHeight);
+                if (parsed.imageFitWidth && bin.w > parsed.w) {
+                    maxH2 = bin.h*parsed.w/bin.w;
+                    c = Math.ceil(maxH2/parsed.lineHeight);
                 }
                 lineCount = (c < lineCount ? c : lineCount);
 
@@ -834,10 +906,10 @@ export default class BookParser {
                 continue;
             }
 
-            if (part.image.id && part.image.inline && this.showImages) {
+            if (part.image.id && part.image.inline && parsed.showImages) {
                 const bin = this.binary[part.image.id];
                 if (bin) {
-                    let imgH = (bin.h > this.fontSize ? this.fontSize : bin.h);
+                    let imgH = (bin.h > parsed.fontSize ? parsed.fontSize : bin.h);
                     imgW += bin.w*imgH/bin.h;
                     line.parts.push({style, text: '',
                         image: {local: part.image.local, inline: true, id: part.image.id, num: part.image.num}});
@@ -952,11 +1024,11 @@ export default class BookParser {
 
         //parsed.visible
         if (imageInPara) {
-            parsed.visible = this.showImages;
+            parsed.visible = parsed.showImages;
         } else {
             parsed.visible = !(
-                (para.addIndex > this.addEmptyParagraphs) ||
-                (para.addIndex == 0 && this.cutEmptyParagraphs && paragraphText.trim() == '')
+                (para.addIndex > parsed.addEmptyParagraphs) ||
+                (para.addIndex == 0 && parsed.cutEmptyParagraphs && paragraphText.trim() == '')
             );
         }
 

+ 27 - 0
client/components/Reader/share/wallpaperStorage.js

@@ -0,0 +1,27 @@
+import localForage from 'localforage';
+//import _ from 'lodash';
+
+const wpStore = localForage.createInstance({
+    name: 'wallpaperStorage'
+});
+
+class WallpaperStorage {
+
+    async getLength() {
+        return await wpStore.length();
+    }
+
+    async setData(key, data) {
+        await wpStore.setItem(key, data);
+    }
+
+    async getData(key) {
+        return await wpStore.getItem(key);
+    }
+
+    async removeData(key) {
+        await wpStore.removeItem(key);
+    }
+}
+
+export default new WallpaperStorage();

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

@@ -1,4 +1,18 @@
 export const versionHistory = [
+{
+    showUntil: '2021-02-16',
+    header: '0.10.0 (2021-02-09)',
+    content:
+`
+<ul>
+    <li>добавлен двухстраничный режим</li>
+    <li>в настройки добавлены все кириллические веб-шрифты от google</li>
+    <li>в настройки добавлена возможность загрузки пользовательских обоев (пока без синхронизации)</li>
+    <li>немного улучшен парсинг fb2</li>
+</ul>
+`
+},
+
 {
     showUntil: '2020-12-17',
     header: '0.9.12 (2020-12-18)',

+ 4 - 2
client/quasar.js

@@ -21,7 +21,8 @@ import {QSlider} from 'quasar/src/components/slider';
 import {QTabs, QTab} from 'quasar/src/components/tabs';
 //import {QTabPanels, QTabPanel} from 'quasar/src/components/tab-panels';
 import {QSeparator} from 'quasar/src/components/separator';
-//import {QList, QItem, QItemSection, QItemLabel} from 'quasar/src/components/item';
+//import {QList} from 'quasar/src/components/item';
+import {QItem, QItemSection, QItemLabel} from 'quasar/src/components/item';
 import {QTooltip} from 'quasar/src/components/tooltip';
 import {QSpinner} from 'quasar/src/components/spinner';
 import {QTable, QTh, QTr, QTd} from 'quasar/src/components/table';
@@ -49,7 +50,8 @@ const components = {
     QTabs, QTab,
     //QTabPanels, QTabPanel,
     QSeparator,
-    //QList, QItem, QItemSection, QItemLabel,
+    //QList,
+    QItem, QItemSection, QItemLabel,
     QTooltip,
     QSpinner,
     QTable, QTh, QTr, QTd,

+ 22 - 0
client/share/dynamicCss.js

@@ -0,0 +1,22 @@
+class DynamicCss {
+    constructor() {
+        this.cssNodes = {};
+    }
+
+    replace(name, cssText) {
+        const style = document.createElement('style');
+        style.type = 'text/css';
+        style.innerHTML = cssText;
+
+        const parent = document.getElementsByTagName('head')[0];
+
+        if (this.cssNodes[name]) {
+            parent.removeChild(this.cssNodes[name]);
+            delete this.cssNodes[name];
+        }
+
+        this.cssNodes[name] = parent.appendChild(style);
+    }
+}
+
+export default new DynamicCss();

+ 1 - 0
client/store/modules/fonts/fonts.json

@@ -0,0 +1 @@
+["Alegreya","Alegreya SC","Alegreya Sans","Alegreya Sans SC","Alice","Amatic SC","Andika","Anonymous Pro","Arimo","Arsenal","Bad Script","Balsamiq Sans","Bellota","Bellota Text","Bitter","Caveat","Comfortaa","Commissioner","Cormorant","Cormorant Garamond","Cormorant Infant","Cormorant SC","Cormorant Unicase","Cousine","Cuprum","Didact Gothic","EB Garamond","El Messiri","Exo 2","Fira Code","Fira Mono","Fira Sans","Fira Sans Condensed","Fira Sans Extra Condensed","Forum","Gabriela","Hachi Maru Pop","IBM Plex Mono","IBM Plex Sans","IBM Plex Serif","Inter","Istok Web","JetBrains Mono","Jost","Jura","Kelly Slab","Kosugi","Kosugi Maru","Kurale","Ledger","Literata","Lobster","Lora","M PLUS 1p","M PLUS Rounded 1c","Manrope","Marck Script","Marmelad","Merriweather","Montserrat","Montserrat Alternates","Neucha","Noto Sans","Noto Serif","Nunito","Old Standard TT","Open Sans","Open Sans Condensed","Oranienbaum","Oswald","PT Mono","PT Sans","PT Sans Caption","PT Sans Narrow","PT Serif","PT Serif Caption","Pacifico","Pangolin","Pattaya","Philosopher","Piazzolla","Play","Playfair Display","Playfair Display SC","Podkova","Poiret One","Prata","Press Start 2P","Prosto One","Raleway","Roboto","Roboto Condensed","Roboto Mono","Roboto Slab","Rubik","Rubik Mono One","Ruda","Ruslan Display","Russo One","Sawarabi Gothic","Scada","Seymour One","Source Code Pro","Source Sans Pro","Source Serif Pro","Spectral","Spectral SC","Stalinist One","Tenor Sans","Tinos","Ubuntu","Ubuntu Condensed","Ubuntu Mono","Underdog","Viaoda Libre","Vollkorn","Vollkorn SC","Yanone Kaffeesatz","Yeseva One"]

+ 13 - 0
client/store/modules/fonts/fonts2list.js

@@ -0,0 +1,13 @@
+const fs = require('fs-extra');
+
+async function main() {
+    const webfonts = await fs.readFile('webfonts.json');
+    let fonts = JSON.parse(webfonts);
+
+    fonts = fonts.items.filter(item => item.subsets.includes('cyrillic'));
+    fonts = fonts.map(item => item.family);
+    fonts.sort();
+
+    await fs.writeFile('fonts.json', JSON.stringify(fonts));
+}
+main();

+ 30 - 117
client/store/modules/reader.js

@@ -1,4 +1,5 @@
 import * as utils from '../../share/utils';
+import googleFonts from './fonts/fonts.json';
 
 const readerActions = {
     'help': 'Вызвать cправку',
@@ -91,125 +92,22 @@ const fonts = [
     {name: 'Rubik', fontVertShift: 0},
 ];
 
-const webFonts = [
-    {css: 'https://fonts.googleapis.com/css?family=Alegreya', name: 'Alegreya', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Alegreya+Sans', name: 'Alegreya Sans', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Alegreya+SC', name: 'Alegreya SC', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Alice', name: 'Alice', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Amatic+SC', name: 'Amatic SC', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Andika', name: 'Andika', fontVertShift: -35},
-    {css: 'https://fonts.googleapis.com/css?family=Anonymous+Pro', name: 'Anonymous Pro', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Arsenal', name: 'Arsenal', fontVertShift: 0},
-
-    {css: 'https://fonts.googleapis.com/css?family=Bad+Script', name: 'Bad Script', fontVertShift: -30},
-
-    {css: 'https://fonts.googleapis.com/css?family=Caveat', name: 'Caveat', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Comfortaa', name: 'Comfortaa', fontVertShift: 10},
-    {css: 'https://fonts.googleapis.com/css?family=Cormorant', name: 'Cormorant', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Cormorant+Garamond', name: 'Cormorant Garamond', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Cormorant+Infant', name: 'Cormorant Infant', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Cormorant+Unicase', name: 'Cormorant Unicase', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Cousine', name: 'Cousine', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Cuprum', name: 'Cuprum', fontVertShift: 5},
-
-    {css: 'https://fonts.googleapis.com/css?family=Didact+Gothic', name: 'Didact Gothic', fontVertShift: -10},
-
-    {css: 'https://fonts.googleapis.com/css?family=EB+Garamond', name: 'EB Garamond', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=El+Messiri', name: 'El Messiri', fontVertShift: -5},
-
-    {css: 'https://fonts.googleapis.com/css?family=Fira+Mono', name: 'Fira Mono', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Fira+Sans', name: 'Fira Sans', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Fira+Sans+Condensed', name: 'Fira Sans Condensed', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Fira+Sans+Extra+Condensed', name: 'Fira Sans Extra Condensed', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Forum', name: 'Forum', fontVertShift: 5},
-
-    {css: 'https://fonts.googleapis.com/css?family=Gabriela', name: 'Gabriela', fontVertShift: 5},
-
-    {css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Mono', name: 'IBM Plex Mono', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Sans', name: 'IBM Plex Sans', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Serif', name: 'IBM Plex Serif', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Istok+Web', name: 'Istok Web', fontVertShift: -5},
-
-    {css: 'https://fonts.googleapis.com/css?family=Jura', name: 'Jura', fontVertShift: 0},
-
-    {css: 'https://fonts.googleapis.com/css?family=Kelly+Slab', name: 'Kelly Slab', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Kosugi', name: 'Kosugi', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Kosugi+Maru', name: 'Kosugi Maru', fontVertShift: 10},
-    {css: 'https://fonts.googleapis.com/css?family=Kurale', name: 'Kurale', fontVertShift: -15},
-
-    {css: 'https://fonts.googleapis.com/css?family=Ledger', name: 'Ledger', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Lobster', name: 'Lobster', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Lora', name: 'Lora', fontVertShift: 0},
-
-    {css: 'https://fonts.googleapis.com/css?family=Marck+Script', name: 'Marck Script', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Marmelad', name: 'Marmelad', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Merriweather', name: 'Merriweather', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Montserrat', name: 'Montserrat', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Montserrat+Alternates', name: 'Montserrat Alternates', fontVertShift: 0},
-
-    {css: 'https://fonts.googleapis.com/css?family=Neucha', name: 'Neucha', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Noto+Sans', name: 'Noto Sans', fontVertShift: -10},
-    {css: 'https://fonts.googleapis.com/css?family=Noto+Sans+SC', name: 'Noto Sans SC', fontVertShift: -15},
-    {css: 'https://fonts.googleapis.com/css?family=Noto+Serif', name: 'Noto Serif', fontVertShift: -10},
-    {css: 'https://fonts.googleapis.com/css?family=Noto+Serif+TC', name: 'Noto Serif TC', fontVertShift: -15},
-    
-    {css: 'https://fonts.googleapis.com/css?family=Old+Standard+TT', name: 'Old Standard TT', fontVertShift: 15},
-    {css: 'https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300', name: 'Open Sans Condensed', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Oranienbaum', name: 'Oranienbaum', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Oswald', name: 'Oswald', fontVertShift: -20},
-
-    {css: 'https://fonts.googleapis.com/css?family=Pacifico', name: 'Pacifico', fontVertShift: -35},
-    {css: 'https://fonts.googleapis.com/css?family=Pangolin', name: 'Pangolin', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Pattaya', name: 'Pattaya', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Philosopher', name: 'Philosopher', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Play', name: 'Play', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Playfair+Display', name: 'Playfair Display', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Playfair+Display+SC', name: 'Playfair Display SC', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Podkova', name: 'Podkova', fontVertShift: 10},
-    {css: 'https://fonts.googleapis.com/css?family=Poiret+One', name: 'Poiret One', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Prata', name: 'Prata', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Prosto+One', name: 'Prosto One', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=PT+Mono', name: 'PT Mono', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=PT+Sans', name: 'PT Sans', fontVertShift: -10},
-    {css: 'https://fonts.googleapis.com/css?family=PT+Sans+Caption', name: 'PT Sans Caption', fontVertShift: -10},
-    {css: 'https://fonts.googleapis.com/css?family=PT+Sans+Narrow', name: 'PT Sans Narrow', fontVertShift: -10},
-    {css: 'https://fonts.googleapis.com/css?family=PT+Serif', name: 'PT Serif', fontVertShift: -10},
-    {css: 'https://fonts.googleapis.com/css?family=PT+Serif+Caption', name: 'PT Serif Caption', fontVertShift: -10},
-
-    {css: 'https://fonts.googleapis.com/css?family=Roboto+Condensed', name: 'Roboto Condensed', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Roboto+Mono', name: 'Roboto Mono', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Roboto+Slab', name: 'Roboto Slab', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Ruslan+Display', name: 'Ruslan Display', fontVertShift: 20},
-    {css: 'https://fonts.googleapis.com/css?family=Russo+One', name: 'Russo One', fontVertShift: 5},
-
-    {css: 'https://fonts.googleapis.com/css?family=Sawarabi+Gothic', name: 'Sawarabi Gothic', fontVertShift: -15},
-    {css: 'https://fonts.googleapis.com/css?family=Scada', name: 'Scada', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Seymour+One', name: 'Seymour One', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Source+Sans+Pro', name: 'Source Sans Pro', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Spectral', name: 'Spectral', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Stalinist+One', name: 'Stalinist One', fontVertShift: 0},
-
-    {css: 'https://fonts.googleapis.com/css?family=Tinos', name: 'Tinos', fontVertShift: 5},
-    {css: 'https://fonts.googleapis.com/css?family=Tenor+Sans', name: 'Tenor Sans', fontVertShift: 5},
-
-    {css: 'https://fonts.googleapis.com/css?family=Underdog', name: 'Underdog', fontVertShift: 10},
-    {css: 'https://fonts.googleapis.com/css?family=Ubuntu+Mono', name: 'Ubuntu Mono', fontVertShift: 0},
-    {css: 'https://fonts.googleapis.com/css?family=Ubuntu+Condensed', name: 'Ubuntu Condensed', fontVertShift: -5},
-
-    {css: 'https://fonts.googleapis.com/css?family=Vollkorn', name: 'Vollkorn', fontVertShift: -5},
-    {css: 'https://fonts.googleapis.com/css?family=Vollkorn+SC', name: 'Vollkorn SC', fontVertShift: 0},
-
-    {css: 'https://fonts.googleapis.com/css?family=Yanone+Kaffeesatz', name: 'Yanone Kaffeesatz', fontVertShift: 20},
-    {css: 'https://fonts.googleapis.com/css?family=Yeseva+One', name: 'Yeseva One', fontVertShift: 10},
-
-
-];
+//webFonts: [{css: 'https://fonts.googleapis.com/css?family=Alegreya', name: 'Alegreya', fontVertShift: 0}, ...],
+const webFonts = [];
+for (const family of googleFonts) {
+    webFonts.push({
+        css: `https://fonts.googleapis.com/css?family=${family.replace(/\s/g, '+')}`,
+        name: family,
+        fontVertShift: 0,
+    });
+}
 
 //----------------------------------------------------------------------------------------------------------
 const settingDefaults = {
     textColor: '#000000',
-    backgroundColor: '#EBE2C9',
+    backgroundColor: '#ebe2c9',
     wallpaper: '',
+    wallpaperIgnoreStatusBar: false,
     fontStyle: '',// 'italic'
     fontWeight: '',// 'bold'
     fontSize: 20,// px
@@ -226,9 +124,22 @@ const settingDefaults = {
     wordWrap: true,//перенос по слогам
     keepLastToFirst: false,// перенос последней строки в первую при листании
 
+    dualPageMode: false,
+    dualIndentLR: 10,// px, отступ слева и справа внутри страницы в двухстраничном режиме
+    dualDivWidth: 2,// px, ширина разделителя
+    dualDivHeight: 100,// процент, высота разделителя
+    dualDivColorAsText: true,//цвет как у текста
+    dualDivColor: '#000000',
+    dualDivColorAlpha: 0.7,// прозрачность разделителя
+    dualDivStrokeFill: 1,// px, заполнение пунктира
+    dualDivStrokeGap: 1,// px, промежуток пунктира
+    dualDivShadowWidth: 0,// px, ширина тени
+
     showStatusBar: true,
     statusBarTop: false,// top, bottom
     statusBarHeight: 19,// px
+    statusBarColorAsText: true,//цвет как у текста
+    statusBarColor: '#000000',
     statusBarColorAlpha: 0.4,
     statusBarClickOpen: true,
 
@@ -265,6 +176,7 @@ const settingDefaults = {
     fontShifts: {},
     showToolButton: {},
     userHotKeys: {},
+    userWallpapers: [],
 };
 
 for (const font of fonts)
@@ -276,12 +188,13 @@ for (const button of toolButtons)
 for (const hotKey of hotKeys)
     settingDefaults.userHotKeys[hotKey.name] = hotKey.codes;
 
-const excludeDiffHotKeys = [];
+const diffExclude = [];
 for (const hotKey of hotKeys)
-    excludeDiffHotKeys.push(`userHotKeys/${hotKey.name}`);
+    diffExclude.push(`userHotKeys/${hotKey.name}`);
+diffExclude.push('userWallpapers');
 
 function addDefaultsToSettings(settings) {
-    const diff = utils.getObjDiff(settings, settingDefaults, {exclude: excludeDiffHotKeys});
+    const diff = utils.getObjDiff(settings, settingDefaults, {exclude: diffExclude});
     if (!utils.isEmptyObjDiffDeep(diff, {isApplyChange: false})) {
         return utils.applyObjDiff(settings, diff, {isApplyChange: false});
     }

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "Liberama",
-  "version": "0.9.12",
+  "version": "0.10.0",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "license": "CC0-1.0",
   "repository": "bookpauk/liberama",
@@ -10,8 +10,8 @@
   "scripts": {
     "dev": "nodemon --inspect --ignore server/public --ignore server/data --ignore client --exec 'node server'",
     "build:client": "webpack --config build/webpack.prod.config.js",
-    "build:linux": "npm run build:client && node build/linux && pkg -t latest-linux-x64 -o dist/linux/liberama .",
-    "build:win": "npm run build:client && node build/win && pkg -t latest-win-x64 -o dist/win/liberama .",
+    "build:linux": "npm run build:client && node build/linux && pkg -t node12-linux-x64 -o dist/linux/liberama .",
+    "build:win": "npm run build:client && node build/win && pkg -t node12-win-x64 -o dist/win/liberama .",
     "lint": "eslint --ext=.js,.vue client server",
     "build:client-dev": "webpack --config build/webpack.dev.config.js",
     "postinstall": "npm run build:client-dev && node build/linux"

+ 7 - 1
server/controllers/WebSocketController.js

@@ -50,8 +50,14 @@ class WebSocketController {
                 log(`WebSocket-IN:  ${message.substr(0, 4000)}`);
             }
 
-            ws.lastActivity = Date.now();
             req = JSON.parse(message);
+
+            ws.lastActivity = Date.now();
+            
+            //pong for WebSocketConnection
+            if (req._rpo === 1)
+                this.send({_rok: 1}, req, ws);
+
             switch (req.action) {
                 case 'test':
                     await this.test(req, ws); break;

+ 237 - 0
server/core/WebSocketConnection.js

@@ -0,0 +1,237 @@
+const isBrowser = (typeof window !== 'undefined');
+
+const utils = {
+    sleep: (ms) => { return new Promise(resolve => setTimeout(resolve, ms)); }
+};
+
+const cleanPeriod = 5*1000;//5 секунд
+
+class WebSocketConnection {
+    //messageLifeTime в секундах (проверка каждый cleanPeriod интервал)
+    constructor(url, openTimeoutSecs = 10, messageLifeTimeSecs = 30) {
+        this.WebSocket = (isBrowser ? WebSocket : require('ws'));
+        this.url = url;
+        this.ws = null;
+        this.listeners = [];
+        this.messageQueue = [];
+        this.messageLifeTime = messageLifeTimeSecs*1000;
+        this.openTimeout = openTimeoutSecs*1000;
+        this.requestId = 0;
+
+        this.wsErrored = false;
+        this.closed = false;
+
+        this.connecting = false;
+        this.periodicClean();//no await
+    }
+
+    //рассылаем сообщение и удаляем те обработчики, которые его получили
+    emit(mes, isError) {
+        const len = this.listeners.length;
+        if (len > 0) {
+            let newListeners = [];
+            for (const listener of this.listeners) {
+                let emitted = false;
+                if (isError) {
+                    listener.onError(mes);
+                    emitted = true;
+                } else {
+                    if ( (listener.requestId && mes.requestId && listener.requestId === mes.requestId) ||
+                        (!listener.requestId && !mes.requestId) ) {
+                        listener.onMessage(mes);
+                        emitted = true;
+                    }
+                }
+
+                if (!emitted)
+                    newListeners.push(listener);
+            }
+            this.listeners = newListeners;
+        }
+        
+        return this.listeners.length != len;
+    }
+
+    get isOpen() {
+        return (this.ws && this.ws.readyState == this.WebSocket.OPEN);
+    }
+
+    processMessageQueue() {
+        let newMessageQueue = [];
+        for (const message of this.messageQueue) {
+            if (!this.emit(message.mes)) {
+                newMessageQueue.push(message);
+            }
+        }
+
+        this.messageQueue = newMessageQueue;
+    }
+
+    _open() {
+        return new Promise((resolve, reject) => { (async() => {
+            if (this.closed)
+                reject(new Error('Этот экземпляр класса уничтожен. Пожалуйста, создайте новый.'));
+
+            if (this.connecting) {
+                let i = this.openTimeout/100;
+                while (i-- > 0 && this.connecting) {
+                    await utils.sleep(100);
+                }
+            }
+
+            //проверим подключение, и если нет, то подключимся заново
+            if (this.isOpen) {
+                resolve(this.ws);
+            } else {
+                this.connecting = true;
+                this.terminate();
+
+                if (isBrowser) {
+                    const protocol = (window.location.protocol == 'https:' ? 'wss:' : 'ws:');
+                    const url = this.url || `${protocol}//${window.location.host}/ws`;
+                    this.ws = new this.WebSocket(url);
+                } else {
+                    this.ws = new this.WebSocket(this.url);
+                }
+
+                const onopen = (e) => {
+                    this.connecting = false;
+                    resolve(this.ws);
+                };
+
+                const onmessage = (data) => {
+                    try {
+                        if (isBrowser)
+                            data = data.data;
+                        const mes = JSON.parse(data);
+                        this.messageQueue.push({regTime: Date.now(), mes});
+
+                        this.processMessageQueue();
+                    } catch (e) {
+                        this.emit(e.message, true);
+                    }
+                };
+
+                const onerror = (e) => {
+                    this.emit(e.message, true);
+                    reject(new Error(e.message));
+                };
+
+                const onclose = (e) => {
+                    this.emit(e.message, true);
+                    reject(new Error(e.message));
+                };
+
+                if (isBrowser) {
+                    this.ws.onopen = onopen;
+                    this.ws.onmessage = onmessage;
+                    this.ws.onerror = onerror;
+                    this.ws.onclose = onclose;
+                } else {
+                    this.ws.on('open', onopen);
+                    this.ws.on('message', onmessage);
+                    this.ws.on('error', onerror);
+                    this.ws.on('close', onclose);
+                }
+
+                await utils.sleep(this.openTimeout);
+                reject(new Error('Соединение не удалось'));
+            }
+        })() });
+    }
+
+    //timeout в секундах (проверка каждый cleanPeriod интервал)
+    message(requestId, timeoutSecs = 4) {
+        return new Promise((resolve, reject) => {
+            this.listeners.push({
+                regTime: Date.now(),
+                requestId,
+                timeout: timeoutSecs*1000,
+                onMessage: (mes) => {
+                    resolve(mes);
+                },
+                onError: (mes) => {
+                    reject(new Error(mes));
+                }
+            });
+            
+            this.processMessageQueue();
+        });
+    }
+
+    async send(req, timeoutSecs = 4) {
+        await this._open();
+        if (this.isOpen) {
+            this.requestId = (this.requestId < 1000000 ? this.requestId + 1 : 1);
+            const requestId = this.requestId;//реентерабельность!!!
+
+            this.ws.send(JSON.stringify(Object.assign({requestId, _rpo: 1}, req)));//_rpo: 1 - ждем в ответ _rok: 1
+
+            let resp = {};
+            try {
+                resp = await this.message(requestId, timeoutSecs);
+            } catch(e) {
+                this.terminate();
+                throw new Error('WebSocket не отвечает');
+            }
+
+            if (resp._rok) {                
+                return requestId;
+            } else {
+                throw new Error('Запрос не принят сервером');
+            }
+        } else {
+            throw new Error('WebSocket коннект закрыт');
+        }
+    }
+
+    terminate() {
+        if (this.ws) {
+            if (isBrowser) {
+                this.ws.close();
+            } else {
+                this.ws.terminate();
+            }
+        }
+        this.ws = null;
+    }
+
+    close() {
+        this.terminate();
+        this.closed = true;
+    }
+
+    async periodicClean() {
+        while (!this.closed) {
+            try {
+                const now = Date.now();
+                //чистка listeners
+                let newListeners = [];
+                for (const listener of this.listeners) {
+                    if (now - listener.regTime < listener.timeout) {
+                        newListeners.push(listener);
+                    } else {
+                        if (listener.onError)
+                            listener.onError('Время ожидания ответа истекло');
+                    }
+                }
+                this.listeners = newListeners;
+
+                //чистка messageQueue
+                let newMessageQueue = [];
+                for (const message of this.messageQueue) {
+                    if (now - message.regTime < this.messageLifeTime) {
+                        newMessageQueue.push(message);
+                    }
+                }
+                this.messageQueue = newMessageQueue;
+            } catch(e) {
+                //
+            }
+
+            await utils.sleep(cleanPeriod);
+        }
+    }
+}
+
+module.exports = WebSocketConnection;

+ 2 - 4
server/db/ConnManager.js

@@ -4,8 +4,6 @@ const SqliteConnectionPool = require('./SqliteConnectionPool');
 const log = new (require('../core/AppLogger'))().log;//singleton
 
 const migrations = {
-    'app': require('./migrations/app'),
-    'readerStorage': require('./migrations/readerStorage'),
 };
 
 let instance = null;
@@ -32,11 +30,11 @@ class ConnManager {
             const dbFileName = this.config.dataDir + '/' + poolConfig.fileName;
 
             //бэкап
-            if (await fs.pathExists(dbFileName))
+            if (!poolConfig.noBak && await fs.pathExists(dbFileName))
                 await fs.copy(dbFileName, `${dbFileName}.bak`);
 
             const connPool = new SqliteConnectionPool();
-            await connPool.open(poolConfig.connCount, dbFileName);
+            await connPool.open(poolConfig, dbFileName);
 
             log(`Opened database "${poolConfig.poolName}"`);
             //миграции

+ 29 - 27
server/db/SqliteConnectionPool.js

@@ -1,27 +1,32 @@
 const sqlite = require('sqlite');
 const SQL = require('sql-template-strings');
 
-const utils = require('../core/utils');
-
-const waitingDelay = 100; //ms
-
 class SqliteConnectionPool {
     constructor() {
         this.closed = true;
     }
 
-    async open(connCount, dbFileName) {
-        if (!Number.isInteger(connCount) || connCount <= 0)
-            return;
+    async open(poolConfig, dbFileName) {
+        const connCount = poolConfig.connCount || 1;
+        const busyTimeout = poolConfig.busyTimeout || 60*1000;
+        const cacheSize = poolConfig.cacheSize || 2000;
+
+        this.dbFileName = dbFileName;
         this.connections = [];
         this.freed = new Set();
+        this.waitingQueue = [];
 
         for (let i = 0; i < connCount; i++) {
             let client = await sqlite.open(dbFileName);
-            client.configure('busyTimeout', 10000); //ms
+
+            client.configure('busyTimeout', busyTimeout); //ms
+            await client.exec(`PRAGMA cache_size = ${cacheSize}`);
 
             client.ret = () => {
                 this.freed.add(i);
+                if (this.waitingQueue.length) {
+                    this.waitingQueue.shift().onFreed(i);
+                }
             };
 
             this.freed.add(i);
@@ -30,30 +35,27 @@ class SqliteConnectionPool {
         this.closed = false;
     }
 
-    _setImmediate() {
+    get() {
         return new Promise((resolve) => {
-            setImmediate(() => {
-                return resolve();
+            if (this.closed)
+                throw new Error('Connection pool closed');
+
+            const freeConnIndex = this.freed.values().next().value;
+            if (freeConnIndex !== undefined) {
+                this.freed.delete(freeConnIndex);
+                resolve(this.connections[freeConnIndex]);
+                return;
+            }
+
+            this.waitingQueue.push({
+                onFreed: (connIndex) => {
+                    this.freed.delete(connIndex);
+                    resolve(this.connections[connIndex]);
+                },
             });
         });
     }
 
-    async get() {
-        if (this.closed)
-            return;
-
-        let freeConnIndex = this.freed.values().next().value;
-        if (freeConnIndex == null) {
-            if (waitingDelay)
-                await utils.sleep(waitingDelay);
-            return await this._setImmediate().then(() => this.get());
-        }
-
-        this.freed.delete(freeConnIndex);
-
-        return this.connections[freeConnIndex];
-    }
-
     async run(query) {
         const dbh = await this.get();
         try {