mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 03:06:31 -05:00
fix: Emoji picker overlay
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
526c16e609
commit
711b520421
4 changed files with 143 additions and 122 deletions
|
@ -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>
|
||||
|
|
|
@ -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'
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue