├── .after-build.sh ├── .eslintrc.yml ├── .gitignore ├── .npmignore ├── README.md ├── babel.config.js ├── demo.html ├── index.html ├── package.json ├── src ├── Editor.vue ├── FixedMarkdownItVKatex.js ├── MarkdownPalettes.vue ├── browserModule.js ├── components │ ├── Dialog │ │ ├── Dialog.vue │ │ ├── DialogForm.vue │ │ ├── DialogTab.vue │ │ └── FormComponent │ │ │ ├── AbstractDialogFormComponent.js │ │ │ ├── DialogCodeMirror.vue │ │ │ ├── DialogFile.vue │ │ │ ├── DialogFormComponentMap.js │ │ │ ├── DialogInput.vue │ │ │ └── DialogSelect.vue │ ├── PreviewArea.js │ └── ToolBarBtns │ │ ├── btn-bold.js │ │ ├── btn-code.js │ │ ├── btn-fullscreen.js │ │ ├── btn-header.js │ │ ├── btn-hide.js │ │ ├── btn-hr.js │ │ ├── btn-img.js │ │ ├── btn-info.jsx │ │ ├── btn-italic.js │ │ ├── btn-link.js │ │ ├── btn-ol.js │ │ ├── btn-scrollsync.js │ │ ├── btn-strikethrough.js │ │ ├── btn-table.js │ │ ├── btn-ul.js │ │ ├── divider.js │ │ └── toolbarBtn.js ├── main.js ├── mixins │ ├── ActionMixin.js │ ├── InputAreaMixin.js │ ├── PreviewAreaMixin.js │ └── ToolbarMixin.js ├── module.js ├── parsers │ ├── ContentParserFactory.js │ └── InjectLnParser.js └── utils │ ├── DefaultConfig.js │ ├── ImageUploaderFactory.js │ └── i18n.js ├── vue.config.js └── yarn.lock /.after-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd ./dist/ 6 | 7 | if test -e ./MarkdownPalettesBrowser.umd.min.js 8 | then 9 | mv ./MarkdownPalettesBrowser.umd.min.js ./markdown-palettes.min.js 10 | mv ./MarkdownPalettesBrowser.umd.js ./markdown-palettes.js 11 | mv ./MarkdownPalettesBrowser.css ./markdown-palettes.css 12 | 13 | rm -rf ./MarkdownPalettesBrowser.common.js 14 | fi 15 | 16 | if test -e ./MarkdownPalettes.umd.js 17 | then 18 | rm -rf ./MarkdownPalettes.umd.js 19 | rm -rf ./MarkdownPalettes.umd.*.js 20 | fi 21 | 22 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: 3 | - "plugin:vue/recommended" 4 | - "@vue/standard" 5 | - "eslint:recommended" 6 | rules: 7 | indent: [error, 4] 8 | vue/name-property-casing: "off" 9 | vue/html-indent: [error, 4] 10 | parserOptions: 11 | parser: babel-eslint 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # local env files 5 | .env.local 6 | .env.*.local 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown*Palettes 2 | 3 | **Markdown*Palettes** is an open-source Markdown editor for the modern web. 4 | 5 | ## Usage 6 | 7 | We have four bundle schemes. Choose what you prefer. 8 | Note that to use **Markdown*Palettes**, your web page must be in standard mode and use UTF-8 encoding. e.g.: 9 | 10 | ```html 11 | 12 | 13 | ``` 14 | 15 | ### With Build Tools (webpack, rollup, etc) 16 | 17 | First install our npm package: 18 | 19 | ```console 20 | $ yarn add markdown-palettes 21 | ``` 22 | 23 | Since **Markdown*Palettes** is a Vue component, we assume you're familiar with Vue. 24 | 25 | #### Use the ES6 Module 26 | 27 | If you use webpack v2+ or rollup, you can use the ES6 module: 28 | 29 | ```html 30 | 35 | 42 | ``` 43 | 44 | Note that the ES6 module didn't resolve its dependencies and pack them inside. It doesn't matter if you configure your webpack or rollup to resolve into `node_modules`, which is the common practice. As a fallback, you can use the CommonJS module. 45 | 46 | #### Use the CommonJS Module 47 | 48 | Replacing the ES6 'import' statement with CommonJS 'require' function: 49 | 50 | ```javascript 51 | const MarkdownPalettes = require('markdown-palettes') 52 | require('markdown-palettes/dist/MarkdownPalettes.css') 53 | ``` 54 | 55 | The CommonJS module resolved its dependencies and packed them inside. 56 | 57 | ### Without Build Tools (use directly in HTML) 58 | 59 | It's OK to use **Markdown*Palettes** without build tools, if you're not so familiar with Vue and Node.js toolchain. 60 | Copy the items in `dist` directory into your project. 61 | 62 | #### Use with Vue 63 | 64 | This is recommended if you use other Vue components in your HTML page. 65 | 66 | ```html 67 | 68 |
69 | 70 |
71 | 72 | 73 | 80 | ``` 81 | 82 | #### Use without Vue 83 | 84 | This is suitable if you don't have other Vue components in your HTML page or you 'dislike' Vue. Note that this bundle includes Vue inside so it's larger. 85 | 86 | ```html 87 | 88 | 89 |
90 |
91 |
92 | 97 | ``` 98 | 99 | ### External Resources 100 | 101 | By default bundle don't contain syntax highlighting for programming languages. If you use the bundles other than ES6 module, unfortunately you have to build it by yourself to get extra language support. If you use ES6 module, you can easily import them: 102 | 103 | ```javascript 104 | // register languages for hljs 105 | import hljs from 'highlight.js/lib/highlight' 106 | import cpp from 'highlight.js/lib/languages/cpp' 107 | hljs.registerLanguage('cpp', cpp) 108 | 109 | // register languages for CodeMirror 110 | import 'codemirror/mode/clike/clike' 111 | ``` 112 | 113 | ## Documentation 114 | 115 | _TODO_ 116 | 117 | ## Development 118 | 119 | First checkout this repo. 120 | 121 | ```console 122 | $ yarn # install dependencies 123 | $ yarn dev # start dev server 124 | $ yarn build # build dist 125 | ``` 126 | 127 | ### Release 128 | 129 | Please upload `dist` directory to npm together. 130 | 131 | ## Credits 132 | 133 | Developed by @darkflames and @lin_toto of the Luogu Dev Team 134 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // This config will overwrite that in node_modules. 2 | module.exports = { 3 | presets: [ 4 | "@vue/babel-preset-app" 5 | ], 6 | plugins: [ 7 | "@babel/plugin-proposal-object-rest-spread", 8 | "lodash" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | luogu-markdown-editor 8 | 9 | 10 |
11 |
12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-palettes", 3 | "description": "Markdown*Palettes: Markdown editor for the modern web.", 4 | "version": "0.4.13", 5 | "author": "Luogu Dev Team", 6 | "license": "MIT", 7 | "main": "dist/MarkdownPalettes.common.js", 8 | "style": "dist/MarkdownPalettes.css", 9 | "scripts": { 10 | "dev": "vue-cli-service serve --open", 11 | "prod-test": "vue-cli-service serve --open --mode production", 12 | "build": "vue-cli-service build --name MarkdownPalettes --target lib src/module.js && vue-cli-service build --name MarkdownPalettesBrowser --target lib src/browserModule.js --no-clean && ./.after-build.sh && cp demo.html dist/demo.html", 13 | "build-lib": "vue-cli-service build --name MarkdownPalettes --target lib src/module.js && ./.after-build.sh", 14 | "build-browser": "vue-cli-service build --name MarkdownPalettesBrowser --target lib src/browserModule.js && ./.after-build.sh", 15 | "build-dev": "vue-cli-service build --name MarkdownPalettes --target lib src/browserModule.js --mode development", 16 | "lint": "vue-cli-service lint", 17 | "lint-fix": "vue-cli-service lint --fix", 18 | "analyze": "vue-cli-service build --name MarkdownPalettes --target lib src/browserModule.js --report", 19 | "test-dist": "hs dist" 20 | }, 21 | "dependencies": { 22 | "@fortawesome/fontawesome-svg-core": "^1.2.15", 23 | "@fortawesome/free-solid-svg-icons": "^5.7.2", 24 | "@fortawesome/vue-fontawesome": "^0.1.6", 25 | "axios": "^0.18.0", 26 | "codemirror": "^5.39.0", 27 | "global": "^4.3.2", 28 | "katex": "^0.11.1", 29 | "lodash": "4.17.10", 30 | "markdown-it": "^8.4.2", 31 | "markdown-it-v": "^1.2.1", 32 | "markdown-it-v-codemirror-highlighter": "1.0.0", 33 | "vue": "^2.5.11" 34 | }, 35 | "devDependencies": { 36 | "@babel/plugin-proposal-object-rest-spread": "7.3.1", 37 | "@babel/preset-env": "7.3.1", 38 | "@vue/cli-plugin-babel": "^3.4.0", 39 | "@vue/cli-plugin-eslint": "^3.1.5", 40 | "@vue/cli-service": "3.4.0", 41 | "@vue/eslint-config-standard": "^4.0.0", 42 | "babel-plugin-lodash": "^3.3.4", 43 | "http-server": "^0.11.1", 44 | "stylus": "^0.54.5", 45 | "stylus-loader": "^3.0.2", 46 | "vue-template-compiler": "^2.5.16" 47 | }, 48 | "browserslist": [ 49 | "Edge >= 15", 50 | "Firefox >= 53", 51 | "Chrome >= 55", 52 | "Opera >= 42", 53 | "Safari >= 10.1" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/Editor.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /src/FixedMarkdownItVKatex.js: -------------------------------------------------------------------------------- 1 | /* Process inline math */ 2 | /* 3 | Like markdown-it-simplemath, this is a stripped down, simplified version of: 4 | https://github.com/runarberg/markdown-it-math 5 | 6 | It differs in that it takes (a subset of) LaTeX as input and relies on KaTeX 7 | for rendering output. 8 | */ 9 | 10 | /*jslint node: true */ 11 | 'use strict'; 12 | 13 | var katex = require('katex'); 14 | 15 | // Luogu Dev: always recognize as valid delim 16 | function isValidDelim(state, pos) { 17 | return { 18 | can_open: true, 19 | can_close: true 20 | }; 21 | } 22 | 23 | function math_inline(state, silent) { 24 | var start, match, token, res, pos, esc_count; 25 | 26 | if (state.src[state.pos] !== "$") { return false; } 27 | 28 | res = isValidDelim(state, state.pos); 29 | if (!res.can_open) { 30 | if (!silent) { state.pending += "$"; } 31 | state.pos += 1; 32 | return true; 33 | } 34 | 35 | // First check for and bypass all properly escaped delimieters 36 | // This loop will assume that the first leading backtick can not 37 | // be the first character in state.src, which is known since 38 | // we have found an opening delimieter already. 39 | start = state.pos + 1; 40 | match = start; 41 | while ( (match = state.src.indexOf("$", match)) !== -1) { 42 | // Found potential $, look for escapes, pos will point to 43 | // first non escape when complete 44 | pos = match - 1; 45 | while (state.src[pos] === "\\") { pos -= 1; } 46 | 47 | // Even number of escapes, potential closing delimiter found 48 | if ( ((match - pos) % 2) == 1 ) { break; } 49 | match += 1; 50 | } 51 | 52 | // No closing delimter found. Consume $ and continue. 53 | if (match === -1) { 54 | if (!silent) { state.pending += "$"; } 55 | state.pos = start; 56 | return true; 57 | } 58 | 59 | // Check if we have empty content, ie: $$. Do not parse. 60 | if (match - start === 0) { 61 | if (!silent) { state.pending += "$$"; } 62 | state.pos = start + 1; 63 | return true; 64 | } 65 | 66 | // Check for valid closing delimiter 67 | res = isValidDelim(state, match); 68 | if (!res.can_close) { 69 | if (!silent) { state.pending += "$"; } 70 | state.pos = start; 71 | return true; 72 | } 73 | 74 | if (!silent) { 75 | token = state.push('math_inline', 'math', 0); 76 | token.markup = "$"; 77 | token.content = state.src.slice(start, match); 78 | } 79 | 80 | state.pos = match + 1; 81 | return true; 82 | } 83 | 84 | function math_block(state, start, end, silent){ 85 | var firstLine, lastLine, next, lastPos, found = false, token, 86 | pos = state.bMarks[start] + state.tShift[start], 87 | max = state.eMarks[start] 88 | 89 | if(pos + 2 > max){ return false; } 90 | if(state.src.slice(pos,pos+2)!=='$$'){ return false; } 91 | 92 | pos += 2; 93 | firstLine = state.src.slice(pos,max); 94 | 95 | if(silent){ return true; } 96 | if(firstLine.trim().slice(-2)==='$$'){ 97 | // Single line expression 98 | firstLine = firstLine.trim().slice(0, -2); 99 | found = true; 100 | } 101 | 102 | for(next = start; !found; ){ 103 | 104 | next++; 105 | 106 | if(next >= end){ break; } 107 | 108 | pos = state.bMarks[next]+state.tShift[next]; 109 | max = state.eMarks[next]; 110 | 111 | if(pos < max && state.tShift[next] < state.blkIndent){ 112 | // non-empty line with negative indent should stop the list: 113 | break; 114 | } 115 | 116 | if(state.src.slice(pos,max).trim().slice(-2)==='$$'){ 117 | lastPos = state.src.slice(0,max).lastIndexOf('$$'); 118 | lastLine = state.src.slice(pos,lastPos); 119 | found = true; 120 | } 121 | 122 | } 123 | 124 | if (next >= end) return false; 125 | state.line = next + 1; 126 | 127 | token = state.push('math_block', 'math', 0); 128 | token.block = true; 129 | token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') 130 | + state.getLines(start + 1, next, state.tShift[start], true) 131 | + (lastLine && lastLine.trim() ? lastLine : ''); 132 | token.map = [ start, state.line ]; 133 | token.markup = '$$'; 134 | return true; 135 | } 136 | 137 | export default function math_plugin(md, options) { 138 | // Default options 139 | 140 | options = options || {}; 141 | 142 | var escapeHtml = function(html) { 143 | var tagsToReplace = { 144 | '&': '&', 145 | '<': '<', 146 | '>': '>' 147 | }; 148 | return html.replace(/[&<>]/g, function(tag) { 149 | return tagsToReplace[tag] || tag; 150 | }); 151 | }; 152 | 153 | // set KaTeX as the renderer for markdown-it-simplemath 154 | var katexInline = function(latex){ 155 | options.displayMode = false; 156 | try{ 157 | return katex.renderToString(latex, options); 158 | } 159 | catch(error){ 160 | if(options.throwOnError){ console.log(error); } 161 | return escapeHtml(latex); 162 | } 163 | }; 164 | 165 | var inlineRenderer = function(tokens, idx, options, env, { sDom }){ 166 | var html = katexInline(tokens[idx].content) 167 | 168 | sDom.openTag('span', { __html: html }) 169 | sDom.closeTag() 170 | return sDom 171 | }; 172 | 173 | var katexBlock = function(latex){ 174 | options.displayMode = true; 175 | try{ 176 | return katex.renderToString(latex, options); 177 | } 178 | catch(error){ 179 | if(options.throwOnError){ console.log(error); } 180 | return escapeHtml(latex); 181 | } 182 | } 183 | 184 | var blockRenderer = function(tokens, idx, options, env, { sDom }){ 185 | var html = katexBlock(tokens[idx].content); 186 | sDom.openTag('p', { __html: html }) 187 | sDom.closeTag() 188 | sDom.appendText('\n') 189 | return sDom 190 | } 191 | 192 | md.inline.ruler.after('escape', 'math_inline', math_inline); 193 | md.block.ruler.after('blockquote', 'math_block', math_block, { 194 | alt: [ 'paragraph', 'reference', 'blockquote', 'list' ] 195 | }); 196 | md.renderer.rules.math_inline = inlineRenderer; 197 | md.renderer.rules.math_block = blockRenderer; 198 | }; 199 | -------------------------------------------------------------------------------- /src/MarkdownPalettes.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 225 | 226 | 313 | -------------------------------------------------------------------------------- /src/browserModule.js: -------------------------------------------------------------------------------- 1 | import { defaultContentParser, defaultConfig } from './module.js' 2 | import Editor from './Editor' 3 | import Vue from 'vue/dist/vue' 4 | 5 | const MarkdownPalettes = class MarkdownPalettes { 6 | #vueInstance = null 7 | 8 | constructor(el, config = defaultConfig) { 9 | this.#vueInstance = new Vue({ 10 | ...Editor, 11 | data () { 12 | return { 13 | value: MarkdownPalettes.content, 14 | config: config 15 | } 16 | } 17 | }) 18 | 19 | this.#vueInstance.$mount(el) 20 | } 21 | 22 | get content () { 23 | return this.#vueInstance.$data.value 24 | } 25 | 26 | set content(value) { 27 | this.#vueInstance.$data.value = value 28 | } 29 | 30 | static defaultContentParser(content) { 31 | return defaultContentParser(content); 32 | } 33 | } 34 | 35 | window.MarkdownPalettes = MarkdownPalettes 36 | 37 | -------------------------------------------------------------------------------- /src/components/Dialog/Dialog.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 128 | 129 | 173 | -------------------------------------------------------------------------------- /src/components/Dialog/DialogForm.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 56 | 57 | 63 | -------------------------------------------------------------------------------- /src/components/Dialog/DialogTab.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 80 | 81 | 127 | -------------------------------------------------------------------------------- /src/components/Dialog/FormComponent/AbstractDialogFormComponent.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | requestField: { 4 | type: Object, 5 | required: true 6 | }, 7 | fieldValue: {} 8 | }, 9 | model: { 10 | prop: 'fieldValue', 11 | event: 'change' 12 | }, 13 | computed: { 14 | title () { return this.t(this.requestField.title) }, 15 | param () { return this.requestField.param }, 16 | value: { 17 | get () { 18 | return this.fieldValue 19 | }, 20 | set (val) { 21 | this.$emit('change', val) 22 | } 23 | } 24 | }, 25 | inject: ['t'] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Dialog/FormComponent/DialogCodeMirror.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 60 | -------------------------------------------------------------------------------- /src/components/Dialog/FormComponent/DialogFile.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 33 | 34 | 48 | -------------------------------------------------------------------------------- /src/components/Dialog/FormComponent/DialogFormComponentMap.js: -------------------------------------------------------------------------------- 1 | import DialogInput from './DialogInput.vue' 2 | import DialogSelect from './DialogSelect.vue' 3 | import DialogCodeMirror from './DialogCodeMirror.vue' 4 | import DialogFile from './DialogFile.vue' 5 | 6 | export default { 7 | 'dialog-input': DialogInput, 8 | 'dialog-select': DialogSelect, 9 | 'dialog-codemirror': DialogCodeMirror, 10 | 'dialog-file': DialogFile 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Dialog/FormComponent/DialogInput.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 31 | 32 | 40 | -------------------------------------------------------------------------------- /src/components/Dialog/FormComponent/DialogSelect.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 37 | 38 | 46 | -------------------------------------------------------------------------------- /src/components/PreviewArea.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'preview-area', 3 | props: { 4 | content: { 5 | type: [String, Object], 6 | default: [] 7 | } 8 | }, 9 | render (h) { 10 | const { content } = this 11 | if (typeof content === 'string') { 12 | return h('div', { domProps: { innerHTML: content } }) 13 | } else { 14 | return h('div', null, content.toVue(h)) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-bold.js: -------------------------------------------------------------------------------- 1 | import { faBold } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'bold', 5 | icon: faBold, 6 | title: '粗体', 7 | action: { 8 | insert: ['**', '**'] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-code.js: -------------------------------------------------------------------------------- 1 | import { faCode } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'img', 5 | icon: faCode, 6 | title: '插入代码', 7 | action () { 8 | const selection = this.editor.getSelection() 9 | const request = { 10 | title: '插入代码', 11 | type: 'form', 12 | body: [ 13 | { 14 | name: 'lang', 15 | title: '语言类型', 16 | type: 'dialog-select', 17 | param: { 18 | options: [ 19 | { 20 | title: 'c', 21 | value: 'c' 22 | }, 23 | { 24 | title: 'cpp', 25 | value: 'cpp' 26 | }, 27 | { 28 | title: 'pascal', 29 | value: 'pascal' 30 | }, 31 | { 32 | title: 'python', 33 | value: 'python' 34 | }, 35 | { 36 | title: 'java', 37 | value: 'java' 38 | }, 39 | { 40 | title: 'javascript', 41 | value: 'javascript' 42 | }, 43 | { 44 | title: 'php', 45 | value: 'php' 46 | }, 47 | { 48 | title: 'latex', 49 | value: 'latex' 50 | }, 51 | { 52 | title: '未选择', 53 | value: '' 54 | } 55 | ] 56 | }, 57 | default: '' 58 | }, 59 | { 60 | name: 'code', 61 | type: 'dialog-codemirror', 62 | default: selection 63 | } 64 | ], 65 | callback: (data) => { 66 | this.editor.replaceSelection('```' + data.lang + '\n' + 67 | data.code + '\n' + 68 | '```\n') 69 | this.editor.focus() 70 | } 71 | } 72 | this.requestData(request) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-fullscreen.js: -------------------------------------------------------------------------------- 1 | import { faExpandArrowsAlt } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'fullScreen', 5 | icon: faExpandArrowsAlt, 6 | title () { return this.fullScreen ? '取消全屏' : '全屏' }, 7 | action: { 8 | event: 'fullScreen' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-header.js: -------------------------------------------------------------------------------- 1 | export default function headerFactory (level) { 2 | return { 3 | name: 'h' + level, 4 | class: ['mp-icon-header'], 5 | content: 'H' + level, 6 | title: level + '级标题', 7 | action () { 8 | const line = this.editor.getCursor().line 9 | const leading = this.editor.getRange({ line, ch: 0 }, { line, ch: 7 }) 10 | const match = /^(#{1,6} )/.exec(leading) 11 | this.editor.replaceRange(''.padEnd(level, '#') + ' ', { line, ch: 0 }, match === null ? undefined : { line, ch: match[1].length }) 12 | this.editor.focus() 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-hide.js: -------------------------------------------------------------------------------- 1 | import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'hide', 5 | icon () { return this.previewDisplay === 'hide' ? faEyeSlash : faEye }, 6 | title () { return this.previewDisplay === 'hide' ? '显示预览' : '隐藏预览' }, 7 | action: { 8 | event: 'hide' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-hr.js: -------------------------------------------------------------------------------- 1 | import { faMinus } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'hr', 5 | icon: faMinus, 6 | title: '分割线', 7 | action: { 8 | insert: '\n\n------------\n' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-img.js: -------------------------------------------------------------------------------- 1 | import { faImage } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'img', 5 | icon: faImage, 6 | title: '插入图片', 7 | action: { 8 | request: { 9 | title: '插入图片', 10 | type: 'form', 11 | body: [ 12 | { 13 | name: 'address', 14 | title: '图片地址', 15 | type: 'dialog-input', 16 | default: '' 17 | }, 18 | { 19 | name: 'description', 20 | title: '图片描述', 21 | type: 'dialog-input', 22 | default: '' 23 | } 24 | ], 25 | callback (data) { 26 | return '![' + data.description + '](' + data.address + ')' 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-info.jsx: -------------------------------------------------------------------------------- 1 | import { faInfoCircle } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'info', 5 | icon: faInfoCircle, 6 | title: '关于', 7 | action () { 8 | this.openDialog({ 9 | title: '关于', 10 | type: 'component', 11 | body: [{ 12 | functional: true, 13 | render: (h, { injections: {t} }) => 14 |
15 |

16 | { t('Markdown*Palettes') } { t('是一个开源的 Markdown 编辑器,面向现代化网络环境。') } 17 |

18 |

19 | { t('访问 GitHub 项目地址') } 20 |

21 |
, 22 | inject: ['t'] 23 | }] 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-italic.js: -------------------------------------------------------------------------------- 1 | import { faItalic } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'italic', 5 | icon: faItalic, 6 | title: '斜体', 7 | action: { 8 | insert: [' _', '_ '] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-link.js: -------------------------------------------------------------------------------- 1 | import { faLink } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'link', 5 | icon: faLink, 6 | title: '插入链接', 7 | action: { 8 | request: { 9 | title: '插入链接', 10 | type: 'form', 11 | body: [ 12 | { 13 | name: 'url', 14 | title: '链接地址', 15 | type: 'dialog-input', 16 | default: '' 17 | }, 18 | { 19 | name: 'description', 20 | title: '链接标题', 21 | type: 'dialog-input', 22 | default: '' 23 | } 24 | ], 25 | callback (data) { 26 | return '[' + data.description + '](' + data.url + ')' 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-ol.js: -------------------------------------------------------------------------------- 1 | import { faListOl } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'ol', 5 | icon: faListOl, 6 | title: '有序列表', 7 | action: { 8 | insert: '1. ' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-scrollsync.js: -------------------------------------------------------------------------------- 1 | import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'scrollSync', 5 | icon () { return this.scrollSync ? faLock : faLockOpen }, 6 | title () { return this.scrollSync ? '停用滚动同步' : '启用滚动同步' }, 7 | action: { 8 | event: 'scrollSync' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-strikethrough.js: -------------------------------------------------------------------------------- 1 | import { faStrikethrough } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'strikethrough', 5 | icon: faStrikethrough, 6 | title: '删除线', 7 | action: { 8 | insert: ['~~', '~~'] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-table.js: -------------------------------------------------------------------------------- 1 | import { faTable } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'table', 5 | icon: faTable, 6 | title: '插入表格', 7 | action: { 8 | request: { 9 | title: '插入表格', 10 | type: 'form', 11 | body: [ 12 | { 13 | name: 'row', 14 | title: '行数', 15 | type: 'dialog-input', 16 | default: '3' 17 | }, 18 | { 19 | name: 'col', 20 | title: '列数', 21 | type: 'dialog-input', 22 | default: '2' 23 | }, 24 | { 25 | name: 'align', 26 | title: '对齐方式', 27 | type: 'dialog-select', 28 | param: { 29 | options: [ 30 | { 31 | title: '左对齐', 32 | value: 1 33 | }, 34 | { 35 | title: '居中', 36 | value: 2 37 | }, 38 | { 39 | title: '右对齐', 40 | value: 3 41 | } 42 | ] 43 | }, 44 | default: 0 45 | } 46 | ], 47 | callback (data) { 48 | let rowString = '' 49 | for (let i = 0; i < data.col; i++) { 50 | rowString += '| ' 51 | } 52 | rowString += '|' 53 | 54 | let divString = '' 55 | for (let i = 0; i < data.col; i++) { 56 | if (data.align === 1) { divString += '| :----------- ' } else if (data.align === 2) { divString += '| :----------: ' } else if (data.align === 3) { divString += '| -----------: ' } else { divString += '| -----------: ' } 57 | } 58 | 59 | divString += '|' 60 | 61 | let outputString = rowString + '\n' + divString + '\n' 62 | 63 | for (let i = 0; i < data.row - 1; i++) { 64 | outputString += rowString + '\n' 65 | } 66 | 67 | return outputString 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/btn-ul.js: -------------------------------------------------------------------------------- 1 | import { faListUl } from '@fortawesome/free-solid-svg-icons' 2 | 3 | export default { 4 | name: 'ul', 5 | icon: faListUl, 6 | title: '无序列表', 7 | action: { 8 | insert: '- ' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/divider.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: '|' 3 | } 4 | -------------------------------------------------------------------------------- /src/components/ToolBarBtns/toolbarBtn.js: -------------------------------------------------------------------------------- 1 | import BtnBold from './btn-bold' 2 | import BtnItalic from './btn-italic' 3 | import BtnStrikeThrough from './btn-strikethrough' 4 | import Divider from './divider' 5 | import BtnImg from './btn-img' 6 | import BtnLink from './btn-link' 7 | import BtnTable from './btn-table' 8 | import BtnsHeaderFactory from './btn-header' 9 | import BtnUl from './btn-ul' 10 | import BtnOl from './btn-ol' 11 | import BtnHr from './btn-hr' 12 | import BtnCode from './btn-code' 13 | import BtnHide from './btn-hide' 14 | import BtnFullscreen from './btn-fullscreen' 15 | import BtnScrollsync from './btn-scrollsync' 16 | import BtnInfo from './btn-info.jsx' 17 | 18 | export const defaultBtns = [ 19 | BtnBold, 20 | BtnStrikeThrough, 21 | BtnItalic, 22 | BtnHr, 23 | Divider, 24 | BtnsHeaderFactory(1), 25 | BtnsHeaderFactory(2), 26 | BtnsHeaderFactory(3), 27 | BtnsHeaderFactory(4), 28 | BtnsHeaderFactory(5), 29 | BtnsHeaderFactory(6), 30 | Divider, 31 | BtnUl, 32 | BtnOl, 33 | Divider, 34 | BtnImg, 35 | BtnLink, 36 | BtnCode, 37 | BtnTable, 38 | Divider, 39 | BtnHide, 40 | BtnFullscreen, 41 | BtnScrollsync, 42 | Divider, 43 | BtnInfo 44 | ] 45 | 46 | export const defaultSimpleBtns = [ 47 | BtnBold, 48 | BtnStrikeThrough, 49 | BtnItalic, 50 | BtnHr, 51 | BtnsHeaderFactory(1), 52 | BtnsHeaderFactory(2), 53 | BtnsHeaderFactory(3), 54 | BtnUl, 55 | BtnOl, 56 | BtnImg, 57 | BtnLink, 58 | BtnTable, 59 | BtnHide, 60 | BtnFullscreen, 61 | BtnInfo 62 | ] 63 | 64 | function getDefaultBtnsMap () { 65 | const btnsMap = {} 66 | defaultBtns.forEach(function (btn) { 67 | btnsMap[btn.name] = btn 68 | }) 69 | return btnsMap 70 | } 71 | 72 | export function getBtns (toolbarConfig) { 73 | const btnsMap = getDefaultBtnsMap() 74 | const btns = [] 75 | toolbarConfig.forEach(function (btn) { 76 | if (typeof btn === 'object') { btns.push(btn) } else { btns.push(btnsMap[btn]) } 77 | }) 78 | return btns 79 | } 80 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Editor, defaultContentParser } from './module.js' 3 | 4 | // eslint-disable-next-line 5 | const app = new Vue({ 6 | el: '#editor', 7 | functional: true, 8 | render: h => h(Editor) 9 | }) 10 | 11 | window.defaultContentParser = defaultContentParser 12 | -------------------------------------------------------------------------------- /src/mixins/ActionMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data () { 3 | return { 4 | showDialog: false, 5 | dialogRequest: {} 6 | } 7 | }, 8 | methods: { 9 | insertCode (code) { 10 | if (code) { 11 | let insert = this.ensureValue(code) 12 | if (!Array.isArray(insert)) { 13 | insert = [insert, ''] 14 | } 15 | const { line: lineFrom, ch: chFrom } = this.editor.getCursor('from') 16 | const { line: lineTo, ch: chTo } = this.editor.getCursor('to') 17 | const { line: lineHead, ch: chHead } = this.editor.getCursor('head') 18 | const selection = this.editor.getSelection() 19 | this.editor.replaceSelection(insert[0] + selection + insert[1]) 20 | const bfLns = insert[0].split('\n') 21 | const secLns = selection.split('\n') 22 | const newLineFrom = lineFrom + bfLns.length - 1 23 | const newLineTo = newLineFrom + (lineTo - lineFrom) 24 | const newChFrom = bfLns.length === 1 ? chFrom + bfLns[0].length : bfLns[bfLns.length - 1].length 25 | const newChTo = secLns.length === 1 ? newChFrom + selection.length : chTo 26 | const newFrom = { line: newLineFrom, ch: newChFrom } 27 | const newTo = { line: newLineTo, ch: newChTo } 28 | this.editor.setSelection( 29 | lineHead === lineFrom && chHead === chFrom ? newTo : newFrom, 30 | selection !== '' ? lineHead === lineFrom && chHead === chFrom ? newFrom : newTo : undefined 31 | ) 32 | this.editor.focus() 33 | } 34 | }, 35 | closeDialog () { 36 | this.showDialog = false 37 | }, 38 | openDialog (request) { 39 | this.dialogRequest = request 40 | this.showDialog = true 41 | }, 42 | dialogFinish (request) { 43 | if (request.callback) { 44 | this.insertCode(request.callback(request.data)) 45 | } 46 | this.closeDialog() 47 | }, 48 | requestData (request) { 49 | if (this.showDialog) { 50 | return 51 | } 52 | this.openDialog(request) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/mixins/InputAreaMixin.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import CodeMirror from 'codemirror' 3 | import 'codemirror/lib/codemirror.css' 4 | import 'codemirror/mode/gfm/gfm' 5 | 6 | export default { 7 | data () { 8 | return { 9 | inputAreaScrollSynced: false, 10 | inputAreaScrollAnimation: null 11 | } 12 | }, 13 | mounted () { 14 | this.editor = CodeMirror(this.$refs.inputArea, this.editorOption) 15 | this.editor.on('change', cm => { 16 | const code = cm.getValue() 17 | if (this.code !== code) { 18 | this.$emit('input', code) 19 | } 20 | this.code = code 21 | }) 22 | this.setCode(this.value) 23 | 24 | const debouncedEmitScrollSync = _.debounce(this.inputAreaEmitScrollSync, 50, { maxWait: 50 }) 25 | const scrollSync = () => { 26 | if (this.inputAreaScrollSynced) { 27 | this.inputAreaScrollSynced = false 28 | } else { 29 | debouncedEmitScrollSync() 30 | if (this.inputAreaScrollAnimation) { 31 | this.inputAreaScrollAnimation.cancel() 32 | } 33 | } 34 | } 35 | this.editor.on('cursorActivity', scrollSync) 36 | this.editor.on('scroll', scrollSync) 37 | }, 38 | methods: { 39 | inputAreaEmitScrollSync () { 40 | if (!this.scrollSync) return 41 | const cursorLine = this.editor.getCursor().line 42 | const scrollInfo = this.editor.getScrollInfo('local') 43 | const viewport = { 44 | from: this.editor.lineAtHeight(scrollInfo.top, 'local'), 45 | to: this.editor.lineAtHeight(scrollInfo.top + scrollInfo.clientHeight, 'local') + 1 46 | } 47 | const linesOffset = [] 48 | for (let line = viewport.from; line < viewport.to; ++line) { 49 | const coords = this.editor.cursorCoords({ line, ch: 0 }, 'local') 50 | linesOffset[line] = { 51 | top: coords.top - scrollInfo.top, 52 | bottom: coords.bottom - scrollInfo.top 53 | } 54 | } 55 | const event = { 56 | cursorLine, scrollInfo, viewport, linesOffset 57 | } 58 | this.doScrollSync('inputArea', event) 59 | }, 60 | inputAreaUpdateScrollSync ({ scrollInfo, linesOffset }) { 61 | if (!linesOffset.length) { 62 | return 63 | } 64 | const scrollMid = scrollInfo.height / 2 65 | let syncLine 66 | linesOffset.forEach(({ top }, line) => { 67 | if (typeof syncLine === 'undefined' || Math.abs(top - scrollMid) < Math.abs(linesOffset[syncLine].top - scrollMid)) { 68 | syncLine = line 69 | } 70 | }) 71 | const scrollTop = this.editor.getScrollInfo().top 72 | const editorLineOffset = this.editor.heightAtLine(syncLine, 'local') - scrollTop 73 | if (this.inputAreaScrollAnimation) { 74 | this.inputAreaScrollAnimation.cancel() 75 | } 76 | let animationCancelled = false 77 | let animationSkipFrame = false 78 | const animationFrom = scrollTop 79 | const animationTo = scrollTop + editorLineOffset - linesOffset[syncLine].top 80 | const animationStartTime = Date.now() 81 | const animationDuration = 200 82 | const animationFrameCallback = () => { 83 | if (animationSkipFrame) { 84 | // skip frame so that user can scroll to interrupt animation 85 | requestAnimationFrame(animationFrameCallback) 86 | animationSkipFrame = false 87 | } else if (!animationCancelled) { 88 | const currentTime = Date.now() 89 | const precent = (currentTime - animationStartTime) / animationDuration 90 | this.inputAreaScrollSynced = true 91 | if (precent >= 1) { 92 | this.editor.scrollTo(null, animationTo) 93 | } else { 94 | this.editor.scrollTo(null, (animationTo - animationFrom) * precent + animationFrom) 95 | requestAnimationFrame(animationFrameCallback) 96 | animationSkipFrame = true 97 | } 98 | } 99 | } 100 | animationFrameCallback() 101 | this.inputAreaScrollAnimation = { 102 | cancel () { 103 | animationCancelled = true 104 | } 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/mixins/PreviewAreaMixin.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import CodeMirror from 'codemirror' 3 | 4 | export default { 5 | data () { 6 | return { 7 | previewAreaLinesBounding: [], 8 | previewAreaScrollSynced: false, 9 | codemirrorLoadedModes: [], 10 | codemirrorFailedModes: [], 11 | codemirrorLoadingModes: [], 12 | codemirrorNeededModes: [], 13 | previewContent: '' 14 | } 15 | }, 16 | created () { 17 | this.debouncedPreviewAreaEmitScrollSync = _.debounce(this.previewAreaEmitScrollSync, 100, { maxWait: 100 }) 18 | }, 19 | watch: { 20 | previewContent () { 21 | if (this.scrollSync) { 22 | this.$nextTick(this.previewAreaMaintainLinesBounding) 23 | } else { 24 | this.previewAreaLinesBounding = [] 25 | } 26 | if (typeof val !== 'string') { 27 | this.codemirrorLoadLeakLangs() 28 | } 29 | }, 30 | scrollSync (val) { 31 | if (val && this.previewAreaLinesBounding.length === 0) { 32 | this.previewAreaMaintainLinesBounding() 33 | } 34 | }, 35 | contentParser () { this.previewContentReparse() }, 36 | code () { this.previewContentReparse() }, 37 | codemirrorNeededModes (modes) { 38 | if (modes.length) { 39 | for (const mode of modes) { 40 | const loadingModes = this.codemirrorLoadingModes 41 | loadingModes.push(mode) 42 | modes.splice(modes.indexOf(mode), 1) 43 | import(`codemirror/mode/${mode}/${mode}.js`) 44 | .then(() => void this.codemirrorLoadedModes.push(mode), 45 | () => void this.codemirrorFailedModes.push(mode)) 46 | .finally(() => void loadingModes.splice(modes.indexOf(mode), 1)) 47 | } 48 | } 49 | }, 50 | codemirrorLoadedModes () { this.previewContentReparse() } 51 | }, 52 | methods: { 53 | previewAreaMaintainLinesBounding () { 54 | this.previewAreaUpdateLinesBounding() 55 | const previewArea = this.$refs.previewArea 56 | Array.from(previewArea.getElementsByTagName('img')).forEach(img => { 57 | // img will become "bigger" when it's loaded 58 | img.addEventListener('load', this.previewAreaUpdateLinesBounding) 59 | }) 60 | }, 61 | previewAreaUpdateLinesBounding () { 62 | const previewArea = this.$refs.previewArea 63 | const previewContent = this.$refs.previewContent.$el 64 | const outerTop = previewContent.getBoundingClientRect().top 65 | this.previewAreaLinesBounding = [] 66 | previewArea.querySelectorAll('[data-line]').forEach(lineE => { 67 | const bounding = lineE.getBoundingClientRect() 68 | const line = parseInt(lineE.dataset.line) 69 | this.previewAreaLinesBounding.push({ 70 | line, 71 | top: bounding.top - outerTop, 72 | bottom: bounding.bottom - outerTop 73 | }) 74 | }) 75 | _.sortBy(this.previewAreaLinesBounding, [b => b.top]) 76 | }, 77 | previewAreaScroll () { 78 | if (this.previewAreaScrollSynced) { 79 | this.previewAreaScrollSynced = false 80 | } else { 81 | this.debouncedPreviewAreaEmitScrollSync() 82 | } 83 | }, 84 | previewAreaEmitScrollSync () { 85 | if (!this.scrollSync) return 86 | const previewArea = this.$refs.previewArea 87 | const scrollTop = previewArea.scrollTop 88 | const scrollBottom = scrollTop + previewArea.getBoundingClientRect().height 89 | const lowerLinePos = _.sortedIndexBy(this.previewAreaLinesBounding, { top: scrollTop }, b => b.top) 90 | const upperLinePos = _.sortedIndexBy(this.previewAreaLinesBounding, { top: scrollBottom }, b => b.top) 91 | const linesOffset = [] 92 | for (let linePos = lowerLinePos; linePos < upperLinePos; ++linePos) { 93 | const line = this.previewAreaLinesBounding[linePos].line 94 | linesOffset[line] = { 95 | top: this.previewAreaLinesBounding[linePos].top - scrollTop, 96 | bottom: this.previewAreaLinesBounding[linePos].bottom - scrollTop 97 | } 98 | } 99 | this.doScrollSync('previewArea', { 100 | scrollInfo: { 101 | top: scrollTop, 102 | bottom: scrollBottom, 103 | height: scrollBottom - scrollTop 104 | }, 105 | linesOffset: linesOffset 106 | }) 107 | }, 108 | previewAreaUpdateScrollSync ({ cursorLine, scrollInfo, viewport, linesOffset }) { 109 | const previewArea = this.$refs.previewArea 110 | const offset = ele => { 111 | const eleRect = ele.getBoundingClientRect() 112 | const araRect = previewArea.getBoundingClientRect() 113 | return { 114 | top: eleRect.top - araRect.top, 115 | bottom: eleRect.bottom - araRect.top 116 | } 117 | } 118 | const getLine = line => previewArea.querySelector(`[data-line="${line}"]`) 119 | const calcScroll = line => { 120 | const lineE = getLine(line) 121 | const previewLineOffset = offset(lineE) 122 | const editorLineOffset = linesOffset[line] 123 | if (typeof editorLineOffset === 'undefined') { 124 | return NaN 125 | } 126 | let scroll = previewLineOffset.top - editorLineOffset.top 127 | const tagName = lineE.tagName 128 | if (/^h\d$/i.test(tagName)) { 129 | scroll = previewLineOffset.bottom - editorLineOffset.bottom 130 | } 131 | if (previewLineOffset.top - scroll < 0) { 132 | scroll = -previewLineOffset.top 133 | } 134 | return scroll 135 | } 136 | 137 | const syncLine = _.inRange(cursorLine, viewport.from, viewport.to) ? cursorLine : Math.round((viewport.from + viewport.to) / 2) 138 | let lowerLine, upperLine 139 | for (lowerLine = syncLine; lowerLine >= viewport.from; --lowerLine) { 140 | if (getLine(lowerLine) !== null) { 141 | break 142 | } 143 | } 144 | for (upperLine = syncLine; upperLine < viewport.to; ++upperLine) { 145 | if (getLine(upperLine) !== null) { 146 | break 147 | } 148 | } 149 | const lowerLineE = getLine(lowerLine) 150 | const upperLineE = getLine(upperLine) 151 | const hasLowerLine = lowerLineE !== null 152 | const hasUpperLine = upperLineE !== null 153 | if (!hasLowerLine && !hasUpperLine) { 154 | // can't sync 155 | return 156 | } 157 | let chosenLine 158 | if (!hasLowerLine) { 159 | chosenLine = upperLine 160 | } else if (!hasUpperLine) { 161 | chosenLine = lowerLine 162 | } else if (lowerLine === upperLine) { 163 | chosenLine = lowerLine 164 | } else { 165 | const lowerScroll = calcScroll(lowerLine) 166 | const upperScroll = calcScroll(upperLine) 167 | if (Math.abs(lowerScroll) < Math.abs(upperScroll) || isNaN(upperScroll)) { 168 | chosenLine = lowerLine 169 | } else { 170 | chosenLine = upperLine 171 | } 172 | } 173 | const scroll = calcScroll(chosenLine) 174 | if (!isNaN(scroll)) { 175 | this.previewAreaScrollSynced = true 176 | previewArea.scrollTop += scroll 177 | } 178 | }, 179 | previewContentReparse () { 180 | this.previewContent = this.contentParser(this.code) 181 | }, 182 | codemirrorLoadLeakLangs () { 183 | const usedLangSet = new Set() 184 | function dfs (node) { 185 | if (node.tagName === 'code') { 186 | const match = /(?:^|\s)language-(.+)(?:$|\s)/.exec(node.attrs['class']) 187 | if (match) { 188 | usedLangSet.add(match[1]) 189 | } 190 | } else if (node.children) { 191 | node.children.forEach(dfs) 192 | } 193 | } 194 | dfs(this.previewContent.currentNode) 195 | const usedModeSet = new Set([...usedLangSet].map(lang => CodeMirror.findModeByName(lang)).filter(x => x).map(({ mode }) => mode)); 196 | ([...usedModeSet]).filter(mode => 197 | !this.codemirrorLoadedModes.includes(mode) && 198 | !this.codemirrorFailedModes.includes(mode) && 199 | !this.codemirrorLoadingModes.includes(mode) && 200 | !this.codemirrorNeededModes.includes(mode)) 201 | .forEach(mode => void this.codemirrorNeededModes.push(mode)) 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/mixins/ToolbarMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | toolbarAction (btn) { 4 | if (typeof btn.action === 'function') { 5 | btn.action.call(this) 6 | } else { 7 | this.toolbarHandleActionLegacy(btn.action) 8 | } 9 | }, 10 | toolbarHandleActionLegacy (action) { 11 | if (action.event) { 12 | this.toolbarHandleEventLegacy(action.event) 13 | } else if (action.insert) { 14 | this.insertCode(action.insert) 15 | } else if (action.request) { 16 | this.requestData(action.request) 17 | } 18 | }, 19 | toolbarHandleEventLegacy (event) { 20 | if (event === 'hide') { 21 | if (this.previewDisplay === 'normal') { 22 | if (window.screen.width > 768) { 23 | this.previewDisplay = 'hide' 24 | } else { 25 | this.previewDisplay = 'full' 26 | } 27 | } else { 28 | this.previewDisplay = 'normal' 29 | } 30 | } 31 | if (event === 'fullScreen') { 32 | this.fullScreen = !this.fullScreen 33 | } 34 | if (event === 'scrollSync') { 35 | this.scrollSync = !this.scrollSync 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/module.js: -------------------------------------------------------------------------------- 1 | import Editor from './MarkdownPalettes.vue' 2 | 3 | import { contentParserFactory } from './parsers/ContentParserFactory' 4 | import { defaultConfig } from './utils/DefaultConfig' 5 | 6 | export default Editor 7 | 8 | const parser = contentParserFactory(defaultConfig.parsers) 9 | const defaultContentParser = (md) => (parser(md).toHTML()) 10 | 11 | export { Editor, defaultContentParser, defaultConfig } 12 | -------------------------------------------------------------------------------- /src/parsers/ContentParserFactory.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it' 2 | 3 | function contentParserFactory (parsers) { 4 | let converter = MarkdownIt() 5 | parsers.forEach(parser => { 6 | converter = converter.use(parser) 7 | }) 8 | return content => converter.render(content) 9 | } 10 | 11 | export { contentParserFactory } 12 | -------------------------------------------------------------------------------- /src/parsers/InjectLnParser.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. 3 | 4 | Permission is hereby granted, free of charge, to any person 5 | obtaining a copy of this software and associated documentation 6 | files (the "Software"), to deal in the Software without 7 | restriction, including without limitation the rights to use, 8 | copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 20 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 21 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 23 | OTHER DEALINGS IN THE SOFTWARE. 24 | */ 25 | 26 | export default mdHtml => { 27 | function injectLineNumbers (tokens, idx, options, env, slf) { 28 | let line 29 | if (tokens[idx].map && tokens[idx].level === 0) { 30 | line = tokens[idx].map[0] 31 | tokens[idx].attrSet('data-line', String(line)) 32 | } 33 | return slf.renderToken(tokens, idx, options, env, slf) 34 | } 35 | 36 | mdHtml.renderer.rules.paragraph_open = mdHtml.renderer.rules.heading_open = injectLineNumbers 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/DefaultConfig.js: -------------------------------------------------------------------------------- 1 | // import parsers 2 | import MarkdownItV from 'markdown-it-v' 3 | import MarkdownItVCodemirrorHighlighter from 'markdown-it-v-codemirror-highlighter/dist/browserIndex.common.js' 4 | import MarkdownItVKatex from '../FixedMarkdownItVKatex' 5 | 6 | // import styles 7 | import 'katex/dist/katex.css' 8 | 9 | // import toolbar buttons 10 | import { defaultBtns, defaultSimpleBtns, getBtns } from '../components/ToolBarBtns/toolbarBtn' 11 | 12 | import _ from 'lodash' 13 | 14 | function mixin (dest, src) { 15 | for (const [key, value] of Object.entries(src)) { 16 | if (typeof dest[key] === 'object' && !Array.isArray(dest[key])) { 17 | mixin(dest[key], value) 18 | } else { 19 | dest[key] = value 20 | } 21 | } 22 | return dest 23 | } 24 | 25 | export const defaultConfig = { 26 | previewDisplay: 'normal', 27 | fullScreen: false, 28 | parsers: [ 29 | MarkdownItV, 30 | MarkdownItVCodemirrorHighlighter, 31 | MarkdownItVKatex 32 | ], 33 | toolbarConfig: defaultBtns, 34 | bigScreenToolbarConfig: defaultBtns, 35 | smallScreenToolbarConfig: defaultSimpleBtns, 36 | editorOption: { 37 | mode: 'gfm', 38 | lineNumbers: true, 39 | lineWrapping: true 40 | }, 41 | scrollSync: true 42 | } 43 | 44 | export function getConfig (config) { 45 | const mergedConfig = mixin(_.cloneDeep(defaultConfig), config) 46 | const processedConfig = {} 47 | for (const key of Object.keys(defaultConfig)) { 48 | processedConfig[key] = mergedConfig[key] 49 | } 50 | processedConfig.toolbarConfig = getBtns(processedConfig.toolbarConfig) 51 | return processedConfig 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/ImageUploaderFactory.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | function ImageUploaderFactory (uploadURL, fieldName) { 4 | return async (file) => { 5 | let form = new FormData() 6 | form.append(fieldName, file, file.name) 7 | 8 | return axios.post(uploadURL, form) 9 | } 10 | } 11 | 12 | export { ImageUploaderFactory } 13 | -------------------------------------------------------------------------------- /src/utils/i18n.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export const dictionary = new Map([ 4 | [/^en/, new Map([ 5 | ['确定', 'OK'], 6 | ['取消', 'Cancel'], 7 | ['粗体', 'Bold'], 8 | ['插入代码', 'Insert code'], 9 | ['语言类型', 'Language'], 10 | ['未选择', 'Unselected'], 11 | ['全屏', 'Fullscreen'], 12 | ['取消全屏', 'Exit fullscreen'], 13 | [/^(\d)级标题$/, (text, match) => `Header ${match[1]}`], 14 | ['隐藏预览', 'Hide preview'], 15 | ['显示预览', 'Show preview'], 16 | ['分割线', 'Horizontal rule'], 17 | ['插入图片', 'Insert image'], 18 | ['图片地址', 'Image URL'], 19 | ['图片描述', 'Image title'], 20 | ['关于', 'About'], 21 | ['斜体', 'Italic'], 22 | ['插入链接', 'Insert hyperlink'], 23 | ['链接地址', 'Link URL'], 24 | ['链接标题', 'Link title'], 25 | ['有序列表', 'Ordered list'], 26 | ['停用滚动同步', 'Disable scroll sync'], 27 | ['启用滚动同步', 'Enable scroll sync'], 28 | ['删除线', 'Strikeout'], 29 | ['插入表格', 'Insert table'], 30 | ['行数', 'Number of rows'], 31 | ['列数', 'Number of columns'], 32 | ['对齐方式', 'Alignment'], 33 | ['左对齐', 'Flush left'], 34 | ['居中', 'Centered'], 35 | ['右对齐', 'Flush right'], 36 | ['无序列表', 'Unordered list'], 37 | ['是一个开源的 Markdown 编辑器,面向现代化网络环境。', 'is an open-source Markdown editor for the modern web.'], 38 | ['访问 GitHub 项目地址', 'View it on GitHub'] 39 | ])], 40 | [/^zh/, new Map([ 41 | [/^(\d)级标题$/, (text, match) => `${'一二三四五六'[match[1] - 1]}级标题`] 42 | ])] 43 | ]) 44 | 45 | export function getText (text) { 46 | for (const language of navigator.languages) { 47 | for (const [langReg, textMapping] of dictionary) { 48 | if (langReg.test(language)) { 49 | let result 50 | let match = null 51 | if (textMapping.has(text)) { 52 | result = textMapping.get(text) 53 | } else { 54 | for (const [re, res] of textMapping) { 55 | if (_.isRegExp(re)) { 56 | match = re.exec(text) 57 | if (match !== null) { 58 | result = res 59 | break 60 | } 61 | } 62 | } 63 | } 64 | if (typeof result !== 'undefined') { 65 | if (typeof result === 'function') { 66 | result = result(text, match === null ? undefined : match) 67 | } 68 | return result 69 | } else { 70 | return text 71 | } 72 | } 73 | } 74 | } 75 | return text 76 | } 77 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const IgnorePlugin = webpack.IgnorePlugin 3 | 4 | module.exports = { 5 | pages: { 6 | index: { 7 | entry: 'src/main.js', 8 | template: 'index.html', 9 | filename: 'index.html' 10 | } 11 | }, 12 | configureWebpack: { 13 | plugins: [ 14 | new IgnorePlugin(/^css-tree$/), 15 | new webpack.optimize.LimitChunkCountPlugin({ 16 | maxChunks: 1, 17 | }), 18 | ] 19 | }, 20 | transpileDependencies: [ 21 | ], 22 | productionSourceMap: false 23 | } 24 | --------------------------------------------------------------------------------