diff --git a/common/package.json b/common/package.json index 6f0f5b29..c324379f 100644 --- a/common/package.json +++ b/common/package.json @@ -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" }, diff --git a/common/util/parse.ts b/common/util/parse.ts index 30dcb952..b29a1c9a 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -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) diff --git a/functions/package.json b/functions/package.json index f8657516..fe63dc4e 100644 --- a/functions/package.json +++ b/functions/package.json @@ -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", diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 7063fa42..5ba42759 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -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 + 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, }) diff --git a/web/components/editor/mention-list.tsx b/web/components/editor/mention-list.tsx new file mode 100644 index 00000000..179a818f --- /dev/null +++ b/web/components/editor/mention-list.tsx @@ -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, 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 ( +
+ {!users.length ? ( + No results... + ) : ( + users.map((user, i) => ( + + )) + )} +
+ ) +}) diff --git a/web/package.json b/web/package.json index f8e1881b..c364e4f5 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 6fcdf53a..07737666 100644 --- a/yarn.lock +++ b/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==