В предыдущей статье я рассказывал, как мы рисуем соединения между нодами на наших пространствах. Сейчас же я расскажу, как у нас реализованы сами пространства!

Рабочая область в нашем приложении
Рабочая область в нашем приложении

Рабочее пространство в нашем приложении представляет собой бесконечную доску, по которой могут перемещаться ноды. Необходимо реализовать масштабирование этого пространства и перемещение по нему. Все это мы делаем без использования Canvas, так как приложение построено на React, в дизайн-системе используется antd, а ноды могут быть огромными формами. Согласитесь, реализовывать такие интерфейсы было бы гораздо сложнее, не будь у нас доступа к нативным средствам HTML-5.

Как все устроено

Если вы читали статью про соединения, то у вас уже есть представление, как у нас устроен DOM. Разберем его здесь чуть более подробно. Все обернуто в .app с position: relative, а так же шириной и высотой в 100%. relative нужен чтобы контролировать внутри дивы с абсолютным позиционированием относительно себя, а ширина с высотой, очевидно, чтобы занимать собой весь экран. Остальные контейнеры имеют схожие стили, с тем лишь отличием, что основной контейнер имеет overflow: hidden.

<div class="app">
	<div class="container">
		<div class="nodes-container">
			<div class="node">node #1</div>
			<div class="node">node #2</div>
			<div class="node">node #3</div>
            <div class="node">node #4</div>
		</div>
	</div>
</div>
html, body {
  width: 100%;
  height: 100%;
}

.app {
  overflow: hidden;
  width: 100%;
  height: 100%;
  position: relative;
}

.container, .nodes-container  {
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0px;
  left: 0px;
}

.container {
  overflow: hidden;
}

.node {
  position: absolute;
}

Для отображения смещения и приближения достаточно будет всего одного css-свойства transform с параметрами в виде двух функций: translate(), которая выполняет смещение по x и y на заданные величины и scale(), которaя меняет размер элемента на заданный множитель. Пример, который будет смещать элемент на 20px по оси x, на 40px по оси y и увеличивать его в 2 раза:

transform: translate(20px, 40px) scale(2);

Это свойство будет применяться к .nodes-container. Как уже упоминалось, все контейнеры по размеру равны разрешению экрана пользователя. .container имеет overflow: hidden, поэтому нативный скролл не появится, какими бы по размеру не были внутренние элементы. При этом .node относительно .nodes-container может иметь любое положение, в том числе и за его пределами, а translate() не имеет ограничений. Таким образом достигается эффект бесконечности, когда .node можно задать любые координаты и смещением .nodes-container вывести его на экран:

<div class="nodes-container" style="transform: translate(0px, 0px) scale(1);">
	<div class="node" style="top: -20px; left: -60px;">node #1</div>
	<div class="node" style="top: 230px; left: 150px;">node #2</div>
	<div class="node" style="top: 330px; left: 350px;">node #3</div>
	<div class="node" style="top: 1200px; left: 600px;">node #4</div>
</div>
 translate(0px, 0px)
translate(0px, 0px)
 translate(300px, 170px)
translate(300px, 170px)
 translate(300px, 170px)
translate(300px, 170px)

Перемещение

Теперь надо дать пользователю возможность управлять смещением. Это будет реализовано через перетаскивание.

В качестве примера используется React-компонент, но все указанные техники могут быть применены как с другими библиотеками, так и на нативном JS.

В компоненте будет использовано два стейта: viewport для хранения информации о текущем положении и isDragging, чтобы отслеживать, когда надо захватывать перемещения курсора. viewport содержит в себе offset - объект смещения по осям x и y, а zoom соответственно является множителем для увеличения. Будем считать, что по умолчанию смещение равно нулю, а увеличение единице.

Нам потребуется отслеживать три события:

  1. mouseDown - начинаем отслеживать перемещения курсора.

  2. mouseUp - перестаем отслеживать перемещения курсора.

  3. mouseMove - собственно отслеживаем перемещение.

Перехватчики этих событий будут висеть на .app, чтобы гарантированно работать в любом месте экрана. С первыми двумя все понятно, они просто изменяют isDragging при нажатии и отжатии. На handleMouseMove остановимся подробнее. Во-первых это событие должно срабатывать, когда isDragging === true, а во-вторых, если e.buttons !== 1, то есть никакая кнопка не нажата, isDragging меняется на false и отслеживание прекращается. Это предупреждает ситуацию, когда по какой-то причине отжатие кнопки не было отслежено handleMouseUp (например, отжали ее на адресной строке, вне приложения), поле не продолжало хаотично двигаться, а принудительно происходила остановка отслеживания курсора. В конце концов, если все проверки пройдены, обновляется viewport.

MouseEvent предоставляет свойства movementX и movementY, которые являются дельтой движения курсора. Достаточно добавлять эту дельту к предыдущему offset. Таким образом, при каждом срабатывании mouseMove будет обновляться viewport, обновление которого, в свою очередь, будет изменять transform у .nodes-container.

export default function App() {
  const [viewport, setViewport] = useState({
    offset: {
      x: 0.0,
      y: 0.0
    },
    zoom: 1
  });

  const [isDragging, setIsDragging] = useState(false);

  const handleMouseDown = () => {
    setIsDragging(true);
  };

  const handleMouseUp = () => {
    setIsDragging(false);
  };

  const handleMouseMove = (e: React.MouseEvent) => {
    if (!isDragging) {
      return;
    }

    if (e.buttons !== 1) {
      setIsDragging(false);

      return;
    }

    setViewport((prev) => ({
      ...prev,
      offset: {
        x: prev.offset.x + e.movementX,
        y: prev.offset.y + e.movementY
      }
    }));
  };

  return (
    <div
      className="app"
      onMouseDown={handleMouseDown}
      onMouseUp={handleMouseUp}
      onMouseMove={handleMouseMove}
    >
      <div className="container">
        <div
          className="nodes-container"
          style={{
            transform: `translate(${viewport.offset.x}px, ${viewport.offset.y}px) scale(${viewport.zoom})`
          }}
        >
          {/* ... */}
        </div>
      </div>
    </div>
  );
}

Приближение

С точки зрения UX, оптимальными действиями, которые пользователь должен совершить для зума - зажать ctrl и покрутить колесико. Во-первых это устоявшийся и понятный процесс, во-вторых он поддерживается и имитируется многими тачпадами на ноутбуках при отслеживании жеста “щипка”.

Повесим на .app очередной листенер событий - onwheel. Правда, на этот раз не через пропы компонента, а через реф. Это имеет достаточно интересное объяснение: если листенер вешать на элемент через React, он приобретает свойство passive: true, который не дает срабатывать preventDefault(), что критически важно для перехвата приближения инструментами браузера. Также необходима проверка, что зажата клавиша ctrl.

Далее нужно вычислить множитель speedFactor для дельты скролла. Дело в том, что ее [дельты] единица измерения может быть в виде пикселей, строк или страниц и ее надо привести примерно к 0.2px за единицу для максимальной плавности. WheelEvent.deltaMode содержит эту информацию как unsigned long, ниже я приведу таблицу, согласно которой будет вычислен speedFactor:

Constant

Value

speedFactor

DOM_DELTA_PIXEL

0x00

0.2

DOM_DELTA_LINE

0x01

5

DOM_DELTA_PAGE

0x02

10

Конечным значением для получения нового состояния zoom будет являться pinchDelta. Это произведение отрицательной дельты скролла и speedFactor. Знак дельты меняется, чтобы обрабатывать правильное направления движения колесика.

Величину приближения так же стоит ограничить, чтобы пользователи не увлекались разглядыванием pixel-perfect верстки. Возьмем, например, 0.1 в качестве нижней границы и 1.3 в качестве верхней. Для сохранение плавности zoom будет увеличиваться экспоненциально, то есть каждый раз будет умножаться на 2 в степени pinchDelta:

export default function App() {
	
	const layerRef = useRef<HTMLDivElement>(null);

  const [viewport, setViewport] = useState({
    offset: {
      x: 0.0,
      y: 0.0
    },
    zoom: 1
  });

	// ...

useEffect(() => {
    if (!layerRef.current) {
      return;
    }

    layerRef.current.onwheel = (e: WheelEvent) => {
      e.preventDefault();
      e.stopPropagation();

      if (e.ctrlKey) {
        const speedFactor =
          (e.deltaMode === 1 ? 0.05 : e.deltaMode ? 1 : 0.002) * 10;

        setViewport((prev) => {
          const pinchDelta = -e.deltaY * speedFactor;

          return {
            ...prev,
            zoom: Math.min(
              1.3,
              Math.max(0.1, prev.zoom * Math.pow(2, pinchDelta))
            )
          };
        });
      }
    };
  }, [setViewport]);

  return (
    <div
      className="app"
      ref={layerRef}
      // ...
    >
      <div className="container">
        <div
          className="nodes-container"
          style={{
            transform: `
              translate(
                ${viewport.offset.x * viewport.zoom}px, 
                ${viewport.offset.y * viewport.zoom}px
              ) 
              scale(${viewport.zoom})
            `
          }}
        >
          {/* ... */}
        </div>
      </div>
    </div>
  );
}

Заключение

К этому пространству будут хорошими дополнениями поддержка скролла для перемещения, как вертикального, так и горизонтального (что опять же очень удобно для пользователей с ноутбуками), поддержка касаний и ограничение перемещения, основанное на крайних нодах. Все это будет несложно добавить, имея готовую базу. На этом все, спасибо за внимание!

Source примера.

Комментарии (1)


  1. Fines_Unes
    00.00.0000 00:00
    +2

    «Величину приближения так же стоит ограничить, чтобы пользователи не увлекались разглядыванием pixel-perfect верстки» — хааххаха, хороший аргумент ????