---
title: Input rules and triggers
description: "Replace typed text as you type (markdown, :smile: to an emoji) with input rules, and open a menu at the caret on a typed character (@ for people, [[ for pages) with triggers."
icon: keyboard
---

A plugin declares two kinds of typing behavior as data, wired into its own hooks by `withRulesAndTriggers(edytor, definitions, { inputRules, triggers })`, which it returns:

- `inputRules` replace text when what you type completes a pattern: `## ` makes a heading, `:smile:` becomes 😄. The [markdown shortcuts](/docs/plugins/markdown-shortcuts) are input rules.
- `triggers` open a menu at the caret when you type a character, and replace that character and the text typed after it with the row you pick: `@` mentions a person ([Mention](/docs/plugins/mention)), `[[` links a page ([Page link](/docs/plugins/page-link)). The [slash menu](/docs/plugins/slash-menu) is a trigger too.

Both read and write positions as block offsets: an offset in the block's content, where an inline atom counts as 1. These are the offsets `edytor.document` takes and `edytor.selection.caret` reads ([Selection](/docs/editor/selection#the-caret)).

## Input rules

```ts title="emoji.ts" check
import { withRulesAndTriggers, type Plugin } from 'edytor';

const EMOJI: Record<string, string> = { smile: '😄', heart: '❤️' };

/** Typing the closing `:` of `:smile:` replaces it with its emoji. */
export const emojiPlugin: Plugin = (edytor) =>
	withRulesAndTriggers(
		edytor,
		{},
		{ inputRules: [{ find: /:(\w+):$/, replace: ([, name]) => EMOJI[name!] ?? null }] }
	);
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `find?` | `RegExp` | - | Tested on the caret's text up to the caret, followed by the typed text. A match must end at the typed text's end (end the pattern with $) and contain all of it. |
| `replace?` | `(match, ctx) => string \| boolean \| null \| void` | - | The replacement. A string replaces the whole match, the caret after it. true: you wrote it yourself. false or null, writing nothing: declined. |

A rule runs when text is typed at a collapsed caret: one `insertText` at the caret, which is what a key and the browser's own typing produce. An IME's commit is not checked, nor is a paste or a drop: a URL pasted at the caret completes no rule. An Android keyboard that composes the word being typed commits it as an IME does, so a rule may not run there until the composition ends; the rows are proven in desktop engines and their mobile emulation, not on a device. Rules do not run in the lines of a code block (a `lines` island).

The typed text is not in the document yet when `replace` runs, so it is never written: a string replacement takes the place of the matched text before the caret and of the typed text. `hi :smile` + <kbd>:</kbd> gives `hi 😄`, the caret after the emoji.

`replace` receives the match (its `input` is the text the pattern was tested on) and a context:

| Prop | Type | Default | Description |
| - | - | - | - |
| `block?` | `Block` | - | The block the caret is in. |
| `from?` | `number` | - | Where the match starts, in block offsets. |
| `to?` | `number` | - | The caret: where the match ends in the document, the typed text left out. |
| `typed?` | `string` | - | The typed text that completed the match. |
| `caret?` | `(offset: number) => void` | - | Set the command's result caret at an offset of block. |
| `remove?` | `(then?: () => unknown) => boolean` | - | Remove from to to in the same plan as the first operation then runs. Answers whether it was written. |
| `edytor?` | `EdytorInstance` | - | The editor. |

A pattern's `^` is the start of the caret's text segment, which starts after an inline atom; read `from === 0` for the block's start.

### One command, one undo step

The replacement is a command of its own. With a string, the matched text's removal and the insertion are one plan, so a plugin that refuses either one (in `onBeforeOperation`), a readonly view or the document refusing it writes neither. It is its own undo step, separate from the typing before it, also when the match is only the typed text (a smart quote replacing a typed `"`): undo after `:smile:` gives back `:smile`, without the colon that was never inserted. A rule that writes its replacement itself gets the same: its first operation starts a step of its own. The typing after a replacement starts another.

When the replacement is refused or every matching rule declines, the typed text is inserted as usual, the caret after it.

### Writing the replacement yourself

For anything other than text, write it and return `true`. `ctx.remove(then)` removes the matched text in the same plan as the first operation `then` runs, which keeps the pair one refusable command. This rule turns `-> ` at a block's start into a to-do:

```ts title="arrowTodo.ts" check
import { withRulesAndTriggers, type Plugin } from 'edytor';

export const arrowTodo: Plugin = (edytor) =>
	withRulesAndTriggers(
		edytor,
		{},
		{
			inputRules: [
				{
					find: /^->\s$/,
					replace: (_match, { edytor, block, from, remove }) => {
						if (from !== 0) return false;
						// Answers whether the removal and the retype were written.
						return remove(() => edytor.setBlock(block.id, { type: 'todo-item' }));
					}
				}
			]
		}
	);
```

When `then` runs no operation (a command that writes later), the matched text is removed on its own; when it answers `false`, nothing is removed. Without `then`, `remove()` only removes the text.

### Order

The rules of one plugin run in their list order, and the plugins in theirs: the first rule whose pattern matches and that writes wins. A rule that declines (`false`, `null`, nothing written) hands the text to the next one. A plugin's own `onBeforeOperation` runs before its rules.

## Triggers

```ts title="tags.ts" check
import { withRulesAndTriggers, type Plugin } from 'edytor';

const TAGS = ['design', 'engineering', 'research'];

/** `#` opens a menu of tags; picking one writes it as text. */
export const tagsPlugin: Plugin = (edytor) =>
	withRulesAndTriggers(
		edytor,
		{},
		{
			triggers: [
				{
					char: '#',
					items: (query) => TAGS.filter((tag) => tag.startsWith(query.toLowerCase())),
					onPick: (tag: string, { edytor, block, from, caret }) => {
						edytor.insertText(block.id, from, `#${tag} `);
						caret(from + tag.length + 2);
					}
				}
			]
		}
	);
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `char` | `string` | - | The text that opens the menu: one character (@), or two ([[) completed by the second. |
| `items` | `(query, ctx) => T[] \| Promise<T[]>` | - | The rows for the text typed after char. A promise is a search: the newest query's answer wins. |
| `onPick` | `(item, ctx) => unknown` | - | Write the picked row. Return false, running nothing, to keep the text. |
| `label?` | `(item) => string` | - | A row's label. Default: the item when it is a string, else its label, title or name. |
| `key?` | `(item) => string` | - | A row's key, which keeps its markup across queries. Default: its id, else its label. Two rows may share one: element ids come from the row's position. |
| `menu?` | `Snippet<[TriggerMenuController<T>]>` | - | The menu and its words (its name, its empty and pending texts), at the caret while it is open. Without it, nothing shows: the keys still move and pick. See Markup and accessibility below. |
| `enabled?` | `(block) => boolean` | - | Whether the menu may open in block. Default: anywhere but in a code block. |

### When the menu opens and closes

- It opens when `char` is typed at a collapsed caret at the start of a text or after whitespace, as in Notion: `a@b.com` opens nothing. An IME that commits the trigger with more text (`@ad`) opens it with that query.
- Typing at the query's end filters it: `items(query)` is asked again for each query. While a search is pending the menu keeps the previous query's rows (with none, `controller.loading` and no rows: your menu says it is searching), and <kbd>Enter</kbd> waits for the answer and picks its first row: never a row of an older query. An older answer that arrives after a newer one is dropped.
- It closes when the caret leaves the query, the selection expands, the trigger is deleted, the query starts with a space, or a query that matches nothing is followed by a space (`@zz ` is prose). A query that matches nothing leaves no rows (your menu's empty state). It does not open in a readonly view, and it closes when the view turns readonly.
- The query range is anchored in the document, so a collaborator's edits elsewhere in the text do not break it.

### Keys

While a menu is open its keys are the editor's, through the plugin's `hotkeys` (before the plugin's own bindings of the same keys):

| Keys                       | Action                                         |
| -------------------------- | ---------------------------------------------- |
| <kbd>↑</kbd>, <kbd>↓</kbd> | Move the highlight (wraps around)              |
| <kbd>Enter</kbd>           | Pick the highlighted row (when there is one, or a search is pending) |
| <kbd>Escape</kbd>          | Close the menu and keep the text               |
| Hover, click               | Highlight, pick                                |

With no row and no search pending, <kbd>Enter</kbd> and the arrows are the editor's as usual. A press in the menu keeps the editor's focus and the caret at the query.

### Picking

`onPick(item, ctx)` receives the context of the trigger's range: `block`, `from` (where the trigger starts), `to` (the query's end), `query` and `caret(offset)`. The trigger and its query are removed in the same plan as the first operation `onPick` runs, so the pick is one undo step and a refusal keeps the text, with the caret back at the query's end. Before `onPick` runs, the caret is where the trigger was; set the caret you want with `ctx.caret`. `ctx.edytor` is the view: write with its operations. To insert an inline atom, `edytor.insertInline(block.id, from, atom)` writes it where the trigger was, and the caret goes after it at `from + 1`:

```ts title="pick.ts" check
import type { Trigger } from 'edytor';

export const dateTrigger: Trigger<string> = {
	char: '@',
	items: (query) => ['today', 'tomorrow'].filter((day) => day.startsWith(query)),
	onPick: (day, { edytor, block, from, caret }) => {
		const atom = edytor.insertInline(block.id, from, { type: 'date', data: { day } });
		if (atom.status === 'applied') caret(from + 1);
		return atom.status === 'applied';
	}
};
```

### Markup and accessibility

The menu is the trigger's `menu` snippet (`Snippet<[TriggerMenuController<T>]>`), rendered while `controller.isOpen` in the editor's overlay, in a `position: fixed` host with the attribute `data-edytor-trigger-menu-host`, below the caret (above it when there is no room). Read `controller.items`, `loading`, `index` and `labelOf(item)`, call `controller.pick(item)`. Put `{@attach controller.popup}` on your list (name it with `aria-label`) and `{@attach controller.option(index)}` on each row: the list takes its id and its `listbox` role, a press in it keeps the caret, and the root names it and its highlighted row. Every snippet's shape is on [Menus and handles](/docs/customization/menus#mention-page-link-and-other-trigger-menus).

Keep a press on a row from moving the caret out of the query: `popup` cancels the `mousedown` of every press in its element but on a field. While the menu is open the editor's root, which holds the keyboard, names the listbox (`aria-controls`, `aria-haspopup="listbox"`) and its highlighted row (`aria-activedescendant`). See [Accessibility](/docs/editor/accessibility#menus-and-popups).
