Возможность масштабировать элементы страницы и детально рассматривать их – это очень крутой пользовательский опыт. Существует множество готовых библиотек с подобной функциональностью, но сегодня мы напишем собственный велосипед на чистом JavaScript! Зачем?
Сторонние решения часто предлагают избыточную функциональность, которая вам не нужна, но бандл приложения увеличивает.
К тому же это замечательный челлендж, который расшевелит ваш мозг и прокачает навыки программирования.
В результате мы получим очень маленькую (всего 69 строчек кода!), простую и удобную библиотечку для масштабирования и панорамирования.
Разметка и стили
Создадим HTML-страницу и разместим на ней элемент-контейнер (#container
). Внутрь поместим рабочую область (.area
), которую мы и будем непосредственно масштабировать и панорамировать.
index.html
<div id="container"> <div class="area"> <div class="rectangle"></div> <div class="circle"></div> <div class="text-area"> <h1>Example line of text</p> </div> </div> </div>
Внутри рабочей области находятся несколько элементов, которые не несут никакой смысловой нагрузки, а просто предназначены для демонстрации работы кода.
Добавим также немного стилей для оформления страницы:
style.css
body { overflow: hidden; } #container { height: 100%; width: 100%; position: absolute; } .area { border: 1px dashed black; height: 80%; width: 80%; position: absolute; } .circle { height: 200px; width: 200px; background-color: navajowhite; border-radius: 50%; display: inline-block; position: relative; } .rectangle { background-color: navajowhite; height: 150px; width: 250px; position: relative; } .text-area { float: right; position: relative; }
Для body
устанавливаем overflow: hidden
. Это нужно, чтобы избежать переполнения страницы и появления прокрутки при чрезмерном увеличении элемента.
Также добавим рамку для визуального обозначения рабочей области (.area
) и немного облагородим демо-контент (классы .circle
, .rectangle
и .text-area
).
Скрипт библиотеки
Код самой библиотеки будет располагаться в файле renderer.js
. Экспортируем из модуля главную функцию renderer
:
renderer.js
const renderer = ({ minScale, maxScale, element, scaleSensitivity = 10 }) => { const state = { element, minScale, maxScale, scaleSensitivity, transformation: { originX: 0, originY: 0, translateX: 0, translateY: 0, scale: 1 }, }; return Object.assign({}, makeZoom(state), makePan(state)); }; module.exports = { renderer };
Она принимает базовые параметры:
minScale
– минимальный масштаб;
maxScale
– максимальный масштаб;
element
– DOM-элемент, с которым будут производиться манипуляции;
scaleSensitivity
– коэффициент чувствительность масштабирования, по умолчанию 10.
В замыкании функции создается объект состояния – state
, который хранит настройки и совершенные над элементом преобразования (поле transformation
).
Из функции возвращается объект с набором методов. При этом возможности масштабирования и панорамирования разделены на отдельные функции-конструкторы – makeZoom
и makePan
, которые мы разберем чуть позже. Конструкторы получают общий объект состояния и возвращают отдельный набор методов для взаимодействия с ним.
Такой подход называется композицией и позволяет проще добавлять новую функциональность и легче тестировать приложение.
Трансформации
Все манипуляции с элементом будут производиться через изменение свойства transform
. Для этого используем CSS-функцию matrix
, которой нужно передать правильные параметры масштаба (scale
) и сдвига (translateX
и translateY
):
renderer.js
const getMatrix = ({ scale, translateX, translateY }) => `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})`;
Вспомогательная функция getMatrix
просто формирует шаблонную строку правильного формата, которую нужно установить в свойство style.transform
элемента.
Панорамирование
При панорамировании должно изменяться положение элемента на странице, то есть производиться его сдвиг. Функция pan
принимает текущее состояние элемента (state
), а также новые координаты. Затем она обновляет состояние, прибавляя новый сдвиг к текущему положению и обновляет свойство style
элемента.
renderer.js
const pan = ({ state, originX, originY }) => { state.transformation.translateX += originX; state.transformation.translateY += originY; state.element.style.transform = getMatrix({ scale: state.transformation.scale, translateX: state.transformation.translateX, translateY: state.transformation.translateY }); };
Теперь реализуем два метода:
panBy
– простой сдвиг на указанные координаты;
panTo
– сдвиг с одновременным масштабированием.
renderer.js
const makePan = (state) => ({ panBy: ({ originX, originY }) => pan({ state, originX, originY }), panTo: ({ originX, originY, scale }) => { state.transformation.scale = scale; pan({ state, originX: originX - state.transformation.translateX, originY: originY - state.transformation.translateY }); }, });
При сдвиге с масштабированием координаты элемента нужно скорректировать.
Масштабирование
Для изменения размера элемента нам потребуется несколько вспомогательных функций для расчетов:
renderer.js
const hasPositionChanged = ({ pos, prevPos }) => pos !== prevPos; const valueInRange = ({ minScale, maxScale, scale }) => scale <= maxScale && scale >= minScale; const getTranslate = ({ minScale, maxScale, scale }) => ({ pos, prevPos, translate }) => valueInRange({ minScale, maxScale, scale }) && hasPositionChanged({ pos, prevPos }) ? translate + (pos - prevPos * scale) * (1 - 1 / scale) : translate; const getScale = ({ scale, minScale, maxScale, scaleSensitivity, deltaScale }) => { let newScale = scale + (deltaScale / (scaleSensitivity / scale)); newScale = Math.max(minScale, Math.min(newScale, maxScale)); return [scale, newScale]; };
Метод getScale
рассчитывает новый масштаб на основе предыдущего значения, минимального и максимального ограничений (minScale
, maxScale
) и коэффициентов (scaleSensitivity
, deltaScale
).
Метод getTranslate
рассчитывает новый сдвиг на основе масштаба и текущей и предыдущей позиции.
А вот и реализация функции makeZoom
:
renderer.js
const makeZoom = (state) => ({ zoom: ({ x, y, deltaScale }) => { const { left, top } = state.element.getBoundingClientRect(); const { minScale, maxScale, scaleSensitivity } = state; const [ scale, newScale ] = getScale({ scale: state.transformation.scale, deltaScale, minScale, maxScale, scaleSensitivity }); const originX = x - left; const originY = y - top; const newOriginX = originX / scale; const newOriginY = originY / scale; const translate = getTranslate({ scale, minScale, maxScale }); const translateX = translate({ pos: originX, prevPos: state.transformation.originX, translate: state.transformation.translateX }); const translateY = translate({ pos: originY, prevPos: state.transformation.originY, translate: state.transformation.translateY }); state.element.style.transformOrigin = `${newOriginX}px ${newOriginY}px`; state.element.style.transform = getMatrix({ scale: newScale, translateX, translateY }); state.transformation = { originX: newOriginX, originY: newOriginY, translateX, translateY, scale: newScale }; } });
Она возвращает только один метод zoom
, предназначенный для масштабирования элемента. Он получает координаты курсора, а также параметр deltaScale
– коэффициент, который определяет направление масштабирования (1
для увеличения, -1
для уменьшения).
Функция вычисляет новые параметры трансформации и обновляет свойство style
элемента.
При масштабировании кроме style.transform
нужно изменять также свойство style.transformOrigin
, чтобы скорректировать позицию элемента. В качестве эксперимента вы можете закомментировать 14 строчку и посмотреть, что будет.
Главный файл
Кроме того мы сделаем главный файл приложения index.js
:
index.js
(() => { const { renderer } = require("./src/renderer"); const container = document.getElementById("container"); const instance = renderer({ minScale: .1, maxScale: 30, element: container.children[0], scaleSensitivity: 50 }); container.addEventListener("wheel", (event) => { if (!event.ctrlKey) { return; } event.preventDefault(); instance.zoom({ deltaScale: Math.sign(event.deltaY) > 0 ? 1 : -1, x: event.pageX, y: event.pageY }); }); container.addEventListener("dblclick", () => { instance.panTo({ originX: 0, originY: 0, scale: 1, }); }); container.addEventListener("mousemove", (event) => { if (!event.shiftKey) { return; } event.preventDefault(); instance.panBy({ originX: event.movementX, originY: event.movementY }); }) })();
Клиентский код создает экземпляр renderer
и передает ему базовую конфигурацию:
элемент, размер которого будет изменяться;
минимальный и максимальный масштаб;
коэффициент чувствительности масштабирования.
Затем устанавливаются слушатели событий мыши и в нужный момент вызываются нужные методы:
для масштабирования используйте колесико мыши или сенсорную панель, зажав клавишу CTRL .
для перемещения – перемещайте мышь или используйте тачпад, зажав клавишу SHIFT .
Двойной щелчок мыши восстановит исходное состояние элемента.
Демо-пример:
Тестирование
Проверим реализованные функции с помощью библиотеки Mocha:
renderer.testCases.js
const panByTestCases = [ { description: 'should pan by passed originX and originY (x: 100, y: 100)', minScale: .1, maxScale: 20, origins: [ { originX: 100, originY: 100, } ], result: 'matrix(1, 0, 0, 1, 100, 100)' }, { description: 'should pan by passed originX and originY (x: 50, y: 50)', minScale: .1, maxScale: 20, origins: [ { originX: 100, originY: 100, }, { originX: -50, originY: -50, } ], result: 'matrix(1, 0, 0, 1, 50, 50)' }, { description: 'should pan by passed originX and originY (x: -50, y: 50)', minScale: .1, maxScale: 20, origins: [ { originX: -100, originY: 100, }, { originX: 50, originY: -50, } ], result: 'matrix(1, 0, 0, 1, -50, 50)' } ]; module.exports = { panByTestCases, };
renderer.spec.js
const assert = require('assert'); const { renderer } = require('../src/renderer'); const { panByTestCases } = require('./renderer.testCases'); describe('renderer', () => { let _element; beforeEach(() => { _element = { getBoundingClientRect: () => ({ left: 0, top: 0 }), style: { transform: "", transformOrigin: "", } } }); describe('#canPan()', () => { describe('#panBy()', () => { panByTestCases.forEach(({ description, minScale, maxScale, origins, result }) => it(description, () => { const instance = renderer({ minScale, maxScale, element: _element }) origins.forEach(({ originX, originY }) => instance.panBy({ originX, originY })) assert.equal(_element.style.transform, result); }), ); }); });
Перед каждым тестом (beforeEach
) создается объект _element
с дефолтными значениями.
Кейсы тестирования для удобства вынесены в отдельный файл renderer.testCases.js
.
***
В итоге у нас получился очень простой и удобный инструмент для масштабирования и панорамирования на JavaScript, состоящий всего из 69 строк кода. Его можно сократить больше, но не хочется терять читабельность.
Полную и минифицированную версии ищите в репозитории проекта.