Extend Markdown Editor with Toolbar #134

Merged
gerhardbeck merged 1 commits from monofox/base:feature/ticket_90 into dev 6 months ago
  1. 120
      main/components/MarkdownEditor.vue
  2. 74
      main/components/TemplateEditor.vue
  3. 72
      main/css/main.css
  4. 203
      main/js/markdown-editor.js
  5. 6
      main/js/markdown.js
  6. 7
      main/server/templates/templates.js
  7. 30
      main/views/TemplatesList.vue
  8. 4
      package.json
  9. 20
      yarn.lock

@ -0,0 +1,120 @@
<template>
<div class="markdown-editor-wrapper form-group">
<div class="markdown-editor-toolbar">
<ul>
<li @click="handleButton($event, 'bold')"><format-bold-icon /></li>
<li @click="handleButton($event, 'italic')"><format-italic-icon /></li>
<li @click="handleButton($event, 'underline')"><format-underline-icon /></li>
<li @click="handleButton($event, 'strikethrough')"><format-strike-icon /></li>
<li @click="handleButton($event, 'quote')"><format-quote-icon /></li>
<li class="divider">|</li>
<li @click="handleButton($event, 'h1')"><span>H1</span></li>
<li @click="handleButton($event, 'h2')"><span>H2</span></li>
<li @click="handleButton($event, 'h3')"><span>H3</span></li>
<li @click="handleButton($event, 'h4')"><span>H4</span></li>
<li class="divider">|</li>
<li @click="handleButton($event, 'list')"><format-list-icon /></li>
<li @click="handleButton($event, 'list-numbered')"><format-list-numbered-icon /></li>
<li class="divider">|</li>
<li @click="handleButton($event, 'insert-link')"><insert-link-icon /></li>
<li @click="handleButton($event, 'insert-image')"><insert-image-icon /></li>
<li @click="handleButton($event, 'insert-code')"><insert-code-icon /></li>
<li class="divider">|</li>
<li @click="toggleFullscreen($event)"><fullscreen-icon v-if="!isFullscreen" /><fullscreen-exit-icon v-if="isFullscreen" /></li>
</ul>
</div>
<div class="markdown-editor-pane">
<textarea class="form-control" :value="value" rows="15" @input="handleInput($event)"
v-on:change="contentChanged"></textarea>
<div class="markdown-preview" v-html="renderedMd"></div>
</div>
</div>
</template>
<script>
import md from "Main/js/markdown.js";
import MarkdownEditorFunctions from 'Main/js/markdown-editor.js';
import FormatBoldIcon from 'vue-material-design-icons/FormatBold.vue';
import FormatItalicIcon from 'vue-material-design-icons/FormatItalic.vue';
import FormatUnderlineIcon from 'vue-material-design-icons/FormatUnderline.vue';
import FormatStrikeIcon from 'vue-material-design-icons/FormatStrikethroughVariant.vue';
import FormatQuoteIcon from 'vue-material-design-icons/FormatQuoteClose.vue';
import FormatListIcon from 'vue-material-design-icons/FormatListBulleted.vue';
import FormatListNumberedIcon from 'vue-material-design-icons/FormatListNumbered.vue';
import InsertLinkIcon from 'vue-material-design-icons/Link.vue'
import InsertImageIcon from 'vue-material-design-icons/ImagePlus.vue'
import InsertCodeIcon from 'vue-material-design-icons/CodeTags.vue'
import FullscreenIcon from 'vue-material-design-icons/Fullscreen.vue'
import FullscreenExitIcon from 'vue-material-design-icons/FullscreenExit.vue'
export default {
components: {
FormatBoldIcon,FormatItalicIcon,FormatUnderlineIcon,
FormatListIcon, FormatListNumberedIcon, InsertLinkIcon,
InsertImageIcon, MarkdownEditorFunctions, InsertCodeIcon,
FormatStrikeIcon, FormatQuoteIcon,
FullscreenIcon, FullscreenExitIcon
},
name: 'markdown-editor',
props: ['value'],
data() {
return {
content: this.value,
renderedMd: '',
mde: undefined,
isFullscreen: false
}
},
watch: {
value: function() {
this.updateRender();
this.contentChanged();
}
},
methods: {
updateRender() {
this.renderedMd = md.renderFull(this.value);
},
handleInput (e) {
this.content = e.target.value;
this.$emit('input', this.content)
},
contentChanged() {
this.$emit('modified');
},
toolboxActionDone(newValue) {
this.content = newValue;
this.$el.querySelector('textarea').focus();
this.$emit('input', this.content)
},
toggleFullscreen(e) {
var elem = this.$el.classList;
if (this.isFullscreen) {
elem.remove('markdown-editor-fullscreen');
this.isFullscreen = false;
} else {
elem.add('markdown-editor-fullscreen');
this.isFullscreen = true;
}
},
handleButton(e, btnType) {
if (this.mde === undefined) {
this.mde = new MarkdownEditorFunctions.toolbox(
this.$el.querySelector('textarea'),
this.toolboxActionDone
);
}
if (!this.mde) {
return;
}
this.mde.execute(btnType);
}
},
mounted() {
this.updateRender();
}
}
</script>

@ -7,22 +7,19 @@
<form>
<div class="form-group row">
<label class="col-form-label">{{$t('type')}}</label>
<select class="form-control" v-model="tplModel.type" v-on:change="checkLoadDefault"
<select class="form-control" v-model="value.type" v-on:change="checkLoadDefault"
:class="{'is-invalid':!isTypeValid}">
<option v-for="(type, index) in templateTypes" v-bind:key="index" :value="type">{{type}}</option>
</select>
<label class="col-form-label">{{$t('language')}}</label>
<select class="form-control" v-model="tplModel.language" v-on:change="checkLoadDefault"
:class="{'is-invalid':!$v(tplModel.language,true,$consts.REGEX_LANGUAGE)}">
<select class="form-control" v-model="value.language" v-on:change="checkLoadDefault"
:class="{'is-invalid':!$v(value.language,true,$consts.REGEX_LANGUAGE)}">
<option v-for="(lng, index) in $consts.SUPPORTED_LANGUAGES" v-bind:key="index" :value="lng">{{lng}}</option>
</select>
<label class="col-form-label">{{$t('title')}}</label>
<input class="form-control" v-model="tplModel.title">
<input class="form-control" v-model="value.title">
<label class="col-form-label">{{$t('content')}}</label>
<div class="form-group column markdown-pane">
<textarea class="form-control" v-model="tplModel.content" v-on:change="bodyModified = true;"></textarea>
<div class="markdown-preview" v-html="mdPreview"></div>
</div>
<markdown-editor ref="mdeditor" v-model="value.content" v-on:modified="contentChanged"></markdown-editor>
</div>
</form>
</div>
@ -32,59 +29,46 @@
<script>
import ModalDialog from "./ModalDialog.vue";
import WarningList from 'Main/components/WarningList.vue';
import md from "Main/js/markdown.js";
import MarkdownEditor from 'Main/components/MarkdownEditor.vue';
import consts from '../../consts';
import store from 'Main/js/store.js';
export default {
components: {ModalDialog, WarningList},
components: {ModalDialog, WarningList, MarkdownEditor},
name: 'template-editor',
props: ['tpl'],
props: ['value'],
data: function() {
return {
warningList: [],
consts: consts,
tplWrk: undefined,
isLoading: true,
templateTypes: [],
bodyModified: false
}
},
computed: {
tplModel: {
get() {
if (!this.tplWrk) {
this.tplWrk = this.tpl ? this.tpl : {
type: '',
language: store.lang,
title: '',
content: ''
};
}
return this.tplWrk
},
set(data) {
this.tplWrk = data;
}
},
isTypeValid() {
return this.templateTypes.indexOf(this.tplWrk.type) > -1
return this.templateTypes.indexOf(this.value.type) > -1
},
warningListTranslated() {
return this.warningList.map(c => { c.msg = this.$t(c.msg); return c; });
},
mdPreview() {
return (this.tplWrk.content ? md.renderFull(this.tplWrk.content) : '');
}
},
methods: {
contentChanged: function() {
// Waiting for initial content to be set?
if (this.bodyModified === undefined) {
this.bodyModified = false;
} else {
this.bodyModified = true;
}
},
saveTemplate: function() {
var baseUrl = 'main/admin/templates';
var req = undefined;
if (this.tplWrk._id) {
req = this.$axios.put(baseUrl + '/' + this.tplWrk._id, this.tplWrk);
if (this.value._id) {
req = this.$axios.put(baseUrl + '/' + this.value._id, this.value);
} else {
req = this.$axios.post(baseUrl, this.tplWrk);
req = this.$axios.post(baseUrl, this.value);
}
req.then(response => {
this.$emit('accept');
@ -101,23 +85,23 @@
});
},
checkLoadDefault: function() {
if (this.tplModel.content.length <= 0 || !this.bodyModified) {
if (this.tplModel.language && this.tplModel.type) {
if (this.value.content.length <= 0 || !this.bodyModified) {
if (this.value.language && this.value.type) {
this.$axios.get('main/admin/templates', {params: {
lang: this.tplModel.language,
type: this.tplModel.type,
lang: this.value.language,
type: this.value.type,
include_default: true,
only_default: true
}}).then(response => {
this.bodyModified = undefined;
if (response.status < 400 && response.data && response.data.length > 0) {
this.tplModel.content = response.data[0].content;
this.tplModel.title = response.data[0].title;
this.value.content = response.data[0].content;
this.value.title = response.data[0].title;
// Ensure, we can load again!
} else {
this.tplModel.content = '';
this.tplModel.title = '';
this.value.content = '';
this.value.title = '';
}
this.bodyModified = false;
});
}
}

@ -99,17 +99,75 @@ table.table-striped tr:nth-child(even) { background-color: #ffffff }
/*Source code*/
code { color:#00a060ff; font-size: 100%}
.markdown-pane {
.full-width {
width: 100%;
}
.markdown-editor-fullscreen {
width: 100%;
height: 100vh;
top: 0;
left: 0;
position: fixed;
z-index: 99999;
border: none;
margin: 0 auto;
background-color: #fff;
}
.markdown-editor-fullscreen .markdown-editor-pane {
flex-grow: 1;
}
.markdown-editor-wrapper {
display: flex;
width: 100%;
flex-flow: column;
}
.markdown-editor-toolbar {
min-height: 35px;
padding: 0 8px;
}
.markdown-editor-toolbar > ul > li {
padding: 5px 1px;
margin: 0;
text-align: center;
}
.markdown-editor-toolbar > ul > li > span {
padding: 5px;
transition: all .3s ease-out;
}
.markdown-editor-toolbar > ul > li > span:hover,
.markdown-editor-toolbar > ul > li > span:active {
border: 1px solid #ddd;
background-color: #eee;
}
.markdown-editor-pane {
display: flex;
width: 100%;
flex-flow: row;
}
.markdown-editor-toolbar {
border: 1px solid #ddd;
}
.markdown-editor-toolbar ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.markdown-editor-toolbar li {
display: inline;
cursor: pointer;
}
.markdown-editor-toolbar li.divider {
display: inline-block;
text-indent: -9999px;
margin: 0 5px;
border-right: 1px solid #ddd;
}
.markdown-pane textarea {
.markdown-editor-pane textarea {
flex: 1;
}
.markdown-pane .markdown-preview {
.markdown-editor-pane .markdown-preview {
flex: 1;
margin-left: 10px;
border: 1px solid #ced4da;
padding: 0.375rem 0.75rem;
}
@ -118,6 +176,12 @@ code { color:#00a060ff; font-size: 100%}
max-height: 100vh;
}
blockquote {
border-left: 4px solid #ddd;
padding-left: 20px;
margin-left: 0;
}
@media screen and (max-width: 500px) {
div.jumbotron { padding: 1rem 0.5rem !important;}
div.jumbotron div.container h1 {font-size: 24px}

@ -0,0 +1,203 @@
export default {
toolbox: class {
constructor(textarea, callback=undefined) {
this.textarea = textarea;
this.callback = callback;
};
execute(btnType) {
switch(btnType) {
case 'bold':
this.bold();
break;
case 'italic':
this.italic();
break;
case 'underline':
this.underline();
break;
case 'strikethrough':
this.strikethrough();
break;
case 'quote':
this.quote();
break;
case 'h1':
this.headline(1);
break;
case 'h2':
this.headline(2);
break;
case 'h3':
this.headline(3);
break;
case 'h4':
this.headline(4);
break;
case 'list':
this.list();
break;
case 'list-numbered':
this.listNumbered();
break;
case 'insert-link':
this.link();
break;
case 'insert-image':
this.image();
break;
case 'insert-code':
this.code();
break;
}
};
bold() {
this._format('**');
};
italic() {
this._format('*');
};
underline() {
this._format('_');
};
strikethrough() {
this._format('~~');
};
quote() {
this._prependLine('> ');
};
headline(num = 1) {
this._prependLine('#'.repeat(num) + ' ');
};
list() {
this._prependLine('- ');
}
listNumbered() {
this._prependLine('1. ');
}
code() {
this._format('`');
}
link(prepend='') {
var startPos = this.textarea.selectionStart;
var endPos = this.textarea.selectionEnd;
var data = this.textarea.value.substring(startPos, endPos);
var url = 'https://';
var validUrl = this.isUrl(data);
if (validUrl) {
url = data.replace('(', '\\(').replace(')', '\\)');
} else if (data.length <= 0) {
data = 'Link';
}
data = data.replace('[', '\\[').replace(']', '\\]');
var wrapper = prepend + '[' + data + ']' + '(' + url + ')';
this.textarea.value =
this.textarea.value.substring(0, startPos) +
wrapper +
this.textarea.value.substring(endPos);
if (startPos == endPos) {
// Position at url input
startPos = startPos + prepend.length + 3 + data.length + url.length;
endPos = startPos;
} else if (validUrl) {
// Select data
startPos = startPos + prepend.length + 1;
endPos = startPos + data.length;
} else {
// Select URL
startPos = startPos + prepend.length + 1 + data.length + 2;
endPos = startPos + url.length;
}
// set cursor
this.textarea.focus();
this.textarea.selectionStart = startPos;
this.textarea.selectionEnd = endPos;
// callback
if (this.callback !== undefined) {
this.callback(this.textarea.value);
}
}
isUrl(url) {
const reg = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/;
return reg.test(url);
}
image() {
this.link('!');
}
_format(wrapper) {
var startPos = this.textarea.selectionStart;
var endPos = this.textarea.selectionEnd;
this.textarea.value =
this.textarea.value.substring(0, startPos) +
wrapper +
this.textarea.value.substring(startPos, endPos) +
wrapper +
this.textarea.value.substring(endPos);
// set cursor
this.textarea.focus();
this.textarea.selectionStart = startPos + wrapper.length;
this.textarea.selectionEnd = endPos + wrapper.length;
// callback
if (this.callback !== undefined) {
this.callback(this.textarea.value);
}
};
_prependLine(wrapper) {
var startPos = this.textarea.selectionStart;
var endPos = this.textarea.selectionEnd;
var positionNewLine = this.textarea.value.substring(0, startPos).lastIndexOf('\n');
var toAddStart = 0;
var toAddEnd = 0;
var selectionContent = this.textarea.value.substring(
startPos, endPos
);
var dataAfter = this.textarea.value.substring(endPos);
var prependData = '';
var appendData = '';
if (dataAfter.length > 0) {
dataAfter = '\n\n';
} else {
dataAfter = '\n';
}
if (positionNewLine < 0) {
// check if there is any content in front.
if (this.textarea.value.substring(0, startPos).trim().length > 0) {
prependData = prependData += '\n';
}
}
if (positionNewLine >= 0) {
// check if there is any content in front.
if (this.textarea.value.substring(positionNewLine, startPos).trim().length > 0) {
prependData = prependData += '\n';
}
}
this.textarea.value =
this.textarea.value.substring(0, startPos) +
prependData + wrapper + selectionContent +
dataAfter +
this.textarea.value.substring(endPos);
toAddStart = toAddStart + prependData.length +
wrapper.length;
toAddEnd = toAddStart + appendData.length;
this.textarea.focus();
this.textarea.selectionStart = startPos + toAddStart;
this.textarea.selectionEnd = endPos + toAddEnd;
// callback
if (this.callback !== undefined) {
this.callback(this.textarea.value);
}
}
}
}

@ -1,9 +1,15 @@
import MarkdownIt from 'markdown-it';
import MarkdownItAttrs from 'markdown-it-attrs';
import MarkdownItTexmath from 'markdown-it-texmath';
import MarkdownItUnderline from 'markdown-it-underline';
import MarkdownItBlockquoteCite from 'markdown-it-blockquote-cite';
//init markdown
var markdownIt=new MarkdownIt({breaks:true});
markdownIt.use(MarkdownItAttrs);
markdownIt.use(MarkdownItTexmath);
markdownIt.use(MarkdownItUnderline);
markdownIt.use(MarkdownItBlockquoteCite);
export default {
/*

@ -4,7 +4,14 @@
var md = require('markdown-it')();
const MarkdownItAttrs = require('markdown-it-attrs');
const MarkdownItTexmath = require('markdown-it-texmath');
const MarkdownItUnderline = require('markdown-it-underline');
const MarkdownItBlockquoteCite = require('markdown-it-blockquote-cite').default;
md.use(MarkdownItAttrs);
md.use(MarkdownItTexmath);
md.use(MarkdownItUnderline);
md.use(MarkdownItBlockquoteCite);
var mustache = require('mustache');
const htmlToText = require('html-to-text');

@ -9,7 +9,7 @@
</modal-confirm>
<!-- Edit/Create user -->
<template-editor v-if="showEditor" :tpl="modifyTpl" v-on:accept="editorDone()"
<template-editor v-if="showEditor" v-model="modifyTpl" v-on:accept="editorDone()"
v-on:close="cancelDeletion();"></template-editor>
<!-- Main content displaying template list -->
@ -70,7 +70,13 @@
warningsList:[],
store:store,
isLoading: true,
modifyTpl: undefined,
modifyTpl: {
_id: undefined,
type: '',
language: '',
title: '',
content: ''
},
showEditor: false
}
},
@ -85,7 +91,9 @@
closeModals: function() {
this.showDialogDelete = false;
this.showEditor = false;
this.modifyTpl = undefined;
},
openEditor: function() {
this.showEditor = true;
},
getBooleanText: function(value) {
return this.$t(value ? 'yes' : 'no');
@ -143,12 +151,20 @@
this.closeModals();
},
createTemplateDialog: function() {
this.modifyTpl = undefined;
this.showEditor = true;
this.modifyTpl._id = undefined;
this.modifyTpl.type = '';
this.modifyTpl.language = store.lang;
this.modifyTpl.title = '';
this.modifyTpl.content = '';
this.openEditor();
},
edit: function(tpl) {
this.modifyTpl = tpl;
this.showEditor = true;
this.modifyTpl._id = tpl._id;
this.modifyTpl.type = tpl.type;
this.modifyTpl.language = tpl.language;
this.modifyTpl.title = tpl.title;
this.modifyTpl.content = tpl.content;
this.openEditor();
},
editorDone: function() {
this.closeModals();

@ -35,7 +35,10 @@
"katex": "^0.10.2",
"markdown-it": "^11.0.1",
"markdown-it-attrs": "^3.0.3",
"markdown-it-blockquote-cite": "^0.1.3",
"markdown-it-texmath": "^0.6.9",
"markdown-it-underline": "^1.0.1",
"material-icons": "^1.11.1",
"md5": "^2.3.0",
"mime": "^2.4.7",
"mongoose": "^5.11.11",
@ -51,6 +54,7 @@
"tslib": "^2.2.0",
"vue": "^2.6.12",
"vue-i18n": "^8.22.3",
"vue-material-design-icons": "^5.0.0",
"vue-qrcode": "^0.3.5",
"vue-router": "^3.4.9"
},

@ -4714,11 +4714,21 @@
"resolved" "https://registry.npmjs.org/markdown-it-attrs/-/markdown-it-attrs-3.0.3.tgz"
"version" "3.0.3"
"markdown-it-blockquote-cite@^0.1.3":
"integrity" "sha512-jtJmgyADtwNFwsjwpg8lkUZrgAqCXV7vvFCwkNDrCOybrLufM58LmseG2tMlsSJPXoVwOMhDGxhyvSZe9uv8ZA=="
"resolved" "https://registry.npmjs.org/markdown-it-blockquote-cite/-/markdown-it-blockquote-cite-0.1.3.tgz"
"version" "0.1.3"
"markdown-it-texmath@^0.6.9":
"integrity" "sha512-0Nz9yycwji3r776RCG2lmCsc2bk+lovsaPQklNR3VZMobQ9x45TRMWo2E/J5bejG3qN58MuBKDQWhIA0r1AWVw=="
"resolved" "https://registry.npmjs.org/markdown-it-texmath/-/markdown-it-texmath-0.6.9.tgz"
"version" "0.6.9"
"markdown-it-underline@^1.0.1":
"integrity" "sha512-J597ni39vPHIH1ONVZoDvQKUUXkOqoB93bm6Fc/5Deu6XaWMXrT0xf2m2r9qZCA8dncWJ5V8d5PyGkpmQuy/vg=="
"resolved" "https://registry.npmjs.org/markdown-it-underline/-/markdown-it-underline-1.0.1.tgz"
"version" "1.0.1"
"markdown-it@^11.0.1", "markdown-it@>= 9.0.0 < 12.0.0":
"integrity" "sha512-aU1TzmBKcWNNYvH9pjq6u92BML+Hz3h5S/QpfTFwiQF852pLT+9qHsrhM9JYipkOXZxGn+sGH8oyJE9FD9WezQ=="
"resolved" "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz"
@ -4740,6 +4750,11 @@
"resolve" "^1.4.0"
"stack-trace" "0.0.10"
"material-icons@^1.11.1":
"integrity" "sha512-FCZ3mSoXzQ0UetDfhWpLOPG+duErvt1ALhnNt1TMB7GYHq5hvOqB0HE292qWcuVkCGxej2aARz//dz47zM3Cdg=="
"resolved" "https://registry.npmjs.org/material-icons/-/material-icons-1.11.1.tgz"
"version" "1.11.1"
"md5.js@^1.3.4":
"integrity" "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg=="
"resolved" "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz"
@ -7936,6 +7951,11 @@
"vue-hot-reload-api" "^2.3.0"
"vue-style-loader" "^4.1.0"
"vue-material-design-icons@^5.0.0":
"integrity" "sha512-lYSJFW/TyQqmg7MvUbEB8ua1mwWy/v8qve7QJuA/UWUAXC4/yVUdAm4pg/sM9+k5n7VLckBv6ucOROuGBsGPDQ=="
"resolved" "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.0.0.tgz"
"version" "5.0.0"
"vue-qrcode@^0.3.5":
"integrity" "sha512-wvlSLVEybJtqSBrOxCCgVLXg/rMr+DDQ9giEG8onLWl5wOt5FuZuAxanwWRPOgcm/sRZcSwZIa0q/o+EphblvA=="
"resolved" "https://registry.npmjs.org/vue-qrcode/-/vue-qrcode-0.3.6.tgz"

Loading…
Cancel
Save