Команды и история
Rector считает одно завершённое действие пользователя одной командой и одним шагом истории. Это правило распространяется на структурные изменения, внутристрочное форматирование, настройки блоков, вставку из буфера, разделение и объединение блоков и элементы управления плагинов.
Понимание границы команды необходимо и при подключении элементов управления приложения, и при разработке интерактивного расширения.
Что такое команда
Команда — синхронная транзакция вокруг изменения документа. На границе внешней команды Rector фиксирует состояние документа, выполняет изменение, помечает затронутые блоки, создаёт события об изменении и записывает ровно один шаг истории.
Если операция выбрасывает исключение, Rector откатывает команду целиком и не добавляет запись в историю. Вложенные изменения присоединяются к внешней транзакции. Перехват ошибки вложенной операции не превращает внешнюю команду в успешную.
Внутренний диспетчер и объекты команд не являются публичным API. Прикладной код использует editor и editor.blocks, а расширение — функцию mutate(...), полученную в своём контексте.
Команды из прикладного кода
Публичные структурные методы уже проходят через конвейер команд. Не создавайте события редактора и не меняйте DOM оболочки блока, пытаясь изобразить операцию вручную.
const inserted = editor.blocks.insert(
'paragraph',
{ text: 'Новый абзац' },
editor.blocks.getBlockCount(),
)
if (inserted) {
editor.blocks.convert(
editor.blocks.getBlockIndex(inserted.id),
'heading',
{ level: 2 },
)
}Каждый вызов в примере становится отдельным шагом истории. То же правило действует для remove() и move().
render() и clear() заменяют документ через общую границу редактора. Используйте их для операций приложения над документом, но не для реализации элементов управления плагина.
Команды из блочного плагина
render(data, context) получает BlockMutationContext. Обработчик, меняющий постоянное состояние блока или DOM, вызывает context.mutate() один раз для каждого завершённого действия пользователя. Каждое новое нажатие в следующем примере является новым действием, поэтому для него создаются новая команда и новый шаг истории.
render(data, context) {
const root = document.createElement('div')
const button = document.createElement('button')
const value = document.createElement('span')
value.textContent = String(data.count ?? 0)
button.textContent = 'Увеличить'
button.addEventListener('click', () => {
context.mutate(() => {
value.textContent = String(Number(value.textContent) + 1)
})
})
root.append(value, button)
return root
}Слово «один» не означает единственный вызов за всё время жизни плагина. Оно обозначает одну границу команды вокруг одного логического действия: нажатие, меняющее несколько связанных полей, использует один вызов mutate(), а два отдельных нажатия используют два вызова и создают два шага истории.
Rector сохраняет состояние DOM плагина до и после изменения. Отмена восстанавливает предыдущий сериализованный документ, а повтор — следующий. Поэтому save() и render() плагина должны образовывать устойчивый цикл сохранения и восстановления.
Не вызывайте mutate() при получении фокуса, наведении, открытии меню или другом временном состоянии интерфейса, которое не является частью документа.
Спископодобному плагину иногда нужна структурная операция ядра после изменения собственных данных. context.splitBlock() вставляет после текущего блока настроенный стандартный блок, а context.exitEmptyBlock() преобразует пустой нестандартный блок в стандартный тип. Вызывайте splitBlock() внутри того же mutate(), который удаляет пустой последний пункт: оба результата станут одним составным шагом истории. exitEmptyBlock() самостоятельно входит в конвейер структурных команд и сообщает, выполнено ли преобразование. Эти методы заменяют искусственные события клавиш Enter и Backspace; плагин не должен имитировать клавиатурный ввод ради изменения структуры редактора.
Команды через /
Ввод / открывает меню команд. В пустом блоке оно предлагает зарегистрированные блочные плагины. Если зарегистрированы внутристрочные плагины, меню также может открываться после уже введённого текста и предлагать подходящие внутристрочные виджеты. Пункты автоматически формируются из массивов plugins и inlinePlugins; отдельного списка команд в конфигурации нет.
Текст после / фильтрует пункты по типу плагина и его локализованному названию. Меню располагается непосредственно под текстом /запрос, а не у левого края блока. При прокрутке контейнера оно следует за командой, а при недостатке места снизу открывается над ней.
| Клавиша или действие | Результат |
|---|---|
ArrowDown / ArrowUp | Перемещение по отфильтрованным пунктам. |
Enter или Tab | Выполнение активной команды. |
| Нажатие указателем | Выполнение выбранной команды без потери выделения редактора. |
Escape | Удаление текущего текста /запрос и закрытие меню. |
Backspace, когда остался только / | Закрытие меню; символ / удаляет браузер. |
Если текущий блок содержит только /запрос, выбор блочного пункта преобразует его на месте. Если перед командой уже есть содержимое, Rector удаляет только /запрос, сохраняет исходный блок и вставляет выбранный блок сразу после него. Выбор внутристрочного пункта удаляет /запрос и вставляет виджет точно в эту позицию. Каждый вариант записывается как одна команда, поэтому одна отмена возвращает состояние до выбора. Стили меню задаются классом .oe-slash-menu и его дочерними классами, перечисленными в разделе «Стили и темы».
Преобразование выделения внутри блока
Переключатель типа позволяет преобразовать выделение, не заменяя весь исходный блок. Обычный текстовый блок разделяется на содержимое до выделения, новый блок и содержимое после выделения. Вся операция создаёт один шаг истории.
Плагин List обрабатывает данные списка и не передаёт разметку <li> универсальному разделителю HTML:
- выделенное содержимое удаляется из исходного списка;
- новый блок вставляется сразу после оставшегося списка;
- оставшиеся пункты нумерованного списка находятся в одном
<ol>, поэтому браузер перенумеровывает их; - при выборе текстового типа выделенная внутристрочная разметка становится полем
textнового блока; - при выборе нетекстового типа выделение удаляется, а новый блок получает исходные данные выбранного плагина;
- если выделены все пункты, исходный список удаляется, а новый блок занимает его позицию.
Отмена и повтор атомарно восстанавливают и список, и вставленный блок. Автор расширения может реализовать такое же поведение методом splitSelection(), описанным в разделе «Создание расширений».
Команды из внутристрочных инструментов
Встроенная панель направляет форматирование через изменение диапазона. Пользовательский инструмент получает один из двух контекстов:
InlineMutationContextвonMount(button, mutations)для непосредственного действия;InlineToolActionContextвrenderActions(ctx)для панели, работающей с сохранённым выделением.
renderActions(ctx) {
const apply = document.createElement('button')
apply.textContent = 'Применить'
apply.addEventListener('click', () => {
ctx.restoreSelection()
ctx.mutate(() => applyStyleToRange(ctx.range))
ctx.close()
})
return apply
}Полужирное и курсивное начертание, создание и удаление ссылки, цвет, выравнивание, размер шрифта и очистка форматирования используют эту же границу. Одно нажатие инструмента нельзя делить на несколько вызовов mutate().
Полный контракт инструмента, правила регистрации, названия встроенных типов, панели действий и отличие от постоянных виджетов описаны в разделе Внутристрочные инструменты и плагины.
Команды из элементов управления блока
renderInlineControls(contentElement, ctx) создаёт специальные элементы блока во внутристрочной панели, например выбор уровня заголовка. Его ctx.mutate() формирует запись истории.
Если действие заменяет редактируемый элемент блока, до замены вызовите ctx.suppressSelectionChange(), а после неё — ctx.onContentElementChanged(newElement). Эти методы сохраняют принадлежность выделения, но сами по себе не создают запись истории.
Команды из внутристрочных виджетов
Постоянный внутристрочный плагин получает InlinePluginContext. Передайте сам виджет или его дочерний узел как target, чтобы Rector связал команду с блоком-владельцем.
hydrate(element, ctx) {
const remove = element.querySelector('[data-remove]')
remove.addEventListener('click', () => {
ctx.mutate(element, () => element.remove())
})
}notifyChanged() сохранён для совместимости с изменениями, уже выполненными до уведомления. В новом коде используйте mutate(), чтобы предыдущее состояние было зафиксировано правильно.
Асинхронная работа
Функция, переданная в mutate(), должна быть синхронной. Не объявляйте её как async и не запускайте внутри неё незавершённый промис. Сначала выполните сетевую, файловую или мультимедийную работу, затем зафиксируйте полученный результат одним коротким изменением.
button.addEventListener('click', async () => {
button.disabled = true
try {
const uploaded = await uploadFile(file)
context.mutate(() => {
preview.src = uploaded.url
preview.dataset.fileId = uploaded.id
})
} finally {
button.disabled = false
}
})Асинхронный обработчик обязан защищаться от устаревшего результата и уничтожения. Расширение должно проигнорировать ответ, если его элемент уже отсоединён, появился более новый запрос или был вызван destroy().
Управление отменой и повтором
Внутри корневого элемента редактора зарегистрированы сочетания клавиш с учётом платформы:
| Действие | Сочетание клавиш |
|---|---|
| Отменить | Mod+Z |
| Повторить | Mod+Shift+Z или Mod+Y |
Mod означает Command в macOS и Control в Windows или Linux. Элементы управления приложения работают с той же историей через публичные методы editor.undo() и editor.redo(). Оба возвращают false, если подходящего шага нет или редактор находится в режиме чтения. Для состояния недоступности кнопок используйте editor.canUndo, editor.canRedo и событие history:changed; обращаться к внутреннему диспетчеру истории не следует.
В обычных элементах input и textarea, принадлежащих плагину, остаётся собственная отмена браузера. История Rector имеет приоритет только тогда, когда фокус находится в редактируемом содержимом документа или интерфейсе редактора.
Порядок и объединение истории
История работает по принципу «последним добавлен — первым отменён». Если сначала вставить блок, а потом применить форматирование, первая отмена удалит форматирование, а вторая — вставленный блок. Повтор восстановит действия в обратном порядке.
Непрерывный ввод текста объединяется в пределах tuning.undo.debounceMs, по умолчанию равного 300 мс. Группу ввода закрывает граница команды, изменение выделения, структурная операция, вставка из буфера или действие панели инструментов.
canUndo и history:changed реагируют сразу после первого события ввода, открывающего такую группу, поэтому кнопки приложения не ждут окончания задержки. Начало новой ветви ввода также сразу меняет canRedo на false.
История хранит не более tuning.undo.maxStack записей; значение по умолчанию — 100. Новое действие после отмены удаляет прежнюю ветвь повтора.
События наблюдают, но не выполняют команды
editor.events позволяет только подписываться. События сообщают о завершённом поведении; прикладной код и плагины не должны создавать их самостоятельно.
const stopHistoryState = editor.events.on('history:changed', ({ canUndo, canRedo }) => {
undoButton.disabled = !canUndo
redoButton.disabled = !canRedo
})
const stopDirtyState = editor.events.on('history:commit', () => {
markDocumentDirty()
})
// Позже
stopHistoryState()
stopDirtyState()history:changed сообщает о доступности команд, history:commit — о записанном шаге, а editor:changed — об изменении документа. onChange — отложенное сериализованное уведомление, предназначенное для сохранения. Переход между режимами отправляет history:changed, но не отправляет history:commit, editor:changed и не вызывает onChange.
Проверка расширения
Перед публикацией интерактивного расширения проверьте все последовательности:
- выполните действие, отмените один раз и сравните полный документ с исходным;
- повторите один раз и сравните документ с результатом действия;
- после отмены выполните новое действие и убедитесь, что старая ветвь повтора исчезла;
- заставьте действие выбросить исключение и убедитесь, что ни DOM, ни история не изменились;
- уничтожьте редактор во время асинхронной работы и убедитесь, что результат проигнорирован;
- проверьте выделение и фокус после отмены и повтора.