WIP: Try to merge in @ and % side by side
This commit is contained in:
parent
3bcbde65a2
commit
f21d0c6ecf
|
@ -18,7 +18,9 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
import { useMutation } from 'react-query'
|
||||||
import { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
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 { 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'
|
||||||
|
@ -90,7 +92,12 @@ export function useTextEditor(props: {
|
||||||
CharacterCount.configure({ limit: max }),
|
CharacterCount.configure({ limit: max }),
|
||||||
simple ? DisplayImage : Image,
|
simple ? DisplayImage : Image,
|
||||||
DisplayLink,
|
DisplayLink,
|
||||||
DisplayContractMention.configure({ suggestion: mentionSuggestion }),
|
DisplayMention.configure({
|
||||||
|
suggestion: mentionSuggestion,
|
||||||
|
}),
|
||||||
|
DisplayContractMention.configure({
|
||||||
|
suggestion: contractMentionSuggestion,
|
||||||
|
}),
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
],
|
],
|
||||||
|
@ -247,6 +254,7 @@ export function RichContent(props: {
|
||||||
StarterKit,
|
StarterKit,
|
||||||
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,
|
||||||
DisplayContractMention,
|
DisplayContractMention,
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
|
|
|
@ -14,7 +14,7 @@ const beginsWith = (text: string, query: string) =>
|
||||||
|
|
||||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||||
// TODO: merge with mention-suggestion.ts?
|
// TODO: merge with mention-suggestion.ts?
|
||||||
export const mentionSuggestion: Suggestion = {
|
export const contractMentionSuggestion: Suggestion = {
|
||||||
char: '%',
|
char: '%',
|
||||||
allowSpaces: true,
|
allowSpaces: true,
|
||||||
items: async ({ query }) =>
|
items: async ({ query }) =>
|
||||||
|
|
63
web/components/editor/mention-list.tsx
Normal file
63
web/components/editor/mention-list.tsx
Normal file
|
@ -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<User>, 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 (
|
||||||
|
<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">
|
||||||
|
{!users.length ? (
|
||||||
|
<span className="m-1 whitespace-nowrap">No results...</span>
|
||||||
|
) : (
|
||||||
|
users.map((user, i) => (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
|
||||||
|
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||||
|
)}
|
||||||
|
onClick={() => submitUser(i)}
|
||||||
|
key={user.id}
|
||||||
|
>
|
||||||
|
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||||
|
{user.username}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
74
web/components/editor/mention-suggestion.ts
Normal file
74
web/components/editor/mention-suggestion.ts
Normal file
|
@ -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<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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
30
web/components/editor/mention.tsx
Normal file
30
web/components/editor/mention.tsx
Normal file
|
@ -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 (
|
||||||
|
<NodeViewWrapper className={clsx(name, 'not-prose text-indigo-700')}>
|
||||||
|
<Linkify text={'@' + props.node.attrs.label} />
|
||||||
|
</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 DisplayMention = Mention.extend({
|
||||||
|
parseHTML: () => [{ tag: name }],
|
||||||
|
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||||
|
addNodeView: () =>
|
||||||
|
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user