В предыдущей статье я рассказывал, как мы рисуем соединения между нодами на наших пространствах. Сейчас же я расскажу, как у нас реализованы сами пространства!
Рабочее пространство в нашем приложении представляет собой бесконечную доску, по которой могут перемещаться ноды. Необходимо реализовать масштабирование этого пространства и перемещение по нему. Все это мы делаем без использования 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>
Перемещение
Теперь надо дать пользователю возможность управлять смещением. Это будет реализовано через перетаскивание.
В качестве примера используется React-компонент, но все указанные техники могут быть применены как с другими библиотеками, так и на нативном JS.
В компоненте будет использовано два стейта: viewport
для хранения информации о текущем положении и isDragging
, чтобы отслеживать, когда надо захватывать перемещения курсора. viewport
содержит в себе offset
- объект смещения по осям x
и y
, а zoom
соответственно является множителем для увеличения. Будем считать, что по умолчанию смещение равно нулю, а увеличение единице.
Нам потребуется отслеживать три события:
mouseDown
- начинаем отслеживать перемещения курсора.mouseUp
- перестаем отслеживать перемещения курсора.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 примера.
Fines_Unes
«Величину приближения так же стоит ограничить, чтобы пользователи не увлекались разглядыванием pixel-perfect верстки» — хааххаха, хороший аргумент ????