Отмена действий и границы операции
Редактор уже изменяет координаты, свойства и порядок фигур. Теперь разберём историю, к которой обращались обработчики прошлых уроков. Пользователь ожидает, что одно нажатие «Отменить» вернёт последнее законченное действие. Для формы это применение свойств, для указателя — весь жест. К концу урока увидим, как одинаковая история обслуживает разные способы ввода.
Полная функция createHistory() находится в model.mjs архива серии. Пример ещё не запускался; цепочки состояний ниже являются ожидаемыми. Наш учебный документ небольшой, поэтому начнём со снимков целиком. Журнал компактных команд можно добавить позднее, когда потребности приложения оправдают дополнительную сложность.
Текущее, прошлое и будущее
История содержит present, массив past и массив future. Текущее состояние — обычный проверенный документ. При новом принятом изменении копируем прежнее состояние в прошлое, заменяем настоящее и очищаем будущее. Последнее правило означает, что повтор доступен только вдоль той ветви, из которой пользователь ещё не вышел новым изменением.
Представьте три состояния: A — исходная схема, B — фигура перемещена, C — её цвет изменён. После двух действий настоящее равно C, прошлое содержит A и B. Одна отмена переносит C в будущее и делает B текущим. Повтор забирает C обратно. Если вместо повтора пользователь в состоянии B меняет подпись и получает D, прежнее C больше не является следующим действием.
undo() {
if (gesture) throw new Error('Операция не завершена');
if (past.length) {
future.push(structuredClone(present));
present = past.pop();
}
}
Это ветвь отмены; полный объект также содержит повтор, принятие и методы жеста. Снимки копируются при сохранении. Без копии последующее изменение вложенной фигуры могло бы поменять сразу несколько исторических состояний. Геттер value тоже возвращает копию, чтобы вызывающий код не получил прямой доступ к внутреннему настоящему.
Принятие отдельного изменения
Метод commit(value) сначала проверяет новый документ. Затем сравнивает его с текущим через JSON.stringify(). Для нашего небольшого нормализованного формата это понятный способ не создавать запись из одинакового состояния. При перестановке уже верхней фигуры или применении неизменённых свойств история остаётся прежней.
Такое сравнение зависит от стабильного устройства объекта. Проверка документа возвращает одинаковый набор полей в одинаковом порядке. Мы не используем произвольные пользовательские объекты с функциями, циклическими ссылками или нестабильными дополнительными свойствами. Если формат станет большим и сложным, сравнение всего JSON нужно будет пересмотреть вместе со способом хранения истории.
Важно проверять до помещения состояния в прошлое. Ошибка новой формы не должна создавать фиктивную запись «отменить неудачную попытку». Пока изменение не принято, текущий документ сохраняется. Само сообщение об ошибке относится к интерфейсу и не входит в историю рисунка.
Перетаскивание как временная транзакция
Для жеста вводим gesture: снимок документа в момент нажатия. begin() сохраняет его, а preview() меняет только текущее состояние. Десятки событий движения не добавляют десятки элементов прошлого. На отпускании finish() сравнивает начальный и конечный документы и, если они различаются, сохраняет начальное состояние одной записью.
begin() {
if (gesture) throw new Error('Операция уже начата');
gesture = structuredClone(present);
},
preview(value) {
if (!gesture) throw new Error('Нет операции');
present = validateDocument(value);
}
Это начало пути временного изменения. В finish() очищается будущее только для действительно изменившегося документа. Нажатие и отпускание без движения не должны разрушать возможность повтора предыдущего действия. Граница операции определяется намерением пользователя, а не просто фактом получения события браузера.
cancel() возвращает gesture в настоящее и очищает временную запись. Отмена самого жеста не добавляет прошлое и не должна менять сохранённую ветвь будущего. Поэтому Escape во время движения отличается от кнопки «Отменить» после него: первый прекращает ещё не принятое действие, вторая возвращает уже принятую операцию.
Какие действия не входят в историю
Выделение и камера находятся вне документа. Пользователь может выбрать другую фигуру или приблизить рисунок, не занимая запись отмены. Если смешать просмотр и редактирование, несколько нажатий увеличения заставят долго возвращаться к последнему изменению подписи. Это редко соответствует ожиданию от графического инструмента.
После отмены обновляем список и форму из нового текущего документа. Выделение сохраняется, пока выбранный id существует. Если его больше нет, оно сбрасывается. История отвечает за данные, а интерфейс приводит своё состояние в соответствие с ними. Такой порядок предотвращает обращения к удалённым объектам после возврата к более раннему снимку.
Импорт JSON принят как замена документа через commit(). Благодаря этому пользователь может отменить загрузку другого файла. В первой версии камера после импорта показывается по его размеру, а отмена возвращает данные без исторического восстановления камеры. Это следствие нашего договора: отмена касается рисунка, а не каждого параметра просмотра.
Цена снимков и ограничение
Массив прошлого ограничен сорока состояниями. При добавлении нового элемента за пределом удаляем самый старый. Это ограничивает глубину возврата, но не является точным лимитом памяти в байтах. Размер каждого снимка зависит от количества фигур и длины подписей. Копии в обработчиках и временный документ жеста тоже занимают память.
| Событие | Прошлое | Будущее |
|---|---|---|
| Новое изменение | Добавляется прежний документ | Очищается |
| Отмена | Удаляется последнее состояние | Добавляется прежнее настоящее |
| Повтор | Добавляется настоящее | Удаляется последнее состояние |
| Отмена жеста | Не меняется | Не меняется |
Для небольшой схемы это разумное учебное начало. При тысячах объектов можно хранить команды с обратным действием или периодические контрольные снимки. Но тогда придётся правильно обращаться с удалёнными идентификаторами, группировкой ввода и изменением формата. Сначала полезно иметь простой и объяснимый контракт, который можно проверять на последовательностях действий.
Проверяем последовательность намерений
При самостоятельном запуске выполните перемещение, изменение цвета, отмену и повтор. Ожидается возврат целых документов. Затем отмените цвет и измените подпись: повтор прежнего цвета должен стать недоступен. Отдельно начните движение и отмените Escape; следующая кнопка отмены должна относиться к предыдущему принятому действию.
Эти сценарии пока не выполнялись. Просматривая исходники, можно также проверить, что во время жеста кнопки истории отключены, а методы защищены от вызова в таком состоянии. Мы получили согласованную историю данных. В следующем уроке вынесем стабильный документ за пределы текущего сеанса: сохраним его локально и проверим внешний JSON перед импортом.