Add @ mentions to editor (#670)

* Add @ mentions to editor

* Fix mention list not loading

* Sort mention list by prefix, follow count

* Render at mention with Linkify component

- mentions are now Next <Link> rather than <a>
- fix bug where editor.getText() returns [object Object] for mentions
- fix mention rendering for posted markets
This commit is contained in:
Sinclair Chen 2022-07-23 20:37:34 -07:00 committed by GitHub
parent f4e4582913
commit 6c89e5f18f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 228 additions and 26 deletions

View File

@ -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"
}, },

View File

@ -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'
import Iframe from './tiptap-iframe' import Iframe from './tiptap-iframe'
export function parseTags(text: string) { export function parseTags(text: string) {
@ -81,9 +82,9 @@ export const exhibitExts = [
Image, Image,
Link, Link,
Mention,
Iframe, Iframe,
] ]
// 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)

View File

@ -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",

View File

@ -11,6 +11,7 @@ import {
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, useState } from 'react' import { useEffect, useState } from 'react'
import { Linkify } from './linkify' import { Linkify } from './linkify'
@ -19,6 +20,9 @@ import { useMutation } from 'react-query'
import { exhibitExts } from 'common/util/parse' import { exhibitExts } 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 { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid' import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
@ -40,33 +44,41 @@ 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,
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
) )
const editor = useEditor({ const editor = useEditor(
editorProps: { attributes: { class: editorClass } }, {
extensions: [ editorProps: { attributes: { class: editorClass } },
StarterKit.configure({ extensions: [
heading: { levels: [1, 2, 3] }, StarterKit.configure({
}), heading: { levels: [1, 2, 3] },
Placeholder.configure({ }),
placeholder, Placeholder.configure({
emptyEditorClass: placeholder,
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0', emptyEditorClass:
}), 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
CharacterCount.configure({ limit: max }), }),
Image, CharacterCount.configure({ limit: max }),
Link.configure({ Image,
HTMLAttributes: { Link.configure({
class: clsx('no-underline !text-indigo-700', linkClass), HTMLAttributes: {
}, class: clsx('no-underline !text-indigo-700', linkClass),
}), },
Iframe, }),
], DisplayMention.configure({
content: defaultValue, suggestion: mentionSuggestion(users),
}) }),
Iframe,
],
content: defaultValue,
},
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
)
const upload = useUploadMutation(editor) const upload = useUploadMutation(editor)
@ -261,7 +273,11 @@ function RichContent(props: { content: JSONContent | string }) {
const { content } = props const { content } = props
const editor = useEditor({ const editor = useEditor({
editorProps: { attributes: { class: proseClass } }, editorProps: { attributes: { class: proseClass } },
extensions: exhibitExts, extensions: [
// replace tiptap's Mention with ours, to add style and link
...exhibitExts.filter((ex) => ex.name !== Mention.name),
DisplayMention,
],
content, content,
editable: false, editable: false,
}) })

View 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#usage
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>
)
})

View File

@ -0,0 +1,72 @@
import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash'
import tippy from 'tippy.js'
import { MentionList } from './mention-list'
type Suggestion = MentionOptions['suggestion']
const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage
export const mentionSuggestion = (users: User[]): Suggestion => ({
items: ({ query }) =>
orderBy(
users.filter((u) => searchInAny(query, u.username, u.name)),
[
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
'followerCountCached',
],
['desc', 'desc']
).slice(0, 5),
render: () => {
let component: ReactRenderer
let popup: ReturnType<typeof tippy>
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 as any,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return (component.ref as any)?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
})

View File

@ -0,0 +1,29 @@
import Mention from '@tiptap/extension-mention'
import {
mergeAttributes,
NodeViewWrapper,
ReactNodeViewRenderer,
} from '@tiptap/react'
import clsx from 'clsx'
import { Linkify } from '../linkify'
const name = 'mention-component'
const MentionComponent = (props: any) => {
return (
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
<Linkify text={'@' + props.node.attrs.label} />
</NodeViewWrapper>
)
}
/**
* Mention extension that renders React. See:
* https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
* https://tiptap.dev/guide/node-views/react#render-a-react-component
*/
export const DisplayMention = Mention.extend({
parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
})

View File

@ -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",

View File

@ -3010,6 +3010,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"
@ -3073,6 +3082,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"
@ -11058,7 +11076,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==