Add @ mentions to editor
This commit is contained in:
parent
8793288dc8
commit
1e667bd9d8
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
|
|
|
@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text'
|
|||
// other tiptap extensions
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Mention } from '@tiptap/extension-mention'
|
||||
|
||||
export function parseTags(text: string) {
|
||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||
|
@ -80,8 +81,8 @@ export const exhibitExts = [
|
|||
|
||||
Image,
|
||||
Link,
|
||||
Mention,
|
||||
]
|
||||
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
|
||||
|
||||
export function richTextToString(text?: JSONContent) {
|
||||
return !text ? '' : generateText(text, exhibitExts)
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"firebase-admin": "10.0.0",
|
||||
"firebase-functions": "3.21.2",
|
||||
|
|
|
@ -7,18 +7,23 @@ import {
|
|||
JSONContent,
|
||||
Content,
|
||||
Editor,
|
||||
ReactRenderer,
|
||||
} from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Mention } from '@tiptap/extension-mention'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect } from 'react'
|
||||
import { Linkify } from './linkify'
|
||||
import { uploadImage } from 'web/lib/firebase/storage'
|
||||
import { useMutation } from 'react-query'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
import { exhibitExts, searchInAny } from 'common/util/parse'
|
||||
import { FileUploadButton } from './file-upload-button'
|
||||
import { linkClass } from './site-link'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { MentionList } from './editor/mention-list'
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
const proseClass = clsx(
|
||||
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
||||
|
@ -33,6 +38,8 @@ export function useTextEditor(props: {
|
|||
}) {
|
||||
const { placeholder, max, defaultValue = '', disabled } = props
|
||||
|
||||
const users = useUsers()
|
||||
|
||||
const editorClass = clsx(
|
||||
proseClass,
|
||||
'box-content min-h-[6em] textarea textarea-bordered text-base'
|
||||
|
@ -56,6 +63,71 @@ export function useTextEditor(props: {
|
|||
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||
},
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: clsx('not-prose text-indigo-700', linkClass),
|
||||
},
|
||||
// TODO: do a Next link instead of raw <a>
|
||||
renderLabel: ({ options, node }) =>
|
||||
[
|
||||
'a',
|
||||
{ href: node.attrs.label },
|
||||
`${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
|
||||
] as any,
|
||||
suggestion: {
|
||||
items: ({ query }) =>
|
||||
users
|
||||
.filter((u) => searchInAny(query, u.username, u.name))
|
||||
.slice(0, 5),
|
||||
render: () => {
|
||||
let component: any
|
||||
let popup: any
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
},
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
return true
|
||||
}
|
||||
return component.ref?.onKeyDown(props)
|
||||
},
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: defaultValue,
|
||||
})
|
||||
|
|
62
web/components/editor/mention-list.tsx
Normal file
62
web/components/editor/mention-list.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { SuggestionProps } from '@tiptap/suggestion'
|
||||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { Avatar } from '../avatar'
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention
|
||||
export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
|
||||
const { items: users, command } = props
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
useEffect(() => setSelectedIndex(0), [users])
|
||||
|
||||
const submitUser = (index: number) => {
|
||||
const user = users[index]
|
||||
if (user) command({ id: user.id, label: user.username } as any)
|
||||
}
|
||||
|
||||
const onUp = () =>
|
||||
setSelectedIndex((i) => (i + users.length - 1) % users.length)
|
||||
const onDown = () => setSelectedIndex((i) => (i + 1) % users.length)
|
||||
const onEnter = () => submitUser(selectedIndex)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: any) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
onUp()
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
onDown()
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
onEnter()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{!users.length ? (
|
||||
<span className="m-1 whitespace-nowrap">No results...</span>
|
||||
) : (
|
||||
users.map((user, i) => (
|
||||
<button
|
||||
className={clsx(
|
||||
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
|
||||
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||
)}
|
||||
onClick={() => submitUser(i)}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||
{user.username}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
|
@ -27,6 +27,7 @@
|
|||
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/extension-placeholder": "2.0.0-beta.53",
|
||||
"@tiptap/react": "2.0.0-beta.114",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
|
@ -49,7 +50,8 @@
|
|||
"react-hot-toast": "2.2.0",
|
||||
"react-instantsearch-hooks-web": "6.24.1",
|
||||
"react-query": "3.39.0",
|
||||
"string-similarity": "^4.0.4"
|
||||
"string-similarity": "^4.0.4",
|
||||
"tippy.js": "6.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "0.4.0",
|
||||
|
|
20
yarn.lock
20
yarn.lock
|
@ -2998,6 +2998,15 @@
|
|||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.0.0-beta.23.tgz#6d1ac7235462b0bcee196f42bb1871669480b843"
|
||||
integrity sha512-AkzvdELz3ZnrlZM0r9+ritBDOnAjXHR/8zCZhW0ZlWx4zyKPMsNG5ygivY+xr4QT65NEGRT8P8b2zOhXrMjjMQ==
|
||||
|
||||
"@tiptap/extension-mention@2.0.0-beta.102":
|
||||
version "2.0.0-beta.102"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.0.0-beta.102.tgz#a80036b0a4481efc4f69b788af3f5c76428624cc"
|
||||
integrity sha512-QTBBpWnRnoV7/ZW31HwhPvZL3HiwnlehlHSLeMioVxAQPF5WrRtlOpxK/SRu7+KuwdCb7ZA1eWW/yjbXI3oktg==
|
||||
dependencies:
|
||||
"@tiptap/suggestion" "^2.0.0-beta.97"
|
||||
prosemirror-model "1.18.1"
|
||||
prosemirror-state "1.4.1"
|
||||
|
||||
"@tiptap/extension-ordered-list@^2.0.0-beta.30":
|
||||
version "2.0.0-beta.30"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.0-beta.30.tgz#1f656b664302d90272c244b2e478d7056203f2a8"
|
||||
|
@ -3061,6 +3070,15 @@
|
|||
"@tiptap/extension-strike" "^2.0.0-beta.29"
|
||||
"@tiptap/extension-text" "^2.0.0-beta.17"
|
||||
|
||||
"@tiptap/suggestion@^2.0.0-beta.97":
|
||||
version "2.0.0-beta.97"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.0.0-beta.97.tgz#2e3dc20deebc2c37c5d39c848e61e9c837e7188a"
|
||||
integrity sha512-3NWG+HE7v2w97Ek6z1tUosoZKpCDH+oAtIG9XoNkK1PmlaVV/H4d6HT9uPX+Y6SeN7fSAqlcXFUGLXcDi9d+Zw==
|
||||
dependencies:
|
||||
prosemirror-model "1.18.1"
|
||||
prosemirror-state "1.4.1"
|
||||
prosemirror-view "1.26.2"
|
||||
|
||||
"@tootallnate/once@2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
||||
|
@ -11044,7 +11062,7 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
|
|||
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tippy.js@^6.3.7:
|
||||
tippy.js@6.3.7, tippy.js@^6.3.7:
|
||||
version "6.3.7"
|
||||
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
|
||||
integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
|
||||
|
|
Loading…
Reference in New Issue
Block a user