JavaScript. Практическое пособие
Интерфейсы: редактирование, общее
Защита от ошибок
“Cannot read Properties of Undefined” — в случае, когда объект или его поле не создано
Современный способ — с помощью оператора опциональной последовательности.
В примере если элемент .potter
создан, вызываем его метод. Если такого элемента нет, ничего не произойдёт.
А если harry
не является свойством объекта wizards
или не имеет поля house
, ничего не произойдет. Если же нужные свойства будут найдены, то house
будет переведён в нижний регистр.
document.querySelector('.potter')?.classList.add('meh');
const wizards = {};
wizards.harry?.house.toLowerCase();
Оператор ?.
можно также использовать совместно с оператором нулевого слияния — ??
— для добавления запасного кода, на случай отсутствия нужного объекта или поля.
В пример если harry
не является свойством объекта wizards
или не имеет поля house
, то Гарри определяем в Пуффендуй (англ. Hufflepuff).
const house =
wizards.harry?.house.toLowerCase() ??
'hufflepuff';
Проверка по-старинке.
const harry = document.querySelector('.potter');
const wizards = {};
/* Если элемент `.potter` создан, вызываем его
метод */
if (harry) {
harry.classList.add('meh');
}
/* Если свойство wizards.harry.house существует,
переводим его в нижний регистр */
if (wizards.harry && wizards.harry.house) {
wizards.harry.house.toLowerCase();
}
Источник. См. также: список ошибок
Полный экран
Для выхода в полноэкранный режим используется Fullscreen API, интерфейсы веб API и метод requestFullscreen()
, который он добавляет объектам Document
и Element
.
<article>
<h1>Hello World</h1>
</article>
<button>Say it louder</button>
const greetings = document.querySelector('article');
const button = document.querySelector('button');
const takeFullscreen = () => {
greetings.requestFullscreen();
};
button.addEventListener('click', takeFullscreen);
Вывод элемента в область видимости прокручиваемого контейнера
Функция scrollToBeVisible
перемещает элемент, переданный в первый параметр, в область видимости контейнера, переданного во второй параметр. См. также раздел «Виден ли элемент в области просмотра блока с прокруткой»
const scrollToBeVisible = (el, container) => {
const eleTop = el.offsetTop;
const eleBottom = eleTop + el.clientHeight;
const containerTop = container.scrollTop;
const containerBottom =
containerTop + container.clientHeight;
if (eleTop < containerTop) {
// Прокрутка вверх контейнера
container.scrollTop -= containerTop - eleTop;
} else if (eleBottom > containerBottom) {
// Прокрутка вниз контейнера
container.scrollTop += eleBottom - containerBottom;
}
};
Пользовательская полоса прокрутки
Можно обновить внешний вид полосы прокрутки исключительно стилями.
// Firefox
body {
scrollbar-width: thin;
// Цвета бегунка и «рельса»
scrollbar-color: #718096 #edf2f7;
}
// Chrome, Edge и Safari
body::-webkit-scrollbar {
width: .75rem;
}
*::-webkit-scrollbar-track {
background-color: #edf2f7;
}
*::-webkit-scrollbar-thumb {
background-color: #718096;
border-radius: 9999px;
}
Альтернативный метод
Но поскольку -webkit-scrollbar
не является стандартным псевдоэлементом и однажды может перестать поддерживаться, можно скрыть родной скроллбар браузера и вместо него использовать пользовательский. Например, для такого контейнера с прокруткой.
<div class="wrapper" id="wrapper">
<div class="content" id="content">
...
</div>
</div>
.wrapper {
overflow: hidden;
max-height: 32rem;
}
.content {
// Скрыть скроллбар по умолчанию
margin-right: -1.6rem;
padding-right: 1.6rem;
overflow: auto;
height: 100%;
}
Пользовательская полоса прокрутки — исходная разметка и логика
Добавим под «обёрткой» добавим «якорь» — ориентир для позиционирования скроллбара — и собственно скроллбар.
<div id="wrapper">...</div>
<!-- Якорь -->
<div
id="anchor"
style="position: absolute; top: 0; left: 0;"
>
</div>
<!-- Скроллбар -->
<div
id="scrollbar"
style="position: absolute; width: .75rem;"
>
</div>
Установим скроллбар в правый верхний угол.
const wrapper = document.getElementById('wrapper');
const content = document.getElementById('content');
const anchor = document.getElementById('anchor');
const scrollbar = document.getElementById('scrollbar');
// Получаем размеры и положения контейнера и якоря
const wrapperRect = wrapper.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
// Устанавливаем скроллбар
const top = wrapperRect.top - anchorRect.top;
const left = wrapperRect.width + wrapperRect.left - anchorRect.left;
scrollbar.style.top = `${top}px`;
scrollbar.style.left = `${left}px`;
// Высота скроллбара равна высоте контейнера
scrollbar.style.height = `${wrapperRect.height}px`;
Конструируем полосу прокрутки
Скроллбар состоит из «рельса» track
и бегунка thumb
— абсолютно позиционированных относительно родительского элемента.
<div id="scrollbar">
<div class="track" id="track"></div>
<div class="thumb" id="thumb"></div>
</div>
.track {
position: absolute;
top: 0;
left: 0;
// Растягиваем на всю площадь
width: 100%;
height: 100%;
}
.thumb {
left: 0;
position: absolute;
// Растягиваем только по ширине
width: 100%;
}
Рассчитываем высоту бегунка: пропорционально отношению высоты содержания и высоты контейнера.
const track = document.getElementById('track');
const thumb = document.getElementById('thumb');
const scrollRatio =
content.clientHeight / content.scrollHeight;
thumb.style.height = `${scrollRatio * 100}%`;
Перетаскивание для прокрутки
Подробнее о технике см. здесь.
let pos = { top: 0, y: 0 };
const mouseDownThumbHandler = (e) => {
pos = {
// Текущая позиция бегунка прокрутки
top: content.scrollTop,
// Текущая позиция курсора
y: e.clientY,
};
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener(
'mouseup', mouseUpHandler
);
};
const mouseMoveHandler = (e) => {
// Как далеко курсор переместился
const dy = e.clientY - pos.y;
// Прокручиваем содержание
content.scrollTop = pos.top + dy / scrollRatio;
};
// Назначаем обработчик `mousedown`
thumb.addEventListener(
'mousedown', mouseDownThumbHandler
);
Когда пользователь перетягивает бегунок, он также прокручивает содержание элемента content
. Для того, чтобы обновлять положение бегунка назначим обработчик события scroll
на элемент content
.
const scrollContentHandler = () => {
window.requestAnimationFrame(() => {
thumb.style.top = `
${(content.scrollTop * 100) /
content.scrollHeight}%
`;
});
};
content.addEventListener('scroll', scrollContentHandler);
Переходы по клику на «рельсе»
Для этого способа прокрутки мы должны снова рассчитать свойство scrollTop
элемента content
.
const trackClickHandler = (e) => {
const bound = track.getBoundingClientRect();
const percentage =
(e.clientY - bound.top) / bound.height;
content.scrollTop =
percentage *
(content.scrollHeight - content.clientHeight);
};
track.addEventListener('click', trackClickHandler);
Слайдер диапазона — `range`
Два способа создать элемент управления.
1) input[type='range']
и CSS
«Дешево и сердито».
<input type="range">
[type='range'] {
--thumb-color: var(--color-primary-base);
--border-color: var(--color-ink-border);
$size-height-range-track: $size-half;
$size-height-range-thumb: $size-2p5;
$size-border-width-range-thumb: $size-half;
appearance: none;
background: none;
height: $size-height-range-thumb;
width: 100%;
// ⚠️ Список селекторов в случае
// с нестандартными псведоэлементами
// не срабатывает. Только миксин и инклуд
// для каждого селектора по отдельности
@mixin range-track {
background-color: var(--border-color);
border-radius: $shape-border-radius-pill;
transition:
background
$motion-duration-sm
$motion-easing-base,
box-shadow
$motion-duration-sm
$motion-easing-base;
width: 100%;
height: $size-height-range-track;
}
// Track
&::-webkit-slider-runnable-track {
@include range-track;
}
&::-moz-range-track {
@include range-track;
}
&::-ms-track {
@include range-track;
}
@mixin range-thumb {
appearance: none;
background-color: var(--thumb-color);
border-radius: 50%;
border:
$size-border-width-range-thumb
solid
var(--range-thumb-border-color);
cursor: pointer;
margin-top: -(
$size-height-range-thumb * .5 -
$size-height-range-track * .5
);
transition:
background
$motion-duration-sm
$motion-easing-base,
transform
$motion-duration-sm
$motion-easing-base;
width: $size-height-range-thumb;
height: $size-height-range-thumb;
}
// Thumb
&::-webkit-slider-thumb {
@include range-thumb;
}
&::-moz-range-thumb {
@include range-thumb;
}
&::-ms-thumb {
@include range-thumb;
}
&:hover {
--thumb-color: hsl(var(--h) var(--s) 65%);
}
&:hover,
&:focus,
&:active {
// Thumb
&::-webkit-slider-thumb {
transform: scale(1.25);
}
&::-moz-range-thumb {
transform: scale(1.25);
}
&::-ms-thumb {
transform: scale(1.25);
}
}
}
input[type='range']
поддерживается абсолютным абсолютным большинством браузеров. Но, если потребуется, можно добавить проверку.
const isRangeInputSupported = () => {
const el = document.createElement('input');
el.setAttribute('type', 'range');
/* Если браузер не поддерживает поле `range`,
атрибуту `type` будет присвоено значение `text` */
return el.type !== 'text';
};
Минус метода — нельзя сделать вертикальный виджет.
2) JS
Пользовательский слайдер диапазона собирается из трех деталей: бегунка и двух половинок дорожки.
<div class="container">
<div class="left"></div>
<div class="thumb" id="thumb"></div>
<div class="right"></div>
</div>
.container {
display: flex;
align-items: center;
height: $size-2p5;
}
.right {
// Правая половинка занимает
// всё свободное место
flex: 1;
height: $size-half;
}
Назначим на бегунок обработчик события mousedown
, чтобы сделать его перетаскиваемым. В обработчик будет сохранятся позиция элемента.
const thumb = document.getElementById('thumb');
const leftSide = thumb.previousElementSibling;
// The current position of mouse
let x = 0;
let y = 0;
let leftWidth = 0;
// Обработчик будет срабатывать при перетаскивании
const mouseDownHandler = (e) => {
// Текущая позиция мыши
x = e.clientX;
y = e.clientY;
leftWidth = leftSide.getBoundingClientRect().width;
// Назначаем вложенные обработчики
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener('mouseup', mouseUpHandler);
};
Когда бегунок перемещаемся, мы можем рассчитать расстояние, которое проделал курсор — это будет разница между исходной и текущей позицией. С этими данными мы можем рассчитать и ширину левой части дорожки.
const mouseMoveHandler = (e) => {
// Как далеко мышь продвинулась
const dx = e.clientX - x;
const dy = e.clientY - y;
const containerWidth =
thumb.parentNode.getBoundingClientRect().width;
let newLeftWidth =
((leftWidth + dx) * 100) / containerWidth;
newLeftWidth = Math.max(newLeftWidth, 0);
newLeftWidth = Math.min(newLeftWidth, 100);
leftSide.style.width = `${newLeftWidth}%`;
};
/* Обработчик срабатывает в тот момент,
когда пользователь отпускает бегунок */
const mouseUpHandler = () => {
leftSide.style.removeProperty('user-select');
leftSide.style.removeProperty('pointer-events');
rightSide.style.removeProperty('user-select');
rightSide.style.removeProperty('pointer-events');
// Удаляем слушатели, пока они не нужны
document.removeEventListener(
'mousemove',
mouseMoveHandler
);
document.removeEventListener(
'mouseup',
mouseUpHandler
);
};
// Назначаем обработчик
thumb.addEventListener(
'mousedown', mouseDownHandler
);
Плавная прокрутка — пользовательская настройка анимации
Плавную прокрутку легко можно сделать в JS…
el.scrollIntoView({ behavior: 'smooth' });
… Или CSS.
scroll-behavior: smooth;
Но оба метода не дают разработчику изменить анимацию прокрутки.
В этом разделе разбираем сложный, но дающий большую свободу метод.
Разметка:
<a class="trigger" href="#section-1"></a>
<a class="trigger" href="#section-2"></a>
...
<div id="section-1">...</div>
<div id="section-2">...</div>
После клика по ссылка страница должны прокрутиться к соответствующему разделу.
const triggers = document.querySelectorAll(
'.trigger'
);
triggers.forEach((el) => {
el.addEventListener('click', clickHandler);
});
clickHandler
обрабатывает событие click
по ссылке. Определяет целевой раздел страницы по атрибуту href
и прокручивает страницу к нужному разделу:
const clickHandler = (e) => {
e.preventDefault();
// Получаем id целевой секции из атрибута `href`
const href = e.target.getAttribute('href');
const id = href.substr(1);
const target = document.getElementById(id);
scrollToTarget(target);
};
Функция scrollToTarget
Чтобы прокрутить страницу к нужному место можно использовать метод window.scrollTo(0, y)
который прокручивает к месту по указанному расстоянию от верхнего края.
- Начальная точка прокрутки — текущее положение страницы в области просмотра —
window.pageYOffset
. - Конечная точка — расстояние до целевого раздела. Значение можно получить с помощью
target.getBoundingClientRect().top
. - Продолжительность прокрутки указывается в миллисекундах. В примере — 800 мс.
const duration = 800;
const scrollToTarget = (target) => {
const { top } = target.getBoundingClientRect();
const startPos = window.pageYOffset;
const diff = top;
let startTime = null;
let requestId;
const loop = (currentTime) => {
if (!startTime) {
startTime = currentTime;
}
// Время в миллисекундах
const time = currentTime - startTime;
const percent = Math.min(time / duration, 1);
window.scrollTo(0, startPos + diff * percent);
if (time < duration) {
// Продолжаем прокрутку
requestId = window.requestAnimationFrame(loop);
} else {
// Останавливаем
window.cancelAnimationFrame(requestId);
}
};
requestId = window.requestAnimationFrame(loop);
};
Здесь мы указываем браузеру выполнить функцию loop
до того, как появится целевой радел. При первом выполнении функции инициализируется переменная starttime
— как текущая временная метка currenttime
.
Затем мы вычисляем время, затраченное к текущему моменту на прокрутку:
const time = currentTime - startTime;
Зная затраченное время и заданную продолжительность, нетрудно рассчитать процент выполнения полного цикла прокрутки. И затем передать данные в метод scrollTo
для расчета конечной координаты.
// `percent` — это диапазон от 0 до 1
const percent = Math.min(time / duration, 1);
window.scrollTo(0, startPos + diff * percent);
Если затраченное время меньше заданной продолжительности, продолжаем прокрутку. В противном случае останавливаемся.
if (time < duration) {
requestId = window.requestAnimationFrame(loop);
} else {
window.cancelAnimationFrame(requestId);
}
☝️🧐 Часто для расчетов анимации используется setTimeout()
или setInterval()
, но использованный в примереrequestAnimationFrame метод лучше по показателям производительности.
Настройка анимации
До сих пор анимация осуществлялась неестественно прямолинейно. Но мы можем добавить любой эффект замедления.
const easeInQuad = (t) => {
return t * t;
};
const loop = (currentTime) => {
// ...
const percent = Math.min(time / duration, 1);
window.scrollTo(
0, startPos + diff * easeInQuad(percent)
);
};
См. демо на HTML DOM.
Пример stateful-компонента — счётчик
- Начнём с создания состояния — объекта в глобальной области видимости (на практике, область видимости ограничивается). Единственным свойством объекта сделаем счетчик, с нулевым исходным значением.
const state = {
count: 0,
};
- Создадим компонент — простую стрелочную функцию возвращающую шаблонный литерал.
const counter = (count) => {
return `<div class="counter">${count}</div>`;
}
- Создадим функцию рендеринга HTML, которая будет обновлять интерфейс после каждого обновления счетчика пользователем.
function renderCount() {
document.getElementById('app')
/* Передаём в контейнер разметку
из функцию компонента */
.innerHTML = counter(state.count);
}
- Сделаем метод обновления счётчика. Встроенного метода SetState в JavaScript нет, поэтому будем обновлять напрямую (в React это категорически не рекомендуется):
function incCountUp() {
const newCount = state.count + 1;
state.count = newCount;
}
- Установим слушатель на кнопку. Всякий раз, когда пользователь будет на неё кликать, будет обновляться значение
state.count
. Затем мы будем вызывать метод рендеринга для обновления HTML.
const btn = document.querySelector(
'[data-action="count-up"]'
);
btn.click(() => {
incCountUp();
renderCount();
});
Полный код, с небольшими улучшениями — добавлением CSS-классов в зависимости от значения счётчика.
const display = document.getElementById(
'display'
);
const btn = document.querySelector(
'[data-action="count-up"]'
);
const state = {
count: 0,
};
// Components
const counter = (count) => {
let className = 'text-error';
if (count > 10) {
className = 'text-success';
}
return `
<p class="counter">
Count:
<span class="${className}">
${count}
</span>
</p>
`;
};
// Render Methods
function renderCount() {
display.innerHTML = counter(state.count);
}
// Update methods
function incCountUp() {
const newCount = state.count + 1;
state.count = newCount;
renderCount();
}
renderCount();
btn.click(() => {
incCountUp();
renderCount();
});
Анимация
Анимация высоты: от 0 до `max-height`
Невозможно закодировать плавный переход от нулевой высоты к высоте содержания, используя только CSS. С помощью JS надо получить высоту содержания открытого контейнера и изменить значение переменной --max-height
. Это можно сделать на клике или на :hover
, как в примере.
<div class="details">
Hover me to see a height transition.
<div class="details__body">Additional content</div>
</div>
.details__body {
overflow: hidden;
transition: max-height .3s;
max-height: 0;
}
.details:hover > .details__body {
max-height: var(--max-height);
}
const el = document.querySelector('.details__body');
const height = el.scrollHeight;
el.style.setProperty('--max-height', height + 'px');
Расчёты
Бинарный поиск
Бинарный поиск — это алгоритм, который получает отсортированный список элементов и ищет заданный элемент, разделяя массив пополам, сравнивая элемент в середине с определенным условием и выполняя эту операцию до тех пор пока не будет найден искомый вариант.
Если элемент, который мы ищем, присутствует в списке, то функция бинарного поиска возвращает ту позицию, в которой он был найден. В противном случае возвращает null.
Например. Некто загадал число от 1 до 100. Мы должны отгадать. А загадавший при каждой попытке будет давать один из трех ответов: «мало», «много» или «угадал».
Если мы начнем перебирать все варианты подряд — 1, 2, 3, 4… — то в случае, если было загадано 99, на поиск верного ответа уйдет 99 попыток.
Существует другой, более эффективный способ.
— Начнем с 50.
— Слишком мало…
Но мы только что исключили половину чисел! Теперь мы знаем, что все числа 1-50 меньше загаданного.
— Следующая попытка: 75.
— На этот раз перелет…
Но мы снова исключили половину оставшихся чисел! С бинарным поиском мы каждый раз проверяем число в середине диапазона и исключаем половину оставшихся чисел.
— 63 (по середине между 50 и 75)?
— Много!
— 57?
— Да!
/**
* Бинарный поиск
* @param {array} list Массив с возможными значениями
* @param {number} item Искомое число
*/
function binarySearch(list, item) {
// Минимальный и максимальный индекс в массиве
let low = 0;
let high = list.length - 1;
/* Пока минимальный индекс меньше или равен
максимальному, находим индекс элемента в середине
массива. И сравним число в середине массива -
list[mid] — с искомым */
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const guess = list[mid];
if (guess === item) {
return mid;
}
/* Если число в середине больше искомого,
тогда соответственно обновляется переменная
high — переносим максимальную границу в меньшую
сторону. А если догадка была слишком мала, то
обновляется переменная low. */
if (guess > item) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return null;
}
const myArr = [1, 3, 5, 7, 9];
console.log(binarySearch(myArr, 3)); // 1
console.log(binarySearch(myArr, -1)); // null
Перетаскивание / перетягивание
Перетаскиваемый элемент
Разметка:
<div class="draggable" id="dragMe">Drag me</div>
Стили:
.draggable {
// Indicate the element draggable
cursor: move;
// It will be positioned absolutely
position: absolute;
// Doesn't allow to select the content inside
user-select: none;
}
Чтобы сделать элемент перетаскиваемым, нам нужно отслеживать три события:
mousedown
позволит нам определить текущее положение мыши;mousemove
даст возможность рассчитать как далеко мышка передвинулась и где сейчас находится перетаскиваемый элемент;mouseup
на этом событии удалим обработчики, установленные с предыдущими двумя.
// Текущая позиция мышки
let x = 0;
let y = 0;
const el = document.getElementById('dragMe');
/** Отслеживаем передвижение */
const mouseMoveHandler = (e) => {
// Как далеко курсор передвинулся
const dx = e.clientX - x;
const dy = e.clientY - y;
// Устанавливаем позицию элемента
el.style.top = `${el.offsetTop + dy}px`;
el.style.left = `${el.offsetLeft + dx}px`;
// Переопределяем позицию курсора
x = e.clientX;
y = e.clientY;
};
/** Удаляем обработчики на прекращении перетаскивания */
const mouseUpHandler = () => {
// Remove the handlers of `mousemove` and `mouseup`
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
};
/** Добавляем обработчики перед перетаскиванием */
const mouseDownHandler = (e) => {
// Получаем координаты курсора
x = e.clientX;
y = e.clientY;
// Устанавливаем слушатели на документ
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
};
el.addEventListener('mousedown', mouseDownHandler);
Масштабируемый элемент
Разметка:
<div class="resizable" id="resize-me">Resize me</div>
Добавим дополнительные div
‘ы вдоль краев объекта. Они нужны, как ручки для перетягивания — границы элемента не являются DOM-узлами. Чтобы упростить пример, добавим их только снизу и справа.
<div id="resize-me" class="resizable">
Resize me
<div class="resizer is-right"></div>
<div class="resizer is-bottom"></div>
</div>
Here is the basic styles for the layout:
.resizable {
position: relative;
}
.resizer {
cursor: row-resize;
position: absolute;
width: 100%;
height: 5px;
}
.is-right {
top: 0;
right: 0;
}
.is-bottom {
bottom: 0;
left: 0;
}
Как во всех случаях с перемещением объектов интерфейса курсором мыши, создадим обработчики для трёх событий.
mousedown
— добавляем обработчик на ручки перетягивания для отслеживания позиции курсора и получения исходных габаритов объекта.mousemove
— добавляем обработчик на документ, чтобы рассчитать, как далеко курсор передвинулся и новые размеры элемента.mouseup
— добавляем обработчик на документ, чтобы удалить обработчикmousemove
и самого себя.
const el = document.getElementById('resize-me');
// Текущая позиция курсора
let x = 0;
let y = 0;
// Размеры элемента
let w = 0;
let h = 0;
const mouseMoveHandler = (e) => {
// Как далеко перемещен курсор
const dx = e.clientX - x;
const dy = e.clientY - y;
// Обновляем размеры элемента
el.style.width = `${w + dx}px`;
el.style.height = `${h + dy}px`;
};
/* Удаляем обработчики */
const mouseUpHandler = () => {
document.removeEventListener(
'mousemove', mouseMoveHandler
);
document.removeEventListener(
'mouseup', mouseUpHandler
);
};
/** Обработчик срабатывает в начале перетаскивания */
const mouseDownHandler = (e) => {
// Текущая позиция курсора
x = e.clientX;
y = e.clientY;
// Измеряем элемент
const styles = window.getComputedStyle(el);
w = parseInt(styles.width, 10);
h = parseInt(styles.height, 10);
/* Устанавливаем слушатели с обработчиками
на документ */
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener(
'mouseup', mouseUpHandler
);
};
Все обработчики готовы. Теперь добавляем обработчик mousedown
на ручки.
const resizers = el.querySelectorAll('.resizer');
resizers.forEach((resizer) => {
resizer.addEventListener(
'mousedown', mouseDownHandler
);
});
См. демо на HTML DOM.
Сортировка элементов списка перетаскиванием
Разметка и стили:
<style>
.draggable {
cursor: move;
user-select: none;
}
</style>
<div id="list">
<div class="draggable">A</div>
<div class="draggable">B</div>
<div class="draggable">C</div>
<div class="draggable">D</div>
<div class="draggable">E</div>
</div>
Делаем элементы перетягиваемыми
Используем технику, описанную в разделе «Перетягиваемый элемент».
// Элемент, перетаскиваемый в данный момент
let draggingEl;
// Текущая позиция курсора относительно перетаскиваемого элемента
let x = 0;
let y = 0;
/** Перетаскивание в процессе */
const mouseMoveHandler = (e) => {
// Устанавливаем позицию перетаскиваемого элемента
draggingEl.style.position = 'absolute';
draggingEl.style.top = `${e.pageY - y}px`;
draggingEl.style.left = `${e.pageX - x}px`;
};
/** Перетаскивание закончилось.
* Удаляем стили позиционирования перетаскиваемого
* элемента и слушатели
*/
const mouseUpHandler = () => {
// Remove the position styles
draggingEl.style.removeProperty('top');
draggingEl.style.removeProperty('left');
draggingEl.style.removeProperty('position');
x = null;
y = null;
draggingEl = null;
// Удаляем слушатели `mousemove` и `mouseup`
document.removeEventListener(
'mousemove', mouseMoveHandler
);
document.removeEventListener(
'mouseup', mouseUpHandler
);
};
/** Перетаскивание начинается */
const mouseDownHandler = (e) => {
draggingEl = e.target;
// Рассчитываем позицию курсора
const rect = draggingEl.getBoundingClientRect();
x = e.pageX - rect.left;
y = e.pageY - rect.top;
// Устанавливаем слушатели на документ
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener('mouseup', mouseUpHandler);
};
/* Теперь установим слушатели `mousedown`
на каждый элемент списка */
const listItems = document
.getElementById('list')
.querySelectorAll('.dragable');
listItems.forEach((item) => {
item.addEventListener('mousedown', mouseDownHandler);
});
Добавим заглушку
Во время перетаскивания списка, место элемента, изъятого из потока, смыкается. Например, место перетягиваемого элемента C
сразу же займет его «сосед снизу» — D
. Это может нарушить спокойствие пользователя и, чтобы предупредить такие сдвиги, надо создать заглушку той же высоты, что и изъятый элемент.
Заглушка создается в начале перетягивания.
let placeholder;
let isDraggingStarted = false;
const mouseMoveHandler = (e) => {
const draggingRect =
draggingEl.getBoundingClientRect();
if (!isDraggingStarted) {
// Обновляем флаг
isDraggingStarted = true;
// Создаем заглушку
placeholder = document.createElement('div');
placeholder.classList.add('placeholder');
draggingEl.parentNode.insertBefore(
placeholder,
draggingEl.nextSibling
);
/* Задаем высоту заглушке — такую же,
как у изъятого элемента */
placeholder.style.height = `${draggingRect.height}px`;
}
// ...
};
Заглушка удаляется, как только пользователь отпустит перетаскиваемый элемент.
const mouseUpHandler = () => {
// Удаляем
placeholder &&
placeholder.parentNode.removeChild(placeholder);
// Восстанавливаем флаг
isDraggingStarted = false;
// ...
};
В итоге в момент перетаскивания элемента C
узлы списка в DOM выстраиваются в такую последовательность:
A
B
«заглушка»
C — перетаскиваемый элемент
D
E
Определяем куда пользователь перетягивает элемент: вверх или вниз
Во-первых, нам потребуется вспомогательная функция, определяющая в каком положении находится перетаскиваемый элемент в текущий момент: сверху или снизу указанного в параметре соседа.
Элемент nodeA
считается выше элемента nodeB
, если значение вертикального центра nodeA
меньше аналогичного значения nodeB
.
Вертикальный центр рассчитывается как сумма координаты top
элемента и половины его высоты.
const isAbove = (nodeA, nodeB) => {
// Получаем границы элементов
const rectA = nodeA.getBoundingClientRect();
const rectB = nodeB.getBoundingClientRect();
return (
rectA.top + rectA.height / 2 <
rectB.top + rectB.height / 2
);
};
Как только пользователь начинает перетаскивание, мы определяем соседей выбранного — снизу и сверху.
const mouseMoveHandler = (e) => {
/* Порядок элементов:
prevEl
draggingEl
placeholder
nextEl */
const prevEl = draggingEl.previousElementSibling;
const nextEl = placeholder.nextElementSibling;
};
Если пользователь перетаскивает элемент вверх, мы меняем местами заглушку и соседа сверху.
const mouseMoveHandler = (e) => {
// ...
// Пользователь тащит элемент вверх
if (prevEl && isAbove(draggingEl, prevEl)) {
/* Изменение порядка: исходный -> новый
prevEl -> placeholder
draggingEl -> draggingEl
placeholder -> prevEl */
swap(placeholder, draggingEl);
swap(placeholder, prevEl);
}
};
Если пользователь перетаскивает элемент вниз, мы меняем местами заглушку и соседа снизу.
const mouseMoveHandler = (e) => {
// ...
// User moves the dragging element to the bottom
if (nextEl && isAbove(nextEl, draggingEl)) {
/* Изменение порядка: исходный -> новый
draggingEl -> nextEl
placeholder -> placeholder
nextEl -> draggingEl */
swap(nextEl, placeholder);
swap(nextEl, draggingEl);
}
};
Полная функция для изменения порядка элементов списка:
const swap = (nodeA, nodeB) => {
const parentA = nodeA.parentNode;
const siblingA =
nodeA.nextSibling === nodeB
? nodeA
: nodeA.nextSibling;
// Поднимаем `nodeA` выше `nodeB`
nodeB.parentNode.insertBefore(nodeA, nodeB);
/* Поднимаем `nodeB` выше соседа элемента `nodeA`
верхнего или нижнего определяется в тернарном
операторе выше */
parentA.insertBefore(nodeB, siblingA);
};
См. демо на HTML DOM.
Оформление виртуальной проекции перетягиваемого элемента
По умолчанию браузер создает полупрозрачного виртуального двойника перетягиваемого элемента. Но его можно еще и оформить по собственному смотрению.
<div class="is-draggable" draggable="true">
Перенеси меня
</div>
const el = document.getElementById('dragMe');
// Виртуальная проекция
let ghostEle;
el.addEventListener('dragstart', (e) => {
// Создаем виртуальную проекцию
ghostEle = document.createElement('div');
ghostEle.classList.add('is-dragging');
ghostEle.innerHTML = 'Уиии!';
document.body.appendChild(ghostEle);
// Заменяем проекцию по умолчанию пользовательской
e.dataTransfer.setDragImage(ghostEle, 0, 0);
});
// По окончанию перетягивания виртуальную проекцию следует удалить.
el.addEventListener('dragend', () => {
document.body.removeChild(ghostEle);
});
Также можно использовать заранее подготовленную разметку.
<div class="is-draggable" draggable="true">
Перенеси меня
</div>
<div class="is-dragging" id="ghost">
Уиии!
</div>
Обработчик события не сильно изменится.
const ghostEle = document.getElementById('ghost');
el.addEventListener('dragstart', (e) => {
e.dataTransfer.setDragImage(ghostEle, 0, 0);
});
Прокрутка перетягиванием, перетягивание «холста»
Кроме традиционной прокрутки некоторые приложения предлагают прокрутку перетягиванием — например, передвижение холста в Photoshop и Figma с зажатым пробелом.
Разметка и стили.
<style>
.container {
cursor: grab;
overflow: auto;
}
</style>
<div id="container" class="container">...</div>
Прокрутка в указанное положение
Поскольку благодаря overflow: auto;
содержание контейнера становится прокручиваемым, мы можем прокрутить его до указанной точки с помощью свойств scrollTop
и scrollLeft
.
const el = document.getElementById('container');
el.scrollTop = 100;
el.scrollLeft = 150;
Теперь то же самое — перетягиванием
Используем технику, описанную в разделе «Перетягиваемый элемент».
let pos = { top: 0, left: 0, x: 0, y: 0 };
const mouseDownHandler = (e) => {
pos = {
// Текущее положение прокрутки
left: el.scrollLeft,
top: el.scrollTop,
// Получить положение мыши
x: e.clientX,
y: e.clientY,
};
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener('mouseup', mouseUpHandler);
};
В объекте pos
сохраняются положение курсора и прокрутки. (Объект, кстати, предпочтительней четырех переменных, так как объединяет логически связанные значения).
Когда пользователь передвигает мышь, мы замеряем, насколько далеко, и затем прокручиваем содержимое в ту же точку.
const mouseMoveHandler = (e) => {
// Как далеко перемещен курсор
const dx = e.clientX - pos.x;
const dy = e.clientY - pos.y;
// Прокручиваем элемент
el.scrollTop = pos.top - dy;
el.scrollLeft = pos.left - dx;
};
Опыт взаимодействия в процессе перетягивания может быть улучшен, с помощью пары CSS-свойств.
const mouseDownHandler = (e) => {
// Change the cursor and prevent user from selecting the text
el.style.cursor = 'grabbing';
el.style.userSelect = 'none';
// ...
};
/** По окончании перетягивания */
const mouseUpHandler = () => {
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
// Меняем курсор и разрешаем выделение
el.style.cursor = 'grab';
el.style.removeProperty('user-select');
};
Перетаскивание строки в таблице
Техника основана на перетягивании элементов списка. Идея заключается в том, чтобы создавать дубликат строк таблицы в виде списка — как только пользователь начинает тянуть. На время перетягивания мы показываем только этот список (каждый элемент — клон строки), а таблицу прячем.
Также во время перетягивания мы определяем порядковый номер перетаскиваемого элемента списка. И переносим соответствующую строку исходной таблицы до или после «строки назначения».
Обработчики событий
Как нам известно из раздела о перетягивании элементов списка, первым делом надо создать обработчики для трёх событий.
mousedown
— добавляем обработчик на первые ячейки строк.mousemove
— добавляем обработчик на документ для создания и вставки «заглушки» на время перетаскивания.mouseup
— событие окончания перетаскивания.
`<table id="table">
...
</table>
<script>
const table = document.getElementById('table');
const mouseMoveHandler = (e) => {
// ...
};
const mouseUpHandler = () => {
// ...
// Удаляем обработчики `mousemove` и `mouseup`
document.removeEventListener(
'mousemove', mouseMoveHandler
);
document.removeEventListener(
'mouseup', mouseUpHandler
);
};
const mouseDownHandler = (e) => {
// ...
// Устанавливаем слушатели
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener(
'mouseup', mouseUpHandler
);
};
// Добавляем обработчики начала перетаскивания
table.querySelectorAll('tr').forEach((row, index) => {
// Игнорируем первую строку — фактический thead.
if (index === 0) {
return;
}
// Первая ячейка строки
const firstCell = row.firstElementChild;
firstCell.classList.add('draggable');
// Устанавливаем слушатели
firstCell.addEventListener('mousedown', mouseDownHandler);
});
</script>
Клонируем таблицу, когда пользователь тащит строку
Во-первых, нам нужен флаг, чтобы помечать, когда происходит перетягивание.
let isDraggingStarted = false;
const mouseMoveHandler = (e) => {
if (!isDraggingStarted) {
isDraggingStarted = true;
cloneTable();
}
// ...
};
Функция cloneTable
создает клон таблицы и показывает его вместо таблицы.
let list;
const cloneTable = () => {
// Получаем границы таблицы
const rect = table.getBoundingClientRect();
// Получаем ширину таблицы
const width = parseInt(
window.getComputedStyle(table).width, 10
);
// Создаем псевдосписок
list = document.createElement('div');
// Устанавливаем его по координатам таблицы
list.style.position = 'absolute';
list.style.left = `${rect.left}px`;
list.style.top = `${rect.top}px`;
// Вставляем до таблицы
table.parentNode.insertBefore(list, table);
// Таблицу скрываем
table.style.visibility = 'hidden';
};
Лист будет состоять из клонов строк.
let list;
const cloneTable = () => {
// Получаем границы таблицы
const rect = table.getBoundingClientRect();
// Получаем ширину таблицы
const width = parseInt(
window.getComputedStyle(table).width, 10
);
// Создаем псевдосписок
list = document.createElement('div');
// Устанавливаем его по координатам таблицы
list.style.position = 'absolute';
list.style.left = `${rect.left}px`;
list.style.top = `${rect.top}px`;
// Вставляем до таблицы
table.parentNode.insertBefore(list, table);
// Таблицу скрываем
table.style.visibility = 'hidden';
};
После клонирования в DOM у нас должна добавиться такая конструкция.
<!-- Список -->
<div>
<!-- Первый элемент… -->
<div>
<table>
<tr>
<!-- …содержит клон первой строки
исходной таблицы -->
</tr>
</table>
</div>
<!-- Второй элемент и т. д. -->
</div>
<table><!-- Исходная таблица --></table>
Теперь нам нужно установить ширину ячеек, чтобы сделать дубликат таблицы похожим на оригинал.
cells.forEach((cell) => {
const newCell = cell.cloneNode(true);
// Устанавливаем ширину оригинальной ячейки
newCell.style.width = `${parseInt(
window.getComputedStyle(cell).width,
10
)}px`;
newRow.appendChild(newCell);
});
Определяем индексы перетаскиваемой и целевой строки.
// Перетаскиваемый элемент
let draggingEl;
// Индекс Перетаскиваемой строки
let draggingRowIndex;
const mouseDownHandler = (e) => {
// Получаем исходную строку
const originalRow = e.target.parentNode;
draggingRowIndex = [].slice
.call(table.querySelectorAll('tr'))
.indexOf(originalRow);
};
const mouseMoveHandler = (e) => {
if (!isDraggingStarted) {
cloneTable();
// Получаем перетягиваемый элемент
draggingEl =
[].slice.call(list.children)[draggingRowIndex];
}
};
const mouseUpHandler = () => {
// Получаем индекс целевой строки
const endRowIndex = [].slice
.call(list.children)
.indexOf(draggingEl);
};
После того, как мы получили draggingRowIndex
и endRowIndex
, нетрудно определить, где пользователь отпустит перетаскиваемый элемент — сверху или снизу от исходной точки. И теперь мы можем решить, куда пристроить целевую строку: до или после нового места перетаскиваемой.
const mouseUpHandler = () => {
// Переносим перетаскиваемую строку к `endRowIndex`
const rows = [].slice.call(table.querySelectorAll('tr'));
draggingRowIndex > endRowIndex
? // Пользователь отпустил сверху
rows[endRowIndex].parentNode.insertBefore(
rows[draggingRowIndex],
rows[endRowIndex]
)
: // Пользователь отпустил снизу
rows[endRowIndex].parentNode.insertBefore(
rows[draggingRowIndex],
rows[endRowIndex].nextSibling
);
};
См. демо на HTML DOM.
Перетаскивание колонки в таблице
Техника основана на перетягивании элементов списка. Идея заключается в том, чтобы создавать дубликат колонок в виде списка — как только пользователь начинает тянуть. На время перетягивания мы показываем только этот список (каждый элемент — клон колонки), а таблицу прячем.
Также во время перетягивания мы определяем порядковый номер перетаскиваемого и целевого элемента списка. И меняем местами соответствующие колонки.
Обработчики событий
Как нам известно из раздела о перетягивании элементов списка, первым делом надо создать обработчики для трёх событий.
mousedown
— добавляем обработчик на первые ячейки колонок, это будет функция перетягивания их клонов.mousemove
— добавляем обработчик на документ для создания и вставки «заглушки» на время перетаскивания.mouseup
— событие окончания перетаскивания.
Разметка и «каркас» скрипта.
<table id="table"><!-- ... --></table>
<script>
const table = document.getElementById('table');
const mouseMoveHandler = (e) => {
// ...
};
const mouseUpHandler = () => {
// ...
// Удаляем обработчики `mousemove` и `mouseup`
document.removeEventListener(
'mousemove', mouseMoveHandler
);
document.removeEventListener(
'mouseup', mouseUpHandler
);
};
const mouseDownHandler = (e) => {
// ...
// Добавляем обработчики на документ
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener(
'mouseup', mouseUpHandler
);
};
// Добавляем обработчики начала перетаскивания
table
.querySelectorAll('th')
.forEach((headerCell) => {
headerCell.addEventListener(
'mousedown', mouseDownHandler
);
});
</script>
Клонируем таблицу, когда пользователь перетягивает колонку
Во-первых, нам нужен флаг, чтобы помечать, когда происходит перетягивание.
let isDraggingStarted = false;
const mouseMoveHandler = (e) => {
if (!isDraggingStarted) {
isDraggingStarted = true;
cloneTable();
}
// ...
};
Функция cloneTable
создает клон таблицы и показывает его вместо таблицы.
let list;
const cloneTable = () => {
// Получаем границы таблицы
const rect = table.getBoundingClientRect();
/* Создает список — не семантический `ul`,
простой `div` */
list = document.createElement('div');
// Устанавливаем его по координатам таблицы
list.style.position = 'absolute';
list.style.left = `${rect.left}px`;
list.style.top = `${rect.top}px`;
// В коде вставляем перед таблицей
table.parentNode.insertBefore(list, table);
// Прячем таблицу
table.style.visibility = 'hidden';
};
Лист будет состоять из клонов колонок.
const cloneTable = () => {
// ...
/* Получаем поверхностную копию всех td.
Делаем из NodeList массив, чтобы применить
позже метод `filter`.*/
const originalCells = [].slice.call(
table.querySelectorAll('tbody td')
);
/* Получаем поверхностную копию всех th.
В данном случае не уверен ыв необходимости
`[].slice.call`, так как `NodeList` имеет
свойство `length` и метод `forEach` */
const originalHeaderCells = [].slice.call(
table.querySelectorAll('th')
);
const numColumns = originalHeaderCells.length;
// Перебираем все th
originalHeaderCells.forEach(
(headerCell, headerIndex) => {
const width = parseInt(
window.getComputedStyle(headerCell).width,
10
);
/* Создаем список и новую таблицу
из первой строки исходной */
const item = document.createElement('div');
item.classList.add('draggable');
const newTable = document.createElement('table');
/* Строка заголовков th
(семантически — потомок thead) */
const th = headerCell.cloneNode(true);
let newRow = document.createElement('tr');
newRow.appendChild(th);
newTable.appendChild(newRow);
const cells = originalCells.filter((c, idx) => {
return (idx - headerIndex) % numColumns === 0;
});
cells.forEach((cell) => {
const newCell = cell.cloneNode(true);
newRow = document.createElement('tr');
newRow.appendChild(newCell);
newTable.appendChild(newRow);
});
item.appendChild(newTable);
list.appendChild(item);
}
);
};
После клонирования в DOM у нас должна добавиться такая конструкция.
<!-- Список -->
<div>
<!-- Первый элемент… -->
<div>
<table>
<!-- …содержит клон первой колонки
исходной таблицы -->
<tr>
...
</tr>
<tr>
...
</tr>
...
</table>
</div>
<!-- Второй элемент и т. д. -->
</div>
<table><!-- Исходная таблица --></table>
Теперь нам нужно установить ширину ячеек, чтобы сделать дубликат таблицы похожим на оригинал.
originalHeaderCells.forEach((headerCell, headerIndex) => {
// Получаем ширину оригинальной ячейки
const width = parseInt(
window.getComputedStyle(headerCell).width, 10
);
newTable.style.width = `${width}px`;
cells.forEach((cell) => {
const newCell = cell.cloneNode(true);
newCell.style.width = `${width}px`;
// ...
});
});
Определяем индексы перетаскиваемой и целевой колонки.
// Перетаскиваемый элемент
let draggingEl;
// Индекс перетаскиваемой колонки
let draggingRowIndex;
const mouseDownHandler = (e) => {
// Получаем индекс перетаскиваемой колонки
draggingColumnIndex = [].slice
.call(table.querySelectorAll('th'))
.indexOf(e.target);
};
const mouseMoveHandler = (e) => {
if (!isDraggingStarted) {
cloneTable();
// Получаем перетаскиваемый элемент
draggingEl = [].slice
.call(list.children)[draggingColumnIndex];
}
};
const mouseUpHandler = () => {
// Получаем индекс последней колонки
const endColumnIndex = [].slice
.call(list.children)
.indexOf(draggingEl);
};
После того, как мы получили draggingColumnIndex
и endColumnIndex
, нетрудно определить, где пользователь отпустит перетаскиваемый элемент — справа или слева от исходной точки. И теперь мы можем решить, куда пристроить целевую колонку: до или после нового места перетаскиваемой.
const mouseUpHandler = () => {
// Двигаем перетаскиваемую колонку к `endColumnIndex`
table.querySelectorAll('tr').forEach(function (row) {
const cells = [].slice.call(
row.querySelectorAll('th, td')
);
draggingColumnIndex > endColumnIndex
? cells[endColumnIndex].parentNode.insertBefore(
cells[draggingColumnIndex],
cells[endColumnIndex]
)
: cells[endColumnIndex].parentNode.insertBefore(
cells[draggingColumnIndex],
cells[endColumnIndex].nextSibling
);
});
};
См. демо на HTML DOM.
Изображения, ifram'ы, файлы и таблицы
Заменить «битые» изображения
Вместо ненайденных картинок показывать картинку с соответствующим сообщением.
// Находим все картинки…
const images = document.querySelectorAll('img');
// и перебираем
images.forEach((el) => {
el.addEventListener('error', (e) => {
e.target.src = '/path/to/404/image.png';
});
});
Вставить изображение из буфера обмена
// Устанавливаем слушатель события `paste`
document.addEventListener('paste', (e) => {
// Получаем данные из буфера
const clipboardItems = e.clipboardData.items;
const items = [].slice
.call(clipboardItems)
.filter((item) => {
// Фильтруем: только картинки
return item.type.indexOf('image') !== -1;
});
if (items.length === 0) {
return;
}
const item = items[0];
// Получаем картинку, как объект `File`
const blob = item.getAsFile();
});
Теперь мы можем увидеть картинку на странице, предположим, в «заглушке» img#preview
. Для это в слушатель, после создания blob
получаем этот элемент и устанавливаем его атрибут src
.
const imageEle = document.getElementById('preview');
imageEle.src = URL.createObjectURL(blob);
А можем отослать на сервер в AJAX-запросе. (Код нужно вставить в том же слушателе).
// Создаем новую FormData
const formData = new FormData();
formData.append('image', blob, 'filename');
// Создает запрос. Лучше использовать `fetch` — см. ниже
const request = new XMLHttpRequest();
request.open('POST', '/path/to/back-end', true);
// Обработчики «успешно» и ошибки
request.onload = () => {
if (request.status >= 200 && request.status < 400) {
const res = request.responseText;
// Do something with the response
// ...
}
};
// Отправляем
request.send(formData);
Предпросмотр изображения перед отправкой на сервер
Исходная разметка. Поле \[type='file'\]
для выбора изображения и «заглушка» картинки.
<input id="input-file" type="file">
<img id="preview">
const fileEl = document.getElementById('input-file');
const previewEl = document.getElementById('preview');
1. Используем метод URL.createObjectURL()
fileEl.addEventListener('change', (e) => {
// Получаем выбранный файл
const file = e.target.files[0];
// Создаем URL-объект выбранной картинки
const url = URL.createObjectURL(file);
// Передаем в «заглушку» URL-объект
previewEl.src = url;
});
2. Используем метод FileReader.readAsDataURL()
fileEl.addEventListener('change', (e) => {
// Получаем выбранный файл
const file = e.target.files[0];
const reader = new FileReader();
reader.addEventListener('load', () => {
// Передаем в «заглушку» ссылку
previewEl.src = reader.result;
});
reader.readAsDataURL(file);
});
Растягивание и уменьшение (scale) изображения
Задача. Масштабировать изображение на заданное в процентах значение. Для выбора картинки есть #upload[type='file']
, для предпросмотра — пустой div#preview
.
const picture = document.getElementById('upload').files[0];
const previewEl = document.getElementById('preview');
/**
* Получение объекта Blob из data URL
*
* Если браузер не поддерживает метод `canvas.toBlob`,
* конвертируем data URL изображения, полученный
* из функции `resize`, в объект Blob.
*
* @param { string } url data URL изображения
*/
const dataUrlToBlob = (url) => {
const arr = url.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const str = atob(arr[1]);
let { length } = str;
const uintArr = new Uint8Array(length);
while (length--) {
uintArr[length] = str.charCodeAt(length);
}
return new Blob([uintArr], { type: mime });
};
/**
* Масштабирование изображения
*
* @param { object } image изображение
* @param { number } ratio коэффициент сжатия: 0.01 - 0.99
*/
const resize = (image, ratio) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
// Читаем файл
reader.readAsDataURL(image);
// После загрузки изображения
reader.addEventListener('load', (e) => {
// Создаем объект типа `Image`
const el = new Image();
el.addEventListener('load', () => {
// Создаем холст
const canvas = document.createElement('canvas');
/* Выводим на холсте заглушку масштабированного
изображения */
const context = canvas.getContext('2d');
const w = el.width * ratio;
const h = el.height * ratio;
canvas.width = w;
canvas.height = h;
context.drawImage(el, 0, 0, w, h);
// Получаем данные масштабированной картинки
'toBlob' in canvas
? canvas.toBlob((blob) => {
resolve(blob);
})
: resolve(dataUrlToBlob(canvas.toDataURL()));
});
// Задаем атрибут `src`
el.src = e.target.result;
});
reader.addEventListener('error', () => {
reject();
});
});
};
Как только мы получаем Blob масштабированной картинки, мы можем просмотреть ее на странице или отослать на сервер, как часть FormData
.
// Уменьшаем картинку на 50%
resize(picture, 0.5).then((blob) => {
// Предпросмотр
previewEl.src = URL.createObjectURL(blob);
});
Приближение и удаление (zoom) изображения с помощью бегунка
Разметка:
<!-- Картинка -->
<div class="image-container">
<img id="image">
</div>
<!-- Бегунок -->
<div>
<!-- Минимальное значение -->
<div>10%</div>
<!-- Собственно бегунок -->
<div>
<div class="left"></div>
<div id="knob"></div>
<div class="right"></div>
</div>
<!-- Максимальное значение -->
<div>200%</div>
</div>
Контейнер изображения
Центрируем картинку в контейнере и обрезаем, когда она больше, чем контейнер.
.image-container {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
width: 100%;
}
Расчет исходных габаритов
Для начала получим исходные размеры изображения. Для этого применяем небольшой трюк: создаем новую картинку, передаем в нее значение атрибута src реальной, чтобы обработать событие load
и получить искомые данные.
const image = document.getElementById('image');
/* Создаем пустую картинку — это будет клон
пользовательской, который можно измерить */
const cloneImage = new Image();
// Измеряем размеры картинки на загрузке
cloneImage.addEventListener('load', (e) => {
// Исходные размеры
const width = e.target.naturalWidth;
const height = e.target.naturalHeight;
// Устанавливаем размеры в стиле
image.style.width = `${width}px`;
image.style.height = `${height}px`;
});
// Передаем `src` в клон
cloneImage.src = image.src;
Поскольку картинка с помощью стилей вписывается в контейнер, нужно узнать насколько она масштабирована в исходном положении.
cloneImage.addEventListener('load', (e) => {
// ...
const scale =
image
.parentNode
.getBoundingClientRect()
.width / width;
});
Теперь восстанавливаем реальные размеры картинки, используя полученный коэффициент исходного масштабирования.
cloneImage.addEventListener('load', (e) => {
//...
image.style.transform = `scale(${scale}, ${scale})`;
});
Бегунок
Подробно о бегунке см. здесь.
В этой задаче мы хотим установить бегунок согласно значению исходного масштабирования, полученного ранее.
Но сначала установим пороговые значения.
const minScale = 0.1;
const maxScale = 2;
const step = (maxScale - minScale) / 100;
Бегунок может менять ширину в строковом стиле изображения, основываясь на ширине своей левой части.
const knob = document.getElementById('knob');
const leftSide = knob.previousElementSibling;
cloneImage.addEventListener('load', (e) => {
// ...
leftSide.style.width = `${(scale - minScale) / step}%`;
});
Изменение размеров с использованием бегунка
Обновляем коэффициент масштабирования, согласно положению бегунка.
const mouseMoveHandler = (e) => {
// Вычисляем ширину левой части бегунка
// ...
const newLeftWidth = ((leftWidth + dx) * 100) / containerWidth;
// Устанавливаем ширину левой части
leftSide.style.width = `${newLeftWidth}%`;
/* Вычисляем коэффициент масштабирования и
изменяем стиль картинки */
const scale = minScale + newLeftWidth * step;
image.style.transform = `scale(${scale}, ${scale})`;
};
См. демо на HTML DOM.
Распечатать одну картинку со страницы
Страницу целиком можно распечатать, вызвав команду браузера Файл ➜ Печать… (⌘ / Ctrl + P) или вызвав функцию window.print()
.
Чтобы напечатать изображение из страницы, можно создать iframe
с заглушкой картинки, передавть в заглушку значение src
и вызвать window.print()
из iframe
.
Для этого кроме собственно картинки нам понадобится кнопка печати.
<img id="image" src="/path/to/image.jpg">
<button id="print">Print</button>
А обработчик печати вызываться из слушателя установленного на кнопку.
const printBtn = document.getElementById('print');
printBtn.addEventListener('click', () => {
// ...
});
Создаем временный iframe
const iframe = document.createElement('iframe');
// Делаем его невидимыми
iframe.style.height = 0;
iframe.style.visibility = 'hidden';
iframe.style.width = 0;
/* Добавляем размекту в `srcdoc` -
https://bit.ly/3v5Iu3p */
iframe.setAttribute(
'srcdoc', '<html><body></body></html>'
);
document.body.appendChild(iframe);
Вставляем изображение, как только iframe
готов
Даже не смотря на то, что в iframe
нет содержания из внешнего источника, надо дождаться его готовности.
iframe.addEventListener('load', () => {
// Клонируем картинку
const image =
document.getElementById('image').cloneNode();
image.style.maxWidth = '100%';
// Вставляем клон в `body` элемента `iframe`
const { body } = iframe.contentDocument;
body.style.textAlign = 'center';
body.appendChild(image);
image.addEventListener('load', () => {
/* Вызываем `print`, как только картинка
загрузилась в `iframe` */
iframe.contentWindow.print();
});
});
☝️🧐 В примере использована техника вложенных обработчиков: слушатель load
, установленный на картинке, вложен в слушатель load
, установленный на iframe
.
Удаляем iframe
…Когда пользователь запускает печать или когда закрывает окно печати.
iframe.contentWindow.addEventListener(
'afterprint',
() => {
iframe.parentNode.removeChild(iframe);
}
);
Виджет сравнения фотографий
Разметка
Виджет состоит из контейнера и трех компонентов.
- Сверху —
div
с фоном из измененного изображения. - Ниже — передвижная грань.
- Внизу — исходное изображения.
<div class="container">
<div class="modified-image"></div>
<!-- Грань -->
<div class="cropper" id="drag-me"></div>
<img src="/original.png">
</div>
В исходном положении измененное изображение обрезано пополам.
.container {
position: relative;
}
.modified-image {
background: url('/modified.png') no-repeat 0;
background-size: auto 100%;
position: absolute;
top: 0;
left: 0;
// Половина измененного изображения
width: 50%;
height: 100%;
}
// Грань пересекает изображения посередине
.cropper {
background-color: #cbd5e0;
cursor: ew-resize;
position: absolute;
top: 0;
left: 50%;
width: 2px;
height: 100%;
}
Обработчики
Когда пользователь передвигает грань, мы рассчитываем насколько переместился курсор и, исходя из этого, — новое положение грани и ширину измененного изображения.
const cropper = document.getElementById('drag-me');
const leftSide = cropper.previousElementSibling;
// Инициализируем координаты курсора
let x = 0;
let y = 0;
// Инициализируем ширину модифицированного изображения
let leftWidth = 0;
/* Обработчик события `mousedown`. Срабатывает,
когда пользователь перетаскивает грань */
const mouseDownHandler = (e) => {
// Текущие координаты курсора
x = e.clientX;
y = e.clientY;
// Текущая ширина модифицированного изображения
leftWidth =
leftSide.getBoundingClientRect().width;
// Устанавливаем слушатели на документ
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener('mouseup', mouseUpHandler);
};
const mouseMoveHandler = (e) => {
// Как далеко передвинулся курсор
const dx = e.clientX - x;
const dy = e.clientY - y;
let newLeftWidth =
((leftWidth + dx) * 100) /
cropper.parentNode.getBoundingClientRect().width;
newLeftWidth = Math.max(newLeftWidth, 0);
newLeftWidth = Math.min(newLeftWidth, 100);
/* Расчет ширины модифицированного изображения
и позиции грани */
leftSide.style.width = `${newLeftWidth}%`;
cropper.style.left = `${newLeftWidth}%`;
};
// Устанавливаем слушатель
cropper.addEventListener(
'mousedown', mouseDownHandler
);
Кроме всего прочего, мы должны следить за тем, чтобы в наших расчетах учитывались передвижения курсора только в рамках контейнера. Поэтому мы должны сравнивать newLeftWidth
с 0 и 100 процентами ширины контейнера.
const mouseMoveHandler = (e) => {
// Предыдущий код...
newLeftWidth = Math.max(newLeftWidth, 0);
newLeftWidth = Math.min(newLeftWidth, 100);
};
Загрузка файла: по клику и при переходе на страницу
Загрузка по клику
<a href="/path/to/file" download>Download</a>
Загрузка при переходе на страницу — вызов события click
// Создаем ссылку
const link = document.createElement('a');
// <a href="/path/to/real-name" download="my-name">
link.download = 'my-name';
link.href = '/path/to/real-name';
document.body.appendChild(link);
// Инициируем клик
link.click();
// Удаляем ссылку
document.body.removeChild(link);
Загрузка сгенерированного содержания
Часто надо загрузить динамически создаваемое содержание: текст, изображение, JSON.
Сгенерированное содержание (в примере — JSON), можно перевести в объект Blob и затем вызвать событие click
, как описывалось выше.
// Создаем Blob c JSON'ом
const data = JSON.stringify({ 'message': 'Hello Word' });
const blob = new Blob([data], {
type: 'application/json'
});
// Преобразовываем blob в URL
const url = window.URL.createObjectURL(blob);
// Создаем ссылку, инициируем `click`, удаляем ссылку
// ...
// Дополнительно удаляем объект URL
window.URL.revokeObjectURL(url);
Обмен данными с `iframe`
Передача данных из iframe
наверх, родительскому окну
В этом примере в переменной message
передаем строку.
window.parent.postMessage(message, '*');
А если нужно передать объект, сначала записываем в формате JSON и конвертируем в строку методом stringify
.
const message = JSON.stringify({
message: 'Hello from iframe',
date: Date.now(),
});
// Вызов из `iframe`
window.parent.postMessage(message, '*');
Передача данных в iframe
// Вызов из родительской страницы
myFrame.contentWindow.postMessage(message, '*');
Получение данных
Чтобы получить данные, в iframe
или на родительской странице нужно добавить слушателя события message
.
window.addEventListener('message', (e) => {
// Получаем данные
const data = e.data;
/* Если данные содержат десериализованный JSON,
их нужно сериализовать — перевести из строки
в объект */
const decoded = JSON.parse(data);
});
Совет
Если обмен данными осуществляется с разными iframe
, можно добавить свойство-«подпись».
// Передача данных из `iframe`
const message = JSON.stringify({
channel: 'FROM_FRAME_A',
// ...
});
window.parent.postMessage(message, '*');
В получателе (в примере — родительская страница) теперь можно распознавать отправителей.
window.addEventListener('message', (e) => {
const data = JSON.parse(e.data);
// Откуда «дровишки»?
const channel = data.channel;
});
Установить высоту iframe по содержанию
const frame = document.getElementById('my-iframe');
frame.addEventListener('load', () => {
// Получим высоту содержания
const height = frame.contentDocument.body.scrollHeight;
// Установим высоту iframe.my-iframe
frame.setAttribute('height', `${height}px`);
});
Индикатор загрузки `iframe`
Разметка и стили
Изначально iframe
скрыто (opacity: 0
). А индикатор выравнен по центру.
<div class="container">
<!-- The loading indicator -->
<div class="loader" id="loader">Loading</div>
<!-- The iframe -->
<iframe id="frame" style="opacity: 0;"></iframe>
</div>
.container {
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
}
.loader {
background: var(--color-background-alt);
box-shadow: var(--layer-box-shadow-z3);
border-radius: var(--shape-border-radius-base);
padding: $size-line $size-1p5;
}
Обработка события load
Скроем индикатор, когда загрузка закончится.
const iframeEle = document.getElementById('iframe');
const loadingEle = document.getElementById('loader');
iframeEle.addEventListener('load', () => {
// Скрыть индикатор
loadingEle.style.display = 'none';
// Показать iframe
iframeEle.style.opacity = 1;
});
Сортировка таблицы
Разметка:
<table class="table" id="sort-me">
...
</table>
Сортировка строк
Сначала выберем все ячейки первой строки и установим на них слушатели события click
.
const table = document.getElementById('sort-me');
/* Нам понадобиться методы `sort` и `map`, поэтому
создадим из набора элементов массив */
const headers =
[].slice.call(table.querySelectorAll('th'));
headers.forEach((header, index) => {
header.addEventListener('click', () => {
// Об этой функции — ниже
sortColumn(index);
});
});
Функция sortColumn
сортирует все строки таблицы по указанному индексу колонки. Для этого мы сделает вот что.
- Используем метод массива
sort()
для сортировки строк - Затем удалим строки в исходном порядке.
- И добавим строки в новом порядке.
const tableBody = table.querySelector('tbody');
const rows = tableBody.querySelectorAll('tr');
const sortColumn = (index) => {
// Клонируем строки
const newRows = Array.from(rows);
// Сортируем строки по содержанию ячеек
newRows.sort((rowA, rowB) => {
const cellA
= rowA.querySelectorAll('td')[index].innerHTML;
const cellB
= rowB.querySelectorAll('td')[index].innerHTML;
switch (true) {
case cellA > cellB:
return 1;
case cellA < cellB:
return -1;
case cellA === cellB:
return 0;
}
});
// Удаляем строки в исходном порядке
rows.forEach((row) => {
tableBody.removeChild(row);
});
// Добавляем новые строки
newRows.forEach((newRow) => {
tableBody.appendChild(newRow);
});
};
Всё хорошо. Но метод массива sort
пригоден только для сортировки строк.
Другие типы данных
Добавим th
колонки пользовательский атрибут для маркировки типов данных
<thead>
<tr>
<th data-type="number">No.</th>
<th>First name</th>
<th>Last name</th>
</tr>
</thead>
Добавим функцию, которая конвертирует строку в другой тип данных.
const transform = (index, content) => {
// Получаем тип данных колонки
const type =
headers[index].getAttribute('data-type');
switch (type) {
case 'number':
return parseFloat(content);
case 'string':
default:
return content;
}
};
В примере конвертируется только числа, но может добавить case
и для других типов: date
etc.
Теперь немного улучшим функцию sortColumn
, заменив в сравнении необработанные данные данными, полученными из функции transform
.-index
newRows.sort((rowA, rowB) => {
const cellA
= rowA.querySelectorAll('td')[index].innerHTML;
const cellB
= rowB.querySelectorAll('td')[index].innerHTML;
// Преобразование типов
const a = transform(index, cellA);
const b = transform(index, cellB);
// AСравнение
switch (true) {
case a > b:
return 1;
case a < b:
return -1;
case a === b:
return 0;
}
});
Обратный порядок
Если пользователь кликает по ячейке th
повторно, порядок сортировки должен меняться. Для этого следует создать специальный массив.
const directions =
Array.from(headers).map((header) => {
return '';
});
В массиве directions
будем сохранять значения asc
или desc
для каждой колонки. Соответственно, обновим функцию sortColumn()
.
const sortColumn = (index) => {
// Текущий порядок сортировки
const direction = directions[index] || 'asc';
// Коэффициент сортировки, основанный на порядке
const multiplier = direction === 'asc' ? 1 : -1;
// ...
newRows.sort((rowA, rowB) => {
const cellA
= rowA.querySelectorAll('td')[index].innerHTML;
const cellB
= rowB.querySelectorAll('td')[index].innerHTML;
const a = transform(index, cellA);
const b = transform(index, cellB);
switch (true) {
case a > b:
return 1 * multiplier;
case a < b:
return -1 * multiplier;
case a === b:
return 0;
}
});
// ...
// Меняем порядок сортировки
directions[index] =
direction === 'asc' ? 'desc' : 'asc';
// ...
};
См. демо на HTML DOM.
Показать или скрыть колонки таблицы
Каркас разметки:
<table id="table">
...
</table>
<ul id="menu"></ul>
Меню
Меню содержит чекбоксы для скрытия каждой из колонок.
<ul id="menu">
<li>
<!-- Чекбокс первой колонки -->
<label>
<input type="checkbox">Название столбца
</label>
<!-- Остальные колонки ... -->
</li>
</ul>
Чтобы добавить в меню все нужные чекбоксы, переберем ячейки первой строки таблицы.
const menu = document.getElementById('menu');
const table = document.getElementById('table');
const headers = table.querySelectorAll('th');
headers.forEach((th, index) => {
// Создаем чекбокс для каждой колонки
const li = document.createElement('li');
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.setAttribute('type', 'checkbox');
// Надпись в `label` соответствует названию столбца
const text = document.createTextNode(th.textContent);
label.appendChild(checkbox);
label.appendChild(text);
li.appendChild(label);
menu.appendChild(li);
});
Для переключения видимости будем обрабатывать событие change
.
headers.forEach((th, index) => {
// Создаем чекбокс для каждой колонки
// ...
// Обрабатываем событие
checkbox.addEventListener('change', (e) => {
e.target.checked
? showColumn(index)
: hideColumn(index);
});
});
К функциям showColumn
и hideColumn
вернемся чуть позже.
Как показать/скрыть меню
- Меню должно открываться, когда пользователь кликает на первую строку таблицы.
- Меню должно скрываться, когда пользователь кликает за его пределами.
Контекстному меню посвящен собственный раздел. А в данном случае оно будет реализовано так.
const thead = table.querySelector('thead');
/* Обработаем событие `contextmenu` на первой
строке таблицы */
thead.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Покажем меню
// ...
document.addEventListener('click', documentClickHandler);
});
// Скроем меню на клике за его границами
const documentClickHandler = (e) => {
// ...
};
Переключаем колонки
Кликая определенный чекбокс в меню, мы должны скрывать и показывать колонки по соответствующему индексу в NodeList
.
Для того, чтобы выбрать все ячейки в колонке, мы каждой добавим атрибут data-column-index
с общим значением.
// Получаем количество колонок
const numColumns = headers.length;
/* Чтобы позже использовать метод `filter`,
сохраним набор элементов, как массив */
const cells =
[].slice.call(table.querySelectorAll('th, td'));
cells.forEach((cell, index) => {
cell.setAttribute(
'data-column-index', index % numColumns
);
});
Прячем колонки с определенным значением data-column-index
.
const hideColumn = (index) => {
cells
.filter((cell) => {
return (
cell.getAttribute('data-column-index') ===
`${index}`
);
})
.forEach((cell) => {
cell.style.display = 'none';
});
};
Показываем колонки.
const showColumn = (index) => {
cells
.filter((cell) => {
return (
cell.getAttribute('data-column-index') ===
`${index}`
);
})
.forEach((cell) => {
cell.style.display = '';
});
};
Не позволяем скрыть последнюю оставшуюся колонку
Добавим чекбоксам в меню тот же data
-атрибут, что и ячейкам.
headers.forEach((th, index) => {
// Создаем чекбокс для каждой колонки
// ...
checkbox.setAttribute('data-column-index', index);
});
Когда колонка скрыта, мы добавляем еще один пользовательский атрибут, для того чтобы поменить скрытую колонку.
const hideColumn = (index) => {
cells
.filter((cell) => {
// ...
})
.forEach((cell) => {
// ...
cell.setAttribute('data-shown', 'false');
});
};
Подсчитав, сколько колонок уже скрыто, мы можем блокировать чекбокс, связанной с единственной оставшейся.
const hideColumn = (index) => {
// Сколько колонок уже скрыто?
const numHiddenCols = [...headers].filter((th) => {
return th.getAttribute('data-shown') === 'false';
}).length;
if (numHiddenCols === numColumns - 1) {
/* Когда остается одна видимая колонка,
блокируем соответствующий чекбокс */
const shownColumnIndex = thead
.querySelector('[data-shown="true"]')
.getAttribute('data-column-index');
const checkbox = menu.querySelector(
`
[type="checkbox"]
[data-column-index="${shownColumnIndex}"]
`
);
checkbox.setAttribute('disabled', 'true');
}
};
Меняем атрибут data-shown
, когда колонка видна.
cells.forEach((cell, index) => {
cell.setAttribute('data-shown', 'true');
});
const showColumn = (index) => {
cells
.filter((cell) => {
// ...
})
.forEach((cell) => {
// ...
cell.setAttribute('data-shown', 'true');
});
menu
.querySelectorAll('[type="checkbox"][disabled]')
.forEach((checkbox) => {
checkbox.removeAttribute('disabled');
});
};
См. демо на HTML DOM.
Изменение ширины колонки в таблице
Разметка:
<table class="table" id="resize-me">
...
</table>
«Ручки»
Для каждой колонки создадим «ручки» — абсолютно позиционированные относительно ячейки первой строки элементы, которые будет перемещать пользователь. Они нужны потому, что края колонок не являются DOM-элементами.
.table th {
position: relative;
}
.resizer {
cursor: col-resize;
position: absolute;
top: 0;
right: 0;
user-select: none;
width: 5px;
}
Создадим «ручки» в JS.
const table = document.getElementById('resize-me');
const cols = table.querySelectorAll('th');
cols.forEach((col) => {
const resizer = document.createElement('div');
resizer.classList.add('resizer');
// Сделаем высоту ручки равной высоте таблицы
resizer.style.height = `${table.offsetHeight}px`;
// Прикрепим ручку к колонке
col.appendChild(resizer);
// До этой функции скоро доберемся
createResizableColumn(col, resizer);
});
Обработчики событий
Напишем функцию createResizableColumn
с двумя параметрами:
col
— ячейка первой строки таблицыresizer
— «ручка»
In order to allow user to resize col
, we have to handle three events:
mousedown
— добавляем обработчик на первые ячейки колонок для отслеживания позиции курсора.mousemove
— добавляем обработчик на документ для рассчета дальности перемещения курсора и соответствующего изменения ширины колонки.mouseup
— добавляем обработчик на документ, чтобы по окончанию перетягивания удалить обработчики, установленные ранее.
const createResizableColumn = (col, resizer) => {
// Текущая позиция мыши
let x = 0;
let w = 0;
const mouseMoveHandler = (e) => {
// Определяем, как далеко курсор переместился
const dx = e.clientX - x;
// Соответственно, меняем ширину колонки
col.style.width = `${w + dx}px`;
};
/* Удаляем слушатели и обработчики, как только
пользователь заканчивает перетаскивание ручки */
const mouseUpHandler = () => {
document.removeEventListener(
'mousemove', mouseMoveHandler
);
document.removeEventListener(
'mouseup', mouseUpHandler
);
};
const mouseDownHandler = (e) => {
// Текущая позиция курсора
x = e.clientX;
// Рассчитываем текущую ширину колонки
const styles = window.getComputedStyle(col);
w = parseInt(styles.width, 10);
// Устанавливаем слушатели на документ
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener(
'mouseup', mouseUpHandler
);
};
resizer.addEventListener(
'mousedown', mouseDownHandler
);
};
Выделение ручки
Окрасим ручки в синий на :hover
.
.resizer:hover,
.resizing {
border-right: 2px solid blue;
}
Добавим ручкам класс is-resizing
— на то время, пока пользователь их перетягивает.
const mouseDownHandler = (e) => {
// ...
resizer.classList.add('is-resizing');
};
const mouseUpHandler = () => {
// ...
resizer.classList.remove('is-resizing');
};
См. демо на HTML DOM.
Экспорт HTML-таблицы в CSV
Экспорт ячеек
В примере мы переведем данные таблицы exportMe
в CSV по нажатию кнопки export
.
<table id="exportMe" class="table"><!--...--></table>
<button id="export">Export</button>
Функция toCsv
переводит ячейки в формат CSV. Получаем строки, перебираем каждую: извлекаем содержимое ячеек, записываем через запятую; каждую tr
записываем на новой строке.
const toCsv = (table) => {
// Получаем все строки таблицы
const rows = table.querySelectorAll('tr');
// Перебираем строки, собираем и возвращаем CSV
return [].slice
.call(rows)
.map((row) => {
// Получаем все ячейки
const cells = row.querySelectorAll('th,td');
return [].slice
.call(cells)
.map((cell) => {
return cell.textContent;
})
.join(',');
})
.join('\n');
};
Загружаем CSV
Функция download
создает временную невидимую ссылку скачивания. Добавляет в атрибут href
CSV, как Data URL. Инициирует событие click
, а затем удаляет временную ссылку.
const download = (text, fileName) => {
const link = document.createElement('a');
link.setAttribute(
'href',
`data:text/csv;
charset=utf-8,${encodeURIComponent(text)}`
);
link.setAttribute('download', fileName);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
Устанавливаем на кнопку слушатель с обработчиком, последовательно вызывающим функции toCsv
и download
.
const table = document.getElementById('exportMe');
const exportBtn = document.getElementById('export');
exportBtn.addEventListener('click', () => {
// Экспорт в CSV
const csv = toCsv(table);
// Скачивание CSV
download(csv, 'download.csv');
});
Редактирование
Масштабируемое разделенное представление (split view)
Исходная разметка.
<div style="display: flex">
<div class="left">Left</div>
<!-- The resizer -->
<div class="resizer" id="dragMe"></div>
<div class="right">Right</div>
</div>
Обновление ширины честей split-представления
О том, как делать перетаскиваемый элемент, см. здесь.
В нашем случае ручка масштабирования будет двигаться только по горизонтали. Сначала нам надо получить позицию мыши и ширину левой части split-представления до масштабирования.
const resizer = document.getElementById('dragMe');
const leftSide = resizer.previousElementSibling;
const rightSide = resizer.nextElementSibling;
// Позиция мыши
let x = 0;
let y = 0;
// Ширина левой части
let leftWidth = 0;
/** Обработка перетягивания мышью */
const mouseDownHandler = (e) => {
// Текущая позиция мыши
x = e.clientX;
y = e.clientY;
leftWidth = leftSide.getBoundingClientRect().width;
// Добавляем вложенные обработчики
document.addEventListener(
'mousemove', mouseMoveHandler
);
document.addEventListener('mouseup', mouseUpHandler);
};
// Назначаем первый обработчик
resizer.addEventListener('mousedown', mouseDownHandler);
Если вернуться к разметке, обнаружим, что левая и правая часть представления являются предыдущим и следующим «соседями» ручки перетаскивания. Следовательно, выбираем их вот таким образом:
const leftSide = resizer.previousElementSibling;
const rightSide = resizer.nextElementSibling;
Далее. Когда пользователь двигает мышью, мы определяем — как далеко — и, соответственно, обновляем ширину левой части представления.
const mouseMoveHandler = (e) => {
// Как далеко сместилась мышь
const dx = e.clientX - x;
const dy = e.clientY - y;
const newLeftWidth =
((leftWidth + dx) * 100) /
resizer.parentNode.getBoundingClientRect().width;
leftSide.style.width = `${newLeftWidth}%`;
};
Меняя ширину левой части представления, устанавливаем новое значение в процентах (от родительского элемента). Устанавливаем для правой части CSS-свойство flex: 1 1 0
и она будет занимать всё оставшееся место — не нужно рассчитывать ширину в JS.
.right { flex: 1 1 0;}
Исправляем мигание курсора
Когда пользователь перетаскивает ручку, мы должны поменять курсор.
Но если мы изменим его только у ручки, то обнаружим, что он при перетягивании постоянно меняется: то нужный, то обычная стрелка. Это происходит потому, что под мышь попадают и другие элементы, а не только resizer
.
Чтобы исправить «баг», следует на момент перетягивания поменять курсор не только для ручки, но и для всего документа.
const mouseMoveHandler = (e) => {
// ...
resizer.style.cursor = 'col-resize';
document.body.style.cursor = 'col-resize';
};
Также на момент перетягивания нужно предотвратить срабатывание событий и выделение текста в обоих частях split-представления. Для этого используем CSS-свойства user-select
и pointer-events
.
const mouseMoveHandler = (e) => {
// ...
leftSide.style.userSelect = 'none';
leftSide.style.pointerEvents = 'none';
rightSide.style.userSelect = 'none';
rightSide.style.pointerEvents = 'none';
};
Стили будут удалены сразу после окончания перетягивания.
const mouseUpHandler = () => {
resizer.style.removeProperty('cursor');
document.body.style.removeProperty('cursor');
leftSide.style.removeProperty('user-select');
leftSide.style.removeProperty('pointer-events');
rightSide.style.removeProperty('user-select');
rightSide.style.removeProperty('pointer-events');
// Удаляем обработчики
document.removeEventListener(
'mousemove', mouseMoveHandler
);
document.removeEventListener(
'mouseup', mouseUpHandler
);
};
Перетягивание по вертикали
Вместо того, чтобы обновлять значение ширины левой части представления, будем обновлять высоту верхней части.
const prevSibling = resizer.previousElementSibling;
let prevSiblingHeight = 0;
const mouseDownHandler = function (e) {
const rect = prevSibling.getBoundingClientRect();
prevSiblingHeight = rect.height;
};
const mouseMoveHandler = function (e) {
const h =
((prevSiblingHeight + dy) * 100) /
resizer.parentNode.getBoundingClientRect().height;
prevSibling.style.height = `${h}%`;
};
Также поменяем курсор на row-resize
.
const mouseMoveHandler = (e) => {
//...
resizer.style.cursor = 'row-resize';
document.body.style.cursor = 'row-resize';
};
Вложенные split-представления
Допустим, что нам нужно добавить в правую часть горизонтального split-представления вертикальное — как в VSCode, если выбрать View ➜ Editor Layout ➜ Two Rows.
У нас будет две ручки перетягивания. Чтобы выбирать одну из них, добавим data
-атрибуты.
<style>
.right {
display: flex;
flex: 1 1 0;
flex-direction: column;
}
</style>
<div style="display: flex">
<div class="left">Left</div>
<div class="resizer" data-direction="horizontal"></div>
<div class="right">
<div>Top</div>
<div class="resizer" data-direction="vertical"></div>
<div style="flex: 1 1 0%">Bottom</div>
</div>
</div>
Теперь мы можем получать направление перетягивания.
const direction =
resizer.getAttribute('data-direction') ||
'horizontal';
The logic of setting the width or height of previous sibling depends on the direction:
const mouseMoveHandler = (e) => {
switch (direction) {
case 'vertical':
const h =
((prevSiblingHeight + dy) * 100) /
resizer.parentNode.getBoundingClientRect().height;
prevSibling.style.height = `${h}%`;
break;
case 'horizontal':
default:
const w =
((prevSiblingWidth + dx) * 100) /
resizer.parentNode.getBoundingClientRect().width;
prevSibling.style.width = `${w}%`;
break;
}
const cursor = direction === 'horizontal'
? 'col-resize'
: 'row-resize';
resizer.style.cursor = cursor;
document.body.style.cursor = cursor;
// ...
};
См. демо на HTML DOM.
«Заглушка» (placeholder) для редактируемого элемента, не `input`'а
Редактируемый элемент не обязательно должен быть полем ввода. Это может быть div[contenteditable]
, а атрибута placeholder
у div
‘ов нет.
1. Делаем с помощью data
-атрибута и псевдокласса :empty
<div
class="is-editable"
id="edit-me"
contenteditable
data-placeholder="Edit me"
></div>
Значение data-placeholder
показываем, если элемент пустой.
.is-editable:empty:before {
content: attr(data-placeholder);
}
2. С помощью обработчиков событий
Тот же div
с id
и data
-атрибутом. Делаем placeholder
по значению data
-атрибута. На фокусе очищаем содержимое элемента, если оно совпадает с текстом placeholder
‘а. На blur
‘е, если пользователь ничего не ввёл, возвращаем placeholder
.
const el = document.getElementById('edit-me');
const placeholder = el.getAttribute('data-placeholder');
/* Если элемент пустой, заполняем его строкой
из `data`-атрибута */
el.innerHTML === '' && (el.innerHTML = placeholder);
el.addEventListener('focus', (e) => {
const value = e.target.innerHTML;
/* Альтернатива выражению `if`: если `value`
равно `placeholder`, тогда очищаем элемент */
value === placeholder &&
(e.target.innerHTML = '');
});
el.addEventListener('blur', (e) => {
const value = e.target.innerHTML;
value === '' &&
(e.target.innerHTML = placeholder);
});
Скопировать пример кода со страницы
Задача. Предоставить пользователю скопировать в буфер обмена пример кода по нажатию кнопки.
<pre id="sample-code"><code>...</code></pre>
<button id="copy-button">Copy</button>
Копирование выполняется в три этапа:
- Выделяем содержимое тега
<code>
. - Копируем в буфер, используя метод
document.execCommand('copy')
☝️🧐 МетодexecCommand()
официально считается устаревшим, но альтернативы пока нет. Если нужны команды для работы с контентом в режиме редактирования документа —document.designMode
— пока приходится пользоватьсяexecCommand()
*/ - Предыдущие шаги связаны с изменением пользовательского выделения. Поэтому исходное выделение надо сохранить и восстановить после копирования.
(() => {
const copyButton =
document.getElementById('copy-button');
const codeEle =
document.getElementById('sample-code');
copyButton.addEventListener('click', () => {
const selection = window.getSelection();
// Сохраняем выделение
const currentRange =
selection.rangeCount === 0
? null
: selection.getRangeAt(0);
// Выделяем содержимое между `<code>`
const range = document.createRange();
range.selectNodeContents(codeEle);
selection.removeAllRanges();
selection.addRange(range);
// Копируем в буфер
try {
document.execCommand('copy');
copyButton.innerHTML = 'Copied';
} catch (err) {
// Невозможно скопировать
copyButton.innerHTML = 'Copy';
} finally {
// Восстанавливаем выделение
selection.removeAllRanges();
currentRange &&
selection.addRange(currentRange);
}
});
})();
См. также шпаргалку и материалы для углубленного изучения.