Popit для бедных или жонглируем событиями

Hello friend! Не так давно стала популярной новая релакс игрушка и я решил попробовать запилить ее аналог для веба.
Хотелось бы, чтобы она была более менее реалистичной внешне и по фичам тоже не отставала.

В этой статье я немного расскажу о том, как я работал с событиями,о некоторых ограничениях с которыми столкнулся и как стилизовал сие чудо.

Разметка и стили

Не знаю на сколько мне удалось добиться внешнего сходства (надеюсь хотябы на 50%), но из-за ограниченности времени остановился на текущей версии.
За реализм у меня ответчают box-shadow и radial-gradient, которые позволяют внешним границам не казаться плоскими, а пупрками более-менее выпуклыми. Для впуклых пупырок я использовал фильтр яркости.

Стоит сказать, что в качестве разметки я решил использовать массив чебоксов, которые легко можно стилизовать в зависимости от состояния элемента и удобно подписываться на событие checked, однако, возникшие ограничения заставили меня использовать другой подход (об этом чуть позже).

Я определил соответствие цветов фона для строк с цветом пупырок на них и в зависимости от этого настроил цвета градиентов и контуров.

Например так:

.pop-row.green .pop-item:before{
  border-color: #24b904;
  background-image: radial-gradient(circle, rgb(6 255 1) 0%, rgb(6 255 1) 30%, rgb(4 156 1) 100%);
}

А вот так накидываю фильтр для впуклой ячейки:

.pop-item.checked:before {
  filter: brightness(0.8);
}

Кстати, про класс .checked. Как известно, если несколько способов привязки лейбла к инпуту:
1.

<input type="checkbox" id="happy" name="happy" value="yes">
<label for='happy'>Это чекбокс</label>
<label>
<input type="checkbox" name="happy" value="yes">Это чекбокс
</label>

Я выбрал второй, потому как решил, что слишком долго будет писать разметку, добавляя уникальные id для чекбоксов в таком массиве. И это, на самом деле, было ошибкой, потому что в плане стилизации 1 способ удобнее, т.к. дает возможность стилизовать элемент через соседний селектор + и псевдокласс :checked. А во втором случае вообще нет смысла использовать чекбокс.
Отсюда и появился класс .checked, который просто накинуть или убрать с элемента. Чекбокс получается у меня остался по историческим соображениям (типа работает - не трогай).

Кстати, есть еще вариант с такой разметкой, который можно стилзовать также через input:checked + .targtet-to-style. Но это уже совсем другая история!

<label>
    <input type="checkbox" name="happy" value="yes">
    <span class="tagtet-to-style"></span>
</label>

Накидав 3 разметки для разных размеров экрана (отличающихся числом строк и числом элементов в каждой строке), я закончил с разметкой.

События

Начинаем жонглирование.

Имеем подписчики

let checkboxLists = document.querySelectorAll(".popit");
let body = document.querySelector("body");

body.addEventListener('mousedown', onMouseDown);
body.addEventListener('mouseup', onMouseUp);
body.addEventListener('touchstart', onTouchstart);
body.addEventListener('touchend', onTouchend);
checkboxLists.addEventListener('change', onCheckboxChange);
checkboxList.addEventListener('mouseover', onLabelMouseover);
checkboxList.addEventListener('touchmove', onToucheMove);

Реализация обычного клика. Подписываемся на change чекбокса и наслаждаемся.

checkboxLists.addEventListener('change', onCheckboxChange);

Реализация массивного пробега с зажатой левой кнопкой. Задаем глобальную переменную isMouseDown, на событие mouseDown устанавливаем isMouseDown = true, на mouseover - проверяем что isMouseDown === true и если находимся над лейблом чекбокса (т е пупыркой), то выполняем пук (основное действие - вдавливание или выдавливание).
На mouseup выставляем isMouseDown=false.

body.addEventListener('mousedown', onMouseDown);
body.addEventListener('mouseup', onMouseUp);
checkboxList.addEventListener('mouseover', onLabelMouseover);

Все бы ничего, однако, если вы начали свой массовый пробег с label элемента, то на нем наше действие не сработает. А вот почему:

  • mouseover срабатывает на ячейке (isMouseDown еще false)
  • зажимаем левую кнопку (isMouseDown становится true)
  • идем дальше по полю и mouseover сработает уже только на следующем элементе

Один из вариантов решений - убрать подписку на change и обрабатывать отдельные клики на onMouseDown

function onMouseDown(e) {
    isMouseDown = true;
    if(isPopLabel(e.target)) {
      onPopClick(e);
    }
  }

Данное решение есть результат различных проб и ошибок с подписаками на родительский элемент и эксперименты с погружением и useCapture = true.
(Если у вас есть мысли на этот счет, буду рад послушать их в коментах)

На этом казалось бы стоит успокоиться - все работает. Однако на мобилках - нет! Ведь там вместо клика (или не вместо, а в дополнение к клику) есть touch и нужно обработать и эти события.

Mousedown отработает и так, а вот с touchmove придется попотеть. touchmove в событии не содержит информации о том, на каком элементе возникло событие. Вместо этого вам нужно будет добыть координаты и самому понять, над каким элементом мы находимся.

let myLocation = e.changedTouches[0];
let realTarget = document.elementFromPoint(myLocation.clientX, myLocation.clientY);

Ну а дальше - по накатанной дорожке. Только стоит сравнивать текущий target события и предыдущий и, если они равны, пропускать выполнение (т к touchmove генерируется постоянно пока вы ведете пальцем по экрану)

Казалось бы, тут уже все, финиш. Но есть ограничение, которое я так и не смог побороть. Это проигрывание звука на мобиле. Не во всех случаях, конечно, а только если вы с самомго начала решили массово давить ячейки. Google Chrome ограничивает автовоспроизведение звуков на сайтах, до тех пор пока пользователь не проявит какой то интерактив.

touchmove, к сожалению, к таким интерактивам не относится, только touchend (ну и клики). Как это обходят в html гемдеве (если такой есть), я увы не нашел.

Кстати, хотел продемонстрировать свой костыль для частого проигрывания звуков щелчков. При массовой давке вы получится паузы и прерывания при обычном вызове play:

const audio = new Audio('scripts/pop.mp3');
audio.play();

Поэтому я создал несколько экземпляров audio и проверяю их состояние так:

function playSound() {
    if(!audio.paused){
      if(!audio1.paused){
        audio2.play();
      } else {
        audio1.play();
      }
    } else {
      audio.play();
    }
  }

На этом пожалуй все: тестируйте работу тут, код смотрите тут.
Всякую подобную дичь я пишу тут!

Спасибо за прочтение!