Add @ mentions to editor
This commit is contained in:
parent
8793288dc8
commit
1e667bd9d8
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
"lodash": "4.17.21"
|
"lodash": "4.17.21"
|
||||||
},
|
},
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text'
|
||||||
// other tiptap extensions
|
// other tiptap extensions
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
export function parseTags(text: string) {
|
||||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||||
|
@ -80,8 +81,8 @@ export const exhibitExts = [
|
||||||
|
|
||||||
Image,
|
Image,
|
||||||
Link,
|
Link,
|
||||||
|
Mention,
|
||||||
]
|
]
|
||||||
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
|
|
||||||
|
|
||||||
export function richTextToString(text?: JSONContent) {
|
export function richTextToString(text?: JSONContent) {
|
||||||
return !text ? '' : generateText(text, exhibitExts)
|
return !text ? '' : generateText(text, exhibitExts)
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"@tiptap/core": "2.0.0-beta.181",
|
"@tiptap/core": "2.0.0-beta.181",
|
||||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
"firebase-admin": "10.0.0",
|
"firebase-admin": "10.0.0",
|
||||||
"firebase-functions": "3.21.2",
|
"firebase-functions": "3.21.2",
|
||||||
|
|
|
@ -7,18 +7,23 @@ import {
|
||||||
JSONContent,
|
JSONContent,
|
||||||
Content,
|
Content,
|
||||||
Editor,
|
Editor,
|
||||||
|
ReactRenderer,
|
||||||
} from '@tiptap/react'
|
} from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
import { uploadImage } from 'web/lib/firebase/storage'
|
import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
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 { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
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(
|
const proseClass = clsx(
|
||||||
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
'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 { placeholder, max, defaultValue = '', disabled } = props
|
||||||
|
|
||||||
|
const users = useUsers()
|
||||||
|
|
||||||
const editorClass = clsx(
|
const editorClass = clsx(
|
||||||
proseClass,
|
proseClass,
|
||||||
'box-content min-h-[6em] textarea textarea-bordered text-base'
|
'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),
|
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,
|
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-character-count": "2.0.0-beta.31",
|
||||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@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/extension-placeholder": "2.0.0-beta.53",
|
||||||
"@tiptap/react": "2.0.0-beta.114",
|
"@tiptap/react": "2.0.0-beta.114",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
|
@ -49,7 +50,8 @@
|
||||||
"react-hot-toast": "2.2.0",
|
"react-hot-toast": "2.2.0",
|
||||||
"react-instantsearch-hooks-web": "6.24.1",
|
"react-instantsearch-hooks-web": "6.24.1",
|
||||||
"react-query": "3.39.0",
|
"react-query": "3.39.0",
|
||||||
"string-similarity": "^4.0.4"
|
"string-similarity": "^4.0.4",
|
||||||
|
"tippy.js": "6.3.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "0.4.0",
|
"@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"
|
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==
|
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":
|
"@tiptap/extension-ordered-list@^2.0.0-beta.30":
|
||||||
version "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"
|
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-strike" "^2.0.0-beta.29"
|
||||||
"@tiptap/extension-text" "^2.0.0-beta.17"
|
"@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":
|
"@tootallnate/once@2":
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
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"
|
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
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"
|
version "6.3.7"
|
||||||
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
|
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
|
||||||
integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
|
integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
|
||||||
|
|
Loading…
Reference in New Issue
Block a user