From d950d3a9350feac73f7c6f6f62143158fba58aaf Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 5 Jul 2023 18:56:32 -0700 Subject: [PATCH] Add mention components This adds the code required for us to mention objects in rich text fields like team descriptions. The mentionSuggestion util fetches data from the server and serves it to MentionList for the user to select, then inserts it into the Editor as a token. --- components/MentionList/index.module.scss | 43 ++++++++ components/MentionList/index.tsx | 118 +++++++++++++++++++++ extensions/CustomMention/index.tsx | 25 +++++ utils/mentionSuggestions.tsx | 124 +++++++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 components/MentionList/index.module.scss create mode 100644 components/MentionList/index.tsx create mode 100644 extensions/CustomMention/index.tsx create mode 100644 utils/mentionSuggestions.tsx diff --git a/components/MentionList/index.module.scss b/components/MentionList/index.module.scss new file mode 100644 index 00000000..77823123 --- /dev/null +++ b/components/MentionList/index.module.scss @@ -0,0 +1,43 @@ +.items { + background: #fff; + border-radius: $item-corner; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1); + color: rgba(0, 0, 0, 0.8); + overflow: hidden; + padding: $unit-half; + pointer-events: all; + position: relative; +} + +.item { + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: $item-corner-small; + color: var(--text-tertiary); + font-size: $font-small; + font-weight: $medium; + display: flex; + gap: $unit; + margin: 0; + padding: $unit-half $unit; + text-align: left; + width: 100%; + + &:hover, + &.selected { + background: var(--menu-bg-item-hover); + color: var(--text-primary); + } + + img { + border-radius: $item-corner-small; + width: $unit-4x; + height: $unit-4x; + } +} + +.noResult { + padding: $unit; + color: var(--text-tertiary); +} diff --git a/components/MentionList/index.tsx b/components/MentionList/index.tsx new file mode 100644 index 00000000..63243a23 --- /dev/null +++ b/components/MentionList/index.tsx @@ -0,0 +1,118 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react' +import { useTranslation } from 'next-i18next' +import { useRouter } from 'next/router' +import { SuggestionProps } from '@tiptap/suggestion' +import classNames from 'classnames' + +import styles from './index.module.scss' + +type Props = Pick + +export type MentionRef = { + onKeyDown: (props: { event: KeyboardEvent }) => boolean +} + +export type MentionSuggestion = { + granblue_id: string + name: { + [key: string]: string + en: string + ja: string + } + type: string + element: number +} + +interface MentionProps extends SuggestionProps { + items: MentionSuggestion[] +} + +export const MentionList = forwardRef( + ({ items, ...props }: Props, forwardedRef) => { + const router = useRouter() + const locale = router.locale || 'en' + + const { t } = useTranslation('common') + + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + const item = items[index] + + if (item) { + props.command({ id: item }) + } + } + + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [items]) + + useImperativeHandle(forwardedRef, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( +
+ {items.length ? ( + items.map((item, index) => ( + + )) + ) : ( +
+ {t('search.errors.no_results_generic')} +
+ )} +
+ ) + } +) diff --git a/extensions/CustomMention/index.tsx b/extensions/CustomMention/index.tsx new file mode 100644 index 00000000..a82e4305 --- /dev/null +++ b/extensions/CustomMention/index.tsx @@ -0,0 +1,25 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import Mention from '@tiptap/extension-mention' + +export default Mention.extend({ + renderHTML({ node, HTMLAttributes }) { + return [ + 'a', + mergeAttributes( + { + href: `https://gbf.wiki/${node.attrs.id.name.en}`, + target: '_blank', + }, + { 'data-type': this.name }, + { 'data-element': node.attrs.id.element.slug }, + { tabindex: -1 }, + this.options.HTMLAttributes, + HTMLAttributes + ), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + }, +}) diff --git a/utils/mentionSuggestions.tsx b/utils/mentionSuggestions.tsx new file mode 100644 index 00000000..5bcf4672 --- /dev/null +++ b/utils/mentionSuggestions.tsx @@ -0,0 +1,124 @@ +import { ReactRenderer } from '@tiptap/react' +import { MentionOptions } from '@tiptap/extension-mention' +import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion' +import tippy, { Instance as TippyInstance } from 'tippy.js' + +import { + MentionList, + MentionRef, + MentionSuggestion, +} from '~components/MentionList' +import api from '~utils/api' +import { numberToElement } from '~utils/elements' + +interface RawSearchResponse { + searchable_type: string + granblue_id: string + name_en: string + name_jp: string + element: number +} + +interface SearchResponse { + name: { + [key: string]: string + en: string + ja: string + } + type: string + granblue_id: string + element: GranblueElement +} + +function transform(object: RawSearchResponse) { + const result: SearchResponse = { + name: { + en: object.name_en, + ja: object.name_jp, + }, + type: object.searchable_type.toLowerCase(), + granblue_id: object.granblue_id, + element: numberToElement(object.element), + } + return result +} + +export const mentionSuggestionOptions: MentionOptions['suggestion'] = { + items: async ({ query }): Promise => { + const response = await api.searchAll(query) + const results = response.data.results + + return results + .map((rawObject: RawSearchResponse, index: number) => { + const object = transform(rawObject) + return { + granblue_id: object.granblue_id, + element: object.element, + type: object.type, + name: { + en: object.name.en, + ja: object.name.ja, + }, + } + }) + .slice(0, 7) + }, + + render: () => { + let component: ReactRenderer | undefined + let popup: TippyInstance | undefined + + return { + onStart: (props) => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + })[0] + }, + + onUpdate(props) { + component?.updateProps(props) + + popup?.setProps({ + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.hide() + return true + } + + if (!component?.ref) { + return false + } + + return component?.ref.onKeyDown(props) + }, + + onExit() { + popup?.destroy() + component?.destroy() + + // Remove references to the old popup and component upon destruction/exit. + // (This should prevent redundant calls to `popup.destroy()`, which Tippy + // warns in the console is a sign of a memory leak, as the `suggestion` + // plugin seems to call `onExit` both when a suggestion menu is closed after + // a user chooses an option, *and* when the editor itself is destroyed.) + popup = undefined + component = undefined + }, + } + }, +}