Browse Source

feat: markdown preview sync + code blocks syntax highlighting

NGPixel 2 years ago
parent
commit
59f6b6fedc

+ 30 - 30
server/tasks/workers/render-page.mjs

@@ -1,5 +1,5 @@
 import { get, has, isEmpty, reduce, times, toSafeInteger } from 'lodash-es'
-import cheerio from 'cheerio'
+import * as cheerio from 'cheerio'
 
 export async function task ({ payload }) {
   WIKI.logger.info(`Rendering page ${payload.id}...`)
@@ -36,37 +36,37 @@ export async function task ({ payload }) {
     }
 
     // Parse TOC
-    const $ = cheerio.load(output)
-    let isStrict = $('h1').length > 0 // <- Allows for documents using H2 as top level
+    // const $ = cheerio.load(output)
+    // let isStrict = $('h1').length > 0 // <- Allows for documents using H2 as top level
     let toc = { root: [] }
 
-    $('h1,h2,h3,h4,h5,h6').each((idx, el) => {
-      const depth = toSafeInteger(el.name.substring(1)) - (isStrict ? 1 : 2)
-      let leafPathError = false
-
-      const leafPath = reduce(times(depth), (curPath, curIdx) => {
-        if (has(toc, curPath)) {
-          const lastLeafIdx = _.get(toc, curPath).length - 1
-          if (lastLeafIdx >= 0) {
-            curPath = `${curPath}[${lastLeafIdx}].children`
-          } else {
-            leafPathError = true
-          }
-        }
-        return curPath
-      }, 'root')
-
-      if (leafPathError) { return }
-
-      const leafSlug = $('.toc-anchor', el).first().attr('href')
-      $('.toc-anchor', el).remove()
-
-      get(toc, leafPath).push({
-        label: $(el).text().trim(),
-        key: leafSlug.substring(1),
-        children: []
-      })
-    })
+    // $('h1,h2,h3,h4,h5,h6').each((idx, el) => {
+    //   const depth = toSafeInteger(el.name.substring(1)) - (isStrict ? 1 : 2)
+    //   let leafPathError = false
+
+    //   const leafPath = reduce(times(depth), (curPath, curIdx) => {
+    //     if (has(toc, curPath)) {
+    //       const lastLeafIdx = get(toc, curPath).length - 1
+    //       if (lastLeafIdx >= 0) {
+    //         curPath = `${curPath}[${lastLeafIdx}].children`
+    //       } else {
+    //         leafPathError = true
+    //       }
+    //     }
+    //     return curPath
+    //   }, 'root')
+
+    //   if (leafPathError) { return }
+
+    //   const leafSlug = $('.toc-anchor', el).first().attr('href')
+    //   $('.toc-anchor', el).remove()
+
+    //   get(toc, leafPath).push({
+    //     label: $(el).text().trim(),
+    //     key: leafSlug.substring(1),
+    //     children: []
+    //   })
+    // })
 
     // Save to DB
     await WIKI.db.pages.query()

+ 1 - 0
ux/package-lock.json

@@ -48,6 +48,7 @@
         "fuse.js": "6.6.2",
         "graphql": "16.6.0",
         "graphql-tag": "2.12.6",
+        "highlight.js": "11.7.0",
         "js-cookie": "3.0.1",
         "jwt-decode": "3.1.2",
         "katex": "0.16.4",

+ 1 - 0
ux/package.json

@@ -53,6 +53,7 @@
     "fuse.js": "6.6.2",
     "graphql": "16.6.0",
     "graphql-tag": "2.12.6",
+    "highlight.js": "11.7.0",
     "js-cookie": "3.0.1",
     "jwt-decode": "3.1.2",
     "katex": "0.16.4",

+ 73 - 123
ux/src/components/EditorMarkdown.vue

@@ -217,7 +217,7 @@
             @click='state.previewShown = false'
             )
             q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.togglePreviewPane') }}
-        .editor-markdown-preview-content.page-contents(ref='editorPreviewContainer')
+        .editor-markdown-preview-content.page-contents(ref='editorPreviewContainerRef')
           div(
             ref='editorPreview'
             v-html='pageStore.render'
@@ -257,8 +257,8 @@ const { t } = useI18n()
 // STATE
 
 let editor
-const cm = shallowRef(null)
 const monacoRef = ref(null)
+const editorPreviewContainerRef = ref(null)
 
 const state = reactive({
   previewShown: true,
@@ -267,9 +267,6 @@ const state = reactive({
 
 const md = new MarkdownRenderer({})
 
-// Platform detection
-const CtrlKey = /Mac/.test(navigator.platform) ? 'Cmd' : 'Ctrl'
-
 // METHODS
 
 function insertAssets () {
@@ -325,9 +322,9 @@ function setHeaderLine (lvl, focus = true) {
 /**
 * Get the header lever of the current line
 */
-function getHeaderLevel (cm) {
-  const curLine = cm.doc.getCursor('head').line
-  const lineContent = cm.doc.getLine(curLine)
+function getHeaderLevel () {
+  const curLine = editor.getPosition().lineNumber
+  const lineContent = editor.getModel().getLineContent(curLine)
   let lvl = 0
   const result = lineContent.match(/^(#+) /)
   if (result) {
@@ -356,13 +353,15 @@ function insertAtCursor ({ content, focus = true }) {
 */
 function insertAfter ({ content, newLine, focus = true }) {
   const curLine = editor.getPosition().lineNumber
+  const lineLength = editor.getModel().getLineContent(curLine).length
   editor.executeEdits('', [{
-    range: new Range(curLine + 1, 1, curLine + 1, 1),
-    text: newLine ? `\n${content}\n` : content,
+    range: new Range(curLine, lineLength + 1, curLine, lineLength + 1),
+    text: newLine ? `\n\n${content}\n` : `\n${content}`,
     forceMoveMarkers: true
   }])
   if (focus) {
     editor.focus()
+    editor.revealLineInCenterIfOutsideViewport(editor.getPosition().lineNumber)
   }
 }
 
@@ -408,7 +407,7 @@ function insertBeforeEachLine ({ content, after, focus = true }) {
 * Insert an Horizontal Bar
 */
 function insertHorizontalBar () {
-  insertAfter({ content: '\n---\n', newLine: true })
+  insertAfter({ content: '---', newLine: true })
 }
 
 /**
@@ -482,11 +481,11 @@ onMounted(async () => {
   editor = monaco.editor.create(monacoRef.value, {
     automaticLayout: true,
     cursorBlinking: 'blink',
-    cursorSmoothCaretAnimation: true,
+    // cursorSmoothCaretAnimation: true,
     fontSize: 16,
     formatOnType: true,
     language: 'markdown',
-    lineNumbersMinChars: 3,
+    lineNumbersMinChars: 4,
     padding: { top: 10, bottom: 10 },
     scrollBeyondLastLine: false,
     tabSize: 2,
@@ -495,7 +494,8 @@ onMounted(async () => {
     wordWrap: 'on'
   })
 
-  window.edd = editor
+  // TODO: For debugging, remove at some point...
+  window.edInstance = editor
 
   // -> Define Formatting Actions
   editor.addAction({
@@ -522,6 +522,29 @@ onMounted(async () => {
     }
   })
 
+  editor.addAction({
+    id: 'markdown.extension.editing.increaseHeaderLevel',
+    keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.RightArrow],
+    label: 'Increase Header Level',
+    precondition: '',
+    run (ed) {
+      let lvl = getHeaderLevel()
+      if (lvl >= 6) { lvl = 5 }
+      setHeaderLine(lvl + 1)
+    }
+  })
+  editor.addAction({
+    id: 'markdown.extension.editing.decreaseHeaderLevel',
+    keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.LeftArrow],
+    label: 'Decrease Header Level',
+    precondition: '',
+    run (ed) {
+      let lvl = getHeaderLevel()
+      if (lvl <= 1) { lvl = 2 }
+      setHeaderLine(lvl - 1)
+    }
+  })
+
   editor.addAction({
     id: 'save',
     keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
@@ -531,6 +554,7 @@ onMounted(async () => {
     }
   })
 
+  // -> Handle content change
   editor.onDidChangeModelContent(debounce(ev => {
     editorStore.$patch({
       lastChangeTimestamp: DateTime.utc()
@@ -541,33 +565,39 @@ onMounted(async () => {
     processContent(pageStore.content)
   }, 500))
 
-  editor.focus()
+  // -> Handle cursor movement
+  editor.onDidChangeCursorPosition(debounce(ev => {
+    if (!state.previewScrollSync || !state.previewShown) { return }
+    const currentLine = editor.getPosition().lineNumber
+    if (currentLine < 3) {
+      editorPreviewContainerRef.value.scrollTo({ top: 0, behavior: 'smooth' })
+    } else {
+      const exactEl = editorPreviewContainerRef.value.querySelector(`[data-line='${currentLine}']`)
+      if (exactEl) {
+        exactEl.scrollIntoView({
+          behavior: 'smooth'
+        })
+      } else {
+        const closestLine = md.getClosestPreviewLine(currentLine)
+        if (closestLine) {
+          const closestEl = editorPreviewContainerRef.value.querySelector(`[data-line='${closestLine}']`)
+          if (closestEl) {
+            closestEl.scrollIntoView({
+              behavior: 'smooth'
+            })
+          }
+        }
+      }
+    }
+  }, 500))
 
-  // -> Set Keybindings
-  // const keyBindings = {
-  //   [`${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
-  //   }
-  // }
-  // this.cm.on('inputRead', this.autocomplete)
+  // -> Post init
 
-  // // Handle cursor movement
-  // this.cm.on('cursorActivity', c => {
-  //   this.positionSync(c)
-  //   this.scrollSync(c)
-  // })
+  editor.focus()
 
-  // // Handle special paste
-  // this.cm.on('paste', this.onCmPaste)
+  nextTick(() => {
+    processContent(pageStore.content)
+  })
 
   EVENT_BUS.on('insertAsset', insertAssetClb)
 
@@ -613,7 +643,8 @@ onBeforeUnmount(() => {
 </script>
 
 <style lang="scss">
-$editor-height: calc(100vh - 64px - 94px - 2px);
+$editor-height: calc(100vh - 64px - 96px);
+$editor-preview-height: calc(100vh - 64px - 96px - 32px);
 $editor-height-mobile: calc(100vh - 112px - 16px);
 
 .editor-markdown {
@@ -658,7 +689,7 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
       background-color: $grey-2;
     }
     @at-root .body--dark & {
-      background-color: $dark-4;
+      background-color: $dark-6;
     }
     // @include until($tablet) {
     //   display: none;
@@ -690,7 +721,7 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
       }
     }
     &-content {
-      height: $editor-height;
+      height: $editor-preview-height;
       overflow-y: scroll;
       padding: 1rem;
       max-width: calc(50vw - 57px);
@@ -744,7 +775,7 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
   }
   &-toolbar {
     background-color: $primary;
-    border-left: 50px solid darken($primary, 5%);
+    border-left: 60px solid darken($primary, 5%);
     color: #FFF;
     height: 32px;
   }
@@ -759,86 +790,5 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
     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>

+ 99 - 0
ux/src/css/page-contents.scss

@@ -6,6 +6,10 @@
     margin-top: 0;
   }
 
+  @at-root .body--dark & {
+    color: #FFF;
+  }
+
   // ---------------------------------
   // LINKS
   // ---------------------------------
@@ -61,6 +65,10 @@
     }
   }
 
+  P + h2 {
+    margin-top: 12px;
+  }
+
   h1 {
     font-size: 3em;
     font-weight: 500;
@@ -405,6 +413,95 @@
     }
   }
 
+  // ---------------------------------
+  // CODE
+  // ---------------------------------
+
+  // code {
+  //   background-color: mc('indigo', '50');
+  //   padding: 0 5px;
+  //   color: mc('indigo', '800');
+  //   font-family: 'Roboto Mono', monospace;
+  //   font-weight: normal;
+  //   font-size: 1rem;
+  //   box-shadow: none;
+
+  //   &::before, &::after {
+  //     display: none;
+  //   }
+
+  //   @at-root .theme--dark & {
+  //     background-color: darken(mc('grey', '900'), 5%);
+  //     color: mc('indigo', '100');
+  //   }
+  // }
+
+  pre.codeblock {
+    border: none;
+    border-radius: 5px;
+    box-shadow: initial;
+    background-color: $dark-5;
+    padding: 1rem;
+    margin: 1rem 0;
+    overflow: auto;
+
+    @at-root .body--dark & {
+      background-color: $dark-5;
+    }
+
+    > code {
+      background-color: transparent;
+      padding: 0;
+      color: #FFF;
+      box-shadow: initial;
+      display: block;
+      font-size: .85rem;
+      font-family: 'Roboto Mono', monospace;
+
+      &:after, &:before {
+        content: initial;
+        letter-spacing: initial;
+      }
+    }
+
+    &.line-numbers {
+      counter-reset: linenumber;
+      padding-left: 3rem;
+
+      > code {
+        position: relative;
+        white-space: inherit;
+      }
+
+      .line-numbers-rows {
+        position: absolute;
+        pointer-events: none;
+        top: 0;
+        font-size: 100%;
+        left: -3.8em;
+        width: 3em;
+        letter-spacing: -1px;
+        border-right: 1px solid #999;
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        user-select: none;
+
+        & > span {
+          display: block;
+          counter-increment: linenumber;
+
+          &:before {
+            content: counter(linenumber);
+            color: #999;
+            display: block;
+            padding-right: .8em;
+            text-align: right;
+          }
+        }
+      }
+    }
+  }
+
   // ---------------------------------
   // LEGACY
   // ---------------------------------
@@ -417,3 +514,5 @@
     padding: 5px;
   }
 }
+
+@import 'highlight.js/styles/atom-one-dark-reasonable.css';

+ 28 - 2
ux/src/renderers/markdown.js

@@ -18,7 +18,9 @@ import twemoji from 'twemoji'
 import plantuml from './modules/plantuml'
 import katexHelper from './modules/katex'
 
-import { escape } from 'lodash-es'
+import hljs from 'highlight.js'
+
+import { escape, findLast, times } from 'lodash-es'
 
 export class MarkdownRenderer {
   constructor (conf = {}) {
@@ -33,7 +35,10 @@ export class MarkdownRenderer {
         } else if (['mermaid', 'plantuml'].includes(lang)) {
           return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
         } else {
-          return `<pre class="line-numbers"><code class="language-${lang}">${escape(str)}</code></pre>`
+          const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : hljs.highlightAuto(str)
+          const lineCount = highlighted.value.match(/\n/g).length
+          const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : ''
+          return `<pre class="codeblock ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
         }
       }
     })
@@ -91,9 +96,30 @@ export class MarkdownRenderer {
         }
       })
     }
+
+    // Inject line numbers for preview scroll sync
+    this.linesMap = []
+    const injectLineNumbers = (tokens, idx, options, env, slf) => {
+      let line
+      if (tokens[idx].map && tokens[idx].level === 0) {
+        line = tokens[idx].map[0] + 1
+        tokens[idx].attrJoin('class', 'line')
+        tokens[idx].attrSet('data-line', String(line))
+        this.linesMap.push(line)
+      }
+      return slf.renderToken(tokens, idx, options, env, slf)
+    }
+    this.md.renderer.rules.paragraph_open = injectLineNumbers
+    this.md.renderer.rules.heading_open = injectLineNumbers
+    this.md.renderer.rules.blockquote_open = injectLineNumbers
   }
 
   render (src) {
+    this.linesMap = []
     return this.md.render(src)
   }
+
+  getClosestPreviewLine (line) {
+    return findLast(this.linesMap, n => n <= line)
+  }
 }