<template lang="pug"> .editor-markdown .editor-markdown-main .editor-markdown-sidebar //-------------------------------------------------------- //- SIDE TOOLBAR //-------------------------------------------------------- q-btn( icon='mdi-link-variant-plus' padding='sm sm' flat ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertLink') }} q-btn( icon='mdi-image-plus-outline' padding='sm sm' flat @click='insertAssets' ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertAssets') }} q-btn( icon='mdi-code-json' padding='sm sm' flat ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertCodeBlock') }} q-btn( icon='mdi-table-large-plus' padding='sm sm' flat @click='insertTable' ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertTable') }} q-btn( icon='mdi-tab-plus' padding='sm sm' flat ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertTabset') }} q-btn( icon='mdi-chart-multiline' padding='sm sm' flat ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertDiagram') }} q-btn( icon='mdi-book-plus' padding='sm sm' flat ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertFootnote') }} q-btn( icon='mdi-cookie-plus' padding='sm sm' flat ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertEmoji') }} q-btn( icon='mdi-line-scan' padding='sm sm' flat @click='insertHorizontalBar' ) q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertHorizontalBar') }} q-space span.editor-markdown-type Markdown .editor-markdown-mid //-------------------------------------------------------- //- TOP TOOLBAR //-------------------------------------------------------- .editor-markdown-toolbar q-btn( icon='mdi-format-bold' padding='xs sm' flat @click='toggleMarkup({ start: `**` })' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.bold') }} q-btn( icon='mdi-format-italic' padding='xs sm' flat @click='toggleMarkup({ start: `*` })' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.italic') }} q-btn( icon='mdi-format-strikethrough' padding='xs sm' flat @click='toggleMarkup({ start: `~~` })' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.strikethrough') }} q-btn( icon='mdi-format-header-pound' padding='xs sm' flat ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.header') }} q-menu(auto-close) q-list(separator) q-item( v-for='lvl in 6' clickable @click='setHeaderLine(lvl)' ) q-item-section(side) q-icon(:name='`mdi-format-header-` + lvl') q-item-section {{ t('editor.markup.headerLevel', { level: lvl }) }} q-btn( icon='mdi-format-subscript' padding='xs sm' flat @click='toggleMarkup({ start: `~` })' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.subscript') }} q-btn( icon='mdi-format-superscript' padding='xs sm' flat @click='toggleMarkup({ start: `^` })' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.superscript') }} q-btn( icon='mdi-alpha-t-box-outline' padding='xs sm' flat ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.blockquoteAdmonitions') }} q-menu(auto-close) q-list(separator) q-item(clickable, @click='insertBeforeEachLine({ content: `> `})') q-item-section(side) q-icon(name='mdi-format-quote-close') q-item-section {{ t('editor.markup.blockquote') }} q-item(clickable, @click='insertBeforeEachLine({ content: `> `, after: `{.is-info}`})') q-item-section(side) q-icon(name='mdi-information-box', color='blue-7') q-item-section {{ t('editor.markup.admonitionInfo') }} q-item(clickable, @click='insertBeforeEachLine({ content: `> `, after: `{.is-success}`})') q-item-section(side) q-icon(name='mdi-check-circle', color='positive') q-item-section {{ t('editor.markup.admonitionSuccess') }} q-item(clickable, @click='insertBeforeEachLine({ content: `> `, after: `{.is-warning}`})') q-item-section(side) q-icon(name='mdi-alert-box', color='orange') q-item-section {{ t('editor.markup.admonitionWarning') }} q-item(clickable, @click='insertBeforeEachLine({ content: `> `, after: `{.is-danger}`})') q-item-section(side) q-icon(name='mdi-close-box', color='negative') q-item-section {{ t('editor.markup.admonitionDanger') }} q-btn( icon='mdi-format-list-bulleted' padding='xs sm' flat @click='insertBeforeEachLine({ content: `- `})' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.unorderedList') }} q-btn( icon='mdi-format-list-numbered' padding='xs sm' flat @click='insertBeforeEachLine({ content: `1. `})' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.orderedList') }} q-btn( icon='mdi-format-list-checks' padding='xs sm' flat ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.taskList') }} q-menu(auto-close) q-list(separator) q-item(clickable, @click='insertBeforeEachLine({ content: `- [ ] `})') q-item-section(side) q-icon(name='mdi-checkbox-blank-outline') q-item-section {{ t('editor.markup.taskListUnchecked') }} q-item(clickable, @click='insertBeforeEachLine({ content: `- [x] `})') q-item-section(side) q-icon(name='mdi-checkbox-outline') q-item-section {{ t('editor.markup.taskListChecked') }} q-btn( icon='mdi-code-tags' padding='xs sm' flat @click='toggleMarkup({ start: "`" })' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.inlineCode') }} q-btn( icon='mdi-keyboard-variant' padding='xs sm' flat @click='toggleMarkup({ start: `<kbd>`, end: `</kbd>` })' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.markup.keyboardKey') }} q-btn( v-if='!state.previewShown' icon='mdi-eye-arrow-right-outline' padding='xs sm' flat @click='state.previewShown = true' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.togglePreviewPane') }} //-------------------------------------------------------- //- CODEMIRROR //-------------------------------------------------------- .editor-markdown-editor textarea(ref='cmRef') transition(name='editor-markdown-preview') .editor-markdown-preview(v-if='state.previewShown') .editor-markdown-preview-toolbar strong: em {{ t('editor.renderPreview') }} q-separator.q-ml-md.q-mr-sm(vertical, inset) q-btn( icon='mdi-arrow-vertical-lock' padding='xs sm' flat @click='state.previewScrollSync = !state.previewScrollSync' :color='state.previewScrollSync ? `primary` : null' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.toggleScrollSync') }} q-btn( icon='mdi-eye-off-outline' padding='xs sm' flat @click='state.previewShown = false' ) q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.togglePreviewPane') }} .editor-markdown-preview-content.contents(ref='editorPreviewContainer') div( ref='editorPreview' v-html='state.previewHTML' ) </template> <script setup> import { reactive, ref, shallowRef, nextTick, onBeforeMount, onMounted, watch } from 'vue' import { useMeta, useQuasar, setCssVar } from 'quasar' import { useI18n } from 'vue-i18n' import { get, flatten, last, times, startsWith, debounce } from 'lodash-es' import { useEditorStore } from 'src/stores/editor' import { usePageStore } from 'src/stores/page' import { useSiteStore } from 'src/stores/site' // Code Mirror import CodeMirror from 'codemirror' import 'codemirror/lib/codemirror.css' import '../css/codemirror.scss' // Language import 'codemirror/mode/markdown/markdown.js' // Addons import 'codemirror/addon/selection/active-line.js' import 'codemirror/addon/display/fullscreen.js' import 'codemirror/addon/display/fullscreen.css' import 'codemirror/addon/selection/mark-selection.js' import 'codemirror/addon/search/searchcursor.js' import 'codemirror/addon/hint/show-hint.js' import 'codemirror/addon/fold/foldcode.js' import 'codemirror/addon/fold/foldgutter.js' import 'codemirror/addon/fold/foldgutter.css' // Markdown Renderer import { MarkdownRenderer } from 'src/renderers/markdown' // QUASAR const $q = useQuasar() // STORES const editorStore = useEditorStore() const pageStore = usePageStore() const siteStore = useSiteStore() // I18N const { t } = useI18n() // STATE const cm = shallowRef(null) const cmRef = ref(null) const state = reactive({ previewShown: true, previewHTML: '', previewScrollSync: true }) const md = new MarkdownRenderer({}) // Platform detection const CtrlKey = /Mac/.test(navigator.platform) ? 'Cmd' : 'Ctrl' // METHODS function insertAssets () { siteStore.$patch({ overlay: 'FileManager' }) } function insertTable () { siteStore.$patch({ overlay: 'TableEditor' }) } /** * Set current line as header */ function setHeaderLine (lvl) { const curLine = cm.value.doc.getCursor('head').line let lineContent = cm.value.doc.getLine(curLine) const lineLength = lineContent.length if (startsWith(lineContent, '#')) { lineContent = lineContent.replace(/^(#+ )/, '') } lineContent = times(lvl, n => '#').join('') + ' ' + lineContent cm.value.doc.replaceRange(lineContent, { line: curLine, ch: 0 }, { line: curLine, ch: lineLength }) } /** * Get the header lever of the current line */ function getHeaderLevel (cm) { const curLine = cm.doc.getCursor('head').line const lineContent = cm.doc.getLine(curLine) let lvl = 0 const result = lineContent.match(/^(#+) /) if (result) { lvl = get(result, '[1]', '').length } return lvl } /** * Insert content at cursor */ function insertAtCursor ({ content }) { const cursor = cm.value.doc.getCursor('head') cm.value.doc.replaceRange(content, cursor) } /** * Insert content after current line */ function insertAfter ({ content, newLine }) { const curLine = cm.value.doc.getCursor('to').line const lineLength = cm.value.doc.getLine(curLine).length cm.value.doc.replaceRange(newLine ? `\n${content}\n` : content, { line: curLine, ch: lineLength + 1 }) } /** * Insert content before current line */ function insertBeforeEachLine ({ content, after }) { let lines = [] if (!cm.value.doc.somethingSelected()) { lines.push(cm.value.doc.getCursor('head').line) } else { lines = flatten(cm.value.doc.listSelections().map(sl => { const range = Math.abs(sl.anchor.line - sl.head.line) + 1 const lowestLine = (sl.anchor.line > sl.head.line) ? sl.head.line : sl.anchor.line return times(range, l => l + lowestLine) })) } lines.forEach(ln => { let lineContent = cm.value.doc.getLine(ln) const lineLength = lineContent.length if (startsWith(lineContent, content)) { lineContent = lineContent.substring(content.length) } cm.value.doc.replaceRange(content + lineContent, { line: ln, ch: 0 }, { line: ln, ch: lineLength }) }) if (after) { const lastLine = last(lines) cm.value.doc.replaceRange(`\n${after}\n`, { line: lastLine, ch: cm.value.doc.getLine(lastLine).length + 1 }) } } /** * Insert an Horizontal Bar */ function insertHorizontalBar () { insertAfter({ content: '---', newLine: true }) } /** * Toggle Markup at selection */ function toggleMarkup ({ start, end }) { if (!end) { end = start } if (!cm.value.doc.somethingSelected()) { return $q.notify({ type: 'negative', message: t('editor.markup.noSelectionError') }) } cm.value.doc.replaceSelections(cm.value.doc.getSelections().map(s => start + s + end)) } const onCmInput = debounce(processContent, 600) function processContent (newContent) { state.previewHTML = md.render(newContent) } // MOUNTED onMounted(async () => { // -> Setup Editor View editorStore.$patch({ hideSideNav: true }) // -> Initialize CodeMirror cm.value = CodeMirror.fromTextArea(cmRef.value, { tabSize: 2, mode: 'text/markdown', theme: 'wikijs-dark', lineNumbers: true, lineWrapping: true, line: true, styleActiveLine: true, highlightSelectionMatches: { annotateScrollbar: true }, viewportMargin: 50, inputStyle: 'contenteditable', allowDropFileTypes: ['image/jpg', 'image/png', 'image/svg', 'image/jpeg', 'image/gif'], // direction: siteConfig.rtl ? 'rtl' : 'ltr', foldGutter: true, gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] }) cm.value.setValue(pageStore.content) cm.value.on('change', c => { pageStore.$patch({ content: c.getValue() }) onCmInput(pageStore.content) }) cm.value.setSize(null, '100%') // -> Set Keybindings const keyBindings = { 'F11' (c) { c.setOption('fullScreen', !c.getOption('fullScreen')) }, 'Esc' (c) { if (c.getOption('fullScreen')) { c.setOption('fullScreen', false) } }, [`${CtrlKey}-S`] (c) { // save() return false }, [`${CtrlKey}-B`] (c) { toggleMarkup({ start: '**' }) return false }, [`${CtrlKey}-I`] (c) { toggleMarkup({ start: '*' }) return false }, [`${CtrlKey}-Alt-Right`] (c) { let lvl = getHeaderLevel(c) if (lvl >= 6) { lvl = 5 } setHeaderLine(lvl + 1) return false }, [`${CtrlKey}-Alt-Left`] (c) { let lvl = getHeaderLevel(c) if (lvl <= 1) { lvl = 2 } setHeaderLine(lvl - 1) return false } } cm.value.setOption('extraKeys', keyBindings) // this.cm.on('inputRead', this.autocomplete) // // Handle cursor movement // this.cm.on('cursorActivity', c => { // this.positionSync(c) // this.scrollSync(c) // }) // // Handle special paste // this.cm.on('paste', this.onCmPaste) // // Render initial preview // this.processContent(this.$store.get('editor/content')) nextTick(() => { cm.value.refresh() cm.value.focus() }) // this.$root.$on('editorInsert', opts => { // switch (opts.kind) { // case 'IMAGE': // let img = `` // if (opts.align && opts.align !== '') { // img += `{.align-${opts.align}}` // } // this.insertAtCursor({ // content: img // }) // break // case 'BINARY': // this.insertAtCursor({ // content: `[${opts.text}](${opts.path})` // }) // break // case 'DIAGRAM': // const selStartLine = this.cm.getCursor('from').line // const selEndLine = this.cm.getCursor('to').line + 1 // this.cm.doc.replaceSelection('```diagram\n' + opts.text + '\n```\n', 'start') // this.processMarkers(selStartLine, selEndLine) // break // } // }) // // Handle save conflict // this.$root.$on('saveConflict', () => { // this.toggleModal(`editorModalConflict`) // }) // this.$root.$on('overwriteEditorContent', () => { // this.cm.setValue(this.$store.get('editor/content')) // }) }) onBeforeMount(() => { // if (editor.value) { // editor.value.destroy() // } }) </script> <style lang="scss"> $editor-height: calc(100vh - 64px - 94px - 2px); $editor-height-mobile: calc(100vh - 112px - 16px); .editor-markdown { &-main { display: flex; width: 100%; } &-mid { background-color: $dark-6; flex: 1 1 50%; display: block; height: $editor-height; position: relative; border-right: 5px solid $primary; } &-editor { display: block; height: calc(100% - 32px); position: relative; // @include until($tablet) { // height: $editor-height-mobile; // } } &-type { writing-mode: vertical-rl; text-orientation: mixed; padding-bottom: 1rem; color: rgba(255,255,255, .4); font-weight: 500; } &-preview { flex: 1 1 50%; position: relative; height: $editor-height; overflow: hidden; @at-root .body--light & { background-color: $grey-2; } @at-root .body--dark & { background-color: $dark-4; } // @include until($tablet) { // display: none; // } &-enter-active, &-leave-active { transition: max-width .5s ease; max-width: 50vw; .editor-code-preview-content { width: 50vw; overflow:hidden; } } &-enter, &-leave-to { max-width: 0; } &-toolbar { color: $grey-8; height: 32px; display: flex; align-items: center; padding: 0 1rem; @at-root .body--light & { background-color: $grey-3; } @at-root .body--dark & { background-color: $dark-2; color: $grey-6; } } &-content { height: $editor-height; overflow-y: scroll; padding: 1rem; width: calc(100% + 17px); // -ms-overflow-style: none; // &::-webkit-scrollbar { // width: 0px; // background: transparent; // } // @include until($tablet) { // height: $editor-height-mobile; // } > div { outline: none; } p.line { overflow-wrap: break-word; } .tabset { background-color: $teal-7; color: $teal-2 !important; padding: 5px 12px; font-size: 14px; font-weight: 500; border-radius: 5px 0 0 0; font-style: italic; &::after { display: none; } &-header { background-color: $teal-5; color: #FFF !important; padding: 5px 12px; font-size: 14px; font-weight: 500; margin-top: 0 !important; &::after { display: none; } } &-content { border-left: 5px solid $teal-5; background-color: $teal-1; padding: 0 15px 15px; overflow: hidden; @at-root .theme--dark & { background-color: rgba($teal-5, .1); } } } } } &-toolbar { background-color: $primary; border-left: 40px solid darken($primary, 5%); color: #FFF; height: 32px; } &-sidebar { background-color: $dark-4; border-top: 32px solid darken($primary, 10%); color: #FFF; width: 56px; display: flex; flex-direction: column; justify-content: flex-start; align-items: center; padding: 12px 0; } &-sysbar { padding-left: 0; &-locale { background-color: rgba(255,255,255,.25); display:inline-flex; padding: 0 12px; height: 24px; width: 63px; justify-content: center; align-items: center; } } // ========================================== // CODE MIRROR // ========================================== .CodeMirror { height: auto; font-family: 'Roboto Mono', monospace; font-size: .9rem; .cm-header-1 { font-size: 1.5rem; } .cm-header-2 { font-size: 1.25rem; } .cm-header-3 { font-size: 1.15rem; } .cm-header-4 { font-size: 1.1rem; } .cm-header-5 { font-size: 1.05rem; } .cm-header-6 { font-size: 1.025rem; } } .CodeMirror-wrap pre.CodeMirror-line, .CodeMirror-wrap pre.CodeMirror-line-like { word-break: break-word; } .CodeMirror-focused .cm-matchhighlight { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVQI12NgYGBgkKzc8x9CMDAwAAAmhwSbidEoSQAAAABJRU5ErkJggg==); background-position: bottom; background-repeat: repeat-x; } .cm-matchhighlight { background-color: $grey-8; } .CodeMirror-selection-highlight-scrollbar { background-color: $green-6; } } // HINT DROPDOWN .CodeMirror-hints { position: absolute; z-index: 10; overflow: hidden; list-style: none; margin: 0; padding: 1px; box-shadow: 2px 3px 5px rgba(0,0,0,.2); border: 1px solid $grey-7; background: $grey-9; font-family: 'Roboto Mono', monospace; font-size: .9rem; max-height: 150px; overflow-y: auto; min-width: 250px; max-width: 80vw; } .CodeMirror-hint { margin: 0; padding: 0 4px; white-space: pre; color: #FFF; cursor: pointer; } li.CodeMirror-hint-active { background: $blue-5; color: #FFF; } </style>