packages/prosemirror-suggest/src/suggest-state.ts
import { PluginKey, Selection, TextSelection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { isFunction, isString, object, sort } from '@remirror/core-helpers';
import {
isInvalidSplitReason,
isJumpReason,
isTextSelection,
isValidMatch,
} from './suggest-predicates';
import type {
AddIgnoredProps,
CompareMatchProps,
EditorState,
EditorStateProps,
EditorView,
RemoveIgnoredProps,
ResolvedPos,
ResolvedPosProps,
SuggestChangeHandlerProps,
Suggester,
SuggestMatch,
SuggestReasonMap,
Transaction,
TransactionProps,
} from './suggest-types';
import {
DEFAULT_SUGGESTER,
findFromSuggesters,
findReason,
IGNORE_SUGGEST_META_KEY,
} from './suggest-utils';
/**
* The `prosemirror-suggest` state which manages the list of suggesters.
*/
export class SuggestState {
/**
* Create an instance of the SuggestState class.
*/
static create(suggesters: Suggester[]): SuggestState {
return new SuggestState(suggesters);
}
/**
* True when the doc changed in the most recently applied transaction.
*/
#docChanged = false;
/**
* Whether the next exit should be ignored.
*/
#ignoreNextExit = false;
/**
* The suggesters that have been registered for the suggesters plugin.
*/
#suggesters: Array<Required<Suggester>>;
/**
* Keeps track of the current state.
*/
#next?: Readonly<SuggestMatch>;
/**
* Holds onto the previous active state.
*/
#prev?: Readonly<SuggestMatch>;
/**
* The handler matches which are passed into the `onChange` handler.
*/
#handlerMatches: SuggestReasonMap = object();
/**
* Holds a copy of the view
*/
private view!: EditorView;
/**
* The set of ignored decorations
*/
#ignored = DecorationSet.empty;
/**
* Lets us know whether the most recent change was to remove a mention.
*/
#removed = false;
/**
* This is true when the last change was caused by a transaction being appended via this plugin.
*/
#lastChangeFromAppend = false;
/**
* The set of all decorations.
*/
get decorationSet(): DecorationSet {
return this.#ignored;
}
/**
* True when the most recent change was to remove a mention.
*
* @remarks
*
* This is needed because sometimes removing a prosemirror `Mark` has no
* effect. Hence we need to keep track of whether it's removed and then later
* in the apply step check that a removal has happened and reset the
* `handlerMatches` to prevent an infinite loop.
*/
get removed(): boolean {
return this.#removed;
}
/**
* Returns the current active suggester state field if one exists
*/
get match(): Readonly<SuggestMatch> | undefined {
return this.#next
? this.#next
: this.#prev && this.#handlerMatches.exit
? this.#prev
: undefined;
}
/**
* Create the state for the `prosemirror-suggest` plugin.
*
* @remarks
*
* Each suggester must provide a name value which is globally unique since it
* acts as the identifier.
*
* It is possible to register multiple suggesters with identical `char`
* properties. The matched suggester is based on the specificity of the
* `regex` and the order in which they are passed in. Earlier suggesters are
* prioritized.
*/
constructor(suggesters: Suggester[]) {
const mapper = createSuggesterMapper();
this.#suggesters = suggesters.map(mapper);
this.#suggesters = sort(this.#suggesters, (a, b) => b.priority - a.priority);
}
/**
* Initialize the SuggestState with a view which is stored for use later.
*/
init(view: EditorView): this {
this.view = view;
return this;
}
/**
* Sets the removed property to be true.
*
* This is useful when working with marks.
*/
readonly setMarkRemoved = (): void => {
this.#removed = true;
};
/**
* Create the props which should be passed into each action handler
*/
private createProps(match: SuggestMatch): SuggestChangeHandlerProps {
const { name, char } = match.suggester;
return {
view: this.view,
addIgnored: this.addIgnored,
clearIgnored: this.clearIgnored,
ignoreNextExit: this.ignoreNextExit,
setMarkRemoved: this.setMarkRemoved,
name,
char,
...match,
};
}
/**
* Check whether the exit callback is valid at this time.
*/
private shouldRunExit(): boolean {
if (this.#ignoreNextExit) {
this.#ignoreNextExit = false;
return false;
}
return true;
}
/**
* Find the next text selection from the current selection.
*/
readonly findNextTextSelection = (selection: Selection): TextSelection | void => {
const doc = selection.$from.doc;
// Make sure the position doesn't exceed the bounds of the document.
const pos = Math.min(doc.nodeSize - 2, selection.to + 1);
const $pos = doc.resolve(pos);
// Get the position furthest along in the editor to pass back to suggesters
// which have the handler.
const nextSelection = Selection.findFrom($pos, 1, true);
// Ignore non-text selections and null / undefined values. This is needed
// for TS mainly, since the `true` in the `Selection.findFrom` method means
// only `TextSelection` instances will be returned.
if (!isTextSelection(nextSelection)) {
return;
}
return nextSelection;
};
/**
* Update all the suggesters with the next valid selection. This is called
* within the `appendTransaction` ProseMirror method before any of the change
* handlers are called.
*
* @internal
*/
updateWithNextSelection(tr: Transaction): void {
// Get the position furthest along in the editor to pass back to suggesters
// which have the handler.
const nextSelection = this.findNextTextSelection(tr.selection);
if (!nextSelection) {
return;
}
// Update every suggester with a method attached.
for (const suggester of this.#suggesters) {
const change = this.#handlerMatches.change?.suggester.name;
const exit = this.#handlerMatches.exit?.suggester.name;
suggester.checkNextValidSelection?.(nextSelection.$from, tr, { change, exit });
}
}
/**
* Call the `onChange` handlers.
*
* @internal
*/
changeHandler(tr: Transaction, appendTransaction: boolean): void {
const { change, exit } = this.#handlerMatches;
const match = this.match;
// Cancel update when a suggester isn't active
if ((!change && !exit) || !isValidMatch(match)) {
return;
}
const shouldRunExit =
appendTransaction === exit?.suggester.appendTransaction && this.shouldRunExit();
const shouldRunChange = appendTransaction === change?.suggester.appendTransaction;
if (!shouldRunExit && !shouldRunChange) {
return;
}
// When a jump happens run the action that involves the position that occurs
// later in the document. This is so that changes don't affect previous
// positions.
if (change && exit && isJumpReason({ change, exit })) {
const exitDetails = this.createProps(exit);
const changeDetails = this.createProps(change);
// Whether the jump was forwards or backwards. A forwards jump means that
// the user was within a suggester nearer the beginning of the document,
// before jumping forward to a point later on in the document.
const movedForwards = exit.range.from < change.range.from;
if (movedForwards) {
// Subtle change to call exit first. Conceptually it happens before the
// change so call the handler before the change handler.
shouldRunExit && exit.suggester.onChange(exitDetails, tr);
shouldRunChange && change.suggester.onChange(changeDetails, tr);
} else {
shouldRunExit && exit.suggester.onChange(exitDetails, tr);
shouldRunChange && change.suggester.onChange(changeDetails, tr);
}
if (shouldRunExit) {
this.#removed = false;
}
return;
}
if (change && shouldRunChange) {
change.suggester.onChange(this.createProps(change), tr);
}
if (exit && shouldRunExit) {
exit.suggester.onChange(this.createProps(exit), tr);
this.#removed = false;
if (isInvalidSplitReason(exit.exitReason)) {
// When the split has made the match invalid, remove the matches before
// the next input.
this.#handlerMatches = object();
}
}
return;
}
/**
* Update the current ignored decorations based on the latest changes to the
* prosemirror document.
*/
private mapIgnoredDecorations(tr: Transaction) {
// Map over and update the ignored decorations.
const ignored = this.#ignored.map(tr.mapping, tr.doc);
const decorations = ignored.find();
// For suggesters with multiple characters it is possible for a `paste` or
// any edit action within the decoration to expand the ignored section. We
// check for that here and if the section size has changed it should be
// marked as invalid and removed from the ignored `DecorationSet`.
const invalid = decorations.filter(({ from, to, spec }) => {
const charLength = isString(spec.char) ? spec.char.length : 1;
if (to - from !== charLength) {
return true;
}
return false;
});
this.#ignored = ignored.remove(invalid);
}
/**
* This sets the next exit to not trigger the exit reason inside the
* `onChange` callback.
*
* This can be useful when you trigger a command, that exists the suggestion
* match and want to prevent further onChanges from occurring for the
* currently active suggester.
*/
readonly ignoreNextExit = (): void => {
this.#ignoreNextExit = true;
};
/**
* Ignores the match specified. Until the match is deleted no more `onChange`
* handler will be triggered. It will be like the match doesn't exist.
*
* @remarks
*
* All we need to ignore is the match character. This means that any further
* matches from the activation character will be ignored.
*/
readonly addIgnored = ({ from, name, specific = false }: AddIgnoredProps): void => {
const suggester = this.#suggesters.find((value) => value.name === name);
if (!suggester) {
throw new Error(`No suggester exists for the name provided: ${name}`);
}
const offset = isString(suggester.char) ? suggester.char.length : 1;
const to = from + offset;
const attributes = suggester.ignoredClassName ? { class: suggester.ignoredClassName } : {};
const decoration = Decoration.inline(
from,
to,
{ nodeName: suggester.ignoredTag, ...attributes },
{ name, specific, char: suggester.char },
);
this.#ignored = this.#ignored.add(this.view.state.doc, [decoration]);
};
/**
* Removes a single match character from the ignored decorations.
*
* @remarks
*
* After this point event handlers will begin to be called again for the match
* character.
*/
readonly removeIgnored = ({ from, name }: RemoveIgnoredProps): void => {
const suggester = this.#suggesters.find((value) => value.name === name);
if (!suggester) {
throw new Error(`No suggester exists for the name provided: ${name}`);
}
const offset = isString(suggester.char) ? suggester.char.length : 1;
const decoration = this.#ignored.find(from, from + offset)[0];
if (!decoration || decoration.spec.name !== name) {
return;
}
this.#ignored = this.#ignored.remove([decoration]);
};
/**
* Removes all the ignored sections of the document. Once this happens
* suggesters will be able to activate in the previously ignored sections.
*/
readonly clearIgnored = (name?: string): void => {
if (!name) {
this.#ignored = DecorationSet.empty;
return;
}
const decorations = this.#ignored.find();
const decorationsToClear = decorations.filter(({ spec }) => spec.name === name);
this.#ignored = this.#ignored.remove(decorationsToClear);
};
/**
* Checks whether a match should be ignored.
*
* TODO add logic here to decide whether to ignore a match based on the active
* node, or mark.
*/
private shouldIgnoreMatch({ range, suggester: { name } }: SuggestMatch) {
const decorations = this.#ignored.find();
const shouldIgnore = decorations.some(({ spec, from }) => {
if (from !== range.from) {
return false;
}
return spec.specific ? spec.name === name : true;
});
return shouldIgnore;
}
/**
* Reset the state.
*/
private resetState() {
this.#handlerMatches = object();
this.#next = undefined;
this.#removed = false;
this.#lastChangeFromAppend = false;
}
/**
* Update the next state value.
*/
private updateReasons(props: UpdateReasonsProps) {
const { $pos, state } = props;
const docChanged = this.#docChanged;
const suggesters = this.#suggesters;
const selectionEmpty = state.selection.empty;
const match = isTextSelection(state.selection)
? findFromSuggesters({ suggesters, $pos, docChanged, selectionEmpty })
: undefined;
// Track the next match if not being ignored.
this.#next = match && this.shouldIgnoreMatch(match) ? undefined : match;
// Store the matches with reasons
this.#handlerMatches = findReason({ next: this.#next, prev: this.#prev, state, $pos });
}
/**
* A helper method to check is a match exists for the provided suggester name
* at the provided position.
*/
readonly findMatchAtPosition = ($pos: ResolvedPos, name?: string): SuggestMatch | undefined => {
const suggesters = name
? this.#suggesters.filter((suggester) => suggester.name === name)
: this.#suggesters;
return findFromSuggesters({ suggesters, $pos, docChanged: false, selectionEmpty: true });
};
/**
* Add a new suggest or replace it if it already exists.
*/
addSuggester(suggester: Suggester): () => void {
const previous = this.#suggesters.find((item) => item.name === suggester.name);
const mapper = createSuggesterMapper();
if (previous) {
this.#suggesters = this.#suggesters.map((item) =>
item === previous ? mapper(suggester) : item,
);
} else {
const suggesters = [...this.#suggesters, mapper(suggester)];
this.#suggesters = sort(suggesters, (a, b) => b.priority - a.priority);
}
return () => this.removeSuggester(suggester.name);
}
/**
* Remove a suggester if it exists.
*/
removeSuggester(suggester: Suggester | string): void {
const name = isString(suggester) ? suggester : suggester.name;
this.#suggesters = this.#suggesters.filter((item) => item.name !== name);
// When removing a suggester make sure to clear the ignored sections.
this.clearIgnored(name);
}
toJSON(): SuggestMatch | undefined {
return this.match;
}
/**
* Applies updates to the state to be used within the plugins apply method.
*
* @param - params
*/
apply(props: TransactionProps & EditorStateProps): this {
const { exit, change } = this.#handlerMatches;
if (this.#lastChangeFromAppend) {
this.#lastChangeFromAppend = false;
if (!exit?.suggester.appendTransaction && !change?.suggester.appendTransaction) {
return this;
}
}
const { tr, state } = props;
const transactionHasChanged = tr.docChanged || tr.selectionSet;
const shouldIgnoreUpdate: boolean = tr.getMeta(IGNORE_SUGGEST_META_KEY);
if (shouldIgnoreUpdate || (!transactionHasChanged && !this.#removed)) {
return this;
}
this.#docChanged = tr.docChanged;
this.mapIgnoredDecorations(tr);
// If the previous run was an exit, reset the suggester matches.
if (exit) {
this.resetState();
}
// Track the previous match.
this.#prev = this.#next;
// Match against the current selection position
this.updateReasons({ $pos: tr.selection.$from, state });
return this;
}
/**
* Handle the decorations which wrap the mention while it is active and not
* yet complete.
*/
createDecorations(state: EditorState): DecorationSet {
const match = this.match;
if (!isValidMatch(match)) {
return this.#ignored;
}
const { disableDecorations } = match.suggester;
const shouldSkip = isFunction(disableDecorations)
? disableDecorations(state, match)
: disableDecorations;
if (shouldSkip) {
return this.#ignored;
}
const { range, suggester } = match;
const { name, suggestTag, suggestClassName } = suggester;
const { from, to } = range;
return this.shouldIgnoreMatch(match)
? this.#ignored
: this.#ignored.add(state.doc, [
Decoration.inline(
from,
to,
{
nodeName: suggestTag,
class: name ? `${suggestClassName} suggest-${name}` : suggestClassName,
},
{ name },
),
]);
}
/**
* Set that the last change was caused by an appended transaction.
*
* @internal
*/
setLastChangeFromAppend = (): void => {
this.#lastChangeFromAppend = true;
};
}
interface UpdateReasonsProps
extends EditorStateProps,
ResolvedPosProps,
Partial<CompareMatchProps> {}
/**
* Map over the suggesters provided and make sure they have all the required
* properties.
*/
function createSuggesterMapper() {
const names = new Set<string>();
return (suggester: Suggester): Required<Suggester> => {
if (names.has(suggester.name)) {
throw new Error(
`A suggester already exists with the name '${suggester.name}'. The name provided must be unique.`,
);
}
// Attach the defaults to the passed in suggester.
const suggesterWithDefaults = { ...DEFAULT_SUGGESTER, ...suggester };
names.add(suggester.name);
return suggesterWithDefaults;
};
}
/**
* This key is stored to provide access to the plugin state.
*/
export const suggestPluginKey = new PluginKey('suggest');