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 + }, + } + }, +}