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

### core/rules/highlight.ts

```ts
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](/en/api/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.

### core/rules/selection.ts

```ts
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);
            },
          };
        },
      })];
    },
  });
}

```

### core/rules/types.ts

```ts
/** Document positions describing a selection; these are not character counts. */
export interface SelectionRange {
  /** Lower document position. */
  readonly from: number;
  /** Upper document position. */
  readonly to: number;
}

```

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](/en/api/extensions).
