feat: refactor project with vue

This commit is contained in:
majianquan 2020-01-13 22:16:04 +08:00
parent d9a170cf87
commit 6bce5947f0
78 changed files with 10752 additions and 355 deletions

2
.browserslistrc Normal file
View File

@ -0,0 +1,2 @@
> 1%
last 2 versions

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

29
.eslintrc.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/essential',
'@vue/standard'
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'camelcase': 'off'
},
parserOptions: {
parser: 'babel-eslint'
},
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)'
],
env: {
jest: true
}
}
]
}

15
.gitignore vendored
View File

@ -27,3 +27,18 @@ yarn-error.log*
dist
package-lock.json
lib
node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,348 +0,0 @@
let app = new Vue({
el: '#app',
data: function () {
let d = {
aboutOutput: '',
output: '',
source: '',
editor: null,
cssEditor: null,
builtinFonts: [
{ label: '无衬线', value: "-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif" },
{ label: '衬线', value: "Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif" }
],
sizeOption: [
{ label: '12px', value: '12px', desc: '更小' },
{ label: '13px', value: '13px', desc: '稍小' },
{ label: '14px', value: '14px', desc: '推荐' },
{ label: '15px', value: '15px', desc: '稍大' },
{ label: '16px', value: '16px', desc: '更大' }
],
colorOption: [
{ label: '经典蓝', value: 'rgba(15, 76, 129, 1)', hex: '最新流行' },
{ label: '翡翠绿', value: 'rgba(0, 152, 116, 1)', hex: '优雅清新' },
{ label: '活力橘', value: 'rgba(250, 81, 81, 1)', hex: '热情活泼' }
],
showBox: true,
aboutDialogVisible: false,
dialogFormVisible: false,
form: {
rows: 1,
cols: 1
}
};
d.currentFont = d.builtinFonts[0].value;
d.currentSize = d.sizeOption[2].value;
d.currentColor = d.colorOption[1].value;
d.status = '1';
return d;
},
mounted() {
this.currentFont = localStorage.getItem('fonts') || this.builtinFonts[0].value;
this.currentColor = localStorage.getItem('color') || this.colorOption[1].value;
this.currentSize = localStorage.getItem('size') || this.sizeOption[2].value;
this.status = localStorage.getItem('status') === 'true';
this.showBox = false
this.editor = CodeMirror.fromTextArea(
document.getElementById('editor'),
{
mode: 'text/x-markdown',
theme: 'xq-light',
lineNumbers: false,
lineWrapping: true,
styleActiveLine: true,
autoCloseBrackets: true
}
);
this.cssEditor = CodeMirror.fromTextArea(
document.getElementById('cssEditor'), {
mode: 'css',
theme: 'style-mirror',
lineNumbers: false,
lineWrapping: true,
matchBrackets: true,
autofocus: true,
extraKeys: {
'Ctrl-F': function autoFormat(editor) {
const totalLines = editor.lineCount();
editor.autoFormatRange({ line: 0, ch: 0 }, { line: totalLines });
}
},
}
);
// 自动提示
this.cssEditor.on('keyup', (cm, e) => {
if ((e.keyCode >= 65 && e.keyCode <= 90) || e.keyCode === 189) {
cm.showHint(e);
}
});
this.editor.on('change', (cm, e) => {
this.refresh();
this.saveEditorContent(this.editor, '__editor_content');
});
// 粘贴上传图片并插入
this.editor.on('paste', (cm, e) => {
if (!(e.clipboardData && e.clipboardData.items)) {
return;
}
for (let i = 0, len = e.clipboardData.items.length; i < len; ++i) {
let item = e.clipboardData.items[i];
if (item.kind === 'file') {
const pasteFile = item.getAsFile();
if (!(this.checkType(pasteFile) && this.checkImageSize(pasteFile))) {
return;
}
let data = new FormData();
data.append('file', pasteFile);
axios.post('https://imgkr.com/api/files/upload', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(resp => {
this.uploaded(resp.data)
}).catch(err => { })
}
}
});
this.cssEditor.on('update', (instance) => {
this.cssChanged();
this.saveEditorContent(this.cssEditor, '__css_content');
});
this.wxRenderer = new WxRenderer({
theme: setColor(this.currentColor),
fonts: this.currentFont,
size: this.currentSize,
status: this.status
});
// 如果有编辑器内容被保存则读取,否则加载默认内容
this.loadLocalStorage(this.editor, '__editor_content', DEFAULT_CONTENT);
this.loadLocalStorage(this.cssEditor, '__css_content', DEFAULT_CSS_CONTENT);
},
methods: {
renderWeChat(source) {
let output = marked(source, { renderer: this.wxRenderer.getRenderer(this.status) });
// 去除第一行的 margin-top
output = output.replace(/(style=".*?)"/, '$1;margin-top: 0"');
if (this.status) {
// 引用脚注
output += this.wxRenderer.buildFootnotes();
// 附加的一些 style
output += this.wxRenderer.buildAddition();
}
return output;
},
editorThemeChanged(editorTheme) {
this.editor.setOption('theme', editorTheme);
},
fontChanged(fonts) {
this.wxRenderer.setOptions({
fonts: fonts
});
this.currentFont = fonts;
localStorage.setItem('fonts', fonts);
this.refresh();
},
sizeChanged(size) {
this.wxRenderer.setOptions({
size: size
});
let theme = setFontSize(size.replace('px', ''));
theme = setColorWithCustomTemplate(theme, this.currentColor);
this.wxRenderer.setOptions({
theme: theme
});
this.currentSize = size;
localStorage.setItem('size', size);
this.refresh();
},
colorChanged(color) {
let theme = setFontSize(this.currentSize.replace('px', ''));
theme = setColorWithCustomTemplate(theme, color);
this.wxRenderer.setOptions({
theme: theme
});
this.currentColor = color;
localStorage.setItem('color', color);
this.refresh();
},
cssChanged() {
let json = css2json(this.cssEditor.getValue(0));
let theme = setFontSize(this.currentSize.replace('px', ''));
theme = customCssWithTemplate(json, this.currentColor, theme);
this.wxRenderer.setOptions({
theme: theme
});
this.refresh();
},
// 图片上传前的处理
beforeUpload(file) {
return this.checkType(file) && this.checkImageSize(file);
},
// 检查文件类型
checkType(file) {
if (!/\.(gif|jpg|jpeg|png|GIF|JPG|PNG)$/.test(file.name)) {
this.$message({
showClose: true,
message: '请上传 JPG/PNG/GIF 格式的图片',
type: 'error'
});
return false;
}
return true;
},
// 检查图片大小
checkImageSize(file) {
if (file.size > 5 * 1024 * 1024) {
this.$message({
showClose: true,
message: '由于公众号限制,图片大小不能超过 5.0M',
type: 'error'
});
return false;
}
return true;
},
// 图片上传结束
uploaded(response, file, fileList) {
if (response.success) {
// 上传成功,获取光标
const cursor = this.editor.getCursor();
const imageUrl = response.data
const markdownImage = `![](${imageUrl})`
// 将 Markdown 形式的 URL 插入编辑框光标所在位置
this.editor.replaceSelection(`\n${markdownImage}\n`, cursor);
this.$message({
showClose: true,
message: '图片插入成功',
type: 'success'
});
this.refresh();
} else {
// 上传失败
this.$message({
showClose: true,
message: response.message,
type: 'error'
});
}
},
// 刷新右侧预览
refresh() {
this.output = this.renderWeChat(this.editor.getValue(0));
},
// 重置页面
reset() {
this.$confirm('此操作将丢失本地缓存的文本和自定义样式,是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--success',
cancelButtonClass: 'el-button--success is-plain',
type: 'warning',
center: true
}).then(() => {
localStorage.clear()
this.editor.setValue(DEFAULT_CONTENT);
this.cssEditor.setValue(DEFAULT_CSS_CONTENT);
this.editor.focus();
this.status = '1';
this.fontChanged(this.builtinFonts[0].value);
this.colorChanged(this.colorOption[1].value);
this.sizeChanged(this.sizeOption[2].value);
this.cssChanged()
}).catch(() => {
this.editor.focus();
});
},
// 插入表格
insertTable() {
const cursor = this.editor.getCursor();
const rows = parseInt(this.form.rows);
const cols = parseInt(this.form.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
this.$message({
showClose: true,
message: '输入的行/列数无效,请重新输入',
type: 'error'
});
return;
}
let table = '';
for (let i = 0; i < rows + 2; ++i) {
for (let j = 0; j < cols + 1; ++j) {
table += (j === 0 ? '|' : (i !== 1 ? ' |' : ' --- |'));
}
table += '\n';
}
this.editor.replaceSelection(`\n${table}\n`, cursor);
this.dialogFormVisible = false
this.refresh();
},
statusChanged() {
localStorage.setItem('status', this.status);
this.refresh();
},
// 将编辑器内容保存到 LocalStorage
saveEditorContent(editor, name) {
const content = editor.getValue(0);
if (content) {
localStorage.setItem(name, content);
} else {
localStorage.removeItem(name);
}
},
loadLocalStorage(editor, name, content) {
if (localStorage.getItem(name)) {
editor.setValue(localStorage.getItem(name));
} else {
editor.setValue(content);
}
},
// 下载编辑器内容到本地
downloadEditorContent() {
let downLink = document.createElement('a');
downLink.download = 'content.md';
downLink.style.display = 'none';
let blob = new Blob([this.editor.getValue(0)]);
downLink.href = URL.createObjectURL(blob);
document.body.appendChild(downLink);
downLink.click();
document.body.removeChild(downLink);
},
// 自定义CSS样式
async customStyle() {
this.showBox = !this.showBox;
let flag = await localStorage.getItem('__css_content')
if (!flag) {
this.cssEditor.setValue(DEFAULT_CSS_CONTENT);
}
},
// 复制渲染后的内容到剪贴板
copy() {
let clipboardDiv = document.getElementById('output');
clipboardDiv.focus();
window.getSelection().removeAllRanges();
let range = document.createRange();
range.setStartBefore(clipboardDiv.firstChild);
range.setEndAfter(clipboardDiv.lastChild);
window.getSelection().addRange(range);
document.execCommand('copy')
// 输出提示
this.$notify({
showClose: true,
message: '已复制渲染后的文章到剪贴板,可直接到公众号后台粘贴',
offset: 80,
duration: 1600,
type: 'success'
});
}
},
updated() {
this.$nextTick(() => {
prettyPrint();
})
}
});

5
babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

3
jest.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
preset: '@vue/cli-plugin-unit-jest'
}

File diff suppressed because one or more lines are too long

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "vue-md",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {
"axios": "^0.19.1",
"codemirror": "^5.50.2",
"core-js": "^3.4.4",
"markdown": "^0.5.0",
"marked": "^0.8.0",
"prettify": "^0.1.7",
"vue": "^2.6.10",
"vue-router": "^3.1.3",
"vuex": "^3.1.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.1.0",
"@vue/cli-plugin-eslint": "^4.1.0",
"@vue/cli-plugin-unit-jest": "^4.1.0",
"@vue/cli-service": "^4.1.0",
"@vue/eslint-config-standard": "^4.0.0",
"@vue/test-utils": "1.0.0-beta.29",
"babel-eslint": "^10.0.3",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"node-sass": "^4.12.0",
"sass-loader": "^8.0.0",
"vue-template-compiler": "^2.6.10"
}
}

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

Before

Width:  |  Height:  |  Size: 852 KiB

After

Width:  |  Height:  |  Size: 852 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

103
public/index.html Normal file
View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<!--
_.._ ,------------.
,' `. ( 你终于发现我啦 )
/ __) __` \ `-,----------'
( (`-`(-') ) _.-'
/) \ = / (
/' |--' . \
( ,---| `-.)__`
)( `-.,--' _`-.
'/,' ( Uu",
(_ , `/,-' )
`.__, : `-'/ /`--'
| `--' |
` `-._ /
\ (
/\ . \.
/ |` \ ,-\
/ \| .) / \
( ,'|\ ,' :
| \,`.`--"/ }
`,' \ |,' /
/ "-._ `-/ |
"-. "-.,'| ;
/ _/["---'""]
: / |"- '
' | /
` |
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="keywords" content="md,markdown,markdown-editor,wechat,official-account,yanglbme,doocs">
<meta name="description" content="Wechat Markdown Editor | 一款高度简洁的微信 Markdown 编辑器">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<title>微信 Markdown 编辑器</title>
<link rel="shortcut icon" href="assets/images/favicon.png">
<link rel="apple-touch-icon-precomposed" href="assets/images/favicon.png">
<link rel="stylesheet" href="assets/css/loading.css">
<link rel="stylesheet" href="libs/css/index.css">
<link rel="stylesheet" href="libs/css/xq-light.min.css">
<link rel="stylesheet" href="libs/css/code-themes/github-v2.min.css">
<!-- codemirror -->
<link rel="stylesheet" href="libs/css/codemirror.min.css">
<link rel="stylesheet" href="libs/css/show-hint.css">
<link rel="stylesheet" href="libs/css/style-mirror.css">
<link rel="stylesheet" href="libs/css/animate.css">
<link rel="stylesheet" href="assets/css/app.css">
<!-- 默认CSS/JS -->
<script src="assets/scripts/themes/default-theme-css.js"></script>
<script src="assets/scripts/default-content.js"></script>
</head>
<body>
<div class="loading" id="loading">
<div class="loading-wrapper">
<div class="loading-text">Loading...</div>
<div class="loading-anim"></div>
</div>
</div>
<div id="app" >
</div>
<!--应用主体-->
<script src="libs/scripts/marked.min.js"></script>
<!-- codemirror -->
<script src="libs/scripts/codemirror/codemirror.min.js"></script>
<script src="libs/scripts/codemirror/css.js"></script>
<script src="libs/scripts/codemirror/matchbrackets.js"></script>
<script src="libs/scripts/codemirror/active-line.js"></script>
<script src="libs/scripts/codemirror/show-hint.js"></script>
<script src="libs/scripts/codemirror/css-hint.js"></script>
<script src="libs/scripts/codemirror/format.js"></script>
<script src="libs/scripts/markdown.min.js"></script>
<!-- <script src="libs/scripts/prettify.min.js"></script> -->
<!-- <script src="libs/scripts/index.js"></script> -->
<script src="libs/scripts/jquery.min.js"></script>
<script src="libs/scripts/closebrackets.js"></script>
<script src="assets/scripts/sync-scroll.js"></script>
<script src="assets/scripts/themes/default-theme.js"></script>
<script src="assets/scripts/util.js"></script>
<script>
$('#loading').hide();
window.console
&& window.console.log
&& (console.log("Think big, train fast, learn deep. See https://github.com/yanglbme"))
</script>
</body>
</html>

498
src/App.vue Normal file
View File

@ -0,0 +1,498 @@
<template>
<div id="app" class="container">
<el-container>
<el-header class="top">
<!-- 图片上传 -->
<el-upload action="https://imgkr.com/api/files/upload" :headers="{'Content-Type': 'multipart/form-data'}"
:show-file-list="false" :multiple="true" accept=".jpg,.jpeg,.png,.gif" name="file"
:before-upload="beforeUpload" :on-success="uploaded">
<el-tooltip class="item" effect="dark" content="上传图片" placement="bottom-start">
<i class="el-icon-upload" size="medium">&nbsp;</i>
</el-tooltip>
</el-upload>
<!-- 下载文本文档 -->
<el-tooltip class="item" effect="dark" content="下载编辑框Markdown文档" placement="bottom-start">
<i class="el-icon-download" size="medium" @click="downloadEditorContent">&nbsp;</i>
</el-tooltip>
<!-- 页面重置 -->
<el-tooltip class="item" effect="dark" content="重置页面" placement="bottom-start">
<i class="el-icon-refresh" size="medium" @click="reset">&nbsp;</i>
</el-tooltip>
<!-- 插入表格 -->
<el-tooltip class="item" effect="dark" content="插入表格" placement="bottom-start">
<i class="el-icon-s-grid" size="medium" @click="dialogFormVisible = true">&nbsp;</i>
</el-tooltip>
<el-form size="mini" class="ctrl" :inline=true>
<el-form-item>
<el-select v-model="currentFont" size="mini" placeholder="选择字体" clearable @change="fontChanged">
<el-option v-for="font in builtinFonts" :style="{fontFamily: font.value}" :key="font.value"
:label="font.label" :value="font.value">
<span class="select-item-left">{{ font.label }}</span>
<span class="select-item-right">Abc</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-select v-model="currentSize" size="mini" placeholder="选择段落字号" clearable @change="sizeChanged">
<el-option v-for="size in sizeOption" :key="size.value" :label="size.label" :value="size.value">
<span class="select-item-left">{{ size.label }}</span>
<span class="select-item-right">{{ size.desc }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-select v-model="currentColor" size="mini" placeholder="选择颜色" clearable @change="colorChanged">
<el-option v-for="color in colorOption" :key="color.value" :label="color.label" :value="color.value">
<span class="select-item-left">{{ color.label }}</span>
<span class="select-item-right">{{ color.hex }}</span>
</el-option>
</el-select>
</el-form-item>
<el-tooltip content="自定义颜色" placement="top">
<el-color-picker v-model="currentColor" size="mini" show-alpha @change="colorChanged"></el-color-picker>
</el-tooltip>
&nbsp;&nbsp;
<el-tooltip content="微信外链自动转为文末引用" placement="top">
<el-switch v-model="status" active-color="#67c23a" inactive-color="#dcdfe6" @change="statusChanged">
</el-switch>
</el-tooltip>
</el-form>
<el-tooltip class="item" effect="dark" content="自定义CSS样式" placement="left">
<el-button type="success" plain size="medium" icon="el-icon-setting" @click="customStyle"></el-button>
</el-tooltip>
<el-button type="success" plain size="medium" @click="copy">复制</el-button>
<el-button type="success" plain size="medium" class="about" @click="aboutDialogVisible = true">关于</el-button>
</el-header>
<el-main class="main-body">
<el-row :gutter="10" class="main-section">
<el-col :span="12">
<textarea id="editor" type="textarea" placeholder="Your markdown text here." v-model="source">
</textarea>
</el-col>
<el-col :span="12" class="preview-wrapper" id="preview">
<section>
<div class="preview" contenteditable="true">
<section id="output" v-html="output">
</section>
</div>
</section>
</el-col>
<transition name="custom-classes-transition" enter-active-class="animated bounceInRight">
<el-col id="cssBox" :span="12" v-show="showBox">
<textarea id="cssEditor" type="textarea" placeholder="Your custom css here.">
</textarea>
</el-col>
</transition>
</el-row>
</el-main>
</el-container>
<el-dialog title="关于" :visible.sync="aboutDialogVisible" width="30%" center>
<div style="text-align: center;">
<h3>一款高度简洁的微信 Markdown 编辑器</h3>
</div>
<div style="text-align: center;margin-top:10px;">
<p>扫码关注我的公众号原创技术文章第一时间推送</p>
<img src="assets/images/qrcode-for-doocs.jpg" style="width: 40%; display: block; margin: 20px auto 10px;">
</div>
<span slot="footer" class="dialog-footer">
<a href="https://github.com/doocs/md" target="_blank">
<el-button type="success" plain>GitHub 仓库</el-button>
</a>
<a href="https://gitee.com/doocs/md" target="_blank">
<el-button type="success" plain>Gitee 仓库</el-button>
</a>
</span>
</el-dialog>
<el-dialog title="插入表格" :visible.sync="dialogFormVisible">
<el-form :model="form">
<el-form-item label="行数(表头不计入行数)">
<el-input v-model="form.rows"></el-input>
</el-form-item>
<el-form-item label="列数">
<el-input v-model="form.cols"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="success" plain @click="dialogFormVisible = false"> </el-button>
<el-button type="success" @click="insertTable"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import CodeMirror from 'codemirror/lib/codemirror'
import 'codemirror/theme/ambiance.css'
import axios from 'axios'
import WxRenderer from './scripts/renderers/wx-renderer'
import marked from 'marked'
import {
setColorWithCustomTemplate,
setColor,
setFontSize,
css2json,
customCssWithTemplate
} from './scripts/util'
import DEFAULT_CONTENT from './scripts/default-content'
import DEFAULT_CSS_CONTENT from './scripts/themes/default-theme-css'
// import { prettyPrint } from 'prettify'
require('codemirror/mode/javascript/javascript')
export default {
data () {
let d = {
wxRenderer: null,
aboutOutput: '',
output: '',
source: '',
editor: null,
cssEditor: null,
builtinFonts: [
{ label: '无衬线', value: '-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif' },
{ label: '衬线', value: "Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif" }
],
sizeOption: [
{ label: '12px', value: '12px', desc: '更小' },
{ label: '13px', value: '13px', desc: '稍小' },
{ label: '14px', value: '14px', desc: '推荐' },
{ label: '15px', value: '15px', desc: '稍大' },
{ label: '16px', value: '16px', desc: '更大' }
],
colorOption: [
{ label: '经典蓝', value: 'rgba(15, 76, 129, 1)', hex: '最新流行' },
{ label: '翡翠绿', value: 'rgba(0, 152, 116, 1)', hex: '优雅清新' },
{ label: '活力橘', value: 'rgba(250, 81, 81, 1)', hex: '热情活泼' }
],
showBox: true,
aboutDialogVisible: false,
dialogFormVisible: false,
form: {
rows: 1,
cols: 1
}
}
d.currentFont = d.builtinFonts[0].value
d.currentSize = d.sizeOption[2].value
d.currentColor = d.colorOption[1].value
d.status = '1'
return d
},
created () {
this.currentFont = localStorage.getItem('fonts') || this.builtinFonts[0].value
this.currentColor = localStorage.getItem('color') || this.colorOption[1].value
this.currentSize = localStorage.getItem('size') || this.sizeOption[2].value
this.status = localStorage.getItem('status') === 'true'
this.showBox = false
this.$nextTick(() => {
this.editor = CodeMirror.fromTextArea(
document.getElementById('editor'),
{
value: '',
mode: 'text/x-markdown',
theme: 'xq-light',
lineNumbers: false,
lineWrapping: true,
styleActiveLine: true,
autoCloseBrackets: true
}
)
this.cssEditor = CodeMirror.fromTextArea(
document.getElementById('cssEditor'), {
value: '',
mode: 'css',
theme: 'style-mirror',
lineNumbers: false,
lineWrapping: true,
matchBrackets: true,
autofocus: true,
extraKeys: {
'Ctrl-F': function autoFormat (editor) {
const totalLines = editor.lineCount()
editor.autoFormatRange({ line: 0, ch: 0 }, { line: totalLines })
}
}
}
)
//
this.cssEditor.on('keyup', (cm, e) => {
if ((e.keyCode >= 65 && e.keyCode <= 90) || e.keyCode === 189) {
cm.showHint(e)
}
})
this.editor.on('change', (cm, e) => {
this.refresh()
this.saveEditorContent(this.editor, '__editor_content')
})
//
this.editor.on('paste', (cm, e) => {
if (!(e.clipboardData && e.clipboardData.items)) {
return
}
for (let i = 0, len = e.clipboardData.items.length; i < len; ++i) {
let item = e.clipboardData.items[i]
if (item.kind === 'file') {
const pasteFile = item.getAsFile()
if (!(this.checkType(pasteFile) && this.checkImageSize(pasteFile))) {
return
}
let data = new FormData()
data.append('file', pasteFile)
axios.post(
'https://imgkr.com/api/files/upload',
data,
{
headers: { 'Content-Type': 'multipart/form-data' }
}
).then(resp => {
this.uploaded(resp.data)
}).catch(err => {
console.log(err.message)
})
}
}
})
this.cssEditor.on('update', (instance) => {
this.cssChanged()
this.saveEditorContent(this.cssEditor, '__css_content')
})
//
this.loadLocalStorage(this.editor, '__editor_content', DEFAULT_CONTENT)
this.loadLocalStorage(this.cssEditor, '__css_content', DEFAULT_CSS_CONTENT)
})
this.wxRenderer = new WxRenderer({
theme: setColor(this.currentColor),
fonts: this.currentFont,
size: this.currentSize,
status: this.status
})
},
methods: {
renderWeChat (source) {
let output = marked(source, { renderer: this.wxRenderer.getRenderer(this.status) })
// margin-top
output = output.replace(/(style=".*?)"/, '$1;margin-top: 0"')
if (this.status) {
//
output += this.wxRenderer.buildFootnotes()
// style
output += this.wxRenderer.buildAddition()
}
return output
},
editorThemeChanged (editorTheme) {
this.editor.setOption('theme', editorTheme)
},
fontChanged (fonts) {
this.wxRenderer.setOptions({
fonts: fonts
})
this.currentFont = fonts
localStorage.setItem('fonts', fonts)
this.refresh()
},
sizeChanged (size) {
this.wxRenderer.setOptions({
size: size
})
let theme = setFontSize(size.replace('px', ''))
theme = setColorWithCustomTemplate(theme, this.currentColor)
this.wxRenderer.setOptions({
theme: theme
})
this.currentSize = size
localStorage.setItem('size', size)
this.refresh()
},
colorChanged (color) {
let theme = setFontSize(this.currentSize.replace('px', ''))
theme = setColorWithCustomTemplate(theme, color)
this.wxRenderer.setOptions({
theme: theme
})
this.currentColor = color
localStorage.setItem('color', color)
this.refresh()
},
cssChanged () {
let json = css2json(this.cssEditor.getValue(0))
let theme = setFontSize(this.currentSize.replace('px', ''))
theme = customCssWithTemplate(json, this.currentColor, theme)
this.wxRenderer.setOptions({
theme: theme
})
this.refresh()
},
//
beforeUpload (file) {
return this.checkType(file) && this.checkImageSize(file)
},
//
checkType (file) {
if (!/\.(gif|jpg|jpeg|png|GIF|JPG|PNG)$/.test(file.name)) {
this.$message({
showClose: true,
message: '请上传 JPG/PNG/GIF 格式的图片',
type: 'error'
})
return false
}
return true
},
//
checkImageSize (file) {
if (file.size > 5 * 1024 * 1024) {
this.$message({
showClose: true,
message: '由于公众号限制,图片大小不能超过 5.0M',
type: 'error'
})
return false
}
return true
},
//
uploaded (response, file, fileList) {
if (response.success) {
//
const cursor = this.editor.getCursor()
const imageUrl = response.data
const markdownImage = `![](${imageUrl})`
// Markdown URL
this.editor.replaceSelection(`\n${markdownImage}\n`, cursor)
this.$message({
showClose: true,
message: '图片插入成功',
type: 'success'
})
this.refresh()
} else {
//
this.$message({
showClose: true,
message: response.message,
type: 'error'
})
}
},
//
refresh () {
this.output = this.renderWeChat(this.editor.getValue(0))
},
//
reset () {
this.$confirm('此操作将丢失本地缓存的文本和自定义样式,是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--success',
cancelButtonClass: 'el-button--success is-plain',
type: 'warning',
center: true
}).then(() => {
localStorage.clear()
this.editor.setValue(DEFAULT_CONTENT)
this.cssEditor.setValue(DEFAULT_CSS_CONTENT)
this.editor.focus()
this.status = '1'
this.fontChanged(this.builtinFonts[0].value)
this.colorChanged(this.colorOption[1].value)
this.sizeChanged(this.sizeOption[2].value)
this.cssChanged()
}).catch(() => {
this.editor.focus()
})
},
//
insertTable () {
const cursor = this.editor.getCursor()
const rows = parseInt(this.form.rows)
const cols = parseInt(this.form.cols)
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
this.$message({
showClose: true,
message: '输入的行/列数无效,请重新输入',
type: 'error'
})
return
}
let table = ''
for (let i = 0; i < rows + 2; ++i) {
for (let j = 0; j < cols + 1; ++j) {
table += (j === 0 ? '|' : (i !== 1 ? ' |' : ' --- |'))
}
table += '\n'
}
this.editor.replaceSelection(`\n${table}\n`, cursor)
this.dialogFormVisible = false
this.refresh()
},
statusChanged () {
localStorage.setItem('status', this.status)
this.refresh()
},
// LocalStorage
saveEditorContent (editor, name) {
const content = editor.getValue(0)
if (content) {
localStorage.setItem(name, content)
} else {
localStorage.removeItem(name)
}
},
loadLocalStorage (editor, name, content) {
if (localStorage.getItem(name)) {
editor.setValue(localStorage.getItem(name))
} else {
editor.setValue(content)
}
},
//
downloadEditorContent () {
let downLink = document.createElement('a')
downLink.download = 'content.md'
downLink.style.display = 'none'
let blob = new Blob([this.editor.getValue(0)])
downLink.href = URL.createObjectURL(blob)
document.body.appendChild(downLink)
downLink.click()
document.body.removeChild(downLink)
},
// CSS
async customStyle () {
this.showBox = !this.showBox
let flag = await localStorage.getItem('__css_content')
if (!flag) {
this.cssEditor.setValue(DEFAULT_CSS_CONTENT)
}
},
//
copy () {
let clipboardDiv = document.getElementById('output')
clipboardDiv.focus()
window.getSelection().removeAllRanges()
let range = document.createRange()
range.setStartBefore(clipboardDiv.firstChild)
range.setEndAfter(clipboardDiv.lastChild)
window.getSelection().addRange(range)
document.execCommand('copy')
//
this.$notify({
showClose: true,
message: '已复制渲染后的文章到剪贴板,可直接到公众号后台粘贴',
offset: 80,
duration: 1600,
type: 'success'
})
}
},
updated () {
this.$nextTick(() => {
// prettyPrint()
})
}
}
</script>
<style lang="scss">
</style>

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,59 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-unit-jest" target="_blank" rel="noopener">unit-jest</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

41
src/element/index.js Normal file
View File

@ -0,0 +1,41 @@
import Vue from 'vue'
import {
Container,
Header,
Upload,
Tooltip,
Form,
FormItem,
Select,
Option,
ColorPicker,
Switch,
Button,
Main,
Col,
Row,
Dialog,
Loading,
Message
} from 'element-ui'
Vue.use(Container)
Vue.use(Header)
Vue.use(Upload)
Vue.use(Tooltip)
Vue.use(Form)
Vue.use(FormItem)
Vue.use(Select)
Vue.use(Option)
Vue.use(ColorPicker)
Vue.use(Switch)
Vue.use(Button)
Vue.use(Main)
Vue.use(Col)
Vue.use(Row)
Vue.use(Dialog)
Vue.use(Loading)
Vue.use(Message)
Vue.prototype.$loading = Loading.service
Vue.prototype.$message = Message

17
src/main.js Normal file
View File

@ -0,0 +1,17 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import './element'
Vue.use(ElementUI)
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

29
src/router/index.js Normal file
View File

@ -0,0 +1,29 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router

View File

@ -0,0 +1,100 @@
const DEFAULT_CONTENT =
`# 示例文章Google 搜索的即时自动补全功能究竟是如何“工作”的?
> Google 搜索**自动补全功能**的强大相信不少朋友都能感受到它帮助我们更快地补全我们所要输入的搜索关键字那么它怎么知道我们要输入什么内容它又是如何工作的在这篇文章里我们一起来看看
## 使用自动补全
Google 搜索的自动补全功能可以在 Google 搜索应用的大多数位置使用包括 [Google](https://www.google.com/) 主页、适用于 IOS 和 Android 的 Google 应用,我们只需要在 Google 搜索框上开始键入关键字,就可以看到联想词了。
![](https://imgkr.cn-bj.ufileos.com/17ed83bf-e028-4db2-9503-5a3b4e64deee.gif)
在上图示例中我们可以看到输入关键字 \`juej\`Google 搜索会联想到“掘金”、“掘金小册”、“绝句”等等,好处就是,我们无须输入完整的关键字即可轻松完成针对这些 topics 的搜索。
谷歌搜索的自动补全功能对于使用移动设备的用户来说特别有用用户可以轻松在难以键入的小屏幕上完成搜索当然对于移动设备用户和台式机用户而言这都节省了大量的时间根据 Google 官方报告自动补全功能可以减少大约 25% 的打字累积起来预计每天可以节省 200 多年的打字时间是的每天
> 注意本文所提到的**联想词****预测**是同一个意思
## 基于预测而非建议
Google 官方将自动补全功能称之为预测而不是建议为什么呢其实是有充分理由的自动补全功能是为了**帮助用户完成他们打算进行的搜索**而不是建议用户要执行什么搜索
那么Google 是如何确定这些预测其实Google 会根据趋势搜索 [trends](https://trends.google.com/trends/?geo=US) 给到我们这些“预测”。简单来说,哪个热门、哪个搜索频率高,就更可能推给我们。当然,这也与我们当前所处的位置以及我们的搜索历史相关。
另外这些预测也会随着我们键入的关键字的变更而更改例如当我们把键入的关键字从 \`juej\` 更改为 \`juex\` 时,与“掘金”相关的预测会“消失”,同时,与“觉醒”、“决心”相关联的词会出现。
![](https://imgkr.cn-bj.ufileos.com/5b17dc99-606d-42c1-9f86-e09e88aaa822.gif)
## 为什么看不到某些联想词
如果我们在输入某个关键字时看不到联想词那么表明 Google 的算法可能检测到
- 这个关键字不是热门字词
- 搜索的字词太新了我们可能需要等待几天或几周才能看到联想词
- 这是一个侮辱性或敏感字词这个搜索字词违反了 Google 的相关政策更加详细的情况可以了解 [Google 搜索自动补全政策](https://support.google.com/websearch/answer/7368877)。
## 为什么会看到某些不当的联想词
Google 拥有专门设计的系统可以自动捕获不适当的预测结果而不显示出来然而Google 每天需要处理数十亿次搜索这意味着 Google 每天会显示数十亿甚至上百亿条预测再好的系统也可能存在缺陷不正确的预测也可能随时会出现
我们作为 Google 搜索的用户如果认定某条预测违反了相关的搜索自动补全政策可以进行举报反馈点击右下角**举报不当的联想查询**并勾选相关选项即可
![](https://imgkr.cn-bj.ufileos.com/6ca8185d-12c6-4550-bb4e-e49cfbf56db7.gif)
## 如何实现自动补全算法
目前Google 官方似乎并没有公开搜索自动补全的算法实现但是业界在这方面已经有了不少研究
一个好的自动补全器必须是快速的并且在用户键入下一个字符后立即更新联想词列表**自动补全器的核心是一个函数它接受输入的前缀并搜索以给定前缀开头的词汇或语句列表**通常来说只需要返回少量的数目即可
接下来我们先从一个简单且低效的实现开始并在此基础上逐步构建更高效的方法
### 词汇表实现
一个**简单粗暴的实现方式**顺序查找词汇表依次检查每个词汇看它是否以给定的前缀开头
但是此方法需要将前缀与每个词汇进行匹配检查若词汇量较少这种方式可能勉强行得通但是如果词汇量规模较大效率就太低了
一个**更好的实现方式是**让词汇按字典顺序排序借助二分搜索算法可以快速搜索有序词汇表中的前缀由于二分搜索的每一步都会将搜索的范围减半因此总的搜索时间与词汇表中单词数量的对数成正比即时间复杂度是 \`O(log N)\`。二分搜索的性能很好,但有没有更好的实现呢?当然有,往下看。
### 前缀树实现
通常来说许多词汇都以相同的前缀开头比如 \`need\`\`nested\` 都以 \`ne\` 开头,\`seed\`\`speed\` 都以 \`s\` 开头。要是为每个单词分别存储公共前缀似乎很浪费。
![](https://imgkr.cn-bj.ufileos.com/7cc3cf37-040a-420e-8ef9-d05e92c82cfd.png)
前缀树是一种利用公共前缀来加速补全速度的数据结构前缀树在节点树中排列一组单词单词沿着从根节点到叶子节点的路径存储树的层次对应于前缀的字母位置
前缀的补全是顺着前缀定义的路径来查找的例如在上图的前缀树中前缀 \`ne\` 对应于从子节点取左边缘 \`N\` 和唯一边缘 \`E\` 的路径。然后可以通过继续遍历从 \`E\` 节点可以达到的所有叶节点来生成补全列表。在图中,\`ne\` 的补全可以是两个分支:\`-ed\`\`-sted\`。如果在数中找不到由前缀定义的路径,则说明词汇表中不包含以该前缀开头的单词。
### 有限状态自动机(DFA)实现
前缀树可以有效处理公共前缀但是对于其他共享词部分仍会分别存储在每个分支中比如后缀 \`ed\`\`ing\`\`tion\` 在英文单词中特别常见。在上一个例子中,\`e\`\`d\` 分别存放在了每一个分支上。
有没有一种方法可以更加节省存储空间呢有的那就是 DFA
<center>
<img src="https://imgkr.cn-bj.ufileos.com/02bc143e-e1a7-4b3c-bd5d-8d6d39139f0a.png" style="width: 50%;"></center>
在上面的例子中单词 \`need\`\`nested\`\`seed\`\`speed\` 仅由 9 个节点组成,而上一张图中的前缀树包含了 17 个节点。
可以看出最小化前缀树 DFA 可以在很大程度上减少数据结构的大小即使词汇量很大最小化 DFA 通常也适合在内存中存储避免昂贵的磁盘访问是实现快速自动补全的关键
### 一些扩展
上面介绍了如何利用合理的数据结构实现基本的自动补全功能这些数据结构可以通过多种方式进行扩展从而改善用户体验
通常满足特定前缀的词汇可能很多而用户界面上能够显示的却不多我们更希望能显示最常搜索或者最有价值的词汇这通常可以通过为词汇表中的每个单词增加一个代表单词值的**权重** \`weight\`,并且按照权重高低来排序自动补全列表。
- 对于排序后的词汇表来说在词汇表每个元素上增加 \`weight\` 属性并不难;
- 对于前缀树来说 \`weight\` 存储在叶子节点中,也是很简单的一个实现;
- 对于 \`DFA\` 来说,则较为复杂。因为一个叶子节点可以通过多条路径到达。一种解决方案是将权重关联到路径而不是叶子节点。
目前有不少开源库都提供了这个功能比如主流的搜索引擎框架 [Elasticsearch](https://www.elastic.co/products/elasticsearch)、[Solr](https://lucene.apache.org/solr/) 等,基于此,我们可以实现高效而强大的自动补全功能。
#### 推荐阅读
- [阿里又一个 20k+ stars 开源项目诞生恭喜 fastjson](https://mp.weixin.qq.com/s/RNKDCK2KoyeuMeEs6GUrow)
- [刷掉 90% 候选人的互联网大厂海量数据面试题附题解 + 方法总结](https://mp.weixin.qq.com/s/rjGqxUvrEqJNlo09GrT1Dw)
- [好用期待已久的文本块功能究竟如何在 Java 13 中发挥作用](https://mp.weixin.qq.com/s/kalGv5T8AZGxTnLHr2wDsA)
- [2019 GitHub 开源贡献排行榜新鲜出炉微软谷歌领头阿里跻身前 12](https://mp.weixin.qq.com/s/_q812aGD1b9QvZ2WFI0Qgw)
---
欢迎关注我的公众号**Doocs开源社区**原创技术文章第一时间推送
<center>
<img src="https://imgkr.cn-bj.ufileos.com/1092dc45-e817-4bb0-82b0-2b2b4826ccf2.gif" style="width: 100px;">
</center>
`
export default DEFAULT_CONTENT

View File

@ -0,0 +1,191 @@
const WxRenderer = function (opts) {
this.opts = opts
let ENV_STRETCH_IMAGE = true
let footnotes = []
let footnoteIndex = 0
let styleMapping = null
const CODE_FONT_FAMILY = 'Menlo, Operator Mono, Consolas, Monaco, monospace'
let merge = (base, extend) => Object.assign({}, base, extend)
this.buildTheme = themeTpl => {
let mapping = {}
let base = merge(themeTpl.BASE, {
'font-family': this.opts.fonts,
'font-size': this.opts.size
})
let base_block = merge(base, {})
for (let ele in themeTpl.inline) {
if (themeTpl.inline.hasOwnProperty(ele)) {
let style = themeTpl.inline[ele]
if (ele === 'codespan') {
style['font-family'] = CODE_FONT_FAMILY
style['white-space'] = 'normal'
}
mapping[ele] = merge(base, style)
}
}
for (let ele in themeTpl.block) {
if (themeTpl.block.hasOwnProperty(ele)) {
let style = themeTpl.block[ele]
if (ele === 'code') {
style['font-family'] = CODE_FONT_FAMILY
}
mapping[ele] = merge(base_block, style)
}
}
return mapping
}
let getStyles = (tokenName, addition) => {
let arr = []
let dict = styleMapping[tokenName]
if (!dict) return ''
for (const key in dict) {
arr.push(key + ':' + dict[key])
}
return `style="${arr.join(';') + (addition || '')}"`
}
let addFootnote = (title, link) => {
footnotes.push([++footnoteIndex, title, link])
return footnoteIndex
}
this.buildFootnotes = () => {
let footnoteArray = footnotes.map(x => {
if (x[1] === x[2]) {
return `<code style="font-size: 90%; opacity: 0.6;">[${x[0]}]</code>: <i>${x[1]}</i><br/>`
}
return `<code style="font-size: 90%; opacity: 0.6;">[${x[0]}]</code> ${x[1]}: <i>${x[2]}</i><br/>`
})
return `<h4 ${getStyles('h4')}>引用链接</h4><p ${getStyles('footnotes')}>${footnoteArray.join('\n')}</p>`
}
this.buildAddition = () => {
return `
<style>
.preview-wrapper pre::before {
font-family: "SourceSansPro", "HelveticaNeue", Arial, sans-serif;
position: absolute;
top: 0;
right: 0;
color: #ccc;
text-align: center;
font-size: 0.8em;
padding: 5px 10px 0;
line-height: 15px;
height: 15px;
font-weight: 600;
}
</style>
`
}
this.setOptions = newOpts => {
this.opts = merge(this.opts, newOpts)
}
this.hasFootnotes = () => footnotes.length !== 0
this.getRenderer = (status) => {
footnotes = []
footnoteIndex = 0
styleMapping = this.buildTheme(this.opts.theme)
let renderer = new marked.Renderer()
renderer.heading = (text, level) => {
switch (level) {
case 1:
return `<h1 ${getStyles('h1')}>${text}</h1>`
case 2:
return `<h2 ${getStyles('h2')}>${text}</h2>`
case 3:
return `<h3 ${getStyles('h3')}>${text}</h3>`
default:
return `<h4 ${getStyles('h4')}>${text}</h4>`
}
}
renderer.paragraph = text => `<p ${getStyles('p')}>${text}</p>`
renderer.blockquote = text => {
text = text.replace(/<p.*?>/, `<p ${getStyles('blockquote_p')}>`)
return `<blockquote ${getStyles('blockquote')}>${text}</blockquote>`
}
renderer.code = (text, infoString) => {
text = text.replace(/</g, '&lt;')
text = text.replace(/>/g, '&gt;')
let lines = text.split('\n')
let codeLines = []
let numbers = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
codeLines.push(`<code class="prettyprint"><span class="code-snippet_outer">${(line || '<br>')}</span></code>`)
numbers.push('<li></li>')
}
let lang = infoString || ''
return `
<section class="code-snippet__fix code-snippet__js">
<ul class="code-snippet__line-index code-snippet__js">${numbers.join('')}</ul>
<pre class="code-snippet__js" data-lang="${lang}">
${codeLines.join('')}
</pre>
</section>
`
}
renderer.codespan = (text, infoString) => `<code ${getStyles('codespan')}>${text}</code>`
renderer.listitem = text => `<span ${getStyles('listitem')}><span style="margin-right: 10px;"><%s/></span>${text}</span>`
renderer.list = (text, ordered, start) => {
text = text.replace(/<\/*p.*?>/g, '')
let segments = text.split(`<%s/>`)
if (!ordered) {
text = segments.join('•')
return `<p ${getStyles('ul')}>${text}</p>`
}
text = segments[0]
for (let i = 1; i < segments.length; i++) {
text = text + i + '.' + segments[i]
}
return `<p ${getStyles('ol')}>${text}</p>`
}
renderer.image = (href, title, text) => {
let subText = ''
if (text) {
subText = `<figcaption ${getStyles('figcaption')}>${text}</figcaption>`
}
let figureStyles = getStyles('figure')
let imgStyles = getStyles(ENV_STRETCH_IMAGE ? 'image' : 'image_org')
return `<figure ${figureStyles}><img ${imgStyles} src="${href}" title="${title}" alt="${text}"/>${subText}</figure>`
}
renderer.link = (href, title, text) => {
if (href.indexOf('https://mp.weixin.qq.com') === 0) {
return `<a href="${href}" title="${(title || text)}" ${getStyles('wx_link')}>${text}</a>`
} else if (href === text) {
return text
} else {
if (status) {
let ref = addFootnote(title || text, href)
return `<span ${getStyles('link')}>${text}<sup>[${ref}]</sup></span>`
} else {
return text
}
}
}
renderer.strong = text => `<strong ${getStyles('strong')}>${text}</strong>`
renderer.em = text => `<p ${getStyles('p', ';font-style: italic;')}>${text}</p>`
renderer.table = (header, body) => `<section style="padding:0 8px;"><table class="preview-table"><thead ${getStyles('thead')}>${header}</thead><tbody>${body}</tbody></table></section>`
// renderer.tablerow = (text) => `<tr style="">${text}</tr>`;
renderer.tablecell = (text, flags) => `<td ${getStyles('td')}>${text}</td>`
renderer.hr = () => `<hr style="border-style: solid;border-width: 1px 0 0;border-color: rgba(0,0,0,0.1);-webkit-transform-origin: 0 0;-webkit-transform: scale(1, 0.5);transform-origin: 0 0;transform: scale(1, 0.5);">`
return renderer
}
}
export default WxRenderer

View File

@ -0,0 +1,23 @@
// 左右栏同步滚动
$(document).ready(() => {
let timeout;
$('div.CodeMirror-scroll, #preview').on("scroll", function callback() {
clearTimeout(timeout);
let source = $(this),
target = $(source.is("#preview") ? 'div.CodeMirror-scroll' : '#preview');
target.off("scroll");
let source0 = source[0];
let target0 = target[0];
let percentage = source0.scrollTop / (source0.scrollHeight - source0.offsetHeight);
let height = percentage * (target0.scrollHeight - target0.offsetHeight);
target0.scrollTo(0, height);
timeout = setTimeout(() => {
target.on("scroll", callback);
}, 100);
});
});

View File

@ -0,0 +1,42 @@
const DEFAULT_CSS_CONTENT =
`/*
按Ctrl+F可格式化
*/
/* 一级标题样式 */
h1 {
}
/* 二级标题样式 */
h2 {
}
/* 三级标题样式 */
h3 {
}
/* 四级标题样式 */
h4 {
}
/* 图片样式 */
image {
}
/* 引用样式 */
blockquote {
}
/* 引用段落样式 */
blockquote_p {
}
/* 段落样式 */
p {
}
/* 行内代码样式 */
codespan {
}
/* 粗体样式 */
strong {
}
/* 链接样式 */
link {
}
/* 微信链接样式 */
wx_link {
}
`
export default DEFAULT_CSS_CONTENT

View File

@ -0,0 +1,177 @@
let default_theme = {
BASE: {
'text-align': 'left',
'color': '#3f3f3f',
'line-height': '1.75',
},
BASE_BLOCK: {
'margin': '1em 8px'
},
block: {
// 一级标题样式
h1: {
'font-size': '1.2em',
'text-align': 'center',
'font-weight': 'bold',
'display': 'table',
'margin': '2em auto 1em',
'padding': '0 1em',
'border-bottom': '2px solid rgba(0, 152, 116, 0.9)'
},
// 二级标题样式
h2: {
'font-size': '1.2em',
'text-align': 'center',
'font-weight': 'bold',
'display': 'table',
'margin': '4em auto 2em',
'padding': '0 0.2em',
'background': 'rgba(0, 152, 116, 0.9)',
'color': '#fff'
},
// 三级标题样式
h3: {
'font-weight': 'bold',
'font-size': '1.1em',
'margin': '2em 8px 0.75em 0',
'line-height': '1.2',
'padding-left': '8px',
'border-left': '3px solid rgba(0, 152, 116, 0.9)'
},
// 四级标题样式
h4: {
'font-weight': 'bold',
'font-size': '1em',
'margin': '2em 8px 0.5em',
'color': 'rgba(66, 185, 131, 0.9)'
},
// 段落样式
p: {
'margin': '1.5em 8px',
'letter-spacing': '0.1em'
},
// 引用样式
blockquote: {
'font-style': 'normal',
'border-left': 'none',
'padding': '1em',
'border-radius': '4px',
'color': '#FEEEED',
'background': 'rgba(27,31,35,.05)',
'margin': '2em 8px'
},
blockquote_p: {
'letter-spacing': '0.1em',
'color': 'rgb(80, 80, 80)',
'font-family': 'PingFangSC-light, PingFangTC-light, Open Sans, Helvetica Neue, sans-serif',
'font-size': '1em',
'display': 'inline',
},
code: {
'font-size': '80%',
'overflow': 'auto',
'color': '#333',
'background': 'rgb(247, 247, 247)',
'border-radius': '2px',
'padding': '10px',
'line-height': '1.5',
'border': '1px solid rgb(236,236,236)',
'margin': '20px 0',
},
image: {
'border-radius': '4px',
'display': 'block',
'margin': '0.1em auto 0.5em',
'width': '100% !important',
},
image_org: {
'border-radius': '4px',
'display': 'block'
},
ol: {
'margin-left': '0',
'padding-left': '1em'
},
ul: {
'margin-left': '0',
'padding-left': '1em',
'list-style': 'circle'
},
footnotes: {
'margin': '0.5em 8px',
'font-size': '80%'
},
figure: {
'margin': '1.5em 8px',
}
},
inline: {
listitem: {
'text-indent': '-1em',
'display': 'block',
'margin': '0.2em 8px'
},
codespan: {
'font-size': '90%',
'color': '#d14',
'background': 'rgba(27,31,35,.05)',
'padding': '3px 5px',
'border-radius': '4px',
},
link: {
'color': '#576b95'
},
wx_link: {
'color': '#576b95',
'text-decoration': 'none',
},
// 字体加粗样式
strong: {
'color': 'rgba(15, 76, 129, 0.9)',
'font-weight': 'bold',
},
table: {
'border-collapse': 'collapse',
'text-align': 'center',
'margin': '1em 8px'
},
thead: {
'background': 'rgba(0, 0, 0, 0.05)',
'font-weight': 'bold'
},
td: {
'border': '1px solid #dfdfdf',
'padding': '0.25em 0.5em'
},
footnote: {
'font-size': '12px'
},
figcaption: {
'text-align': 'center',
'color': '#888',
'font-size': '0.8em'
}
}
};

166
src/scripts/util.js Normal file
View File

@ -0,0 +1,166 @@
// 设置自定义颜色
export function setColorWithTemplate (template) {
return function (color) {
let custom_theme = JSON.parse(JSON.stringify(template))
custom_theme.block.h1['border-bottom'] = `2px solid ${color}`
custom_theme.block.h2['background'] = color
custom_theme.block.h3['border-left'] = `3px solid ${color}`
custom_theme.block.h4['color'] = color
custom_theme.inline.strong['color'] = color
return custom_theme
}
}
export const setColorWithCustomTemplate = function setColorWithCustomTemplate (
template,
color
) {
let custom_theme = JSON.parse(JSON.stringify(template))
custom_theme.block.h1['border-bottom'] = `2px solid ${color}`
custom_theme.block.h2['background'] = color
custom_theme.block.h3['border-left'] = `3px solid ${color}`
custom_theme.block.h4['color'] = color
custom_theme.inline.strong['color'] = color
return custom_theme
}
// 设置自定义字体大小
export function setFontSizeWithTemplate (template) {
return function (fontSize) {
let custom_theme = JSON.parse(JSON.stringify(template))
custom_theme.block.h1['font-size'] = `${fontSize * 1.14}px`
custom_theme.block.h2['font-size'] = `${fontSize * 1.1}px`
custom_theme.block.h3['font-size'] = `${fontSize}px`
custom_theme.block.h4['font-size'] = `${fontSize}px`
return custom_theme
}
}
export const setColor = setColorWithTemplate(default_theme)
export const setFontSize = setFontSizeWithTemplate(default_theme)
export function customCssWithTemplate (jsonString, color, theme) {
let custom_theme = JSON.parse(JSON.stringify(theme))
// block
custom_theme.block.h1['border-bottom'] = `2px solid ${color}`
custom_theme.block.h2['background'] = color
custom_theme.block.h3['border-left'] = `3px solid ${color}`
custom_theme.block.h4['color'] = color
custom_theme.inline.strong['color'] = color
custom_theme.block.h1 = Object.assign(custom_theme.block.h1, jsonString.h1)
custom_theme.block.h2 = Object.assign(custom_theme.block.h2, jsonString.h2)
custom_theme.block.h3 = Object.assign(custom_theme.block.h3, jsonString.h3)
custom_theme.block.h4 = Object.assign(custom_theme.block.h4, jsonString.h4)
custom_theme.block.p = Object.assign(custom_theme.block.p, jsonString.p)
custom_theme.block.blockquote = Object.assign(
custom_theme.block.blockquote,
jsonString.blockquote
)
custom_theme.block.blockquote_p = Object.assign(
custom_theme.block.blockquote_p,
jsonString.blockquote_p
)
custom_theme.block.image = Object.assign(
custom_theme.block.image,
jsonString.image
)
// inline
custom_theme.inline.strong = Object.assign(
custom_theme.inline.strong,
jsonString.strong
)
custom_theme.inline.codespan = Object.assign(
custom_theme.inline.codespan,
jsonString.codespan
)
custom_theme.inline.link = Object.assign(
custom_theme.inline.link,
jsonString.link
)
custom_theme.inline.wx_link = Object.assign(
custom_theme.inline.wx_link,
jsonString.wx_link
)
return custom_theme
}
/**
* 将CSS形式的字符串转换为JSON
*
* @param {css字符串} css
*/
export function css2json (css) {
// 移除CSS所有注释
while (
(open = css.indexOf('/*')) !== -1 &&
(close = css.indexOf('*/')) !== -1
) {
css = css.substring(0, open) + css.substring(close + 2)
}
// 初始化返回值
let json = {}
while (css.length > 0 && css.indexOf('{') !== -1 && css.indexOf('}') !== -1) {
// 存储第一个左/右花括号的下标
const lbracket = css.indexOf('{')
const rbracket = css.indexOf('}')
// 第一步将声明转换为Object
// `font: 'Times New Roman' 1em; color: #ff0000; margin-top: 1em;`
// ==>
// `{"font": "'Times New Roman' 1em", "color": "#ff0000", "margin-top": "1em"}`
// 辅助方法将array转为object
function toObject (array) {
let ret = {}
array.forEach(e => {
const index = e.indexOf(':')
const property = e.substring(0, index).trim()
const value = e.substring(index + 1).trim()
ret[property] = value
})
return ret
}
// 切割声明块并移除空白符,然后放入数组中
let declarations = css
.substring(lbracket + 1, rbracket)
.split(';')
.map(e => e.trim())
.filter(e => e.length > 0) // 移除所有""空值
// 转为Object对象
declarations = toObject(declarations)
// 第二步:选择器处理,每个选择器会与它对应的声明相关联,如:
// `h1, p#bar {color: red}`
// ==>
// {"h1": {color: red}, "p#bar": {color: red}}
let selectors = css
.substring(0, lbracket)
// 以,切割,并移除空格:`"h1, p#bar, span.foo"` => ["h1", "p#bar", "span.foo"]
.split(',')
.map(selector => selector.trim())
// 迭代赋值
selectors.forEach(selector => {
// 若不存在,则先初始化
if (!json[selector]) json[selector] = {}
// 赋值到JSON
Object.keys(declarations).forEach(key => {
json[selector][key] = declarations[key]
})
})
// 继续下个声明块
css = css.slice(rbracket + 1).trim()
}
// 返回JSON形式的结果串
return json
}

15
src/store/index.js Normal file
View File

@ -0,0 +1,15 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})

5
src/views/About.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

18
src/views/Home.vue Normal file
View File

@ -0,0 +1,18 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'home',
components: {
HelloWorld
}
}
</script>

View File

@ -0,0 +1,12 @@
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
})
expect(wrapper.text()).toMatch(msg)
})
})

9160
yarn.lock Normal file

File diff suppressed because it is too large Load Diff