Асинхронный доступ к буферу обмена

Последние несколько лет для взаимодействия с буфером обмена в браузере принято использовать document.execCommand(). Несмотря на широкую поддержку, этот метод копипасты имеет ограничения: доступ к буферу обмена синхронный и можно только читать и записывать то, что связано с DOM.

Это нормально для небольших фрагментов текста, но в более тяжелых случаях синхронная блокировка страницы для передачи из буфера обмена неэффективна. Прежде чем содержимое будет готово для безопасной вставки, может потребоваться его длительная очистка или декодирование изображения. Браузеру может потребоваться загрузить или встроить связанные со вставленным документом ресурсы. Это заблокирует страницу во время ожидания окончания работ с диском или сетью. Сюда ещё следует добавить разрешения, требуемые, браузеру для блокировки страницы при запросе доступа к буферу обмена. Эти разрешения, установленные методу document.execCommand() для взаимодействия с буфером обмена, слабо определены и различаются в зависимости от браузера.

API асинхронного буфера обмена (Async Clipboard API) решает эти проблемы, предоставляя четко определенную модель разрешений, которая не блокирует страницу. Safari объявил о его поддержке в версии 13.1, а другие основные браузеры поддерживают его на базовом уровне. На лето 2020 Firefox поддерживает только текст. В некоторых браузерах поддержка изображений ограничена PNG. В-общем, пока рано говорить о полной поддержке этого API браузерами и стоит обращаться к таблице совместимости за уточнениями.

Копирование: запись данных в буфер обмена

writeText()

Для копирования текста в буфер обмена следует использовать метод writeText(). Поскольку этот API является асинхронным, функция writeText() возвращает обещание (Promise), которое разрешает или отклоняет в зависимости от того, успешно ли скопирован переданный текст:

async function copyPageUrl() {
  try {
    await navigator.clipboard.writeText(location.href);
    console.log('URL страницы скопирован в буфер обмена');
  } catch (err) {
    console.error('Ошибка копирования в буфер: ', err);
  }
}

write()

На самом деле writeText() – это всего лишь удобный метод для общего метода write(), который позволяет копировать ещё и изображения в буфер обмена. Аналогично методу writeText(), он асинхронный и возвращает Promise.

Чтобы записать изображение в буфер обмена, вам нужен blob этого изображения. Один из способов сделать это – запросить изображение с сервера с помощью метода fetch(), а затем для полученного ответа вызвать blob().

По ряду причин запрос изображения с сервера может быть нежелательным или невозможным. Поэтому ещё один вариант – использовать canvas для отрисовки изображения и вызвать его метод toBlob().

После извлечения изображения, массив объектов ClipboardItem можно передавать в качестве параметра методу write(). В настоящее время за раз можно передавать только одно изображение, но в будущем наверное будет добавлена поддержка передачи нескольких изображений разом.ClipboardItem принимает объект с MIME-типом изображения в качестве ключа и большого двоичного объекта в качестве значения. Blob-объекты, полученные из fetch() или canvas.toBlob(), возвращают правильный MIME-тип для изображения в свойстве blob.type.

try {
  const imgURL = '/images/generic/file.png';
  const data = await fetch(imgURL);
  const blob = await data.blob();
  await navigator.clipboard.write([
    new ClipboardItem({
      [blob.type]: blob
    })
  ]);
  console.log('Картинка скопирована.');
} catch (err) {
  console.error(err.name, err.message);
}

Событие copy (копирования)

Когда пользователь инициирует копирование в буфер обмена, все данные, кроме текстовых, предоставляются как Blob. Событие copy включает свойство clipboardData с элементами уже в правильном формате и это избавляет от необходимости создавать Blob вручную. Можно вызвать preventDefault(), чтобы предотвратить поведение по умолчанию в пользу собственной логики, а затем скопировать содержимое в буфер обмена. В этом примере не рассматривается, как откатиться к более ранним API, если Clipboard API не поддерживается, но чуть ниже и с этим разберемся.

document.addEventListener('copy', async (e) => {
    e.preventDefault();
    try {
      let clipboardItems = [];
      for (const item of e.clipboardData.items) {
        if (!item.type.startsWith('image/')) {
          continue;
        }
        clipboardItems.push(
          new ClipboardItem({
            [item.type]: item,
          })
        );
        await navigator.clipboard.write(clipboardItems);
        console.log('Изображение скопировано.');
      }
    } catch (err) {
      console.error(err.name, err.message);
    }
  });

Paste: чтение данных из буфера обмена

readText()

Чтобы прочитать текст из буфера обмена, следует вызвать navigator.clipboard.readText() и дождаться разрешения Promise, которое он возвращает:

async function getClipboardContents() {
  try {
    const text = await navigator.clipboard.readText();
    console.log('Вставляемое содержимое: ', text);
  } catch (err) {
    console.error('Ошибка чтения из буфера обмена: ', err);
  }
}

read()

Метод navigator.clipboard.read() тоже асинхронный и возвращает Promise. Чтобы прочитать изображение из буфера обмена, надо получить список объектов ClipboardItem, а затем пробежать по ним.

Каждый ClipboardItem может содержать разные типы контента, поэтому внутри нужно будет перебирать список типов, используя цикл for … of. Чтобы получить соответствующий Blob, для каждого типа нужно вызывать метод getType() с текущим типом в качестве аргумента. Как и раньше, этот пример кода не привязан конкретно к изображениям и может работать с другими типами файлов.

async function getClipboardContents() {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      for (const type of clipboardItem.types) {
        const blob = await clipboardItem.getType(type);
        console.log(URL.createObjectURL(blob));
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
}

Событие paste (вставки)

Пока только планируется ввести события для работы с Clipboard API, можно использовать существующее событие paste. Он прекрасно работает с новыми асинхронными методами чтения текста из буфера обмена. Как и в случае с событием copy, можно вызвать preventDefault().

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  const text = await navigator.clipboard.readText();
  console.log('Вставляемый текст: ', text);
});

Аналогично copy, откат к более ранним API, когда API буфера обмена не поддерживается, будет рассмотрен ниже.

Работа с несколькими типами файлов

Большинство реализаций помещают в буфер обмена несколько форматов данных для одной операции копипасты. Для этого есть две причины: как разработчик приложения не можете знать возможности приложения, в которое пользователь будет копировать текст или изображения, вторая – многие приложения поддерживают вставку структурированных данных в виде простого текста. Для пользователям это реализуется обычно с помощью пунктов меню типа «Вставить и сопоставить стиль» (Paste and match style) или «Вставить без форматирования» (Paste without formatting).

В следующем примере показано, как это сделать. Здесь для получения данных изображения используется функция fetch(), но аналогично можно использовать canvas или API доступа к файловой системе (File System Access API.).

function copy() {
  const image = await fetch('kitten.png');
  const text = new Blob(['Спящая кошечка'], {type: 'text/plain'});
  const item = new ClipboardItem({
    'text/plain': text,
    'image/png': image
  });
  await navigator.clipboard.write([item]);
}

Безопасность и разрешения

Доступ к буферу обмена всегда доставлял для браузеров проблемы с безопасностью. Без надлежащих разрешений страница могла бы незаметно копировать всевозможный вредоносный контент в буфер обмена пользователя и это приводило бы к катастрофическим результатам при вставке.

Запрос разрешения для Clipboard API
Запрос разрешения для Clipboard API

Предоставление веб-страницам неограниченного доступа для чтения к буферу обмена еще более проблематично. Пользователи могут копировать конфиденциальную информацию: пароли и личные данные, в буфер обмена, который затем может быть прочитан любой страницей без их ведома.

Как и многие другие новые API, Clipboard API поддерживается только для страниц, возвращаемых через HTTPS. Чтобы предотвратить злоупотребления, доступ к буферу обмена разрешен только тогда, когда страница является активной вкладкой. Страницы на активных вкладках могут записывать в буфер обмена без разрешения, но для чтения из буфера обмена разрешение всегда требуется.

Разрешения на копирование и вставку были добавлены в Permissions API. Разрешение на запись в буфер обмена clipboard-write автоматически предоставляется странице, когда вкладка с ней активна. Разрешение на чтение из буфера обмена clipboard-read должно быть запрошено, для этого можно попытаться прочитать данные из буфера обмена. Код ниже демонстрирует это:

const queryOpts = { name: 'clipboard-read', allowWithoutGesture: false };
const permissionStatus = await navigator.permissions.query(queryOpts);
// Будет 'granted', 'denied' или 'prompt':
console.log(permissionStatus.state);

// Следим за изменениями в состоянии разрешения
permissionStatus.onchange = () => {
  console.log(permissionStatus.state);
};

Ещё, используя параметр allowWithoutGesture можно указать, требуется ли жест пользователя для вызова вырезания или вставки. Значение по умолчанию для этого параметра зависит от браузера, поэтому всегда нужно указывать желаемое.

Здесь асинхронный характер Clipboard API действительно пригодится: при попытке чтения или записи данных буфера обмена у пользователя автоматически запрашивается разрешение, если оно еще не было предоставлено. Поскольку API основан на обещаниях (Promise), всё происходит естественным путём, и пользователь, отказывающий в разрешении буфера обмена, вызывает отклонение обещания, чтобы страница могла ответить соответствующим образом.

Поскольку Chrome разрешает доступ к буферу обмена только тогда, когда страница является активной вкладкой, вы можете обнаружить, что некоторые из приведенных здесь примеров не работают, если вставлены непосредственно в DevTools, поскольку само окно DevTools является активной вкладкой. Если очень надо, то есть уловка: отложить доступ к буферу обмена с помощью setTimeout(), а затем быстро щелкнуть внутри страницы, чтобы сфокусировать её перед вызовом функций:

setTimeout(async () => {
  const text = await navigator.clipboard.readText();
  console.log(text);
}, 2000);

Интеграция политики разрешений

Чтобы использовать API в iframe, необходимо включить его с помощью Permissions Policy, которая определяет механизм, позволяющий выборочно включать и отключать различные функции браузера и API. Т.е. нужно передавать clipboard-read (чтение из буфера обмена) или clipboard-write (запись в буфер обмена) или оба варианта, в зависимости от потребностей приложения.

<iframe
    src="index.html"
    allow="clipboard-read; clipboard-write"
></iframe>

Feature detection

Чтобы обеспечить поддержку всех браузеров для асинхронного Clipboard API, надо проверить navigator.clipboard и, если не работает, использовать прежние методы. Например, вот как можно реализовать такую вставку:

document.addEventListener('paste', async function(e) {
  e.preventDefault();
  let text;
  if (navigator.clipboard) {
    text = await navigator.clipboard.readText();
  }
  else {
    text = e.clipboardData.getData('text/plain');
  }
  console.log('Вставляемый текст: ', text);
});

Это еще не все. До появления асинхронного Clipboard API в веб-браузерах использовались разные реализации копирования и вставки. В большинстве браузеров собственное копирование и вставка браузера может быть запущено с помощью document.execCommand('copy') и document.execCommand('paste'). Если копируемый текст представляет собой строку, отсутствующую в DOM, она должна быть вставлена ​​в DOM и выбрана:

button.addEventListener('click', function(e) {
  const input = document.createElement('input');
  document.body.appendChild(input);
  input.value = text;
  input.focus();
  input.select();
  const result = document.execCommand('copy');
  if (result === 'unsuccessful') {
    console.error('Не получилось скопировать текст.');
  }
});

В Internet Explorer доступ к буферу обмена можно получить через window.clipboardData. Если нужен доступ в рамках действия пользователя, например событие click, как часть ответственного запроса разрешения, то запрос разрешений не отображается.

Пример

See this code Async Clipboard API Image Demo on x.xhtml.ru.

Пример работает в Chrome 86. В остальных браузерах может не работать, т.к. в примере не реализован fallback.