mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-02 07:59:56 -04:00
Replace emoji-mart with emoji-picker-element (#620)
* Change dependencies * Use emoji-picker-element instead of emoji-mart * Optimize emoji-picker appeareance and data-source * Add twemoji font to emoji-picker * Add missing useEffect dependency * Add emoji-shortcode map * Include emoji-data into bundle and remove dynamic fetch * Rename shortcode-map * Fix emoji-picker being hidden on second attempt to open it * Add support for skin-tone short-codes * Remove whitespace line * Don't reinitialize the picker on every open * Fixed linting and test issues * Update CHANGELOG entry
This commit is contained in:
parent
fe40d7247d
commit
5574f09ef5
15 changed files with 203 additions and 167 deletions
|
@ -1,11 +1,14 @@
|
|||
import { Editor, Hint, Hints, Pos } from 'codemirror'
|
||||
import { Data, EmojiData, NimbleEmojiIndex } from 'emoji-mart'
|
||||
import data from 'emoji-mart/data/twitter.json'
|
||||
import Database from 'emoji-picker-element/database'
|
||||
import { Emoji, EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared'
|
||||
import { customEmojis } from '../tool-bar/emoji-picker/emoji-picker'
|
||||
import { getEmojiIcon, getEmojiShortCode } from '../tool-bar/utils/emojiUtils'
|
||||
import { findWordAtCursor, Hinter } from './index'
|
||||
|
||||
const emojiIndex = new NimbleEmojiIndex(data as unknown as Data)
|
||||
const emojiIndex = new Database({
|
||||
customEmoji: customEmojis,
|
||||
dataSource: '/static/js/emoji-data.json'
|
||||
})
|
||||
const emojiWordRegex = /^:([\w-_+]*)$/
|
||||
|
||||
const generateEmojiHints = (editor: Editor): Promise< Hints| null > => {
|
||||
|
@ -17,34 +20,40 @@ const generateEmojiHints = (editor: Editor): Promise< Hints| null > => {
|
|||
return
|
||||
}
|
||||
const term = searchResult[1]
|
||||
let search: EmojiData[] | null = emojiIndex.search(term, {
|
||||
emojisToShowFilter: () => true,
|
||||
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): Hint => ({
|
||||
text: getEmojiShortCode(emojiData),
|
||||
render: (parent: HTMLLIElement) => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.innerHTML = `${getEmojiIcon(emojiData)} ${getEmojiShortCode(emojiData)}`
|
||||
parent.appendChild(wrapper)
|
||||
let suggestionList: Emoji[]
|
||||
emojiIndex.getEmojiBySearchQuery(term)
|
||||
.then(async (result) => {
|
||||
suggestionList = result
|
||||
if (result.length === 0) {
|
||||
suggestionList = await emojiIndex.getTopFavoriteEmoji(7)
|
||||
}
|
||||
const cursor = editor.getCursor()
|
||||
const skinTone = await emojiIndex.getPreferredSkinTone()
|
||||
const emojiEventDetails: EmojiClickEventDetail[] = suggestionList.map((emoji) => {
|
||||
return {
|
||||
emoji,
|
||||
skinTone: skinTone,
|
||||
unicode: ((emoji as NativeEmoji).unicode ? (emoji as NativeEmoji).unicode : undefined),
|
||||
name: emoji.name
|
||||
}
|
||||
})),
|
||||
from: Pos(cursor.line, searchTerm.start),
|
||||
to: Pos(cursor.line, searchTerm.end)
|
||||
})
|
||||
resolve({
|
||||
list: emojiEventDetails.map((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)
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
resolve(null)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -16,10 +16,13 @@ export const EmojiPickerButton: React.FC<EmojiPickerButtonProps> = ({ editor })
|
|||
|
||||
return (
|
||||
<Fragment>
|
||||
<EmojiPicker show={showEmojiPicker} onEmojiSelected={(emoji) => {
|
||||
setShowEmojiPicker(false)
|
||||
addEmoji(emoji, editor)
|
||||
}} onDismiss={() => setShowEmojiPicker(false)}/>
|
||||
<EmojiPicker
|
||||
show={showEmojiPicker}
|
||||
onEmojiSelected={(emoji) => {
|
||||
setShowEmojiPicker(false)
|
||||
addEmoji(emoji, editor)
|
||||
}}
|
||||
onDismiss={() => setShowEmojiPicker(false)}/>
|
||||
<Button variant='light' onClick={() => setShowEmojiPicker(old => !old)} title={t('editor.editorToolbar.emoji')}>
|
||||
<ForkAwesomeIcon icon="smile-o"/>
|
||||
</Button>
|
||||
|
|
|
@ -1,10 +1,3 @@
|
|||
@import '../../../../../../node_modules/emoji-mart/css/emoji-mart';
|
||||
|
||||
.emoji-mart {
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.emoji-mart-emoji-native {
|
||||
font-family: "twemoji", monospace;
|
||||
.emoji-picker-container {
|
||||
z-index: 1111;
|
||||
}
|
||||
|
|
|
@ -1,47 +1,81 @@
|
|||
import { CustomEmoji, Data, EmojiData, NimblePicker } from 'emoji-mart'
|
||||
import emojiData from 'emoji-mart/data/twitter.json'
|
||||
import React, { useRef } from 'react'
|
||||
import { Picker } from 'emoji-picker-element'
|
||||
import { CustomEmoji, EmojiClickEvent, EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useClickAway } from 'react-use'
|
||||
import { ShowIf } from '../../../../common/show-if/show-if'
|
||||
import { ApplicationState } from '../../../../../redux'
|
||||
import './emoji-picker.scss'
|
||||
import forkawesomeIcon from './forkawesome.png'
|
||||
import { ForkAwesomeIcons } from './icon-names'
|
||||
|
||||
export interface EmojiPickerProps {
|
||||
show: boolean
|
||||
onEmojiSelected: (emoji: EmojiData) => void
|
||||
onEmojiSelected: (emoji: EmojiClickEventDetail) => void
|
||||
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: forkawesomeIcon,
|
||||
customCategory: 'ForkAwesome'
|
||||
shortcodes: [`fa-${name.toLowerCase()}`],
|
||||
url: forkawesomeIcon,
|
||||
category: 'ForkAwesome'
|
||||
}))
|
||||
|
||||
export const EmojiPicker: React.FC<EmojiPickerProps> = ({ show, onEmojiSelected, onDismiss }) => {
|
||||
const pickerRef = useRef(null)
|
||||
const darkModeEnabled = useSelector((state: ApplicationState) => state.darkMode.darkMode)
|
||||
const pickerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const firstOpened = useRef(false)
|
||||
|
||||
useClickAway(pickerRef, () => {
|
||||
useClickAway(pickerContainerRef, () => {
|
||||
onDismiss()
|
||||
})
|
||||
|
||||
const emojiClickListener = useCallback((event) => {
|
||||
onEmojiSelected((event as EmojiClickEvent).detail)
|
||||
}, [onEmojiSelected])
|
||||
|
||||
const twemojiStyle = useMemo(() => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'section.picker { --font-family: "twemoji" !important; }'
|
||||
return style
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerContainerRef.current || firstOpened.current) {
|
||||
return
|
||||
}
|
||||
const picker = new Picker({
|
||||
customEmoji: customEmojis,
|
||||
dataSource: '/static/js/emoji-data.json'
|
||||
})
|
||||
const container = pickerContainerRef.current
|
||||
picker.addEventListener('emoji-click', emojiClickListener)
|
||||
if (picker.shadowRoot) {
|
||||
picker.shadowRoot.appendChild(twemojiStyle)
|
||||
}
|
||||
container.appendChild(picker)
|
||||
firstOpened.current = true
|
||||
}, [pickerContainerRef, emojiClickListener, darkModeEnabled, twemojiStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerContainerRef.current) {
|
||||
return
|
||||
}
|
||||
const pickerDomList = pickerContainerRef.current.getElementsByTagName('emoji-picker')
|
||||
if (pickerDomList.length === 0) {
|
||||
return
|
||||
}
|
||||
const picker = pickerDomList[0]
|
||||
picker.setAttribute('class', darkModeEnabled ? 'dark' : 'light')
|
||||
if (darkModeEnabled) {
|
||||
picker.removeAttribute('style')
|
||||
} else {
|
||||
picker.setAttribute('style', '--background: #f8f9fa')
|
||||
}
|
||||
}, [darkModeEnabled, pickerContainerRef, firstOpened])
|
||||
|
||||
// noinspection CheckTagEmptyBody
|
||||
return (
|
||||
<ShowIf condition={show}>
|
||||
<div className={'position-relative'} ref={pickerRef}>
|
||||
<NimblePicker
|
||||
data={emojiData as unknown as Data}
|
||||
native={true}
|
||||
onSelect={onEmojiSelected}
|
||||
theme={'auto'}
|
||||
title=''
|
||||
custom={customEmojis}
|
||||
/>
|
||||
</div>
|
||||
</ShowIf>
|
||||
<div className={`position-absolute emoji-picker-container ${!show ? 'd-none' : ''}`} ref={pickerContainerRef}></div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import { BaseEmoji, CustomEmoji, EmojiData } from 'emoji-mart'
|
||||
import { EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared'
|
||||
|
||||
export const getEmojiIcon = (emoji: EmojiData):string => {
|
||||
if ((emoji as BaseEmoji).native) {
|
||||
return (emoji as BaseEmoji).native
|
||||
} else if ((emoji as CustomEmoji).imageUrl) {
|
||||
export const getEmojiIcon = (emoji: EmojiClickEventDetail): string => {
|
||||
if (emoji.unicode) {
|
||||
return emoji.unicode
|
||||
}
|
||||
if (emoji.name) {
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<i class="fa ${(emoji as CustomEmoji).name}"></i>`
|
||||
return `<i class="fa ${emoji.name}"></i>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getEmojiShortCode = (emoji: EmojiData):string => {
|
||||
return (emoji as BaseEmoji).colons
|
||||
export const getEmojiShortCode = (emoji: EmojiClickEventDetail): string => {
|
||||
let skinToneModifier = ''
|
||||
if ((emoji.emoji as NativeEmoji).skins && emoji.skinTone !== 0) {
|
||||
skinToneModifier = `:skin-tone-${emoji.skinTone as number}:`
|
||||
}
|
||||
return `:${emoji.emoji.shortcodes[0]}:${skinToneModifier}`
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Editor, Position, Range } from 'codemirror'
|
||||
import { EmojiData } from 'emoji-mart'
|
||||
import CodeMirror, { Editor, Position, Range } from 'codemirror'
|
||||
import { EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import {
|
||||
addCodeFences,
|
||||
|
@ -1762,8 +1762,23 @@ describe('test addTable', () => {
|
|||
describe('test addEmoji with native emoji', () => {
|
||||
const { cursor, firstLine, multiline, multilineOffset } = buildRanges()
|
||||
const textFirstLine = testContent.split('\n')[0]
|
||||
const emoji = Mock.of<EmojiData>({
|
||||
colons: ':+1:'
|
||||
const emoji = Mock.of<EmojiClickEventDetail>({
|
||||
emoji: {
|
||||
annotation: 'input numbers',
|
||||
group: 8,
|
||||
order: 3809,
|
||||
shortcodes: [
|
||||
'1234'
|
||||
],
|
||||
tags: [
|
||||
'1234',
|
||||
'input',
|
||||
'numbers'
|
||||
],
|
||||
unicode: '🔢',
|
||||
version: 0.6
|
||||
},
|
||||
unicode: '🔢'
|
||||
})
|
||||
it('just cursor', done => {
|
||||
Mock.extend(editor).with({
|
||||
|
@ -1778,7 +1793,7 @@ describe('test addEmoji with native emoji', () => {
|
|||
),
|
||||
getLine: (): string => (textFirstLine),
|
||||
replaceRange: (replacement: string | string[]) => {
|
||||
expect(replacement).toEqual(':+1:')
|
||||
expect(replacement).toEqual(':1234:')
|
||||
done()
|
||||
}
|
||||
})
|
||||
|
@ -1800,7 +1815,7 @@ describe('test addEmoji with native emoji', () => {
|
|||
replaceRange: (replacement: string | string[], from: CodeMirror.Position, to?: CodeMirror.Position) => {
|
||||
expect(from).toEqual(firstLine.from)
|
||||
expect(to).toEqual(firstLine.to)
|
||||
expect(replacement).toEqual(':+1:')
|
||||
expect(replacement).toEqual(':1234:')
|
||||
done()
|
||||
}
|
||||
})
|
||||
|
@ -1822,7 +1837,7 @@ describe('test addEmoji with native emoji', () => {
|
|||
replaceRange: (replacement: string | string[], from: CodeMirror.Position, to?: CodeMirror.Position) => {
|
||||
expect(from).toEqual(multiline.from)
|
||||
expect(to).toEqual(multiline.to)
|
||||
expect(replacement).toEqual(':+1:')
|
||||
expect(replacement).toEqual(':1234:')
|
||||
done()
|
||||
}
|
||||
})
|
||||
|
@ -1844,7 +1859,7 @@ describe('test addEmoji with native emoji', () => {
|
|||
replaceRange: (replacement: string | string[], from: CodeMirror.Position, to?: CodeMirror.Position) => {
|
||||
expect(from).toEqual(multilineOffset.from)
|
||||
expect(to).toEqual(multilineOffset.to)
|
||||
expect(replacement).toEqual(':+1:')
|
||||
expect(replacement).toEqual(':1234:')
|
||||
done()
|
||||
}
|
||||
})
|
||||
|
@ -1856,10 +1871,16 @@ describe('test addEmoji with native emoji', () => {
|
|||
const { cursor, firstLine, multiline, multilineOffset } = buildRanges()
|
||||
const textFirstLine = testContent.split('\n')[0]
|
||||
const forkAwesomeIcon = ':fa-star:'
|
||||
const emoji = Mock.of<EmojiData>({
|
||||
name: 'star',
|
||||
colons: ':fa-star:',
|
||||
imageUrl: '/img/forkawesome.png'
|
||||
const emoji = Mock.of<EmojiClickEventDetail>({
|
||||
emoji: {
|
||||
name: 'fa-star',
|
||||
shortcodes: [
|
||||
'fa-star'
|
||||
],
|
||||
url: '/img/forkawesome.png'
|
||||
},
|
||||
skinTone: 0,
|
||||
name: 'fa-star'
|
||||
})
|
||||
it('just cursor', done => {
|
||||
Mock.extend(editor).with({
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Editor } from 'codemirror'
|
||||
import { EmojiData } from 'emoji-mart'
|
||||
import { EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
||||
import { getEmojiShortCode } from './emojiUtils'
|
||||
|
||||
export const makeSelectionBold = (editor: Editor): void => wrapTextWith(editor, '**')
|
||||
|
@ -25,7 +25,7 @@ export const addCollapsableBlock = (editor: Editor): void => changeLines(editor,
|
|||
export const addComment = (editor: Editor): void => changeLines(editor, line => `${line}\n> []`)
|
||||
export const addTable = (editor: Editor): void => changeLines(editor, line => `${line}\n| # 1 | # 2 | # 3 |\n| ---- | ---- | ---- |\n| Text | Text | Text |`)
|
||||
|
||||
export const addEmoji = (emoji: EmojiData, editor: Editor): void => {
|
||||
export const addEmoji = (emoji: EmojiClickEventDetail, editor: Editor): void => {
|
||||
insertAtCursor(editor, getEmojiShortCode(emoji))
|
||||
}
|
||||
|
||||
|
|
|
@ -1,36 +1,40 @@
|
|||
import emojiData from 'emoji-mart/data/twitter.json'
|
||||
import { Data } from 'emoji-mart/dist-es/utils/data'
|
||||
import { ForkAwesomeIcons } from '../../../editor/editor-pane/tool-bar/emoji-picker/icon-names'
|
||||
import emojiData from 'emojibase-data/en/compact.json'
|
||||
|
||||
export const markdownItTwitterEmojis = Object.keys((emojiData as unknown as Data).emojis)
|
||||
.reduce((reduceObject, emojiIdentifier) => {
|
||||
const emoji = (emojiData as unknown as Data).emojis[emojiIdentifier]
|
||||
const emojiCodes = emoji.unified ?? emoji.b
|
||||
if (emojiCodes) {
|
||||
reduceObject[emojiIdentifier] = emojiCodes.split('-').map(char => `&#x${char};`).join('')
|
||||
}
|
||||
interface EmojiEntry {
|
||||
shortcodes: string[]
|
||||
unicode: string
|
||||
}
|
||||
|
||||
type ShortCodeMap = { [key: string]: string }
|
||||
|
||||
const shortCodeMap = (emojiData as unknown as EmojiEntry[])
|
||||
.reduce((reduceObject, emoji) => {
|
||||
emoji.shortcodes.forEach(shortcode => {
|
||||
reduceObject[shortcode] = emoji.unicode
|
||||
})
|
||||
return reduceObject
|
||||
}, {} as { [key: string]: string })
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
export const emojiSkinToneModifierMap = [2, 3, 4, 5, 6]
|
||||
const emojiSkinToneModifierMap = [1, 2, 3, 4, 5]
|
||||
.reduce((reduceObject, modifierValue) => {
|
||||
const lightSkinCode = 127995
|
||||
const codepoint = lightSkinCode + (modifierValue - 2)
|
||||
const codepoint = lightSkinCode + (modifierValue - 1)
|
||||
const shortcode = `skin-tone-${modifierValue}`
|
||||
reduceObject[shortcode] = `&#${codepoint};`
|
||||
return reduceObject
|
||||
}, {} as { [key: string]: string })
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
export const forkAwesomeIconMap = Object.keys(ForkAwesomeIcons)
|
||||
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 })
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
export const combinedEmojiData = {
|
||||
...markdownItTwitterEmojis,
|
||||
...shortCodeMap,
|
||||
...emojiSkinToneModifierMap,
|
||||
...forkAwesomeIconMap
|
||||
}
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue