關於 Clipboard API 主流瀏覽器支援程度分析

這篇文章在討論最新的技術,部分內容將隨時間淘汰而不再適用,請留意文章的最後更新時間。

前言

幾天前白花殿加入了一款剪貼簿小工具,其中應用到了近年才稍微完整,但又不夠普及的瀏覽器剪貼簿 API。今天要探討的是 W3C Clipboard API and events最新一期 (2020-08-18) 各家瀏覽器的實作狀況 ٩(。・ω・。)و

關於 Clipboard API

W3C 最初起草第一版標準是在 2006 年,當時只提出簡要目的、操作、及可能的事件對象,一路緩慢地修改,直到 2017 年才趨於穩定。(依然是 Working Draft)

在這之後,透過 JavaScript 從瀏覽器「讀寫多媒體內容」的流程,才有一套能遵循的標準。以往只能依賴終端系統的選取複製、貼上和拖拉操作進行豐富剪貼簿互動,現在則能透過腳本來實現。

這些豐富剪貼簿互動原生就具備於現今主流行動裝置及個人電腦系統中,只是彼此間形式與格式並不相同,因此在訂定標準上得將其納入考量,這是瀏覽器實現跨平台過程的一項重要指標。

Can I use Clipboard API: read (2020-10-12)
不難看出還有好長一段路。

文章方向

我會簡單解釋 Clipboard API根據標準該如何使用,並透過實際應用來測試主流瀏覽器對 Asynchronous Clipboard API 的實作程度,再來會補充能透過哪些 hacky way 來實現最高限度相容的代碼,最後是懶人包。篇幅內不會解釋基礎語法與概念,有任何疑問歡迎來本殿發問 ٩(。・ω・。)و

實測對象 (測試日期:2020-09-30)
  1. Chrome Canary 版本 87
  2. Chrome 版本 86 (2020-10-16 追加修復)
  3. Chrome 版本 85
  4. Firefox Developer Edition 版本 81
  5. Safari 版本 13.1 (build 605)
測試目標
  1. 測試主流瀏覽器根據 WD-clipboard-apis-20200818關於 Clipboard介面的實作完成度:
    interface Clipboard : EventTarget {
      Promise<Iterable<ClipboardItem>> read();
      Promise<DOMString> readText();
      Promise<undefined> write(Iterable<ClipboardItem> data);
      Promise<undefined> readText(DOMString data);
    };
  2. 如果支援,測試實作完整度與差異。
  3. 如果不支援,能用什麼方式檢測與替代。

Clipboard API 簡介

根據 WD-clipboard-apis-20200818Clipboard API將只暴露在具備 SecureContext(https or localhost) 的環境,路徑為 window.navigator.clipboard,並提供四個主要介面:

clipboard.read()
讀取剪貼簿複合內容。無參數,返回 Promise<Array<ClipboardItem>>
clipboard.readText()
讀取剪貼簿純文字內容。無參數,返回 Promise<string>
clipboard.write(data)
對剪貼簿寫入複合內容。參數為一組 Array<ClipboardItem>,返回 Promise<void>
clipboard.writeText(data)
對剪貼簿寫入純文字。參數為一組字串,返回 Promise<void>

透過新介面,開發者能對剪貼簿讀寫多媒體內容,也就是這篇文章的實測重點。

重要!

以上四個方法在網站初次呼叫時會發生

  1. 詢問使用者是否授權使用剪貼簿 (webkit),或者
  2. 需要先透過 Permission API 請求權限 (moz)

,若使用者拒絕,所有方法將拋出 NotAllowedError,記得捕捉例外事件。

以下範例程式碼將忽略例外處理。

Chrome 會主動向使用者詢問權限 (2020-10-14)

重構「讀寫純文字」

先從簡單的開始,以往從剪貼簿中讀取、寫入純文字會需要 mock hidden input,並透過 fake event 或 execCommand 來觸發瀏覽器的複製與貼上行為。

自從有了 Clipboard API,往後只需簡單地呼叫 read()write() 即可讀寫剪貼簿:

從剪貼簿讀取純文字
const clipboardText = await navigator.clipboard.readText()
對剪貼簿寫入純文字
const text = 'Hello, world!'
await navigator.clipboard.writeText(text)

實作「讀寫多媒體」

能夠讀寫純文字早已行之有年,看點是在對多媒體的讀寫。

為了讀寫多媒體,需要先對 MIME typeBlob有基本認知。

再次提醒!這是按照 W3C 標準實作的範例,不是所有瀏覽器都按照標準或已完成實作!

讀取純文字 (text/plain)

既然能後讀寫多媒體,純文字自然不在話下;首先是用另一組介面讀取純文字的例子:

const clipboardItems = await navigator.clipboard.read()
for (const clipboardItem of clipboardItems) {
  if (clipboardItem.types.indexOf('text/plain') < 0) {
    continue
  }

  const textBlob = await clipboardItem.getType('text/plain')
  const text = await (new Response(blob)).text()

  // result = text
}
寫入純文字 (text/plain)
const text = 'Hello, world!'
const textBlob = new Blob([text], { type: 'text/plain' })
const clipboardItem = new ClipboardItem({
  'text/plain': textBlob
})
await navigator.clipboard.write([clipboardItem])

讀寫 XML 格式 text/xmltext/html 的作法與 text/plain 一模一樣,只要把 text/plain 替換為 text/html 即可。對 clipboardItem.types 做 case mapping 會是不錯的方法。

再來是 Binary 該如何讀寫,差別只在 Blob 的處理方式不同。

接下來的範例是透過 FileReaderbase64 編碼來讀取 Blob。這只是其中一種方式:

讀取 Binary 並轉為 base64 (image/png)
const clipboardItems = await navigator.clipboard.read()

for (const clipboardItem of clipboardItems) {
  if (clipboardItem.types.indexOf('image/png') < 0) {
    continue
  }

  const imageBlob = await clipboardItem.getType('image/png')
  const imageBase64Text = await new Promise((resolve) => {
    const fileReader = new FileReader()
    fileReader.onLoad = () => {
      // 格式為 "data:[<mediatype>];base64,<data>"
      const dataUri = fileReader.result
      const [, base64Text] = dataUri.split(',')
      resolve(base64Text)
    }
    fileReader.readAsDataURL(imageBlob)
  })

  // result = imageBase64Text
}
寫入 Binary 從 base64 (image/png)

這邊使用前一個例子讀入的 base64 字串當作輸入:

const imageBase64Text = 'iVBORw0KGgoAAAANSUhEUgAAAQ...'
const chars = atob(imageBase64Text)
const buffer = new ArrayBuffer(chars.length)

const bytes = new Uint8Array(buffer)
for (let i = chars.length; i >= 0; --i) {
  bytes[i] = chars.charCodeAt(i)
}

const imageBlob = new Blob([buffer], { type: 'image/png' })
const clipboardItem = new ClipboardItem({
  'image/png': imageBlob
})

await navigator.clipboard.write([clipboardItem])

介紹完 Clipboard API 的基本用法,接下來是文章主軸:「到底哪些瀏覽器能使用」。


上機實作

複習測試內容:本次的測試日期是 (2020-09-30),測試對象有 Chrome、Firefox 和 Safari,測試項目為 Clipboard API的四個方法能不能正常使用。

測試過程是開發剪貼簿小工具的過程中,一路上遇到的磕磕碰碰。雖然功能看起來陽春又單純,但該用上的都確實用上了,應該足夠經得起檢驗。

Chrome Canary 版本 87

Chrome Canary 果然不讓人失望;會長起初就以 Canary 為主進行開發才會覺得沒有瓶頸,過程異常順利。後來遇到的所有問題都沒有在它身上發生,依照 W3C 的標準和範例來開發,它就是能運作。

作為第一個測試對象,它是會長開發常規前端項目時的主要環境,自然有它與眾不同的優點。將來有機會再寫一篇會長玩遍各家瀏覽器,最後選擇它的原因吧 (ノ∀`)

Chrome 版本 86 (2020-10-19 追加)

會長在後記 (2020-10-14) 提到自己算是踩了個早坑,沒想到就只早了那麼一點點…

兩天後 (2020-10-16) 收到了 v86 正式版推送,下個段落提到的 v85 的問題已經完成修復。

只是目前 v86 的使用率還很低,遷移期可能還要三、四週,前一個版本的解決方案也還是應該作為 fall through 保留著。

Chrome 版本 85

開發用的瀏覽器自然不會和日常使用併在一起;雖然比 Canary 少了兩個版號,但功能還算齊全,只有兩個小問題:一個可以處理、一個束手無策。

問題就在 text/html 的讀寫上。

讀取 text/html

讀取 text/html 現階段無法透過 Clipboard API實現,這點目前為止是束手無策。具體來說,Clipboard API 可以正常使用,但返回的 ClipboardItem裡面的 MIME type text/html 會被抹去、神隱、且無法偵測。看起來就像是剪貼簿只有純文字內容:

const clipboardItem = await navigator.clipboard.read()
// 總是成立
assert(clipboardItem.types.indexOf('text/html') < 0)

有一個變通的做法:透過 ClipboardEvent可以取得 DataTransfer形式的剪貼簿內容,裡面的內容和 ClipboardItem 大同小異,可以從裡面擷取 text/html 的內容。

但 ClipboardEvent 只能透過「使用者主動進行貼上行為」才能捕捉,並且我們無法偵測 ClipboardItem 是否含有消失的 text/html,所以無法提示使用者需要他們主動貼上,暫時束手無策。

以下是從 ClipboardEvent 讀取 text/html 的例子:

const onPaste = (event) => {
  // event instanceof ClipboardEvent
  const dataTransfer = event.clipboardData

  let htmlText = ''
  for (const item of dataTransfer.items) {
    // item instanceof DataTransferItem
    if (item.type !== 'text/html') {
      continue
    }

    htmlText = await new Promise(resolve => item.getAsString(resolve))
    break
  }

  // result = htmlText
}

// 這裡無關乎實作,只要考慮 onPaste 的實作即可。
const inputEl = document.createElement('input')
inputEl.placeholder = '在這邊貼上'
inputEl.addEventListener('paste', onPaste)
document.body.appendChild(inputEl)
寫入 text/html

對剪貼簿寫入 text/html 將拋出例外:

Chrome 85 寫入 'text/html' 時拋出 DOMException (2020-10-14)

這是一個可處理的問題,只是處理方式非常麻煩。

你需要對 Selection APIRange和快要被淘汰的 execCommand有基本認知。主要邏輯是透過 execCommand來複製頁面上的特定選取範圍至剪貼簿;實際上就是模擬使用者操作滑鼠或觸控選擇特定範圍,再模擬複製行為。

沒錯,有一個盲點——該元素「必須可見地呈現在頁面上」。這可以透過 JavaScript 快速繪製再移除來迴避,只是要記得請求 nextTick,也就是 requestAnimationFrame

範例:

const containerEl = document.createElement('div')

const onClick = (event) => {
  const selection = window.getSelection()
  const range = document.createRange()

  // 建立選擇範圍
  range.selectNodeContents(containerEl)
  selection.removeAllRanges()
  selection.addRange(range)

  // 模擬複製
  document.execCommand('copy')

  // 移除選擇範圍
  // 由於範例綁在 click 事件上,所以不還原原始的選擇範圍。
  selection.removeRange(range)
}

// 透過腳本繪製內容,實際上只要考慮 onClick 的實作即可。
containerEl.innerHTML = '<span style="background: black; color: white">Hello, world!</span>'
containerEl.addEventListener('click', onClick)
document.appendChild(containerEl)

現在我們知道了兩種讀取、兩種寫入剪貼簿的方式,這些方法最後得要交替著使用,作為發現功能缺失時的替代方案。

Firefox Developer Edition 版本 81

Firefox 的支援度不太理想,有些硬傷不像 Chrome 有方法可以補救,需要加強 (ノω・。)

首先是 Clipboard API,目前 Firefox 不提供 ClipboardItem,也只支援:

  • clipboard.read()
  • clipboard.readText()
  • clipboard.write()
  • clipboard.writeText()
Firefox 81 Clipboard API 介面缺失 (2020-10-14)

也就是說:

  1. 無法讀取剪貼簿 (但可以貼上)。
  2. 可以直接寫入純文字。
  3. 只能透過模擬複製來寫入 text/html。 (和 Chrome v85 相同)
  4. 無法寫入 Blob

補充

  1. 想讀取剪貼簿(純文字),目前只能透過擴充套件作為 worker 來讀取。
  2. Permissions API 尚未實作 clipboard-readclipboard-write,所以 MDN 的範例實際上是無法在 Firefox 運作的。
Safari 版本 13.1 (build 605)

蘋果總是和別人不一樣,只有在 Safari 應用程式中複製進剪貼簿的內容能夠被 Safari 透過 Clipboard API讀取;其餘則要求使用者顯性地與 UI 互動才能存取剪貼簿。

除此之外,從檔案系統中複製的圖片,貼上時檔名會被忽略。

雖然讀取剪貼簿有許多限制,但寫入就和 Chrome v87 一樣沒有其他限制,也不用像 Chrome v85 需要模擬複製行為。

整理一下:

  1. 讀取剪貼簿需要透過原生 UI 互動,否則只能讀取透過 Safari 寫入剪貼簿的內容。例如:滑鼠右鍵貼上、鍵盤快捷鍵貼上。
  2. 從檔案系統中複製的圖片檔名會被忽略。
  3. 透過非顯性互動來讀取剪貼簿時,整個頁面會被停住,直到下一個使用者互動。

總結

這邊用最常見的三種 MIME type來比較可用程度,可以當作循序漸進的指標:

text/plain
讀 / 寫
text/html
讀 / 寫
image/png
讀 / 寫
Chrome 87✓ / ✓✓ / ✓✓ / ✓
Chrome 86✓ / ✓✓ / ✓✓ / ✓
Chrome 85✓ / ✓▵ / ▵[1]✓ / ✓
Firefox 81▵ / ✓[2]▵ / ▵[3]▵ / ✕[4]
Safari 13.1[5]▵ / ✓▵ / ✓▵ / ✓
Clipboard API 可用度懶人包 (2020-09-30)

✓ 可用 / ▵ 需要特殊方法 / ✕ 完全不可用。

  1. 使用 clipboard.read() 返回值總是不含 text/html
    使用 clipboard.write() 包含 text/html 時,將拋出例外。

  2. 未實作 clipboard.readText()

  3. 未實作 clipboard.read()
    未實作 clipboard.write()

  4. 同上,但寫入 Blob 不能被模擬。

  5. 允許讀取剪貼簿的門檻較高。

後記

其實本來並不會有這篇文章,會長只是想簡單快樂的寫一個自己想要的剪貼簿小工具,來補足手邊開發工具的坑,沒想到做完功課進入開發時才發現,這其實是蠻新的功能,連 Clipboard API - Web APIs | MDN提供的文件都 Work in Progress,最後回頭讀 W3C 才搞懂該怎麼用。

搞懂怎麼用是一回事,跨瀏覽器能不能運作又是一回事,會長算是踩了個早坑,過幾個月再來看可能根本不會遇到這些問題…

反正有玩到、玩得開心就好啦 (ノ∀`)