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 cdaa6a6c..cacd0862 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' import Iframe from './tiptap-iframe' export function parseTags(text: string) { @@ -81,9 +82,9 @@ export const exhibitExts = [ Image, Link, + Mention, Iframe, ] -// 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 4dfddac9..963cea7e 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -11,6 +11,7 @@ import { 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, useState } from 'react' import { Linkify } from './linkify' @@ -19,6 +20,9 @@ import { useMutation } from 'react-query' import { exhibitExts } from 'common/util/parse' import { FileUploadButton } from './file-upload-button' 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 { CodeIcon, PhotographIcon } from '@heroicons/react/solid' import { Modal } from './layout/modal' @@ -40,33 +44,41 @@ export function useTextEditor(props: { }) { const { placeholder, max, defaultValue = '', disabled } = props + const users = useUsers() + const editorClass = clsx( proseClass, 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' ) - const editor = useEditor({ - editorProps: { attributes: { class: editorClass } }, - extensions: [ - StarterKit.configure({ - heading: { levels: [1, 2, 3] }, - }), - Placeholder.configure({ - placeholder, - emptyEditorClass: - 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0', - }), - CharacterCount.configure({ limit: max }), - Image, - Link.configure({ - HTMLAttributes: { - class: clsx('no-underline !text-indigo-700', linkClass), - }, - }), - Iframe, - ], - content: defaultValue, - }) + const editor = useEditor( + { + editorProps: { attributes: { class: editorClass } }, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + }), + Placeholder.configure({ + placeholder, + emptyEditorClass: + 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0', + }), + CharacterCount.configure({ limit: max }), + Image, + Link.configure({ + HTMLAttributes: { + class: clsx('no-underline !text-indigo-700', linkClass), + }, + }), + DisplayMention.configure({ + 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) @@ -261,7 +273,11 @@ function RichContent(props: { content: JSONContent | string }) { const { content } = props const editor = useEditor({ 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, editable: false, }) diff --git a/web/components/editor/mention-list.tsx b/web/components/editor/mention-list.tsx new file mode 100644 index 00000000..f9e67daf --- /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#usage +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/components/editor/mention-suggestion.ts b/web/components/editor/mention-suggestion.ts new file mode 100644 index 00000000..e21789c9 --- /dev/null +++ b/web/components/editor/mention-suggestion.ts @@ -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 + 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() + }, + } + }, +}) diff --git a/web/components/editor/mention.tsx b/web/components/editor/mention.tsx new file mode 100644 index 00000000..3ad5de39 --- /dev/null +++ b/web/components/editor/mention.tsx @@ -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 ( + + + + ) +} + +/** + * 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), +}) diff --git a/web/package.json b/web/package.json index d09ccaf0..9f27643e 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 ffa8e6f0..019a3dd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,6 +3010,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" @@ -3073,6 +3082,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" @@ -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" 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==