# 输入规则与状态插件

把 ==文字== 转成标记，并用 PM 插件观察选区，避免维护第二份正文。

输入规则把用户刚输入的模式转成正文；PM 插件保存事务派生的状态。两者都依附同一个 Editor，无需监听整个页面再手动同步一份文本。

## 输入规则：键入结束符后转换

本例定义 `readingHighlight` 标记。键入 `==重点==` 的最后一个 `=` 后，规则去掉两端符号，把“重点”转成该标记。最后一个正则捕获组是保留的文字；末尾的 `$` 限制匹配到刚输入的位置。

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

```

将 `createReadingHighlight()` 加入已有 Editor 的 `extensions`。这里的 `tone` 是持久属性，默认 `note`；HTML 使用 `data-tone` 保存。`parseHTML()` 限定只读取带本产品标识的 `<mark>`，不能把任意外部高亮都当作相同业务含义。

例程的 `renderHTML()` 用于 DOM 与 HTML 转换。Canvas/SVG 需要另外声明该标记的绘制样式；定义 HTML 标签不会自动给画布上色。它也没有声明 Markdown 的 `==...==` 转换规则，输入快捷方式与文件转换是两个合同。

## 粘贴规则：一次处理多个匹配

输入规则通常用末尾锚定表达式；粘贴规则用全局标志 `g` 匹配本次粘贴中的每个片段。例程的两个规则共享同一标记，因此输入后或粘贴后的 JSON 含义相同。

`setContent()`、JSON 恢复和服务器导入不应依赖用户输入规则。导入 Markdown、HTML 或文件时，应使用对应格式的解析器。需要关闭规则时，在创建选项中配置 `enableInputRules` / `enablePasteRules`，见[创建选项](/api/options)。

## PM 插件：只保存需要的派生状态

假设你要显示当前选区的位置。`createSelectionRange(notify)` 把 `from` / `to` 存入 `PluginKey` 对应的状态，通过 `readSelectionRange(editor.state.pm)` 查询；mounted plugin view 只在范围变化时通知界面。

### 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;
}

```

创建时安装 `createSelectionRange(range => updateSelectionLabel(range))`，其中 `updateSelectionLabel` 是应用的界面函数。`from` / `to` 是文档位置，不能用差值当作准确字数：节点边界、UTF-16 和跨块内容都会影响它。

`apply()` 每次只比较两个位置，不扫描全文，也不发命令。它返回旧对象时，观察者知道界面不需要更新。无视图的 Core 仍有插件状态；`view()` 及其 UI 通知只在对应视图挂载时存在。

## 资源和验证

插件若创建 DOM 监听、定时器或外部订阅，在 plugin view 的 `destroy()` 中清理；与整个 Editor 同寿命的资源可由扩展 `onDestroy` 清理。不要在 `apply()` 中启动异步请求，或在观察者里无条件写入事务形成循环。

验证时分别覆盖键入、一次粘贴两个匹配、撤销、JSON 恢复和移动选区。恢复 JSON 不应重新触发正则转换；移动选区只更新派生状态，不改变正文。PM 的 `filterTransaction` / `appendTransaction` 会影响全局事务流程，确有需要时再使用，参数见[扩展 API](/api/extensions)。
