Dump in files
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
export {Tags} from './plugin'
|
||||||
|
export {createTagsAutocomplete} from './view'
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* This is basically a fork of the Mention plugin from Tiptap.
|
||||||
|
*
|
||||||
|
* @see https://github.com/ueberdosis/tiptap/blob/025dfff1d9e4796edf3a451f7f53d06a07b95d69/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'
|
||||||
|
|
||||||
|
import {findSuggestionMatch} from './utils'
|
||||||
|
|
||||||
|
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({node}) {
|
||||||
|
return `#${node.attrs.id}`
|
||||||
|
},
|
||||||
|
suggestion: {
|
||||||
|
char: '#',
|
||||||
|
allowSpaces: true,
|
||||||
|
pluginKey: TagsPluginKey,
|
||||||
|
command: ({editor, range, props}) => {
|
||||||
|
const {tag, punctuation} = 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: {id: tag},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `${punctuation || ''} `,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run()
|
||||||
|
|
||||||
|
window.getSelection()?.collapseToEnd()
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This method and `findSuggestionMatch` below both have to return a
|
||||||
|
* truthy value, otherwise the suggestiond plugin will call `onExit`
|
||||||
|
* and we lose the ability to add a tag
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
const cursorPosition = $position.pos
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return findSuggestionMatch({text, cursorPosition})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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}) {
|
||||||
|
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,74 @@
|
|||||||
|
import {
|
||||||
|
HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX,
|
||||||
|
TRAILING_PUNCTUATION_REGEX,
|
||||||
|
LEADING_HASH_REGEX,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method eventually receives the `query` property from the result of
|
||||||
|
* `findSuggestionMatch` below.
|
||||||
|
*/
|
||||||
|
export function parsePunctuationFromTag(value: string) {
|
||||||
|
const reg = TRAILING_PUNCTUATION_REGEX
|
||||||
|
const tag = value.replace(reg, '')
|
||||||
|
const punctuation = value.match(reg)?.[0] || ''
|
||||||
|
|
||||||
|
return {tag, punctuation}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A result must be returned from this method in order for the suggestion
|
||||||
|
* plugin to remain active and allow for the user to select a suggestion.
|
||||||
|
*
|
||||||
|
* That's why we use the loose regex form that includes trialing punctuation.
|
||||||
|
* We strip that our later.
|
||||||
|
*/
|
||||||
|
export function findSuggestionMatch({
|
||||||
|
text,
|
||||||
|
cursorPosition,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
cursorPosition: number
|
||||||
|
}) {
|
||||||
|
const match = Array.from(
|
||||||
|
text.matchAll(HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX),
|
||||||
|
).pop()
|
||||||
|
|
||||||
|
if (!match || match.input === undefined || match.index === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = cursorPosition - text.length
|
||||||
|
let [matchedString, tagWithTrailingPunctuation] = match
|
||||||
|
|
||||||
|
const sanitized = tagWithTrailingPunctuation
|
||||||
|
.replace(TRAILING_PUNCTUATION_REGEX, '')
|
||||||
|
.replace(LEADING_HASH_REGEX, '')
|
||||||
|
|
||||||
|
// one of our hashtag spec rules
|
||||||
|
if (sanitized.length > 64) return null
|
||||||
|
|
||||||
|
const from =
|
||||||
|
startIndex + match.index + matchedString.indexOf(tagWithTrailingPunctuation)
|
||||||
|
const to = from + tagWithTrailingPunctuation.length
|
||||||
|
|
||||||
|
if (from < cursorPosition && to >= cursorPosition) {
|
||||||
|
return {
|
||||||
|
range: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This is passed to the `items({ query })` method configured in
|
||||||
|
* `createTagsAutocomplete`.
|
||||||
|
*
|
||||||
|
* We parse out the punctuation later.
|
||||||
|
*/
|
||||||
|
query: tagWithTrailingPunctuation.replace(LEADING_HASH_REGEX, ''),
|
||||||
|
// raw text string
|
||||||
|
text: matchedString,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
import {parsePunctuationFromTag} from './utils'
|
||||||
|
|
||||||
|
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 {
|
||||||
|
/**
|
||||||
|
* This `query` param comes from the result of `findSuggestionMatch`
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
(query: string) => {
|
||||||
|
const {tag, punctuation} = parsePunctuationFromTag(query)
|
||||||
|
/*
|
||||||
|
* This values here are passed directly to the `command` method
|
||||||
|
* configured in the `Tags` plugin.
|
||||||
|
*
|
||||||
|
* The type error is ignored because we parse the tag and punctuation
|
||||||
|
* separately above. We could do this in `command` definition, but we
|
||||||
|
* only want to `commitRecentTag` with the sanitized tag.
|
||||||
|
*/
|
||||||
|
// @ts-ignore
|
||||||
|
command({tag, punctuation})
|
||||||
|
},
|
||||||
|
[command],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(autocompleteModel.query)
|
||||||
|
} else {
|
||||||
|
selectItem(selectedIndex)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === ' ') {
|
||||||
|
commit(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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user