Делаем бесконечную рабочую область без Canvas

от автора

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

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

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

Рабочее пространство в нашем приложении представляет собой бесконечную доску, по которой могут перемещаться ноды. Необходимо реализовать масштабирование этого пространства и перемещение по нему. Все это мы делаем без использования 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 примера.


ссылка на оригинал статьи https://habr.com/ru/post/722964/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *