mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 11:16: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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Fragment, useCallback, useState } from 'react'
|
import React, { Fragment, useCallback, useRef, useState } from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button, Overlay } from 'react-bootstrap'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
|
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 { cypressId } from '../../../../../utils/cypress-attribute'
|
||||||
import type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
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 { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback'
|
||||||
import { replaceSelection } from '../formatters/replace-selection'
|
import { replaceSelection } from '../formatters/replace-selection'
|
||||||
import { extractEmojiShortCode } from './extract-emoji-short-code'
|
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.
|
* Renders a button to open the emoji picker.
|
||||||
* @see EmojiPicker
|
* @see EmojiPickerPopover
|
||||||
*/
|
*/
|
||||||
export const EmojiPickerButton: React.FC = () => {
|
export const EmojiPickerButton: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
const changeEditorContent = useChangeEditorContentCallback()
|
const changeEditorContent = useChangeEditorContentCallback()
|
||||||
|
const buttonRef = useRef(null)
|
||||||
|
|
||||||
const onEmojiSelected = useCallback(
|
const onEmojiSelected = useCallback(
|
||||||
(emojiClickEvent: EmojiClickEventDetail) => {
|
(emojiClickEvent: EmojiClickEventDetail) => {
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
Optional.ofNullable(extractEmojiShortCode(emojiClickEvent)).ifPresent((shortCode) => {
|
const shortCode = extractEmojiShortCode(emojiClickEvent)
|
||||||
|
if (shortCode) {
|
||||||
changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, shortCode, false))
|
changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, shortCode, false))
|
||||||
})
|
}
|
||||||
},
|
},
|
||||||
[changeEditorContent]
|
[changeEditorContent]
|
||||||
)
|
)
|
||||||
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
|
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
|
||||||
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
|
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
|
||||||
|
|
||||||
|
const createPopoverElement = useCallback<(props: OverlayInjectedProps) => React.ReactElement>(
|
||||||
|
(props) => <EmojiPickerPopover {...props} className={styles.tooltip} onEmojiSelected={onEmojiSelected} />,
|
||||||
|
[onEmojiSelected]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<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
|
<Button
|
||||||
{...cypressId('show-emoji-picker')}
|
{...cypressId('show-emoji-picker')}
|
||||||
variant='light'
|
variant='light'
|
||||||
onClick={showPicker}
|
onClick={showPicker}
|
||||||
title={t('editor.editorToolbar.emoji')}
|
title={t('editor.editorToolbar.emoji')}
|
||||||
disabled={!changeEditorContent}>
|
disabled={!changeEditorContent}
|
||||||
|
ref={buttonRef}>
|
||||||
<ForkAwesomeIcon icon='smile-o' />
|
<ForkAwesomeIcon icon='smile-o' />
|
||||||
</Button>
|
</Button>
|
||||||
</Fragment>
|
</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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.emoji-picker-container {
|
.tooltip {
|
||||||
z-index: 1111;
|
&, :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