fix: Emoji picker overlay

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-11-08 22:22:17 +01:00
parent 526c16e609
commit 711b520421
4 changed files with 143 additions and 122 deletions

View file

@ -4,48 +4,66 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useState } from 'react'
import { Button } from 'react-bootstrap'
import React, { Fragment, useCallback, useRef, useState } from 'react'
import { Button, Overlay } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { EmojiPicker } from './emoji-picker'
import { EmojiPickerPopover } from './emoji-picker-popover'
import { cypressId } from '../../../../../utils/cypress-attribute'
import type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
import { Optional } from '@mrdrogdrog/optional'
import { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback'
import { replaceSelection } from '../formatters/replace-selection'
import { extractEmojiShortCode } from './extract-emoji-short-code'
import styles from './emoji-picker.module.scss'
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
/**
* Renders a button to open the emoji picker.
* @see EmojiPicker
* @see EmojiPickerPopover
*/
export const EmojiPickerButton: React.FC = () => {
const { t } = useTranslation()
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const changeEditorContent = useChangeEditorContentCallback()
const buttonRef = useRef(null)
const onEmojiSelected = useCallback(
(emojiClickEvent: EmojiClickEventDetail) => {
setShowEmojiPicker(false)
Optional.ofNullable(extractEmojiShortCode(emojiClickEvent)).ifPresent((shortCode) => {
const shortCode = extractEmojiShortCode(emojiClickEvent)
if (shortCode) {
changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, shortCode, false))
})
}
},
[changeEditorContent]
)
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
const createPopoverElement = useCallback<(props: OverlayInjectedProps) => React.ReactElement>(
(props) => <EmojiPickerPopover {...props} className={styles.tooltip} onEmojiSelected={onEmojiSelected} />,
[onEmojiSelected]
)
return (
<Fragment>
<EmojiPicker show={showEmojiPicker} onEmojiSelected={onEmojiSelected} onDismiss={hidePicker} />
<Overlay
show={showEmojiPicker}
onHide={hidePicker}
placement={'auto'}
flip={true}
target={buttonRef.current}
rootClose={true}
offset={[0, 0]}>
{createPopoverElement}
</Overlay>
<Button
{...cypressId('show-emoji-picker')}
variant='light'
onClick={showPicker}
title={t('editor.editorToolbar.emoji')}
disabled={!changeEditorContent}>
disabled={!changeEditorContent}
ref={buttonRef}>
<ForkAwesomeIcon icon='smile-o' />
</Button>
</Fragment>

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Picker } from 'emoji-picker-element'
import type { CustomEmoji, EmojiClickEvent, EmojiClickEventDetail } from 'emoji-picker-element/shared'
import React, { useEffect, useRef } from 'react'
import { useIsDarkModeActivated } from '../../../../../hooks/common/use-is-dark-mode-activated'
import styles from './emoji-picker.module.scss'
import forkawesomeIcon from './forkawesome.png'
import { ForkAwesomeIcons } from '../../../../common/fork-awesome/fork-awesome-icons'
import fontStyles from '../../../../../../global-styles/variables.module.scss'
import { Popover } from 'react-bootstrap'
import type { PopoverProps } from 'react-bootstrap/Popover'
const customEmojis: CustomEmoji[] = ForkAwesomeIcons.map((name) => ({
name: `fa-${name}`,
shortcodes: [`fa-${name.toLowerCase()}`],
url: forkawesomeIcon.src,
category: 'ForkAwesome'
}))
const EMOJI_DATA_PATH = '_next/static/js/emoji-data.json'
const emojiPickerConfig = {
customEmoji: customEmojis,
dataSource: EMOJI_DATA_PATH
}
const twemojiStyle = (): HTMLStyleElement => {
const style = document.createElement('style')
style.textContent = `section.picker { --font-family: ${fontStyles['font-family-emojis']} !important; }`
return style
}
export interface EmojiPickerProps extends PopoverProps {
onEmojiSelected: (emoji: EmojiClickEventDetail) => void
}
/**
* Renders the emoji picker.
*
* @param show If the emoji picker should be shown
* @param onEmojiSelected The callback, that will be called if an emoji is selected
* @param onDismiss The callback, that will be called if the picker should be closed.
* @external {Picker} https://www.npmjs.com/package/emoji-picker-element
*/
export const EmojiPickerPopover = React.forwardRef<HTMLDivElement, EmojiPickerProps>(
({ onEmojiSelected, ...props }, ref) => {
const darkModeEnabled = useIsDarkModeActivated()
const pickerContainerRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<Picker>()
useEffect(() => {
if (!pickerContainerRef.current) {
return
}
const picker = new Picker(emojiPickerConfig)
if (picker.shadowRoot) {
picker.shadowRoot.appendChild(twemojiStyle())
}
pickerContainerRef.current.appendChild(picker)
pickerRef.current = picker
return () => {
picker.remove()
pickerRef.current = undefined
}
}, [])
useEffect(() => {
if (!pickerRef.current) {
return
}
const emojiClick = (event: EmojiClickEvent): void => {
onEmojiSelected(event.detail)
}
const picker = pickerRef.current
picker.addEventListener('emoji-click', emojiClick, true)
return () => {
picker.removeEventListener('emoji-click', emojiClick, true)
}
}, [onEmojiSelected])
useEffect(() => {
if (!pickerRef.current) {
return
}
pickerRef.current.setAttribute('class', darkModeEnabled ? 'dark' : 'light')
if (darkModeEnabled) {
pickerRef.current.removeAttribute('style')
} else {
pickerRef.current.setAttribute('style', '--background: #f8f9fa')
}
}, [darkModeEnabled])
return (
<Popover {...props} ref={ref} className={styles.tooltip}>
<Popover.Body>
<div ref={pickerContainerRef} />
</Popover.Body>
</Popover>
)
}
)
EmojiPickerPopover.displayName = 'EmojiPickerPopover'

View file

@ -1,9 +1,13 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
/*!
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.emoji-picker-container {
z-index: 1111;
.tooltip {
&, :global(body.dark) & {
--bs-popover-max-width: 100%;
--bs-popover-body-padding-y: 0;
--bs-popover-body-padding-x: 0;
}
}

View file

@ -1,109 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Picker } from 'emoji-picker-element'
import type { CustomEmoji, EmojiClickEvent, EmojiClickEventDetail } from 'emoji-picker-element/shared'
import React, { useEffect, useRef } from 'react'
import { useClickAway } from 'react-use'
import { useIsDarkModeActivated } from '../../../../../hooks/common/use-is-dark-mode-activated'
import styles from './emoji-picker.module.scss'
import forkawesomeIcon from './forkawesome.png'
import { ForkAwesomeIcons } from '../../../../common/fork-awesome/fork-awesome-icons'
import fontStyles from '../../../../../../global-styles/variables.module.scss'
const customEmojis: CustomEmoji[] = ForkAwesomeIcons.map((name) => ({
name: `fa-${name}`,
shortcodes: [`fa-${name.toLowerCase()}`],
url: forkawesomeIcon.src,
category: 'ForkAwesome'
}))
const EMOJI_DATA_PATH = '_next/static/js/emoji-data.json'
const emojiPickerConfig = {
customEmoji: customEmojis,
dataSource: EMOJI_DATA_PATH
}
const twemojiStyle = (): HTMLStyleElement => {
const style = document.createElement('style')
style.textContent = `section.picker { --font-family: ${fontStyles['font-family-emojis']} !important; }`
return style
}
export interface EmojiPickerProps {
show: boolean
onEmojiSelected: (emoji: EmojiClickEventDetail) => void
onDismiss: () => void
}
/**
* Renders the emoji picker.
*
* @param show If the emoji picker should be shown
* @param onEmojiSelected The callback, that will be called if an emoji is selected
* @param onDismiss The callback, that will be called if the picker should be closed.
* @external {Picker} https://www.npmjs.com/package/emoji-picker-element
*/
export const EmojiPicker: React.FC<EmojiPickerProps> = ({ show, onEmojiSelected, onDismiss }) => {
const darkModeEnabled = useIsDarkModeActivated()
const pickerContainerRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<Picker>()
useClickAway(pickerContainerRef, () => {
onDismiss()
})
useEffect(() => {
if (!pickerContainerRef.current) {
return
}
const picker = new Picker(emojiPickerConfig)
if (picker.shadowRoot) {
picker.shadowRoot.appendChild(twemojiStyle())
}
pickerContainerRef.current.appendChild(picker)
pickerRef.current = picker
return () => {
picker.remove()
pickerRef.current = undefined
}
}, [])
useEffect(() => {
if (!pickerRef.current) {
return
}
const emojiClick = (event: EmojiClickEvent): void => {
onEmojiSelected(event.detail)
}
const picker = pickerRef.current
picker.addEventListener('emoji-click', emojiClick, true)
return () => {
picker.removeEventListener('emoji-click', emojiClick, true)
}
}, [onEmojiSelected])
useEffect(() => {
if (!pickerRef.current) {
return
}
pickerRef.current.setAttribute('class', darkModeEnabled ? 'dark' : 'light')
if (darkModeEnabled) {
pickerRef.current.removeAttribute('style')
} else {
pickerRef.current.setAttribute('style', '--background: #f8f9fa')
}
}, [darkModeEnabled])
return (
<div
className={`position-absolute ${styles['emoji-picker-container']} ${!show ? 'd-none' : ''}`}
ref={pickerContainerRef}
/>
)
}