Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Input rules and triggers

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.

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 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), [[ links a page (Page link). The 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).

Input rules

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 }] }
	);
PropType
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.

TypeRegExp
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.

Type(match, ctx) => string | boolean | null | void

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 + : gives hi 😄, the caret after the emoji.

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

PropType
block?Block

The block the caret is in.

TypeBlock
from?number

Where the match starts, in block offsets.

Typenumber
to?number

The caret: where the match ends in the document, the typed text left out.

Typenumber
typed?string

The typed text that completed the match.

Typestring
caret?(offset: number) => void

Set the command's result caret at an offset of block.

Type(offset: number) => void
remove?(then?: () => unknown) => boolean

Remove from to to in the same plan as the first operation then runs. Answers whether it was written.

Type(then?: () => unknown) => boolean
edytor?EdytorInstance

The editor.

TypeEdytorInstance

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:

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

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);
					}
				}
			]
		}
	);
PropType
charstring

The text that opens the menu: one character (@), or two ([[) completed by the second.

Typestring
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.

Type(query, ctx) => T[] | Promise<T[]>
onPick(item, ctx) => unknown

Write the picked row. Return false, running nothing, to keep the text.

Type(item, ctx) => unknown
label?(item) => string

A row's label. Default: the item when it is a string, else its label, title or name.

Type(item) => string
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.

Type(item) => string
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.

TypeSnippet<[TriggerMenuController<T>]>
enabled?(block) => boolean

Whether the menu may open in block. Default: anywhere but in a code block.

Type(block) => boolean

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

With no row and no search pending, Enter 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:

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.

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.

Was this page helpful?