Add Emoji/FA Autocompletion (#387)

added emoji/fork-awesome autocompletion
added autocompletion e2e test

Co-authored-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
Co-authored-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Philip Molares 2020-08-14 13:58:59 +02:00 committed by GitHub
parent c8c5569426
commit c15f0d9900
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 279 additions and 38 deletions

View file

@ -35,6 +35,7 @@
- Images will be loaded via proxy if an image proxy is configured in the backend
- Asciinema videos may now be embedded by pasting the URL of one video into a single line
- The Toolbar includes an EmojiPicker
- Added shortcodes for [fork-awesome icons](https://forkaweso.me/Fork-Awesome/icons/) (e.g. `:fa-picture-o:`)
### Changed

View file

@ -0,0 +1,64 @@
describe('Autocompletion', () => {
beforeEach(() => {
cy.visit('/n/test')
cy.get('.btn.active.btn-outline-secondary > i.fa-columns')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{ctrl}a', { force: true })
.type('{backspace}')
})
describe('normal emoji', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':book')
.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')
.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')
})
})
})

View file

@ -1,6 +1,7 @@
@import '../../../../node_modules/codemirror/lib/codemirror.css';
@import '../../../../node_modules/codemirror/addon/display/fullscreen.css';
@import './one-dark.css';
@import 'hints';
.CodeMirror {
font-family: "Source Code Pro", "twemoji", Consolas, monaco, monospace;
@ -9,3 +10,4 @@
font-size: 18px;
height: 100%;
}

View file

@ -1,4 +1,4 @@
import { Editor } from 'codemirror'
import { Editor, EditorChange } from 'codemirror'
import 'codemirror/addon/comment/comment'
import 'codemirror/addon/display/autorefresh'
import 'codemirror/addon/display/fullscreen'
@ -10,14 +10,16 @@ import 'codemirror/addon/edit/matchbrackets'
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/match-highlighter'
import 'codemirror/addon/selection/active-line'
import 'codemirror/keymap/sublime.js'
import 'codemirror/mode/gfm/gfm.js'
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import { useTranslation } from 'react-i18next'
import './editor-window.scss'
import { emojiHints, emojiWordRegex, findWordAtCursor } from './hints/emoji'
import { defaultKeyMap } from './key-map'
import { ToolBar } from './tool-bar/tool-bar'
@ -26,10 +28,28 @@ export interface EditorWindowProps {
content: string
}
const hintOptions = {
hint: emojiHints,
completeSingle: false,
completeOnSingleClick: false,
alignWithWord: true
}
const onChange = (editor: Editor) => {
const searchTerm = findWordAtCursor(editor)
if (emojiWordRegex.test(searchTerm.text)) {
editor.showHint(hintOptions)
}
}
export const EditorWindow: React.FC<EditorWindowProps> = ({ onContentChange, content }) => {
const { t } = useTranslation()
const [editor, setEditor] = useState<Editor>()
const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => {
onContentChange(value)
}, [onContentChange])
return (
<div className={'d-flex flex-column h-100'}>
<ToolBar
@ -67,12 +87,13 @@ export const EditorWindow: React.FC<EditorWindowProps> = ({ onContentChange, con
addModeClass: true,
autoRefresh: true,
// otherCursors: true,
placeholder: t('editor.placeholder')
placeholder: t('editor.placeholder'),
showHint: false,
hintOptions: hintOptions
}}
editorDidMount={mountedEditor => setEditor(mountedEditor)}
onBeforeChange={(editor, data, value) => {
onContentChange(value)
}}
onBeforeChange={onBeforeChange}
onChange={onChange}
/></div>
)
}

View file

@ -0,0 +1,32 @@
.CodeMirror-hints {
position: absolute;
z-index: 10;
overflow: hidden;
list-style: none;
margin: 0;
padding: 4px;
box-shadow: 2px 3px 5px rgba(0,0,0,.2);
border-radius: 3px;
border: 1px solid silver;
background: white;
max-height: 20em;
overflow-y: auto;
}
.CodeMirror-hint {
margin: 0;
padding: 3px 15px;
border-radius: 2px;
white-space: pre;
color: black;
cursor: pointer;
}
li.CodeMirror-hint-active {
background: #08f;
color: white;
}

View file

@ -0,0 +1,75 @@
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 '../../../../utils/emoji'
import { customEmojis } from '../tool-bar/emoji-picker/emoji-picker'
interface findWordAtCursorResponse {
start: number,
end: number,
text: string
}
const allowedCharsInEmojiCodeRegex = /(:|\w|-|_|\+)/
const emojiIndex = new NimbleEmojiIndex(data as unknown as Data)
export 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 emojiHints = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
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, {
emojisToShowFilter: () => true,
maxResults: 5,
include: [],
exclude: [],
custom: customEmojis as EmojiData[]
})
const cursor = editor.getCursor()
if (!search) {
resolve(null)
} else {
resolve({
list: search.map((emojiData: EmojiData): Hint => ({
text: getEmojiShortCode(emojiData),
render: (parent: HTMLLIElement) => {
const wrapper = document.createElement('div')
wrapper.innerHTML = `${getEmojiIcon(emojiData)} ${getEmojiShortCode(emojiData)}`
parent.appendChild(wrapper)
}
})),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end)
})
}
})
}

View file

@ -1,7 +1,7 @@
import { Data, EmojiData, NimblePicker } from 'emoji-mart'
import { CustomEmoji, Data, EmojiData, NimblePicker } from 'emoji-mart'
import 'emoji-mart/css/emoji-mart.css'
import emojiData from 'emoji-mart/data/twitter.json'
import React, { useMemo, useRef } from 'react'
import React, { useRef } from 'react'
import { useClickAway } from 'react-use'
import { ShowIf } from '../../../../common/show-if/show-if'
import './emoji-picker.scss'
@ -13,18 +13,18 @@ export interface EmojiPickerProps {
onDismiss: () => void
}
export const customEmojis: CustomEmoji[] = Object.keys(ForkAwesomeIcons).map((name) => ({
name: `fa-${name}`,
short_names: [`fa-${name.toLowerCase()}`],
text: '',
emoticons: [],
keywords: ['fork awesome'],
imageUrl: '/img/forkawesome.png',
customCategory: 'ForkAwesome'
}))
export const EmojiPicker: React.FC<EmojiPickerProps> = ({ show, onEmojiSelected, onDismiss }) => {
const pickerRef = useRef(null)
const customIcons = useMemo(() =>
Object.keys(ForkAwesomeIcons).map((name) => ({
name: `fa-${name}`,
short_names: [`fa-${name.toLowerCase()}`],
text: '',
emoticons: [],
keywords: ['fork awesome'],
imageUrl: '/img/forkawesome.png',
customCategory: 'ForkAwesome'
})), [])
useClickAway(pickerRef, () => {
onDismiss()
@ -39,7 +39,7 @@ export const EmojiPicker: React.FC<EmojiPickerProps> = ({ show, onEmojiSelected,
onSelect={onEmojiSelected}
theme={'auto'}
title=''
custom={customIcons}
custom={customEmojis}
/>
</div>
</ShowIf>

View file

@ -1738,10 +1738,10 @@ describe('test addEmoji with native emoji', () => {
describe('test addEmoji with native emoji', () => {
const { cursor, firstLine, multiline, multilineOffset } = buildRanges()
const textFirstLine = testContent.split('\n')[0]
// noinspection CheckTagEmptyBody
const forkAwesomeIcon = '<i class="fa star"></i>'
const forkAwesomeIcon = ':fa-star:'
const emoji = Mock.of<EmojiData>({
name: 'star',
colons: ':fa-star:',
imageUrl: '/img/forkawesome.png'
})
it('just cursor', done => {

View file

@ -1,5 +1,6 @@
import { Editor } from 'codemirror'
import { BaseEmoji, CustomEmoji, EmojiData } from 'emoji-mart'
import { EmojiData } from 'emoji-mart'
import { getEmojiShortCode } from '../../../../utils/emoji'
export const makeSelectionBold = (editor: Editor): void => wrapTextWith(editor, '**')
export const makeSelectionItalic = (editor: Editor): void => wrapTextWith(editor, '*')
@ -24,14 +25,7 @@ export const addComment = (editor: Editor): void => changeLines(editor, line =>
export const addTable = (editor: Editor): void => changeLines(editor, line => `${line}\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |`)
export const addEmoji = (emoji: EmojiData, editor: Editor): void => {
let replacement = ''
if ((emoji as BaseEmoji).colons) {
replacement = (emoji as BaseEmoji).colons
} else if ((emoji as CustomEmoji).imageUrl) {
// noinspection CheckTagEmptyBody
replacement = `<i class="fa ${(emoji as CustomEmoji).name}"></i>`
}
insertAtCursor(editor, replacement)
insertAtCursor(editor, getEmojiShortCode(emoji))
}
export const wrapTextWith = (editor: Editor, symbol: string, endSymbol?: string): void => {

View file

@ -1,5 +1,6 @@
import equal from 'deep-equal'
import { DomElement } from 'domhandler'
import emojiData from 'emoji-mart/data/twitter.json'
import { Data } from 'emoji-mart/dist-es/utils/data'
import yaml from 'js-yaml'
import MarkdownIt from 'markdown-it'
@ -14,8 +15,8 @@ import imsize from 'markdown-it-imsize'
import inserted from 'markdown-it-ins'
import marked from 'markdown-it-mark'
import mathJax from 'markdown-it-mathjax'
import markdownItRegex from 'markdown-it-regex'
import plantuml from 'markdown-it-plantuml'
import markdownItRegex from 'markdown-it-regex'
import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
import taskList from 'markdown-it-task-lists'
@ -31,13 +32,14 @@ import { ApplicationState } from '../../../redux'
import { slugify } from '../../../utils/slugify'
import { InternalLink } from '../../common/links/internal-link'
import { ShowIf } from '../../common/show-if/show-if'
import { ForkAwesomeIcons } from '../editor-window/tool-bar/emoji-picker/icon-names'
import { RawYAMLMetadata, YAMLMetaData } from '../yaml-metadata/yaml-metadata'
import { createRenderContainer, validAlertLevels } from './container-plugins/alert'
import { highlightedCode } from './markdown-it-plugins/highlighted-code'
import { linkifyExtra } from './markdown-it-plugins/linkify-extra'
import { MarkdownItParserDebugger } from './markdown-it-plugins/parser-debugger'
import './markdown-renderer.scss'
import { plantumlError } from './markdown-it-plugins/plantuml-error'
import './markdown-renderer.scss'
import { replaceAsciinemaLink } from './regex-plugins/replace-asciinema-link'
import { replaceGistLink } from './regex-plugins/replace-gist-link'
import { replaceLegacyGistShortCode } from './regex-plugins/replace-legacy-gist-short-code'
@ -51,8 +53,8 @@ import { replaceQuoteExtraColor } from './regex-plugins/replace-quote-extra-colo
import { replaceQuoteExtraTime } from './regex-plugins/replace-quote-extra-time'
import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link'
import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link'
import { ComponentReplacer, SubNodeConverter } from './replace-components/ComponentReplacer'
import { AsciinemaReplacer } from './replace-components/asciinema/asciinema-replacer'
import { ComponentReplacer, SubNodeConverter } from './replace-components/ComponentReplacer'
import { GistReplacer } from './replace-components/gist/gist-replacer'
import { HighlightedCodeReplacer } from './replace-components/highlighted-fence/highlighted-fence-replacer'
import { ImageReplacer } from './replace-components/image/image-replacer'
@ -63,7 +65,6 @@ import { QuoteOptionsReplacer } from './replace-components/quote-options/quote-o
import { TocReplacer } from './replace-components/toc/toc-replacer'
import { VimeoReplacer } from './replace-components/vimeo/vimeo-replacer'
import { YoutubeReplacer } from './replace-components/youtube/youtube-replacer'
import emojiData from 'emoji-mart/data/twitter.json'
export interface MarkdownRendererProps {
content: string
@ -77,12 +78,29 @@ export interface MarkdownRendererProps {
const markdownItTwitterEmojis = Object.keys((emojiData as unknown as Data).emojis)
.reduce((reduceObject, emojiIdentifier) => {
const emoji = (emojiData as unknown as Data).emojis[emojiIdentifier]
if (emoji.b) {
reduceObject[emojiIdentifier] = `&#x${emoji.b};`
if (emoji.unified) {
reduceObject[emojiIdentifier] = emoji.unified.split('-').map(char => `&#x${char};`).join('')
}
return reduceObject
}, {} as { [key: string]: string })
const emojiSkinToneModifierMap = [2, 3, 4, 5, 6]
.reduce((reduceObject, modifierValue) => {
const lightSkinCode = 127995
const codepoint = lightSkinCode + (modifierValue - 2)
const shortcode = `skin-tone-${modifierValue}`
reduceObject[shortcode] = `&#${codepoint};`
return reduceObject
}, {} as { [key: string]: string })
const forkAwesomeIconMap = Object.keys(ForkAwesomeIcons)
.reduce((reduceObject, icon) => {
const shortcode = `fa-${icon}`
// noinspection CheckTagEmptyBody
reduceObject[shortcode] = `<i class="fa fa-${icon}"></i>`
return reduceObject
}, {} as { [key: string]: string })
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onMetaDataChange, onFirstHeadingChange, onTocChange, className, wide }) => {
const [tocAst, setTocAst] = useState<TocAst>()
const [lastTocAst, setLastTocAst] = useState<TocAst>()
@ -154,7 +172,11 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onM
md.use(plantumlError)
}
md.use(emoji, {
defs: markdownItTwitterEmojis
defs: {
...markdownItTwitterEmojis,
...emojiSkinToneModifierMap,
...forkAwesomeIconMap
}
})
md.use(abbreviation)
md.use(definitionList)

View file

@ -0,0 +1,15 @@
import 'emoji-mart'
declare module 'emoji-mart' {
export interface SearchOption {
emojisToShowFilter: (emoji: EmojiData) => boolean
maxResults: number,
include: EmojiData[]
exclude: EmojiData[]
custom: EmojiData[]
}
export class NimbleEmojiIndex {
search (query: string, options: SearchOption): EmojiData[] | null;
}
}

View file

@ -10,7 +10,7 @@ html {
body {
min-height: 100%;
background-color: darken($dark, 8%);
font-family: "Source Sans Pro", Helvetica, Arial, sans-serif;
font-family: "Source Sans Pro", Helvetica, Arial, twemoji, sans-serif;
}
*:focus {

15
src/utils/emoji.ts Normal file
View file

@ -0,0 +1,15 @@
import { BaseEmoji, CustomEmoji, EmojiData } from 'emoji-mart'
export const getEmojiIcon = (emoji: EmojiData):string => {
if ((emoji as BaseEmoji).native) {
return (emoji as BaseEmoji).native
} else if ((emoji as CustomEmoji).imageUrl) {
// noinspection CheckTagEmptyBody
return `<i class="fa ${(emoji as CustomEmoji).name}"></i>`
}
return ''
}
export const getEmojiShortCode = (emoji: EmojiData):string => {
return (emoji as BaseEmoji).colons
}