add missing autocompletions (#514)

* added missing autocompletions:
- code-block
- container
- header
- image
- link
- pdf

* added extraTags ([name=], [time=], [color=]) to the link autocompletion, because they trigger on the same characters
added getUser in /redux/user/methods to retrive the current user outside of .tsx files
improve the regexps on several autocompletion

* renamed hints to auto
Co-authored-by: Erik Michelson <github@erik.michelson.eu>
Co-authored-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
Philip Molares 2020-09-01 22:28:08 +02:00 committed by GitHub
parent 2decfc1fa2
commit db4f2a4478
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 599 additions and 86 deletions

View file

@ -8,56 +8,260 @@ describe('Autocompletion', () => {
.type('{backspace}')
})
describe('normal emoji', () => {
describe('code block', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':book')
.type('```')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':book:')
cy.get('.markdown-body')
.should('have.text', '📖')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type(':book')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':book:')
cy.get('.markdown-body')
.should('have.text', '📖')
})
})
describe('fork-awesome-icon', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':facebook')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':fa-facebook:')
cy.get('.markdown-body > p > i.fa.fa-facebook')
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span')
.should('have.text', '```1c')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', '```')
cy.get('.markdown-body > pre > code')
.should('exist')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type(':facebook')
.type('```')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span')
.should('have.text', '```1c')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', '```')
cy.get('.markdown-body > pre > code')
.should('exist')
})
})
describe('container', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':::')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span')
.should('have.text', ':::success')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', ':::')
cy.get('.markdown-body > div.alert')
.should('exist')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type(':::')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span')
.should('have.text', ':::success')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', ':::')
cy.get('.markdown-body > div.alert')
.should('exist')
})
})
describe('emoji', () => {
describe('normal emoji', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':book')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':book:')
cy.get('.markdown-body')
.should('have.text', '📖')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type(':book')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':book:')
cy.get('.markdown-body')
.should('have.text', '📖')
})
})
describe('fork-awesome-icon', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':facebook')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':fa-facebook:')
cy.get('.markdown-body > p > i.fa.fa-facebook')
.should('exist')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type(':facebook')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':fa-facebook:')
cy.get('.markdown-body > p > i.fa.fa-facebook')
.should('exist')
})
})
})
describe('header', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type('#')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '# ')
cy.get('.markdown-body > h1 ')
.should('have.text', ' ')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type('#')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':fa-facebook:')
cy.get('.markdown-body > p > i.fa.fa-facebook')
.should('have.text', '# ')
cy.get('.markdown-body > h1')
.should('have.text', ' ')
})
})
describe('images', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type('!')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '![image alt](https:// "title")')
cy.get('.markdown-body > p > img')
.should('have.attr', 'alt', 'image alt')
.should('have.attr', 'src', 'https://')
.should('have.attr', 'title', 'title')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type('!')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '![image alt](https:// "title")')
cy.get('.markdown-body > p > img')
.should('have.attr', 'alt', 'image alt')
.should('have.attr', 'src', 'https://')
.should('have.attr', 'title', 'title')
})
})
describe('links', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type('[')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '[link text](https:// "title") ')
cy.get('.markdown-body > p > a')
.should('have.text', 'link text')
.should('have.attr', 'href', 'https://')
.should('have.attr', 'title', 'title')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type('[')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '[link text](https:// "title") ')
cy.get('.markdown-body > p > a')
.should('have.text', 'link text')
.should('have.attr', 'href', 'https://')
.should('have.attr', 'title', 'title')
})
})
describe('pdf', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type('{')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '{%pdf https:// %}')
cy.get('.markdown-body > p')
.should('exist')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type('{')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '{%pdf https:// %}')
cy.get('.markdown-body > p')
.should('exist')
})
})

View file

@ -0,0 +1,39 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import hljs from 'highlight.js'
import { findWordAtCursor, Hinter, search } from './index'
const allowedChars = /[`\w-_+]/
const wordRegExp = /^```((\w|-|_|\+)*)$/
const allSupportedLanguages = hljs.listLanguages().concat('csv', 'flow', 'html')
const codeBlockHint = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor, allowedChars)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const term = searchResult[1]
const suggestions = search(term, allSupportedLanguages)
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion: string): Hint => ({
text: '```' + suggestion + '\n\n```\n',
displayText: suggestion
})),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end)
})
}
})
}
export const CodeBlockHinter: Hinter = {
allowedChars,
wordRegExp,
hint: codeBlockHint
}

View file

@ -0,0 +1,37 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import { findWordAtCursor, Hinter } from './index'
const allowedChars = /[:\w-_+]/
const wordRegExp = /^:::((\w|-|_|\+)*)$/
const allSupportedConatiner = ['success', 'info', 'warning', 'danger']
const containerHint = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor, allowedChars)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = allSupportedConatiner
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion: string): Hint => ({
text: ':::' + suggestion + '\n\n:::\n',
displayText: suggestion
})),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end)
})
}
})
}
export const ContainerHinter: Hinter = {
allowedChars,
wordRegExp,
hint: containerHint
}

View file

@ -1,64 +1,40 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import { Data, EmojiData, NimbleEmojiIndex } from 'emoji-mart'
import data from 'emoji-mart/data/twitter.json'
import { getEmojiIcon, getEmojiShortCode } from '../tool-bar/utils/emojiUtils'
import { customEmojis } from '../tool-bar/emoji-picker/emoji-picker'
import { getEmojiIcon, getEmojiShortCode } from '../tool-bar/utils/emojiUtils'
import { findWordAtCursor, Hinter } from './index'
interface findWordAtCursorResponse {
start: number,
end: number,
text: string
}
const allowedCharsInEmojiCodeRegex = /(:|\w|-|_|\+)/
const allowedCharsInEmojiCodeRegex = /[:\w-_+]/
const emojiIndex = new NimbleEmojiIndex(data as unknown as Data)
export const emojiWordRegex = /^:((\w|-|_|\+)+)$/
const emojiWordRegex = /^:([\w-_+]*)$/
export const findWordAtCursor = (editor: Editor): findWordAtCursorResponse => {
const cursor = editor.getCursor()
const line = editor.getLine(cursor.line)
let start = cursor.ch
let end = cursor.ch
while (start && allowedCharsInEmojiCodeRegex.test(line.charAt(start - 1))) {
--start
}
while (end < line.length && allowedCharsInEmojiCodeRegex.test(line.charAt(end))) {
++end
}
return {
text: line.slice(start, end).toLowerCase(),
start: start,
end: end
}
}
export const generateEmojiHints = (editor: Editor): Promise< Hints| null > => {
const generateEmojiHints = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchTerm = findWordAtCursor(editor, allowedCharsInEmojiCodeRegex)
const searchResult = emojiWordRegex.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const term = searchResult[1]
if (!term) {
resolve(null)
return
}
const search = emojiIndex.search(term, {
let search: EmojiData[] | null = emojiIndex.search(term, {
emojisToShowFilter: () => true,
maxResults: 5,
maxResults: 7,
include: [],
exclude: [],
custom: customEmojis as EmojiData[]
})
if (search === null) {
// set search to the first seven emojis in data
search = Object.values(emojiIndex.emojis).slice(0, 7)
}
const cursor = editor.getCursor()
if (!search) {
resolve(null)
} else {
resolve({
list: search.map((emojiData: EmojiData): Hint => ({
list: search.map((emojiData): Hint => ({
text: getEmojiShortCode(emojiData),
render: (parent: HTMLLIElement) => {
const wrapper = document.createElement('div')
@ -72,3 +48,9 @@ export const generateEmojiHints = (editor: Editor): Promise< Hints| null > => {
}
})
}
export const EmojiHinter: Hinter = {
allowedChars: allowedCharsInEmojiCodeRegex,
wordRegExp: emojiWordRegex,
hint: generateEmojiHints
}

View file

@ -0,0 +1,43 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import { findWordAtCursor, Hinter, search } from './index'
const allowedChars = /#/
const wordRegExp = /^(\s{0,3})(#{1,6})$/
const allSupportedHeaders = ['# h1', '## h2', '### h3', '#### h4', '##### h5', '###### h6', '###### tags: `example`']
const allSupportedHeadersTextToInsert = ['# ', '## ', '### ', '#### ', '##### ', '###### ', '###### tags: `example`']
const headerHint = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor, allowedChars)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const term = searchResult[0]
if (!term) {
resolve(null)
return
}
const suggestions = search(term, allSupportedHeaders)
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion, index): Hint => ({
text: allSupportedHeadersTextToInsert[index],
displayText: suggestion
})),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end)
})
}
})
}
export const HeaderHinter: Hinter = {
allowedChars,
wordRegExp,
hint: headerHint
}

View file

@ -0,0 +1,40 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import { findWordAtCursor, Hinter } from './index'
const allowedChars = /[![\]\w]/
const wordRegExp = /^(!(\[.*])?)$/
const allSupportedImages = [
'![image alt](https:// "title")',
'![image alt](https:// "title" =WidthxHeight)',
'![image alt][reference]'
]
const imageHint = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor, allowedChars)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = allSupportedImages
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion: string): Hint => ({
text: suggestion
})),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const ImageHinter: Hinter = {
allowedChars,
wordRegExp,
hint: imageHint
}

View file

@ -0,0 +1,59 @@
import { Editor, Hints } from 'codemirror'
import { CodeBlockHinter } from './code-block'
import { ContainerHinter } from './container'
import { EmojiHinter } from './emoji'
import { HeaderHinter } from './header'
import { ImageHinter } from './image'
import { LinkAndExtraTagHinter } from './link-and-extra-tag'
import { PDFHinter } from './pdf'
interface findWordAtCursorResponse {
start: number,
end: number,
text: string
}
export interface Hinter {
allowedChars: RegExp,
wordRegExp: RegExp,
hint: (editor: Editor) => Promise< Hints| null >
}
export const findWordAtCursor = (editor: Editor, allowedChars: RegExp): findWordAtCursorResponse => {
const cursor = editor.getCursor()
const line = editor.getLine(cursor.line)
let start = cursor.ch
let end = cursor.ch
while (start && allowedChars.test(line.charAt(start - 1))) {
--start
}
while (end < line.length && allowedChars.test(line.charAt(end))) {
++end
}
return {
text: line.slice(start, end).toLowerCase(),
start: start,
end: end
}
}
export const search = (term: string, list: string[]): string[] => {
const suggestions: string[] = []
list.forEach(item => {
if (item.toLowerCase().startsWith(term.toLowerCase())) {
suggestions.push(item)
}
})
return suggestions.slice(0, 7)
}
export const allHinters: Hinter[] = [
CodeBlockHinter,
ContainerHinter,
EmojiHinter,
HeaderHinter,
ImageHinter,
LinkAndExtraTagHinter,
PDFHinter
]

View file

@ -0,0 +1,69 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import moment from 'moment'
import { getUser } from '../../../../redux/user/methods'
import { findWordAtCursor, Hinter } from './index'
const allowedChars = /[[\]\w]/
const wordRegExp = /^(\[(.*])?)$/
const allSupportedLinks = [
'[link text](https:// "title")',
'[reference]: https:// "title"',
'[link text][reference]',
'[reference]',
'[^footnote reference]: https://',
'[^footnote reference]',
'^[inline footnote]',
'[TOC]',
'name',
'time',
'[color=#FFFFFF]'
]
const linkAndExtraTagHint = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor, allowedChars)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = allSupportedLinks
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion: string): Hint => {
const user = getUser()
const userName = user ? user.name : 'Anonymous'
switch (suggestion) {
case 'name':
// Get the user when a completion happens, this prevents to early calls resulting in 'Anonymous'
return {
text: `[name=${userName}]`
}
case 'time':
// show the current time when the autocompletion is opened and not when the function is loaded
return {
text: `[time=${moment(new Date()).format('llll')}]`
}
default:
return {
text: suggestion + ' ',
displayText: suggestion
}
}
}),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const LinkAndExtraTagHinter: Hinter = {
allowedChars,
wordRegExp,
hint: linkAndExtraTagHint
}

View file

@ -0,0 +1,35 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import { findWordAtCursor, Hinter } from './index'
const allowedChars = /[{%]/
const wordRegExp = /^({[%}]?)$/
const pdfHint = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor, allowedChars)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = ['{%pdf https:// %}']
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion: string): Hint => ({
text: suggestion
})),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const PDFHinter: Hinter = {
allowedChars,
wordRegExp,
hint: pdfHint
}

View file

@ -12,20 +12,20 @@ import 'codemirror/addon/edit/matchtags'
import 'codemirror/addon/fold/foldcode'
import 'codemirror/addon/fold/foldgutter'
import 'codemirror/addon/hint/show-hint'
import 'codemirror/addon/search/search'
import 'codemirror/addon/search/jump-to-line'
import 'codemirror/addon/search/match-highlighter'
import 'codemirror/addon/search/search'
import 'codemirror/addon/selection/active-line'
import 'codemirror/keymap/sublime'
import 'codemirror/keymap/emacs'
import 'codemirror/keymap/sublime'
import 'codemirror/keymap/vim'
import 'codemirror/mode/gfm/gfm'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import { useTranslation } from 'react-i18next'
import './editor-pane.scss'
import { ScrollProps, ScrollState } from '../scroll/scroll-props'
import { generateEmojiHints, emojiWordRegex, findWordAtCursor } from './hints/emoji'
import { allHinters, findWordAtCursor } from './autocompletion'
import './editor-pane.scss'
import { defaultKeyMap } from './key-map'
import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar'
import { ToolBar } from './tool-bar/tool-bar'
@ -35,17 +35,18 @@ export interface EditorPaneProps {
content: string
}
const hintOptions = {
hint: generateEmojiHints,
completeSingle: false,
completeOnSingleClick: false,
alignWithWord: true
}
const onChange = (editor: Editor) => {
const searchTerm = findWordAtCursor(editor)
if (emojiWordRegex.test(searchTerm.text)) {
editor.showHint(hintOptions)
for (const hinter of allHinters) {
const searchTerm = findWordAtCursor(editor, hinter.allowedChars)
if (hinter.wordRegExp.test(searchTerm.text)) {
editor.showHint({
hint: hinter.hint,
completeSingle: false,
completeOnSingleClick: false,
alignWithWord: true
})
return
}
}
}

View file

@ -15,3 +15,7 @@ export const clearUser: () => void = () => {
}
store.dispatch(action)
}
export const getUser = (): UserState | null => {
return store.getState().user
}