Merge branch 'release/0.1.0'
45
.eslintrc
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/essential"
|
||||
],
|
||||
"plugins": [
|
||||
"vue",
|
||||
"html",
|
||||
"node"
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"globals": {
|
||||
"LM_OK": false,
|
||||
"LM_INFO": false,
|
||||
"LM_WARN": false,
|
||||
"LM_ERR": false,
|
||||
"LM_FATAL": false,
|
||||
"LM_TOTAL": false
|
||||
},
|
||||
"rules": {
|
||||
"strict": 0,
|
||||
"indent": [0, 4, {
|
||||
"SwitchCase": 1
|
||||
}],
|
||||
"space-before-function-paren": [2, "never"],
|
||||
"valid-jsdoc": [2, {
|
||||
"requireReturn": false,
|
||||
"prefer": {
|
||||
"returns": "return"
|
||||
}
|
||||
}],
|
||||
"require-jsdoc": 0,
|
||||
"max-len": [1, 200, 4, {
|
||||
"ignoreComments": true,
|
||||
"ignoreUrls": true
|
||||
}],
|
||||
"no-console": off
|
||||
}
|
||||
}
|
||||
48
.eslintrc.js
@@ -1,48 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
sourceType: 'module'
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/essential',
|
||||
'standard'
|
||||
],
|
||||
plugins: [
|
||||
'html',
|
||||
'standard',
|
||||
'vue'
|
||||
],
|
||||
rules: {
|
||||
'generator-star-spacing': 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'indent': [ 'error', 4, { 'SwitchCase': 1 } ],
|
||||
'brace-style': [ 'error', '1tbs' ],
|
||||
'semi': [ 'error', 'always' ],
|
||||
'no-console': 'error',
|
||||
'comma-dangle': [ 'error', {
|
||||
'arrays': 'never',
|
||||
'objects': 'always-multiline',
|
||||
'imports': 'never',
|
||||
'exports': 'never',
|
||||
'functions': 'never'
|
||||
}],
|
||||
'no-multiple-empty-lines': [ 'error', { 'max': 2, 'maxBOF': 1 }],
|
||||
'no-undef': 'error',
|
||||
'space-in-parens': ['error', 'never'],
|
||||
'space-before-function-paren': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'quotes': ['error', 'single'],
|
||||
'space-before-blocks': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'no-empty': 'error',
|
||||
'no-duplicate-imports': 'error'
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/node_modules
|
||||
/server/data
|
||||
/server/public
|
||||
/server/ipfs
|
||||
/dist
|
||||
74
build/linux.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
const stream = require('stream');
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
const got = require('got');
|
||||
const decompress = require('decompress');
|
||||
const decompressTargz = require('decompress-targz');
|
||||
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
const publicDir = `${distDir}/tmp/public`;
|
||||
const outDir = `${distDir}/linux`;
|
||||
|
||||
const tempDownloadDir = `${distDir}/tmp/download`;
|
||||
|
||||
async function main() {
|
||||
await fs.emptyDir(outDir);
|
||||
// перемещаем public на место
|
||||
if (await fs.pathExists(publicDir))
|
||||
await fs.move(publicDir, `${outDir}/public`);
|
||||
|
||||
await fs.ensureDir(tempDownloadDir);
|
||||
|
||||
//sqlite3
|
||||
const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.0.4/node-v64-linux-x64.tar.gz';
|
||||
const sqliteDecompressedFilename = `${tempDownloadDir}/node-v64-linux-x64/node_sqlite3.node`;
|
||||
|
||||
if (!await fs.pathExists(sqliteDecompressedFilename)) {
|
||||
// Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
|
||||
await pipeline(got.stream(sqliteRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
|
||||
console.log(`done downloading ${sqliteRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/sqlite.tar.gz`, `${tempDownloadDir}`, {
|
||||
plugins: [
|
||||
decompressTargz()
|
||||
]
|
||||
});
|
||||
console.log('files decompressed');
|
||||
}
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
|
||||
console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
|
||||
|
||||
//ipfs
|
||||
const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs`;
|
||||
if (!await fs.pathExists(ipfsDecompressedFilename)) {
|
||||
// Скачиваем ipfs
|
||||
const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_linux-amd64.tar.gz';
|
||||
|
||||
await pipeline(got.stream(ipfsRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/ipfs.tar.gz`));
|
||||
console.log(`done downloading ${ipfsRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/ipfs.tar.gz`, `${tempDownloadDir}`, {
|
||||
plugins: [
|
||||
decompressTargz()
|
||||
]
|
||||
});
|
||||
console.log('files decompressed');
|
||||
}
|
||||
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs`);
|
||||
console.log(`copied ${tempDownloadDir}/go-ipfs/ipfs to ${outDir}/ipfs`);
|
||||
//для development
|
||||
const devIpfsFile = path.resolve(__dirname, '../server/ipfs');
|
||||
if (!await fs.pathExists(devIpfsFile)) {
|
||||
await fs.copy(ipfsDecompressedFilename, devIpfsFile);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
66
build/webpack.base.config.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const path = require('path');
|
||||
//const webpack = require('webpack');
|
||||
const VueLoaderPlugin = require('vue-loader/lib/plugin');
|
||||
|
||||
const clientDir = path.resolve(__dirname, '../client');
|
||||
|
||||
module.exports = {
|
||||
entry: [`${clientDir}/main.js`],
|
||||
output: {
|
||||
publicPath: '/app/',
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: "vue-loader"
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /node_modules/,
|
||||
query: {
|
||||
plugins: [
|
||||
'syntax-dynamic-import',
|
||||
'transform-decorators-legacy',
|
||||
'transform-class-properties',
|
||||
// ["component", { "libraryName": "element-ui", "styleLibraryName": `~${clientDir}/theme` } ]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.gif$/,
|
||||
loader: "url-loader",
|
||||
options: {
|
||||
name: "images/[name]-[hash:6].[ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.png$/,
|
||||
loader: "url-loader",
|
||||
options: {
|
||||
name: "images/[name]-[hash:6].[ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.jpg$/,
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
name: "images/[name]-[hash:6].[ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(ttf|eot|woff|woff2)$/,
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
name: "fonts/[name]-[hash:6].[ext]"
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
]
|
||||
};
|
||||
43
build/webpack.dev.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const merge = require('webpack-merge');
|
||||
const baseWpConfig = require('./webpack.base.config');
|
||||
|
||||
baseWpConfig.entry.unshift('webpack-hot-middleware/client');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
|
||||
const publicDir = path.resolve(__dirname, '../server/public');
|
||||
const clientDir = path.resolve(__dirname, '../client');
|
||||
|
||||
module.exports = merge(baseWpConfig, {
|
||||
mode: 'development',
|
||||
devtool: "#inline-source-map",
|
||||
output: {
|
||||
path: `${publicDir}/app`,
|
||||
filename: 'bundle.js'
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
'vue-style-loader',
|
||||
'css-loader'
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
new HtmlWebpackPlugin({
|
||||
template: `${clientDir}/index.html.template`,
|
||||
filename: `${publicDir}/index.html`
|
||||
}),
|
||||
new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
|
||||
]
|
||||
});
|
||||
50
build/webpack.prod.config.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const path = require('path');
|
||||
//const webpack = require('webpack');
|
||||
|
||||
const merge = require('webpack-merge');
|
||||
const baseWpConfig = require('./webpack.base.config');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const CleanWebpackPlugin = require('clean-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
|
||||
const publicDir = path.resolve(__dirname, '../dist/tmp/public');
|
||||
const clientDir = path.resolve(__dirname, '../client');
|
||||
|
||||
module.exports = merge(baseWpConfig, {
|
||||
mode: 'production',
|
||||
output: {
|
||||
path: `${publicDir}/app_new`,
|
||||
filename: 'bundle.[contenthash].js'
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
'css-loader'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin(),
|
||||
new OptimizeCSSAssetsPlugin()
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin([publicDir], {root: path.resolve(__dirname, '..')}),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "[name].[contenthash].css"
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: `${clientDir}/index.html.template`,
|
||||
filename: `${publicDir}/index.html`
|
||||
}),
|
||||
new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
|
||||
]
|
||||
});
|
||||
64
build/win.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
const stream = require('stream');
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
const got = require('got');
|
||||
const decompress = require('decompress');
|
||||
const decompressTargz = require('decompress-targz');
|
||||
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
const publicDir = `${distDir}/tmp/public`;
|
||||
const outDir = `${distDir}/win`;
|
||||
|
||||
const tempDownloadDir = `${distDir}/tmp/download`;
|
||||
|
||||
async function main() {
|
||||
await fs.emptyDir(outDir);
|
||||
// перемещаем public на место
|
||||
if (await fs.pathExists(publicDir))
|
||||
await fs.move(publicDir, `${outDir}/public`);
|
||||
|
||||
await fs.ensureDir(tempDownloadDir);
|
||||
|
||||
//sqlite3
|
||||
const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.0.4/node-v64-win32-x64.tar.gz';
|
||||
const sqliteDecompressedFilename = `${tempDownloadDir}/node-v64-win32-x64/node_sqlite3.node`;
|
||||
|
||||
if (!await fs.pathExists(sqliteDecompressedFilename)) {
|
||||
// Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
|
||||
await pipeline(got.stream(sqliteRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
|
||||
console.log(`done downloading ${sqliteRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/sqlite.tar.gz`, `${tempDownloadDir}`, {
|
||||
plugins: [
|
||||
decompressTargz()
|
||||
]
|
||||
});
|
||||
console.log('files decompressed');
|
||||
}
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
|
||||
console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
|
||||
|
||||
//ipfs
|
||||
const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs.exe`;
|
||||
if (!await fs.pathExists(ipfsDecompressedFilename)) {
|
||||
// Скачиваем ipfs
|
||||
const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_windows-amd64.zip';
|
||||
|
||||
await pipeline(got.stream(ipfsRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/ipfs.zip`));
|
||||
console.log(`done downloading ${ipfsRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/ipfs.zip`, `${tempDownloadDir}`);
|
||||
console.log('files decompressed');
|
||||
}
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs.exe`);
|
||||
console.log(`copied ${ipfsDecompressedFilename} to ${outDir}/ipfs.exe`);
|
||||
}
|
||||
|
||||
main();
|
||||
14
client/api/misc.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api'
|
||||
});
|
||||
|
||||
class Misc {
|
||||
async loadConfig() {
|
||||
const response = await api.post('/config', {params: ['name', 'version', 'mode']});
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Misc();
|
||||
109
client/api/reader.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import axios from 'axios';
|
||||
import {sleep} from '../share/utils';
|
||||
|
||||
const maxFileUploadSize = 50*1024*1024;
|
||||
const api = axios.create({
|
||||
baseURL: '/api/reader'
|
||||
});
|
||||
|
||||
const workerApi = axios.create({
|
||||
baseURL: '/api/worker'
|
||||
});
|
||||
|
||||
class Reader {
|
||||
async loadBook(url, callback) {
|
||||
const refreshPause = 200;
|
||||
if (!callback) callback = () => {};
|
||||
|
||||
let response = await api.post('/load-book', {type: 'url', url});
|
||||
|
||||
const workerId = response.data.workerId;
|
||||
if (!workerId)
|
||||
throw new Error('Неверный ответ api');
|
||||
|
||||
callback({totalSteps: 4});
|
||||
|
||||
let i = 0;
|
||||
while (1) {// eslint-disable-line no-constant-condition
|
||||
callback(response.data);
|
||||
|
||||
if (response.data.state == 'finish') {//воркер закончил работу, можно скачивать кешированный на сервере файл
|
||||
callback({step: 4});
|
||||
const book = await this.loadCachedBook(response.data.path, callback);
|
||||
return Object.assign({}, response.data, {data: book.data});
|
||||
}
|
||||
if (response.data.state == 'error') {
|
||||
let errMes = response.data.error;
|
||||
if (errMes.indexOf('getaddrinfo') >= 0 ||
|
||||
errMes.indexOf('ECONNRESET') >= 0 ||
|
||||
errMes.indexOf('EINVAL') >= 0 ||
|
||||
errMes.indexOf('404') >= 0)
|
||||
errMes = `Ресурс не найден по адресу: ${response.data.url}`;
|
||||
throw new Error(errMes);
|
||||
}
|
||||
if (i > 0)
|
||||
await sleep(refreshPause);
|
||||
|
||||
i++;
|
||||
if (i > 30*1000/refreshPause) {//30 сек ждем телодвижений воркера
|
||||
throw new Error('Слишком долгое время ожидания');
|
||||
}
|
||||
//проверка воркера
|
||||
const prevProgress = response.data.progress;
|
||||
response = await workerApi.post('/get-state', {workerId});
|
||||
i = (prevProgress != response.data.progress ? 1 : i);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCachedBook(url, callback){
|
||||
const response = await axios.head(url);
|
||||
|
||||
let estSize = 1000000;
|
||||
if (response.headers['content-length']) {
|
||||
estSize = response.headers['content-length'];
|
||||
}
|
||||
|
||||
const options = {
|
||||
onDownloadProgress: progress => {
|
||||
while (progress.loaded > estSize) estSize *= 1.5;
|
||||
|
||||
if (callback)
|
||||
callback({state: 'loading', progress: Math.round((progress.loaded*100)/estSize)});
|
||||
}
|
||||
}
|
||||
//загрузка
|
||||
return await axios.get(url, options);
|
||||
}
|
||||
|
||||
async uploadFile(file, callback) {
|
||||
if (file.size > maxFileUploadSize)
|
||||
throw new Error(`Размер файла превышает ${maxFileUploadSize} байт`);
|
||||
|
||||
let formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: progress => {
|
||||
const total = (progress.total ? progress.total : progress.loaded + 200000);
|
||||
if (callback)
|
||||
callback({state: 'upload', progress: Math.round((progress.loaded*100)/total)});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
let response = await api.post('/upload-file', formData, options);
|
||||
if (response.data.state == 'error')
|
||||
throw new Error(response.data.error);
|
||||
|
||||
const url = response.data.url;
|
||||
if (!url)
|
||||
throw new Error('Неверный ответ api');
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Reader();
|
||||
BIN
client/assets/apple-touch-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
client/assets/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
client/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 227 B |
328
client/components/App.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-aside v-if="showAsideBar" :width="asideWidth">
|
||||
<div class="app-name"><span v-html="appName"></span></div>
|
||||
<el-button class="el-button-collapse" @click="toggleCollapse" :icon="buttonCollapseIcon"></el-button>
|
||||
<el-menu class="el-menu-vertical" :default-active="rootRoute" :collapse="isCollapse" router>
|
||||
<el-menu-item index="/cardindex">
|
||||
<i class="el-icon-search"></i>
|
||||
<span :class="itemTitleClass('/cardindex')" slot="title">{{ this.itemRuText['/cardindex'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/reader">
|
||||
<i class="el-icon-tickets"></i>
|
||||
<span :class="itemTitleClass('/reader')" slot="title">{{ this.itemRuText['/reader'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/forum" disabled>
|
||||
<i class="el-icon-message"></i>
|
||||
<span :class="itemTitleClass('/forum')" slot="title">{{ this.itemRuText['/forum'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/income">
|
||||
<i class="el-icon-upload"></i>
|
||||
<span :class="itemTitleClass('/income')" slot="title">{{ this.itemRuText['/income'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/sources">
|
||||
<i class="el-icon-menu"></i>
|
||||
<span :class="itemTitleClass('/sources')" slot="title">{{ this.itemRuText['/sources'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<i class="el-icon-setting"></i>
|
||||
<span :class="itemTitleClass('/settings')" slot="title">{{ this.itemRuText['/settings'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/help">
|
||||
<i class="el-icon-question"></i>
|
||||
<span :class="itemTitleClass('/help')" slot="title">{{ this.itemRuText['/help'] }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-main v-if="showMain" :style="{padding: (isReaderActive ? 0 : '5px')}">
|
||||
<keep-alive>
|
||||
<router-view></router-view>
|
||||
</keep-alive>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
watch: {
|
||||
rootRoute: function() {
|
||||
this.setAppTitle();
|
||||
this.redirectIfNeeded();
|
||||
},
|
||||
mode: function() {
|
||||
this.redirectIfNeeded();
|
||||
}
|
||||
},
|
||||
|
||||
})
|
||||
class App extends Vue {
|
||||
itemRuText = {
|
||||
'/cardindex': 'Картотека',
|
||||
'/reader': 'Читалка',
|
||||
'/forum': 'Форум-чат',
|
||||
'/income': 'Поступления',
|
||||
'/sources': 'Источники',
|
||||
'/settings': 'Параметры',
|
||||
'/help': 'Справка',
|
||||
}
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.dispatch = this.$store.dispatch;
|
||||
this.state = this.$store.state;
|
||||
this.uistate = this.$store.state.uistate;
|
||||
this.config = this.$store.state.config;
|
||||
|
||||
// set-app-title
|
||||
this.$root.$on('set-app-title', this.setAppTitle);
|
||||
|
||||
//global keyHooks
|
||||
this.keyHooks = [];
|
||||
this.keyHook = (event) => {
|
||||
for (const hook of this.keyHooks)
|
||||
hook(event);
|
||||
}
|
||||
|
||||
this.$root.addKeyHook = (hook) => {
|
||||
if (this.keyHooks.indexOf(hook) < 0)
|
||||
this.keyHooks.push(hook);
|
||||
}
|
||||
|
||||
this.$root.removeKeyHook = (hook) => {
|
||||
const i = this.keyHooks.indexOf(hook);
|
||||
if (i >= 0)
|
||||
this.keyHooks.splice(i, 1);
|
||||
}
|
||||
|
||||
document.addEventListener('keyup', (event) => {
|
||||
this.keyHook(event);
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
this.keyHook(event);
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
this.$root.$emit('resize');
|
||||
});
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.dispatch('config/loadConfig');
|
||||
this.$watch('apiError', function(newError) {
|
||||
if (newError) {
|
||||
this.$notify.error({
|
||||
title: 'Ошибка API',
|
||||
dangerouslyUseHTMLString: true,
|
||||
message: newError.response.config.url + '<br>' + newError.response.statusText
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleCollapse() {
|
||||
this.commit('uistate/setAsideBarCollapse', !this.uistate.asideBarCollapse);
|
||||
this.$root.$emit('resize');
|
||||
}
|
||||
|
||||
get isCollapse() {
|
||||
return this.uistate.asideBarCollapse;
|
||||
}
|
||||
|
||||
get asideWidth() {
|
||||
if (this.uistate.asideBarCollapse) {
|
||||
return '64px';
|
||||
} else {
|
||||
return '170px';
|
||||
}
|
||||
}
|
||||
|
||||
get buttonCollapseIcon() {
|
||||
if (this.uistate.asideBarCollapse) {
|
||||
return 'el-icon-d-arrow-right';
|
||||
} else {
|
||||
return 'el-icon-d-arrow-left';
|
||||
}
|
||||
}
|
||||
|
||||
get appName() {
|
||||
if (this.isCollapse)
|
||||
return '<br><br>';
|
||||
else
|
||||
return `${this.config.name} <br>v${this.config.version}`;
|
||||
}
|
||||
|
||||
get apiError() {
|
||||
return this.state.apiError;
|
||||
}
|
||||
|
||||
get rootRoute() {
|
||||
const m = this.$route.path.match(/^(\/[^/]*).*$/i);
|
||||
this.$root.rootRoute = (m ? m[1] : this.$route.path);
|
||||
|
||||
return this.$root.rootRoute;
|
||||
}
|
||||
|
||||
setAppTitle(title) {
|
||||
if (!title) {
|
||||
if (this.mode == 'omnireader') {
|
||||
document.title = `Omni Reader - всегда с вами`;
|
||||
} else if (this.config && this.mode !== null) {
|
||||
document.title = `${this.config.name} - ${this.itemRuText[this.$root.rootRoute]}`;
|
||||
}
|
||||
} else {
|
||||
document.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
itemTitleClass(path) {
|
||||
return (this.rootRoute == path ? {'bold-font': true} : {});
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get showAsideBar() {
|
||||
return (this.mode !== null && this.mode != 'reader' && this.mode != 'omnireader');
|
||||
}
|
||||
|
||||
get isReaderActive() {
|
||||
return this.rootRoute == '/reader';
|
||||
}
|
||||
|
||||
get showMain() {
|
||||
return (this.showAsideBar || this.isReaderActive);
|
||||
}
|
||||
|
||||
redirectIfNeeded() {
|
||||
if ((this.mode == 'reader' || this.mode == 'omnireader') && (this.rootRoute != '/reader')) {
|
||||
//старый url
|
||||
const search = window.location.search.substr(1);
|
||||
const url = search.split('url=')[1] || '';
|
||||
if (url) {
|
||||
window.location = `/#/reader?url=${url}`;
|
||||
} else {
|
||||
this.$router.replace('/reader');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-name {
|
||||
margin-left: 10px;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
line-height: 140%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.bold-font {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.el-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
line-height: 1;
|
||||
background-color: #ccc;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
padding: 0;
|
||||
background-color: #E6EDF4;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.el-menu-vertical:not(.el-menu--collapse) {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.el-menu--collapse {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.el-button-collapse, .el-button-collapse:focus, .el-button-collapse:active, .el-button-collapse:hover {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
border: 0;
|
||||
}
|
||||
.el-menu-item {
|
||||
font-size: 85%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body, html, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font: normal 12pt ReaderDefault;
|
||||
}
|
||||
|
||||
.el-tabs__content {
|
||||
flex: 1;
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'ReaderDefault';
|
||||
src: url('fonts/reader-default.woff') format('woff'),
|
||||
url('fonts/reader-default.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'OpenSans';
|
||||
src: url('fonts/open-sans.woff') format('woff'),
|
||||
url('fonts/open-sans.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('fonts/roboto.woff') format('woff'),
|
||||
url('fonts/roboto.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Rubik';
|
||||
src: url('fonts/rubik.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Avrile';
|
||||
src: url('fonts/avrile.woff') format('woff'),
|
||||
url('fonts/avrile.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Arimo';
|
||||
src: url('fonts/arimo.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'GEO_1';
|
||||
src: url('fonts/geo_1.woff') format('woff'),
|
||||
url('fonts/geo_1.ttf') format('truetype');
|
||||
}
|
||||
|
||||
</style>
|
||||
20
client/components/CardIndex/Book/Book.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Book в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Book extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
20
client/components/CardIndex/Card/Card.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Card в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Card extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
76
client/components/CardIndex/CardIndex.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<el-container direction="vertical">
|
||||
<el-tabs type="border-card" style="height: 100%;" v-model="selectedTab">
|
||||
<el-tab-pane label="Поиск"></el-tab-pane>
|
||||
<el-tab-pane label="Автор"></el-tab-pane>
|
||||
<el-tab-pane label="Книга"></el-tab-pane>
|
||||
<el-tab-pane label="История"></el-tab-pane>
|
||||
<keep-alive>
|
||||
<router-view></router-view>
|
||||
</keep-alive>
|
||||
</el-tabs>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import _ from 'lodash';
|
||||
|
||||
const rootRoute = '/cardindex';
|
||||
const tab2Route = [
|
||||
'/cardindex/search',
|
||||
'/cardindex/card',
|
||||
'/cardindex/book',
|
||||
'/cardindex/history',
|
||||
];
|
||||
let lastActiveTab = null;
|
||||
|
||||
export default @Component({
|
||||
watch: {
|
||||
selectedTab: function(newValue, oldValue) {
|
||||
lastActiveTab = newValue;
|
||||
this.setRouteByTab(newValue);
|
||||
},
|
||||
curRoute: function(newValue, oldValue) {
|
||||
this.setTabByRoute(newValue);
|
||||
},
|
||||
},
|
||||
})
|
||||
class CardIndex extends Vue {
|
||||
selectedTab = null;
|
||||
|
||||
mounted() {
|
||||
this.setTabByRoute(this.curRoute);
|
||||
}
|
||||
|
||||
setTabByRoute(route) {
|
||||
const t = _.indexOf(tab2Route, route);
|
||||
if (t >= 0) {
|
||||
if (t !== this.selectedTab)
|
||||
this.selectedTab = t.toString();
|
||||
} else {
|
||||
if (route == rootRoute && lastActiveTab !== null)
|
||||
this.setRouteByTab(lastActiveTab);
|
||||
}
|
||||
}
|
||||
|
||||
setRouteByTab(tab) {
|
||||
const t = Number(tab);
|
||||
if (tab2Route[t] !== this.curRoute) {
|
||||
this.$router.replace(tab2Route[t]);
|
||||
}
|
||||
}
|
||||
|
||||
get curRoute() {
|
||||
const m = this.$route.path.match(/^(\/[^\/]*\/[^\/]*).*$/i);
|
||||
return (m ? m[1] : this.$route.path);
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
20
client/components/CardIndex/History/History.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел History в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class History extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
20
client/components/CardIndex/Search/Search.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Search в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Search extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
20
client/components/Help/Help.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Help в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Help extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
20
client/components/Income/Income.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Income в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Income extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
20
client/components/NotFound404/NotFound404.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Страница не найдена
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class NotFound404 extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
80
client/components/Reader/ClickMapPage/ClickMapPage.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div ref="page" class="map-page">
|
||||
<div class="content" v-html="mapHtml"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
import {sleep} from '../../../share/utils';
|
||||
import {clickMap, clickMapText} from '../share/clickMap';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class ClickMapPage extends Vue {
|
||||
fontSize = '200%';
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
get mapHtml() {
|
||||
let result = '<div style="flex: 1; display: flex;">';
|
||||
|
||||
let px = 0;
|
||||
for (const x in clickMap) {
|
||||
let div = `<div style="display: flex; flex-direction: column; width: ${x - px}%;">`;
|
||||
|
||||
let py = 0;
|
||||
for (const y in clickMap[x]) {
|
||||
const text = clickMapText[clickMap[x][y]].split(' ');
|
||||
let divText = '';
|
||||
for (const t of text)
|
||||
divText += `<span>${t}</span>`;
|
||||
div += `<div style="display: flex; flex-direction: column; justify-content: center; align-items: center; ` +
|
||||
`height: ${y - py}%; border: 1px solid white; font-size: ${this.fontSize}; line-height: 100%;">${divText}</div>`;
|
||||
py = y;
|
||||
}
|
||||
|
||||
div += '</div>';
|
||||
px = x;
|
||||
result += div;
|
||||
}
|
||||
|
||||
result += '</div>';
|
||||
return result;
|
||||
}
|
||||
|
||||
async slowDisappear() {
|
||||
const page = this.$refs.page;
|
||||
page.style.animation = 'click-map-disappear 5s ease-in 1';
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-page {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 19;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@keyframes click-map-disappear {
|
||||
0% { opacity: 0.9; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
148
client/components/Reader/CopyTextPage/CopyTextPage.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Скопировать текст
|
||||
</template>
|
||||
|
||||
<div ref="text" class="text" tabindex="-1">
|
||||
<div v-html="text"></div>
|
||||
</div>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import {sleep} from '../../../share/utils';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
})
|
||||
class CopyTextPage extends Vue {
|
||||
text = null;
|
||||
initStep = null;
|
||||
initPercentage = 0;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.reader = this.$store.state.reader;
|
||||
}
|
||||
|
||||
async init(bookPos, parsed, copyFullText) {
|
||||
this.text = 'Загрузка';
|
||||
await this.$nextTick();
|
||||
|
||||
const paraIndex = parsed.findParaIndex(bookPos || 0);
|
||||
this.initStep = true;
|
||||
this.stopInit = false;
|
||||
|
||||
let nextPerc = 0;
|
||||
let text = '';
|
||||
let cut = '';
|
||||
let from = 0;
|
||||
let to = parsed.para.length;
|
||||
if (!copyFullText) {
|
||||
from = paraIndex - 100;
|
||||
from = (from < 0 ? 0 : from);
|
||||
to = paraIndex + 100;
|
||||
to = (to > parsed.para.length ? parsed.para.length : to);
|
||||
cut = '<p>..... Текст вырезан. Если хотите скопировать больше, поставьте в настройках галочку "Загружать весь текст"';
|
||||
}
|
||||
|
||||
if (from > 0)
|
||||
text += cut;
|
||||
for (let i = from; i < to; i++) {
|
||||
const p = parsed.para[i];
|
||||
const parts = parsed.splitToStyle(p.text);
|
||||
if (this.stopInit)
|
||||
return;
|
||||
|
||||
text += `<p id="p${i}" class="copyPara">`;
|
||||
for (const part of parts)
|
||||
text += part.text;
|
||||
|
||||
const perc = Math.round(i/parsed.para.length*100);
|
||||
|
||||
if (perc > nextPerc) {
|
||||
this.initPercentage = perc;
|
||||
await sleep(1);
|
||||
nextPerc = perc + 10;
|
||||
}
|
||||
}
|
||||
if (to < parsed.para.length)
|
||||
text += cut;
|
||||
|
||||
this.text = text;
|
||||
this.initStep = false;
|
||||
|
||||
await this.$nextTick();
|
||||
this.$refs.text.focus();
|
||||
|
||||
const p = document.getElementById('p' + paraIndex);
|
||||
if (p) {
|
||||
this.$refs.text.scrollTop = p.offsetTop;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stopInit = true;
|
||||
this.$emit('copy-text-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && (event.code == 'Escape')) {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 0 10px;
|
||||
position: relative;
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
.text:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.copyPara {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-indent: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h4>Возможности читалки:</h4>
|
||||
<ul>
|
||||
<li>загрузка любой страницы интернета</li>
|
||||
<li>изменение цвета фона, текста, размер и тип шрифта и прочее</li>
|
||||
<li>установка и запоминание текущей позиции и настроек в браузере (в будущем планируется сохранение и на сервер)</li>
|
||||
<li>кэширование файлов книг на клиенте и на сервере</li>
|
||||
<li>открытие книг с локального диска</li>
|
||||
<li>плавный скроллинг текста</li>
|
||||
<li>анимация перелистывания (скоро)</li>
|
||||
<li>поиск по тексту и копирование фрагмента</li>
|
||||
<li>запоминание недавних книг, скачивание книги из читалки в формате fb2</li>
|
||||
<li>управление кликом и с клавиатуры</li>
|
||||
<li>подключение к интернету не обязательно для чтения книги после ее загрузки</li>
|
||||
<li>регистрация не требуется</li>
|
||||
<li>поддерживаемые браузеры: Google Chrome, Mozilla Firefox последних версий</li>
|
||||
</ul>
|
||||
|
||||
<p>В качестве URL можно задавать html-страничку с книгой, либо прямую ссылку
|
||||
на файл из онлайн-библиотеки (например, скопировав адрес ссылки или кнопки "скачать fb2").</p>
|
||||
<p>Поддерживаемые форматы: <strong>html, txt, fb2, fb2.zip</strong></p>
|
||||
|
||||
<div v-html="automationHtml"></div>
|
||||
<p>Связаться с разработчиком: <a href="mailto:bookpauk@gmail.com">bookpauk@gmail.com</a></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class CommonHelpPage extends Vue {
|
||||
created() {
|
||||
this.config = this.$store.state.config;
|
||||
}
|
||||
|
||||
get automationHtml() {
|
||||
if (this.config.mode == 'omnireader') {
|
||||
return `<p>Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
|
||||
<br><strong>javascript:location.href='http://omnireader.ru/?url='+location.href;</strong>
|
||||
<br>Тогда, нажав на получившуюся кнопку на любой странице интернета, вы автоматически откроете ее в Omni Reader.</p>`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="box">
|
||||
<p class="p">Проект существует исключительно на личном энтузиазме.</p>
|
||||
<p class="p">Чтобы энтузиазма было побольше, вы можете пожертвовать на развитие проекта любую сумму:</p>
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/yandex.png">
|
||||
<el-button class="button" @click="donateYandexMoney">Пожертвовать</el-button><br>
|
||||
<div class="para">{{ yandexAddress }}</div>
|
||||
</div>
|
||||
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/bitcoin.png">
|
||||
<el-button class="button" @click="copyAddress(bitcoinAddress, 'Bitcoin')">Скопировать</el-button><br>
|
||||
<div class="para">{{ bitcoinAddress }}</div>
|
||||
</div>
|
||||
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/litecoin.png">
|
||||
<el-button class="button" @click="copyAddress(litecoinAddress, 'Litecoin')">Скопировать</el-button><br>
|
||||
<div class="para">{{ litecoinAddress }}</div>
|
||||
</div>
|
||||
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/monero.png">
|
||||
<el-button class="button" @click="copyAddress(moneroAddress, 'Monero')">Скопировать</el-button><br>
|
||||
<div class="para">{{ moneroAddress }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {copyTextToClipboard} from '../../../../share/utils';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class DonateHelpPage extends Vue {
|
||||
yandexAddress = '410018702323056';
|
||||
bitcoinAddress = '3EbgZ7MK1UVaN38Gty5DCBtS4PknM4Ut85';
|
||||
litecoinAddress = 'MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ';
|
||||
moneroAddress = '8BQPnvHcPSHM5gMQsmuypDgx9NNsYqwXKfDDuswEyF2Q2ewQSfd2pkK6ydH2wmMyq2JViZvy9DQ35hLMx7g72mFWNJTPtnz';
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
donateYandexMoney() {
|
||||
window.open(`https://money.yandex.ru/to/${this.yandexAddress}`, '_blank');
|
||||
}
|
||||
|
||||
async copyAddress(address, prefix) {
|
||||
const result = await copyTextToClipboard(address);
|
||||
const msg = (result ? `${prefix}-адрес ${address} успешно скопирован в буфер обмена` : 'Копирование не удалось');
|
||||
if (result)
|
||||
this.$notify.success({message: msg});
|
||||
else
|
||||
this.$notify.error({message: msg});
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-indent: 20px;
|
||||
}
|
||||
|
||||
.box {
|
||||
flex: 1;
|
||||
max-width: 550px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.address {
|
||||
padding-top: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.para {
|
||||
margin: 10px 10px 10px 40px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 130px;
|
||||
position: relative;
|
||||
top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
100
client/components/Reader/HelpPage/HelpPage.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Справка
|
||||
</template>
|
||||
|
||||
<el-tabs type="border-card" v-model="selectedTab">
|
||||
<el-tab-pane class="tab" label="Общее">
|
||||
<CommonHelpPage></CommonHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Клавиатура">
|
||||
<HotkeysHelpPage></HotkeysHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Мышь/тачпад">
|
||||
<MouseHelpPage></MouseHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Помочь проекту" name="donate">
|
||||
<DonateHelpPage></DonateHelpPage>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import CommonHelpPage from './CommonHelpPage/CommonHelpPage.vue';
|
||||
import HotkeysHelpPage from './HotkeysHelpPage/HotkeysHelpPage.vue';
|
||||
import MouseHelpPage from './MouseHelpPage/MouseHelpPage.vue';
|
||||
import DonateHelpPage from './DonateHelpPage/DonateHelpPage.vue';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
CommonHelpPage,
|
||||
HotkeysHelpPage,
|
||||
MouseHelpPage,
|
||||
DonateHelpPage,
|
||||
},
|
||||
})
|
||||
class HelpPage extends Vue {
|
||||
selectedTab = null;
|
||||
|
||||
close() {
|
||||
this.$emit('help-toggle');
|
||||
}
|
||||
|
||||
activateDonateHelpPage() {
|
||||
this.selectedTab = 'donate';
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && (event.code == 'Escape')) {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-tab-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h4>Управление с помощью горячих клавиш:</h4>
|
||||
<ul>
|
||||
<li><b>F1, H</b> - открыть справку</li>
|
||||
<li><b>Escape</b> - показать/скрыть страницу загрузки</li>
|
||||
<li><b>Tab</b> - показать/скрыть панель управления</li>
|
||||
<li><b>PageUp, Left, Shift+Space, Backspace</b> - страницу назад</li>
|
||||
<li><b>PageDown, Right, Space</b> - страницу вперед</li>
|
||||
<li><b>Home</b> - в начало книги</li>
|
||||
<li><b>End</b> - в конец книги</li>
|
||||
<li><b>Up</b> - строчку назад</li>
|
||||
<li><b>Down</b> - строчку вперёд</li>
|
||||
<li><b>A, Shift+A</b> - изменить размер шрифта</li>
|
||||
<li><b>Enter, F, F11, ` (апостроф)</b> - вкл./выкл. полный экран</li>
|
||||
<li><b>Z</b> - вкл./выкл. плавный скроллинг текста</li>
|
||||
<li><b>Shift+Down/Shift+Up</b> - увеличить/уменьшить скорость скроллинга
|
||||
<li><b>P</b> - установить страницу</li>
|
||||
<li><b>Ctrl+F</b> - найти в тексте</li>
|
||||
<li><b>Ctrl+C</b> - скопировать текст со страницы</li>
|
||||
<li><b>R</b> - принудительно обновить книгу в обход кэша</li>
|
||||
<li><b>X</b> - открыть недавние</li>
|
||||
<li><b>S</b> - открыть окно настроек</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class HotkeysHelpPage extends Vue {
|
||||
created() {
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h4>Управление с помощью мыши/тачпада:</h4>
|
||||
<ul>
|
||||
<li><b>ЛКМ/ТАЧ</b> по экрану в одну из областей - активация действия:</li>
|
||||
<div class="click-map-page">
|
||||
<ClickMapPage ref="clickMapPage"></ClickMapPage>
|
||||
</div>
|
||||
<li><b>ПКМ</b> - показать/скрыть панель управления</li>
|
||||
<li><b>СКМ</b> - вкл./выкл. плавный скроллинг текста</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
import ClickMapPage from '../../ClickMapPage/ClickMapPage.vue';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
ClickMapPage,
|
||||
},
|
||||
})
|
||||
class MouseHelpPage extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.$refs.clickMapPage.$el.style.fontSize = '50%';
|
||||
this.$refs.clickMapPage.$el.style.backgroundColor = '#478355';
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.click-map-page {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
margin: 10px 0 10px 0;
|
||||
}
|
||||
</style>
|
||||
251
client/components/Reader/HistoryPage/HistoryPage.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Последние 100 открытых книг
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
size="mini"
|
||||
height="1px"
|
||||
stripe
|
||||
border
|
||||
:default-sort = "{prop: 'touchDateTime', order: 'descending'}"
|
||||
:header-cell-style = "headerCellStyle"
|
||||
>
|
||||
|
||||
<el-table-column
|
||||
prop="touchDateTime"
|
||||
min-width="90px"
|
||||
sortable
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<span style="font-size: 90%">Время<br>просм.</span>
|
||||
</template>
|
||||
<template slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
{{ scope.row.touchDate }}<br>
|
||||
{{ scope.row.touchTime }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<!--el-input ref="input"
|
||||
:value="search" @input="search = $event"
|
||||
size="mini"
|
||||
style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 10px"
|
||||
placeholder="Найти"/-->
|
||||
<div class="el-input el-input--mini">
|
||||
<input class="el-input__inner"
|
||||
placeholder="Найти"
|
||||
style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 20px; padding: 0 10px 0 10px"
|
||||
:value="search" @input="search = $event.target.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table-column
|
||||
min-width="300px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
<span style="color: green">{{ scope.row.desc.author }}</span><br>
|
||||
<span>{{ scope.row.desc.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
min-width="100px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<a v-show="isUrl(scope.row.url)" :href="scope.row.url" target="_blank">Оригинал</a><br>
|
||||
<a :href="scope.row.path" :download="getFileNameFromPath(scope.row.path)">Скачать FB2</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
width="60px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
style="width: 30px; padding: 7px 0 7px 0; margin-left: 4px"
|
||||
@click="handleDel(scope.row.key)"><i class="el-icon-close"></i>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import path from 'path';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {formatDate} from '../../../share/utils';
|
||||
import Window from '../../share/Window.vue';
|
||||
import bookManager from '../share/bookManager';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
search: function() {
|
||||
this.updateTableData();
|
||||
}
|
||||
},
|
||||
})
|
||||
class HistoryPage extends Vue {
|
||||
search = null;
|
||||
tableData = null;
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.updateTableData();
|
||||
this.mostRecentBook = bookManager.mostRecentBook();
|
||||
}
|
||||
|
||||
updateTableData() {
|
||||
let result = [];
|
||||
|
||||
for (let bookKey in bookManager.recent) {
|
||||
const book = bookManager.recent[bookKey];
|
||||
let d = new Date();
|
||||
d.setTime(book.touchTime);
|
||||
const t = formatDate(d).split(' ');
|
||||
|
||||
let perc = '';
|
||||
let textLen = '';
|
||||
const p = (book.bookPosSeen ? book.bookPosSeen : (book.bookPos ? book.bookPos : 0));
|
||||
if (book.textLength) {
|
||||
perc = ` [${((p/book.textLength)*100).toFixed(2)}%]`;
|
||||
textLen = ` ${Math.round(book.textLength/1000)}k`;
|
||||
}
|
||||
|
||||
const fb2 = (book.fb2 ? book.fb2 : {});
|
||||
result.push({
|
||||
touchDateTime: book.touchTime,
|
||||
touchDate: t[0],
|
||||
touchTime: t[1],
|
||||
desc: {
|
||||
title: `"${fb2.bookTitle}"${perc}${textLen}`,
|
||||
author: _.compact([
|
||||
fb2.lastName,
|
||||
fb2.firstName,
|
||||
fb2.middleName
|
||||
]).join(' '),
|
||||
},
|
||||
url: book.url,
|
||||
path: book.path,
|
||||
key: book.key,
|
||||
});
|
||||
}
|
||||
|
||||
const search = this.search;
|
||||
this.tableData = result.filter(item => {
|
||||
return !search ||
|
||||
item.touchTime.includes(search) ||
|
||||
item.touchDate.includes(search) ||
|
||||
item.desc.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.desc.author.toLowerCase().includes(search.toLowerCase())
|
||||
});
|
||||
}
|
||||
|
||||
headerCellStyle(cell) {
|
||||
let result = {margin: 0, padding: 0};
|
||||
if (cell.columnIndex > 0) {
|
||||
result['border-bottom'] = 0;
|
||||
}
|
||||
if (cell.rowIndex > 0) {
|
||||
result.height = '0px';
|
||||
result['border-right'] = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getFileNameFromPath(fb2Path) {
|
||||
return path.basename(fb2Path).substr(0, 10) + '.fb2';
|
||||
}
|
||||
|
||||
openOriginal(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
openFb2(path) {
|
||||
window.open(path, '_blank');
|
||||
}
|
||||
|
||||
async handleDel(key) {
|
||||
await bookManager.delRecentBook({key});
|
||||
this.updateTableData();
|
||||
|
||||
const newRecent = bookManager.mostRecentBook();
|
||||
if (this.mostRecentBook != newRecent)
|
||||
this.$emit('load-book', newRecent);
|
||||
|
||||
this.mostRecentBook = newRecent;
|
||||
if (!this.mostRecentBook)
|
||||
this.close();
|
||||
}
|
||||
|
||||
loadBook(url) {
|
||||
this.$emit('load-book', {url});
|
||||
this.close();
|
||||
}
|
||||
|
||||
isUrl(url) {
|
||||
return (url.indexOf('file://') != 0);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('history-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && event.code == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.desc {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
160
client/components/Reader/LoaderPage/LoaderPage.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div ref="main" class="main">
|
||||
<div class="part">
|
||||
<span class="greeting bold-font">{{ title }}</span>
|
||||
<span class="greeting">Добро пожаловать!</span>
|
||||
<span class="greeting">Поддерживаются форматы: fb2, fb2.zip, html, txt</span>
|
||||
</div>
|
||||
<div class="part center">
|
||||
<el-input ref="input" placeholder="URL книги" v-model="bookUrl">
|
||||
<el-button slot="append" icon="el-icon-check" @click="submitUrl"></el-button>
|
||||
</el-input>
|
||||
<div class="space"></div>
|
||||
<input type="file" id="file" ref="file" @change="loadFile" style='display: none;'/>
|
||||
<el-button size="mini" @click="loadFileClick">
|
||||
Загрузить файл с диска
|
||||
</el-button>
|
||||
<div class="space"></div>
|
||||
<span v-if="config.mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Комментарии</span>
|
||||
</div>
|
||||
<div class="part bottom">
|
||||
<span class="bottom-span clickable" @click="openHelp">Справка</span>
|
||||
<span class="bottom-span clickable" @click="openDonate">Помочь проекту</span>
|
||||
<span class="bottom-span">{{ version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class LoaderPage extends Vue {
|
||||
bookUrl = null;
|
||||
loadPercent = 0;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.config = this.$store.state.config;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.progress = this.$refs.progress;
|
||||
}
|
||||
|
||||
activated() {
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
|
||||
get title() {
|
||||
if (this.config.mode == 'omnireader')
|
||||
return 'Omni Reader - браузерная онлайн-читалка.';
|
||||
return 'Универсальная читалка книг и ресурсов интернета.';
|
||||
|
||||
}
|
||||
|
||||
get version() {
|
||||
return `v${this.config.version}`;
|
||||
}
|
||||
|
||||
submitUrl() {
|
||||
if (this.bookUrl) {
|
||||
this.$emit('load-book', {url: this.bookUrl});
|
||||
this.bookUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
loadFileClick() {
|
||||
this.$refs.file.click();
|
||||
}
|
||||
|
||||
loadFile() {
|
||||
const file = this.$refs.file.files[0];
|
||||
if (file)
|
||||
this.$emit('load-file', {file});
|
||||
}
|
||||
|
||||
openHelp() {
|
||||
this.$emit('help-toggle');
|
||||
}
|
||||
|
||||
openDonate() {
|
||||
this.$emit('donate-toggle');
|
||||
}
|
||||
|
||||
openComments() {
|
||||
window.open('http://samlib.ru/comment/b/bookpauk/bookpauk_reader', '_blank');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
//недостатки сторонних ui
|
||||
const input = this.$refs.input.$refs.input;
|
||||
if (document.activeElement === input && event.type == 'keydown' && event.code == 'Enter') {
|
||||
this.submitUrl();
|
||||
}
|
||||
|
||||
if (event.type == 'keydown' && (event.code == 'F1' || (document.activeElement !== input && event.code == 'KeyH'))) {
|
||||
this.$emit('help-toggle');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
<style scoped>
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.part {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 130%;
|
||||
line-height: 170%;
|
||||
}
|
||||
|
||||
.bold-font {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: flex-start;
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bottom-span {
|
||||
font-size: 70%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.space {
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
99
client/components/Reader/ProgressPage/ProgressPage.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div v-show="visible" class="main">
|
||||
<div class="center">
|
||||
<el-progress type="circle" :width="100" :stroke-width="6" color="#0F9900" :percentage="percentage"></el-progress>
|
||||
<p class="text">{{ text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
const ruMessage = {
|
||||
'start': ' ',
|
||||
'finish': ' ',
|
||||
'error': ' ',
|
||||
'download': 'скачивание',
|
||||
'decompress': 'распаковка',
|
||||
'convert': 'конвертирование',
|
||||
'loading': 'загрузка',
|
||||
'parse': 'обработка',
|
||||
'upload': 'отправка',
|
||||
};
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class ProgressPage extends Vue {
|
||||
text = '';
|
||||
totalSteps = 1;
|
||||
step = 1;
|
||||
progress = 0;
|
||||
visible = false;
|
||||
|
||||
show() {
|
||||
this.$el.style.width = this.$parent.$el.offsetWidth + 'px';
|
||||
this.$el.style.height = this.$parent.$el.offsetHeight + 'px';
|
||||
this.text = '';
|
||||
this.totalSteps = 1;
|
||||
this.step = 1;
|
||||
this.progress = 0;
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
if (state.state)
|
||||
this.text = (ruMessage[state.state] ? ruMessage[state.state] : state.state);
|
||||
this.step = (state.step ? state.step : this.step);
|
||||
this.totalSteps = (state.totalSteps > this.totalSteps ? state.totalSteps : this.totalSteps);
|
||||
this.progress = state.progress || 0;
|
||||
}
|
||||
|
||||
get percentage() {
|
||||
let circle = document.querySelector('path[class="el-progress-circle__path"]');
|
||||
if (circle)
|
||||
circle.style.transition = '';
|
||||
return Math.round(((this.step - 1)/this.totalSteps + this.progress/(100*this.totalSteps))*100);
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
<style scoped>
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
z-index: 100;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
|
||||
position: absolute;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
color: white;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style>
|
||||
.el-progress__text {
|
||||
color: lightgreen;
|
||||
}
|
||||
</style>
|
||||
901
client/components/Reader/Reader.vue
Normal file
@@ -0,0 +1,901 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header v-show="toolBarActive" height='50px'>
|
||||
<div class="header">
|
||||
<el-tooltip content="Загрузить книгу" :open-delay="1000" effect="light">
|
||||
<el-button ref="loader" class="tool-button" :class="buttonActiveClass('loader')" @click="buttonClick('loader')"><i class="el-icon-back"></i></el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<div>
|
||||
<el-tooltip content="Действие назад" :open-delay="1000" effect="light">
|
||||
<el-button ref="undoAction" class="tool-button" :class="buttonActiveClass('undoAction')" @click="buttonClick('undoAction')" ><i class="el-icon-arrow-left"></i></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Действие вперед" :open-delay="1000" effect="light">
|
||||
<el-button ref="redoAction" class="tool-button" :class="buttonActiveClass('redoAction')" @click="buttonClick('redoAction')" ><i class="el-icon-arrow-right"></i></el-button>
|
||||
</el-tooltip>
|
||||
<div class="space"></div>
|
||||
<el-tooltip content="На весь экран" :open-delay="1000" effect="light">
|
||||
<el-button ref="fullScreen" class="tool-button" :class="buttonActiveClass('fullScreen')" @click="buttonClick('fullScreen')"><i class="el-icon-rank"></i></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Плавный скроллинг" :open-delay="1000" effect="light">
|
||||
<el-button ref="scrolling" class="tool-button" :class="buttonActiveClass('scrolling')" @click="buttonClick('scrolling')"><i class="el-icon-sort"></i></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Перелистнуть" :open-delay="1000" effect="light">
|
||||
<el-button ref="setPosition" class="tool-button" :class="buttonActiveClass('setPosition')" @click="buttonClick('setPosition')"><i class="el-icon-d-arrow-right"></i></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Найти в тексте" :open-delay="1000" effect="light">
|
||||
<el-button ref="search" class="tool-button" :class="buttonActiveClass('search')" @click="buttonClick('search')"><i class="el-icon-search"></i></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Скопировать текст со страницы" :open-delay="1000" effect="light">
|
||||
<el-button ref="copyText" class="tool-button" :class="buttonActiveClass('copyText')" @click="buttonClick('copyText')"><i class="el-icon-edit-outline"></i></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Принудительно обновить книгу в обход кэша" :open-delay="1000" effect="light">
|
||||
<el-button ref="refresh" class="tool-button" :class="buttonActiveClass('refresh')" @click="buttonClick('refresh')">
|
||||
<i class="el-icon-refresh" :class="{clear: !showRefreshIcon}"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<div class="space"></div>
|
||||
<el-tooltip content="Открыть недавние" :open-delay="1000" effect="light">
|
||||
<el-button ref="history" class="tool-button" :class="buttonActiveClass('history')" @click="buttonClick('history')"><i class="el-icon-document"></i></el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-tooltip content="Настроить" :open-delay="1000" effect="light">
|
||||
<el-button ref="settings" class="tool-button" :class="buttonActiveClass('settings')" @click="buttonClick('settings')"><i class="el-icon-setting"></i></el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<keep-alive>
|
||||
<component ref="page" :is="activePage"
|
||||
@load-book="loadBook"
|
||||
@load-file="loadFile"
|
||||
@book-pos-changed="bookPosChanged"
|
||||
@tool-bar-toggle="toolBarToggle"
|
||||
@full-screen-toogle="fullScreenToggle"
|
||||
@stop-scrolling="stopScrolling"
|
||||
@scrolling-toggle="scrollingToggle"
|
||||
@help-toggle="helpToggle"
|
||||
@donate-toggle="donateToggle"
|
||||
></component>
|
||||
</keep-alive>
|
||||
|
||||
<SetPositionPage v-if="setPositionActive" ref="setPositionPage" @set-position-toggle="setPositionToggle" @book-pos-changed="bookPosChanged"></SetPositionPage>
|
||||
<SearchPage v-show="searchActive" ref="searchPage"
|
||||
@search-toggle="searchToggle"
|
||||
@book-pos-changed="bookPosChanged"
|
||||
@start-text-search="startTextSearch"
|
||||
@stop-text-search="stopTextSearch">
|
||||
</SearchPage>
|
||||
<CopyTextPage v-if="copyTextActive" ref="copyTextPage" @copy-text-toggle="copyTextToggle"></CopyTextPage>
|
||||
<HistoryPage v-if="historyActive" ref="historyPage" @load-book="loadBook" @history-toggle="historyToggle"></HistoryPage>
|
||||
<SettingsPage v-if="settingsActive" ref="settingsPage" @settings-toggle="settingsToggle"></SettingsPage>
|
||||
<HelpPage v-if="helpActive" ref="helpPage" @help-toggle="helpToggle"></HelpPage>
|
||||
<ClickMapPage v-show="clickMapActive" ref="clickMapPage"></ClickMapPage>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import LoaderPage from './LoaderPage/LoaderPage.vue';
|
||||
import TextPage from './TextPage/TextPage.vue';
|
||||
import ProgressPage from './ProgressPage/ProgressPage.vue';
|
||||
|
||||
import SetPositionPage from './SetPositionPage/SetPositionPage.vue';
|
||||
import SearchPage from './SearchPage/SearchPage.vue';
|
||||
import CopyTextPage from './CopyTextPage/CopyTextPage.vue';
|
||||
import HistoryPage from './HistoryPage/HistoryPage.vue';
|
||||
import SettingsPage from './SettingsPage/SettingsPage.vue';
|
||||
import HelpPage from './HelpPage/HelpPage.vue';
|
||||
import ClickMapPage from './ClickMapPage/ClickMapPage.vue';
|
||||
|
||||
import bookManager from './share/bookManager';
|
||||
import readerApi from '../../api/reader';
|
||||
import _ from 'lodash';
|
||||
import {sleep} from '../../share/utils';
|
||||
import restoreOldSettings from './share/restoreOldSettings';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
LoaderPage,
|
||||
TextPage,
|
||||
ProgressPage,
|
||||
|
||||
SetPositionPage,
|
||||
SearchPage,
|
||||
CopyTextPage,
|
||||
HistoryPage,
|
||||
SettingsPage,
|
||||
HelpPage,
|
||||
ClickMapPage,
|
||||
},
|
||||
watch: {
|
||||
bookPos: function(newValue) {
|
||||
if (newValue !== undefined && this.activePage == 'TextPage') {
|
||||
const textPage = this.$refs.page;
|
||||
if (textPage.bookPos != newValue) {
|
||||
textPage.bookPos = newValue;
|
||||
}
|
||||
this.debouncedSetRecentBook(newValue);
|
||||
}
|
||||
},
|
||||
routeParamPos: function(newValue) {
|
||||
if (newValue !== undefined && newValue != this.bookPos) {
|
||||
this.bookPos = newValue;
|
||||
}
|
||||
},
|
||||
routeParamUrl: function(newValue) {
|
||||
if (newValue !== '' && newValue !== this.mostRecentBook().url) {
|
||||
this.loadBook({url: newValue, bookPos: this.routeParamPos});
|
||||
}
|
||||
},
|
||||
settings: function(newValue) {
|
||||
this.allowUrlParamBookPos = newValue.allowUrlParamBookPos;
|
||||
this.copyFullText = newValue.copyFullText;
|
||||
this.showClickMapPage = newValue.showClickMapPage;
|
||||
this.updateRoute();
|
||||
},
|
||||
},
|
||||
})
|
||||
class Reader extends Vue {
|
||||
loaderActive = false;
|
||||
progressActive = false;
|
||||
fullScreenActive = false;
|
||||
|
||||
scrollingActive = false;
|
||||
setPositionActive = false;
|
||||
searchActive = false;
|
||||
copyTextActive = false;
|
||||
historyActive = false;
|
||||
settingsActive = false;
|
||||
helpActive = false;
|
||||
clickMapActive = false;
|
||||
|
||||
bookPos = null;
|
||||
allowUrlParamBookPos = false;
|
||||
showRefreshIcon = true;
|
||||
mostRecentBookReactive = null;
|
||||
|
||||
actionList = [];
|
||||
actionCur = -1;
|
||||
|
||||
created() {
|
||||
this.loading = true;
|
||||
this.commit = this.$store.commit;
|
||||
this.dispatch = this.$store.dispatch;
|
||||
this.reader = this.$store.state.reader;
|
||||
|
||||
this.$root.addKeyHook(this.keyHook);
|
||||
|
||||
this.lastActivePage = false;
|
||||
|
||||
this.debouncedUpdateRoute = _.debounce(() => {
|
||||
this.updateRoute();
|
||||
}, 1000);
|
||||
|
||||
this.debouncedSetRecentBook = _.debounce(async(newValue) => {
|
||||
const recent = this.mostRecentBook();
|
||||
if (recent && recent.bookPos != newValue) {
|
||||
await bookManager.setRecentBook(Object.assign({}, recent, {bookPos: newValue, bookPosSeen: this.bookPosSeen}));
|
||||
|
||||
if (this.actionCur < 0 || (this.actionCur >= 0 && this.actionList[this.actionCur] != newValue))
|
||||
this.addAction(newValue);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
this.fullScreenActive = (document.fullscreenElement !== null);
|
||||
});
|
||||
|
||||
this.allowUrlParamBookPos = this.settings.allowUrlParamBookPos;
|
||||
this.copyFullText = this.settings.copyFullText;
|
||||
this.showClickMapPage = this.settings.showClickMapPage;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
(async() => {
|
||||
await bookManager.init();
|
||||
await restoreOldSettings(this.settings, bookManager, this.commit);
|
||||
|
||||
if (this.$root.rootRoute == '/reader') {
|
||||
if (this.routeParamUrl) {
|
||||
this.loadBook({url: this.routeParamUrl, bookPos: this.routeParamPos});
|
||||
} else if (this.mostRecentBook()) {
|
||||
this.loadBook({url: this.mostRecentBook().url});
|
||||
} else {
|
||||
this.loaderActive = true;
|
||||
}
|
||||
}
|
||||
this.loading = false;
|
||||
})();
|
||||
}
|
||||
|
||||
get routeParamPos() {
|
||||
let result = undefined;
|
||||
const q = this.$route.query;
|
||||
if (q['__p']) {
|
||||
result = q['__p'];
|
||||
if (Array.isArray(result))
|
||||
result = result[0];
|
||||
}
|
||||
|
||||
return (result ? parseInt(result, 10) || 0 : result);
|
||||
}
|
||||
|
||||
updateRoute(isNewRoute) {
|
||||
const recent = this.mostRecentBook();
|
||||
const pos = (recent && recent.bookPos && this.allowUrlParamBookPos ? `__p=${recent.bookPos}&` : '');
|
||||
const url = (recent ? `url=${recent.url}` : '');
|
||||
if (isNewRoute)
|
||||
this.$router.push(`/reader?${pos}${url}`);
|
||||
else
|
||||
this.$router.replace(`/reader?${pos}${url}`);
|
||||
|
||||
}
|
||||
|
||||
get routeParamUrl() {
|
||||
let result = '';
|
||||
const path = this.$route.fullPath;
|
||||
const i = path.indexOf('url=');
|
||||
if (i >= 0) {
|
||||
result = path.substr(i + 4);
|
||||
}
|
||||
|
||||
return decodeURIComponent(result);
|
||||
}
|
||||
|
||||
bookPosChanged(event) {
|
||||
if (event.bookPosSeen !== undefined)
|
||||
this.bookPosSeen = event.bookPosSeen;
|
||||
this.bookPos = event.bookPos;
|
||||
this.debouncedUpdateRoute();
|
||||
}
|
||||
|
||||
get toolBarActive() {
|
||||
return this.reader.toolBarActive;
|
||||
}
|
||||
|
||||
mostRecentBook() {
|
||||
const result = bookManager.mostRecentBook();
|
||||
this.mostRecentBookReactive = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.$store.state.reader.settings;
|
||||
}
|
||||
|
||||
addAction(pos) {
|
||||
let a = this.actionList;
|
||||
if (!a.length || a[a.length - 1] != pos) {
|
||||
a.push(pos);
|
||||
if (a.length > 20)
|
||||
a.shift();
|
||||
this.actionCur = a.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
toolBarToggle() {
|
||||
this.commit('reader/setToolBarActive', !this.toolBarActive);
|
||||
this.$root.$emit('resize');
|
||||
}
|
||||
|
||||
fullScreenToggle() {
|
||||
this.fullScreenActive = !this.fullScreenActive;
|
||||
if (this.fullScreenActive) {
|
||||
const element = document.documentElement;
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen();
|
||||
} else if (element.webkitrequestFullscreen) {
|
||||
element.webkitRequestFullscreen();
|
||||
} else if (element.mozRequestFullscreen) {
|
||||
element.mozRequestFullScreen();
|
||||
}
|
||||
} else {
|
||||
if (document.cancelFullScreen) {
|
||||
document.cancelFullScreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.webkitCancelFullScreen) {
|
||||
document.webkitCancelFullScreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeAllTextPages() {
|
||||
this.setPositionActive = false;
|
||||
this.copyTextActive = false;
|
||||
this.historyActive = false;
|
||||
this.settingsActive = false;
|
||||
this.stopScrolling();
|
||||
this.stopSearch();
|
||||
this.helpActive = false;
|
||||
}
|
||||
|
||||
loaderToggle() {
|
||||
this.loaderActive = !this.loaderActive;
|
||||
if (this.loaderActive) {
|
||||
this.closeAllTextPages();
|
||||
}
|
||||
}
|
||||
|
||||
setPositionToggle() {
|
||||
this.setPositionActive = !this.setPositionActive;
|
||||
if (this.setPositionActive && this.activePage == 'TextPage' && this.mostRecentBook()) {
|
||||
this.closeAllTextPages();
|
||||
this.setPositionActive = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.setPositionPage.sliderMax = this.mostRecentBook().textLength - 1;
|
||||
this.$refs.setPositionPage.sliderValue = this.mostRecentBook().bookPos;
|
||||
});
|
||||
} else {
|
||||
this.setPositionActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
stopScrolling() {
|
||||
if (this.scrollingActive)
|
||||
this.scrollingToggle();
|
||||
}
|
||||
|
||||
scrollingToggle() {
|
||||
this.scrollingActive = !this.scrollingActive;
|
||||
if (this.activePage == 'TextPage') {
|
||||
const page = this.$refs.page;
|
||||
if (this.scrollingActive) {
|
||||
page.startTextScrolling();
|
||||
} else {
|
||||
page.stopTextScrolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopSearch() {
|
||||
if (this.searchActive)
|
||||
this.searchToggle();
|
||||
}
|
||||
|
||||
startTextSearch(opts) {
|
||||
if (this.activePage == 'TextPage')
|
||||
this.$refs.page.startSearch(opts.needle);
|
||||
}
|
||||
|
||||
stopTextSearch() {
|
||||
if (this.activePage == 'TextPage')
|
||||
this.$refs.page.stopSearch();
|
||||
}
|
||||
|
||||
searchToggle() {
|
||||
this.searchActive = !this.searchActive;
|
||||
const page = this.$refs.page;
|
||||
if (this.searchActive && this.activePage == 'TextPage' && page.parsed) {
|
||||
this.closeAllTextPages();
|
||||
this.searchActive = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.searchPage.init(page.parsed);
|
||||
});
|
||||
} else {
|
||||
this.stopTextSearch();
|
||||
this.searchActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyTextToggle() {
|
||||
this.copyTextActive = !this.copyTextActive;
|
||||
const page = this.$refs.page;
|
||||
if (this.copyTextActive && this.activePage == 'TextPage' && page.parsed) {
|
||||
this.closeAllTextPages();
|
||||
this.copyTextActive = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.copyTextPage.init(this.mostRecentBook().bookPos, page.parsed, this.copyFullText);
|
||||
});
|
||||
} else {
|
||||
this.copyTextActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
historyToggle() {
|
||||
this.historyActive = !this.historyActive;
|
||||
if (this.historyActive) {
|
||||
this.closeAllTextPages();
|
||||
this.historyActive = true;
|
||||
} else {
|
||||
this.historyActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
settingsToggle() {
|
||||
this.settingsActive = !this.settingsActive;
|
||||
if (this.settingsActive) {
|
||||
this.closeAllTextPages();
|
||||
this.settingsActive = true;
|
||||
} else {
|
||||
this.settingsActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
helpToggle() {
|
||||
this.helpActive = !this.helpActive;
|
||||
if (this.helpActive) {
|
||||
this.closeAllTextPages();
|
||||
this.helpActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
donateToggle() {
|
||||
this.helpToggle();
|
||||
if (this.helpActive) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.helpPage.activateDonateHelpPage();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
buttonClick(button) {
|
||||
const activeClass = this.buttonActiveClass(button);
|
||||
if (!activeClass['tool-button-disabled'])
|
||||
switch (button) {
|
||||
case 'loader':
|
||||
this.loaderToggle();
|
||||
break;
|
||||
case 'undoAction':
|
||||
if (this.actionCur > 0) {
|
||||
this.actionCur--;
|
||||
this.bookPosChanged({bookPos: this.actionList[this.actionCur]});
|
||||
}
|
||||
break;
|
||||
case 'redoAction':
|
||||
if (this.actionCur < this.actionList.length - 1) {
|
||||
this.actionCur++;
|
||||
this.bookPosChanged({bookPos: this.actionList[this.actionCur]});
|
||||
}
|
||||
break;
|
||||
case 'fullScreen':
|
||||
this.fullScreenToggle();
|
||||
break;
|
||||
case 'setPosition':
|
||||
this.setPositionToggle();
|
||||
break;
|
||||
case 'scrolling':
|
||||
this.scrollingToggle();
|
||||
break;
|
||||
case 'search':
|
||||
this.searchToggle();
|
||||
break;
|
||||
case 'copyText':
|
||||
this.copyTextToggle();
|
||||
break;
|
||||
case 'history':
|
||||
this.historyToggle();
|
||||
break;
|
||||
case 'refresh':
|
||||
if (this.mostRecentBook()) {
|
||||
this.loadBook({url: this.mostRecentBook().url, force: true});
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
this.settingsToggle();
|
||||
break;
|
||||
}
|
||||
this.$refs[button].$el.blur();
|
||||
}
|
||||
|
||||
buttonActiveClass(button) {
|
||||
const classActive = { 'tool-button-active': true, 'tool-button-active:hover': true };
|
||||
const classDisabled = { 'tool-button-disabled': true, 'tool-button-disabled:hover': true };
|
||||
let classResult = {};
|
||||
|
||||
switch (button) {
|
||||
case 'loader':
|
||||
case 'fullScreen':
|
||||
case 'setPosition':
|
||||
case 'scrolling':
|
||||
case 'search':
|
||||
case 'copyText':
|
||||
case 'history':
|
||||
case 'settings':
|
||||
if (this[`${button}Active`])
|
||||
classResult = classActive;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (button) {
|
||||
case 'undoAction':
|
||||
if (this.actionCur <= 0)
|
||||
classResult = classDisabled;
|
||||
break;
|
||||
case 'redoAction':
|
||||
if (this.actionCur == this.actionList.length - 1)
|
||||
classResult = classDisabled;
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.activePage == 'LoaderPage' || !this.mostRecentBook()) {
|
||||
switch (button) {
|
||||
case 'undoAction':
|
||||
case 'redoAction':
|
||||
case 'setPosition':
|
||||
case 'scrolling':
|
||||
case 'search':
|
||||
case 'copyText':
|
||||
classResult = classDisabled;
|
||||
break;
|
||||
case 'history':
|
||||
case 'refresh':
|
||||
if (!this.mostRecentBook())
|
||||
classResult = classDisabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return classResult;
|
||||
}
|
||||
|
||||
async acivateClickMapPage() {
|
||||
if (this.showClickMapPage && !this.clickMapActive) {
|
||||
this.clickMapActive = true;
|
||||
await this.$refs.clickMapPage.slowDisappear();
|
||||
this.clickMapActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
get activePage() {
|
||||
let result = '';
|
||||
|
||||
if (this.progressActive)
|
||||
result = 'ProgressPage';
|
||||
else if (this.loaderActive)
|
||||
result = 'LoaderPage';
|
||||
else if (this.mostRecentBookReactive)
|
||||
result = 'TextPage';
|
||||
|
||||
if (!result && !this.loading) {
|
||||
this.loaderActive = true;
|
||||
result = 'LoaderPage';
|
||||
}
|
||||
|
||||
if (result != 'TextPage') {
|
||||
this.$root.$emit('set-app-title');
|
||||
}
|
||||
|
||||
if (this.lastActivePage != result && result == 'TextPage') {
|
||||
//акивируем страницу с текстом
|
||||
this.$nextTick(async() => {
|
||||
const last = this.mostRecentBookReactive;
|
||||
const isParsed = bookManager.hasBookParsed(last);
|
||||
if (!isParsed) {
|
||||
this.$root.$emit('set-app-title');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateRoute();
|
||||
const textPage = this.$refs.page;
|
||||
if (textPage.showBook) {
|
||||
textPage.lastBook = last;
|
||||
textPage.bookPos = (last.bookPos !== undefined ? last.bookPos : 0);
|
||||
|
||||
textPage.showBook();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.lastActivePage = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
loadBook(opts) {
|
||||
if (!opts) {
|
||||
this.mostRecentBook();
|
||||
return;
|
||||
}
|
||||
|
||||
// уже просматривается сейчас
|
||||
const lastBook = (this.$refs.page ? this.$refs.page.lastBook : null);
|
||||
if (!opts.force && lastBook && lastBook.url == opts.url && bookManager.hasBookParsed(lastBook)) {
|
||||
this.loaderActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.progressActive = true;
|
||||
this.$nextTick(async() => {
|
||||
const progress = this.$refs.page;
|
||||
|
||||
this.actionList = [];
|
||||
this.actionCur = -1;
|
||||
|
||||
try {
|
||||
progress.show();
|
||||
progress.setState({state: 'parse'});
|
||||
|
||||
// есть ли среди недавних
|
||||
const key = bookManager.keyFromUrl(opts.url);
|
||||
let wasOpened = await bookManager.getRecentBook({key});
|
||||
wasOpened = (wasOpened ? wasOpened : {});
|
||||
const bookPos = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos);
|
||||
const bookPosSeen = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen);
|
||||
const bookPosPercent = wasOpened.bookPosPercent;
|
||||
|
||||
let book = null;
|
||||
|
||||
if (!opts.force) {
|
||||
// пытаемся загрузить и распарсить книгу в менеджере из локального кэша
|
||||
const bookParsed = await bookManager.getBook({url: opts.url}, (prog) => {
|
||||
progress.setState({progress: prog});
|
||||
});
|
||||
|
||||
// если есть в локальном кэше
|
||||
if (bookParsed) {
|
||||
await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, bookManager.metaOnly(bookParsed)));
|
||||
this.mostRecentBook();
|
||||
this.addAction(bookPos);
|
||||
this.loaderActive = false;
|
||||
progress.hide(); this.progressActive = false;
|
||||
this.blinkCachedLoadMessage();
|
||||
|
||||
await this.acivateClickMapPage();
|
||||
return;
|
||||
}
|
||||
|
||||
// иначе идем на сервер
|
||||
// пытаемся загрузить готовый файл с сервера
|
||||
if (wasOpened.path) {
|
||||
try {
|
||||
const resp = await readerApi.loadCachedBook(wasOpened.path, (state) => {
|
||||
progress.setState(state);
|
||||
});
|
||||
book = Object.assign({}, wasOpened, {data: resp.data});
|
||||
} catch (e) {
|
||||
//молчим
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress.setState({totalSteps: 5});
|
||||
|
||||
// не удалось, скачиваем книгу полностью с конвертацией
|
||||
let loadCached = true;
|
||||
if (!book) {
|
||||
book = await readerApi.loadBook(opts.url, (state) => {
|
||||
progress.setState(state);
|
||||
});
|
||||
loadCached = false;
|
||||
}
|
||||
|
||||
// добавляем в bookManager
|
||||
progress.setState({state: 'parse', step: 5});
|
||||
const addedBook = await bookManager.addBook(book, (prog) => {
|
||||
progress.setState({progress: prog});
|
||||
});
|
||||
|
||||
// добавляем в историю
|
||||
await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, bookManager.metaOnly(addedBook)));
|
||||
this.mostRecentBook();
|
||||
this.addAction(bookPos);
|
||||
this.updateRoute(true);
|
||||
|
||||
this.loaderActive = false;
|
||||
progress.hide(); this.progressActive = false;
|
||||
if (loadCached) {
|
||||
this.blinkCachedLoadMessage();
|
||||
} else
|
||||
this.stopBlink = true;
|
||||
|
||||
await this.acivateClickMapPage();
|
||||
} catch (e) {
|
||||
progress.hide(); this.progressActive = false;
|
||||
this.loaderActive = true;
|
||||
this.$alert(e.message, 'Ошибка', {type: 'error'});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadFile(opts) {
|
||||
this.progressActive = true;
|
||||
this.$nextTick(async() => {
|
||||
const progress = this.$refs.page;
|
||||
try {
|
||||
progress.show();
|
||||
progress.setState({state: 'upload'});
|
||||
|
||||
const url = await readerApi.uploadFile(opts.file, (state) => {
|
||||
progress.setState(state);
|
||||
});
|
||||
|
||||
progress.hide(); this.progressActive = false;
|
||||
|
||||
this.loadBook({url});
|
||||
} catch (e) {
|
||||
progress.hide(); this.progressActive = false;
|
||||
this.loaderActive = true;
|
||||
this.$alert(e.message, 'Ошибка', {type: 'error'});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
blinkCachedLoadMessage() {
|
||||
this.blinkCount = 30;
|
||||
if (!this.inBlink) {
|
||||
this.inBlink = true;
|
||||
this.stopBlink = false;
|
||||
this.$nextTick(async() => {
|
||||
let page = this.$refs.page;
|
||||
while (this.blinkCount) {
|
||||
this.showRefreshIcon = !this.showRefreshIcon;
|
||||
if (page.blinkCachedLoadMessage)
|
||||
page.blinkCachedLoadMessage(this.showRefreshIcon);
|
||||
await sleep(500);
|
||||
if (this.stopBlink)
|
||||
break;
|
||||
this.blinkCount--;
|
||||
page = this.$refs.page;
|
||||
}
|
||||
this.showRefreshIcon = true;
|
||||
this.inBlink = false;
|
||||
if (page.blinkCachedLoadMessage)
|
||||
page.blinkCachedLoadMessage('finish');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (this.$root.rootRoute == '/reader') {
|
||||
let handled = false;
|
||||
if (!handled && this.helpActive)
|
||||
handled = this.$refs.helpPage.keyHook(event);
|
||||
|
||||
if (!handled && this.settingsActive)
|
||||
handled = this.$refs.settingsPage.keyHook(event);
|
||||
|
||||
if (!handled && this.historyActive)
|
||||
handled = this.$refs.historyPage.keyHook(event);
|
||||
|
||||
if (!handled && this.setPositionActive)
|
||||
handled = this.$refs.setPositionPage.keyHook(event);
|
||||
|
||||
if (!handled && this.searchActive)
|
||||
handled = this.$refs.searchPage.keyHook(event);
|
||||
|
||||
if (!handled && this.copyTextActive)
|
||||
handled = this.$refs.copyTextPage.keyHook(event);
|
||||
|
||||
if (!handled && this.$refs.page && this.$refs.page.keyHook)
|
||||
handled = this.$refs.page.keyHook(event);
|
||||
|
||||
if (!handled && event.type == 'keydown') {
|
||||
if (event.code == 'Escape')
|
||||
this.loaderToggle();
|
||||
|
||||
if (this.activePage == 'TextPage') {
|
||||
switch (event.code) {
|
||||
case 'KeyH':
|
||||
case 'F1':
|
||||
this.helpToggle();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case 'KeyP':
|
||||
this.setPositionToggle();
|
||||
break;
|
||||
case 'KeyF':
|
||||
if (event.ctrlKey) {
|
||||
this.searchToggle();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
case 'KeyC':
|
||||
if (event.ctrlKey) {
|
||||
this.copyTextToggle();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
case 'KeyZ':
|
||||
this.scrollingToggle();
|
||||
break;
|
||||
case 'KeyX':
|
||||
this.historyToggle();
|
||||
break;
|
||||
case 'KeyS':
|
||||
this.settingsToggle();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-container {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.el-header {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
background-color: #1B695F;
|
||||
color: #000;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-width: 550px;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: #EBE2C9;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
margin: 0 2px 0 2px;
|
||||
padding: 0;
|
||||
color: #3E843E;
|
||||
background-color: #E6EDF4;
|
||||
margin-top: 5px;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
border: 0;
|
||||
box-shadow: 3px 3px 5px black;
|
||||
}
|
||||
|
||||
.tool-button:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.tool-button-active {
|
||||
box-shadow: 0 0 0;
|
||||
color: white;
|
||||
background-color: #8AB45F;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.tool-button-active:hover {
|
||||
color: white;
|
||||
background-color: #81C581;
|
||||
}
|
||||
|
||||
.tool-button-disabled {
|
||||
color: lightgray;
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
.tool-button-disabled:hover {
|
||||
color: lightgray;
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
.space {
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.clear {
|
||||
color: rgba(0,0,0,0);
|
||||
}
|
||||
</style>
|
||||
230
client/components/Reader/SearchPage/SearchPage.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
{{ header }}
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<span v-show="initStep">{{ initPercentage }}%</span>
|
||||
|
||||
<div v-show="!initStep" class="input">
|
||||
<input ref="input" class="el-input__inner"
|
||||
placeholder="что ищем"
|
||||
:value="needle" @input="needle = $event.target.value"/>
|
||||
<div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">{{ foundText }}</div>
|
||||
</div>
|
||||
<el-button-group v-show="!initStep" class="button-group">
|
||||
<el-button @click="showNext"><i class="el-icon-arrow-down"></i></el-button>
|
||||
<el-button @click="showPrev"><i class="el-icon-arrow-up"></i></el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import {sleep} from '../../../share/utils';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
needle: function() {
|
||||
this.find();
|
||||
|
||||
},
|
||||
foundText: function(newValue) {
|
||||
this.$refs.input.style.paddingRight = (10 + newValue.length*12) + 'px';
|
||||
},
|
||||
},
|
||||
})
|
||||
class SearchPage extends Vue {
|
||||
header = null;
|
||||
initStep = null;
|
||||
initPercentage = 0;
|
||||
needle = null;
|
||||
foundList = [];
|
||||
foundCur = -1;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.reader = this.$store.state.reader;
|
||||
}
|
||||
|
||||
async init(parsed) {
|
||||
if (this.parsed != parsed) {
|
||||
this.initStep = true;
|
||||
this.stopInit = false;
|
||||
this.header = 'Подготовка';
|
||||
await this.$nextTick();
|
||||
await sleep(10);
|
||||
|
||||
let nextPerc = 0;
|
||||
let text = '';
|
||||
for (let i = 0; i < parsed.para.length; i++) {
|
||||
const p = parsed.para[i];
|
||||
const parts = parsed.splitToStyle(p.text);
|
||||
if (this.stopInit)
|
||||
return;
|
||||
|
||||
for (const part of parts)
|
||||
text += part.text;
|
||||
|
||||
const perc = Math.round(i/parsed.para.length*100);
|
||||
|
||||
if (perc > nextPerc) {
|
||||
this.initPercentage = perc;
|
||||
await sleep(1);
|
||||
nextPerc = perc + 10;
|
||||
}
|
||||
}
|
||||
this.text = text.toLowerCase();
|
||||
this.initStep = false;
|
||||
this.needle = '';
|
||||
this.foundList = [];
|
||||
this.foundCur = -1;
|
||||
this.parsed = parsed;
|
||||
}
|
||||
|
||||
this.header = 'Найти';
|
||||
await this.$nextTick();
|
||||
this.$refs.input.focus();
|
||||
this.$refs.input.select();
|
||||
}
|
||||
|
||||
get foundText() {
|
||||
if (this.foundList.length && this.foundCur >= 0)
|
||||
return `${this.foundCur + 1}/${this.foundList.length}`;
|
||||
else
|
||||
return '';
|
||||
}
|
||||
|
||||
find() {
|
||||
let foundList = [];
|
||||
if (this.needle) {
|
||||
const needle = this.needle.toLowerCase();
|
||||
let i = 0;
|
||||
while (i < this.text.length) {
|
||||
const found = this.text.indexOf(needle, i);
|
||||
if (found >= 0)
|
||||
foundList.push(found);
|
||||
i = (found >= 0 ? found + 1 : this.text.length);
|
||||
}
|
||||
}
|
||||
this.foundList = foundList;
|
||||
this.foundCur = -1;
|
||||
this.showNext();
|
||||
}
|
||||
|
||||
showNext() {
|
||||
const next = this.foundCur + 1;
|
||||
if (next < this.foundList.length)
|
||||
this.foundCur = next;
|
||||
else
|
||||
this.foundCur = (this.foundList.length ? 0 : -1);
|
||||
|
||||
if (this.foundCur >= 0) {
|
||||
this.$emit('start-text-search', {needle: this.needle.toLowerCase()});
|
||||
this.$emit('book-pos-changed', {bookPos: this.foundList[this.foundCur]});
|
||||
} else {
|
||||
this.$emit('stop-text-search');
|
||||
}
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
|
||||
showPrev() {
|
||||
const prev = this.foundCur - 1;
|
||||
if (prev >= 0)
|
||||
this.foundCur = prev;
|
||||
else
|
||||
this.foundCur = this.foundList.length - 1;
|
||||
|
||||
if (this.foundCur >= 0) {
|
||||
this.$emit('start-text-search', {needle: this.needle.toLowerCase()});
|
||||
this.$emit('book-pos-changed', {bookPos: this.foundList[this.foundCur]});
|
||||
} else {
|
||||
this.$emit('stop-text-search');
|
||||
}
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stopInit = true;
|
||||
this.$emit('search-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
//недостатки сторонних ui
|
||||
if (document.activeElement === this.$refs.input && event.type == 'keydown' && event.key == 'Enter') {
|
||||
this.showNext();
|
||||
}
|
||||
|
||||
if (event.type == 'keydown' && (event.code == 'Escape')) {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 125px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
top: -50px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: 150px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
padding: 9px 17px 9px 17px;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
96
client/components/Reader/SetPositionPage/SetPositionPage.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Установить позицию
|
||||
</template>
|
||||
|
||||
<div class="slider">
|
||||
<el-slider v-model="sliderValue" :max="sliderMax" :format-tooltip="formatTooltip"></el-slider>
|
||||
</div>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
sliderValue: function(newValue) {
|
||||
this.$emit('book-pos-changed', {bookPos: newValue});
|
||||
},
|
||||
},
|
||||
})
|
||||
class SetPositionPage extends Vue {
|
||||
sliderValue = null;
|
||||
sliderMax = null;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.reader = this.$store.state.reader;
|
||||
}
|
||||
|
||||
formatTooltip(val) {
|
||||
if (this.sliderMax)
|
||||
return (val/this.sliderMax*100).toFixed(2) + '%';
|
||||
else
|
||||
return 0;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('set-position-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && (event.code == 'Escape' || event.code == 'KeyP')) {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
top: -50px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
margin: 20px;
|
||||
background-color: #efefef;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.el-slider {
|
||||
margin-right: 20px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
455
client/components/Reader/SettingsPage/SettingsPage.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Настройки
|
||||
</template>
|
||||
|
||||
<el-tabs type="border-card" tab-position="left" v-model="selectedTab">
|
||||
<!--------------------------------------------------------------------------->
|
||||
<el-tab-pane label="Вид">
|
||||
|
||||
<el-form :model="form" size="small" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Цвет</div>
|
||||
|
||||
<el-form-item label="Текст">
|
||||
<el-col :span="12">
|
||||
<el-color-picker v-model="textColor" color-format="hex" :predefine="predefineTextColors"></el-color-picker>
|
||||
<span class="color-picked"><b>{{ textColor }}</b></span>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<span style="position: relative; top: 20px;">Обои:</span>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Фон">
|
||||
<el-col :span="12">
|
||||
<el-color-picker v-model="backgroundColor" color-format="hex" :predefine="predefineBackgroundColors" :disabled="wallpaper != ''"></el-color-picker>
|
||||
<span v-show="wallpaper == ''" class="color-picked"><b>{{ backgroundColor }}</b></span>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="11">
|
||||
<el-select v-model="wallpaper">
|
||||
<el-option label="Нет" value=""></el-option>
|
||||
<el-option label="1" value="paper1"></el-option>
|
||||
<el-option label="2" value="paper2"></el-option>
|
||||
<el-option label="3" value="paper3"></el-option>
|
||||
<el-option label="4" value="paper4"></el-option>
|
||||
<el-option label="5" value="paper5"></el-option>
|
||||
<el-option label="6" value="paper6"></el-option>
|
||||
<el-option label="7" value="paper7"></el-option>
|
||||
<el-option label="8" value="paper8"></el-option>
|
||||
<el-option label="9" value="paper9"></el-option>
|
||||
</el-select>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Шрифт</div>
|
||||
|
||||
<el-form-item label="Локальный/веб">
|
||||
<el-col :span="11">
|
||||
<el-select v-model="fontName" placeholder="Шрифт" :disabled="webFontName != ''">
|
||||
<el-option v-for="item in fonts"
|
||||
:key="item.name"
|
||||
:label="item.label"
|
||||
:value="item.name">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light" placement="top">
|
||||
<template slot="content">
|
||||
Веб шрифты дают большое разнообразие,<br>
|
||||
однако есть шанс, что шрифт будет загружаться<br>
|
||||
очень медленно или вовсе не загрузится
|
||||
</template>
|
||||
<el-select v-model="webFontName">
|
||||
<el-option label="Нет" value=""></el-option>
|
||||
<el-option v-for="item in webFonts"
|
||||
:key="item.name"
|
||||
:value="item.name">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="Размер">
|
||||
<el-col :span="17">
|
||||
<el-input-number v-model="fontSize" :min="5" :max="100"></el-input-number>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
<a href="https://fonts.google.com/?subset=cyrillic" target="_blank">Примеры</a>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="Сдвиг">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Сдвиг шрифта по вертикали в процентах от размера.<br>
|
||||
Отрицательное значение сдвигает вверх, положительное -<br>
|
||||
вниз. Значение зависит от метрики шрифта.
|
||||
</template>
|
||||
<el-input-number v-model="vertShift" :min="-100" :max="100"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Стиль">
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="fontBold">Жирный</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="fontItalic">Курсив</el-checkbox>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Текст</div>
|
||||
|
||||
<el-form-item label="Интервал">
|
||||
<el-input-number v-model="lineInterval" :min="0" :max="100"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="Параграф">
|
||||
<el-input-number v-model="p" :min="0" :max="200"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="Отступ">
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Слева/справа
|
||||
</template>
|
||||
<el-input-number v-model="indentLR" :min="0" :max="200"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Сверху/снизу
|
||||
</template>
|
||||
<el-input-number v-model="indentTB" :min="0" :max="200"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="Сдвиг">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Сдвиг текста по вертикали в процентах от размера шрифта.<br>
|
||||
Отрицательное значение сдвигает вверх, положительное -<br>
|
||||
вниз.
|
||||
</template>
|
||||
<el-input-number v-model="textVertShift" :min="-100" :max="100"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="Скроллинг">
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Замедление скроллинга в миллисекундах.<br>
|
||||
Определяет время, за которое текст<br>
|
||||
прокручивается на одну строку.
|
||||
</template>
|
||||
<el-input-number v-model="scrollingDelay" :min="1" :max="10000"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light" placement="top">
|
||||
<template slot="content">
|
||||
Вид скроллинга: линейный,<br>
|
||||
ускорение-замедление и пр.
|
||||
</template>
|
||||
|
||||
<el-select v-model="scrollingType">
|
||||
<el-option value="linear"></el-option>
|
||||
<el-option value="ease"></el-option>
|
||||
<el-option value="ease-in"></el-option>
|
||||
<el-option value="ease-out"></el-option>
|
||||
<el-option value="ease-in-out"></el-option>
|
||||
</el-select>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
|
||||
</el-form-item>
|
||||
<el-form-item label="Выравнивание">
|
||||
<el-checkbox v-model="textAlignJustify">По ширине</el-checkbox>
|
||||
<el-checkbox v-model="wordWrap">Перенос по слогам</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Строка статуса</div>
|
||||
|
||||
<el-form-item label="Статус">
|
||||
<el-checkbox v-model="showStatusBar">Показывать</el-checkbox>
|
||||
<el-checkbox v-model="statusBarTop" :disabled="!showStatusBar">Вверху/внизу</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="Высота">
|
||||
<el-input-number v-model="statusBarHeight" :min="5" :max="50" :disabled="!showStatusBar"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="Прозрачность">
|
||||
<el-input-number v-model="statusBarColorAlpha" :min="0" :max="1" :precision="2" :step="0.1" :disabled="!showStatusBar"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!--------------------------------------------------------------------------->
|
||||
<el-tab-pane label="Листание">
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Анимация</div>
|
||||
|
||||
<el-form-item label="Вид">
|
||||
не готово
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Скорость">
|
||||
не готово
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Другое</div>
|
||||
|
||||
<el-form-item label="Страница">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Переносить последнюю строку страницы<br>
|
||||
в начало следующей при листании
|
||||
</template>
|
||||
<el-checkbox v-model="keepLastToFirst">Переносить последнюю строку</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
</el-tab-pane>
|
||||
<!--------------------------------------------------------------------------->
|
||||
<el-tab-pane label="Прочее">
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<el-form-item label="Подсказка">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Показывать или нет подсказку при каждой загрузке книги
|
||||
</template>
|
||||
<el-checkbox v-model="showClickMapPage">Показывать области управления кликом</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="URL">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Добавление параметра "__p" в строке браузера<br>
|
||||
позволяет передавать ссылку на книгу в читалке<br>
|
||||
без потери текущей позиции. Однако в этом случае<br>
|
||||
при листании забивается история браузера, т.к. на<br>
|
||||
каждое изменение позиции происходит смена URL.
|
||||
</template>
|
||||
<el-checkbox v-model="allowUrlParamBookPos">Добавлять параметр "__p"</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="Парсинг">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Включение этой опции позволяет делать предварительную<br>
|
||||
обработку текста в ленивом режиме сразу после загрузки<br>
|
||||
книги. Это может повысить отзывчивость читалки, но<br>
|
||||
нагружает процессор каждый раз при открытии книги.
|
||||
</template>
|
||||
<el-checkbox v-model="lazyParseEnabled">Предварительная обработка текста</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="Копирование">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Загружать весь текст в окно<br>
|
||||
копирования текста со страницы
|
||||
</template>
|
||||
<el-checkbox v-model="copyFullText">Загружать весь текст</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import rstore from '../../../store/modules/reader';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
data: function() {
|
||||
return Object.assign({}, rstore.settingDefaults);
|
||||
},
|
||||
watch: {
|
||||
form: function(newValue) {
|
||||
this.commit('reader/setSettings', newValue);
|
||||
},
|
||||
fontBold: function(newValue) {
|
||||
this.fontWeight = (newValue ? 'bold' : '');
|
||||
},
|
||||
fontItalic: function(newValue) {
|
||||
this.fontStyle = (newValue ? 'italic' : '');
|
||||
},
|
||||
vertShift: function(newValue) {
|
||||
const font = (this.webFontName ? this.webFontName : this.fontName);
|
||||
this.fontShifts = Object.assign({}, this.fontShifts, {[font]: newValue});
|
||||
this.fontVertShift = newValue;
|
||||
},
|
||||
fontName: function(newValue) {
|
||||
const font = (this.webFontName ? this.webFontName : newValue);
|
||||
this.vertShift = this.fontShifts[font] || 0;
|
||||
},
|
||||
webFontName: function(newValue) {
|
||||
const font = (newValue ? newValue : this.fontName);
|
||||
this.vertShift = this.fontShifts[font] || 0;
|
||||
},
|
||||
},
|
||||
})
|
||||
class SettingsPage extends Vue {
|
||||
selectedTab = null;
|
||||
form = {};
|
||||
fontBold = false;
|
||||
fontItalic = false;
|
||||
vertShift = 0;
|
||||
|
||||
webFonts = [];
|
||||
fonts = [];
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.reader = this.$store.state.reader;
|
||||
|
||||
this.form = Object.assign({}, this.settings);
|
||||
for (let prop in rstore.settingDefaults) {
|
||||
this[prop] = this.form[prop];
|
||||
this.$watch(prop, (newValue) => {
|
||||
this.form = Object.assign({}, this.form, {[prop]: newValue});
|
||||
});
|
||||
}
|
||||
this.fontBold = (this.fontWeight == 'bold');
|
||||
this.fontItalic = (this.fontStyle == 'italic');
|
||||
|
||||
this.fonts = rstore.fonts;
|
||||
this.webFonts = rstore.webFonts;
|
||||
const font = (this.webFontName ? this.webFontName : this.fontName);
|
||||
this.vertShift = this.fontShifts[font] || 0;
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.$store.state.reader.settings;
|
||||
}
|
||||
|
||||
get predefineTextColors() {
|
||||
return [
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#202020',
|
||||
'#323232',
|
||||
'#aaaaaa',
|
||||
'#00c0c0',
|
||||
];
|
||||
}
|
||||
|
||||
get predefineBackgroundColors() {
|
||||
return [
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#202020',
|
||||
'#ebe2c9',
|
||||
'#909080',
|
||||
'#808080',
|
||||
'#c8c8c8',
|
||||
'#478355',
|
||||
'#a6caf0',
|
||||
];
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('settings-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && event.code == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
height: 70%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.el-form {
|
||||
border-top: 2px solid #bbbbbb;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.color-picked {
|
||||
margin-left: 10px;
|
||||
position: relative;
|
||||
top: -11px;
|
||||
}
|
||||
|
||||
.partHeader {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.el-tab-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 420px;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
</style>
|
||||
98
client/components/Reader/TextPage/DrawHelper.js
Normal file
@@ -0,0 +1,98 @@
|
||||
export default class DrawHelper {
|
||||
fontBySize(size) {
|
||||
return `${size}px ${this.fontName}`;
|
||||
}
|
||||
|
||||
drawPercentBar(x, y, w, h, font, fontSize, bookPos, textLength) {
|
||||
const pad = 3;
|
||||
const fh = h - 2*pad;
|
||||
const fh2 = fh/2;
|
||||
|
||||
const t1 = `${Math.floor((bookPos + 1)/1000)}k/${Math.floor(textLength/1000)}k`;
|
||||
const w1 = this.measureTextFont(t1, font) + fh2;
|
||||
const read = (bookPos + 1)/textLength;
|
||||
const t2 = `${(read*100).toFixed(2)}%`;
|
||||
const w2 = this.measureTextFont(t2, font);
|
||||
let w3 = w - w1 - w2;
|
||||
|
||||
let out = '';
|
||||
if (w1 + w2 <= w)
|
||||
out += this.fillTextShift(t1, x, y, font, fontSize);
|
||||
|
||||
if (w1 + w2 + w3 <= w && w3 > (10 + fh2)) {
|
||||
const barWidth = w - w1 - w2 - fh2;
|
||||
out += this.strokeRect(x + w1, y + pad, barWidth, fh - 2, this.statusBarColor);
|
||||
out += this.fillRect(x + w1 + 2, y + pad + 2, (barWidth - 4)*read, fh - 6, this.statusBarColor);
|
||||
}
|
||||
|
||||
if (w1 <= w)
|
||||
out += this.fillTextShift(t2, x + w1 + w3, y, font, fontSize);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
drawStatusBar(statusBarTop, statusBarHeight, bookPos, textLength, title) {
|
||||
|
||||
let out = `<div class="layout" style="` +
|
||||
`width: ${this.realWidth}px; height: ${statusBarHeight}px; ` +
|
||||
`color: ${this.statusBarColor}">`;
|
||||
|
||||
const fontSize = statusBarHeight*0.75;
|
||||
const font = 'bold ' + this.fontBySize(fontSize);
|
||||
|
||||
out += this.fillRect(0, (statusBarTop ? statusBarHeight : 0), this.realWidth, 1, this.statusBarColor);
|
||||
|
||||
const date = new Date();
|
||||
const time = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
const timeW = this.measureTextFont(time, font);
|
||||
out += this.fillTextShift(time, this.realWidth - timeW - fontSize, 2, font, fontSize);
|
||||
|
||||
out += this.fillTextShift(this.fittingString(title, this.realWidth/2 - fontSize - 3, font), fontSize, 2, font, fontSize);
|
||||
|
||||
out += this.drawPercentBar(this.realWidth/2, 2, this.realWidth/2 - timeW - 2*fontSize, statusBarHeight, font, fontSize, bookPos, textLength);
|
||||
|
||||
out += '</div>';
|
||||
return out;
|
||||
}
|
||||
|
||||
statusBarClickable(statusBarTop, statusBarHeight) {
|
||||
return `<div class="layout" style="position: absolute; ` +
|
||||
`left: 0px; top: ${statusBarTop ? 1 : this.realHeight - statusBarHeight + 1}px; ` +
|
||||
`width: ${this.realWidth/2}px; height: ${statusBarHeight}px; cursor: pointer"></div>`;
|
||||
}
|
||||
|
||||
fittingString(str, maxWidth, font) {
|
||||
let w = this.measureTextFont(str, font);
|
||||
const ellipsis = '…';
|
||||
const ellipsisWidth = this.measureTextFont(ellipsis, font);
|
||||
if (w <= maxWidth || w <= ellipsisWidth) {
|
||||
return str;
|
||||
} else {
|
||||
let len = str.length;
|
||||
while (w >= maxWidth - ellipsisWidth && len-- > 0) {
|
||||
str = str.substring(0, len);
|
||||
w = this.measureTextFont(str, font);
|
||||
}
|
||||
return str + ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
fillTextShift(text, x, y, font, size, css) {
|
||||
return this.fillText(text, x, y + size*this.fontShift, font, css);
|
||||
}
|
||||
|
||||
fillText(text, x, y, font, css) {
|
||||
css = (css ? css : '');
|
||||
return `<div style="position: absolute; white-space: pre; left: ${x}px; top: ${y}px; font: ${font}; ${css}">${text}</div>`;
|
||||
}
|
||||
|
||||
fillRect(x, y, w, h, color) {
|
||||
return `<div style="position: absolute; left: ${x}px; top: ${y}px; ` +
|
||||
`width: ${w}px; height: ${h}px; background-color: ${color}"></div>`;
|
||||
}
|
||||
|
||||
strokeRect(x, y, w, h, color) {
|
||||
return `<div style="position: absolute; left: ${x}px; top: ${y}px; ` +
|
||||
`width: ${w}px; height: ${h}px; box-sizing: border-box; border: 1px solid ${color}"></div>`;
|
||||
}
|
||||
}
|
||||
1165
client/components/Reader/TextPage/TextPage.vue
Normal file
BIN
client/components/Reader/TextPage/images/paper1.jpg
Normal file
|
After Width: | Height: | Size: 430 KiB |
BIN
client/components/Reader/TextPage/images/paper2.jpg
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
client/components/Reader/TextPage/images/paper3.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
client/components/Reader/TextPage/images/paper4.jpg
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
client/components/Reader/TextPage/images/paper5.jpg
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
client/components/Reader/TextPage/images/paper6.jpg
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
client/components/Reader/TextPage/images/paper7.jpg
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
client/components/Reader/TextPage/images/paper8.jpg
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
client/components/Reader/TextPage/images/paper9.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
636
client/components/Reader/share/BookParser.js
Normal file
@@ -0,0 +1,636 @@
|
||||
import he from 'he';
|
||||
import sax from '../../../../server/core/BookConverter/sax';
|
||||
import {sleep} from '../../../share/utils';
|
||||
|
||||
export default class BookParser {
|
||||
constructor() {
|
||||
// defaults
|
||||
this.p = 30;// px, отступ параграфа
|
||||
this.w = 300;// px, ширина страницы
|
||||
this.wordWrap = false;// перенос по слогам
|
||||
|
||||
//заглушка
|
||||
this.measureText = (text, style) => {// eslint-disable-line no-unused-vars
|
||||
return text.length*20;
|
||||
};
|
||||
}
|
||||
|
||||
async parse(data, callback) {
|
||||
if (!callback)
|
||||
callback = () => {};
|
||||
callback(0);
|
||||
|
||||
if (data.indexOf('<FictionBook') < 0) {
|
||||
throw new Error('Неверный формат файла');
|
||||
}
|
||||
|
||||
//defaults
|
||||
let fb2 = {
|
||||
firstName: '',
|
||||
middleName: '',
|
||||
lastName: '',
|
||||
bookTitle: '',
|
||||
};
|
||||
|
||||
let path = '';
|
||||
let tag = '';
|
||||
let center = false;
|
||||
let bold = false;
|
||||
let italic = false;
|
||||
|
||||
let paraIndex = -1;
|
||||
let paraOffset = 0;
|
||||
let para = []; /*array of
|
||||
{
|
||||
index: Number,
|
||||
offset: Number, //сумма всех length до этого параграфа
|
||||
length: Number, //длина text без тегов
|
||||
text: String //текст параграфа (или title или epigraph и т.д) с вложенными тегами
|
||||
}
|
||||
*/
|
||||
const newParagraph = (text, len) => {
|
||||
paraIndex++;
|
||||
let p = {
|
||||
index: paraIndex,
|
||||
offset: paraOffset,
|
||||
length: len,
|
||||
text: text,
|
||||
};
|
||||
|
||||
para[paraIndex] = p;
|
||||
paraOffset += p.length;
|
||||
};
|
||||
|
||||
const growParagraph = (text, len) => {
|
||||
if (paraIndex < 0) {
|
||||
newParagraph(text, len);
|
||||
return;
|
||||
}
|
||||
|
||||
let p = para[paraIndex];
|
||||
if (p) {
|
||||
paraOffset -= p.length;
|
||||
if (p.length == 1 && p.text[0] == ' ' && len > 0) {
|
||||
p.length = 0;
|
||||
p.text = p.text.substr(1);
|
||||
}
|
||||
p.length += len;
|
||||
p.text += text;
|
||||
} else {
|
||||
p = {
|
||||
index: paraIndex,
|
||||
offset: paraOffset,
|
||||
length: len,
|
||||
text: text
|
||||
};
|
||||
}
|
||||
|
||||
para[paraIndex] = p;
|
||||
paraOffset += p.length;
|
||||
};
|
||||
|
||||
const onStartNode = (elemName) => {// eslint-disable-line no-unused-vars
|
||||
if (elemName == '?xml')
|
||||
return;
|
||||
|
||||
tag = elemName;
|
||||
path += '/' + elemName;
|
||||
|
||||
if ((tag == 'p' || tag == 'empty-line' || tag == 'v') && path.indexOf('/fictionbook/body/section') == 0) {
|
||||
newParagraph(' ', 1);
|
||||
}
|
||||
|
||||
if (tag == 'emphasis' || tag == 'strong') {
|
||||
growParagraph(`<${tag}>`, 0);
|
||||
}
|
||||
|
||||
if (tag == 'title') {
|
||||
newParagraph(' ', 1);
|
||||
bold = true;
|
||||
center = true;
|
||||
}
|
||||
|
||||
if (tag == 'subtitle') {
|
||||
newParagraph(' ', 1);
|
||||
bold = true;
|
||||
}
|
||||
|
||||
if (tag == 'epigraph') {
|
||||
italic = true;
|
||||
}
|
||||
|
||||
if (tag == 'stanza') {
|
||||
newParagraph(' ', 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onEndNode = (elemName) => {// eslint-disable-line no-unused-vars
|
||||
if (tag == elemName) {
|
||||
if (tag == 'emphasis' || tag == 'strong') {
|
||||
growParagraph(`</${tag}>`, 0);
|
||||
}
|
||||
|
||||
if (tag == 'title') {
|
||||
bold = false;
|
||||
center = false;
|
||||
}
|
||||
|
||||
if (tag == 'subtitle') {
|
||||
bold = false;
|
||||
}
|
||||
|
||||
if (tag == 'epigraph') {
|
||||
italic = false;
|
||||
}
|
||||
|
||||
if (tag == 'stanza') {
|
||||
newParagraph(' ', 1);
|
||||
}
|
||||
|
||||
path = path.substr(0, path.length - tag.length - 1);
|
||||
let i = path.lastIndexOf('/');
|
||||
if (i >= 0) {
|
||||
tag = path.substr(i + 1);
|
||||
} else {
|
||||
tag = path;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTextNode = (text) => {// eslint-disable-line no-unused-vars
|
||||
text = he.decode(text);
|
||||
text = text.replace(/>/g, '>');
|
||||
text = text.replace(/</g, '<');
|
||||
|
||||
if (text != ' ' && text.trim() == '')
|
||||
text = text.trim();
|
||||
|
||||
if (text == '')
|
||||
return;
|
||||
|
||||
text = text.replace(/[\t\n\r]/g, ' ');
|
||||
|
||||
switch (path) {
|
||||
case '/fictionbook/description/title-info/author/first-name':
|
||||
fb2.firstName = text;
|
||||
break;
|
||||
case '/fictionbook/description/title-info/author/middle-name':
|
||||
fb2.middleName = text;
|
||||
break;
|
||||
case '/fictionbook/description/title-info/author/last-name':
|
||||
fb2.lastName = text;
|
||||
break;
|
||||
case '/fictionbook/description/title-info/genre':
|
||||
fb2.genre = text;
|
||||
break;
|
||||
case '/fictionbook/description/title-info/date':
|
||||
fb2.date = text;
|
||||
break;
|
||||
case '/fictionbook/description/title-info/book-title':
|
||||
fb2.bookTitle = text;
|
||||
break;
|
||||
case '/fictionbook/description/title-info/id':
|
||||
fb2.id = text;
|
||||
break;
|
||||
}
|
||||
|
||||
if (path.indexOf('/fictionbook/description/title-info/annotation') == 0) {
|
||||
if (!fb2.annotation)
|
||||
fb2.annotation = '';
|
||||
if (tag != 'annotation')
|
||||
fb2.annotation += `<${tag}>${text}</${tag}>`;
|
||||
else
|
||||
fb2.annotation += text;
|
||||
}
|
||||
|
||||
let tOpen = (center ? '<center>' : '');
|
||||
tOpen += (bold ? '<strong>' : '');
|
||||
tOpen += (italic ? '<emphasis>' : '');
|
||||
let tClose = (center ? '</center>' : '');
|
||||
tClose += (bold ? '</strong>' : '');
|
||||
tClose += (italic ? '</emphasis>' : '');
|
||||
|
||||
if (path.indexOf('/fictionbook/body/title') == 0) {
|
||||
newParagraph(`${tOpen}${text}${tClose}`, text.length, true);
|
||||
}
|
||||
|
||||
if (path.indexOf('/fictionbook/body/section') == 0) {
|
||||
switch (tag) {
|
||||
case 'p':
|
||||
growParagraph(`${tOpen}${text}${tClose}`, text.length);
|
||||
break;
|
||||
default:
|
||||
growParagraph(`${tOpen}${text}${tClose}`, text.length);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onProgress = async(prog) => {
|
||||
await sleep(1);
|
||||
callback(prog);
|
||||
};
|
||||
|
||||
await sax.parse(data, {
|
||||
onStartNode, onEndNode, onTextNode, onProgress
|
||||
});
|
||||
|
||||
this.fb2 = fb2;
|
||||
this.para = para;
|
||||
|
||||
this.textLength = paraOffset;
|
||||
|
||||
callback(100);
|
||||
await sleep(10);
|
||||
|
||||
return {fb2};
|
||||
}
|
||||
|
||||
findParaIndex(bookPos) {
|
||||
let result = undefined;
|
||||
//дихотомия
|
||||
let first = 0;
|
||||
let last = this.para.length - 1;
|
||||
while (first < last) {
|
||||
let mid = first + Math.floor((last - first)/2);
|
||||
if (bookPos <= this.para[mid].offset + this.para[mid].length - 1)
|
||||
last = mid;
|
||||
else
|
||||
first = mid + 1;
|
||||
}
|
||||
|
||||
if (last >= 0) {
|
||||
const ofs = this.para[last].offset;
|
||||
if (bookPos >= ofs && bookPos < ofs + this.para[last].length)
|
||||
result = last;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
splitToStyle(s) {
|
||||
let result = [];/*array of {
|
||||
style: {bold: Boolean, italic: Boolean, center: Boolean},
|
||||
text: String,
|
||||
}*/
|
||||
let style = {};
|
||||
|
||||
const onTextNode = async(text) => {// eslint-disable-line no-unused-vars
|
||||
result.push({
|
||||
style: Object.assign({}, style),
|
||||
text: text
|
||||
});
|
||||
};
|
||||
|
||||
const onStartNode = async(elemName) => {// eslint-disable-line no-unused-vars
|
||||
switch (elemName) {
|
||||
case 'strong':
|
||||
style.bold = true;
|
||||
break;
|
||||
case 'emphasis':
|
||||
style.italic = true;
|
||||
break;
|
||||
case 'center':
|
||||
style.center = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onEndNode = async(elemName) => {// eslint-disable-line no-unused-vars
|
||||
switch (elemName) {
|
||||
case 'strong':
|
||||
style.bold = false;
|
||||
break;
|
||||
case 'emphasis':
|
||||
style.italic = false;
|
||||
break;
|
||||
case 'center':
|
||||
style.center = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
sax.parseSync(s, {
|
||||
onStartNode, onEndNode, onTextNode
|
||||
});
|
||||
|
||||
|
||||
//длинные слова (или белиберду без пробелов) тоже разобьем
|
||||
const maxWordLength = this.maxWordLength;
|
||||
const parts = result;
|
||||
result = [];
|
||||
for (const part of parts) {
|
||||
let p = part;
|
||||
let i = 0;
|
||||
let spaceIndex = -1;
|
||||
while (i < p.text.length) {
|
||||
if (p.text[i] == ' ')
|
||||
spaceIndex = i;
|
||||
|
||||
if (i - spaceIndex >= maxWordLength && i < p.text.length - 1 &&
|
||||
this.measureText(p.text.substr(spaceIndex + 1, i - spaceIndex), p.style) >= this.w - this.p) {
|
||||
result.push({style: p.style, text: p.text.substr(0, i + 1)});
|
||||
p = {style: p.style, text: p.text.substr(i + 1)};
|
||||
spaceIndex = -1;
|
||||
i = -1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
result.push(p);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
splitToSlogi(word) {
|
||||
let result = [];
|
||||
|
||||
const glas = new Set(['а', 'А', 'о', 'О', 'и', 'И', 'е', 'Е', 'ё', 'Ё', 'э', 'Э', 'ы', 'Ы', 'у', 'У', 'ю', 'Ю', 'я', 'Я']);
|
||||
const soglas = new Set([
|
||||
'б', 'в', 'г', 'д', 'ж', 'з', 'й', 'к', 'л', 'м', 'н', 'п', 'р', 'с', 'т', 'ф', 'х', 'ц', 'ч', 'ш', 'щ',
|
||||
'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'Й', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', 'Ч', 'Ц', 'Ш', 'Щ'
|
||||
]);
|
||||
const znak = new Set(['ь', 'Ь', 'ъ', 'Ъ', 'й', 'Й']);
|
||||
const alpha = new Set([...glas, ...soglas, ...znak]);
|
||||
|
||||
let slog = '';
|
||||
let slogLen = 0;
|
||||
const len = word.length;
|
||||
word += ' ';
|
||||
for (let i = 0; i < len; i++) {
|
||||
slog += word[i];
|
||||
if (alpha.has(word[i]))
|
||||
slogLen++;
|
||||
|
||||
if (slogLen > 1 && i < len - 2 && (
|
||||
//гласная, а следом не 2 согласные буквы
|
||||
(glas.has(word[i]) && !(soglas.has(word[i + 1]) &&
|
||||
soglas.has(word[i + 2])) && alpha.has(word[i + 1]) && alpha.has(word[i + 2])
|
||||
) ||
|
||||
//предыдущая не согласная буква, текущая согласная, а следом согласная и согласная|гласная буквы
|
||||
(alpha.has(word[i - 1]) && !soglas.has(word[i - 1]) &&
|
||||
soglas.has(word[i]) && soglas.has(word[i + 1]) &&
|
||||
(glas.has(word[i + 2]) || soglas.has(word[i + 2])) &&
|
||||
alpha.has(word[i + 1]) && alpha.has(word[i + 2])
|
||||
) ||
|
||||
//мягкий или твердый знак или Й
|
||||
(znak.has(word[i]) && alpha.has(word[i + 1]) && alpha.has(word[i + 2])) ||
|
||||
(word[i] == '-')
|
||||
) &&
|
||||
//нельзя оставлять окончания на ь, ъ, й
|
||||
!(znak.has(word[i + 2]) && !alpha.has(word[i + 3]))
|
||||
|
||||
) {
|
||||
result.push(slog);
|
||||
slog = '';
|
||||
slogLen = 0;
|
||||
}
|
||||
}
|
||||
if (slog)
|
||||
result.push(slog);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
parsePara(paraIndex) {
|
||||
const para = this.para[paraIndex];
|
||||
|
||||
if (!this.force &&
|
||||
para.parsed &&
|
||||
para.parsed.w === this.w &&
|
||||
para.parsed.p === this.p &&
|
||||
para.parsed.wordWrap === this.wordWrap &&
|
||||
para.parsed.maxWordLength === this.maxWordLength &&
|
||||
para.parsed.font === this.font
|
||||
)
|
||||
return para.parsed;
|
||||
|
||||
const parsed = {
|
||||
w: this.w,
|
||||
p: this.p,
|
||||
wordWrap: this.wordWrap,
|
||||
maxWordLength: this.maxWordLength,
|
||||
font: this.font,
|
||||
};
|
||||
|
||||
|
||||
const lines = []; /* array of
|
||||
{
|
||||
begin: Number,
|
||||
end: Number,
|
||||
first: Boolean,
|
||||
last: Boolean,
|
||||
parts: array of {
|
||||
style: {bold: Boolean, italic: Boolean, center: Boolean},
|
||||
text: String,
|
||||
}
|
||||
}*/
|
||||
let parts = this.splitToStyle(para.text);
|
||||
|
||||
let line = {begin: para.offset, parts: []};
|
||||
let partText = '';//накапливаемый кусок со стилем
|
||||
|
||||
let str = '';//измеряемая строка
|
||||
let prevStr = '';//строка без крайнего слова
|
||||
let prevW = 0;
|
||||
let j = 0;//номер строки
|
||||
let style = {};
|
||||
let ofs = 0;//смещение от начала параграфа para.offset
|
||||
|
||||
// тут начинается самый замес, перенос по слогам и стилизация
|
||||
for (const part of parts) {
|
||||
const words = part.text.split(' ');
|
||||
style = part.style;
|
||||
|
||||
let sp1 = '';
|
||||
let sp2 = '';
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
ofs += word.length + (i < words.length - 1 ? 1 : 0);
|
||||
|
||||
if (word == '' && i > 0 && i < words.length - 1)
|
||||
continue;
|
||||
|
||||
str += sp1 + word;
|
||||
|
||||
let p = (j == 0 ? parsed.p : 0);
|
||||
let w = this.measureText(str, style) + p;
|
||||
let wordTail = word;
|
||||
if (w > parsed.w && prevStr != '') {
|
||||
if (parsed.wordWrap) {//по слогам
|
||||
let slogi = this.splitToSlogi(word);
|
||||
|
||||
if (slogi.length > 1) {
|
||||
let s = prevStr + sp1;
|
||||
let ss = sp1;
|
||||
|
||||
let pw;
|
||||
const slogiLen = slogi.length;
|
||||
for (let k = 0; k < slogiLen - 1; k++) {
|
||||
let slog = slogi[0];
|
||||
let ww = this.measureText(s + slog + (slog[slog.length - 1] == '-' ? '' : '-'), style) + p;
|
||||
if (ww <= parsed.w) {
|
||||
s += slog;
|
||||
ss += slog;
|
||||
} else
|
||||
break;
|
||||
pw = ww;
|
||||
slogi.shift();
|
||||
}
|
||||
|
||||
if (pw) {
|
||||
prevW = pw;
|
||||
partText += ss + (ss[ss.length - 1] == '-' ? '' : '-');
|
||||
wordTail = slogi.join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (partText != '')
|
||||
line.parts.push({style, text: partText});
|
||||
|
||||
if (line.parts.length) {//корявенько, коррекция при переносе, отрефакторить не вышло
|
||||
let t = line.parts[line.parts.length - 1].text;
|
||||
if (t[t.length - 1] == ' ') {
|
||||
line.parts[line.parts.length - 1].text = t.trimRight();
|
||||
prevW -= this.measureText(' ', style);
|
||||
}
|
||||
}
|
||||
|
||||
line.end = para.offset + ofs - wordTail.length - 1 - (i < words.length - 1 ? 1 : 0);
|
||||
if (line.end - line.begin < 0)
|
||||
console.error(`Parse error, empty line in paragraph ${paraIndex}`);
|
||||
|
||||
line.width = prevW;
|
||||
line.first = (j == 0);
|
||||
line.last = false;
|
||||
lines.push(line);
|
||||
|
||||
line = {begin: line.end + 1, parts: []};
|
||||
partText = '';
|
||||
sp2 = '';
|
||||
str = wordTail;
|
||||
j++;
|
||||
}
|
||||
|
||||
prevStr = str;
|
||||
partText += sp2 + wordTail;
|
||||
sp1 = ' ';
|
||||
sp2 = ' ';
|
||||
prevW = w;
|
||||
}
|
||||
|
||||
if (partText != '')
|
||||
line.parts.push({style, text: partText});
|
||||
partText = '';
|
||||
}
|
||||
|
||||
if (line.parts.length) {//корявенько, коррекция при переносе
|
||||
let t = line.parts[line.parts.length - 1].text;
|
||||
if (t[t.length - 1] == ' ') {
|
||||
line.parts[line.parts.length - 1].text = t.trimRight();
|
||||
prevW -= this.measureText(' ', style);
|
||||
}
|
||||
|
||||
line.end = para.offset + para.length - 1;
|
||||
if (line.end - line.begin < 0)
|
||||
console.error(`Parse error, empty line in paragraph ${paraIndex}`);
|
||||
|
||||
line.width = prevW;
|
||||
line.first = (j == 0);
|
||||
line.last = true;
|
||||
lines.push(line);
|
||||
} else {//подстраховка
|
||||
if (lines.length) {
|
||||
line = lines[lines.length - 1];
|
||||
const end = para.offset + para.length - 1;
|
||||
if (line.end != end)
|
||||
console.error(`Parse error, wrong end in paragraph ${paraIndex}`);
|
||||
line.end = end;
|
||||
}
|
||||
}
|
||||
|
||||
parsed.lines = lines;
|
||||
para.parsed = parsed;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
findLineIndex(bookPos, lines) {
|
||||
let result = undefined;
|
||||
|
||||
//дихотомия
|
||||
let first = 0;
|
||||
let last = lines.length - 1;
|
||||
while (first < last) {
|
||||
let mid = first + Math.floor((last - first)/2);
|
||||
if (bookPos <= lines[mid].end)
|
||||
last = mid;
|
||||
else
|
||||
first = mid + 1;
|
||||
}
|
||||
|
||||
if (last >= 0) {
|
||||
if (bookPos >= lines[last].begin && bookPos <= lines[last].end)
|
||||
result = last;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getLines(bookPos, n) {
|
||||
let result = [];
|
||||
let paraIndex = this.findParaIndex(bookPos);
|
||||
|
||||
if (paraIndex === undefined)
|
||||
return result;
|
||||
|
||||
if (n > 0) {
|
||||
let parsed = this.parsePara(paraIndex);
|
||||
let i = this.findLineIndex(bookPos, parsed.lines);
|
||||
if (i === undefined)
|
||||
return result;
|
||||
|
||||
while (n > 0) {
|
||||
result.push(parsed.lines[i]);
|
||||
i++;
|
||||
|
||||
if (i >= parsed.lines.length) {
|
||||
paraIndex++;
|
||||
if (paraIndex < this.para.length)
|
||||
parsed = this.parsePara(paraIndex);
|
||||
else
|
||||
return result;
|
||||
i = 0;
|
||||
}
|
||||
|
||||
n--;
|
||||
}
|
||||
} else if (n < 0) {
|
||||
n = -n;
|
||||
let parsed = this.parsePara(paraIndex);
|
||||
let i = this.findLineIndex(bookPos, parsed.lines);
|
||||
if (i === undefined)
|
||||
return result;
|
||||
|
||||
while (n > 0) {
|
||||
result.push(parsed.lines[i]);
|
||||
i--;
|
||||
|
||||
if (i < 0) {
|
||||
paraIndex--;
|
||||
if (paraIndex >= 0)
|
||||
parsed = this.parsePara(paraIndex);
|
||||
else
|
||||
return result;
|
||||
i = parsed.lines.length - 1;
|
||||
}
|
||||
|
||||
n--;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.length)
|
||||
result = null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
234
client/components/Reader/share/bookManager.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import localForage from 'localforage';
|
||||
|
||||
import * as utils from '../../../share/utils';
|
||||
import BookParser from './BookParser';
|
||||
|
||||
const maxDataSize = 500*1024*1024;//chars, not bytes
|
||||
|
||||
const bmMetaStore = localForage.createInstance({
|
||||
name: 'bmMetaStore'
|
||||
});
|
||||
|
||||
const bmDataStore = localForage.createInstance({
|
||||
name: 'bmDataStore'
|
||||
});
|
||||
|
||||
const bmRecentStore = localForage.createInstance({
|
||||
name: 'bmRecentStore'
|
||||
});
|
||||
|
||||
class BookManager {
|
||||
async init() {
|
||||
this.books = {};
|
||||
this.recent = {};
|
||||
this.recentChanged = true;
|
||||
|
||||
let len = await bmMetaStore.length();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const key = await bmMetaStore.key(i);
|
||||
const keySplit = key.split('-');
|
||||
|
||||
if (keySplit.length == 2 && keySplit[0] == 'bmMeta') {
|
||||
let meta = await bmMetaStore.getItem(key);
|
||||
|
||||
this.books[meta.key] = meta;
|
||||
}
|
||||
}
|
||||
|
||||
len = await bmRecentStore.length();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const key = await bmRecentStore.key(i);
|
||||
let r = await bmRecentStore.getItem(key);
|
||||
this.recent[r.key] = r;
|
||||
}
|
||||
|
||||
await this.cleanBooks();
|
||||
}
|
||||
|
||||
async cleanBooks() {
|
||||
while (1) {// eslint-disable-line no-constant-condition
|
||||
let size = 0;
|
||||
let min = Date.now();
|
||||
let toDel = null;
|
||||
for (let key in this.books) {
|
||||
let book = this.books[key];
|
||||
size += (book.length ? book.length : 0);
|
||||
|
||||
if (book.addTime < min) {
|
||||
toDel = book;
|
||||
min = book.addTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (size > maxDataSize && toDel) {
|
||||
await this.delBook(toDel);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async addBook(newBook, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
let meta = {url: newBook.url, path: newBook.path};
|
||||
meta.key = this.keyFromUrl(meta.url);
|
||||
meta.addTime = Date.now();
|
||||
|
||||
const result = await this.parseBook(meta, newBook.data, callback);
|
||||
|
||||
this.books[meta.key] = result;
|
||||
|
||||
await bmMetaStore.setItem(`bmMeta-${meta.key}`, this.metaOnly(result));
|
||||
await bmDataStore.setItem(`bmData-${meta.key}`, result.data);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
hasBookParsed(meta) {
|
||||
if (!this.books)
|
||||
return false;
|
||||
if (!meta.url)
|
||||
return false;
|
||||
if (!meta.key)
|
||||
meta.key = this.keyFromUrl(meta.url);
|
||||
let book = this.books[meta.key];
|
||||
return !!(book && book.parsed);
|
||||
}
|
||||
|
||||
async getBook(meta, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
let result = undefined;
|
||||
if (!meta.key)
|
||||
meta.key = this.keyFromUrl(meta.url);
|
||||
result = this.books[meta.key];
|
||||
|
||||
if (result && !result.data) {
|
||||
result.data = await bmDataStore.getItem(`bmData-${meta.key}`);
|
||||
this.books[meta.key] = result;
|
||||
}
|
||||
|
||||
if (result && !result.parsed) {
|
||||
result = await this.parseBook(result, result.data, callback);
|
||||
this.books[meta.key] = result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async delBook(meta) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
|
||||
await bmMetaStore.removeItem(`bmMeta-${meta.key}`);
|
||||
await bmDataStore.removeItem(`bmData-${meta.key}`);
|
||||
|
||||
delete this.books[meta.key];
|
||||
}
|
||||
|
||||
async parseBook(meta, data, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
const parsed = new BookParser();
|
||||
|
||||
const parsedMeta = await parsed.parse(data, callback);
|
||||
const result = Object.assign({}, meta, parsedMeta, {
|
||||
length: data.length,
|
||||
textLength: parsed.textLength,
|
||||
data,
|
||||
parsed
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
metaOnly(book) {
|
||||
let result = Object.assign({}, book);
|
||||
delete result.data;
|
||||
delete result.parsed;
|
||||
return result;
|
||||
}
|
||||
|
||||
keyFromUrl(url) {
|
||||
return utils.stringToHex(url);
|
||||
}
|
||||
|
||||
async setRecentBook(value, noTouch) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
const result = Object.assign({}, value);
|
||||
if (!noTouch)
|
||||
Object.assign(result, {touchTime: Date.now()});
|
||||
|
||||
if (result.textLength && !result.bookPos && result.bookPosPercent)
|
||||
result.bookPos = Math.round(result.bookPosPercent*result.textLength);
|
||||
|
||||
this.recent[result.key] = result;
|
||||
|
||||
await bmRecentStore.setItem(result.key, result);
|
||||
await this.cleanRecentBooks();
|
||||
|
||||
this.recentChanged = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
async getRecentBook(value) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
return this.recent[value.key];
|
||||
}
|
||||
|
||||
async delRecentBook(value) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
|
||||
await bmRecentStore.removeItem(value.key);
|
||||
delete this.recent[value.key];
|
||||
this.recentChanged = true;
|
||||
}
|
||||
|
||||
async cleanRecentBooks() {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
|
||||
if (Object.keys(this.recent).length > 100) {
|
||||
let min = Date.now();
|
||||
let found = null;
|
||||
for (let key in this.recent) {
|
||||
const book = this.recent[key];
|
||||
if (book.touchTime < min) {
|
||||
min = book.touchTime;
|
||||
found = book;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
await this.delRecentBook(found);
|
||||
await this.cleanRecentBooks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mostRecentBook() {
|
||||
if (!this.recentChanged && this.mostRecentCached) {
|
||||
return this.mostRecentCached;
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
let result = null;
|
||||
for (let key in this.recent) {
|
||||
const book = this.recent[key];
|
||||
if (book.touchTime > max) {
|
||||
max = book.touchTime;
|
||||
result = book;
|
||||
}
|
||||
}
|
||||
this.mostRecentCached = result;
|
||||
this.recentChanged = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new BookManager();
|
||||
13
client/components/Reader/share/clickMap.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export const clickMap = {
|
||||
33: {30: 'PgUp', 100: 'PgDown'},
|
||||
67: {30: 'Up', 70: 'Menu', 100: 'Down'},
|
||||
100: {30: 'PgUp', 100: 'PgDown'}
|
||||
};
|
||||
|
||||
export const clickMapText = {
|
||||
'PgUp': 'Страницу назад',
|
||||
'PgDown': 'Страницу вперед',
|
||||
'Up': 'Строку назад',
|
||||
'Down': 'Строку вперед',
|
||||
'Menu': 'Показать или скрыть панель',
|
||||
};
|
||||
70
client/components/Reader/share/restoreOldSettings.js
Normal file
@@ -0,0 +1,70 @@
|
||||
export default async function restoreOldSettings(settings, bookManager, commit) {
|
||||
const oldSets = localStorage['colorSetting'];
|
||||
let isOld = false;
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
let key = unescape(localStorage.key(i));
|
||||
if (key.indexOf('bpr-book-') == 0)
|
||||
isOld = true;
|
||||
}
|
||||
|
||||
if (isOld || oldSets) {
|
||||
let newSettings = null;
|
||||
if (oldSets) {
|
||||
const [textColor, backgroundColor, lineStep, , , statusBarHeight, scInt] = unescape(oldSets).split('|');
|
||||
|
||||
const fontSize = Math.round(lineStep*0.8);
|
||||
const scrollingDelay = fontSize*scInt;
|
||||
|
||||
newSettings = Object.assign({}, settings, {
|
||||
textColor,
|
||||
backgroundColor,
|
||||
fontSize,
|
||||
statusBarHeight: statusBarHeight*1,
|
||||
scrollingDelay,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
let key = localStorage.key(i);
|
||||
if (key.indexOf('bpr-') == 0) {
|
||||
let v = unescape(localStorage[key]);
|
||||
key = unescape(key);
|
||||
|
||||
if (key.lastIndexOf('=timestamp') == key.length - 10) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.indexOf('bpr-book-') == 0) {
|
||||
const url = key.substr(9);
|
||||
const [scrollTop, scrollHeight, ] = v.split('|');
|
||||
|
||||
const bookPosPercent = scrollTop*1/(scrollHeight*1 + 1);
|
||||
const title = unescape(localStorage[`bpr-title-${escape(url)}`]);
|
||||
const author = unescape(localStorage[`bpr-author-${escape(url)}`]);
|
||||
const time = unescape(localStorage[`bpr-book-${escape(url)}=timestamp`]).split(';')[0];
|
||||
const touchTime = Date.parse(time);
|
||||
|
||||
const bookKey = bookManager.keyFromUrl(url);
|
||||
const recent = await bookManager.getRecentBook({key: bookKey});
|
||||
|
||||
if (!recent) {
|
||||
await bookManager.setRecentBook({
|
||||
key: bookKey,
|
||||
touchTime,
|
||||
bookPosPercent,
|
||||
url,
|
||||
fb2: {
|
||||
bookTitle: title,
|
||||
lastName: author,
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.clear();
|
||||
if (oldSets)
|
||||
commit('reader/setSettings', newSettings);
|
||||
}
|
||||
}
|
||||
20
client/components/Settings/Settings.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Settings в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Settings extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
20
client/components/Sources/Sources.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Sources в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Sources extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
BIN
client/components/fonts/arimo.woff2
Normal file
BIN
client/components/fonts/avrile.ttf
Normal file
BIN
client/components/fonts/avrile.woff
Normal file
BIN
client/components/fonts/geo_1.ttf
Normal file
BIN
client/components/fonts/geo_1.woff
Normal file
BIN
client/components/fonts/open-sans.ttf
Normal file
BIN
client/components/fonts/open-sans.woff
Normal file
BIN
client/components/fonts/reader-default.ttf
Normal file
BIN
client/components/fonts/reader-default.woff
Normal file
BIN
client/components/fonts/roboto.ttf
Normal file
BIN
client/components/fonts/roboto.woff
Normal file
BIN
client/components/fonts/rubik.woff2
Normal file
61
client/components/share/Window.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="window">
|
||||
<div class="header">
|
||||
<span class="header-text"><slot name="header"></slot></span>
|
||||
<span class="close-button" @click="close"><i class="el-icon-close"></i></span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Window extends Vue {
|
||||
close() {
|
||||
this.$emit('close');
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.window {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 10px;
|
||||
background-color: #ffffff;
|
||||
border: 3px double black;
|
||||
border-radius: 4px;
|
||||
box-shadow: 3px 3px 5px black;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background-color: #e5e7ea;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
122
client/element.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
/*
|
||||
import ElementUI from 'element-ui';
|
||||
import './theme/index.css';
|
||||
import locale from 'element-ui/lib/locale/lang/ru-RU';
|
||||
|
||||
Vue.use(ElementUI, { locale });
|
||||
*/
|
||||
|
||||
//------------------------------------------------------
|
||||
//import './theme/index.css';
|
||||
|
||||
import './theme/icon.css';
|
||||
import './theme/tooltip.css';
|
||||
|
||||
import ElMenu from 'element-ui/lib/menu';
|
||||
import './theme/menu.css';
|
||||
|
||||
import ElMenuItem from 'element-ui/lib/menu-item';
|
||||
import './theme/menu-item.css';
|
||||
|
||||
import ElButton from 'element-ui/lib/button';
|
||||
import './theme/button.css';
|
||||
|
||||
import ElButtonGroup from 'element-ui/lib/button-group';
|
||||
import './theme/button-group.css';
|
||||
|
||||
import ElCheckbox from 'element-ui/lib/checkbox';
|
||||
import './theme/checkbox.css';
|
||||
|
||||
import ElTabs from 'element-ui/lib/tabs';
|
||||
import './theme/tabs.css';
|
||||
|
||||
import ElTabPane from 'element-ui/lib/tab-pane';
|
||||
import './theme/tab-pane.css';
|
||||
|
||||
import ElTooltip from 'element-ui/lib/tooltip';
|
||||
import './theme/tooltip.css';
|
||||
|
||||
import ElCol from 'element-ui/lib/col';
|
||||
import './theme/col.css';
|
||||
|
||||
import ElContainer from 'element-ui/lib/container';
|
||||
import './theme/container.css';
|
||||
|
||||
import ElAside from 'element-ui/lib/aside';
|
||||
import './theme/aside.css';
|
||||
|
||||
import ElHeader from 'element-ui/lib/header';
|
||||
import './theme/header.css';
|
||||
|
||||
import ElMain from 'element-ui/lib/main';
|
||||
import './theme/main.css';
|
||||
|
||||
import ElInput from 'element-ui/lib/input';
|
||||
import './theme/input.css';
|
||||
|
||||
import ElInputNumber from 'element-ui/lib/input-number';
|
||||
import './theme/input-number.css';
|
||||
|
||||
import ElSelect from 'element-ui/lib/select';
|
||||
import './theme/select.css';
|
||||
|
||||
import ElOption from 'element-ui/lib/option';
|
||||
import './theme/option.css';
|
||||
|
||||
import ElTable from 'element-ui/lib/table';
|
||||
import './theme/table.css';
|
||||
|
||||
import ElTableColumn from 'element-ui/lib/table-column';
|
||||
import './theme/table-column.css';
|
||||
|
||||
import ElProgress from 'element-ui/lib/progress';
|
||||
import './theme/progress.css';
|
||||
|
||||
import ElSlider from 'element-ui/lib/slider';
|
||||
import './theme/slider.css';
|
||||
|
||||
import ElForm from 'element-ui/lib/form';
|
||||
import './theme/form.css';
|
||||
|
||||
import ElFormItem from 'element-ui/lib/form-item';
|
||||
import './theme/form-item.css';
|
||||
|
||||
import ElColorPicker from 'element-ui/lib/color-picker';
|
||||
import './theme/color-picker.css';
|
||||
|
||||
import Notification from 'element-ui/lib/notification';
|
||||
import './theme/notification.css';
|
||||
|
||||
import Loading from 'element-ui/lib/loading';
|
||||
import './theme/loading.css';
|
||||
|
||||
import MessageBox from 'element-ui/lib/message-box';
|
||||
import './theme/message-box.css';
|
||||
|
||||
const components = {
|
||||
ElMenu, ElMenuItem, ElButton, ElButtonGroup, ElCheckbox, ElTabs, ElTabPane, ElTooltip,
|
||||
ElCol, ElContainer, ElAside, ElMain, ElHeader,
|
||||
ElInput, ElInputNumber, ElSelect, ElOption, ElTable, ElTableColumn,
|
||||
ElProgress, ElSlider, ElForm, ElFormItem,
|
||||
ElColorPicker,
|
||||
};
|
||||
|
||||
for (let name in components) {
|
||||
Vue.component(name, components[name]);
|
||||
}
|
||||
|
||||
//Vue.use(Loading.directive);
|
||||
|
||||
Vue.prototype.$loading = Loading.service;
|
||||
Vue.prototype.$msgbox = MessageBox;
|
||||
Vue.prototype.$alert = MessageBox.alert;
|
||||
Vue.prototype.$confirm = MessageBox.confirm;
|
||||
Vue.prototype.$prompt = MessageBox.prompt;
|
||||
Vue.prototype.$notify = Notification;
|
||||
//Vue.prototype.$message = Message;
|
||||
|
||||
import lang from 'element-ui/lib/locale/lang/ru-RU';
|
||||
import locale from 'element-ui/lib/locale';
|
||||
locale.use(lang);
|
||||
11
client/index.html.template
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
14
client/main.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Vue from 'vue';
|
||||
import App from './components/App.vue';
|
||||
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
import './element';
|
||||
|
||||
//Vue.config.productionTip = false;
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
}).$mount('#app');
|
||||
67
client/router.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
import _ from 'lodash';
|
||||
|
||||
import App from './components/App.vue';
|
||||
|
||||
const CardIndex = () => import('./components/CardIndex/CardIndex.vue');
|
||||
const Search = () => import('./components/CardIndex/Search/Search.vue');
|
||||
const Card = () => import('./components/CardIndex/Card/Card.vue');
|
||||
const Book = () => import('./components/CardIndex/Book/Book.vue');
|
||||
const History = () => import('./components/CardIndex/History/History.vue');
|
||||
|
||||
const Reader = () => import('./components/Reader/Reader.vue');
|
||||
//const Forum = () => import('./components/Forum/Forum.vue');
|
||||
const Income = () => import('./components/Income/Income.vue');
|
||||
const Sources = () => import('./components/Sources/Sources.vue');
|
||||
const Settings = () => import('./components/Settings/Settings.vue');
|
||||
const Help = () => import('./components/Help/Help.vue');
|
||||
const NotFound404 = () => import('./components/NotFound404/NotFound404.vue');
|
||||
|
||||
const myRoutes = [
|
||||
['/', null, null, '/cardindex'],
|
||||
['/cardindex', CardIndex ],
|
||||
['/cardindex~search', Search ],
|
||||
['/cardindex~card', Card ],
|
||||
['/cardindex~card/:authorId', Card ],
|
||||
['/cardindex~book', Book ],
|
||||
['/cardindex~book/:bookId', Book ],
|
||||
['/cardindex~history', History ],
|
||||
|
||||
['/reader', Reader ],
|
||||
['/income', Income ],
|
||||
['/sources', Sources ],
|
||||
['/settings', Settings ],
|
||||
['/help', Help ],
|
||||
['*', null, null, '/cardindex' ],
|
||||
];
|
||||
|
||||
let routes = {};
|
||||
|
||||
for (let route of myRoutes) {
|
||||
const [path, component, name, redirect] = route;
|
||||
let cleanRoute = _.pickBy({path, component, name, redirect}, _.identity);
|
||||
|
||||
let parts = cleanRoute.path.split('~');
|
||||
let f = routes;
|
||||
for (let part of parts) {
|
||||
const curRoute = _.assign({}, cleanRoute, { path: part });
|
||||
|
||||
if (!f.children)
|
||||
f.children = [];
|
||||
let r = f.children;
|
||||
|
||||
f = _.find(r, {path: part});
|
||||
if (!f) {
|
||||
r.push(curRoute);
|
||||
f = curRoute;
|
||||
}
|
||||
}
|
||||
}
|
||||
routes = routes.children;
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
export default new VueRouter({
|
||||
routes
|
||||
});
|
||||
65
client/share/utils.js
Normal file
@@ -0,0 +1,65 @@
|
||||
export function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function stringToHex(str) {
|
||||
let result = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
result += str.charCodeAt(i).toString(16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hexToString(str) {
|
||||
let result = '';
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
result += String.fromCharCode(parseInt(str.substr(i, 2), 16));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatDate(d, format) {
|
||||
if (!format)
|
||||
format = 'normal';
|
||||
|
||||
switch (format) {
|
||||
case 'normal':
|
||||
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()} ` +
|
||||
`${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function fallbackCopyTextToClipboard(text) {
|
||||
let textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
let result = false;
|
||||
try {
|
||||
result = document.execCommand('copy');
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text) {
|
||||
if (!navigator.clipboard) {
|
||||
return fallbackCopyTextToClipboard(text);
|
||||
}
|
||||
|
||||
let result = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
result = true;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
22
client/store/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import createPersistedState from 'vuex-persistedstate';
|
||||
|
||||
import root from './root.js';
|
||||
import uistate from './modules/uistate';
|
||||
import config from './modules/config';
|
||||
import reader from './modules/reader';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
const debug = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export default new Vuex.Store(Object.assign({}, root, {
|
||||
modules: {
|
||||
uistate,
|
||||
config,
|
||||
reader,
|
||||
},
|
||||
strict: debug,
|
||||
plugins: [createPersistedState()]
|
||||
}));
|
||||
39
client/store/modules/config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import miscApi from '../../api/misc';
|
||||
// initial state
|
||||
const state = {
|
||||
name: null,
|
||||
version: null,
|
||||
mode: null,
|
||||
};
|
||||
|
||||
// getters
|
||||
const getters = {};
|
||||
|
||||
// actions
|
||||
const actions = {
|
||||
async loadConfig({ commit, state }) {
|
||||
commit('setApiError', null, { root: true });
|
||||
commit('setConfig', {});
|
||||
try {
|
||||
const config = await miscApi.loadConfig();
|
||||
commit('setConfig', config);
|
||||
} catch (e) {
|
||||
commit('setApiError', e, { root: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// mutations
|
||||
const mutations = {
|
||||
setConfig(state, value) {
|
||||
Object.assign(state, value);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations
|
||||
};
|
||||
202
client/store/modules/reader.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
const fonts = [
|
||||
{name: 'ReaderDefault', label: 'По-умолчанию', fontVertShift: 0},
|
||||
{name: 'GEO_1', label: 'BPG Arial', fontVertShift: 10},
|
||||
{name: 'Arimo', fontVertShift: 0},
|
||||
{name: 'Avrile', fontVertShift: -10},
|
||||
{name: 'OpenSans', fontVertShift: -5},
|
||||
{name: 'Roboto', fontVertShift: 10},
|
||||
{name: 'Rubik', fontVertShift: 0},
|
||||
];
|
||||
|
||||
const webFonts = [
|
||||
{css: 'https://fonts.googleapis.com/css?family=Alegreya', name: 'Alegreya', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Alegreya+Sans', name: 'Alegreya Sans', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Alegreya+SC', name: 'Alegreya SC', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Alice', name: 'Alice', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Amatic+SC', name: 'Amatic SC', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Andika', name: 'Andika', fontVertShift: -35},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Anonymous+Pro', name: 'Anonymous Pro', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Arsenal', name: 'Arsenal', fontVertShift: 0},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Bad+Script', name: 'Bad Script', fontVertShift: -30},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Caveat', name: 'Caveat', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Comfortaa', name: 'Comfortaa', fontVertShift: 10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Cormorant', name: 'Cormorant', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Cormorant+Garamond', name: 'Cormorant Garamond', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Cormorant+Infant', name: 'Cormorant Infant', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Cormorant+Unicase', name: 'Cormorant Unicase', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Cousine', name: 'Cousine', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Cuprum', name: 'Cuprum', fontVertShift: 5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Didact+Gothic', name: 'Didact Gothic', fontVertShift: -10},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=EB+Garamond', name: 'EB Garamond', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=El+Messiri', name: 'El Messiri', fontVertShift: -5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Fira+Mono', name: 'Fira Mono', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Fira+Sans', name: 'Fira Sans', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Fira+Sans+Condensed', name: 'Fira Sans Condensed', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Fira+Sans+Extra+Condensed', name: 'Fira Sans Extra Condensed', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Forum', name: 'Forum', fontVertShift: 5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Gabriela', name: 'Gabriela', fontVertShift: 5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Mono', name: 'IBM Plex Mono', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Sans', name: 'IBM Plex Sans', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=IBM+Plex+Serif', name: 'IBM Plex Serif', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Istok+Web', name: 'Istok Web', fontVertShift: -5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Jura', name: 'Jura', fontVertShift: 0},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Kelly+Slab', name: 'Kelly Slab', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Kosugi', name: 'Kosugi', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Kosugi+Maru', name: 'Kosugi Maru', fontVertShift: 10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Kurale', name: 'Kurale', fontVertShift: -15},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Ledger', name: 'Ledger', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Lobster', name: 'Lobster', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Lora', name: 'Lora', fontVertShift: 0},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Marck+Script', name: 'Marck Script', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Marmelad', name: 'Marmelad', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Merriweather', name: 'Merriweather', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Montserrat', name: 'Montserrat', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Montserrat+Alternates', name: 'Montserrat Alternates', fontVertShift: 0},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Neucha', name: 'Neucha', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Noto+Sans', name: 'Noto Sans', fontVertShift: -10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Noto+Sans+SC', name: 'Noto Sans SC', fontVertShift: -15},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Noto+Serif', name: 'Noto Serif', fontVertShift: -10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Noto+Serif+TC', name: 'Noto Serif TC', fontVertShift: -15},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Old+Standard+TT', name: 'Old Standard TT', fontVertShift: 15},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300', name: 'Open Sans Condensed', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Oranienbaum', name: 'Oranienbaum', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Oswald', name: 'Oswald', fontVertShift: -20},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Pacifico', name: 'Pacifico', fontVertShift: -35},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Pangolin', name: 'Pangolin', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Pattaya', name: 'Pattaya', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Philosopher', name: 'Philosopher', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Play', name: 'Play', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Playfair+Display', name: 'Playfair Display', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Playfair+Display+SC', name: 'Playfair Display SC', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Podkova', name: 'Podkova', fontVertShift: 10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Poiret+One', name: 'Poiret One', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Prata', name: 'Prata', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Prosto+One', name: 'Prosto One', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=PT+Mono', name: 'PT Mono', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=PT+Sans', name: 'PT Sans', fontVertShift: -10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=PT+Sans+Caption', name: 'PT Sans Caption', fontVertShift: -10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=PT+Sans+Narrow', name: 'PT Sans Narrow', fontVertShift: -10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=PT+Serif', name: 'PT Serif', fontVertShift: -10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=PT+Serif+Caption', name: 'PT Serif Caption', fontVertShift: -10},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Roboto+Condensed', name: 'Roboto Condensed', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Roboto+Mono', name: 'Roboto Mono', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Roboto+Slab', name: 'Roboto Slab', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Ruslan+Display', name: 'Ruslan Display', fontVertShift: 20},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Russo+One', name: 'Russo One', fontVertShift: 5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Sawarabi+Gothic', name: 'Sawarabi Gothic', fontVertShift: -15},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Scada', name: 'Scada', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Seymour+One', name: 'Seymour One', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Source+Sans+Pro', name: 'Source Sans Pro', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Spectral', name: 'Spectral', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Stalinist+One', name: 'Stalinist One', fontVertShift: 0},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Tinos', name: 'Tinos', fontVertShift: 5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Tenor+Sans', name: 'Tenor Sans', fontVertShift: 5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Underdog', name: 'Underdog', fontVertShift: 10},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Ubuntu+Mono', name: 'Ubuntu Mono', fontVertShift: 0},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Ubuntu+Condensed', name: 'Ubuntu Condensed', fontVertShift: -5},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Vollkorn', name: 'Vollkorn', fontVertShift: -5},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Vollkorn+SC', name: 'Vollkorn SC', fontVertShift: 0},
|
||||
|
||||
{css: 'https://fonts.googleapis.com/css?family=Yanone+Kaffeesatz', name: 'Yanone Kaffeesatz', fontVertShift: 20},
|
||||
{css: 'https://fonts.googleapis.com/css?family=Yeseva+One', name: 'Yeseva One', fontVertShift: 10},
|
||||
|
||||
|
||||
];
|
||||
|
||||
const settingDefaults = {
|
||||
textColor: '#000000',
|
||||
backgroundColor: '#EBE2C9',
|
||||
wallpaper: '',
|
||||
fontStyle: '',// 'italic'
|
||||
fontWeight: '',// 'bold'
|
||||
fontSize: 20,// px
|
||||
fontName: 'ReaderDefault',
|
||||
webFontName: '',
|
||||
fontVertShift: 0,
|
||||
textVertShift: -20,
|
||||
|
||||
lineInterval: 3,// px, межстрочный интервал
|
||||
textAlignJustify: true,// выравнивание по ширине
|
||||
p: 25,// px, отступ параграфа
|
||||
indentLR: 15,// px, отступ всего текста слева и справа
|
||||
indentTB: 0,// px, отступ всего текста сверху и снизу
|
||||
wordWrap: true,//перенос по слогам
|
||||
keepLastToFirst: true,// перенос последней строки в первую при листании
|
||||
|
||||
showStatusBar: true,
|
||||
statusBarTop: false,// top, bottom
|
||||
statusBarHeight: 19,// px
|
||||
statusBarColorAlpha: 0.4,
|
||||
|
||||
scrollingDelay: 3000,// замедление, ms
|
||||
scrollingType: 'ease-in-out', //linear, ease, ease-in, ease-out, ease-in-out
|
||||
|
||||
pageChangeTransition: '',// '' - нет, downShift, rightShift, thaw - протаивание, blink - мерцание
|
||||
pageChangeTransitionSpeed: 50, //0-100%
|
||||
|
||||
allowUrlParamBookPos: false,
|
||||
lazyParseEnabled: false,
|
||||
copyFullText: false,
|
||||
showClickMapPage: true,
|
||||
fontShifts: {},
|
||||
};
|
||||
|
||||
for (const font of fonts)
|
||||
settingDefaults.fontShifts[font.name] = font.fontVertShift;
|
||||
for (const font of webFonts)
|
||||
settingDefaults.fontShifts[font.name] = font.fontVertShift;
|
||||
|
||||
// initial state
|
||||
const state = {
|
||||
toolBarActive: true,
|
||||
settings: Object.assign({}, settingDefaults),
|
||||
};
|
||||
|
||||
// getters
|
||||
const getters = {};
|
||||
|
||||
// actions
|
||||
const actions = {};
|
||||
|
||||
// mutations
|
||||
const mutations = {
|
||||
setToolBarActive(state, value) {
|
||||
state.toolBarActive = value;
|
||||
},
|
||||
setSettings(state, value) {
|
||||
state.settings = Object.assign({}, state.settings, value);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
fonts,
|
||||
webFonts,
|
||||
settingDefaults,
|
||||
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations
|
||||
};
|
||||
25
client/store/modules/uistate.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// initial state
|
||||
const state = {
|
||||
asideBarCollapse: false,
|
||||
};
|
||||
|
||||
// getters
|
||||
const getters = {};
|
||||
|
||||
// actions
|
||||
const actions = {};
|
||||
|
||||
// mutations
|
||||
const mutations = {
|
||||
setAsideBarCollapse(state, value) {
|
||||
state.asideBarCollapse = value;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations
|
||||
};
|
||||
25
client/store/root.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// initial state
|
||||
const state = {
|
||||
apiError: null,
|
||||
};
|
||||
|
||||
// getters
|
||||
const getters = {};
|
||||
|
||||
// actions
|
||||
const actions = {};
|
||||
|
||||
// mutations
|
||||
const mutations = {
|
||||
setApiError(state, value) {
|
||||
state.apiError = value;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations
|
||||
};
|
||||
1
client/theme/alert.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-alert{width:100%;padding:8px 16px;margin:0;-webkit-box-sizing:border-box;box-sizing:border-box;border-radius:4px;position:relative;background-color:#fff;overflow:hidden;opacity:1;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-transition:opacity .2s;transition:opacity .2s}.el-alert.is-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.el-alert--success{background-color:#f0f9eb;color:#67c23a}.el-alert--success .el-alert__description{color:#67c23a}.el-alert--info{background-color:#f4f4f5;color:#909399}.el-alert--info .el-alert__description{color:#909399}.el-alert--warning{background-color:#fdf6ec;color:#e6a23c}.el-alert--warning .el-alert__description{color:#e6a23c}.el-alert--error{background-color:#fef0f0;color:#f56c6c}.el-alert--error .el-alert__description{color:#f56c6c}.el-alert__content{display:table-cell;padding:0 8px}.el-alert__icon{font-size:16px;width:16px}.el-alert__icon.is-big{font-size:28px;width:28px}.el-alert__title{font-size:13px;line-height:18px}.el-alert__title.is-bold{font-weight:700}.el-alert .el-alert__description{font-size:12px;margin:5px 0 0}.el-alert__closebtn{font-size:12px;color:#c0c4cc;opacity:1;position:absolute;top:12px;right:15px;cursor:pointer}.el-alert__closebtn.is-customed{font-style:normal;font-size:13px;top:9px}.el-alert-fade-enter,.el-alert-fade-leave-active{opacity:0}
|
||||
1
client/theme/aside.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-aside{overflow:auto;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-flex-negative:0;flex-shrink:0}
|
||||
1
client/theme/autocomplete.css
Normal file
1
client/theme/badge.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-badge{position:relative;vertical-align:middle;display:inline-block}.el-badge__content{background-color:#f56c6c;border-radius:10px;color:#fff;display:inline-block;font-size:12px;height:18px;line-height:18px;padding:0 6px;text-align:center;white-space:nowrap;border:1px solid #fff}.el-badge__content.is-fixed{position:absolute;top:0;right:10px;-webkit-transform:translateY(-50%) translateX(100%);transform:translateY(-50%) translateX(100%)}.el-badge__content.is-fixed.is-dot{right:5px}.el-badge__content.is-dot{height:8px;width:8px;padding:0;right:0;border-radius:50%}.el-badge__content--primary{background-color:#00468F}.el-badge__content--success{background-color:#67c23a}.el-badge__content--warning{background-color:#e6a23c}.el-badge__content--info{background-color:#909399}.el-badge__content--danger{background-color:#f56c6c}
|
||||
1
client/theme/base.css
Normal file
0
client/theme/breadcrumb-item.css
Normal file
1
client/theme/breadcrumb.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-breadcrumb{font-size:14px;line-height:1}.el-breadcrumb::after,.el-breadcrumb::before{display:table;content:""}.el-breadcrumb::after{clear:both}.el-breadcrumb__separator{margin:0 9px;font-weight:700;color:#c0c4cc}.el-breadcrumb__separator[class*=icon]{margin:0 6px;font-weight:400}.el-breadcrumb__item{float:left}.el-breadcrumb__inner{color:#606266}.el-breadcrumb__inner a,.el-breadcrumb__inner.is-link{font-weight:700;text-decoration:none;-webkit-transition:color .2s cubic-bezier(.645,.045,.355,1);transition:color .2s cubic-bezier(.645,.045,.355,1);color:#303133}.el-breadcrumb__inner a:hover,.el-breadcrumb__inner.is-link:hover{color:#00468F;cursor:pointer}.el-breadcrumb__item:last-child .el-breadcrumb__inner,.el-breadcrumb__item:last-child .el-breadcrumb__inner a,.el-breadcrumb__item:last-child .el-breadcrumb__inner a:hover,.el-breadcrumb__item:last-child .el-breadcrumb__inner:hover{font-weight:400;color:#606266;cursor:text}.el-breadcrumb__item:last-child .el-breadcrumb__separator{display:none}
|
||||
0
client/theme/button-group.css
Normal file
1
client/theme/button.css
Normal file
1
client/theme/card.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-card{border-radius:4px;border:1px solid #ebeef5;background-color:#fff;overflow:hidden;color:#303133;-webkit-transition:.3s;transition:.3s}.el-card.is-always-shadow,.el-card.is-hover-shadow:focus,.el-card.is-hover-shadow:hover{-webkit-box-shadow:0 2px 12px 0 rgba(0,0,0,.1);box-shadow:0 2px 12px 0 rgba(0,0,0,.1)}.el-card__header{padding:18px 20px;border-bottom:1px solid #ebeef5;-webkit-box-sizing:border-box;box-sizing:border-box}.el-card__body{padding:20px}
|
||||
1
client/theme/carousel-item.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-carousel__item,.el-carousel__mask{position:absolute;height:100%;top:0;left:0}.el-carousel__item{width:100%;display:inline-block;overflow:hidden;z-index:0}.el-carousel__item.is-active{z-index:2}.el-carousel__item.is-animating{-webkit-transition:-webkit-transform .4s ease-in-out;transition:-webkit-transform .4s ease-in-out;transition:transform .4s ease-in-out;transition:transform .4s ease-in-out,-webkit-transform .4s ease-in-out}.el-carousel__item--card{width:50%;-webkit-transition:-webkit-transform .4s ease-in-out;transition:-webkit-transform .4s ease-in-out;transition:transform .4s ease-in-out;transition:transform .4s ease-in-out,-webkit-transform .4s ease-in-out}.el-carousel__item--card.is-in-stage{cursor:pointer;z-index:1}.el-carousel__item--card.is-in-stage.is-hover .el-carousel__mask,.el-carousel__item--card.is-in-stage:hover .el-carousel__mask{opacity:.12}.el-carousel__item--card.is-active{z-index:2}.el-carousel__mask{width:100%;background-color:#fff;opacity:.24;-webkit-transition:.2s;transition:.2s}
|
||||
1
client/theme/carousel.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-carousel{overflow-x:hidden;position:relative}.el-carousel__container{position:relative;height:300px}.el-carousel__arrow{border:none;outline:0;padding:0;margin:0;height:36px;width:36px;cursor:pointer;-webkit-transition:.3s;transition:.3s;border-radius:50%;background-color:rgba(31,45,61,.11);color:#fff;position:absolute;top:50%;z-index:10;-webkit-transform:translateY(-50%);transform:translateY(-50%);text-align:center;font-size:12px}.el-carousel__arrow--left{left:16px}.el-carousel__arrow--right{right:16px}.el-carousel__arrow:hover{background-color:rgba(31,45,61,.23)}.el-carousel__arrow i{cursor:pointer}.el-carousel__indicators{position:absolute;list-style:none;bottom:0;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%);margin:0;padding:0;z-index:2}.el-carousel__indicators--outside{bottom:26px;text-align:center;position:static;-webkit-transform:none;transform:none}.el-carousel__indicators--outside .el-carousel__indicator:hover button{opacity:.64}.el-carousel__indicators--outside button{background-color:#c0c4cc;opacity:.24}.el-carousel__indicators--labels{left:0;right:0;-webkit-transform:none;transform:none;text-align:center}.el-carousel__indicators--labels .el-carousel__button{height:auto;width:auto;padding:2px 18px;font-size:12px}.el-carousel__indicators--labels .el-carousel__indicator{padding:6px 4px}.el-carousel__indicator{display:inline-block;background-color:transparent;padding:12px 4px;cursor:pointer}.el-carousel__indicator:hover button{opacity:.72}.el-carousel__indicator.is-active button{opacity:1}.el-carousel__button{display:block;opacity:.48;width:30px;height:2px;background-color:#fff;border:none;outline:0;padding:0;margin:0;cursor:pointer;-webkit-transition:.3s;transition:.3s}.carousel-arrow-left-enter,.carousel-arrow-left-leave-active{-webkit-transform:translateY(-50%) translateX(-10px);transform:translateY(-50%) translateX(-10px);opacity:0}.carousel-arrow-right-enter,.carousel-arrow-right-leave-active{-webkit-transform:translateY(-50%) translateX(10px);transform:translateY(-50%) translateX(10px);opacity:0}
|
||||