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();
}

Источник. См. также: список ошибок

Пользовательское контекстное меню на ПКМ
<div class="is-relative">
  <div id="element">
    Кликни правой кнопкой мыши (ПКМ)
  </div>
  <ul class="menu is-context" role="menu" id="menu">
    ...
  </ul>
</div>

Предотвращаем вызов стандартного контекстного меню

const el = document.getElementById('element');
el.addEventListener('contextmenu', (e) => {
  e.preventDefault();
});

Показываем пользовательское меню в области клика

Чтобы получить координаты меню, оно должно быть абсолютно позиционировано относительно своего контейнера. Поэтому в разметке добавлена «обёртка» .is-relative.

.is-relative {
  position: relative;
}

.menu.is-context {
  // Скрыто по умолчанию
  display: none;
  position: absolute;

  &.is-on {
    display: block;
  }
}

Рассчитываем позицию меню по позиции мышки.

el.addEventListener('contextmenu', (e) => {
  const rect = el.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;

  // Задаем координаты
  menu.style.top = `${y}px`;
  menu.style.left = `${x}px`;

  // Показываем меню
  menu.classList.add('is-on');
});

Закрываем меню по клику за его пределами

el.addEventListener('contextmenu', (e) => {
  /* ...
  Добавляем вложенный слушатель */
  document.addEventListener('click', docClickHandler);
});

/** Прячем меню на клике за его пределами */
const docClickHandler = (e) => {
  const isClickedOutside = !menu.contains(e.target);
  if (isClickedOutside) {
    menu.classList.remove('is-on');

    // Удаляем обработчик
    document.removeEventListener(
      'click', docClickHandler
    );
  }
};

Обработчик клика удаляется со страницы вместе с меню, так как он не нужен, пока меню скрыто.

Полный экран

Для выхода в полноэкранный режим используется 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-компонента — счётчик
  1. Начнём с создания состояния — объекта в глобальной области видимости (на практике, область видимости ограничивается). Единственным свойством объекта сделаем счетчик, с нулевым исходным значением.
const state = {
  count: 0,
};
  1. Создадим компонент — простую стрелочную функцию возвращающую шаблонный литерал.
const counter = (count) => {
  return  `<div class="counter">${count}</div>`;
}
  1. Создадим функцию рендеринга HTML, которая будет обновлять интерфейс после каждого обновления счетчика пользователем.
function renderCount() {
  document.getElementById('app')
    /* Передаём в контейнер разметку
    из функцию компонента */
    .innerHTML = counter(state.count);
}
  1. Сделаем метод обновления счётчика. Встроенного метода SetState в JavaScript нет, поэтому будем обновлять напрямую (в React это категорически не рекомендуется):
function incCountUp() {
  const newCount = state.count + 1;
  state.count = newCount;
}
  1. Установим слушатель на кнопку. Всякий раз, когда пользователь будет на неё кликать, будет обновляться значение 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();
});

Подробнее о stateful-компонентах

Анимация

Анимация высоты: от 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');

Расчёты

Перетаскивание / перетягивание

Перетаскиваемый элемент

Разметка:

<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);
    }
  });
})();

См. также шпаргалку и материалы для углубленного изучения.