diff --git a/packages/suggestion/src/findSuggestionMatch.ts b/packages/suggestion/src/findSuggestionMatch.ts new file mode 100644 index 000000000..23a1c0886 --- /dev/null +++ b/packages/suggestion/src/findSuggestionMatch.ts @@ -0,0 +1,82 @@ +import { ResolvedPos } from 'prosemirror-model' + +export interface Trigger { + char: string, + allowSpaces: boolean, + startOfLine: boolean, + $position: ResolvedPos, +} + +export type SuggestionMatch = { + range: { + from: number, + to: number, + }, + query: string, + text: string, +} | null + +export function findSuggestionMatch(config: Trigger): SuggestionMatch { + const { + char, allowSpaces, startOfLine, $position, + } = config + + // cancel if top level node + if ($position.depth <= 0) { + return null + } + + // Matching expressions used for later + const escapedChar = `\\${char}` + const suffix = new RegExp(`\\s${escapedChar}$`) + const prefix = startOfLine ? '^' : '' + const regexp = allowSpaces + ? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${escapedChar}|$)`, 'gm') + : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, 'gm') + + // Lookup the boundaries of the current node + const textFrom = $position.before() + + // Only look up to the cursor, old behavior: textTo = $position.end() + const textTo = $position.pos + + const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0') + + let match = regexp.exec(text) + let position = null + + while (match !== null) { + // JavaScript doesn't have lookbehinds; this hacks a check that first character is " " + // or the line beginning + const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index) + + if (/^[\s\0]?$/.test(matchPrefix)) { + // The absolute position of the match in the document + const from = match.index + $position.start() + let to = from + match[0].length + + // Edge case handling; if spaces are allowed and we're directly in between + // two triggers + if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) { + match[0] += ' ' + to += 1 + } + + // If the $position is located within the matched substring, return that range + if (from < $position.pos && to >= $position.pos) { + position = { + range: { + from, + to, + }, + query: match[0].slice(char.length), + text: match[0], + } + } + } + + match = regexp.exec(text) + } + + return position +} diff --git a/packages/suggestion/src/getVirtualNode.ts b/packages/suggestion/src/getVirtualNode.ts new file mode 100644 index 000000000..7f2ca9ab3 --- /dev/null +++ b/packages/suggestion/src/getVirtualNode.ts @@ -0,0 +1,9 @@ +export function getVirtualNode(node: Element) { + return { + getBoundingClientRect() { + return node.getBoundingClientRect() + }, + clientWidth: node.clientWidth, + clientHeight: node.clientHeight, + } +} diff --git a/packages/suggestion/src/suggestion.ts b/packages/suggestion/src/suggestion.ts index 4aa765b54..01b9a2cd4 100644 --- a/packages/suggestion/src/suggestion.ts +++ b/packages/suggestion/src/suggestion.ts @@ -1,79 +1,7 @@ import { Plugin, PluginKey } from 'prosemirror-state' -import { ResolvedPos } from 'prosemirror-model' import { Decoration, DecorationSet } from 'prosemirror-view' - -export interface Trigger { - char: string, - allowSpaces: boolean, - startOfLine: boolean, - $position: ResolvedPos, -} - -// Create a matcher that matches when a specific character is typed. Useful for @mentions and #tags. -function triggerCharacter(config: Trigger) { - const { - char, allowSpaces, startOfLine, $position, - } = config - - // cancel if top level node - if ($position.depth <= 0) { - return false - } - - // Matching expressions used for later - const escapedChar = `\\${char}` - const suffix = new RegExp(`\\s${escapedChar}$`) - const prefix = startOfLine ? '^' : '' - const regexp = allowSpaces - ? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${escapedChar}|$)`, 'gm') - : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, 'gm') - - // Lookup the boundaries of the current node - const textFrom = $position.before() - - // Only look up to the cursor, old behavior: textTo = $position.end() - const textTo = $position.pos - - const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0') - - let match = regexp.exec(text) - let position - - while (match !== null) { - // JavaScript doesn't have lookbehinds; this hacks a check that first character is " " - // or the line beginning - const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index) - - if (/^[\s\0]?$/.test(matchPrefix)) { - // The absolute position of the match in the document - const from = match.index + $position.start() - let to = from + match[0].length - - // Edge case handling; if spaces are allowed and we're directly in between - // two triggers - if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) { - match[0] += ' ' - to += 1 - } - - // If the $position is located within the matched substring, return that range - if (from < $position.pos && to >= $position.pos) { - position = { - range: { - from, - to, - }, - query: match[0].slice(char.length), - text: match[0], - } - } - } - - match = regexp.exec(text) - } - - return position -} +import { findSuggestionMatch } from './findSuggestionMatch' +import { getVirtualNode } from './getVirtualNode' export function Suggestion({ char = '@', @@ -84,7 +12,7 @@ export function Suggestion({ command = () => false, items = [], onEnter = (props: any) => false, - onChange = (props: any) => false, + onUpdate = (props: any) => false, onExit = (props: any) => false, onKeyDown = (props: any) => false, onFilter = (searchItems: any[], query: string) => { @@ -121,24 +49,17 @@ export function Suggestion({ const state = handleExit ? prev : next const decorationNode = document.querySelector(`[data-decoration-id="${state.decorationId}"]`) - - // build a virtual node for popper.js or tippy.js - // this can be used for building popups without a DOM node - const virtualNode = decorationNode ? { - getBoundingClientRect() { - return decorationNode.getBoundingClientRect() - }, - clientWidth: decorationNode.clientWidth, - clientHeight: decorationNode.clientHeight, - } : null - const props = { view, range: state.range, query: state.query, text: state.text, decorationNode, - virtualNode, + // build a virtual node for popper.js or tippy.js + // this can be used for building popups without a DOM node + virtualNode: decorationNode + ? getVirtualNode(decorationNode) + : null, items: (handleChange || handleStart) // @ts-ignore ? await onFilter(Array.isArray(items) ? items : await items(), state.query) @@ -165,7 +86,7 @@ export function Suggestion({ } if (handleChange) { - onChange(props) + onUpdate(props) } if (handleStart) { @@ -199,15 +120,13 @@ export function Suggestion({ } // Try to match against where our cursor currently is - const match = triggerCharacter({ + const match = findSuggestionMatch({ char, allowSpaces, startOfLine, $position: selection.$from, }) - const decorationId = (Math.random() + 1).toString(36).substr(2, 5) - - console.log({ match }) + const decorationId = `id_${Math.floor(Math.random() * 0xFFFFFFFF)}` // If we found a match, update the current state to show it if (match) { @@ -240,18 +159,22 @@ export function Suggestion({ handleKeyDown(view, event) { const { active, range } = this.getState(view.state) - if (!active) return false + if (!active) { + return false + } return onKeyDown({ view, event, range }) }, // Setup decorator on the currently active suggestion. - decorations(editorState) { - const { active, range, decorationId } = this.getState(editorState) + decorations(state) { + const { active, range, decorationId } = this.getState(state) - if (!active) return null + if (!active) { + return null + } - return DecorationSet.create(editorState.doc, [ + return DecorationSet.create(state.doc, [ Decoration.inline(range.from, range.to, { nodeName: 'span', class: suggestionClass,