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.
This commit is contained in:
parent
b1c8fb1a76
commit
d950d3a935
4 changed files with 310 additions and 0 deletions
43
components/MentionList/index.module.scss
Normal file
43
components/MentionList/index.module.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
118
components/MentionList/index.tsx
Normal file
118
components/MentionList/index.tsx
Normal file
|
|
@ -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<SuggestionProps, 'items' | 'command'>
|
||||
|
||||
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<MentionRef, Props>(
|
||||
({ 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 (
|
||||
<div className={styles.items}>
|
||||
{items.length ? (
|
||||
items.map((item, index) => (
|
||||
<button
|
||||
className={classNames({
|
||||
[styles.item]: true,
|
||||
[styles.selected]: index === selectedIndex,
|
||||
})}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<img
|
||||
alt={item.name[locale]}
|
||||
src={
|
||||
item.type === 'character'
|
||||
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}_01.jpg`
|
||||
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}.jpg`
|
||||
}
|
||||
/>
|
||||
<span>{item.name[locale]}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.noResult}>
|
||||
{t('search.errors.no_results_generic')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
25
extensions/CustomMention/index.tsx
Normal file
25
extensions/CustomMention/index.tsx
Normal file
|
|
@ -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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
124
utils/mentionSuggestions.tsx
Normal file
124
utils/mentionSuggestions.tsx
Normal file
|
|
@ -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<MentionSuggestion[]> => {
|
||||
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<MentionRef> | 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
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Loading…
Reference in a new issue