Разблокирование доступа к буферу обмена

Более безопасный и незаблокированный доступ к буферу обмена для текста и изображений

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

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

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

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

writeText()

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

async function copyPageUrl() {
  try {
    await navigator.clipboard.writeText(location.href);
    console.log('Page URL copied to clipboard');
  } catch (err) {
    console.error('Failed to copy: ', err);
  }
}

Browser Support

  • Хром: 66.
  • Край: 79.
  • Firefox: 63.
  • Сафари: 13.1.

Source

писать()

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

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

Запрос изображения с сервера может быть нежелателен или невозможен по ряду причин. К счастью, вы также можете нарисовать изображение на холсте и вызвать метод toBlob() холста.

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

try {
  const imgURL = '/images/generic/file.png';
  const data = await fetch(imgURL);
  const blob = await data.blob();
  await navigator.clipboard.write([
    new ClipboardItem({
      // The key is determined dynamically based on the blob's type.
      [blob.type]: blob
    })
  ]);
  console.log('Image copied.');
} catch (err) {
  console.error(err.name, err.message);
}

В качестве альтернативы вы можете написать обещание объекту ClipboardItem . Для этого шаблона необходимо заранее знать MIME-тип данных.

try {
  const imgURL = '/images/generic/file.png';
  await navigator.clipboard.write([
    new ClipboardItem({
      // Set the key beforehand and write a promise as the value.
      'image/png': fetch(imgURL).then(response => response.blob()),
    })
  ]);
  console.log('Image copied.');
} catch (err) {
  console.error(err.name, err.message);
}

Browser Support

  • Хром: 76.
  • Край: 79.
  • Firefox: 127.
  • Сафари: 13.1.

Source

Копирование события

В случае, если пользователь инициирует копирование в буфер обмена и не вызывает preventDefault() , событие copy включает свойство clipboardData с элементами, уже имеющими правильный формат. Если вы хотите реализовать собственную логику, необходимо вызвать метод preventDefault() , чтобы предотвратить поведение по умолчанию в пользу вашей реализации. В этом случае clipboardData будет пустым. Рассмотрим страницу с текстом и изображением: когда пользователь выделяет всё и инициирует копирование в буфер обмена, ваше пользовательское решение должно отбрасывать текст и копировать только изображение. Этого можно добиться, как показано в примере кода ниже. В этом примере не рассматривается, как вернуться к более ранним API, когда API буфера обмена не поддерживается.

<!-- The image we want on the clipboard. -->
<img src="kitten.webp" alt="Cute kitten.">
<!-- Some text we're not interested in. -->
<p>Lorem ipsum</p>
document.addEventListener("copy", async (e) => {
  // Prevent the default behavior.
  e.preventDefault();
  try {
    // Prepare an array for the clipboard items.
    let clipboardItems = [];
    // Assume `blob` is the blob representation of `kitten.webp`.
    clipboardItems.push(
      new ClipboardItem({
        [blob.type]: blob,
      })
    );
    await navigator.clipboard.write(clipboardItems);
    console.log("Image copied, text ignored.");
  } catch (err) {
    console.error(err.name, err.message);
  }
});

Для события copy :

Browser Support

  • Хром: 1.
  • Край: 12.
  • Firefox: 22.
  • Сафари: 3.

Source

Для ClipboardItem :

Browser Support

  • Хром: 76.
  • Край: 79.
  • Firefox: 127.
  • Сафари: 13.1.

Source

Вставить: чтение данных из буфера обмена

readText()

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

async function getClipboardContents() {
  try {
    const text = await navigator.clipboard.readText();
    console.log('Pasted content: ', text);
  } catch (err) {
    console.error('Failed to read clipboard contents: ', err);
  }
}

Browser Support

  • Хром: 66.
  • Край: 79.
  • Firefox: 125.
  • Сафари: 13.1.

Source

читать()

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

Каждый ClipboardItem может содержать содержимое разных типов, поэтому вам потребуется перебрать список типов, снова используя цикл for...of . Для каждого типа вызовите метод 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);
  }
}

Browser Support

  • Хром: 76.
  • Край: 79.
  • Firefox: 127.
  • Сафари: 13.1.

Source

Работа со вставленными файлами

Пользователям полезно иметь возможность использовать сочетания клавиш для работы с буфером обмена, такие как ctrl + c и ctrl + v . Chromium открывает файлы в буфере обмена только для чтения, как описано ниже. Это происходит, когда пользователь нажимает сочетание клавиш для вставки по умолчанию в операционной системе или нажимает «Изменить», а затем «Вставить» в строке меню браузера. Дополнительный код не требуется.

document.addEventListener("paste", async e => {
  e.preventDefault();
  if (!e.clipboardData.files.length) {
    return;
  }
  const file = e.clipboardData.files[0];
  // Read the file's contents, assuming it's a text file.
  // There is no way to write back to it.
  console.log(await file.text());
});

Browser Support

  • Хром: 3.
  • Край: 12.
  • Firefox: 3.6.
  • Сафари: 4.

Source

Событие вставки

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

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  const text = await navigator.clipboard.readText();
  console.log('Pasted text: ', text);
});

Browser Support

  • Хром: 1.
  • Край: 12.
  • Firefox: 22.
  • Сафари: 3.

Source

Обработка нескольких типов MIME

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

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

async function copy() {
  const image = await fetch('kitten.png').then(response => response.blob());
  const text = new Blob(['Cute sleeping kitten'], {type: 'text/plain'});
  const item = new ClipboardItem({
    'text/plain': text,
    'image/png': image
  });
  await navigator.clipboard.write([item]);
}

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

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

Браузер запрашивает у пользователя разрешение на доступ к буферу обмена.
Запрос на разрешение для API буфера обмена.

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

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

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

const queryOpts = { name: 'clipboard-read', allowWithoutGesture: false };
const permissionStatus = await navigator.permissions.query(queryOpts);
// Will be 'granted', 'denied' or 'prompt':
console.log(permissionStatus.state);

// Listen for changes to the permission state
permissionStatus.onchange = () => {
  console.log(permissionStatus.state);
};

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

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

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

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

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

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

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

Обнаружение особенностей

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

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  let text;
  if (navigator.clipboard) {
    text = await navigator.clipboard.readText();
  }
  else {
    text = e.clipboardData.getData('text/plain');
  }
  console.log('Got pasted text: ', text);
});

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

button.addEventListener('click', (e) => {
  const input = document.createElement('input');
  input.style.display = 'none';
  document.body.appendChild(input);
  input.value = text;
  input.focus();
  input.select();
  const result = document.execCommand('copy');
  if (result === 'unsuccessful') {
    console.error('Failed to copy text.');
  }
  input.remove();
});

Демо-версии

Вы можете поэкспериментировать с API асинхронного буфера обмена в демонстрационных примерах ниже. Первый пример демонстрирует перемещение текста в буфер обмена и из него.

Чтобы опробовать API с изображениями, воспользуйтесь этой демоверсией. Обратите внимание, что поддерживаются только PNG-файлы и только в некоторых браузерах .

Благодарности

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

Главное изображение от Маркуса Винклера на Unsplash .