hensei-web/extensions/CustomSuggestion/index.tsx
Justin Edmund a4e4328329
Add support for including/excluding items from team filtering (#363)
This PR adds support for including/excluding specific items from team
filtering. Users can use the filter modal to only show teams that
include specific items, only show teams that _don't_ include specific
items, or combine the two to create a very powerful filter.
2023-08-21 20:01:11 -07:00

291 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Editor, Range } from '@tiptap/core'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import { findSuggestionMatch } from './findSuggestionMatch'
export interface SuggestionOptions<I = any> {
pluginKey?: PluginKey
editor: Editor
char?: string
allowSpaces?: boolean
allowedPrefixes?: string[] | null
startOfLine?: boolean
decorationTag?: string
decorationClass?: string
command?: (props: { editor: Editor; range: Range; props: I }) => void
items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>
render?: () => {
onBeforeStart?: (props: SuggestionProps<I>) => void
onStart?: (props: SuggestionProps<I>) => void
onBeforeUpdate?: (props: SuggestionProps<I>) => void
onUpdate?: (props: SuggestionProps<I>) => void
onExit?: (props: SuggestionProps<I>) => void
onFocus?: (props: SuggestionProps<I>) => void
onKeyDown?: (props: SuggestionKeyDownProps) => boolean
}
allow?: (props: {
editor: Editor
state: EditorState
range: Range
}) => boolean
}
export interface SuggestionProps<I = any> {
editor: Editor
range: Range
query: string
text: string
items: I[]
command: (props: I) => void
decorationNode: Element | null
clientRect?: (() => DOMRect) | null
}
export interface SuggestionKeyDownProps {
view: EditorView
event: KeyboardEvent
range: Range
}
export const SuggestionPluginKey = new PluginKey('suggestion')
export function CustomSuggestion<I = any>({
pluginKey = SuggestionPluginKey,
editor,
char = '@',
allowSpaces = true,
allowedPrefixes = [' '],
startOfLine = false,
decorationTag = 'span',
decorationClass = 'suggestion',
command = () => null,
items = () => [],
render = () => ({}),
allow = () => true,
}: SuggestionOptions<I>) {
let props: SuggestionProps<I> | undefined
const renderer = render?.()
const plugin: Plugin<any> = new Plugin({
key: pluginKey,
view() {
return {
update: async (view, prevState) => {
// This should be this.key?
const prev = pluginKey.getState(prevState)
const next = pluginKey.getState(view.state)
// See how the state changed
const moved =
prev.active && next.active && prev.range.from !== next.range.from
const started = !prev.active && next.active
const stopped = prev.active && !next.active
const changed = !started && !stopped && prev.query !== next.query
const handleStart = started || moved
const handleChange = changed && !moved
const handleExit = stopped || moved
const handleFocused = view.hasFocus()
console.log(handleFocused)
// Cancel when suggestion isn't active
if (!handleStart && !handleChange && !handleExit && !handleFocused) {
return
}
const state = handleExit && !handleStart ? prev : next
const decorationNode = view.dom.querySelector(
`[data-decoration-id="${state.decorationId}"]`
)
props = {
editor,
range: state.range,
query: state.query,
text: state.text,
items: [],
command: (commandProps) => {
command({
editor,
range: state.range,
props: commandProps,
})
},
decorationNode,
// virtual node for popper.js or tippy.js
// this can be used for building popups without a DOM node
clientRect: decorationNode
? () => {
// because of `items` can be asynchrounous well search for the current decoration node
const { decorationId } = pluginKey.getState(editor.state) // eslint-disable-line
const currentDecorationNode = view.dom.querySelector(
`[data-decoration-id="${decorationId}"]`
)
// @ts-ignore-error
return currentDecorationNode.getBoundingClientRect()
}
: null,
}
if (handleFocused) {
console.log('Handling focus')
}
if (handleStart) {
renderer?.onBeforeStart?.(props)
}
if (handleChange) {
renderer?.onBeforeUpdate?.(props)
}
if (handleChange || handleStart) {
props.items = await items({
editor,
query: state.query,
})
}
if (handleExit) {
renderer?.onExit?.(props)
}
if (handleChange) {
renderer?.onUpdate?.(props)
}
if (handleStart) {
renderer?.onStart?.(props)
}
},
destroy: () => {
if (!props) {
return
}
renderer?.onExit?.(props)
},
}
},
state: {
// Initialize the plugin's internal state.
init() {
const state: {
active: boolean
range: Range
query: null | string
text: null | string
composing: boolean
decorationId?: string | null
} = {
active: false,
range: {
from: 0,
to: 0,
},
query: null,
text: null,
composing: false,
}
return state
},
// Apply changes to the plugin state from a view transaction.
apply(transaction, prev, oldState, state) {
const { isEditable } = editor
const { composing } = editor.view
const { selection } = transaction
const { empty, from } = selection
const next = { ...prev }
next.composing = composing
// We can only be suggesting if the view is editable, and:
// * there is no selection, or
// * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
if (isEditable && (empty || editor.view.composing)) {
// Reset active state if we just left the previous suggestion range
if (
(from < prev.range.from || from > prev.range.to) &&
!composing &&
!prev.composing
) {
next.active = false
}
// Try to match against where our cursor currently is
const match = findSuggestionMatch({
char,
allowSpaces,
allowedPrefixes,
startOfLine,
$position: selection.$from,
})
const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`
// If we found a match, update the current state to show it
if (match && allow({ editor, state, range: match.range })) {
next.active = true
next.decorationId = prev.decorationId
? prev.decorationId
: decorationId
next.range = match.range
next.query = match.query
next.text = match.text
} else {
next.active = false
}
} else {
next.active = false
}
// Make sure to empty the range if suggestion is inactive
if (!next.active) {
next.decorationId = null
next.range = { from: 0, to: 0 }
next.query = null
next.text = null
}
return next
},
},
props: {
// Call the keydown hook if suggestion is active.
handleKeyDown(view, event) {
const { active, range } = plugin.getState(view.state)
if (!active) {
return false
}
return renderer?.onKeyDown?.({ view, event, range }) || false
},
// Setup decorator on the currently active suggestion.
decorations(state) {
const { active, range, decorationId } = plugin.getState(state)
if (!active) {
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: decorationClass,
'data-decoration-id': decorationId,
}),
])
},
},
})
return plugin
}