Browse Source

feat: link autocomplete + insert link modal (markdown)

NGPixel 5 years ago
parent
commit
76ade8df53

+ 1 - 0
client/components/common/page-selector.vue

@@ -10,6 +10,7 @@
         v-icon.mr-3(color='white') mdi-page-next-outline
         v-icon.mr-3(color='white') mdi-page-next-outline
         .body-1(v-if='mode === `create`') Select New Page Location
         .body-1(v-if='mode === `create`') Select New Page Location
         .body-1(v-else-if='mode === `move`') Move / Rename Page Location
         .body-1(v-else-if='mode === `move`') Move / Rename Page Location
+        .body-1(v-else-if='mode === `select`') Select Page
         v-spacer
         v-spacer
         v-progress-circular(
         v-progress-circular(
           indeterminate
           indeterminate

+ 113 - 4
client/components/editor/editor-markdown.vue

@@ -109,7 +109,7 @@
       .editor-markdown-sidebar
       .editor-markdown-sidebar
         v-tooltip(right, color='teal')
         v-tooltip(right, color='teal')
           template(v-slot:activator='{ on }')
           template(v-slot:activator='{ on }')
-            v-btn.animated.fadeInLeft(icon, tile, v-on='on', dark, disabled).mx-0
+            v-btn.animated.fadeInLeft(icon, tile, v-on='on', dark, @click='insertLink').mx-0
               v-icon mdi-link-plus
               v-icon mdi-link-plus
           span {{$t('editor:markup.insertLink')}}
           span {{$t('editor:markup.insertLink')}}
         v-tooltip(right, color='teal')
         v-tooltip(right, color='teal')
@@ -130,7 +130,7 @@
         v-tooltip(right, color='teal')
         v-tooltip(right, color='teal')
           template(v-slot:activator='{ on }')
           template(v-slot:activator='{ on }')
             v-btn.mt-3.animated.fadeInLeft.wait-p4s(icon, tile, v-on='on', dark, disabled).mx-0
             v-btn.mt-3.animated.fadeInLeft.wait-p4s(icon, tile, v-on='on', dark, disabled).mx-0
-              v-icon mdi-library-video
+              v-icon mdi-movie
           span {{$t('editor:markup.insertVideoAudio')}}
           span {{$t('editor:markup.insertVideoAudio')}}
         v-tooltip(right, color='teal')
         v-tooltip(right, color='teal')
           template(v-slot:activator='{ on }')
           template(v-slot:activator='{ on }')
@@ -176,14 +176,16 @@
         .caption Ln {{cursorPos.line + 1}}, Col {{cursorPos.ch + 1}}
         .caption Ln {{cursorPos.line + 1}}, Col {{cursorPos.ch + 1}}
 
 
     markdown-help(v-if='helpShown')
     markdown-help(v-if='helpShown')
+    page-selector(mode='select', v-model='insertLinkDialog', :open-handler='insertLinkHandler', :path='path', :locale='locale')
 </template>
 </template>
 
 
 <script>
 <script>
 import _ from 'lodash'
 import _ from 'lodash'
 import { get, sync } from 'vuex-pathify'
 import { get, sync } from 'vuex-pathify'
 import markdownHelp from './markdown/help.vue'
 import markdownHelp from './markdown/help.vue'
+import gql from 'graphql-tag'
 
 
-/* global siteConfig */
+/* global siteConfig, siteLangs */
 
 
 // ========================================
 // ========================================
 // IMPORTS
 // IMPORTS
@@ -202,6 +204,7 @@ import 'codemirror/addon/display/fullscreen.js'
 import 'codemirror/addon/display/fullscreen.css'
 import 'codemirror/addon/display/fullscreen.css'
 import 'codemirror/addon/selection/mark-selection.js'
 import 'codemirror/addon/selection/mark-selection.js'
 import 'codemirror/addon/search/searchcursor.js'
 import 'codemirror/addon/search/searchcursor.js'
+import 'codemirror/addon/hint/show-hint.js'
 
 
 // Markdown-it
 // Markdown-it
 import MarkdownIt from 'markdown-it'
 import MarkdownIt from 'markdown-it'
@@ -353,7 +356,8 @@ export default {
       cursorPos: { ch: 0, line: 1 },
       cursorPos: { ch: 0, line: 1 },
       previewShown: true,
       previewShown: true,
       previewHTML: '',
       previewHTML: '',
-      helpShown: false
+      helpShown: false,
+      insertLinkDialog: false
     }
     }
   },
   },
   computed: {
   computed: {
@@ -544,6 +548,72 @@ export default {
         mmElm.innerHTML = `<div id="mermaid-id-${mermaidId}">${mermaid.render(`mermaid-id-${mermaidId}`, mermaidDef)}</div>`
         mmElm.innerHTML = `<div id="mermaid-id-${mermaidId}">${mermaid.render(`mermaid-id-${mermaidId}`, mermaidDef)}</div>`
         elm.parentElement.replaceWith(mmElm)
         elm.parentElement.replaceWith(mmElm)
       })
       })
+    },
+    autocomplete (cm, change) {
+      if (cm.getModeAt(cm.getCursor()).name !== 'markdown') {
+        return
+      }
+
+      // Links
+      if (change.text[0] === '(') {
+        const curLine = cm.getLine(change.from.line).substring(0, change.from.ch)
+        if (curLine[curLine.length - 1] === ']') {
+          cm.showHint({
+            hint: async (cm, options) => {
+              const cur = cm.getCursor()
+              const token = cm.getTokenAt(cur)
+              try {
+                const respRaw = await this.$apollo.query({
+                  query: gql`
+                    query ($query: String!, $locale: String) {
+                      pages {
+                        search(query:$query, locale:$locale) {
+                          results {
+                            title
+                            path
+                            locale
+                          }
+                          totalHits
+                        }
+                      }
+                    }
+                  `,
+                  variables: {
+                    query: token.string,
+                    locale: this.locale
+                  },
+                  fetchPolicy: 'cache-first'
+                })
+                const resp = _.get(respRaw, 'data.pages.search', {})
+                if (resp && resp.totalHits > 0) {
+                  return {
+                    list: resp.results.map(r => ({
+                      text: (siteLangs.length > 0 ? `/${r.locale}/${r.path}` : `/${r.path}`) + ')',
+                      displayText: siteLangs.length > 0 ? `/${r.locale}/${r.path} - ${r.title}` : `/${r.path} - ${r.title}`
+                    })),
+                    from: CodeMirror.Pos(cur.line, token.start),
+                    to: CodeMirror.Pos(cur.line, token.end)
+                  }
+                }
+              } catch (err) {}
+              return {
+                list: [],
+                from: CodeMirror.Pos(cur.line, token.start),
+                to: CodeMirror.Pos(cur.line, token.end)
+              }
+            }
+          })
+        }
+      }
+    },
+    insertLink () {
+      this.insertLinkDialog = true
+    },
+    insertLinkHandler ({ locale, path }) {
+      const lastPart = _.last(path.split('/'))
+      this.insertAtCursor({
+        content: siteLangs.length > 0 ? `[${lastPart}](/${locale}/${path})` : `[${lastPart}](/${path})`
+      })
     }
     }
   },
   },
   mounted() {
   mounted() {
@@ -624,6 +694,8 @@ export default {
     })
     })
     this.cm.setOption('extraKeys', keyBindings)
     this.cm.setOption('extraKeys', keyBindings)
 
 
+    this.cm.on('inputRead', this.autocomplete)
+
     // Handle cursor movement
     // Handle cursor movement
 
 
     this.cm.on('cursorActivity', c => {
     this.cm.on('cursorActivity', c => {
@@ -921,6 +993,43 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
     text-decoration: underline;
     text-decoration: underline;
     color: white !important;
     color: white !important;
   }
   }
+}
+
+// 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 mc('grey', '700');
+
+  background: mc('grey', '900');
+  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: mc('blue', '500');
+  color: #FFF;
 }
 }
 </style>
 </style>

+ 2 - 0
server/modules/search/db/engine.js

@@ -34,9 +34,11 @@ module.exports = {
           if (WIKI.config.db.type === 'postgres') {
           if (WIKI.config.db.type === 'postgres') {
             builderSub.where('title', 'ILIKE', `%${q}%`)
             builderSub.where('title', 'ILIKE', `%${q}%`)
             builderSub.orWhere('description', 'ILIKE', `%${q}%`)
             builderSub.orWhere('description', 'ILIKE', `%${q}%`)
+            builderSub.orWhere('path', 'ILIKE', `%${q.toLowerCase()}%`)
           } else {
           } else {
             builderSub.where('title', 'LIKE', `%${q}%`)
             builderSub.where('title', 'LIKE', `%${q}%`)
             builderSub.orWhere('description', 'LIKE', `%${q}%`)
             builderSub.orWhere('description', 'LIKE', `%${q}%`)
+            builderSub.orWhere('path', 'LIKE', `%${q.toLowerCase()}%`)
           }
           }
         })
         })
       })
       })

+ 18 - 4
server/modules/search/postgres/engine.js

@@ -60,12 +60,26 @@ module.exports = {
   async query(q, opts) {
   async query(q, opts) {
     try {
     try {
       let suggestions = []
       let suggestions = []
-      const results = await WIKI.models.knex.raw(`
+      let qry = `
         SELECT id, path, locale, title, description
         SELECT id, path, locale, title, description
         FROM "pagesVector", to_tsquery(?,?) query
         FROM "pagesVector", to_tsquery(?,?) query
-        WHERE query @@ "tokens"
-        ORDER BY ts_rank(tokens, query) DESC
-      `, [this.config.dictLanguage, tsquery(q)])
+        WHERE (query @@ "tokens" OR path ILIKE ?)
+      `
+      let qryEnd = `ORDER BY ts_rank(tokens, query) DESC`
+      let qryParams = [this.config.dictLanguage, tsquery(q), `%${q.toLowerCase()}%`]
+
+      if (opts.locale) {
+        qry = `${qry} AND locale = ?`
+        qryParams.push(opts.locale)
+      }
+      if (opts.path) {
+        qry = `${qry} AND path ILIKE ?`
+        qryParams.push(`%${opts.path}`)
+      }
+      const results = await WIKI.models.knex.raw(`
+        ${qry}
+        ${qryEnd}
+      `, qryParams)
       if (results.rows.length < 5) {
       if (results.rows.length < 5) {
         const suggestResults = await WIKI.models.knex.raw(`SELECT word, word <-> ? AS rank FROM "pagesWords" WHERE similarity(word, ?) > 0.2 ORDER BY rank LIMIT 5;`, [q, q])
         const suggestResults = await WIKI.models.knex.raw(`SELECT word, word <-> ? AS rank FROM "pagesWords" WHERE similarity(word, ?) > 0.2 ORDER BY rank LIMIT 5;`, [q, q])
         suggestions = suggestResults.rows.map(r => r.word)
         suggestions = suggestResults.rows.map(r => r.word)