diff --git a/CHANGELOG.md b/CHANGELOG.md index 75459f821..18b5e189f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cypress/integration/autocompletion.spec.ts b/cypress/integration/autocompletion.spec.ts new file mode 100644 index 000000000..aa8f63e2b --- /dev/null +++ b/cypress/integration/autocompletion.spec.ts @@ -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') + }) + }) +}) diff --git a/src/components/editor/editor-window/editor-window.scss b/src/components/editor/editor-window/editor-window.scss index 08000812b..9a80ff180 100644 --- a/src/components/editor/editor-window/editor-window.scss +++ b/src/components/editor/editor-window/editor-window.scss @@ -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%; } + diff --git a/src/components/editor/editor-window/editor-window.tsx b/src/components/editor/editor-window/editor-window.tsx index 5b8cc81fc..4abb874b2 100644 --- a/src/components/editor/editor-window/editor-window.tsx +++ b/src/components/editor/editor-window/editor-window.tsx @@ -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 = ({ onContentChange, content }) => { const { t } = useTranslation() const [editor, setEditor] = useState() + const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => { + onContentChange(value) + }, [onContentChange]) + return (
= ({ 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} />
) } diff --git a/src/components/editor/editor-window/hints.scss b/src/components/editor/editor-window/hints.scss new file mode 100644 index 000000000..9382d19d3 --- /dev/null +++ b/src/components/editor/editor-window/hints.scss @@ -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; +} diff --git a/src/components/editor/editor-window/hints/emoji.ts b/src/components/editor/editor-window/hints/emoji.ts new file mode 100644 index 000000000..2fa0eb101 --- /dev/null +++ b/src/components/editor/editor-window/hints/emoji.ts @@ -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) + }) + } + }) +} diff --git a/src/components/editor/editor-window/tool-bar/emoji-picker/emoji-picker.tsx b/src/components/editor/editor-window/tool-bar/emoji-picker/emoji-picker.tsx index b9eaffdf2..94266f0da 100644 --- a/src/components/editor/editor-window/tool-bar/emoji-picker/emoji-picker.tsx +++ b/src/components/editor/editor-window/tool-bar/emoji-picker/emoji-picker.tsx @@ -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 = ({ 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 = ({ show, onEmojiSelected, onSelect={onEmojiSelected} theme={'auto'} title='' - custom={customIcons} + custom={customEmojis} /> diff --git a/src/components/editor/editor-window/tool-bar/utils.test.ts b/src/components/editor/editor-window/tool-bar/utils.test.ts index 455faa85e..02b5e319b 100644 --- a/src/components/editor/editor-window/tool-bar/utils.test.ts +++ b/src/components/editor/editor-window/tool-bar/utils.test.ts @@ -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 = '' + const forkAwesomeIcon = ':fa-star:' const emoji = Mock.of({ name: 'star', + colons: ':fa-star:', imageUrl: '/img/forkawesome.png' }) it('just cursor', done => { diff --git a/src/components/editor/editor-window/tool-bar/utils.ts b/src/components/editor/editor-window/tool-bar/utils.ts index 7113fcd43..c94124a3f 100644 --- a/src/components/editor/editor-window/tool-bar/utils.ts +++ b/src/components/editor/editor-window/tool-bar/utils.ts @@ -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 = `` - } - insertAtCursor(editor, replacement) + insertAtCursor(editor, getEmojiShortCode(emoji)) } export const wrapTextWith = (editor: Editor, symbol: string, endSymbol?: string): void => { diff --git a/src/components/editor/markdown-renderer/markdown-renderer.tsx b/src/components/editor/markdown-renderer/markdown-renderer.tsx index 5b1eaf7c1..ca6845175 100644 --- a/src/components/editor/markdown-renderer/markdown-renderer.tsx +++ b/src/components/editor/markdown-renderer/markdown-renderer.tsx @@ -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] = `` + return reduceObject + }, {} as { [key: string]: string }) + export const MarkdownRenderer: React.FC = ({ content, onMetaDataChange, onFirstHeadingChange, onTocChange, className, wide }) => { const [tocAst, setTocAst] = useState() const [lastTocAst, setLastTocAst] = useState() @@ -154,7 +172,11 @@ export const MarkdownRenderer: React.FC = ({ content, onM md.use(plantumlError) } md.use(emoji, { - defs: markdownItTwitterEmojis + defs: { + ...markdownItTwitterEmojis, + ...emojiSkinToneModifierMap, + ...forkAwesomeIconMap + } }) md.use(abbreviation) md.use(definitionList) diff --git a/src/external-types/emoji-mart/dist-es/utils/emoji-index/nimble-emoji-index.d.ts b/src/external-types/emoji-mart/dist-es/utils/emoji-index/nimble-emoji-index.d.ts new file mode 100644 index 000000000..ff06da275 --- /dev/null +++ b/src/external-types/emoji-mart/dist-es/utils/emoji-index/nimble-emoji-index.d.ts @@ -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; + } +} diff --git a/src/global-style/index.scss b/src/global-style/index.scss index 7d84cbc5e..5af86b539 100644 --- a/src/global-style/index.scss +++ b/src/global-style/index.scss @@ -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 { diff --git a/src/utils/emoji.ts b/src/utils/emoji.ts new file mode 100644 index 000000000..e42469da5 --- /dev/null +++ b/src/utils/emoji.ts @@ -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 `` + } + return '' +} + +export const getEmojiShortCode = (emoji: EmojiData):string => { + return (emoji as BaseEmoji).colons +}