├── .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 |
31 |
32 |
33 |
34 |
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 |
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 |
13 |
14 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | luogu-markdown-editor
8 |
9 |
10 |
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 |
2 |
3 |
6 |
7 |
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 |
2 |
5 |
8 |
31 |
32 |
62 |
63 |
68 |
69 |
70 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
128 |
129 |
173 |
--------------------------------------------------------------------------------
/src/components/Dialog/DialogForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
56 |
57 |
63 |
--------------------------------------------------------------------------------
/src/components/Dialog/DialogTab.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
16 | {{ category.title }}
17 |
18 |
19 |
20 |
31 |
32 |
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 |
2 |
8 |
9 |
10 |
21 |
22 |
60 |
--------------------------------------------------------------------------------
/src/components/Dialog/FormComponent/DialogFile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ title }}
4 |
8 |
9 |
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 |
2 |
3 | {{ title }}
4 |
5 |
6 |
7 |
8 |
31 |
32 |
40 |
--------------------------------------------------------------------------------
/src/components/Dialog/FormComponent/DialogSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ title }}
4 |
5 | {{ t(option.title) }}
9 |
10 |
11 |
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 ''
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 |
--------------------------------------------------------------------------------