Dump in files

This commit is contained in:
Eric Bailey
2024-09-08 13:41:02 -05:00
parent 330be974d0
commit 0ab51794e2
4 changed files with 500 additions and 0 deletions
@@ -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,
},
})