Input rules and state plugins
Turn ==text== into a mark and observe selection through a PM plugin without duplicating content.
Input rules transform a pattern the user just typed. PM plugins retain state derived from transactions. Both belong to the same Editor; neither requires a second text model synchronized through page listeners.
Input rules: convert after the closing delimiter
The readingHighlight example converts ==important== when the final = is typed. It removes the delimiters and marks the captured text. The last regular-expression capture is the text to retain; $ anchors the match at the current input position.
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@quirekit/core";
/**
* Creates a small DOM-oriented mark with ==text== typing and paste rules.
* @returns A fresh mark with semantic HTML and persisted tone attributes.
*/
export function createReadingHighlight() {
return Mark.create({
name: "readingHighlight",
/** @returns Persisted semantic tone and its HTML decoding rule. */
addAttributes() {
return { tone: { default: "note",
/** @param element - HTML mark. @returns Supported tone only. */
parseHTML: (element: HTMLElement) => element.getAttribute("data-tone") === "important" ? "important" : "note",
/** @param attrs - Canonical attributes. @returns Stable HTML data attributes. */
renderHTML: (attrs: Record<string, unknown>) => ({ "data-tone": attrs.tone }),
} };
},
/** @returns Only this application's semantic marks are recognized. */
parseHTML() { return [{ tag: "mark[data-reading-highlight]" }]; },
/** @param props - Resolved attributes. @returns A mark containing its text. */
renderHTML({ HTMLAttributes }) { return ["mark", mergeAttributes(HTMLAttributes, { "data-reading-highlight": "" }), 0]; },
/** @returns End-anchored rule, applied after a closing == is typed. */
addInputRules() { return [markInputRule({ find: /==([^=]+)==$/u, type: this.name })]; },
/** @returns Global rule, applied to each match in pasted text. */
addPasteRules() { return [markPasteRule({ find: /==([^=]+)==/gu, type: this.name })]; },
});
}
Add createReadingHighlight() to the Editor's extensions. The persisted tone defaults to note and maps to HTML data-tone. parseHTML() recognizes only this application's marked elements instead of assigning the same business meaning to every external <mark>.
renderHTML() covers DOM and HTML conversion. Canvas/SVG require a separate paint-style declaration. An HTML tag does not automatically paint a canvas mark. The example also declares no Markdown ==...== conversion: a typing shortcut and a file format are separate contracts.
Paste rules: process multiple matches
Input rules usually anchor at the end. Paste rules use g to process every match in the pasted text. Both example rules produce the same canonical mark and JSON meaning.
setContent(), JSON restoration and server imports should not depend on interactive input rules. Use the relevant format parser. Creation options enableInputRules / enablePasteRules can disable rules; see Editor options.
PM plugins: retain only the required projection
To show selection positions, createSelectionRange(notify) stores from / to under a PluginKey. Query it through readSelectionRange(editor.state.pm). Its mounted plugin view notifies UI only when the range changes.
import { Extension } from "@quirekit/core";
import { Plugin, PluginKey, type EditorState } from "@quirekit/pm";
import type { SelectionRange } from "./types";
const key = new PluginKey<SelectionRange>("docsSelectionRange");
/** @param state - Accepted PM state. @returns The extension's immutable range, if installed. */
export function readSelectionRange(state: EditorState): SelectionRange | undefined { return key.getState(state); }
/**
* Projects selection changes without scanning or serializing the document.
* @param notify - Mounted UI notification; call no document commands here.
* @returns Fresh extension owning its plugin state and mounted observer.
*/
export function createSelectionRange(notify: (range: SelectionRange) => void) {
return Extension.create({
name: "docsSelectionRange",
/** @returns One state owner; no second editor or document is created. */
addProseMirrorPlugins() {
return [new Plugin<SelectionRange>({
key,
state: {
/** @param _config - PM construction options. @param state - Initial state. @returns Initial range. */
init: (_config, state) => ({ from: state.selection.from, to: state.selection.to }),
/** @param tr - Accepted transaction. @param value - Last projection. @returns Reused value unless the range changed. */
apply: (tr, value) => tr.selection.from === value.from && tr.selection.to === value.to
? value : { from: tr.selection.from, to: tr.selection.to },
},
/** @param view - Mounted PM view. @returns A view-scoped observer released with the view. */
view(view) {
notify(key.getState(view.state)!);
return {
/** @param current - Current view. @param previous - Previously accepted state. */
update(current, previous) {
const value = key.getState(current.state)!;
if (value !== key.getState(previous)) notify(value);
},
};
},
})];
},
});
}
Install createSelectionRange(range => updateSelectionLabel(range)), supplying your application's UI function. These are document positions, not character counts: structural boundaries, UTF-16 and multiple blocks affect their difference.
apply() compares two positions without scanning content or issuing commands. Reusing the old value tells the observer that no UI update is required. Headless Core retains plugin state; view() and its UI notifications exist only while the corresponding view is mounted.
Resources and verification
Release DOM listeners, timers and subscriptions in the plugin view's destroy(). Extension onDestroy can release resources owned by the whole Editor. Avoid asynchronous work in apply() or unconditional observer writes that form transaction loops.
Test typing, two matches in one paste, undo, JSON restoration and selection movement separately. Restoration must not rerun regular-expression conversion, and selection changes must not modify content. Use filterTransaction / appendTransaction only when your feature needs to affect the global transaction flow; see the extension API.