add tags autocomplete

This commit is contained in:
Eric Bailey
2023-10-09 13:36:01 -05:00
parent 3bcce36418
commit 7bef0eca6a
3 changed files with 451 additions and 0 deletions
@@ -0,0 +1,2 @@
export {Tags} from './plugin'
export {createTagsAutocomplete} from './view'
@@ -0,0 +1,235 @@
/** @see https://github.com/ueberdosis/tiptap/blob/main/packages/extension-mention/src/mention.ts */
import {mergeAttributes, Node} from '@tiptap/core'
import {Node as ProseMirrorNode} from '@tiptap/pm/model'
import {PluginKey} from '@tiptap/pm/state'
import Suggestion, {SuggestionOptions} from '@tiptap/suggestion'
export type TagOptions = {
HTMLAttributes: Record<string, any>
renderLabel: (props: {options: TagOptions; node: ProseMirrorNode}) => string
suggestion: Omit<SuggestionOptions, 'editor'>
}
export const TagsPluginKey = new PluginKey('tags')
export const Tags = Node.create<TagOptions>({
name: 'tag',
addOptions() {
return {
HTMLAttributes: {},
renderLabel({options, node}) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
},
suggestion: {
char: '#',
allowSpaces: true,
pluginKey: TagsPluginKey,
command: ({editor, range, props}) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')
if (overrideSpace) {
range.to += 1
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props,
},
{
type: 'text',
text: ' ',
},
])
.run()
window.getSelection()?.collapseToEnd()
},
allow: ({state, range}) => {
const $from = state.doc.resolve(range.from)
const type = state.schema.nodes[this.name]
const allow = !!$from.parent.type.contentMatch.matchType(type)
return allow
},
findSuggestionMatch({$position}) {
const text = $position.nodeBefore?.isText && $position.nodeBefore.text
if (!text) {
return null
}
const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
const puncRegex = /\p{P}+$/gu
const match = Array.from(text.matchAll(regex)).pop()
if (
!match ||
match.input === undefined ||
match.index === undefined
) {
return null
}
const cursorPosition = $position.pos
const startIndex = cursorPosition - text.length
let [matchedString, tag] = match
const tagWithoutPunctuation = tag.replace(puncRegex, '')
// allow for multiple ending punctuation marks
const punctuationIndexOffset =
tag.length - tagWithoutPunctuation.length
if (tagWithoutPunctuation.length > 66) return null
const from = startIndex + match.index + matchedString.indexOf(tag)
// `to` should not include ending punctuation
const to = from + tagWithoutPunctuation.length
if (
from < cursorPosition &&
to >= cursorPosition - punctuationIndexOffset
) {
return {
range: {
from,
to,
},
// should not include ending punctuation
query: tagWithoutPunctuation.replace(/^#/, ''),
// raw text string
text: matchedString,
}
}
return null
},
},
}
},
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: element => element.getAttribute('data-id'),
renderHTML: attributes => {
if (!attributes.id) {
return {}
}
return {
'data-id': attributes.id,
}
},
},
label: {
default: null,
parseHTML: element => element.getAttribute('data-label'),
renderHTML: attributes => {
if (!attributes.label) {
return {}
}
return {
'data-label': attributes.label,
}
},
},
}
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
]
},
renderHTML({node, HTMLAttributes}) {
console.log(
'renderText',
node,
this.options.renderLabel({
options: this.options,
node,
}),
)
return [
'span',
mergeAttributes(
{'data-type': this.name},
this.options.HTMLAttributes,
HTMLAttributes,
),
this.options.renderLabel({
options: this.options,
node,
}),
]
},
renderText({node}) {
return this.options.renderLabel({
options: this.options,
node,
})
},
addKeyboardShortcuts() {
return {
Backspace: () =>
this.editor.commands.command(({tr, state}) => {
let isTag = false
const {selection} = state
const {empty, anchor} = selection
if (!empty) {
return false
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isTag = true
tr.insertText(
this.options.suggestion.char || '',
pos,
pos + node.nodeSize,
)
return false
}
})
return isTag
}),
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})
@@ -0,0 +1,214 @@
import React, {forwardRef, useImperativeHandle, useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {ReactRenderer} from '@tiptap/react'
import tippy, {Instance as TippyInstance} from 'tippy.js'
import {
SuggestionOptions,
SuggestionProps,
SuggestionKeyDownProps,
} from '@tiptap/suggestion'
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text'
type AutocompleteResult = string
type ListProps = SuggestionProps<AutocompleteResult> & {
autocompleteModel: TagsAutocompleteModel
}
type AutocompleteRef = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
}
export function createTagsAutocomplete({
autocompleteModel,
}: {
autocompleteModel: TagsAutocompleteModel
}): Omit<SuggestionOptions, 'editor'> {
return {
async items({query}) {
autocompleteModel.setActive(true)
await autocompleteModel.search(query)
return autocompleteModel.suggestions.slice(0, 8)
},
render() {
let component: ReactRenderer<AutocompleteRef> | undefined
let popup: TippyInstance[] | undefined
return {
onStart: props => {
component = new ReactRenderer(Autocomplete, {
props: {
...props,
autocompleteModel,
},
editor: props.editor,
})
if (!props.clientRect) return
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
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({
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
getReferenceClientRect: props.clientRect,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide()
return true
}
return component?.ref?.onKeyDown(props) || false
},
onExit() {
popup?.[0]?.destroy()
component?.destroy()
},
}
},
}
}
const Autocomplete = forwardRef<AutocompleteRef, ListProps>(
function AutocompleteImpl(props, ref) {
const {items, command, autocompleteModel} = props
const pal = usePalette('default')
const [selectedIndex, setSelectedIndex] = useState(0)
const commit = React.useCallback(
(tag: string) => {
// @ts-ignore we're dealing with strings here not mentions
command({id: tag})
autocompleteModel.commitRecentTag(tag)
},
[command, autocompleteModel],
)
const selectItem = React.useCallback(
(index: number) => {
const item = items[index]
if (item) commit(item)
},
[items, commit],
)
useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => {
if (event.key === 'ArrowUp') {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((selectedIndex + 1) % props.items.length)
return true
}
if (event.key === 'Enter') {
if (!props.items.length) {
// no items, use whatever the user typed
commit(props.autocompleteModel.query)
} else {
selectItem(selectedIndex)
}
return true
}
if (event.key === ' ') {
commit(props.autocompleteModel.query)
return true
}
return false
},
}))
// hide entirely if no suggestions
if (!items.length) return null
return (
<div className="items">
<View style={[pal.borderDark, pal.view, styles.container]}>
{items.map((tag, index) => {
const isSelected = selectedIndex === index
const isFirst = index === 0
const isLast = index === items.length - 1
return (
<Pressable
key={tag}
style={state => [
styles.resultContainer,
{
backgroundColor: state.hovered
? pal.viewLight.backgroundColor
: undefined,
},
isSelected ? pal.viewLight : undefined,
isFirst
? styles.firstResult
: isLast
? styles.lastResult
: undefined,
]}
onPress={() => selectItem(index)}
accessibilityRole="button">
<Text type="md" style={pal.textLight} numberOfLines={1}>
#{tag}
</Text>
</Pressable>
)
})}
</View>
</div>
)
},
)
const styles = StyleSheet.create({
container: {
width: 500,
borderRadius: 6,
borderWidth: 1,
borderStyle: 'solid',
padding: 4,
},
resultContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 8,
gap: 4,
},
firstResult: {
borderTopLeftRadius: 2,
borderTopRightRadius: 2,
},
lastResult: {
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
},
})