Use %mention to embed a contract card in rich text editor (#869)

* Bring up a list of contracts with @

* Fix hot reload for RichContent

* Render contracts as half-size cards

* Use % as the prompt; allow for spaces

* WIP: When there's no matching question, create a new contract

* Revert "WIP: When there's no matching question, create a new contract"

This reverts commit efae1bf715.

* Rename to contract-mention

* WIP: Try to merge in @ and % side by side

* Add a different pluginKey

* Track the prosemirror-state dep
This commit is contained in:
Austin Chen 2022-09-15 15:12:26 -07:00 committed by GitHub
parent e9fcf5a352
commit 140628692f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 211 additions and 3 deletions

View File

@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link'
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'
import { EmbedModal } from './editor/embed-modal'
@ -97,7 +99,12 @@ export function useTextEditor(props: {
CharacterCount.configure({ limit: max }),
simple ? DisplayImage : Image,
DisplayLink,
DisplayMention.configure({ suggestion: mentionSuggestion }),
DisplayMention.configure({
suggestion: mentionSuggestion,
}),
DisplayContractMention.configure({
suggestion: contractMentionSuggestion,
}),
Iframe,
TiptapTweet,
],
@ -316,13 +323,21 @@ export function RichContent(props: {
smallImage ? DisplayImage : Image,
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
DisplayMention,
DisplayContractMention.configure({
// Needed to set a different PluginKey for Prosemirror
suggestion: contractMentionSuggestion,
}),
Iframe,
TiptapTweet,
],
content,
editable: false,
})
useEffect(() => void editor?.commands?.setContent(content), [editor, content])
useEffect(
// Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769
() => void !editor?.isDestroyed && editor?.commands?.setContent(content),
[editor, content]
)
return <EditorContent className={className} editor={editor} />
}

View File

@ -0,0 +1,68 @@
import { SuggestionProps } from '@tiptap/suggestion'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { contractPath } from 'web/lib/firebase/contracts'
import { Avatar } from '../avatar'
// copied from https://tiptap.dev/api/nodes/mention#usage
const M = forwardRef((props: SuggestionProps<Contract>, ref) => {
const { items: contracts, command } = props
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => setSelectedIndex(0), [contracts])
const submitUser = (index: number) => {
const contract = contracts[index]
if (contract)
command({ id: contract.id, label: contractPath(contract) } as any)
}
const onUp = () =>
setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length)
const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.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">
{!contracts.length ? (
<span className="m-1 whitespace-nowrap">No results...</span>
) : (
contracts.map((contract, i) => (
<button
className={clsx(
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4 hover:bg-indigo-200',
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
)}
onClick={() => submitUser(i)}
key={contract.id}
>
<Avatar avatarUrl={contract.creatorAvatarUrl} size="xs" />
{contract.question}
</button>
))
)}
</div>
)
})
// Just to keep the formatting pretty
export { M as MentionList }

View File

@ -0,0 +1,76 @@
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 { getCachedContracts } from 'web/hooks/use-contracts'
import { MentionList } from './contract-mention-list'
import { PluginKey } from 'prosemirror-state'
type Suggestion = MentionOptions['suggestion']
const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage
// TODO: merge with mention-suggestion.ts?
export const contractMentionSuggestion: Suggestion = {
char: '%',
allowSpaces: true,
pluginKey: new PluginKey('contract-mention'),
items: async ({ query }) =>
orderBy(
(await getCachedContracts()).filter((c) =>
searchInAny(query, c.question)
),
[(c) => [c.question].some((s) => beginsWith(s, query))],
['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,41 @@
import Mention from '@tiptap/extension-mention'
import {
mergeAttributes,
NodeViewWrapper,
ReactNodeViewRenderer,
} from '@tiptap/react'
import clsx from 'clsx'
import { useContract } from 'web/hooks/use-contract'
import { ContractCard } from '../contract/contract-card'
const name = 'contract-mention-component'
const ContractMentionComponent = (props: any) => {
const contract = useContract(props.node.attrs.id)
return (
<NodeViewWrapper className={clsx(name, 'not-prose')}>
{contract && (
<ContractCard
contract={contract}
className="my-2 w-full border border-gray-100"
/>
)}
</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 DisplayContractMention = Mention.extend({
parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () =>
ReactNodeViewRenderer(ContractMentionComponent, {
// On desktop, render cards below half-width so you can stack two
className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1',
}),
})

View File

@ -9,8 +9,9 @@ import {
listenForNewContracts,
getUserBetContracts,
getUserBetContractsQuery,
listAllContracts,
} from 'web/lib/firebase/contracts'
import { useQueryClient } from 'react-query'
import { QueryClient, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
export const useContracts = () => {
@ -23,6 +24,12 @@ export const useContracts = () => {
return contracts
}
const q = new QueryClient()
export const getCachedContracts = async () =>
q.fetchQuery(['contracts'], () => listAllContracts(1000), {
staleTime: Infinity,
})
export const useActiveContracts = () => {
const [activeContracts, setActiveContracts] = useState<
Contract[] | undefined

View File

@ -48,6 +48,7 @@
"nanoid": "^3.3.4",
"next": "12.2.5",
"node-fetch": "3.2.4",
"prosemirror-state": "1.4.1",
"react": "17.0.2",
"react-beautiful-dnd": "13.1.1",
"react-confetti": "6.0.1",