Перейти к содержанию

Перемещение и команды изменения

После прошлого урока редактор определяет фигуру под указателем. Теперь сделаем её перемещение. Пользователь нажимает на прямоугольник, двигает его и отпускает кнопку. Для нас это одна операция, хотя браузер присылает много событий. К концу урока разберём путь от начальной точки до нового документа и отделим временное движение от окончательного изменения.

Фрагменты относятся к model.mjs и main.mjs архива «Схемы». История, которую вызывают обработчики, подробно рассматривается в седьмом уроке. Пример ещё не запускался, поэтому описанные реакции являются ожидаемыми. Сейчас сосредоточимся на жизненном цикле жеста, а не на измерении его скорости.

Команда меняет данные

Функция moveShape(doc, id, x, y) получает документ и желаемое положение. Она создаёт копию, находит фигуру и ограничивает координаты размерами страницы. Возвращаемый документ проходит ту же проверку, что и импорт. Обработчик события не должен отдельно менять атрибут SVG и надеяться, что данные догонят изображение позднее.

const result = structuredClone(doc);
const shape = result.shapes.find(item => item.id === id);
shape.x = Math.min(Math.max(x, 0), doc.width - shape.width);
shape.y = Math.min(Math.max(y, 0), doc.height - shape.height);
return validateDocument(result);

Это основная часть функции; в полном файле перед присваиванием также проверяются фигура и конечность координат. При попытке переместить прямоугольник шириной 240 на x = 900 документ шириной 1000 получит x = 760. Команда сохраняет фигуру целиком на странице. Для полей формы мы позднее используем другое поведение: неверные размеры отклоняются сообщением, а не молча изменяются.

Различие полезно пользователю. При перетаскивании невозможно точно остановить руку на математической границе, поэтому ограничение естественно. В числовом поле человек вводит конкретное значение и ожидает либо его применения, либо объяснения ошибки. Общая модель одинакова, но способы получения допустимого изменения могут различаться.

Что запоминаем при нажатии

На pointerdown вычисляем точку документа и верхнюю фигуру. Если подходящей фигуры нет, снимаем выделение и завершаем обработчик. Если она есть, сохраняем идентификатор указателя, начальную точку, исходный документ и исходную фигуру. На каждом последующем движении используем именно эту исходную пару.

drag = {
  pointer: event.pointerId,
  id: shape.id,
  point,
  original: doc,
  shape
};

Это запись временной операции, а не формат сохранённой схемы. Она существует только до отпускания или отмены. pointerId позволяет отличить активный указатель от второго касания. Первая версия допускает одно перемещение одновременно; дополнительные нажатия во время него игнорируются. Поддержка двух пальцев для масштабирования потребовала бы другого договора взаимодействия.

Сначала вызываем history.begin(), затем захватываем указатель на постоянном корневом SVG. Метод setPointerCapture() направляет дальнейшие события данного указателя выбранному элементу. Это помогает продолжить жест, когда курсор выходит за полотно. Захват не заменяет проверку pointerId и обработку отмены браузером.

На время жеста отключаем свойства, добавление, удаление, перестановку слоёв, импорт, историю и экспорт. Такие действия могут неожиданно поменять документ, к которому относится исходная точка. Вместо сложного объединения двух операций наша версия явно завершает одну перед началом другой. Это небольшое ограничение облегчает понимание состояния.

Новое положение из разницы точек

В pointermove снова переводим экранную точку в координаты документа. Разницу между ней и исходной точкой добавляем к исходным координатам фигуры. Если пользователь нажал внутри прямоугольника на расстоянии 50 единиц от левого края, этот отступ сохранится. Фигура не должна прыгать так, чтобы её угол оказался прямо под курсором.

const next = moveShape(
  drag.original,
  drag.id,
  drag.shape.x + point.x - drag.point.x,
  drag.shape.y + point.y - drag.point.y
);
history.preview(next);

Фрагмент расположен внутри обработчика движения. Каждое новое положение вычисляется от исходного документа. Если добавлять полный сдвиг к уже изменённой фигуре, путь будет накапливаться несколько раз. Указатель пройдёт десять единиц, а объект может уйти на десятки или сотни — в зависимости от числа событий.

Во время движения обновляем только сцену. Перестроение списка слоёв и полей на каждом событии увеличило бы лишнюю работу и могло бы нарушать фокус. Полное обновление элементов управления выполняем при завершении. Координаты камеры на время жеста не меняются, поэтому исходная и текущая точки остаются в одной системе.

Завершение и отмена

Успешное отпускание вызывает history.finish(). Отмена через pointercancel, потерю захвата или Escape вызывает history.cancel(). В обоих случаях удаляем временную запись, освобождаем захват, обновляем интерфейс и планируем сохранение стабильного документа. Перед освобождением захвата обнуляем drag, чтобы последующее событие потери захвата не отменило уже завершённую операцию.

Браузер может отменить указатель при изменении способа взаимодействия или состояния страницы. Если обрабатывать только pointerup, редактор останется в режиме перемещения с отключёнными кнопками. Поэтому отмена — отдельный обычный переход, а не редкая аварийная ветка. Пользователь должен получить понятное сообщение и вернуться к прежнему документу.

Перед началом жеста очищаем ожидающий таймер автосохранения. Иначе таймер от предыдущего действия может сработать, когда в истории находится промежуточный кадр. После окончания или отмены ставим новый таймер. Более подробный разбор сохранения будет в восьмом уроке, но границу стабильного состояния определяем уже здесь.

На полотне CSS ограничивает стандартное сенсорное действие через touch-action. Это настройка поверхности редактора, а не всей страницы: окружающая статья и панель должны оставаться пригодными для обычной прокрутки. При добавлении новых жестов нужно проверить, не мешают ли они браузерному увеличению и навигации. Захват указателя и preventDefault() не являются универсальным разрешением перехватывать любое взаимодействие.

Кнопка указателя также проверяется до начала: первая версия принимает основное действие. Контекстное нажатие не должно неожиданно передвигать объект. При нескольких касаниях активный pointerId сохраняется до завершения, а чужое отпускание не заканчивает его. Эти условия описывают границы взаимодействия, которые важно сохранить при последующей оптимизации обработчиков.

Ожидаемые случаи

Проследите перемещение «Редактора» на 50 единиц вправо и 20 вниз. При исходных 100, 120 ожидаются 150, 140 независимо от числа событий движения. Затем выйдите курсором за край страницы: координаты фигуры должны остановиться на допустимой границе. После отпускания следующая команда отмены должна вернуть одно исходное положение, а не прокручивать десятки промежуточных точек.

Другой сценарий — нажать, передвинуть и использовать Escape. Ожидается полное восстановление исходного документа без новой записи истории. То же правило действует при pointercancel. Эти проверки пока не выполнены; они описывают контракт реализации. Теперь у нас есть целостное перемещение. В следующем уроке изменим масштаб просмотра, сохранив геометрию документа и правильность преобразования указателя.

Оглавление серии