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:
Erik Michelson 2020-10-10 23:12:17 +02:00 committed by GitHub
parent fe40d7247d
commit 5574f09ef5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 203 additions and 167 deletions

View file

@ -35,7 +35,7 @@
- HedgeDoc instances can now be branded either with a '@ <custom string>' or '@ <custom logo>' after the HedgeDoc logo and text
- 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
- The toolbar includes an emoji and fork-awesome icon picker.
- Collapsable blocks can be added via a toolbar button or via autocompletion of "<details"
- Added shortcodes for [fork-awesome icons](https://forkaweso.me/Fork-Awesome/icons/) (e.g. `:fa-picture-o:`)
- The code button now adds code fences even if the user selected nothing beforehand

View file

@ -8,7 +8,8 @@ module.exports = {
new CopyPlugin({
patterns: [
{ from: 'node_modules/@hpcc-js/wasm/dist/graphvizlib.wasm', to: 'static/js' },
{ from: 'node_modules/@hpcc-js/wasm/dist/expatlib.wasm', to: 'static/js' }
{ from: 'node_modules/@hpcc-js/wasm/dist/expatlib.wasm', to: 'static/js' },
{ from: 'node_modules/emojibase-data/en/data.json', to: 'static/js/emoji-data.json' }
],
}),
...when(Boolean(process.env.ANALYZE), () => [

View file

@ -80,7 +80,7 @@ describe('Autocompletion', () => {
describe('normal emoji', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':book')
.type(':hedg')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
@ -88,29 +88,29 @@ describe('Autocompletion', () => {
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':book:')
.should('have.text', ':hedgehog:')
cy.get('.markdown-body')
.should('have.text', '📖')
.should('have.text', '🦔')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type(':book')
.type(':hedg')
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:')
.should('have.text', ':hedgehog:')
cy.get('.markdown-body')
.should('have.text', '📖')
.should('have.text', '🦔')
})
})
describe('fork-awesome-icon', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type(':facebook')
.type(':fa-face')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
@ -124,7 +124,7 @@ describe('Autocompletion', () => {
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type(':facebook')
.type(':fa-face')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()

View file

@ -275,28 +275,14 @@ describe('Toolbar', () => {
.should('have.text', '> []')
})
describe('emoji', () => {
it('picker is show when clicked', () => {
cy.get('.emoji-mart')
.should('not.exist')
describe('emoji-picker', () => {
it('show when clicked', () => {
cy.get('emoji-picker')
.should('not.be.visible')
cy.get('.fa-smile-o')
.click()
cy.get('.emoji-mart')
.should('exist')
})
it('picker is show when clicked', () => {
cy.get('.fa-smile-o')
.click()
cy.get('.emoji-mart')
.should('exist')
cy.get('.emoji-mart-emoji-native')
.first()
.click()
cy.get('.markdown-body')
.should('have.text', '👍')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span ')
.should('have.text', ':+1:')
cy.get('emoji-picker')
.should('be.visible')
})
})

View file

@ -13,7 +13,6 @@
"@types/d3-graphviz": "2.6.6",
"@types/diff": "4.0.2",
"@types/domhandler": "2.4.1",
"@types/emoji-mart": "3.0.2",
"@types/highlight.js": "9.12.4",
"@types/jest": "26.0.14",
"@types/js-yaml": "3.12.5",
@ -41,7 +40,8 @@
"copy-webpack-plugin": "6.2.1",
"d3-graphviz": "3.1.0",
"diff": "4.0.2",
"emoji-mart": "3.0.0",
"emoji-picker-element": "^1.2.1",
"emojibase-data": "5",
"eslint-config-react-app": "5.2.1",
"eslint-config-standard": "14.1.1",
"eslint-plugin-flowtype": "5.2.0",

View file

@ -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)
})
}
})
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -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}`
}

View file

@ -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({

View file

@ -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))
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -2066,13 +2066,6 @@
dependencies:
domhandler "^2.4.0"
"@types/emoji-mart@3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.2.tgz#5814064ce7c622069adf1583e17b3851a00802cb"
integrity sha512-Cmq8xpPK5Va+fjQE7ZaE5oykXzACBQ64CpNnYOIU7gWcR6nYTxWjMR3yPhnAMzw4yQn9R9761FpTvAyi/SH9MQ==
dependencies:
"@types/react" "*"
"@types/eslint-visitor-keys@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
@ -5755,13 +5748,10 @@ elliptic@^6.5.3:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"
emoji-mart@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-3.0.0.tgz#eca24a04881e27752a6921e09f65a86ce8539a50"
integrity sha512-r5DXyzOLJttdwRYfJmPq/XL3W5tiAE/VsRnS0Hqyn27SqPA/GOYwVUSx50px/dXdJyDSnvmoPbuJ/zzhwSaU4A==
dependencies:
"@babel/runtime" "^7.0.0"
prop-types "^15.6.0"
emoji-picker-element@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/emoji-picker-element/-/emoji-picker-element-1.2.1.tgz#a8eb99035e07f970c16e202a6e0588dce15dda02"
integrity sha512-gk0NBg7G/S6ClfIUjRKchXLrl4o1dcvKmEamFT9GERHfCeAyi+afUeMhwVY168I65RiqjGCJkGpoTV2CVa2QNA==
emoji-regex@^7.0.1, emoji-regex@^7.0.2:
version "7.0.3"
@ -5778,6 +5768,11 @@ emoji-regex@^9.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.0.0.tgz#48a2309cc8a1d2e9d23bc6a67c39b63032e76ea4"
integrity sha512-6p1NII1Vm62wni/VR/cUMauVQoxmLVb9csqQlvLz+hO2gk8U2UYDfXHQSUYIBKmZwAKz867IDqG7B+u0mj+M6w==
emojibase-data@5:
version "5.1.1"
resolved "https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-5.1.1.tgz#0a0d63dd07ce1376b3d27642f28cafa46f651de6"
integrity sha512-za/ma5SfogHjwUmGFnDbTvSfm8GGFvFaPS27GPti16YZSp5EPgz+UDsZCATXvJGit+oRNBbG/FtybXHKi2UQgQ==
emojis-list@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
@ -11513,7 +11508,7 @@ prop-types-extra@^1.1.0:
react-is "^16.3.2"
warning "^4.0.0"
prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==