Создание расширений
Rector поддерживает блочные плагины, инструменты форматирования, постоянные внутристрочные плагины и рендереры блоков. Выбирайте наименьшую роль, действительно владеющую возможностью. Объединение несвязанных ролей усложняет проверку сериализации, истории и освобождения ресурсов.
Выбор типа расширения
| Задача | Расширение |
|---|---|
| Добавить упорядоченную единицу документа со своими данными | Блочный плагин |
| Форматировать выделенный текст без отдельного набора данных | Внутристрочный инструмент |
| Встроить в текст постоянный структурированный виджет | Внутристрочный плагин |
| Отобразить блок вне редактора | Рендерер блока |
У каждого опубликованного типа блока должны быть редакторский плагин и рендерер с одинаковым type и совместимым контрактом данных.
Контракт блочного плагина
Обязательная поверхность невелика:
interface BlockPlugin<Data> {
readonly type: string
readonly title: string
readonly icon: string
render(data: Data, context: BlockMutationContext): HTMLElement
save(element: HTMLElement): Data
}type — устойчивый машинный идентификатор, сохраняемый в документе. Не локализуйте и не переименовывайте его без миграции. title — исходная видимая подпись. icon — доверенная разметка расширения; используйте неизменяемый SVG, принадлежащий пакету.
render() создаёт элемент содержимого одного блока. save() читает тот же элемент и возвращает данные, совместимые с JSON. Оба метода должны выдавать одинаковый результат для эквивалентного входа.
Минимальный текстовый плагин
import { sanitizeHtml } from '@shelamkoff/rector'
export class Callout {
static isTextBlock = true
static styles = [new URL('./callout.css', import.meta.url).href]
type = 'callout'
icon = '<svg viewBox="0 0 24 24" aria-hidden="true">...</svg>'
inlineTools = true
i18n = null
setI18n(i18n) {
this.i18n = i18n
}
get title() {
return this.i18n?.t('title') ?? 'Выноска'
}
render(data, context) {
const root = document.createElement('aside')
root.className = 'callout'
root.contentEditable = 'true'
root.innerHTML = sanitizeHtml(String(data.text ?? ''))
this.context = context
return root
}
save(root) {
return { text: root.innerHTML }
}
validate(data) {
return typeof data.text === 'string'
}
isEmpty(root) {
return root.textContent.trim().length === 0
}
}Обычный ввод в contenteditable отслеживается Rector автоматически. Кнопка, переключатель, выбор значения, завершённая загрузка и другое явное действие плагина должны использовать context.mutate(), сохранённый при вызове render().
Контекст изменения блока
BlockMutationContext отделяет изменения, принадлежащие плагину, от структурных операций редактора:
| Член | Назначение |
|---|---|
mutate(operation) | Выполнить одно синхронное локальное изменение плагина как один шаг истории. Возвращает результат функции или undefined в режиме чтения. |
splitBlock() | Вставить сразу после текущего блока стандартный тип блока и передать ему фокус. При вызове внутри активного mutate() очистка данных плагина и вставка входят в один шаг истории. |
exitEmptyBlock() | Преобразовать текущий пустой нестандартный блок в настроенный стандартный тип. Возвращает true, только если преобразование выполнено. |
readOnly | Равно true, когда элементы управления, меняющие документ, нельзя подключать или активировать. |
Используйте splitBlock() в спископодобном плагине, когда пустой последний пункт удаляется, а ввод должен продолжиться в обычном абзаце. Используйте exitEmptyBlock(), когда пуст весь структурированный блок. Не создавайте искусственные клавиатурные события для этих операций: плагин может повторно перехватить такое событие, а граница команды останется неявной. Структурные методы ничего не меняют, когда службы режима редактирования недоступны.
В режиме только для просмотра Rector по умолчанию отключает кнопки, поля ввода и другие элементы управления внутри блока. Элемент, который меняет только представление, можно оставить активным с помощью атрибута data-oe-read-only-interactive: например, кнопку копирования, навигацию карусели или раскрытие спойлера. Не добавляйте этот атрибут голосованию, загрузке файлов, настройкам, переходам с побочными действиями приложения и любым операциям, меняющим сохраняемые данные. Атрибут является явным обещанием безопасности элемента в режиме только для просмотра, но сам по себе не делает обработчик безопасным.
Необязательные возможности блока
| Член | Назначение |
|---|---|
inlineTools | true включает общий набор редактора, массив строк задаёт разрешённые инструменты блока, а false отключает их |
getPluginConfig() | передать Rector неизменяемую конфигурацию конструктора, чтобы редактор мог применить общие параметры injectStyles и css, не обращаясь к закрытым полям; BlockPluginAbstract уже реализует этот метод |
setPlaceholder(value) | принять общий placeholder редактора для настроенного стандартного блока; значение, переданное непосредственно плагину, должно иметь более высокий приоритет |
validate(data) | принять или отклонить данные плагина |
destroy(element) | освободить обработчики и ресурсы блока |
dispose() | освободить общие ресурсы всех блоков плагина при уничтожении редактора-владельца |
isEmpty(element) | определить правило пустого блока |
toolbox | один или несколько вариантов вставки с исходными данными |
shortcuts | сочетания клавиш внутри блока |
merge(element, data) | объединить следующий совместимый блок с текущим |
renderSettings(element) | создать интерфейс настроек блока |
changeLevel(element, level) | заменить элемент с настраиваемым уровнем |
onSettingsAction(element, action) | применить именованное действие настроек |
pasteConfig | объявить обрабатываемые теги, файлы и текстовые шаблоны |
onPaste(event) | преобразовать совпавшую вставку в данные плагина |
waitForPaste(element) | дождаться работы плагина до записи вставки в историю |
exportData(element) | выдать нейтральные данные для преобразования блока |
splitSelection(element, range) | описать оставшиеся и переносимые данные при преобразовании части структурированного блока |
renderInlineControls(element, ctx) | добавить специальные элементы во внутристрочную панель |
mapTextFields(data, transform) | открыть все поля с HTML для переноса виджетов |
Реализуйте только те возможности, которыми плагин действительно владеет. Наличие необязательных методов определяется во время выполнения.
Конфигурация плагина во время выполнения
Плагин может открыть общие параметры через getPluginConfig():
interface PluginRuntimeConfig extends Record<string, unknown> {
injectStyles?: boolean
css?: string
}BlockPluginAbstract копирует и замораживает переданную конструктору конфигурацию. Потребитель может безопасно читать её, но изменение исходного объекта не перенастроит работающий экземпляр. Значение injectStyles: false отключает адреса из статического массива styles плагина. Поле css содержит адрес одной дополнительной таблицы стилей, загружаемой после статических стилей; это не строка с правилами CSS. Рядом с общими параметрами плагин может объявлять собственные поля конфигурации.
Специальные элементы внутристрочной панели
Метод renderInlineControls(element, context) добавляет элементы управления, относящиеся только к активному блоку:
interface InlineControlContext {
suppressSelectionChange(): void
mutate<T>(operation: () => T): T
onContentElementChanged(newElement: HTMLElement): void
}
interface InlineControlGroup {
elements: HTMLElement[]
destroy?(): void
}Верните InlineControlGroup или null, если в текущем состоянии блоку нечего показывать. Rector помещает все элементы из elements в специальную часть внутристрочной панели и вызывает destroy() при удалении этой группы. Вызывайте mutate() один раз на одно завершённое синхронное действие. Если элемент управления заменяет элемент содержимого плагина, перед заменой вызовите suppressSelectionChange(), а после неё — onContentElementChanged(newElement), чтобы Rector сохранил правильную принадлежность блока и состояние выделения.
Преобразование части данных
Обычный текстовый блок можно разделить по HTML-диапазону. Структурированный плагин, например список, должен самостоятельно описать границы данных методом splitSelection(element, range):
static isTextBlock = true
splitSelection(element, range) {
const selected = readSelectedItems(element, range)
if (selected.length === 0) return null
const remaining = readUnselectedItems(element, range)
return {
remainingData: remaining.length
? { style: element.dataset.style, items: remaining }
: null,
selectedData: {
text: selected.join('<br>'),
items: selected,
},
}
}Метод только описывает результат и не должен менять element, создавать блоки, перемещать выделение или создавать события. Rector применяет возвращённые данные внутри активной команды:
remainingDataзаменяет данные исходного плагина; значениеnullудаляет полностью использованный исходный блок;selectedDataпередаётся целевому типу, конструктор которого объявляетstatic isTextBlock = true;- нетекстовый целевой тип получает только собственные исходные данные из меню или переключателя;
- новый блок вставляется сразу после оставшегося исходного блока или на позицию удалённого блока.
Верните null, если диапазон нельзя безопасно представить данными плагина. Сохраняйте очищенную внутристрочную разметку и используйте те же поля, что в save() и mapTextFields(). Эталонная реализация находится в плагине List: выделенные пункты удаляются, нумерация оставшихся пунктов обновляется, а при выборе текстового типа выделенная разметка становится полем text.
Резервный алгоритм работает только с корневым элементом плагина, у которого установлено contenteditable="true" и который сохраняет содержимое в поле text. Он никогда не перезаписывает оболочку с несколькими редактируемыми дочерними элементами. Если структурный плагин не реализует splitSelection() или возвращает null, Rector не меняет документ. Преобразование всего блока по-прежнему доступно через exportData().
Обработка вставки из буфера
Контракт вставки различает HTML-теги, файлы и совпавший текст:
interface PasteConfig {
tags?: string[]
files?: string[]
patterns?: RegExp[]
}
interface TagPasteEvent {
type: 'tag'
element: HTMLElement
tag: string
}
interface FilePasteEvent {
type: 'file'
file: File
}
interface PatternPasteEvent {
type: 'pattern'
data: string
}
type PasteEvent = TagPasteEvent | FilePasteEvent | PatternPasteEventpasteConfig = {
tags: ['aside'],
patterns: [/^!callout\s+/i],
}
onPaste(event) {
if (event.type === 'tag') {
return { text: event.element.innerHTML }
}
if (event.type === 'pattern') {
return { text: event.data.replace(/^!callout\s+/i, '') }
}
return null
}Для файлов используются шаблоны MIME, например image/*. Считайте HTML, текст и файлы из буфера недоверенными. onPaste() возвращает данные плагина и не должен самостоятельно вставлять блок или создавать события.
Сочетания клавиш блока
interface ShortcutEntry {
combo: string
handler(contentElement: HTMLElement): void
}Каждый элемент shortcuts регистрирует нормализованное сочетание, например Ctrl+Shift+K. Rector отменяет стандартное действие браузера, передаёт в handler() элемент содержимого текущего блока и выполняет обработчик внутри одной транзакции изменения плагина. Обработчик сочетания должен оставаться синхронным. Если он запускает асинхронную работу, сохраните BlockMutationContext, полученный в render(), и вызовите context.mutate() только после завершения этой работы.
Отображение текстовых полей
Текстовый плагин с постоянными внутристрочными виджетами должен перечислить каждое поле, содержащее HTML:
mapTextFields(data, transform) {
data.text = transform(data.text)
}Для списка обработайте каждый элемент; для цитаты — текст и подпись, если оба поля поддерживают виджеты. Соответствующий рендерер обязан повторять тот же набор полей.
Контракт внутристрочного инструмента
Внутристрочный инструмент проверяет сохранённое выделение и переключает форматирование. Минимально нужны type, icon, isActive(selection) и toggle(selection).
Для форматирования одним HTML-элементом используйте открытую фабрику createSimpleInlineTool(). Полный объектный контракт нужен инструменту с панелью выбора значения, изменяемым состоянием кнопки или собственным раскрывающимся элементом.
Для панели, например формы ссылки или выбора цвета, используйте renderActions(ctx). onMount(button, mutations) нужен только установленному элементу с прямым доступом к изменению диапазона. Одно завершённое действие должно создавать одно изменение. Освобождайте обработчики в destroy().
Полный запускаемый пример, все необязательные методы, правила регистрации, постоянные элементы управления и каталог встроенных типов приведены в разделе Внутристрочные инструменты и плагины.
Контракт внутристрочного плагина
Внутристрочный плагин сохраняет структурированные данные в тексте. Он обязан сохранить переданный в createWidget(data, id) идентификатор в атрибуте data-id корневого элемента.
interface InlinePlugin {
readonly type: string
readonly title: string
readonly icon: string
createWidget(data: Record<string, string>, id?: string): HTMLElement
hydrate(element: HTMLElement, context: InlinePluginContext): void
getData(element: HTMLElement): Record<string, string>
isCommitted?(element: HTMLElement): boolean
}Необязательные trigger, pasteConfig, onPatternMatch(), onEdit(), onCancel(), onCommit() и insertFresh() поддерживают подсказки и преобразование шаблонов. Символ активации должен состоять ровно из одного символа Юникода. Редактор вызывает onCancel(), когда активный сеанс ввода отменён, чтобы плагин закрыл временный интерфейс и прервал незавершённую работу. Метод isCommitted() позволяет не сохранять незавершённый виджет как структурированные данные, оставляя его видимый текст обычным текстом. Изменения сохранённых данных виджета выполняются через context.mutate(target, operation).
Один объект плагина может обслуживать много элементов виджетов. Храните состояние отдельного элемента в WeakMap, а не в одном общем поле.
Хранение виджетов, вставка, регистрация рендерера и сравнение с инструментами форматирования описаны в разделе Внутристрочные инструменты и плагины.
Контракт рендерера блока
Рендерер блока превращает сохранённые данные в итоговый DOM:
export function createCalloutRenderer(prefix = 'oe') {
return {
type: 'callout',
styles: [new URL('./callout-renderer.css', import.meta.url).href],
render(block, parseInline) {
const root = document.createElement('aside')
root.className = `${prefix}-callout`
root.append(parseInline(String(block.data.text ?? '')))
return root
},
mapTextFields(data, transform) {
data.text = transform(data.text)
},
}
}Зарегистрируйте его через renderer.registerRenderer(). parseInline() очищает поддерживаемое форматирование и восстанавливает зарегистрированные внутристрочные виджеты. Не назначайте недоверенные данные плагина напрямую в innerHTML.
Стили и локализация
Ограничивайте таблицу стилей расширения его корневым классом. URL редакторских стилей объявляются через статическое styles, а URL стилей рендерера — в массиве styles объекта рендерера. Не включайте стили демонстрации или документации в рабочий пакет.
Ключи локализации блока должны начинаться с plugin.<type>.*. createEditor() передаёт в setI18n() словарь, уже ограниченный этим префиксом, поэтому вызов i18n.t('title') из примера обращается к ключу plugin.callout.title. Экспортируйте словари из пакета расширения и объединяйте выбранный язык со словарём редактора:
import ru from '@shelamkoff/rector/locale/ru'
import calloutRu from '@acme/rector-callout/locale/ru'
createEditor({
holder,
plugins: [new Callout()],
locale: { ...ru, ...calloutRu },
})Если расширение поддерживает два языка, поставляйте английский и русский словари. Машинные идентификаторы, ключи конфигурации и сохраняемые значения не должны зависеть от языка.
Освобождение ресурсов и владение
Освобождайте каждый ресурс там, где он принадлежит. destroy(element) может вызываться при замене, удалении или восстановлении отдельного блока из истории. dispose() вызывается один раз, когда редактор освобождает экземпляр плагина:
- удаляйте обработчики блока и отменяйте его запросы в
destroy(element); - удаляйте общие обработчики и кэши плагина в
dispose(); - отключайте наблюдатели и таймеры;
- уничтожайте сторонние экземпляры;
- сохраняйте объектные URL, используемые снимками истории, до
dispose(), а затем отзывайте их; - удаляйте временные всплывающие элементы через переданный контекст;
- игнорируйте асинхронные результаты после уничтожения или отсоединения элемента.
Проверка перед публикацией
- Опишите установку, путь импорта, конфигурацию, форму данных, стили, локализацию и освобождение ресурсов.
- Проверьте пустые, корректные, ошибочные и исторические данные.
- Проверьте устойчивость цикла
render → save → render. - Проверьте пошаговую отмену и повтор каждого элемента управления.
- Проверьте вставку враждебного HTML, URL и чрезмерно больших файлов.
- Проверьте несколько блоков и несколько экземпляров редактора.
- Для нового типа блока поставьте соответствующий рендерер.
- Экспортируйте декларации и CSS через карту
exportsпакета.
Конкретные контракты собраны в каталоге блочных плагинов, каталоге внутристрочных плагинов и каталоге рендереров.