%mentions for embedding contract card, take 2 (#884)
* Revert "Revert "Use %mention to embed a contract card in rich text editor (#869)""
This reverts commit e0634cea6d.
* Overwrite name to prevent breakages
* Fix '%' mentioning if you escape out
* Cleanup: merge render functions
			
			
This commit is contained in:
		
							parent
							
								
									ca4a2bc7db
								
							
						
					
					
						commit
						1321b95eb1
					
				| 
						 | 
					@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button'
 | 
				
			||||||
import { linkClass } from './site-link'
 | 
					import { linkClass } from './site-link'
 | 
				
			||||||
import { mentionSuggestion } from './editor/mention-suggestion'
 | 
					import { mentionSuggestion } from './editor/mention-suggestion'
 | 
				
			||||||
import { DisplayMention } from './editor/mention'
 | 
					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 Iframe from 'common/util/tiptap-iframe'
 | 
				
			||||||
import TiptapTweet from './editor/tiptap-tweet'
 | 
					import TiptapTweet from './editor/tiptap-tweet'
 | 
				
			||||||
import { EmbedModal } from './editor/embed-modal'
 | 
					import { EmbedModal } from './editor/embed-modal'
 | 
				
			||||||
| 
						 | 
					@ -97,7 +99,12 @@ export function useTextEditor(props: {
 | 
				
			||||||
      CharacterCount.configure({ limit: max }),
 | 
					      CharacterCount.configure({ limit: max }),
 | 
				
			||||||
      simple ? DisplayImage : Image,
 | 
					      simple ? DisplayImage : Image,
 | 
				
			||||||
      DisplayLink,
 | 
					      DisplayLink,
 | 
				
			||||||
      DisplayMention.configure({ suggestion: mentionSuggestion }),
 | 
					      DisplayMention.configure({
 | 
				
			||||||
 | 
					        suggestion: mentionSuggestion,
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					      DisplayContractMention.configure({
 | 
				
			||||||
 | 
					        suggestion: contractMentionSuggestion,
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
      Iframe,
 | 
					      Iframe,
 | 
				
			||||||
      TiptapTweet,
 | 
					      TiptapTweet,
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
| 
						 | 
					@ -316,13 +323,21 @@ export function RichContent(props: {
 | 
				
			||||||
      smallImage ? DisplayImage : Image,
 | 
					      smallImage ? DisplayImage : Image,
 | 
				
			||||||
      DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
 | 
					      DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
 | 
				
			||||||
      DisplayMention,
 | 
					      DisplayMention,
 | 
				
			||||||
 | 
					      DisplayContractMention.configure({
 | 
				
			||||||
 | 
					        // Needed to set a different PluginKey for Prosemirror
 | 
				
			||||||
 | 
					        suggestion: contractMentionSuggestion,
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
      Iframe,
 | 
					      Iframe,
 | 
				
			||||||
      TiptapTweet,
 | 
					      TiptapTweet,
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    content,
 | 
					    content,
 | 
				
			||||||
    editable: false,
 | 
					    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} />
 | 
					  return <EditorContent className={className} editor={editor} />
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										68
									
								
								web/components/editor/contract-mention-list.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								web/components/editor/contract-mention-list.tsx
									
									
									
									
									
										Normal 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 }
 | 
				
			||||||
							
								
								
									
										27
									
								
								web/components/editor/contract-mention-suggestion.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								web/components/editor/contract-mention-suggestion.ts
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,27 @@
 | 
				
			||||||
 | 
					import type { MentionOptions } from '@tiptap/extension-mention'
 | 
				
			||||||
 | 
					import { searchInAny } from 'common/util/parse'
 | 
				
			||||||
 | 
					import { orderBy } from 'lodash'
 | 
				
			||||||
 | 
					import { getCachedContracts } from 'web/hooks/use-contracts'
 | 
				
			||||||
 | 
					import { MentionList } from './contract-mention-list'
 | 
				
			||||||
 | 
					import { PluginKey } from 'prosemirror-state'
 | 
				
			||||||
 | 
					import { makeMentionRender } from './mention-suggestion'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Suggestion = MentionOptions['suggestion']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const beginsWith = (text: string, query: string) =>
 | 
				
			||||||
 | 
					  text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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: makeMentionRender(MentionList),
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										42
									
								
								web/components/editor/contract-mention.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/components/editor/contract-mention.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,42 @@
 | 
				
			||||||
 | 
					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({
 | 
				
			||||||
 | 
					  name: 'contract-mention',
 | 
				
			||||||
 | 
					  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',
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
| 
						 | 
					@ -5,6 +5,7 @@ import { orderBy } from 'lodash'
 | 
				
			||||||
import tippy from 'tippy.js'
 | 
					import tippy from 'tippy.js'
 | 
				
			||||||
import { getCachedUsers } from 'web/hooks/use-users'
 | 
					import { getCachedUsers } from 'web/hooks/use-users'
 | 
				
			||||||
import { MentionList } from './mention-list'
 | 
					import { MentionList } from './mention-list'
 | 
				
			||||||
 | 
					type Render = Suggestion['render']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Suggestion = MentionOptions['suggestion']
 | 
					type Suggestion = MentionOptions['suggestion']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = {
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
      ['desc', 'desc']
 | 
					      ['desc', 'desc']
 | 
				
			||||||
    ).slice(0, 5),
 | 
					    ).slice(0, 5),
 | 
				
			||||||
  render: () => {
 | 
					  render: makeMentionRender(MentionList),
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function makeMentionRender(mentionList: any): Render {
 | 
				
			||||||
 | 
					  return () => {
 | 
				
			||||||
    let component: ReactRenderer
 | 
					    let component: ReactRenderer
 | 
				
			||||||
    let popup: ReturnType<typeof tippy>
 | 
					    let popup: ReturnType<typeof tippy>
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      onStart: (props) => {
 | 
					      onStart: (props) => {
 | 
				
			||||||
        component = new ReactRenderer(MentionList, {
 | 
					        component = new ReactRenderer(mentionList, {
 | 
				
			||||||
          props,
 | 
					          props,
 | 
				
			||||||
          editor: props.editor,
 | 
					          editor: props.editor,
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
| 
						 | 
					@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = {
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      onKeyDown(props) {
 | 
					      onKeyDown(props) {
 | 
				
			||||||
        if (props.event.key === 'Escape') {
 | 
					        if (props.event.key)
 | 
				
			||||||
          popup?.[0].hide()
 | 
					          if (
 | 
				
			||||||
          return true
 | 
					            props.event.key === 'Escape' ||
 | 
				
			||||||
        }
 | 
					            // Also break out of the mention if the tooltip isn't visible
 | 
				
			||||||
 | 
					            (props.event.key === 'Enter' && !popup?.[0].state.isShown)
 | 
				
			||||||
 | 
					          ) {
 | 
				
			||||||
 | 
					            popup?.[0].destroy()
 | 
				
			||||||
 | 
					            component?.destroy()
 | 
				
			||||||
 | 
					            return false
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
        return (component?.ref as any)?.onKeyDown(props)
 | 
					        return (component?.ref as any)?.onKeyDown(props)
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      onExit() {
 | 
					      onExit() {
 | 
				
			||||||
| 
						 | 
					@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = {
 | 
				
			||||||
        component?.destroy()
 | 
					        component?.destroy()
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,8 +9,9 @@ import {
 | 
				
			||||||
  listenForNewContracts,
 | 
					  listenForNewContracts,
 | 
				
			||||||
  getUserBetContracts,
 | 
					  getUserBetContracts,
 | 
				
			||||||
  getUserBetContractsQuery,
 | 
					  getUserBetContractsQuery,
 | 
				
			||||||
 | 
					  listAllContracts,
 | 
				
			||||||
} from 'web/lib/firebase/contracts'
 | 
					} from 'web/lib/firebase/contracts'
 | 
				
			||||||
import { useQueryClient } from 'react-query'
 | 
					import { QueryClient, useQueryClient } from 'react-query'
 | 
				
			||||||
import { MINUTE_MS } from 'common/util/time'
 | 
					import { MINUTE_MS } from 'common/util/time'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const useContracts = () => {
 | 
					export const useContracts = () => {
 | 
				
			||||||
| 
						 | 
					@ -23,6 +24,12 @@ export const useContracts = () => {
 | 
				
			||||||
  return contracts
 | 
					  return contracts
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const q = new QueryClient()
 | 
				
			||||||
 | 
					export const getCachedContracts = async () =>
 | 
				
			||||||
 | 
					  q.fetchQuery(['contracts'], () => listAllContracts(1000), {
 | 
				
			||||||
 | 
					    staleTime: Infinity,
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const useActiveContracts = () => {
 | 
					export const useActiveContracts = () => {
 | 
				
			||||||
  const [activeContracts, setActiveContracts] = useState<
 | 
					  const [activeContracts, setActiveContracts] = useState<
 | 
				
			||||||
    Contract[] | undefined
 | 
					    Contract[] | undefined
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -48,6 +48,7 @@
 | 
				
			||||||
    "nanoid": "^3.3.4",
 | 
					    "nanoid": "^3.3.4",
 | 
				
			||||||
    "next": "12.2.5",
 | 
					    "next": "12.2.5",
 | 
				
			||||||
    "node-fetch": "3.2.4",
 | 
					    "node-fetch": "3.2.4",
 | 
				
			||||||
 | 
					    "prosemirror-state": "1.4.1",
 | 
				
			||||||
    "react": "17.0.2",
 | 
					    "react": "17.0.2",
 | 
				
			||||||
    "react-beautiful-dnd": "13.1.1",
 | 
					    "react-beautiful-dnd": "13.1.1",
 | 
				
			||||||
    "react-confetti": "6.0.1",
 | 
					    "react-confetti": "6.0.1",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user