From f21d0c6ecf505b7726eb9eb58d1bbf732c1c970e Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 12 Sep 2022 09:58:11 -0700 Subject: [PATCH] WIP: Try to merge in @ and % side by side --- web/components/editor.tsx | 12 ++- .../editor/contract-mention-suggestion.ts | 2 +- web/components/editor/mention-list.tsx | 63 ++++++++++++++++ web/components/editor/mention-suggestion.ts | 74 +++++++++++++++++++ web/components/editor/mention.tsx | 30 ++++++++ 5 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 web/components/editor/mention-list.tsx create mode 100644 web/components/editor/mention-suggestion.ts create mode 100644 web/components/editor/mention.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index e2b5bbc3..e7107b9a 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -18,7 +18,9 @@ import { uploadImage } from 'web/lib/firebase/storage' import { useMutation } from 'react-query' import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' -import { mentionSuggestion } from './editor/contract-mention-suggestion' +import { mentionSuggestion } from './editor/mention-suggestion' +import { DisplayMention } from './editor/mention' +import { contractMentionSuggestion } from './editor/contract-mention-suggestion' import { DisplayContractMention } from './editor/contract-mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' @@ -90,7 +92,12 @@ export function useTextEditor(props: { CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image, DisplayLink, - DisplayContractMention.configure({ suggestion: mentionSuggestion }), + DisplayMention.configure({ + suggestion: mentionSuggestion, + }), + DisplayContractMention.configure({ + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], @@ -247,6 +254,7 @@ export function RichContent(props: { StarterKit, smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) + DisplayMention, DisplayContractMention, Iframe, TiptapTweet, diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts index 5f7c78f9..cf2a1b59 100644 --- a/web/components/editor/contract-mention-suggestion.ts +++ b/web/components/editor/contract-mention-suggestion.ts @@ -14,7 +14,7 @@ const beginsWith = (text: string, query: string) => // copied from https://tiptap.dev/api/nodes/mention#usage // TODO: merge with mention-suggestion.ts? -export const mentionSuggestion: Suggestion = { +export const contractMentionSuggestion: Suggestion = { char: '%', allowSpaces: true, items: async ({ query }) => diff --git a/web/components/editor/mention-list.tsx b/web/components/editor/mention-list.tsx new file mode 100644 index 00000000..aeab4636 --- /dev/null +++ b/web/components/editor/mention-list.tsx @@ -0,0 +1,63 @@ +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..9f016d47 --- /dev/null +++ b/web/components/editor/mention-suggestion.ts @@ -0,0 +1,74 @@ +import type { MentionOptions } from '@tiptap/extension-mention' +import { ReactRenderer } from '@tiptap/react' +import { searchInAny } from 'common/util/parse' +import { orderBy } from 'lodash' +import tippy from 'tippy.js' +import { getCachedUsers } from 'web/hooks/use-users' +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: Suggestion = { + items: async ({ query }) => + orderBy( + (await getCachedUsers()).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..5ccea6f5 --- /dev/null +++ b/web/components/editor/mention.tsx @@ -0,0 +1,30 @@ +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, { className: 'inline-block' }), +})