packages/prosemirror-suggest/src/suggest-types.ts
import * as PMModel from 'prosemirror-model';
import * as PMState from 'prosemirror-state';
import * as PMView from 'prosemirror-view';
/**
* This [[`Suggester`]] interface defines all the options required to create a
* suggestion within your editor.
*
* @remarks
*
* The options are passed to the [[`suggest`]] method which uses them.
*/
export interface Suggester {
/**
* The activation character(s) to match against.
*
* @remarks
*
* For example if building a mention plugin you might want to set this to `@`.
* Multi string characters are theoretically supported (although currently
* untested).
*
* The character does not have to be unique amongst the suggesters and the
* eventually matched suggester will depend on the order in which the
* suggesters are added to the plugin.
*
* Please note that when this is a plain string, it is assumed to be a plain
* string. Which means that it will be matched as it is and **not** as a
* string representation of `RegExp`.
*
* It can also be regex, but the regex support has a few caveats.
*
* - All flags specified will be ignored.
* - Avoid using `^` to denote the start of line since that can conflict with
* the [[`Suggester.startOfLine`]] value and cause an invalid regex.
*/
char: RegExp | string;
/**
* A unique identifier for the suggester.
*
* @remarks
*
* This should be globally unique amongst all suggesters registered with this
* plugin. The plugin will throw an error if duplicates names are used.
*
* Typically this value will be appended to classes to uniquely identify them
* amongst the suggesters and even in your own nodes and mark.
*/
name: string;
/**
* Set this to true so that the `onChange` handler is called in the
* `appendTransaction` ProseMirror plugin hook instead of the the view update
* handler.
*
* This tends to work better with updates that are run multiple times while
* preserving the redo/undo history stack.
*
* Please note this should only be set to true if updates are expected to be
* synchronous and immediately available. If you're planning on packaging the
* update into a command which dispatches the update in response to user
* interaction, then you're better off leaving this as false.
*
* An example of how it's being used is in the `autoLink` functionality for
* the `LinkExtension` in remirror. Since autolinking is purely based on
* configuration and not on user interaction it's possible to create the links
* automatically within the `appendTransaction` hook.
*
* @defaultValue false
*/
appendTransaction?: boolean;
/**
* Called whenever a suggester becomes active or changes in any way.
*
* @remarks
*
* It receives a parameters object with the `changeReason` or `exitReason` to
* let you know whether the change was an exit and what caused the change.
*/
onChange: SuggestChangeHandler;
/**
* The priority for this suggester.
*
* A higher number means that this will be added to the list earlier. If
* you're using this within the context of `remirror` you can also use the
* `ExtensionPriority` from the `remirror/core` library.
*
* @defaultValue 50
*/
priority?: number;
/**
* When set to true, matches will only be recognised at the start of a new
* line.
*
* @defaultValue false
*/
startOfLine?: boolean;
/**
* A regex containing all supported characters when within an active
* suggester.
*
* @defaultValue /[\w\d_]+/
*/
supportedCharacters?: RegExp | string;
/**
* A regex expression used to validate the text directly before the match.
*
* @remarks
*
* This will be used when {@link Suggester.invalidPrefixCharacters} is not
* provided.
*
* @defaultValue /^[\s\0]?$/ - translation: only space and zero width characters
* allowed.
*/
validPrefixCharacters?: RegExp | string;
/**
* A regex expression used to invalidate the text directly before the match.
*
* @remarks
*
* This has preference over the `validPrefixCharacters` option and when it is
* defined only it will be looked at in determining whether a prefix is valid.
*
* @defaultValue ''
*/
invalidPrefixCharacters?: RegExp | string | null;
/**
* Sets the characters that need to be present after the initial character
* match before a match is triggered.
*
* @remarks
*
* For example with `char` = `@` the following is true.
*
* - `matchOffset: 0` matches `'@'` immediately
* - `matchOffset: 1` matches `'@a'` but not `'@'`
* - `matchOffset: 2` matches `'@ab'` but not `'@a'` or `'@'`
* - `matchOffset: 3` matches `'@abc'` but not `'@ab'` or `'@a'` or `'@'`
* - And so on...
*
* @defaultValue 0
*/
matchOffset?: number;
/**
* Class name to use for the decoration while the suggester is active.
*
* @defaultValue 'suggest'
*/
suggestClassName?: string;
/**
* Tag for the prosemirror decoration which wraps an active match.
*
* @defaultValue 'span'
*/
suggestTag?: string;
/**
* Set a class for the ignored suggester decoration.
*
* @defaultValue null
*/
ignoredClassName?: string | null;
/**
* Set a tag for the ignored suggester decoration.
*
* @defaultValue 'span'
*/
ignoredTag?: string;
/**
* When true, decorations are not created when this mention is being edited.
*/
disableDecorations?: boolean | ShouldDisableDecorations;
/**
* A list of node names which will be marked as invalid.
*
* @defaultValue []
*/
invalidNodes?: string[];
/**
* A list of node names which will be marked as invalid. This takes priority
* over `invalidNodes` if both properties are present.
*
* Setting this to an empty array would mean that this [[`Suggester`]] can
* never match.
*
* @defaultValue null
*/
validNodes?: string[] | null;
/**
* A list of node names which will be marked as invalid.
*
* @defaultValue []
*/
invalidMarks?: string[];
/**
* A list of node names which will be marked as invalid. This takes priority
* over `invalidMarks` if both properties are present.
*
* By setting this value to the empty array `[]`, you are telling this
* [[`Suggester`]] to never match if any marks are found.
*
* @defaultValue null
*/
validMarks?: string[] | null;
/**
* This is run after the `valid` and `invalid` node and mark checks regardless
* of their outcomes and only when the current suggester has found a match at
* the current position.
*
* It is a method of doing more advanced checking of the resolved position.
* Perhaps checking the attributes on the marks `resolvedRange.$to.marks`, or
* the checking the attributes of the direct parent node
* `resolvedRange.$to.parent.attrs` to check if something is missing.
*/
isValidPosition?: (resolvedRange: ResolvedRangeWithCursor, match: SuggestMatch) => boolean;
/**
* This is a utility option that may be necessary for you when building
* editable mentions using `prosemirror-suggest`.
*
* By default `prosemirror-suggest` searches backwards from the current cursor
* position to see if any text matches any of the configured suggesters. For
* the majority of use cases this is perfectly acceptable behaviour.
*
* However, [#639](https://github.com/remirror/remirror/issues/639) shows that
* it's possible to delete forward and make mentions invalid. Without adding
* this option, the only solution to this problem would have required,
* creating a custom plugin to check each state update and see if the next
* character is still valid.
*
* This method removes this requirement. It is run on every single update
* where there is a valid text selection after the current cursor position. It
* makes use of the `appendTransaction` ProseMirror plugin hook and provides
* you with a transaction (`tr`) which should be mutated with updates. These
* updates are added to the updates for the editor and makes it much easier to
* build `history` friendly functionality.
*
* This is called before all `onChange` handlers.
*
* @defaultValue null
*/
checkNextValidSelection?: CheckNextValidSelection | null;
/**
* Whether this suggester should only be valid for empty selections.
*
* @defaultValue false
*/
emptySelectionsOnly?: boolean;
/**
* Whether the match should be case insensitive and ignore the case. This adds
* the `i` flag to the regex used.
*
* @defaultValue false
*/
caseInsensitive?: boolean;
/**
* When true support matches across multiple lines.
*
* @defaultValue false
*/
multiline?: boolean;
/**
* When true support matches using Unicode Regex.
*
* @defaultValue false
*/
unicode?: boolean;
/**
* Whether to capture the `char character as the first capture group.
*
* When this is set to true it will be the first matching group with
* `match[1]`.
*
* @defaultValue true
*/
captureChar?: boolean;
}
/**
* A function for checking whether the next selection is valid.
*
* It is called for all registered suggesters before any of the onChange
* handlers are fired.
*
* @param $pos - the next valid position that supports text selections.
* @param tr - the transaction that can be mutated when `appendTransaction` is
* set to true.
* @param matches - the possibly undefined exit and change matcher names. These
* can be used to check if the name matches the current suggester.
*/
export type CheckNextValidSelection = (
$pos: ResolvedPos,
tr: Transaction,
matches: { change?: string; exit?: string },
) => Transaction | null | void;
/**
* A function that can be used to determine whether the decoration should be set
* or not.
*
* @param match - the current active match
* @param resolvedRange - the range of the match with each position resolved.
*/
export type ShouldDisableDecorations = (
state: EditorState,
match: Readonly<SuggestMatch>,
) => boolean;
/**
* The potential reasons for an exit of a mention.
*/
export enum ExitReason {
/**
* The user has pasted some text with multiple characters or run a command
* that adds multiple characters.
*
* `onExit` should be called but the previous match should be retested as it's
* possible that it's been extended.
*/
End = 'exit-end',
/**
* The suggestion has been removed.
*/
Removed = 'delete',
/**
* The user has pasted some text with multiple characters or run a command
* that adds multiple characters somewhere within the active suggestion. e.g.
* `@abc` -> `@ab123 asdf aiti c`
*
* `onExit` should be called but the previous match should be retested as it's
* possible that it's been extended.
*/
Split = 'exit-split',
/**
* The user has pasted some text with multiple characters or run a command
* that adds multiple characters right after the initial multi-character. e.g.
* `@abc` -> `@ this is newabc`
*
* In this case it is best to remove the mention completely.
*/
InvalidSplit = 'invalid-exit-split',
/**
* User has moved out of the suggestion at the end. This can happen via using
* arrow keys, but can also be via the suggestion no longer matching as the
* user types, a mouse click or custom command. All that has changed is the
* cursor position.
*/
MoveEnd = 'move-end',
/**
* User has moved out of the suggestion but from the beginning. This can be
* via the arrow keys but can also be via a mouse click or custom command. All
* that changed is the cursor position.
*/
MoveStart = 'move-start',
/**
* The user has jumped to another suggestion which occurs afterwards in the
* editor. This can be via a click, a keyboard jump or custom commands. In
* this case since there is still an active suggestion it will trigger both an
* `onExit` and `onChange` call.
*/
JumpForward = 'jump-forward-exit',
/**
* The user has jumped to another suggestion which occurs before the previous
* suggestion in the editor. This can happen via a click, a keyboard jump
* (END) or a custom command. In this case since there is still an active
* suggestion it will trigger both an `onExit` and `onChange` call.
*/
JumpBackward = 'jump-backward-exit',
/**
* The user has selected some text outside the current selection, this can
* trigger an exit. This can be from a triple click to select the line or
* Ctrl-A to select all.
*/
SelectionOutside = 'selection-outside',
}
/**
* The potential reason for changes
*/
export enum ChangeReason {
/**
* The user has entered or started a new suggestion.
*/
Start = 'start',
/**
* A changed happened to the character. This can be addition, deletion or
* replacement.
*/
Text = 'change-character',
/**
* A change happened to the selection status which was not purely a move. The
* selection area may have been increased.
*/
SelectionInside = 'selection-inside',
/**
* The cursor was moved.
*/
Move = 'move',
/**
* The user has moved from one suggestion to another suggestion earlier in the
* document.
*/
JumpBackward = 'jump-backward-change',
/**
* The user has moved from one suggestion to another suggestion further along
* in the document.
*/
JumpForward = 'jump-forward-change',
}
/**
* The parameters needed for the [[`SuggestIgnoreProps.addIgnored`]] action
* method available to the suggest plugin handlers.
*
* @remarks
*
* See:
* - [[`RemoveIgnoredProps`]]
*/
export interface AddIgnoredProps extends RemoveIgnoredProps {
/**
* When `false` this will ignore the range for all matching suggesters. When
* true the ignored suggesters will only be the ones provided by the name.
*/
specific?: boolean;
}
/**
* The parameters needed for the {@link SuggestIgnoreProps.removeIgnored}
* action method available to the suggest plugin handlers.
*/
export interface RemoveIgnoredProps extends Pick<Suggester, 'name'> {
/**
* The starting point of the match that should be ignored.
*/
from: number;
}
/**
* A parameter builder interface describing the ignore methods available to the
* [[`Suggester`]] handlers.
*/
export interface SuggestIgnoreProps {
/**
* Add a match target to the ignored matches.
*
* @remarks
*
* Until the activation character is deleted the `onChange` handler will no
* longer be triggered for the matched character. It will be like the match
* doesn't exist.
*
* By ignoring the activation character the plugin ensures that any further
* matches from the activation character will be ignored.
*
* There are a number of use cases for this. You may chose to ignore a match
* when:
*
* - The user presses the `escape` key to exit your suggestion dropdown.
* - The user continues typing without selecting any of the options for the
* selection drop down.
* - The user clicks outside of the suggesters dropdown.
*
* ```ts
* const suggester = {
* onChange: ({ addIgnored, range: { from }, suggester: { char, name } }: SuggestExitHandlerProps) => {
* addIgnored({ from, char, name }); // Ignore this suggester
* },
* }
* ```
*/
addIgnored: (props: AddIgnoredProps) => void;
/**
* When the name is provided remove all ignored decorations which match the
* named suggester. Otherwise remove **all** ignored decorations from the
* document.
*/
clearIgnored: (name?: string) => void;
/**
* Use this method to skip the next `onChange` when the reason is an exit.
*
* @remarks
*
* This is useful when you manually call a command which applies the
* suggestion outside of the `onChange` handler. When that happens `onChange`
* will still be triggered since the document has now changed. If you don't
* have the logic set up properly it may rerun your exit logic. This can lead
* to mismatched transaction errors since the `onChange` handler is provided
* the last active range and query when the reason is an exit, but these
* values are probably no longer valid.
*
* This helper method can be applied to make life easier. Call it when running
* a command in a click handler or key binding and you don't have to worry
* about your `onChange` handler being called again and leading to a
* mismatched transaction error.
*/
ignoreNextExit: () => void;
}
/**
* The match value with the full and partial text.
*
* @remarks
*
* For a suggester with a char `@` then the following text `@ab|c` where `|` is
* the current cursor position will create a queryText with the following
* signature.
*
* ```json
* { "full": "abc", "partial": "ab" }
* ```
*/
export interface MatchValue {
/**
* The complete match independent of the cursor position.
*/
full: string;
/**
* This value is a partial match which ends at the position of the cursor
* within the matching text.
*/
partial: string;
}
/**
* A range with the cursor attached.
*
* - `from` - describes the start position of the query, including the
* activation character.
* - `to` - describes the end position of the match, so the point at which there
* is no longer an active suggest.
* - `cursor` describes the cursor potion within that match.
*
* Depending on the use case you can decide which is most important in your
* application.
*
* If you want to replace the whole match regardless of cursor position, then
* `from` and `to` are all that you need.
*
* If you want to split the match and only replace up until the cursor while
* preserving the remaining part of the match, then you can use `from`, `cursor`
* for the initial replacement and then take the value between `cursor` and `to`
* and append it in whatever way you feel works.
*/
export interface RangeWithCursor {
/**
* The absolute starting point of the matching string.
*/
from: number;
/**
* The current cursor position, which may not be at the end of the full match.
*/
cursor: number;
/**
* The absolute end of the matching string.
*/
to: number;
}
export interface ResolvedRangeWithCursor {
/**
* The absolute starting point of the matching string as a [resolved
* position](https://prosemirror.net/docs/ref/#model.Resolved_Positions).
*/
$from: ResolvedPos;
/**
* The current cursor position as a [resolved
* position](https://prosemirror.net/docs/ref/#model.Resolved_Positions),
* which may not be at the end of the full match.
*/
$cursor: ResolvedPos;
/**
* The absolute end of the matching string as a [resolved
* position](https://prosemirror.net/docs/ref/#model.Resolved_Positions).
*/
$to: ResolvedPos;
}
/**
* Describes the properties of a match which includes range and the text as well
* as information of the suggester that created the match.
*
*/
export interface SuggestMatch extends SuggesterProps {
/**
* Range of current match; for example `@foo|bar` (where | is the cursor)
* - `from` is the start (= 0)
* - `to` is cursor position (= 4)
* - `end` is the end of the match (= 7)
*/
range: RangeWithCursor;
/**
* Current query of match which doesn't include the activation character.
*/
query: MatchValue;
/**
* Full text of match including the activation character
*
* @remarks
*
* For a `char` of `'@'` and query of `'awesome'` `text.full` would be
* `'@awesome'`.
*/
text: MatchValue;
/**
* The raw regex match which caused this suggester to be triggered.
*
* - `rawMatch[0]` is always the full match.
* - `rawMatch[1]` is always the matching character (or regex pattern).
*
* To make use of this you can set the [[`Suggester.supportedCharacters`]]
* property to be a regex which included matching capture group segments.
*/
match: RegExpExecArray;
/**
* The text after full match, up until the end of the text block.
*/
textAfter: string;
/**
* The text before the full match, up until the beginning of the node.
*/
textBefore: string;
}
export interface DocChangedProps {
/**
* - `true` when there was a changed in the editor content.
* - `false` when only the selection changed.
*
* TODO currently unused. Should be used to differentiate between a cursor
* exit using the keyboard navigation and a document update change typing
* invalid character, space, etc...
*/
docChanged: boolean;
}
/**
* A parameter builder interface describing match found by the suggest plugin.
*/
export interface SuggestStateMatchProps {
/**
* The match that will be triggered.
*/
match: SuggestMatch;
}
/**
* A special parameter needed when creating editable suggester using prosemirror
* `Marks`. The method should be called when removing a suggestion that was
* identified by a prosemirror `Mark`.
*/
export interface SuggestMarkProps {
/**
* When managing suggesters with marks it is possible to remove a mark without
* the change reflecting in the prosemirror state. This method should be used
* when removing a suggestion if you are using prosemirror `Marks` to identify
* the suggestion.
*
* When this method is called, `prosemirror-suggest` will handle the removal
* of the mark in the next state update (during apply).
*/
setMarkRemoved: () => void;
}
/**
* A parameter builder interface indicating the reason the handler was called.
*
* @typeParam Reason - Whether this is change or an exit reason.
*/
export interface ReasonProps {
/**
* The reason for the exit. Either this or the change reason must have a
* value.
*/
exitReason?: ExitReason;
/**
* The reason for the change. Either this or change reason must have a value..
*/
changeReason?: ChangeReason;
}
/**
* The parameter passed to the [[`Suggester.onChange`]] method. It the
* properties `changeReason` and `exitReason` which are available depending on
* whether this is an exit or change.
*
* Exactly **ONE** will always be available. Unfortunately that's quite hard to
* model in TypeScript without complicating all dependent types.
*/
export interface SuggestChangeHandlerProps
extends SuggestMatchWithReason,
EditorViewProps,
SuggestIgnoreProps,
SuggestMarkProps,
Pick<Suggester, 'name' | 'char'> {}
/**
* The type signature of the `onChange` handler method.
*
* @param changeDetails - all the information related to the change that caused
* this to be called.
* @param tr - the transaction that can be updated when `appendTransaction` is
* set to true.
*/
export type SuggestChangeHandler = (
changeDetails: SuggestChangeHandlerProps,
tr: Transaction,
) => void;
export interface SuggesterProps {
/**
* The suggester to use for finding matches.
*/
suggester: Required<Suggester>;
}
/**
* The matching suggester along with the reason, whether it is a `changeReason`
* or an `exitReason`.
*/
export interface SuggestMatchWithReason extends SuggestMatch, ReasonProps {}
/**
* A mapping of the handler matches with their reasons for occurring within the
* suggest state.
*/
export interface SuggestReasonMap {
/**
* Change reasons for triggering the change handler.
*/
change?: SuggestMatchWithReason;
/**
* Exit reasons for triggering the change handler.
*/
exit?: SuggestMatchWithReason;
}
/**
* A parameter builder interface which adds the match property.
*
* @remarks
*
* This is used to build parameters for {@link Suggester} handler methods.
*
* @typeParam Reason - Whether this is change or an exit reason.
*/
export interface ReasonMatchProps {
/**
* The match with its reason property.
*/
match: SuggestMatchWithReason;
}
/**
* A parameter builder interface which compares the previous and next match.
*
* @remarks
*
* It is used within the codebase to determine the kind of change that has
* occurred (i.e. change or exit see {@link SuggestReasonMap}) and the reason
* for that that change. See {@link ExitReason} {@link ChangeReason}
*/
export interface CompareMatchProps {
/**
* The initial match
*/
prev: SuggestMatch;
/**
* The current match
*/
next: SuggestMatch;
}
/**
* Makes specified keys of an interface optional while the rest stay the same.
*/
export type MakeOptional<Type extends object, Keys extends keyof Type> = Omit<Type, Keys> & {
[Key in Keys]+?: Type[Key];
};
export type EditorSchema = PMModel.Schema;
export type ProsemirrorNode = PMModel.Node;
export type Transaction = PMState.Transaction;
/**
* A parameter builder interface containing the `tr` property.
*
* @typeParam Schema - the underlying editor schema.
*/
export interface TransactionProps {
/**
* The prosemirror transaction
*/
tr: Transaction;
}
export type EditorState = PMState.EditorState;
/**
* A parameter builder interface containing the `state` property.
*
* @typeParam Schema - the underlying editor schema.
*/
export interface EditorStateProps {
/**
* A snapshot of the prosemirror editor state.
*/
state: EditorState;
}
export type ResolvedPos = PMModel.ResolvedPos;
/**
* @typeParam Schema - the underlying editor schema.
*/
export interface ResolvedPosProps {
/**
* A prosemirror resolved pos with provides helpful context methods when
* working with a position in the editor.
*
* In prosemirror suggest this always uses the lower bound of the text
* selection.
*/
$pos: ResolvedPos;
}
export interface TextProps {
/**
* The text to insert or work with.
*/
text: string;
}
export type EditorView = PMView.EditorView;
/**
* A parameter builder interface containing the `view` property.
*
* @typeParam Schema - the underlying editor schema.
*/
export interface EditorViewProps {
/**
* An instance of the ProseMirror editor `view`.
*/
view: EditorView;
}
export interface SelectionProps {
/**
* The text editor selection
*/
selection: PMState.Selection;
}