diff --git a/client/api/misc.js b/client/api/misc.js index 9f3d60a2..c4e49009 100644 --- a/client/api/misc.js +++ b/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; diff --git a/client/api/reader.js b/client/api/reader.js index 316787fe..d3995597 100644 --- a/client/api/reader.js +++ b/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 diff --git a/client/api/webSocketConnection.js b/client/api/webSocketConnection.js index 189c9283..279bfc13 100644 --- a/client/api/webSocketConnection.js +++ b/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(); \ No newline at end of file diff --git a/client/components/App.vue b/client/components/App.vue index 9232d1c1..71f8e346 100644 --- a/client/components/App.vue +++ b/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; } diff --git a/client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue b/client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue index b3994dc4..9d8bb480 100644 --- a/client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue +++ b/client/components/Reader/HelpPage/DonateHelpPage/DonateHelpPage.vue @@ -3,10 +3,10 @@

Вы можете пожертвовать на развитие проекта любую сумму:

- - Пожертвовать
-
{{ yandexAddress }} - + + Пожертвовать
+
{{ yooAddress }} + Скопировать
@@ -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) { diff --git a/client/components/Reader/HelpPage/DonateHelpPage/assets/yandex.png b/client/components/Reader/HelpPage/DonateHelpPage/assets/yandex.png deleted file mode 100644 index c7705212..00000000 Binary files a/client/components/Reader/HelpPage/DonateHelpPage/assets/yandex.png and /dev/null differ diff --git a/client/components/Reader/HelpPage/DonateHelpPage/assets/yoomoney.png b/client/components/Reader/HelpPage/DonateHelpPage/assets/yoomoney.png new file mode 100644 index 00000000..dd47ef50 Binary files /dev/null and b/client/components/Reader/HelpPage/DonateHelpPage/assets/yoomoney.png differ diff --git a/client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue b/client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue index 14fc7ab2..d667a263 100644 --- a/client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue +++ b/client/components/Reader/LoaderPage/PasteTextPage/PasteTextPage.vue @@ -68,7 +68,7 @@ class PasteTextPage extends Vue { } loadBuffer() { - this.$emit('load-buffer', {buffer: `${utils.escapeXml(this.bookTitle)}${this.$refs.textArea.value}`}); + this.$emit('load-buffer', {buffer: `${utils.escapeXml(this.bookTitle)}${utils.escapeXml(this.$refs.textArea.value)}`}); this.close(); } diff --git a/client/components/Reader/Reader.vue b/client/components/Reader/Reader.vue index ea0f12e1..570dd1a5 100644 --- a/client/components/Reader/Reader.vue +++ b/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; diff --git a/client/components/Reader/RecentBooksPage/RecentBooksPage.vue b/client/components/Reader/RecentBooksPage/RecentBooksPage.vue index 338c837c..6eb0b65a 100644 --- a/client/components/Reader/RecentBooksPage/RecentBooksPage.vue +++ b/client/components/Reader/RecentBooksPage/RecentBooksPage.vue @@ -27,8 +27,11 @@ placeholder="Найти" v-model="search" @click.stop - /> - + > + + @@ -53,6 +56,7 @@
{{ props.row.desc.author }}
{{ props.row.desc.title }}
+
@@ -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; +} - - diff --git a/client/components/Reader/TextPage/images/paper10.png b/client/components/Reader/TextPage/images/paper10.png new file mode 100644 index 00000000..34727bd1 Binary files /dev/null and b/client/components/Reader/TextPage/images/paper10.png differ diff --git a/client/components/Reader/TextPage/images/paper11.png b/client/components/Reader/TextPage/images/paper11.png new file mode 100644 index 00000000..cb543e6f Binary files /dev/null and b/client/components/Reader/TextPage/images/paper11.png differ diff --git a/client/components/Reader/TextPage/images/paper12.png b/client/components/Reader/TextPage/images/paper12.png new file mode 100644 index 00000000..2890c8b4 Binary files /dev/null and b/client/components/Reader/TextPage/images/paper12.png differ diff --git a/client/components/Reader/TextPage/images/paper13.png b/client/components/Reader/TextPage/images/paper13.png new file mode 100644 index 00000000..a64c2b66 Binary files /dev/null and b/client/components/Reader/TextPage/images/paper13.png differ diff --git a/client/components/Reader/TextPage/images/paper14.png b/client/components/Reader/TextPage/images/paper14.png new file mode 100644 index 00000000..a39e02a9 Binary files /dev/null and b/client/components/Reader/TextPage/images/paper14.png differ diff --git a/client/components/Reader/TextPage/images/paper15.png b/client/components/Reader/TextPage/images/paper15.png new file mode 100644 index 00000000..3766d987 Binary files /dev/null and b/client/components/Reader/TextPage/images/paper15.png differ diff --git a/client/components/Reader/TextPage/images/paper16.png b/client/components/Reader/TextPage/images/paper16.png new file mode 100644 index 00000000..7d34aa92 Binary files /dev/null and b/client/components/Reader/TextPage/images/paper16.png differ diff --git a/client/components/Reader/TextPage/images/paper17.png b/client/components/Reader/TextPage/images/paper17.png new file mode 100644 index 00000000..7ca0f351 Binary files /dev/null and b/client/components/Reader/TextPage/images/paper17.png differ diff --git a/client/components/Reader/share/BookParser.js b/client/components/Reader/share/BookParser.js index 500a64bc..a1bbaefa 100644 --- a/client/components/Reader/share/BookParser.js +++ b/client/components/Reader/share/BookParser.js @@ -4,23 +4,55 @@ import * as utils from '../../../share/utils'; const maxImageLineCount = 100; +// 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]); + export default class BookParser { - constructor(settings) { - if (settings) { - this.showInlineImagesInCenter = settings.showInlineImagesInCenter; - } + constructor(settings = {}) { + this.sets = {}; - // defaults - this.p = 30;// px, отступ параграфа - this.w = 300;// px, ширина страницы - this.wordWrap = false;// перенос по слогам - - //заглушка - this.measureText = (text, style) => {// eslint-disable-line no-unused-vars - return text.length*20; - }; + 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(``, 0); else newParagraph(`${' '.repeat(maxImageLineCount)}`, 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(`${' '.repeat(maxImageLineCount)}`, maxImageLineCount); this.images.push({paraIndex, num: imageNum, id, local, alt}); @@ -264,25 +319,25 @@ export default class BookParser { newParagraph(`${a}`, 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, '>'); - text = text.replace(//g, '>').replace(/= 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 ? '
' : ''); - tOpen += (bold ? '' : ''); - tOpen += (italic ? '' : ''); - tOpen += (space ? `` : ''); - let tClose = (space ? '' : ''); - tClose += (italic ? '' : ''); - tClose += (bold ? '' : ''); - tClose += (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); - } + let tOpen = (center ? '
' : ''); + tOpen += (bold ? '' : ''); + tOpen += (italic ? '' : ''); + tOpen += (space ? `` : ''); + let tClose = (space ? '' : ''); + tClose += (italic ? '' : ''); + tClose += (bold ? '' : ''); + tClose += (center ? '
' : ''); - if (binaryId) { - dimPromises.push(getImageDimensions(binaryId, binaryType, text)); + 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 (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])) + 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; + ) { + 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() == '') ); } diff --git a/client/components/Reader/share/wallpaperStorage.js b/client/components/Reader/share/wallpaperStorage.js new file mode 100644 index 00000000..904f5f47 --- /dev/null +++ b/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(); \ No newline at end of file diff --git a/client/components/Reader/versionHistory.js b/client/components/Reader/versionHistory.js index a577d570..566e8edc 100644 --- a/client/components/Reader/versionHistory.js +++ b/client/components/Reader/versionHistory.js @@ -1,4 +1,18 @@ export const versionHistory = [ +{ + showUntil: '2021-02-16', + header: '0.10.0 (2021-02-09)', + content: +` +
    +
  • добавлен двухстраничный режим
  • +
  • в настройки добавлены все кириллические веб-шрифты от google
  • +
  • в настройки добавлена возможность загрузки пользовательских обоев (пока без синхронизации)
  • +
  • немного улучшен парсинг fb2
  • +
+` +}, + { showUntil: '2020-12-17', header: '0.9.12 (2020-12-18)', diff --git a/client/quasar.js b/client/quasar.js index 52fa825b..38f6decc 100644 --- a/client/quasar.js +++ b/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, diff --git a/client/share/dynamicCss.js b/client/share/dynamicCss.js new file mode 100644 index 00000000..494effa6 --- /dev/null +++ b/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(); \ No newline at end of file diff --git a/client/store/modules/fonts/fonts.json b/client/store/modules/fonts/fonts.json new file mode 100644 index 00000000..888b5214 --- /dev/null +++ b/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"] \ No newline at end of file diff --git a/client/store/modules/fonts/fonts2list.js b/client/store/modules/fonts/fonts2list.js new file mode 100644 index 00000000..7c08e2cf --- /dev/null +++ b/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(); \ No newline at end of file diff --git a/client/store/modules/reader.js b/client/store/modules/reader.js index 484969bb..f143f410 100644 --- a/client/store/modules/reader.js +++ b/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}); } diff --git a/package.json b/package.json index aac37439..b729fb55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Liberama", - "version": "0.9.12", + "version": "0.10.0", "author": "Book Pauk ", "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" diff --git a/server/controllers/WebSocketController.js b/server/controllers/WebSocketController.js index ea59241f..393cd4b4 100644 --- a/server/controllers/WebSocketController.js +++ b/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; diff --git a/server/core/WebSocketConnection.js b/server/core/WebSocketConnection.js new file mode 100644 index 00000000..f8993354 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/server/db/ConnManager.js b/server/db/ConnManager.js index 5576fa47..14098a6a 100644 --- a/server/db/ConnManager.js +++ b/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}"`); //миграции diff --git a/server/db/SqliteConnectionPool.js b/server/db/SqliteConnectionPool.js index a5189a9b..01b8f991 100644 --- a/server/db/SqliteConnectionPool.js +++ b/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 {